The window's shape is editor state, not a constant

The grid was five panes at three fixed widths and one fixed height, written as
lengths in the stylesheet. That is a claim about every screen the tool is ever
opened on, and it was wrong on two of them: a phone, where 210 + 250 pixels of
side pane leave nothing for a 320x200 stage, and a large display, where the
inspector is the width somebody once typed rather than the width their work
wants.

So the tracks are custom properties and `ui/layout` owns the numbers. A drag of
an edge is one `assoc-in` and one property on `.app`; no pane's contents
re-render, which is the same reason the picture is painted by `ui/player`'s loop
rather than by anything reactive. Sizes stay in PIXELS and not fractions —
three of these panes hold fixed-width rows, so what you drag an edge to is the
width you meant at any window size.

State, events and widgets are one namespace, which is not this repo's shape
anywhere else. They are the same six integers: `:pool` is a width, a grid track,
a drag's clamp and a button's label at once, and splitting six integers across
`db`, `events/` and `ui/` is more wiring than state.

Three things follow from making it state at all:

SHUTTING A PANE. Pool and inspector unmount; the timeline collapses to its
transport strip instead, because play, pause and the frame readout live in that
strip and a window with nowhere to press play is broken rather than small. The
toggles are in the top bar because a shut pane has no head left to carry its
own handle.

A NARROW WINDOW. Below 760px the grid is one column and the side panes are
drawers over the stage rather than columns beside it. The breakpoint is read
once, at load, and only decides what the layout OPENS at: throwing away the
sizes somebody dragged because they turned a tablet sideways is worse than a
layout that is briefly the wrong shape.

ZOOM. The stage's was already a constant 2 in `ui/stage`; it is now an integer
in [1 8], still CSS over a canvas that is the raster's own size, so the browser
suite still reads 320x200 of real pixels off `canvas.stage`. Whole pixels
only — a fractional scale under `image-rendering: pixelated` draws some rows of
the raster thicker than others, which misrepresents the one thing the preview
exists to judge. The timeline's is the stylesheet's entirely: every mark in the
tracks is positioned as a percentage of their width, so one multiplier on that
width spreads the grid, the spans, the keys, the ruler and the playhead apart
together. Both readouts are the button back to normal, accented while there is
something to return from, because the quick way back belongs in the one place
you already look to find out where you are.

One trap, paid for and then found: the top strip has to scroll sideways on a
narrow screen, and `overflow-x: auto` makes an element a clipping container on
BOTH axes. Every top-bar menu — open, snapshots, share, sign in — was then cut
off by a 30px-tall bar and drawn at whatever x the strip happened to be
scrolled to, which reads as the button doing nothing at all. They are fixed to
the viewport there, as `.menu-drop` already was one pane down for the same
reason. The same shape of bug ate the polygon tool and both zoom readouts: a
flex row that does not fit shrinks every item and pushes the last ones off the
end, so nothing in a strip shrinks now and the strip scrolls as a strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-01 21:50:05 -04:00
parent 93f5112bb3
commit 4e5e02c856
7 changed files with 374 additions and 32 deletions

View file

@ -97,19 +97,32 @@ audio { display: none; }
/* --------------------------------------------------------------------------
the frame */
/* THE TRACKS ARE CUSTOM PROPERTIES, NOT LENGTHS. `ui/layout` writes all six on
this element, which is what makes the three edges between the panes draggable:
a drag is one `assoc-in` and one property, and no pane's contents re-render.
The defaults here are the same numbers `ui/layout`'s `normal` holds, written
twice so that this file still describes a window on its own. */
.app {
display: grid;
height: 100%;
grid-template-columns: var(--label) minmax(0, 1fr) 250px;
--pool-w: 210px;
--params-w: 250px;
--time-h: 232px;
--grip-pool: 4px;
--grip-params: 4px;
--grip-time: 4px;
grid-template-columns:
var(--pool-w) var(--grip-pool) minmax(0, 1fr) var(--grip-params) var(--params-w);
/* The location bar is its own row and takes its height from the stage, not
from the timeline: it exists to explain what the timeline is showing, so
paying for it in timeline rows would be the wrong trade. */
grid-template-rows: 30px minmax(0, 1fr) 21px 232px;
grid-template-rows: 30px minmax(0, 1fr) 21px var(--grip-time) var(--time-h);
grid-template-areas:
"top top top"
"pool view params"
"loc loc loc"
"time time time";
"top top top top top"
"pool gpool view gparm params"
"loc loc loc loc loc"
"gtime gtime gtime gtime gtime"
"time time time time time";
gap: 1px;
background: var(--line);
}
@ -121,6 +134,28 @@ audio { display: none; }
.loc { grid-area: loc; }
.time { grid-area: time; }
/* The draggable edge between two panes. Four pixels of chrome that lights up
under the pointer: a hairline is honest about where the boundary is and
impossible to hit, and a wide gutter would be furniture. */
.grip { touch-action: none; background: var(--chrome); }
.grip:hover, .grip:active { background: var(--sel); }
.grip.col { cursor: col-resize; }
.grip.row { cursor: row-resize; }
.grip-pool { grid-area: gpool; }
.grip-params { grid-area: gparm; }
.grip-time { grid-area: gtime; }
/* Which panes are open, in the top bar. Lower-case words rather than icons: the
three panes have names and the names are what the rest of the chrome calls
them. */
.panes > button { color: var(--dim); }
.panes > button.on { color: var(--fg); }
/* A zoom readout that is also the way back to 1:1, so it is a button that looks
like the number it shows. Tabular figures, because the + beside it must not
move as the percentage passes 100. */
.zoom-at { min-width: 44px; text-align: center; font-variant-numeric: tabular-nums; }
/* Every pane is its own scroll container. `min-height: 0` is what lets a grid
row shrink below its content instead of pushing the layout taller than the
viewport — the single line that separates "a page that scrolls" from "an
@ -792,7 +827,11 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
flex: 1;
min-height: 0;
display: grid;
place-items: center;
/* `safe`, because the stage zooms: a centred grid item larger than its scroll
container puts its top-left edge out of reach on the start side, which at
8x is most of the picture. Safe alignment falls back to start when it does
not fit, and centring is only ever about where a SMALL stage sits. */
place-items: safe center;
overflow: auto;
padding: 14px;
}
@ -959,7 +998,12 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.tl-tracks {
flex: 1;
min-width: 340px;
/* The timeline's zoom, and the whole of it: every mark in here is positioned
as a percentage of this element's width, so one multiplier on the width
spreads the frame grid, the spans, the keys, the ruler and the playhead
apart together, and `.tl-body` was already a scroll container. 1 is the
width that exactly fills the body, which is what `flex: 1` alone gave. */
min-width: max(340px, calc((100% - var(--label)) * var(--tl-zoom, 1)));
position: relative;
background: #fff;
/* The frame grid, five frames to a division, as a background rather than as
@ -1296,3 +1340,104 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
hold, dimmed because its own frames have no place on this ruler. */
.tl-span.unmapped { opacity: 0.45; cursor: pointer; }
.tl-hint.refusing { background: var(--bad, #b00); color: #fff; }
/* --------------------------------------------------------------------------
narrow windows
A phone is not a small desk: there is room for ONE column, so the stage gets
it and the two side panes become drawers over the top of it rather than
columns beside it. They are mounted only while open (`ui/shell`), so a shut
drawer is not holding thumbnails live, and the top bar's pane buttons are
what opens them — which is why they are at the left end of a strip that
scrolls.
The timeline keeps its row. Shut, that row is its transport strip and
nothing else, because a window with nowhere to press play is broken rather
than small; its height is still draggable, by touch, on the grip above it. */
@media (max-width: 760px) {
/* A uuid-wide label column is half the screen. */
:root { --label: 136px; }
.app {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 30px minmax(0, 1fr) 21px var(--grip-time) var(--time-h);
grid-template-areas:
"top"
"view"
"loc"
"gtime"
"time";
}
/* Everything in the strip stays reachable, by scrolling it. */
.top { overflow-x: auto; }
.project-title { max-width: 32vw; }
/* So that 34px below is the same distance on both pages: the editor's strip
takes its 30px from the grid row, and the index page has no grid. */
.index > .top { flex: 0 0 30px; }
/* A SCROLL CONTAINER CLIPS BOTH AXES. `overflow-x: auto` above makes the
strip one, so a panel anchored to its own button — open, snapshots, share,
sign in — is cut off by a 30px-tall bar and drawn at whatever x the strip
happens to be scrolled to, which reads as the button doing nothing at all.
Fixed to the viewport instead, as `.menu-drop` already is for the same
reason one pane down: these are all top-bar menus, the top bar is at the top
of the window, and a near-full-width sheet is what there is room for. Both
offsets on the axis, so `.menu-left`'s anchoring is overridden rather than
left half-applied. `.menu-drop` is excluded because `ui/menu` places that
one itself, inline, and would then be over-constrained. */
.top .menu:not(.menu-drop),
.top .menu.menu-left:not(.menu-drop) {
position: fixed;
top: 34px;
left: 4px;
right: 4px;
width: auto;
min-width: 0;
}
/* Out of the grid altogether: fixed to the viewport, so the column the grid
has left is the stage's whether a drawer is open or not. */
.pool, .params {
position: fixed;
z-index: 30;
top: 31px;
bottom: 0;
width: min(78vw, 300px);
box-shadow: 0 2px 16px rgba(0, 0, 0, .4);
}
.pool { left: 0; border-right: 1px solid var(--line); }
.params { right: 0; border-left: 1px solid var(--line); }
/* There is no column edge left to drag. */
.grip.col { display: none; }
/* The picture, not the desk around it. */
.stage-area { padding: 5px; }
/* Touch targets, in the strips that are all buttons. */
.pane-head, .palette-bar { gap: 7px; }
.palette-bar button, .pane-head button, .top button { min-height: 24px; }
/* A STRIP OF CONTROLS SCROLLS AS A STRIP. Flexbox's instinct when a row does
not fit is to shrink every item and then push the last ones off the end —
and the last ones here are the polygon tool, the timing commands and both
zoom readouts, which would be unreachable rather than merely off-screen.
So nothing shrinks, nothing wraps, and each strip scrolls. The `.spacer`
and `.status` rules above still win on specificity, which is what keeps the
right-hand groups right-hand while there IS room. */
.top > *, .pane-head > *, .palette-bar > * { flex-shrink: 0; }
.top button, .pane-head button { white-space: nowrap; }
.pane-head, .palette-bar { overflow-x: auto; }
/* The one compressible thing in the palette bar is the swatches, so they
scroll inside their own share instead of taking it from the commands. The
tone's NAME goes: the ringed swatch already says which one is active, and
it is the widest thing in the bar that nothing is lost by dropping. */
.palette-bar .swatches { flex: 1 1 130px; min-width: 56px; overflow-x: auto; }
/* A swatch is a 15px circle or it is not a swatch: the strip scrolls, the
dots do not get thinner. */
.palette-bar .swatches > * { flex-shrink: 0; }
.palette-bar > .dim { display: none; }
}