Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2021849
Add a Sponsor button: GitHub Sponsors and Ko-fi
claude Oct 21, 2026
cb40ad0
Merge remote-tracking branch 'origin/main' into claude/repo-overview-…
claude Oct 21, 2026
7e55d75
Give both templates the Sponsor button, and tell their users to chang…
claude Oct 21, 2026
a7efcd2
Merge remote-tracking branch 'origin/main' into claude/repo-overview-…
claude Oct 21, 2026
87fecc9
Let the template mirror run: no pnpm cache in a job without pnpm
claude Oct 21, 2026
bcf2dd1
Merge remote-tracking branch 'origin/main' into claude/repo-overview-…
claude Oct 21, 2026
0fb9581
Queue a toolbar for a control Panel, and say where to look first
claude Oct 21, 2026
d022377
Queue color-scheme: dark for the board, alongside the Panel actions slot
claude Oct 21, 2026
db446ef
Rewrap the actions-slot reminder to the file's width
claude Oct 21, 2026
37a30e3
Queue a Panel that folds and carries header actions, from a studio th…
claude Oct 21, 2026
a7b0af8
Give a Panel actions on its title line, and let it fold
claude Oct 21, 2026
34a2762
Stripe the board's panels, darken its native pickers, and size header…
claude Oct 21, 2026
a3d3e31
Let a stepper and a stopwatch fill their cell, and fold the demo's pa…
claude Oct 22, 2026
3a4fa2e
Say in the reference that a stepper and a stopwatch fill their cell
claude Oct 23, 2026
4bb7904
Merge remote-tracking branch 'origin/main' into claude/repo-overview-…
claude Oct 23, 2026
1a6ac5b
Cut 0.13.0, so the folding Panel, the stripes and filling steppers go…
claude Oct 23, 2026
b5b8b04
Merge remote-tracking branch 'origin/main' into claude/repo-overview-…
claude Oct 23, 2026
45a6791
Queue three reports from client work: board padding, col-auto pickers…
claude Oct 23, 2026
a044263
Cut 0.14.0: the board's spacing as properties, a ceiling on col-auto …
claude Oct 23, 2026
2c2937e
Stop a stalled Chromium download holding CI: cache it, retry it, time…
claude Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ on:
jobs:
verify:
runs-on: ubuntu-latest
# A healthy run is about 4 minutes. Without a limit GitHub allows 360, and on
# 2026-10-23 three runs hung in the Chromium install for 7-10 minutes each,
# holding a pull request until somebody noticed and cancelled. The install has
# its own retries below; this is only the floor under everything else.
timeout-minutes: 20
steps:
- uses: actions/checkout@v5

Expand Down Expand Up @@ -77,8 +82,41 @@ jobs:

# Integration coverage for the parts that only exist in a browser:
# SharedWorker startup, cross-tab BroadcastChannel fan-out, IndexedDB.
#
# The browser is cached, keyed on Playwright's version, so most runs do not
# download it at all. The download is what hung: three times on 2026-10-23,
# 7-10 minutes each against 20-27 seconds healthy, and a re-run on another
# runner always went straight through. So each attempt gets two minutes and
# three tries, and a stall costs two minutes rather than a cancelled run.
# The system packages (`install-deps`) still install every run -- they are
# not in the cache path -- under the same retries.
- name: Find Playwright's version
id: playwright
run: echo "version=$(apps/fixture/node_modules/.bin/playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"

- name: Cache Chromium
id: browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ steps.playwright.outputs.version }}

- name: Install Chromium
run: pnpm --filter @single-studio/fixture exec playwright install --with-deps chromium
timeout-minutes: 8
env:
CACHED: ${{ steps.browsers.outputs.cache-hit }}
run: |
if [ "$CACHED" = true ]; then what='install-deps chromium'; else what='install --with-deps chromium'; fi

for attempt in 1 2 3; do
# `timeout` signals the whole process group, so a hung apt-get under
# sudo goes with Playwright rather than holding the dpkg lock.
if timeout --kill-after=10 120 apps/fixture/node_modules/.bin/playwright $what; then exit 0; fi
echo "::warning::Chromium install attempt $attempt stalled or failed after up to 2 minutes; trying again"
done

echo "::error::Chromium did not install in three attempts"
exit 1

- name: Smoke test
working-directory: apps/fixture
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
# 19-28 seconds when healthy. A limit so a hung step fails rather than holding
# the pages concurrency group, and with it the next deploy, for GitHub's 360.
timeout-minutes: 10
steps:
- uses: actions/checkout@v5

Expand Down Expand Up @@ -76,6 +79,8 @@ jobs:
deploy:
needs: build
runs-on: ubuntu-latest
# 9-13 seconds when healthy.
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@ Every published package shares a version — `@single-studio/core`,
`@single-studio/provider-supabase` and the four plugins go out as one release. The
tag is checked against all six manifests, so they cannot drift apart.

## 0.14.0

Three findings from a studio running 0.13.0 on client work, each measured on a live
board. A minor because a `col-auto` image picker now stops at a width, and a board's
own spacing moved from Tailwind utilities in the markup to `base.css`: a studio that
overrode those utilities by name should move to the properties below.

### Added

- **The board's spacing as properties.** `--ss-panel-pad`, `--ss-board-pad`,
`--ss-board-gap`, `--ss-bar-pad-x` and `--ss-bar-pad-y`, set on `.ss-control`. They
default to what the board always had, stepping up at 640px; a studio that sets one
gets it at every width -- in an OBS dock the viewport is the dock, so the step
re-flowed the board as an operator dragged its edge. The board's `<main>` has a
class of its own, `.ss-board`, instead of being reachable only as
`.ss-control > main`.
- **`--ss-picker-max`**, the widest a `col-auto` `ImagePicker` grows. Defaults to
`9.25rem`.

### Changed

- **A `col-auto` `ImagePicker` stops at `--ss-picker-max`.** A picker is a `<select>`,
as wide as the longest name in the image library, which the operator chooses: in
the studio, a logo named `northwind-esports-primary-logo-2026` made the picker
365px and left the `Field` beside it 25px. The cap is part of `col-auto` itself, so
`sm:col-auto` caps only from `sm` up, and below that the picker still has its line
to itself. That is the part a studio could not write: the class is on the element
at every width.

### Documented

- **Read an `--ss-*` property with a fallback**, `var(--ss-basis, 0%)`. A misspelt or
renamed property makes the declaration invalid, which the browser treats as no rule
at all.
- **A dev server serving half a stylesheet**, in Troubleshooting: a CSS change with no
effect, or a board with its colours but no utility classes, is the dev server when
`npm run build` is right, and a restart clears it.

## 0.13.0

A board that reads better and folds away: panels with actions on their title line
Expand Down
85 changes: 85 additions & 0 deletions apps/fixture/e2e/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2844,6 +2844,91 @@ for (const width of [380, 800, 1280]) {
check(Math.abs(measured.auto - 160) <= 1, `while a col-auto stepper keeps its own 160px, at ${width}px`)
}

// -- The board's spacing is the studio's to set ---------------------------------
// It stepped at 640px through utilities in the markup, which a studio could only
// beat with CSS aimed at the framework's own classes. Now properties, with the same
// values and the same step by default, which a studio sets on .ss-control.
const spacing = () => {
const px = (el, prop) => Math.round(parseFloat(getComputedStyle(el)[prop]))
const panel = document.querySelector('.ss-panel')
const board = document.querySelector('.ss-board') ?? document.querySelector('.ss-control > main')
const bar = document.querySelector('.ss-control-bar')

return {
named: board.classList.contains('ss-board') && board.parentElement.classList.contains('ss-control'),
panel: px(panel, 'paddingTop'),
board: px(board, 'paddingTop'),
gap: px(board, 'rowGap'),
barX: px(bar, 'paddingLeft'),
barY: px(bar, 'paddingTop'),
}
}
const studioSets = () => {
const style = document.createElement('style')
style.textContent = '.ss-control { --ss-panel-pad: 20px; --ss-board-pad: 20px; --ss-board-gap: 20px; --ss-bar-pad-x: 20px; --ss-bar-pad-y: 20px; }'
document.head.append(style)
const px = (el, prop) => Math.round(parseFloat(getComputedStyle(el)[prop]))
const board = document.querySelector('.ss-board') ?? document.querySelector('.ss-control > main')
const bar = document.querySelector('.ss-control-bar')

return [px(document.querySelector('.ss-panel'), 'paddingTop'), px(board, 'paddingTop'), px(board, 'rowGap'), px(bar, 'paddingLeft'), px(bar, 'paddingTop')]
}
const slimSpacing = await rowsAt(380, spacing)
const wideSpacing = await rowsAt(1280, spacing)
console.log(` spacing at 380px: ${JSON.stringify(slimSpacing)}; at 1280px: ${JSON.stringify(wideSpacing)}`)
check(slimSpacing.named, "the board's <main> has a class of its own, .ss-board")
check(
[slimSpacing.panel, slimSpacing.board, slimSpacing.gap, slimSpacing.barX, slimSpacing.barY].join() === '12,12,12,12,10' &&
[wideSpacing.panel, wideSpacing.board, wideSpacing.gap, wideSpacing.barX, wideSpacing.barY].join() === '16,16,16,16,12',
'left alone, the board keeps the spacing it always had, and its step at 640px',
)
const setSlim = await rowsAt(380, studioSets)
const setWide = await rowsAt(1280, studioSets)
check(
[...setSlim, ...setWide].every((value) => value === 20),
'a studio that sets the spacing on .ss-control gets it at every width, step and all',
)

// -- A col-auto picker stops at a width -------------------------------------------
// An ImagePicker is as wide as the longest name in the library. col-auto let it take
// that, however long, out of the field beside it.
await control.locator('.ss-asset-library input[aria-label="Image URL"]').fill(`${BASE}/logos/broncos.svg`)
await control.locator('.ss-asset-library input[aria-label="Asset name"]').fill('northwind-esports-primary-logo-2026')
await control.locator('.ss-asset-library button:has-text("Add URL")').click()
check(
await becomes(control, () => [...document.querySelectorAll('.fixture-capped option')].some((option) => /northwind/.test(option.textContent))),
'a long name reaches the picker',
)
const capped = () => {
const width = (el) => Math.round(el.getBoundingClientRect().width)
const row = document.querySelector('.fixture-capped')

const picker = row.querySelector('.ss-image-picker').getBoundingClientRect()
const field = row.querySelector('.ss-field').getBoundingClientRect()

return {
picker: Math.round(picker.width),
field: Math.round(field.width),
row: width(row),
gap: Math.round(parseFloat(getComputedStyle(row).columnGap)),
shared: Math.abs(picker.top - field.top) < 1,
}
}
const viewport = control.viewportSize()
await control.setViewportSize({ width: 1024, height: 900 })
await control.waitForTimeout(300)
const capWide = await control.evaluate(capped)
await control.setViewportSize({ width: 380, height: 900 })
await control.waitForTimeout(300)
const capSlim = await control.evaluate(capped)
await control.setViewportSize(viewport)
console.log(` at 1024px: picker ${capWide.picker}px, field ${capWide.field}px of ${capWide.row}px; at 380px: picker ${capSlim.picker}px of ${capSlim.row}px`)
check(
capWide.shared && capWide.picker === 148 && capWide.field === capWide.row - capWide.picker - capWide.gap,
'a col-auto picker with a long name stops at 9.25rem, and the field on its line keeps the rest',
)
check(capSlim.picker === capSlim.row, 'below its breakpoint the same picker has its line to itself, uncapped')

// -- A graphic that crashes ---------------------------------------------------
// On air it must paint nothing. A missing lower third reads as a cue that did not
// fire; a red error box reads as the broadcast being broken. `?debug` -- which an
Expand Down
10 changes: 10 additions & 0 deletions apps/fixture/src/control/Control.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ function Folding() {
</Row>
</Panel>

<Panel title="Picker width">
{/* A picker at its own width from 640px, beside the field that used to lose:
with a long name in the library it stops at --ss-picker-max rather than
leaving the field 25px. Below 640px it has the line to itself. */}
<Row className="fixture-capped">
<ImagePicker name="fixture.logo" label="Crest" className="sm:col-auto" />
<Field name="fixture.team" label="Team name" />
</Row>
</Panel>

{/* Every header button at size="sm", both ways a reset and a swap render, beside
the same reset at its own size on a Row. Mounted only when the e2e suite asks,
because checks long before it find the board's one swap and one reset by
Expand Down
6 changes: 3 additions & 3 deletions demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
"@single-studio/core": "^0.13.0",
"@single-studio/plugin-twitch": "^0.13.0",
"@single-studio/provider-supabase": "^0.13.0",
"@single-studio/core": "^0.14.0",
"@single-studio/plugin-twitch": "^0.14.0",
"@single-studio/provider-supabase": "^0.14.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router-dom": "^7.18.4",
Expand Down
42 changes: 42 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,35 @@ is `--ss-input-h` tall (default `2.5rem`):
} /* a tighter board */
```

The board's own spacing is set the same way. Each steps up at 640px by default, as it
always has; set one on `.ss-control` and it holds at every width.

| Property | Spaces | Below 640px | From 640px |
| ----------------- | ----------------------------------- | ----------- | ---------- |
| `--ss-panel-pad` | inside every `Panel` | `0.75rem` | `1rem` |
| `--ss-board-pad` | around the board (`.ss-board`) | `0.75rem` | `1rem` |
| `--ss-board-gap` | between the panels | `0.75rem` | `1rem` |
| `--ss-bar-pad-x` | the top bar, either side | `0.75rem` | `1rem` |
| `--ss-bar-pad-y` | the top bar, above and below | `0.625rem` | `0.75rem` |
| `--ss-picker-max` | the widest a `col-auto` ImagePicker | `9.25rem` | `9.25rem` |

```css
.ss-control {
--ss-panel-pad: 1rem;
--ss-board-pad: 1rem;
--ss-board-gap: 1rem;
} /* one size at every width, in a dock you resize mid-show */
```

`--ss-picker-max` exists because an `ImagePicker` is as wide as the longest name in the
library, which the operator chooses: given `col-auto`, a picker with a long logo name
took the line from the field beside it. It stops there now.

**Reading an `--ss-*` property, give it a fallback** — `var(--ss-basis, 0%)`, not
`var(--ss-basis)`. A property that is misspelt, or renamed in a later release, makes
the whole declaration invalid, and the browser treats that as if the rule were not
there at all: no error, just nothing. A fallback turns that into a default.

Two rules keep a wide control from breaking a narrow dock, and are worth knowing if
you write your own layout: `min-width: 0` is what lets a flex or grid item shrink
below its content at all, and `max-width: 100%` is what stops a wide control from
Expand Down Expand Up @@ -1166,6 +1195,19 @@ text over the programme.
unloaded. If the dock is closed and every graphic is hidden at once, the next one to
appear reloads the show from disk — slower, but nothing is lost.

### A CSS change has no effect, or the board comes up unstyled, in `npm run dev`

Run `npm run build` and look at the CSS it writes to `dist`. If that is right, your
code is right and the dev server is not: **restart it** — a reload does not help.

Two ways it goes wrong, both seen in a real studio. A hot update can apply one of two
quick edits to the same file, so a rule arrives but the custom property it reads does
not, and the rule silently does nothing. And when the dev server restarts itself — a
branch checkout touching `vite.config.js` is enough — it can come back serving
Tailwind's colours but none of its utility classes, so the board is serif text and
bare buttons. To tell which you have, read the page's own `<style>` in the browser's
dev tools rather than your file.

### The board will not load on my phone

Not supported yet — see [browser requirements](#browser-requirements).
Loading
Loading