From 2021849898370cc473261b7df196b2109870dadb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:00:01 +0000 Subject: [PATCH 01/15] Add a Sponsor button: GitHub Sponsors and Ko-fi .github/FUNDING.yml names FourCourtJester on GitHub Sponsors and fourcourtjester on Ko-fi. GitHub reads it from the default branch only, so the button appears once this is on main and Sponsorships is ticked under Settings > General > Features. Checked: prettier and lint pass. Not checked: either link resolves -- this environment cannot reach ko-fi.com or github.com/sponsors -- and the button itself, which only shows once this is merged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFGhv32jx1vxD7qxaMoFxU --- .github/FUNDING.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5ec8074 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: FourCourtJester +ko_fi: fourcourtjester From 7e55d75606fb9357af17ee26c5515609c6d90e18 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:09:40 +0000 Subject: [PATCH 02/15] Give both templates the Sponsor button, and tell their users to change it templates/studio/.github/FUNDING.yml and templates/plugin/.github/FUNDING.yml are the same file as the repository's own. They have to live here rather than be pushed to the mirrors: a sync deletes everything in a mirror but .git and copies the template across, so a file added there directly is gone at the next one. The demo is left without, as asked. A project started from a template inherits the file, and with it a Sponsor button pointing at Single Studio. Each README now says to delete it or point it at your own accounts: the studio's beside its note about LICENSE, the plugin's as step 4 of building your own. Checked: lint passes; verify:template builds both templates and the demo against packed packages. Not checked: the mirrors themselves, which sync on merge; the links, which this environment cannot reach. Prettier flags templates/plugin/README.md, as it did before this change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFGhv32jx1vxD7qxaMoFxU --- templates/plugin/.github/FUNDING.yml | 2 ++ templates/plugin/README.md | 2 ++ templates/studio/.github/FUNDING.yml | 2 ++ templates/studio/README.md | 3 +++ 4 files changed, 9 insertions(+) create mode 100644 templates/plugin/.github/FUNDING.yml create mode 100644 templates/studio/.github/FUNDING.yml diff --git a/templates/plugin/.github/FUNDING.yml b/templates/plugin/.github/FUNDING.yml new file mode 100644 index 0000000..5ec8074 --- /dev/null +++ b/templates/plugin/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: FourCourtJester +ko_fi: fourcourtjester diff --git a/templates/plugin/README.md b/templates/plugin/README.md index 03bb11c..9d2102d 100644 --- a/templates/plugin/README.md +++ b/templates/plugin/README.md @@ -57,6 +57,8 @@ The short version: 1. Pick `ExampleSocket` or `ExamplePoller` in `src/index.js` and delete the other. 2. Rewrite `src/events.js` to match what your far end actually sends. 3. Rename the package, and the `name` inside `definePlugin`. +4. Delete `.github/FUNDING.yml`, which is Single Studio's Sponsor button, or point it + at your own accounts. ## Licence diff --git a/templates/studio/.github/FUNDING.yml b/templates/studio/.github/FUNDING.yml new file mode 100644 index 0000000..5ec8074 --- /dev/null +++ b/templates/studio/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: FourCourtJester +ko_fi: fourcourtjester diff --git a/templates/studio/README.md b/templates/studio/README.md index b18128d..8de2e5c 100644 --- a/templates/studio/README.md +++ b/templates/studio/README.md @@ -86,5 +86,8 @@ Once this is your studio rather than the starter, `LICENSE` is describing your w A show is a private application, so most people simply delete it; replace it instead if you ever open-source yours. +`.github/FUNDING.yml` puts a Sponsor button for Single Studio on your repository. +Delete it, or point it at your own accounts. + The framework packages you install from npm are MIT and keep their own licences inside `node_modules`. That is the normal arrangement and needs nothing from you. From 87fecc98d9f16a256f4b2aaf92e28c9e64b72638 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:15:34 +0000 Subject: [PATCH 03/15] Let the template mirror run: no pnpm cache in a job without pnpm The mirror job failed for both templates on #65, the first push that ever reached its install step: setup-node@v5 "Unable to locate executable file: pnpm". v5 turns on a package-manager cache by itself whenever the checked-out package.json has a "packageManager", and the root one names pnpm, which this job never installs -- it installs the mirror's copy with npm, from npm. Its only two runs before were the merge that added it, where nothing changed, and #63, which changed packages/ and so was left to the release. So the step had never run. package-manager-cache: false is setup-node's own switch for that (checked in its v5 action.yml: "Set to false to disable automatic caching based on the package manager field in package.json"). Nothing was pushed by the failed run, so the template repositories are still on their v0.12.0 sync. Checked: prettier and lint pass. Not checked: the job itself, which can only run on GitHub. This change touches only .github, so merging it does not mirror anything; Run workflow on the Mirror workflow, on main, is what will carry #65's FUNDING.yml across and prove the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFGhv32jx1vxD7qxaMoFxU --- .github/workflows/mirror.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index c15ce28..fdb1c23 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -98,10 +98,17 @@ jobs: echo "go=true" >> "$GITHUB_OUTPUT" fi + # No package-manager cache. setup-node@v5 turns one on by itself when the + # checked-out package.json has a "packageManager", and ours names pnpm, which + # this job never installs: the first push to actually reach this step (#65) + # failed here with "Unable to locate executable file: pnpm", on both + # templates, before anything was pushed. The mirror installs with npm, from + # npm, in a directory of its own, so there is nothing of pnpm's to cache. - uses: actions/setup-node@v5 if: steps.decide.outputs.go == 'true' with: node-version: 22 + package-manager-cache: false # Outside the workspace, so nothing resolves to packages/ by accident: what # installs here is what npm serves, as on the mirror. From 0fb9581f529ce114ba58a0d30c778b6afd705a0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:55:35 +0000 Subject: [PATCH 04/15] Queue a toolbar for a control Panel, and say where to look first docs/internal/next.md gets a "Raise these first" section, for what the owner wants reminding of at the top of any "where are we at": - a right-aligned toolbar on a Panel's title line, for extra components, with the three questions to settle before building it; - Sequence in a real OBS, which 0.12.0 shipped without. AGENTS.md is what every session reads first, so it now says to start from next.md, and that section, when asked where things stand. Without that the note would only be found by somebody already looking for it. Checked: lint passes. Prettier flags next.md, as it did before this change (two *emphasis* spans and a table, all older than this section). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFGhv32jx1vxD7qxaMoFxU --- AGENTS.md | 3 +++ docs/internal/next.md | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1ac695e..1e426e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,3 +111,6 @@ finding, and it lets somebody check you. | `templates/studio` | What a new studio is scaffolded from. Mirrored to its own repository on release. | | `docs/` | The published site. `docs/api.md` is **generated** — edit the JSDoc instead. | | `docs/internal/` | Findings, decisions and open threads. Written to survive the conversation they came from. | + +Asked where things stand, start with `docs/internal/next.md`, and raise its **Raise +these first** section before anything else. diff --git a/docs/internal/next.md b/docs/internal/next.md index a523cf9..9ad2ecf 100644 --- a/docs/internal/next.md +++ b/docs/internal/next.md @@ -2,6 +2,22 @@ Left here rather than in a chat, so it survives. +## Raise these first + +Asked for at the top of any "where are we at": the owner wants reminding of these +before anything else. + +- **A toolbar on a control `Panel`.** Room for extra components -- buttons, a toggle, + a status dot -- right-aligned on the panel's title line. Today `Panel` takes only + `title`, `children` and `className`, and the title is a bare `

` with nothing + beside it, so the likely shape is a `toolbar` prop rendered opposite the title. + Open before building: does it show on a panel with no `title`; does it wrap under + the title in a slim OBS dock; is it `Panel`'s alone or every control container's. +- **`Sequence` in a real OBS.** 0.12.0 shipped it tested only in Chromium. The check + that matters: a browser source with _Refresh browser when scene becomes active_ + ticked starts the sequence again from its first item every time the scene is + taken. + ## Rocket League - **A test suite with the real game.** `dev/replay.mjs` proves the plugin and From d022377288cfed784ed07f66bee3dc6ffd2d07eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:03:14 +0000 Subject: [PATCH 05/15] Queue color-scheme: dark for the board, alongside the Panel actions slot A time input's clock icon is Chrome's to draw, and it draws it dark unless the page declares a dark theme, so on the board it all but disappeared. A studio fixed it with color-scheme: dark on its board in its own index.css, and found it also darkens the time picker's popup, the menus behind Select and the scrollbars. Every board is dark, so the line belongs in core's base.css, scoped to .ss-control as the board's cursors are, where it cannot reach a graphic. Queued, not done. The toolbar entry is renamed to the owner's word for it: an actions slot. Checked: lint passes. Not checked: anything in a browser; this only records the finding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFGhv32jx1vxD7qxaMoFxU --- docs/internal/next.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/internal/next.md b/docs/internal/next.md index 9ad2ecf..3f94384 100644 --- a/docs/internal/next.md +++ b/docs/internal/next.md @@ -7,12 +7,19 @@ Left here rather than in a chat, so it survives. Asked for at the top of any "where are we at": the owner wants reminding of these before anything else. -- **A toolbar on a control `Panel`.** Room for extra components -- buttons, a toggle, +- **An actions slot on a control `Panel`.** A toolbar for extra components -- buttons, a toggle, a status dot -- right-aligned on the panel's title line. Today `Panel` takes only `title`, `children` and `className`, and the title is a bare `

` with nothing beside it, so the likely shape is a `toolbar` prop rendered opposite the title. Open before building: does it show on a panel with no `title`; does it wrap under the title in a slim OBS dock; is it `Panel`'s alone or every control container's. +- **`color-scheme: dark` on the board, in core.** A time input's clock icon is + drawn by Chrome, dark unless the page declares a dark theme, so it all but + vanished on the board. A studio fixed it with `color-scheme: dark` on the board + in its own `index.css`, which also gave dark versions of the time picker's + popup, the menus behind `Select`, and the scrollbars. Every board is dark, so it + belongs in `base.css`, scoped to `.ss-control` as the board's cursors are, so it + never reaches a graphic. - **`Sequence` in a real OBS.** 0.12.0 shipped it tested only in Chromium. The check that matters: a browser source with _Refresh browser when scene becomes active_ ticked starts the sequence again from its first item every time the scene is From db446ef8d2ffbebf9794b1b3bfeda8831eb7f727 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:03:25 +0000 Subject: [PATCH 06/15] Rewrap the actions-slot reminder to the file's width Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFGhv32jx1vxD7qxaMoFxU --- docs/internal/next.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/internal/next.md b/docs/internal/next.md index 3f94384..536ddab 100644 --- a/docs/internal/next.md +++ b/docs/internal/next.md @@ -7,12 +7,13 @@ Left here rather than in a chat, so it survives. Asked for at the top of any "where are we at": the owner wants reminding of these before anything else. -- **An actions slot on a control `Panel`.** A toolbar for extra components -- buttons, a toggle, - a status dot -- right-aligned on the panel's title line. Today `Panel` takes only - `title`, `children` and `className`, and the title is a bare `

` with nothing - beside it, so the likely shape is a `toolbar` prop rendered opposite the title. - Open before building: does it show on a panel with no `title`; does it wrap under - the title in a slim OBS dock; is it `Panel`'s alone or every control container's. +- **An actions slot on a control `Panel`.** A toolbar for extra components -- + buttons, a toggle, a status dot -- right-aligned on the panel's title line. Today + `Panel` takes only `title`, `children` and `className`, and the title is a bare + `

` with nothing beside it, so the likely shape is a `toolbar` prop rendered + opposite the title. Open before building: does it show on a panel with no `title`; + does it wrap under the title in a slim OBS dock; is it `Panel`'s alone or every + control container's. - **`color-scheme: dark` on the board, in core.** A time input's clock icon is drawn by Chrome, dark unless the page declares a dark theme, so it all but vanished on the board. A studio fixed it with `color-scheme: dark` on the board From 37a30e3840fe903fa5f25598ffae815a5f26bbb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:34:45 +0000 Subject: [PATCH 07/15] Queue a Panel that folds and carries header actions, from a studio that built one SS-Mercy-Riftbound-2v2 built a collapsible Panel with header actions in its own src/control, because core's Panel has nothing beside its title. Its notes go into next.md so core can take it over rather than rediscover it: - the behaviour: everything starts folded on every load, and the fold is component state only -- not the show, which would fold every operator's board, and not localStorage, so a reload is always the same board; - the markup: the button inside the h2, not the other way round; - what bit: .ss-panel-body's display: flex beats the hidden attribute, and a grid of panels needs items-start; - the API it asks for: collapsible, defaultOpen, exported useCollapsed and Chevron, an actions slot, and size="sm" on the four header buttons, none of which has a size today. It replaces the earlier actions-slot entry in "Raise these first", which now points here. Checked: lint passes. Not checked: the hidden-attribute finding, which is the studio's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFGhv32jx1vxD7qxaMoFxU --- docs/internal/next.md | 75 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/docs/internal/next.md b/docs/internal/next.md index 536ddab..920e360 100644 --- a/docs/internal/next.md +++ b/docs/internal/next.md @@ -7,13 +7,10 @@ Left here rather than in a chat, so it survives. Asked for at the top of any "where are we at": the owner wants reminding of these before anything else. -- **An actions slot on a control `Panel`.** A toolbar for extra components -- - buttons, a toggle, a status dot -- right-aligned on the panel's title line. Today - `Panel` takes only `title`, `children` and `className`, and the title is a bare - `

` with nothing beside it, so the likely shape is a `toolbar` prop rendered - opposite the title. Open before building: does it show on a panel with no `title`; - does it wrap under the title in a slim OBS dock; is it `Panel`'s alone or every - control container's. +- **A `Panel` with header actions, that folds.** An `actions` slot on the title + line, a fold that starts closed on every load, and a small size for the buttons + that go in the header. Built and used in a studio first; see + [the section below](#panel-header-actions-and-folding) for what it found. - **`color-scheme: dark` on the board, in core.** A time input's clock icon is drawn by Chrome, dark unless the page declares a dark theme, so it all but vanished on the board. A studio fixed it with `color-scheme: dark` on the board @@ -26,6 +23,70 @@ before anything else. ticked starts the sequence again from its first item every time the scene is taken. +## Panel: header actions, and folding + +From the SS-Mercy-Riftbound-2v2 studio, which built all of it locally +(`src/control/collapse.jsx` and its own `Panel.jsx`) because core's `Panel` has +nothing beside its title. What it does, and what it learned, so core can take it +over rather than rediscover it. + +**Behaviour.** + +- The title is a button -- chevron and title -- that folds the panel body away. +- **Everything starts folded, on every load**, panels and the cards inside them + alike. A fresh board is a short list of headers, and the operator opens what the + next segment needs. +- The fold is component state and nothing else. Not the show, which would fold the + panel on every operator's board; and not `localStorage`, so that a reload is + always the same board. +- Header `actions` (Reset, Swap, Seed…) stay in the header and keep working while + folded. +- A folded team card keeps a one-line summary -- "A1 · Comet Crew · 2–0 · 6 pts" -- + so a folded group still reads at a glance. Folding hides the controls, not the + state of the show. + +**How the studio built it.** + +- `useCollapsed(initial = true)` returns `[collapsed, toggle]`: a plain `useState`, + no keys and no storage. `false` starts one open. +- `` points down when open and turns −90° when folded. +- Markup is `

`, with + `id={bodyId}` from `useId` on the body. The button goes inside the heading: a + heading inside a button is invalid HTML. +- The wrapper copies core's markup -- `.ss-panel`, `.ss-panel-body`, same classes + and spacing -- so board CSS aimed at those still applies. The header's bottom + margin goes while folded. + +**What bit.** + +- `.ss-panel-body { display: flex }` beats the `hidden` attribute, so the studio + sets the attribute _and_ Tailwind's `hidden` class. In core, the better fix is a + `[hidden]` rule beside the flex one, so a studio's own CSS cannot reintroduce it. +- A grid of panels or cards needs `items-start`, or a folded one stretches to its + neighbours' height. +- A folded summary reads saved values (`useVelcroValue`), not the staged draft, so + it changes when the operator saves, not as they type. + +**The shape asked for in core.** + +```jsx +} collapsible defaultOpen={false}> +``` + +- `collapsible` turns folding on; `defaultOpen` starts one open. Folded is the + default. +- `useCollapsed` and `Chevron` exported from `@single-studio/core/control`, so a + studio folds its own cards the same way. +- It pairs with the other asks from the same studio: + - `actions` on `Panel`, rendered right-aligned on the title line; + - `size="sm"` on `Confirm`, `ResetButton`, `SwapButton` and `Toggle`, so a + header button needs no overrides -- today it takes + `min-h-0! px-2.5! py-1! text-xs!`. None of the four has a size yet; + - `color-scheme: dark` on the board, above. + +**Still open.** What a panel with `actions` but no `title` looks like, and whether +the header wraps, or the actions drop under the title, in a slim OBS dock. + ## Rocket League - **A test suite with the real game.** `dev/replay.mjs` proves the plugin and From a7b0af8ebe2f326dfb19528db07b7a3fc147148a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:00:14 +0000 Subject: [PATCH 08/15] Give a Panel actions on its title line, and let it fold Two things a studio (SS-Mercy-Riftbound-2v2) built for itself because core's Panel had nothing beside its title: - `actions`: anything at all, right-aligned on the title line, or a function given `{ open }` that returns it. They stay in the header and work while the panel is folded, and in a slim dock take a line of their own under the title rather than squeezing it. - `collapsible`: the title becomes a button that folds the body away. A collapsible panel starts folded on every load; `defaultOpen` starts one open. The fold is component state only -- not the show, which would fold every operator's board, and not storage, so a reload is the same board. The body is hidden, not unmounted, so a draft in a folded panel survives. Off unless asked for, so every existing panel renders as before, except that its title now sits in a `.ss-panel-header` line. With no title, actions keep their line and a collapsible panel gets a chevron of its own, named "Fold panel" / "Open panel"; with nothing for a header to hold there is none. The button sits inside the h2, never the reverse. `chevron` joins Icon. Cross-checked against the studio's own Panel and collapse.jsx, and took its header element and its larger title hit area. Not taken: its Tailwind `hidden` class on the body. Its notes say `.ss-panel-body`'s display: flex beats the hidden attribute, but Tailwind's preflight, which every studio imports, has `[hidden] { display: none !important }`: a `[hidden]` rule written into base.css for it changed nothing (the folded body measured 0px tall without it), so it was taken out again rather than kept as code nothing can catch. Thirteen e2e checks, in their own section near the end. Placed after Rows first, they wedged the asset library's folder upload a hundred lines later -- the stall AGENTS.md describes -- and moved, everything passed. Negative-tested with nine breaks, each turning its check red: body not hidden; body unmounted while folded; function actions not given the fold; a header with nothing in it; collapsible by default; defaultOpen ignored; a header that does not wrap; a header that toggles on any press inside it; a fold remembered in localStorage. Docs: getting-started's Panel row and a paragraph with an example, CHANGELOG Unreleased, api.md regenerated, and next.md now says what is built and what the studio still wants: size="sm" on header buttons, useCollapsed and Chevron exported for folding cards, and zebra striping. Checked: test (core 603, every package passes), lint, typecheck, verify:template, docs:build, and e2e 306 of 306 against a fresh build. Not checked: in OBS's own browser, or in the studio itself, which still uses its own Panel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFGhv32jx1vxD7qxaMoFxU --- CHANGELOG.md | 15 +++ apps/fixture/e2e/smoke.mjs | 101 ++++++++++++++++++ apps/fixture/src/control/Control.jsx | 56 ++++++++++ docs/api.md | 18 ++++ docs/getting-started.md | 15 ++- docs/internal/next.md | 44 ++++++-- packages/core/src/components/common/Icon.jsx | 2 + .../core/src/components/control/Panel.jsx | 92 +++++++++++++++- 8 files changed, 328 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a043f87..b73b797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ 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. +## Unreleased + +### Added + +- **A `Panel` header that does more.** `actions` puts anything -- a reset, a swap, + a status light -- at the right of the title, or a function given `{ open }` that + returns it. They stay in the header, and work, while the panel is folded, and + drop under the title in a dock too narrow for both. `collapsible` makes the title + a button that folds the panel away; a collapsible panel starts folded on every + load, and `defaultOpen` starts one open. The fold belongs to the page, not the + show, and is not remembered. A panel with no title keeps its actions and gets a + chevron of its own to fold by; one with nothing for a header to hold has none. + Panels that ask for neither are unchanged, except that the title now sits in a + `.ss-panel-header` line. + ## 0.12.0 `Sequence`: a rotation of anything, each item on screen for as long as it says. A diff --git a/apps/fixture/e2e/smoke.mjs b/apps/fixture/e2e/smoke.mjs index d665aab..a7aa732 100644 --- a/apps/fixture/e2e/smoke.mjs +++ b/apps/fixture/e2e/smoke.mjs @@ -2656,6 +2656,107 @@ await becomes(control, () => !document.querySelector('.ss-plugins-dialog[open]') await rotation.close() } +// -- A panel's header: folding, and actions on its line ------------------------ +// Everything here is local to the page -- a fold, a counter, a plain input -- so the +// show is left exactly as the checks after it expect. +const fold = control.locator('.fixture-fold') +const foldState = () => + control.evaluate(() => { + const panel = document.querySelector('.fixture-fold') + const body = panel.querySelector('.ss-panel-body') + + return { + expanded: panel.querySelector('.ss-panel-fold').getAttribute('aria-expanded'), + shown: body.getBoundingClientRect().height > 0, + says: panel.querySelector('.fixture-fold-says')?.textContent, + presses: panel.querySelector('.fixture-fold-press')?.textContent, + } + }) + +let state = await foldState() +check(state.expanded === 'false' && !state.shown, 'a collapsible panel starts folded, its controls off the board') +check(state.says === 'folded', 'and actions given as a function are told it is folded') + +await fold.locator('.fixture-fold-press').click() +state = await foldState() +check(state.presses === 'Pressed 1' && !state.shown, 'an action in the header works while the panel is folded, and pressing it does not open it') + +await fold.locator('.ss-panel-fold').click() +check( + await becomes(control, () => document.querySelector('.fixture-fold .fixture-fold-says')?.textContent === 'open'), + 'pressing the title opens it, and the actions hear about it', +) +state = await foldState() +check(state.expanded === 'true' && state.shown, 'with its controls back on the board') + +await fold.locator('.fixture-fold-input').fill('still here') +await fold.locator('.ss-panel-fold').click() +await fold.locator('.ss-panel-fold').click() +check( + (await fold.locator('.fixture-fold-input').inputValue()) === 'still here', + 'folding hides the controls without unmounting them, so what was typed survives', +) + +check( + (await control.locator('.ss-panel-fold').count()) === 2, + 'and only the panels that ask to be collapsible fold -- every other panel on the board is as it was', +) + +// Not remembered: a page that loads now starts folded, though this one is open. +const reloaded = await context.newPage() +await reloaded.goto(`${BASE}/#/`) +await reloaded.waitForSelector('.fixture-fold') +check( + (await reloaded.locator('.fixture-fold .ss-panel-fold').getAttribute('aria-expanded')) === 'false', + 'a fold is not remembered: another load of the board starts folded while this one is open', +) +await reloaded.close() + +// Back to folded, as the board loads, for whatever comes after. +await fold.locator('.fixture-fold-input').fill('') +await fold.locator('.ss-panel-fold').click() + +const headers = await control.evaluate(() => { + const untitled = document.querySelector('.fixture-untitled') + const bare = document.querySelector('.fixture-bare') + + return { + untitledHeading: untitled.querySelector('h2') !== null, + untitledAction: untitled.querySelector('.ss-panel-actions .fixture-untitled-action') !== null, + untitledFold: untitled.querySelector('.ss-panel-fold')?.getAttribute('aria-label'), + untitledOpen: untitled.querySelector('.ss-panel-body').getBoundingClientRect().height > 0, + bareHeader: bare.querySelector('.ss-panel-header') !== null, + } +}) +check(!headers.untitledHeading && headers.untitledAction, 'a panel without a title has no heading, and its actions still have their line') +check(headers.untitledFold === 'Fold panel' && headers.untitledOpen, 'its fold is a chevron with a name of its own, and defaultOpen starts it open') +check(!headers.bareHeader, 'a panel with nothing for a header to hold has no header at all') + +// The title line in both shapes of dock. Wide, the actions sit at the right of the +// title; slim, they take a line of their own rather than squeezing it, and nothing +// pokes out of the panel. +const titleLine = () => { + const panel = document.querySelector('.fixture-fold') + const box = (el) => el.getBoundingClientRect() + const title = box(panel.querySelector('h2')) + const tools = box(panel.querySelector('.ss-panel-actions')) + const edge = box(panel) + + return { + beside: tools.top < title.bottom && tools.left >= title.right, + under: tools.top >= title.bottom, + inside: tools.right <= edge.right && title.right <= edge.right, + titleLines: Math.round(title.height / parseFloat(getComputedStyle(panel.querySelector('h2')).lineHeight)), + } +} +const wideLine = await rowsAt(1280, titleLine) +check(wideLine.beside && wideLine.inside, 'on a wide board the actions sit on the title line, at its right') +const slimLine = await rowsAt(380, titleLine) +check( + slimLine.under && slimLine.inside && slimLine.titleLines === 1, + 'in a slim dock they drop under the title, which keeps its line, and stay inside the panel', +) + // -- 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 diff --git a/apps/fixture/src/control/Control.jsx b/apps/fixture/src/control/Control.jsx index 35cca4e..b56b880 100644 --- a/apps/fixture/src/control/Control.jsx +++ b/apps/fixture/src/control/Control.jsx @@ -1,3 +1,5 @@ +import { useState } from 'react' + import { useVelcroMutate } from '@single-studio/core' import { AssetLibrary, @@ -47,6 +49,58 @@ function Draft({ side, title }) { ) } +/** + * The three shapes a Panel's header takes. Kept to plain inputs and local state, + * nothing bound to the show, so folding and pressing here leave the show exactly + * as the checks around them expect it. + */ +function Folding() { + const [presses, setPresses] = useState(0) + const press = 'rounded-md border border-slate-700 bg-slate-900 px-2.5 py-1 text-xs text-slate-200' + + return ( + <> + {/* Folded on load, with actions that read the fold and one that counts its + presses, folded or not. The title is long enough that a slim dock has to + put the actions on a line of their own; the e2e suite measures that. */} + ( + <> + {open ? 'open' : 'folded'} + + + )} + > + + + + {/* No title: actions keep their line, and the fold is a chevron of its own. */} + + Untitled action + + } + > +

No title; starts open.

+
+ + {/* Nothing for a header to hold, so no header. */} + +

No title, no actions.

+
+ + ) +} + export default function Control() { const mutate = useVelcroMutate() @@ -172,6 +226,8 @@ export default function Control() {
+ + {/* The manager, inline. The same component opens as a modal from any picker. */} diff --git a/docs/api.md b/docs/api.md index 472a610..e02ea01 100644 --- a/docs/api.md +++ b/docs/api.md @@ -552,10 +552,28 @@ A titled group of controls, one row per child, top to bottom. ``` +```jsx +// Folded until the operator opens it, with its reset reachable either way +}> + + + +``` + +```jsx +// Actions that change with the fold: the swap only while the names are in view + open && }> + … + +``` + | Prop | Type | Required | Description | | --- | --- | --- | --- | +| `actions` | `ReactNode \| ((state: { open: boolean }) => ReactNode)` | | Anything at all, right-aligned on the title line: a reset, a swap, a status light. Or a function that returns it, given whether the panel is open. | | `children` | `ReactNode` | | Controls, one row each. Put several in a `Row` to share a line. | | `className` | `string` | | Added to the component's own classes. | +| `collapsible` | `boolean` | | The title folds the panel away and opens it again. Defaults to `false`. | +| `defaultOpen` | `boolean` | | A collapsible panel starts folded on every load; `true` starts this one open. Defaults to `false`. | | `title` | `string` | | Heading for the group. | diff --git a/docs/getting-started.md b/docs/getting-started.md index 51940ec..a1b757f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -279,10 +279,23 @@ instead, over `--ss-fade`; anything beyond that is a rule of your own on | `Stopwatch` | `timers.` | Counts up. Start, pause, reset. | | `Leaderboard` | `variables.` | One delimited string; paste view and table view. Staged. | | `SaveButton` | — | Commits every staged edit. Hosts the save and discard shortcuts. | -| `Panel` | — | Titled group. One row per child. | +| `Panel` | — | Titled group, one row per child. `actions` on the title line; `collapsible`. | | `Row` | — | Side by side when there is room, stacked in a dock. Widths in twelfths. | | `Break` | — | Deprecated: draws nothing. Use `Row`. | +A `Panel`'s `actions` go at the right of its title, and work while it is folded: +anything at all, or a function given `{ open }` that returns it. `collapsible` +makes the title fold the panel away. A collapsible panel starts folded on every +load, so a fresh board is a short list of headers; `defaultOpen` starts one open. +The fold is that page's alone, never the show's, so folding a panel on your board +folds nobody else's. + +```jsx +}> + + +``` + ## Working with other people A studio is one operator by default and needs nothing. To bring in others, press diff --git a/docs/internal/next.md b/docs/internal/next.md index 920e360..2b84f31 100644 --- a/docs/internal/next.md +++ b/docs/internal/next.md @@ -7,10 +7,11 @@ Left here rather than in a chat, so it survives. Asked for at the top of any "where are we at": the owner wants reminding of these before anything else. -- **A `Panel` with header actions, that folds.** An `actions` slot on the title - line, a fold that starts closed on every load, and a small size for the buttons - that go in the header. Built and used in a studio first; see - [the section below](#panel-header-actions-and-folding) for what it found. +- **A `Panel` with header actions, that folds -- built, not released.** `actions` + (anything, or a function given `{ open }`), `collapsible` and `defaultOpen` are in + core on the branch. Still open from the same studio: `size="sm"` on the header + buttons, `useCollapsed` and `Chevron` exported for folding cards, and zebra + striping. See [the section below](#panel-header-actions-and-folding). - **`color-scheme: dark` on the board, in core.** A time input's clock icon is drawn by Chrome, dark unless the page declares a dark theme, so it all but vanished on the board. A studio fixed it with `color-scheme: dark` on the board @@ -59,9 +60,12 @@ over rather than rediscover it. **What bit.** -- `.ss-panel-body { display: flex }` beats the `hidden` attribute, so the studio - sets the attribute _and_ Tailwind's `hidden` class. In core, the better fix is a - `[hidden]` rule beside the flex one, so a studio's own CSS cannot reintroduce it. +- The studio's notes say `.ss-panel-body { display: flex }` beats the `hidden` + attribute, so it sets Tailwind's `hidden` class as well. That does not reproduce + in core: Tailwind's preflight, which every studio imports, has + `[hidden] { display: none !important }`. A `[hidden]` rule written into `base.css` + for it changed nothing -- with it removed, the folded body still measured 0px tall + -- so core relies on the attribute alone. - A grid of panels or cards needs `items-start`, or a folded one stretches to its neighbours' height. - A folded summary reads saved values (`useVelcroValue`), not the staged draft, so @@ -84,8 +88,30 @@ over rather than rediscover it. `min-h-0! px-2.5! py-1! text-xs!`. None of the four has a size yet; - `color-scheme: dark` on the board, above. -**Still open.** What a panel with `actions` but no `title` looks like, and whether -the header wraps, or the actions drop under the title, in a slim OBS dock. +**What core has now** (on the branch, not released). `actions`, as anything or as +a function given `{ open }`; `collapsible`, off by default, so existing boards are +unchanged; `defaultOpen`. Settled on the way: + +- No title, but actions: the header holds just the actions. No title and + collapsible: a chevron of its own, named "Fold panel" or "Open panel". Neither, + and no actions: no header at all. +- In a slim dock the actions take a line of their own under the title, still at the + right, and the title keeps one line. Measured at 380px and 1280px. +- Folding hides the body without unmounting it, so a draft typed into a folded + panel survives. + +**Still open.** + +- `size="sm"` on `Confirm`, `ResetButton`, `SwapButton` and `Toggle`. The studio's + header buttons still need `min-h-0! px-2.5! py-1! text-xs!`. +- `useCollapsed` and `Chevron`, exported, for the team cards the studio folds + inside a panel. Core's chevron is ``, unexported as a + component of its own. +- **Zebra striping.** The studio shades every other panel, + `.ss-control .ss-panel:nth-of-type(even) { background-color: rgb(30 41 59 / 0.7) }`, + and its team cards and bracket rows with `odd:`/`even:`. The owner likes how it + breaks up a long board; whether core does it by default, and for nested panels, + is undecided. ## Rocket League diff --git a/packages/core/src/components/common/Icon.jsx b/packages/core/src/components/common/Icon.jsx index 056ac10..99ab582 100644 --- a/packages/core/src/components/common/Icon.jsx +++ b/packages/core/src/components/common/Icon.jsx @@ -109,6 +109,8 @@ const shapes = { ), + /* Pointing down: open. A folded Panel turns it a quarter to point at its title. */ + chevron: , /* Two figures: one nearer, one behind. Collaboration, without a metaphor. */ people: ( <> diff --git a/packages/core/src/components/control/Panel.jsx b/packages/core/src/components/control/Panel.jsx index cd8846f..334ad9e 100644 --- a/packages/core/src/components/control/Panel.jsx +++ b/packages/core/src/components/control/Panel.jsx @@ -1,8 +1,14 @@ +import { useId, useState } from 'react' + import { cx } from '../../toolkits/cx' +import { Icon } from '../common/Icon' /** * @typedef {object} PanelProps * @property {string} [title] - Heading for the group. + * @property {import("react").ReactNode | ((state: { open: boolean }) => import("react").ReactNode)} [actions] - Anything at all, right-aligned on the title line: a reset, a swap, a status light. Or a function that returns it, given whether the panel is open. + * @property {boolean} [collapsible] - The title folds the panel away and opens it again. Defaults to `false`. + * @property {boolean} [defaultOpen] - A collapsible panel starts folded on every load; `true` starts this one open. Defaults to `false`. * @property {import("react").ReactNode} [children] - Controls, one row each. Put several in a `Row` to share a line. * @property {string} [className] - Added to the component's own classes. */ @@ -16,8 +22,24 @@ import { cx } from '../../toolkits/cx' * at every width. To put controls side by side when there is room, put them in a * `Row`, which stacks them again when there is not. * + * **`actions`** go on the title line, at the right: anything, not only buttons. They + * stay in the header, and keep working, while the panel is folded. In a dock too + * narrow for both, they drop under the title rather than squeezing it. + * + * **`collapsible`** makes the title a button that folds the body away. Folded is + * where every collapsible panel starts, on every load: a fresh board is a short + * list of headers, and the operator opens what the next segment needs. The fold is + * this page's alone -- not the show's, which would fold the panel on every + * operator's board, and not remembered, so a reload is always the same board. + * Folding hides the controls without unmounting them, so a draft typed into a + * folded panel is still there when it opens. + * + * Without a `title`, `actions` or `collapsible`, there is no header at all. Actions + * without a title still get their line; a collapsible panel without one gets a + * chevron on its own to fold it by. + * * The panel's own box is `.ss-panel-body` in the stylesheet if you want to take it - * further. + * further; the title line is `.ss-panel-header`, the actions `.ss-panel-actions`. * * @example * @@ -28,13 +50,73 @@ import { cx } from '../../toolkits/cx' * * * + * @example + * // Folded until the operator opens it, with its reset reachable either way + * }> + * + * + * + * + * @example + * // Actions that change with the fold: the swap only while the names are in view + * open && }> + * … + * + * * @param {PanelProps & import("react").HTMLAttributes} props */ -export function Panel({ title, children, className, ...rest }) { +export function Panel({ title, actions, collapsible = false, defaultOpen = false, children, className, ...rest }) { + const [open, setOpen] = useState(() => !collapsible || defaultOpen) + const body = useId() + + // Read every render rather than stored, so a panel that stops being collapsible + // is simply open, whatever it was folded to. + const folded = collapsible && !open + const tools = typeof actions === 'function' ? actions({ open: !folded }) : actions + const hasTools = tools !== undefined && tools !== null && tools !== false && tools !== '' + + const chevron = + + // The button goes inside the heading, never the other way round: a heading inside + // a button is invalid HTML, and a screen reader's list of headings loses it. The + // negative margin and padding give it a bigger target without moving the title. + const fold = (label) => ( + + ) + + const heading = title ? ( +

{collapsible ? fold(title) : title}

+ ) : collapsible ? ( + {fold(null)} + ) : null + return ( -
- {title ?

{title}

: null} -
{children}
+
+ {heading || hasTools ? ( + // Wraps rather than shrinking the title: in a slim dock the actions take the + // next line, still at the right, and the title keeps its words. +
+ {heading} + {hasTools ?
{tools}
: null} +
+ ) : null} +
) } From 34a2762a3eadcb99a43367fc319999ed9f2b54c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:51:22 +0000 Subject: [PATCH 09/15] Stripe the board's panels, darken its native pickers, and size header buttons Four things from SS-Mercy-Riftbound-2v2 that every studio should have without writing them: - Striped panels, by default. Every other panel is a shade lighter, so a long board reads as blocks rather than one slab. The studio's own rule and colours: rgb(15 23 42 / 0.4) and rgb(30 41 59 / 0.7). The background moved from a class on the element into base.css, because the count -- nth-of-type among sibling panels -- can only be made in CSS; in the components layer, so a bg-* on one panel or a studio's own rule still wins. A panel wrapped in a
of its own never counts as even; documented rather than worked around. - color-scheme: dark on .ss-control. Chrome draws a time input's clock icon, its picker, a select's list and scrollbars in the page's scheme, and on the board they were black on slate. On the board only; a graphic stays normal. - size="sm" on Confirm, ResetButton, SwapButton and Toggle, for a button in a panel's header: text-xs, smaller padding, off the Row's input line, and a min-h-6 floor so it is never under the 24px pointer target WCAG 2.5.8 asks for. Replaces the studio's `min-h-0! px-2.5! py-1! text-xs!`, which had no floor. Measured: 24 or 26px against 40px on a row, 12px text. - useCollapsed and Chevron, exported from @single-studio/core/control, so a card folds the way a Panel does. Panel is now built on them, and both are in the API reference's list. Seven e2e checks, all near the end, and the small buttons mount only when those checks ask: mounted from the start, they gave earlier checks that find the board's one swap by class three to choose from. Negative-tested, each check red when broken for it: stripes removed; the scheme normal; the scheme on :root, reaching graphics; Toggle ignoring size; a confirming Reset not passing size on; the small size under 24px; useCollapsed starting open; the chevron never turning. Removing the min-h-6 floor at first changed nothing, because the padding alone makes 24px, so the fixture now squeezes one button's padding to zero and the floor is what holds it at 24. Checked: test (core 603, every package passes), lint, typecheck, verify:template, docs:build, and e2e 313 of 313 against a fresh build. The useCollapsed doc comment was reworded after that run; nothing else changed. Not checked: OBS's own browser, or the studio itself, which still uses its own Panel and CSS until core is released. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFGhv32jx1vxD7qxaMoFxU --- CHANGELOG.md | 24 +++++++- apps/fixture/e2e/smoke.mjs | 57 ++++++++++++++++++ apps/fixture/src/control/Control.jsx | 58 ++++++++++++++++++- docs/api.md | 55 +++++++++++++++++- docs/getting-started.md | 10 +++- docs/internal/next.md | 36 ++++-------- .../core/src/components/control/Collapse.jsx | 58 +++++++++++++++++++ .../core/src/components/control/Confirm.jsx | 7 ++- .../core/src/components/control/Label.jsx | 11 ++++ .../core/src/components/control/Panel.jsx | 28 +++++---- .../src/components/control/ResetButton.jsx | 18 ++++-- .../src/components/control/SwapButton.jsx | 19 ++++-- .../core/src/components/control/Toggle.jsx | 9 +-- packages/core/src/components/control/index.js | 1 + packages/core/src/styles/base.css | 32 ++++++++++ scripts/api-reference.mjs | 2 + 16 files changed, 368 insertions(+), 57 deletions(-) create mode 100644 packages/core/src/components/control/Collapse.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b73b797..61f3e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,30 @@ tag is checked against all six manifests, so they cannot drift apart. load, and `defaultOpen` starts one open. The fold belongs to the page, not the show, and is not remembered. A panel with no title keeps its actions and gets a chevron of its own to fold by; one with nothing for a header to hold has none. - Panels that ask for neither are unchanged, except that the title now sits in a + Panels that ask for neither keep their layout; the title now sits in a `.ss-panel-header` line. +- **`size="sm"` on `Confirm`, `ResetButton`, `SwapButton` and `Toggle`**, for a + button in a panel's header: smaller text and padding, off the Row's input line, + and never under the 24px pointer target WCAG 2.5.8 asks for, however its padding + is squeezed. It replaces overriding the line height away with + `min-h-0! px-2.5! py-1! text-xs!`, which had no floor at all. +- **`useCollapsed` and `Chevron`**, from `@single-studio/core/control`: what a + collapsible panel folds with, for folding anything else on the board the same + way -- a team card inside a panel, say. + +### Changed + +- **Panels are striped.** Every other panel on the board is a shade lighter, so a + long board reads as blocks rather than one slab. Counted among sibling panels: + a panel wrapped in an element of its own (`
`) stays the + darker shade. A `bg-*` class on a panel, or a studio's own rule, still wins. The + panel's background moved from a class on the element into the stylesheet to make + the count possible. +- **The board declares `color-scheme: dark`.** Chrome draws a time input's clock + icon, its picker, the list behind a `, scrollbars -- + * in the page's colour scheme, and without this they came out black on the + * board's slate: the clock icon in CountdownTo all but disappeared. Found in a + * studio, which set it in its own index.css. On `.ss-control` alone, so a graphic, + * which has a scene behind it rather than a board, never gets it. + */ + .ss-control { + color-scheme: dark; + } + /* * Cursors on the board. * @@ -438,6 +450,26 @@ * pushing the panel wider than the dock and giving the whole board a horizontal * scrollbar; they are easy to leave out and painful to find. */ + /* + * Every other panel a shade lighter, so a long board reads as separate blocks + * rather than one slab of slate. Taken from a studio that did it for itself, as + * the default because every board is expected to want it. + * + * Counted among sibling panels, so panels nested inside a panel alternate among + * themselves. A panel wrapped in an element of its own -- `
` -- + * is the only one of its kind in there and never counts as even; render panels + * directly, or in a fragment, to keep the stripes. Here rather than as a class on + * the element so the count can be made at all, and in this layer so a studio's + * `bg-*` on one panel, or its own rule, still wins. + */ + .ss-panel { + background-color: rgb(15 23 42 / 0.4); + } + + .ss-panel:nth-of-type(even) { + background-color: rgb(30 41 59 / 0.7); + } + .ss-panel-body { display: flex; flex-direction: column; diff --git a/scripts/api-reference.mjs b/scripts/api-reference.mjs index 86e6f6a..1ee83bd 100644 --- a/scripts/api-reference.mjs +++ b/scripts/api-reference.mjs @@ -57,6 +57,8 @@ const AUTHORED = { 'Leaderboard', 'Panel', 'Row', + 'useCollapsed', + 'Chevron', 'Break', 'Confirm', 'Hotkeys', From a3d3e31a43d12e0705a0d8ac5521be926416e9b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 00:35:58 +0000 Subject: [PATCH 10/15] Let a stepper and a stopwatch fill their cell, and fold the demo's panels A stepper's number field stopped at 4rem, so a stepper was 160px in any cell and the rest of the cell sat empty -- the negative space at every breakpoint. Measured on the fixture: 170px of 330 in a 380px dock, 414px of 574 at 640px, 601px of 761 at 1600px. A stopwatch's readout did the same, up to 295px. Both now grow between their buttons (`grow`, not `flex-1`, so `w-16` is still the basis and a `col-auto` stepper keeps its own 160px). The same thing SS-Mercy-Riftbound-2v2's FillStepper did for itself, now for everyone. The demo, built against these packages: its two score steppers left 121-201px empty below 1024px on 0.12.0 and leave nothing now, measured at 380, 640, 800, 1024, 1280 and 1600px. Its panels are collapsible: the scoreboard starts open, the rest folded. Its Swap sides moved into the scoreboard's header at size="sm", where it works with the panel folded. The demo uses props 0.12.0 does not have, so it has to go out with the release; the mirror leaves a push that also changes packages/ to the release anyway. Nine e2e checks at 380, 800 and 1280px: every stepper on a Row reaches its cell's edge, so does a stopwatch, and a col-auto stepper is 160px. Negative- tested: the field not growing, the readout not growing, and the field sized to its cell (which loses col-auto its width) each turned their checks red. Checked: test (core 603, every package passes), lint, typecheck, verify:template, docs:build, and e2e 322 of 322 against a fresh build. Not checked: OBS's own browser. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFGhv32jx1vxD7qxaMoFxU --- CHANGELOG.md | 6 ++++ apps/fixture/e2e/smoke.mjs | 30 +++++++++++++++++++ apps/fixture/src/control/Control.jsx | 7 +++++ demo/src/control/Control.jsx | 3 ++ demo/src/control/panels/Clock.jsx | 2 +- demo/src/control/panels/LowerThirds.jsx | 2 +- demo/src/control/panels/Scoreboard.jsx | 29 ++++++++++-------- demo/src/control/panels/Static.jsx | 2 +- demo/src/control/panels/Twitch.jsx | 2 +- docs/getting-started.md | 5 ++++ docs/internal/next.md | 7 +++-- .../core/src/components/control/Stepper.jsx | 10 +++++-- .../core/src/components/control/Stopwatch.jsx | 8 +++-- 13 files changed, 90 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f3e2c..cd6a731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ tag is checked against all six manifests, so they cannot drift apart. ### Changed +- **A `Stepper` and a `Stopwatch` fill their cell.** A stepper's number field + stopped at 4rem and a stopwatch's readout at its minimum, so on a board with + breakpoints both sat in the corner of their cell: a stepper left 170px of 330 + empty in a slim dock, and 121–201px in the demo below 1024px. The field and the + readout now grow between their buttons. `col-auto` still keeps one at its own + width -- a stepper is 160px, as before. - **Panels are striped.** Every other panel on the board is a shade lighter, so a long board reads as blocks rather than one slab. Counted among sibling panels: a panel wrapped in an element of its own (`
`) stays the diff --git a/apps/fixture/e2e/smoke.mjs b/apps/fixture/e2e/smoke.mjs index c38726c..77ca8db 100644 --- a/apps/fixture/e2e/smoke.mjs +++ b/apps/fixture/e2e/smoke.mjs @@ -2814,6 +2814,36 @@ folded = await cardState() check(folded.expanded === 'true' && folded.shown && !folded.turned, 'and one press opens it, the chevron pointing down') await card.locator('.fixture-card-fold').click() +// -- A stepper fills its cell --------------------------------------------------- +// A stepper's field used to stop at 4rem, so a stepper was 160px in any cell and the +// rest of the cell sat empty -- 414px of 574 at 640px wide. Measured across the +// widths a dock and a board take, for every stepper and stopwatch on a Row. +const fills = () => { + const edge = (el) => el.getBoundingClientRect() + const short = (cell, last) => Math.round(edge(cell).right - edge(last).right) + const steppers = [...document.querySelectorAll('.ss-row > .ss-stepper:not(.col-auto)')] + const watches = [...document.querySelectorAll('.ss-row > .ss-stopwatch')] + const auto = document.querySelector('.fixture-auto .ss-stepper') + + return { + steppers: steppers.map((cell) => short(cell, [...cell.querySelectorAll('button')].at(-1))), + watches: watches.map((cell) => short(cell, [...cell.querySelectorAll('button')].at(-1))), + auto: Math.round(edge(auto).width), + } +} +for (const width of [380, 800, 1280]) { + const measured = await rowsAt(width, fills) + console.log( + ` at ${width}px: steppers short of their cell by ${measured.steppers.join(', ')}px; stopwatch ${measured.watches.join(', ')}px; col-auto stepper ${measured.auto}px`, + ) + check( + measured.steppers.length >= 4 && measured.steppers.every((gap) => Math.abs(gap) <= 1), + `at ${width}px every stepper on a Row reaches the edge of its cell, with nothing empty beside it`, + ) + check(measured.watches.length >= 1 && measured.watches.every((gap) => Math.abs(gap) <= 1), `and so does a stopwatch, at ${width}px`) + check(Math.abs(measured.auto - 160) <= 1, `while a col-auto stepper keeps its own 160px, at ${width}px`) +} + // -- 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 diff --git a/apps/fixture/src/control/Control.jsx b/apps/fixture/src/control/Control.jsx index 650b8d7..0fc54ac 100644 --- a/apps/fixture/src/control/Control.jsx +++ b/apps/fixture/src/control/Control.jsx @@ -114,6 +114,13 @@ function Folding() { >

No title; starts open.

+ {/* A stepper at its own width beside a field taking the rest: `col-auto` still + means 160px now that a stepper grows into any other cell. Measured, never + pressed or typed into. */} + + + + {/* Every header button at size="sm", both ways a reset and a swap render, beside diff --git a/demo/src/control/Control.jsx b/demo/src/control/Control.jsx index a2f03b1..128b198 100644 --- a/demo/src/control/Control.jsx +++ b/demo/src/control/Control.jsx @@ -11,6 +11,9 @@ import Twitch from './panels/Twitch' * scoreboard is touched every round, the lower thirds a few times an hour, the * static card at the start and the breaks, the clock once, and Twitch looks after * itself. Panels live in ./panels, one per file. + * + * Every panel folds, and all but the scoreboard start folded: the board opens on the + * one panel a round needs and a list of headers for the rest. */ export default function Control() { return ( diff --git a/demo/src/control/panels/Clock.jsx b/demo/src/control/panels/Clock.jsx index 57b93a0..0b6803a 100644 --- a/demo/src/control/panels/Clock.jsx +++ b/demo/src/control/panels/Clock.jsx @@ -9,7 +9,7 @@ import { Cycle, Panel, Row, Toggle } from '@single-studio/core/control' */ export default function Clock() { return ( - + {/* The switch at its own width beside the format, from 640px. */} diff --git a/demo/src/control/panels/LowerThirds.jsx b/demo/src/control/panels/LowerThirds.jsx index 345bca5..3b6b17d 100644 --- a/demo/src/control/panels/LowerThirds.jsx +++ b/demo/src/control/panels/LowerThirds.jsx @@ -29,7 +29,7 @@ function Third({ which, label, name, tag }) { */ export default function LowerThirds() { return ( - + {/* Below 1024px each strap is two or three lines, and the two run together without a line between them. */} diff --git a/demo/src/control/panels/Scoreboard.jsx b/demo/src/control/panels/Scoreboard.jsx index 08a8e2a..b81dee7 100644 --- a/demo/src/control/panels/Scoreboard.jsx +++ b/demo/src/control/panels/Scoreboard.jsx @@ -36,7 +36,21 @@ export default function Scoreboard() { const mutate = useVelcroMutate() return ( - + // Open on load, where every other panel starts folded: it is the one touched every + // round. The swap is in the header, so it can be pressed with the panel folded. + + } + > {/* Below 1024px a side is four or two lines of controls, and two sides of identical controls run together without a line between them. From there @@ -45,17 +59,8 @@ export default function Scoreboard() {
- {/* The round takes the line and the swap only the room its words need, from 640px. */} - - {/* A field, not a stepper: empty, its placeholder says what the scoreboard shows. */} - - {/* One button, every path a side owns: half a swap is a scoreboard that lies. */} - - + {/* A field, not a stepper: empty, its placeholder says what the scoreboard shows. */} +