feat(ui): make blink/blackout exclusive and put the buttons beside the preview

Blink and blackout are two ways of hiding the same timer, and having both on
does not mean anything the audience can read differently from either alone.
Turning one on now turns the other off, enforced where the two toggles are
sent so the invariant holds regardless of which control calls it.

The screen buttons move from a row below the stage to a column on its right,
which is where they act and costs less height than a row. The stage and the
button column live in a grid so the buttons can be a fixed, content-sized
width instead of stretching to match the stage's height. A container query
against the panel's own width collapses back to a stacked layout, buttons in
a row below the stage, for a narrower fit (a resized /messagecontrol window,
for instance) where the two can't sit side by side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
This commit is contained in:
Carlos Valente
2026-08-26 13:36:52 +00:00
parent c4ac9df8cf
commit 99f8f4885c
4 changed files with 32 additions and 9 deletions
+5 -2
View File
@@ -51,8 +51,11 @@ export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }),
secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }),
timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }),
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }),
// blink and blackout are mutually exclusive stage states, so turning one on turns the other off
timerBlink: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blink: true, blackout: false } } : { timer: { blink: false } }),
timerBlackout: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blackout: true, blink: false } } : { timer: { blackout: false } }),
timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
sendSocket('message', { timer: { secondarySource: payload } }),
/** returns the stage to a plain timer, keeping whatever the operator has typed */
@@ -1,8 +1,18 @@
/* the screen state buttons belong with the preview they act on, not with the message inputs */
/* the screen state buttons belong with the preview they act on, not with the message inputs.
buttons sit in a column to the right of the stage by default; once the container narrows
too far for that (e.g. an extracted window resized narrow), they drop into a row below it */
.screenGroup {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: $element-inner-spacing;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr;
gap: $element-spacing;
}
@container (max-width: 22rem) {
.screenGroup {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
}
}
@@ -1,6 +1,16 @@
.screenControl {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
grid-auto-flow: row;
gap: $element-spacing;
align-self: center;
width: 8rem;
}
@container (max-width: 22rem) {
.screenControl {
grid-auto-flow: column;
grid-auto-columns: 1fr;
align-self: stretch;
width: 100%;
}
}
@@ -1,6 +1,6 @@
/* the stage is the only elastic element in the panel, the controls below it keep their size */
.stage {
flex: 1 1 auto;
min-width: 0;
min-height: 3rem;
position: relative;
/* a query container so that the embedded timer scales to the preview, not to the viewport */