` of its own.
+
+```jsx
+
}>
+
+
+```
+
## Working with other people
A studio is one operator by default and needs nothing. To bring in others, press
@@ -830,6 +849,11 @@ With a breakpoint a class holds from there up; without one, at every width, so
`row-cols-2` keeps buttons in pairs even in a slim dock. A line that does not fill
has no hole at the end: the last control on it takes the room.
+Every control fills the cell it is given, a `Stepper` and a `Stopwatch` included:
+the number field and the readout grow between their buttons. To keep one at its own
+width and give the rest of the line to its neighbours, that is what `col-auto` is
+for — a stepper is 160px.
+
Side by side, controls line up along their inputs. Every control is a label, then one
input line of the same height, then anything extra — colour presets, a warning —
below, where it cannot push its neighbours out of line. A switch or button beside a
diff --git a/docs/internal/next.md b/docs/internal/next.md
index a523cf9b..9e3b4fac 100644
--- a/docs/internal/next.md
+++ b/docs/internal/next.md
@@ -2,6 +2,106 @@
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.
+
+- **The Panel work and `color-scheme: dark` -- built, not released.** `actions`,
+ `collapsible`, `defaultOpen`, `size="sm"` on the header buttons, `useCollapsed`
+ and `Chevron`, striped panels and a dark scheme on the board are in core, under
+ Unreleased in the changelog, as are steppers and stopwatches that fill their
+ cell and a demo whose panels fold. Next: release them, then move
+ SS-Mercy-Riftbound-2v2 off its own `Panel.jsx`, `collapse.jsx`, `FillStepper.jsx`,
+ `HEADER_BUTTON` and the two rules in its `index.css`. See [the section below](#panel-header-actions-and-folding).
+- **`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.
+
+## 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.**
+
+- 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
+ 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.
+
+**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.
+
+**Since then**, also in core: `size="sm"` on `Confirm`, `ResetButton`,
+`SwapButton` and `Toggle`, with a 24px floor that holds when a studio squeezes the
+padding; `useCollapsed` and `Chevron` exported from `@single-studio/core/control`;
+every other panel a shade lighter, by default; and `color-scheme: dark` on
+`.ss-control`. The stripes count sibling panels, so a panel wrapped in a `` of
+its own stays the darker shade -- written into the docs rather than worked around.
+
## Rocket League
- **A test suite with the real game.** `dev/replay.mjs` proves the plugin and
diff --git a/packages/core/src/components/common/Icon.jsx b/packages/core/src/components/common/Icon.jsx
index 056ac10b..99ab5823 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/Collapse.jsx b/packages/core/src/components/control/Collapse.jsx
new file mode 100644
index 00000000..d0750732
--- /dev/null
+++ b/packages/core/src/components/control/Collapse.jsx
@@ -0,0 +1,58 @@
+import { useState } from 'react'
+
+import { cx } from '../../toolkits/cx'
+import { Icon } from '../common/Icon'
+
+/**
+ * Whether something on the board is folded away, and a function that flips it:
+ * `const [collapsed, toggle] = useCollapsed()`. Folded to begin with;
+ * `useCollapsed(false)` starts it open. What a collapsible `Panel` folds with, for
+ * folding anything else the same way -- a team card inside a panel, a row of
+ * advanced settings.
+ *
+ * Starts folded unless told otherwise, on every load, as a panel does. Component
+ * state and nothing else: not the show, which would fold the same thing on every
+ * operator's board, and not browser storage, so a reload is always the same board.
+ *
+ * @example
+ * function TeamCard({ team }) {
+ * const [collapsed, toggle] = useCollapsed()
+ * const body = useId()
+ *
+ * return (
+ *
+ *
+ * {team}
+ *
+ *
…
+ *
+ * )
+ * }
+ *
+ * @param {boolean} [initial] - Start folded. Defaults to `true`; `false` starts it open.
+ * @returns {[boolean, () => void]} Whether it is folded, and a function that folds or opens it.
+ */
+export function useCollapsed(initial = true) {
+ const [collapsed, setCollapsed] = useState(Boolean(initial))
+
+ return [collapsed, () => setCollapsed((was) => !was)]
+}
+
+/**
+ * @typedef {object} ChevronProps
+ * @property {boolean} [collapsed] - Folded: turned a quarter to point at what it belongs to.
+ * @property {string} [className] - Added to the component's own classes.
+ */
+/**
+ * The fold's chevron: pointing down when open, turned a quarter when folded, as a
+ * `Panel`'s is. Decoration -- the button it sits in says what it does, with
+ * `aria-expanded` saying which way it is.
+ *
+ * @example
+ *
+ *
+ * @param {ChevronProps & import("react").SVGAttributes
} props
+ */
+export function Chevron({ collapsed = false, className, ...rest }) {
+ return
+}
diff --git a/packages/core/src/components/control/Confirm.jsx b/packages/core/src/components/control/Confirm.jsx
index 93ff3f6e..b175e21f 100644
--- a/packages/core/src/components/control/Confirm.jsx
+++ b/packages/core/src/components/control/Confirm.jsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { cx } from '../../toolkits/cx'
import { ARMED_TONE, toneClass } from '../../toolkits/tone'
+import { SMALL } from './Label'
const ARMED = 4000
@@ -12,6 +13,7 @@ const ARMED = 4000
* @property {string} [ask] - What it says once armed. Defaults to `"Click to confirm"`.
* @property {'danger'|'warn'|'go'|'primary'|'quiet'} [tone] - What the button means. Defaults to `"danger"`.
* @property {boolean} [disabled] - Nothing happens on click.
+ * @property {'md'|'sm'} [size] - `"sm"` for a Panel's header: smaller, off the input line, never under a 24px target. Defaults to `"md"`.
* @property {import("react").ReactNode} [children] - Replaces `label` when idle.
* @property {string} [className] - Added to the component's own classes.
*/
@@ -77,7 +79,7 @@ const ASK = 'Click to confirm'
*
* @param {ConfirmProps & import("react").ButtonHTMLAttributes} props
*/
-export function Confirm({ onConfirm, label, ask = ASK, tone = 'danger', disabled, className, children, ...rest }) {
+export function Confirm({ onConfirm, label, ask = ASK, tone = 'danger', size = 'md', disabled, className, children, ...rest }) {
const [armed, setArmed] = useState(false)
const timer = useRef(null)
@@ -118,7 +120,8 @@ export function Confirm({ onConfirm, label, ask = ASK, tone = 'danger', disabled
disabled={disabled}
data-armed={armed ? '' : undefined}
className={cx(
- 'ss-confirm rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40',
+ 'ss-confirm rounded-md font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40',
+ size === 'sm' ? SMALL : 'px-3 py-1.5 text-sm',
armed ? ARMED_TONE : looks,
className,
)}
diff --git a/packages/core/src/components/control/Label.jsx b/packages/core/src/components/control/Label.jsx
index b8ec16c4..219f3cb9 100644
--- a/packages/core/src/components/control/Label.jsx
+++ b/packages/core/src/components/control/Label.jsx
@@ -15,6 +15,17 @@ import { useRowLabel } from './Row'
export const LINE = 'h-[var(--ss-input-h,2.5rem)]'
export const MIN_LINE = 'min-h-[var(--ss-input-h,2.5rem)]'
+/**
+ * A button sized for a Panel's header rather than for a Row: `size="sm"` on
+ * Confirm, ResetButton, SwapButton and Toggle.
+ *
+ * Off the input line on purpose -- nothing in a header lines up with a field -- but
+ * never under 24px tall, the smallest pointer target WCAG 2.5.8 allows. Before
+ * this, a studio got a header-sized button by overriding the line height away with
+ * `min-h-0! px-2.5! py-1! text-xs!`, and `min-h-0` has no floor at all.
+ */
+export const SMALL = 'min-h-6 px-2.5 py-1 text-xs'
+
/**
* A control's label line. One component so every control's line is the same height:
* a Row lines its controls up by assuming it, and a label one pixel taller than its
diff --git a/packages/core/src/components/control/Panel.jsx b/packages/core/src/components/control/Panel.jsx
index cd8846f8..6c9b7573 100644
--- a/packages/core/src/components/control/Panel.jsx
+++ b/packages/core/src/components/control/Panel.jsx
@@ -1,8 +1,14 @@
+import { useId } from 'react'
+
import { cx } from '../../toolkits/cx'
+import { Chevron, useCollapsed } from './Collapse'
/**
* @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,32 @@ 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.
+ *
+ * A button in `actions` takes `size="sm"` -- Confirm, ResetButton, SwapButton and
+ * Toggle all have it -- to sit in the header rather than on a Row's input line.
+ * `useCollapsed` and `Chevron` fold anything else on the board the same way.
+ *
+ * Every other panel is a shade lighter, so a long board reads as blocks. The stripes
+ * count sibling panels: render panels directly, or in a fragment, rather than each in
+ * an element of its own.
+ *
* 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 +58,71 @@ 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 [collapsed, toggle] = useCollapsed(!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 && collapsed
+ const tools = typeof actions === 'function' ? actions({ open: !folded }) : actions
+ const hasTools = tools !== undefined && tools !== null && tools !== false && tools !== ''
+
+ // 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) => (
+
+
+ {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}
+
+ {children}
+
)
}
diff --git a/packages/core/src/components/control/ResetButton.jsx b/packages/core/src/components/control/ResetButton.jsx
index 2820566d..2e3db481 100644
--- a/packages/core/src/components/control/ResetButton.jsx
+++ b/packages/core/src/components/control/ResetButton.jsx
@@ -1,7 +1,7 @@
import { useVelcroMutate } from '../../hooks/useVelcroMutate'
import { qualify } from '../../toolkits/address'
import { cx } from '../../toolkits/cx'
-import { MIN_LINE } from './Label'
+import { MIN_LINE, SMALL } from './Label'
import { Confirm } from './Confirm'
/** Where this component's values live. Not a prop: a studio never needs another. */
@@ -14,6 +14,7 @@ const NAMESPACE = 'variables'
* @property {string} [label] - Names what gets cleared: the button reads "Reset ``". Defaults to `"Reset"`.
* @property {boolean} [confirm] - Ask before clearing. Defaults to `true`; pass `confirm={false}` for a button that fires on one press.
* @property {import("react").ReactNode} [children] - Replaces the generated "Reset ``" text.
+ * @property {'md'|'sm'} [size] - `"sm"` for a Panel's header: smaller, off the input line, never under a 24px target. Defaults to `"md"`.
* @property {string} [className] - Added to the component's own classes.
*/
/**
@@ -54,7 +55,7 @@ const NAMESPACE = 'variables'
*
* @param {ResetButtonProps & import("react").ButtonHTMLAttributes} props
*/
-export function ResetButton({ names = [], paths = [], label = 'Reset', confirm = true, className, children, ...rest }) {
+export function ResetButton({ names = [], paths = [], label = 'Reset', confirm = true, size = 'md', className, children, ...rest }) {
const mutate = useVelcroMutate()
const targets = qualify({ names, paths, namespace: NAMESPACE })
@@ -78,7 +79,14 @@ export function ResetButton({ names = [], paths = [], label = 'Reset', confirm =
*/
if (confirm) {
return (
-
+
{children}
)
@@ -90,8 +98,8 @@ export function ResetButton({ names = [], paths = [], label = 'Reset', confirm =
onClick={clear}
title={text}
className={cx(
- 'ss-reset ss-unlabelled rounded-md bg-rose-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-rose-500',
- MIN_LINE,
+ 'ss-reset ss-unlabelled rounded-md bg-rose-600 font-medium text-white transition-colors hover:bg-rose-500',
+ size === 'sm' ? SMALL : cx('px-3 py-2 text-sm', MIN_LINE),
className,
)}
{...rest}
diff --git a/packages/core/src/components/control/Stepper.jsx b/packages/core/src/components/control/Stepper.jsx
index 59cc3cfb..8c05d1fb 100644
--- a/packages/core/src/components/control/Stepper.jsx
+++ b/packages/core/src/components/control/Stepper.jsx
@@ -18,6 +18,8 @@ const NAMESPACE = 'variables'
/**
* A number with minus and plus buttons, and a field to type into. The buttons add
* and subtract, so two operators pressing +1 at once come to +2 rather than +1. Writes immediately.
+ * The field fills whatever width its cell has; `col-auto` keeps the stepper at its
+ * own 160px.
*
* The buttons use the `increment`/`decrement` mutations rather than writing an
* absolute value, which is what makes two operators tapping +1 at the same moment
@@ -136,9 +138,15 @@ export function Stepper({ name, label, step = 1, className, ...rest }) {
/* The native spinners are hidden rather than styled. They would sit beside
the -/+ buttons doing the same job by a different rule -- an absolute
write where the buttons add -- and two controls a pixel apart that
- disagree about what they mean is worse than one control fewer. */
+ disagree about what they mean is worse than one control fewer.
+
+ It grows into whatever room its cell has, between the two buttons. At
+ 4rem and no more it left a stepper 160px wide in any cell, and on a
+ board with breakpoints that was the empty space beside every stepper:
+ 170px of 330 in a slim dock, 414px of 574 at 640px wide. `w-16` is
+ still where it starts, so a `col-auto` stepper is 160px as before. */
className={cx(
- 'ss-stepper-value w-16 min-w-0 rounded-md border bg-slate-950 px-2 text-center text-xl font-semibold tabular-nums text-slate-100 outline-none transition-colors',
+ 'ss-stepper-value w-16 min-w-0 grow rounded-md border bg-slate-950 px-2 text-center text-xl font-semibold tabular-nums text-slate-100 outline-none transition-colors',
LINE,
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
dirty ? 'border-amber-500/70 focus:border-amber-400' : 'border-transparent focus:border-sky-500',
diff --git a/packages/core/src/components/control/Stopwatch.jsx b/packages/core/src/components/control/Stopwatch.jsx
index 3fbbf921..88e85289 100644
--- a/packages/core/src/components/control/Stopwatch.jsx
+++ b/packages/core/src/components/control/Stopwatch.jsx
@@ -15,6 +15,7 @@ const NAMESPACE = 'timers'
/**
* Counts up from when it was started, with pause and reset. Stores an origin
* rather than a running total, so every machine derives the same number. Writes immediately.
+ * The readout fills whatever width its cell has, with the buttons at the end.
*
* The third of the three clocks. counts down a duration,
* counts down to a wall-clock time, and this one counts up from the moment it was
@@ -42,11 +43,15 @@ export function Stopwatch({ name, label = 'Stopwatch', className, ...rest }) {
{/* Wraps rather than overflows. Three controls have a natural width the panel
knows nothing about, and at the one width where three clocks fit in a row
and each is barely wide enough, Reset used to hang out past the panel's
- edge. A control that runs out of room should fold, not escape. */}
+ edge. A control that runs out of room should fold, not escape.
+
+ The readout grows into the rest of the line, as a stepper's field does, so
+ the buttons end at the cell's edge instead of leaving up to 295px empty
+ beside them on a wide board. */}
diff --git a/packages/core/src/components/control/SwapButton.jsx b/packages/core/src/components/control/SwapButton.jsx
index 0e7a3dfb..6ecb572f 100644
--- a/packages/core/src/components/control/SwapButton.jsx
+++ b/packages/core/src/components/control/SwapButton.jsx
@@ -1,7 +1,7 @@
import { useVelcroMutate } from '../../hooks/useVelcroMutate'
import { qualify } from '../../toolkits/address'
import { cx } from '../../toolkits/cx'
-import { MIN_LINE } from './Label'
+import { MIN_LINE, SMALL } from './Label'
import { Confirm } from './Confirm'
/** Where this component's values live. Not a prop: a studio never needs another. */
@@ -14,6 +14,7 @@ const NAMESPACE = 'variables'
* @property {string} [label] - Names what gets traded, on the button. Defaults to `"Swap"`.
* @property {boolean} [confirm] - Ask before trading. Defaults to `true`; pass `confirm={false}` to trade on one press.
* @property {import("react").ReactNode} [children] - Replaces the generated text.
+ * @property {'md'|'sm'} [size] - `"sm"` for a Panel's header: smaller, off the input line, never under a 24px target. Defaults to `"md"`.
* @property {string} [className] - Added to the component's own classes.
*/
/**
@@ -56,7 +57,7 @@ const NAMESPACE = 'variables'
*
* @param {SwapButtonProps & import("react").ButtonHTMLAttributes} props
*/
-export function SwapButton({ names = [], paths = [], label = 'Swap', confirm = true, className, children, ...rest }) {
+export function SwapButton({ names = [], paths = [], label = 'Swap', confirm = true, size = 'md', className, children, ...rest }) {
const mutate = useVelcroMutate()
const targets = qualify({ names, paths, namespace: NAMESPACE })
@@ -64,7 +65,15 @@ export function SwapButton({ names = [], paths = [], label = 'Swap', confirm = t
if (confirm) {
return (
-
+
{children}
)
@@ -75,8 +84,8 @@ export function SwapButton({ names = [], paths = [], label = 'Swap', confirm = t
type="button"
onClick={swap}
className={cx(
- 'ss-swap ss-unlabelled rounded-md bg-amber-500 px-3 py-2 text-sm font-medium text-slate-950 transition-colors hover:bg-amber-400',
- MIN_LINE,
+ 'ss-swap ss-unlabelled rounded-md bg-amber-500 font-medium text-slate-950 transition-colors hover:bg-amber-400',
+ size === 'sm' ? SMALL : cx('px-3 py-2 text-sm', MIN_LINE),
className,
)}
{...rest}
diff --git a/packages/core/src/components/control/Toggle.jsx b/packages/core/src/components/control/Toggle.jsx
index 1b16122b..4f180a06 100644
--- a/packages/core/src/components/control/Toggle.jsx
+++ b/packages/core/src/components/control/Toggle.jsx
@@ -2,7 +2,7 @@ import { useToggleGroup } from '../../hooks/useToggleGroup'
import { useVelcroMutate } from '../../hooks/useVelcroMutate'
import { useVelcroValue } from '../../hooks/useVelcroValue'
import { cx } from '../../toolkits/cx'
-import { MIN_LINE } from './Label'
+import { MIN_LINE, SMALL } from './Label'
/** Where this component's values live. Not a prop: a studio never needs another. */
const NAMESPACE = 'toggles'
@@ -13,6 +13,7 @@ const NAMESPACE = 'toggles'
* @property {string} [label] - Names what is toggled: the button reads "Show ``".
* @property {string} [group] - A group's name. Buttons sharing one behave as radio buttons.
* @property {import("react").ReactNode} [children] - Replaces the generated "Show ``" text.
+ * @property {'md'|'sm'} [size] - `"sm"` for a Panel's header: smaller, off the input line, never under a 24px target. Defaults to `"md"`.
* @property {string} [className] - Added to the component's own classes.
*/
/**
@@ -36,7 +37,7 @@ const NAMESPACE = 'toggles'
*
* @param {ToggleProps & import("react").ButtonHTMLAttributes} props
*/
-export function Toggle({ name, label, group, className, children, ...rest }) {
+export function Toggle({ name, label, group, size = 'md', className, children, ...rest }) {
const path = `${NAMESPACE}.${name}`
const active = Boolean(useVelcroValue(path, false))
const mutate = useVelcroMutate()
@@ -57,8 +58,8 @@ export function Toggle({ name, label, group, className, children, ...rest }) {
onClick={onClick}
aria-pressed={active}
className={cx(
- 'ss-toggle-button ss-unlabelled rounded-md px-3 py-2 text-sm font-medium transition-colors',
- MIN_LINE,
+ 'ss-toggle-button ss-unlabelled rounded-md font-medium transition-colors',
+ size === 'sm' ? SMALL : cx('px-3 py-2 text-sm', MIN_LINE),
active ? 'bg-sky-600 text-white hover:bg-sky-500' : 'border border-slate-700 bg-slate-900 text-slate-300 hover:border-slate-500',
className,
)}
diff --git a/packages/core/src/components/control/index.js b/packages/core/src/components/control/index.js
index ab23f3ab..761b8bfe 100644
--- a/packages/core/src/components/control/index.js
+++ b/packages/core/src/components/control/index.js
@@ -16,6 +16,7 @@ export { Menu } from './Menu'
export { MutationTrouble } from './MutationTrouble'
export { Operator } from './Operator'
export { Panel } from './Panel'
+export { Chevron, useCollapsed } from './Collapse'
export { Plugins, PluginsDialog } from './Plugins'
export { RelayAdmin } from './RelayAdmin'
export { Row } from './Row'
diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css
index a3764604..bfcbd217 100644
--- a/packages/core/src/styles/base.css
+++ b/packages/core/src/styles/base.css
@@ -331,6 +331,18 @@
--ss-ease: linear;
}
+ /*
+ * The board is dark, so it says so. Chrome draws a native control's own pieces --
+ * 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 86e6f6a5..1ee83bda 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',