Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,49 @@ 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 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

- **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 (`<div><Panel /></div>`) 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 `<select>` and scrollbars in the page's
scheme; on the board they were black on slate, and the clock icon in
`CountdownTo` all but disappeared. On `.ss-control` alone, so graphics are
untouched.

## 0.12.0

`Sequence`: a rotation of anything, each item on screen for as long as it says. A
Expand Down
188 changes: 188 additions & 0 deletions apps/fixture/e2e/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2656,6 +2656,194 @@ 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',
)

// -- The board's own look: stripes, a dark scheme, header-sized buttons ---------
await control.locator('.fixture-small-mount').click()
await control.waitForSelector('.fixture-small-md')
const look = await control.evaluate(() => {
const board = document.querySelector('.ss-control')
const panels = [...board.querySelectorAll('.ss-panel')].filter((panel) => !panel.parentElement.closest('.ss-panel'))
const fill = (el) => getComputedStyle(el).backgroundColor
const box = (el) => el.getBoundingClientRect()
const small = [...document.querySelectorAll('.fixture-small .ss-panel-actions > *')]

return {
fills: panels.slice(0, 4).map(fill),
scheme: getComputedStyle(board).colorScheme,
smallHeights: small.map((el) => Math.round(box(el).height)),
smallText: small.map((el) => getComputedStyle(el).fontSize),
regular: Math.round(box(document.querySelector('.fixture-small-md')).height),
}
})
console.log(` panel fills: ${look.fills.join(' | ')}`)
check(
look.fills[0] !== look.fills[1] && look.fills[0] === look.fills[2] && look.fills[1] === look.fills[3],
'panels are striped, every other one a shade lighter, without the studio asking',
)
check(look.scheme === 'dark', 'the board tells the browser it is dark, so native pickers and scrollbars draw dark')

const plain = await context.newPage()
await plain.goto(`${BASE}/#/source/sequence`)
await plain.waitForSelector('.ss-source')
check((await plain.evaluate(() => getComputedStyle(document.documentElement.querySelector('.ss-source')).colorScheme)) === 'normal', 'and a graphic does not')
await plain.close()

console.log(` header buttons: ${look.smallHeights.join(', ')}px tall, ${[...new Set(look.smallText)].join(', ')}; on a row ${look.regular}px`)
check(
look.smallHeights.length === 7 && look.smallHeights.every((height) => height >= 24 && height < look.regular),
'a header button at size="sm" is smaller than its row-sized self, and never under the 24px a pointer needs, even squeezed',
)
check(
look.smallText.every((size) => size === '12px'),
'with smaller text, on every button that takes the size, whichever way it renders',
)
await control.locator('.fixture-small-mount').click()

// A card folded with core's own hook and chevron, as a studio folds one.
const card = control.locator('.fixture-card')
const cardState = () =>
card.evaluate((el) => ({
expanded: el.querySelector('.fixture-card-fold').getAttribute('aria-expanded'),
shown: el.querySelector('.fixture-card-body').getBoundingClientRect().height > 0,
turned: el.querySelector('.ss-chevron').classList.contains('-rotate-90'),
}))
let folded = await cardState()
check(folded.expanded === 'false' && !folded.shown && folded.turned, 'useCollapsed starts a card folded, and Chevron turns to say so')
await card.locator('.fixture-card-fold').click()
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
Expand Down
119 changes: 119 additions & 0 deletions apps/fixture/src/control/Control.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { useId, useState } from 'react'

import { useVelcroMutate } from '@single-studio/core'
import {
AssetLibrary,
Chevron,
ColorPicker,
Confirm,
Countdown,
CountdownTo,
Cycle,
Expand All @@ -17,6 +21,7 @@ import {
SwapButton,
TextArea,
Toggle,
useCollapsed,
} from '@single-studio/core/control'

import { ARMY_SIZE, COMMANDERS, FACTIONS, MAPS, UNITS } from '../roster'
Expand Down Expand Up @@ -47,6 +52,118 @@ 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.
*/
/** A card folded the way a Panel folds, with core's own hook and chevron. */
function Card() {
const [collapsed, toggle] = useCollapsed()
const body = useId()

return (
<div className="fixture-card rounded-md border border-slate-800 p-2">
<button type="button" onClick={toggle} aria-expanded={!collapsed} aria-controls={body} className="fixture-card-fold flex items-center gap-2 text-sm">
<Chevron collapsed={collapsed} />
Card
</button>
<p id={body} hidden={collapsed} className="fixture-card-body mt-2 text-sm text-slate-400">
Folded by useCollapsed.
</p>
</div>
)
}

function Folding() {
const [presses, setPresses] = useState(0)
const [small, setSmall] = useState(false)
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. */}
<Panel
title="Folding, with actions on its line"
collapsible
className="fixture-fold"
actions={({ open }) => (
<>
<span className="fixture-fold-says text-xs text-slate-500">{open ? 'open' : 'folded'}</span>
<button type="button" onClick={() => setPresses((n) => n + 1)} className={`fixture-fold-press ${press}`}>
Pressed {presses}
</button>
</>
)}
>
<input aria-label="Kept while folded" className="fixture-fold-input rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-sm" />
</Panel>

{/* No title: actions keep their line, and the fold is a chevron of its own. */}
<Panel
collapsible
defaultOpen
className="fixture-untitled"
actions={
<button type="button" className={`fixture-untitled-action ${press}`}>
Untitled action
</button>
}
>
<p className="text-sm text-slate-400">No title; starts open.</p>
<Card />
{/* 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. */}
<Row className="fixture-auto">
<Stepper name="fixture.auto" label="At its own width" className="col-auto" />
<Field name="fixture.beside" label="The rest" />
</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
class. Bound to paths nothing else reads, and only measured, never pressed. */}
<Panel
title="Header buttons"
className="fixture-small"
actions={
small ? (
<>
<Confirm size="sm" label="Confirm" onConfirm={() => {}} />
<ResetButton size="sm" label="nothing" names={['fixture.nothing']} />
<ResetButton size="sm" label="at once" names={['fixture.nothing']} confirm={false} />
<SwapButton size="sm" label="nothing" names={['fixture.left', 'fixture.right']} />
<SwapButton size="sm" label="at once" names={['fixture.left', 'fixture.right']} confirm={false} />
<Toggle size="sm" name="fixture-small" label="small" />
{/* Squeezed the way a studio might, to fit one more in: the size keeps a
floor under it however its padding is cut. */}
<Toggle size="sm" name="fixture-small" label="squeezed" style={{ paddingBlock: 0 }} />
</>
) : null
}
>
<button type="button" onClick={() => setSmall((was) => !was)} className={`fixture-small-mount ${press}`}>
{small ? 'Unmount' : 'Mount'} header buttons
</button>
{small ? (
<Row>
<ResetButton label="nothing" names={['fixture.nothing']} className="fixture-small-md" />
</Row>
) : null}
</Panel>

{/* Nothing for a header to hold, so no header. */}
<Panel className="fixture-bare">
<p className="text-sm text-slate-400">No title, no actions.</p>
</Panel>
</>
)
}

export default function Control() {
const mutate = useVelcroMutate()

Expand Down Expand Up @@ -172,6 +289,8 @@ export default function Control() {
<Toggle name="intro" label="intro" />
</Panel>

<Folding />

<Panel title="Images">
{/* The manager, inline. The same component opens as a modal from any picker. */}
<AssetLibrary />
Expand Down
Loading
Loading