Split the shell into topbar, pool, stage, timeline and params panes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 12:25:46 -04:00
parent ddabfbeaa8
commit 179770d7d4
24 changed files with 2138 additions and 611 deletions

565
static/arthur/app.css Normal file
View file

@ -0,0 +1,565 @@
/* The application chrome: one screen, five panes, no scrolling page.
*
* Macromedia-era, deliberately. Light grey panels, hairline rules, small type,
* and a timeline whose keyframes are DOTS on a frame grid — Flash's vocabulary,
* because this tool's model is Flash's model (a library of timelines, instances
* placed on a stage, a layer list against a ruler) and borrowing the look is the
* cheapest way to make that legible to anyone who has used one. What is not
* borrowed is the chrome of the period: no bevels, no gradients on buttons, no
* inset wells. Flat, clean, one accent.
*
* Raw CSS on purpose. This file styles a fixed grid of panes whose class names
* are the domain's own — `.tl-row`, `.pool-item`, `.swatch` — so a utility
* framework would buy naming we want anyway, at the cost of a third process in
* a dev loop the repo keeps at two. Tokens are custom properties; everything
* else is a handful of grids.
*
* It lives under static/ rather than inline in the template because it is now
* long enough that "the styles" and "the page" are two things. */
:root {
color-scheme: light;
/* The stage's background is the PALETTE's index 0, not a theme colour. The
two agreeing is what makes the preview honest, so it is written here as the
same value arthur.domain.palette declares and nowhere else in this file.
It is the one dark surface on the page, and it should be: the picture is
320x200 of indexed colour and the UI is not allowed to compete with it. */
--stage: #12141c;
--desk: #8e8e8e; /* the work area the stage floats on */
--pane: #f2f2f0;
--chrome: #e4e4e1;
--sunk: #ebebe8;
--line: #b0b0ac;
--hair: #d2d2ce;
--fg: #1f1f1f;
--dim: #6e6e6a;
/* One accent, used for selection and for nothing else. */
--sel: #2f6fc0;
--sel-bg: #cfe0f5;
/* A keyframe is a dot and a dot is ink. Flash draws them black and so does
this; the frames a node exists over are a pale tint behind them. */
--key: #1f1f1f;
--span: #dfe8f4;
--span-line:#a9bdd8;
--grid: #dcdcd8;
--grid-5: #c6c6c2;
--playhead: #c8322b;
--warn: #b4502a;
--row: 21px;
--ruler: 19px;
--label: 210px;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; }
body {
background: var(--desk);
color: var(--fg);
font: 11px/1.45 "Lucida Grande", "Segoe UI", system-ui, sans-serif;
}
#app { height: 100%; }
/* The preview is nearest-neighbour everywhere. A browser that smoothed the
upscale would misrepresent the exact look the tool exists to judge, so this
rule is load-bearing rather than cosmetic. */
canvas { image-rendering: pixelated; }
audio { display: none; }
/* --------------------------------------------------------------------------
the frame */
.app {
display: grid;
height: 100%;
grid-template-columns: var(--label) minmax(0, 1fr) 250px;
grid-template-rows: 30px minmax(0, 1fr) 232px;
grid-template-areas:
"top top top"
"pool view params"
"time time time";
gap: 1px;
background: var(--line);
}
.top { grid-area: top; }
.pool { grid-area: pool; }
.view { grid-area: view; }
.params { grid-area: params; }
.time { grid-area: time; }
/* 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
application window". */
.pane {
background: var(--pane);
min-height: 0;
min-width: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
.pane-head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 5px;
padding: 0 7px;
height: 21px;
flex: 0 0 21px;
background: var(--chrome);
border-bottom: 1px solid var(--line);
color: var(--dim);
letter-spacing: .03em;
}
.pane-head .spacer { flex: 1; }
.pane-body { padding: 5px; }
/* --------------------------------------------------------------------------
controls */
button {
font: inherit;
color: var(--fg);
background: var(--pane);
border: 1px solid var(--line);
border-radius: 2px;
padding: 1px 7px;
cursor: pointer;
}
button:hover:not(:disabled) { background: #fff; }
button:active:not(:disabled) { background: var(--sunk); }
button:disabled { opacity: .38; cursor: default; }
button.on {
background: var(--sel-bg);
border-color: var(--sel);
color: #14395f;
}
select, input[type="text"] {
font: inherit;
color: var(--fg);
background: #fff;
border: 1px solid var(--line);
border-radius: 2px;
padding: 1px 3px;
max-width: 100%;
}
input[type="range"] { width: 100%; accent-color: var(--sel); }
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.dim { color: var(--dim); }
.warn { color: var(--warn); }
/* --------------------------------------------------------------------------
top bar */
.top {
display: flex;
align-items: center;
gap: 7px;
padding: 0 8px;
background: var(--chrome);
}
.top .brand { color: var(--dim); letter-spacing: .14em; text-transform: uppercase; }
.top .status {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dim);
}
/* --------------------------------------------------------------------------
the open menu */
.menu-wrap { position: relative; }
/* A full-page catcher behind the panel, so a click anywhere else dismisses it.
More predictable than a document listener that has to be added, removed, and
told to ignore the click that opened the menu. */
.menu-scrim { position: fixed; inset: 0; z-index: 40; }
.menu {
position: absolute;
top: calc(100% + 3px);
right: 0;
z-index: 41;
min-width: 250px;
max-height: 72vh;
overflow: auto;
padding: 5px;
background: var(--pane);
border: 1px solid var(--line);
border-radius: 2px;
box-shadow: 0 3px 10px rgba(0, 0, 0, .3);
}
.menu > h2 {
margin: 6px 0 3px;
font: inherit;
font-weight: 600;
color: var(--dim);
text-transform: uppercase;
letter-spacing: .07em;
}
.menu > h2:first-child { margin-top: 0; }
.menu > h2 + .dim { margin-bottom: 4px; }
.menu-item {
display: block;
width: 100%;
text-align: left;
padding: 2px 6px;
border: 1px solid transparent;
border-radius: 2px;
background: none;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-item:hover:not(:disabled) { background: var(--sel-bg); }
/* A fixture is not a document. Italic is the whole of the distinction, and it is
enough: the heading above already says what these are. */
.menu-item.example { font-style: italic; }
.menu-item .sub { display: block; color: var(--dim); }
/* --------------------------------------------------------------------------
media pool */
.pool-group { margin-bottom: 9px; }
/* The one-line gloss under a group heading. Small and quiet: it answers "what
is this list" once, for someone who has not read the model. */
.pool-group > h2 + .dim { margin-bottom: 4px; }
.pool-group > h2 {
margin: 0 0 3px;
font: inherit;
font-weight: 600;
color: var(--dim);
text-transform: uppercase;
letter-spacing: .07em;
}
.pool-item {
display: block;
width: 100%;
text-align: left;
padding: 2px 6px;
border: 1px solid transparent;
border-radius: 2px;
background: none;
cursor: grab;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-item:hover:not(:disabled) { background: #fff; }
.pool-item.on { background: var(--sel-bg); border-color: var(--sel); }
.pool-item .sub { display: block; color: var(--dim); }
/* The whole pane is the drop target, so the cue has to be the pane and not a
zone inside it — a drop that lands two pixels outside a dashed rectangle and
silently does nothing is the failure this avoids. */
.pool.dropping {
outline: 2px dashed var(--sel);
outline-offset: -3px;
background: var(--sel-bg);
}
/* --------------------------------------------------------------------------
the viewer: palette above, stage below */
.view {
display: flex;
flex-direction: column;
background: var(--desk);
min-height: 0;
overflow: hidden;
}
.palette-bar {
display: flex;
align-items: center;
gap: 9px;
padding: 4px 8px;
background: var(--chrome);
border-bottom: 1px solid var(--line);
}
.swatches { display: flex; gap: 3px; }
/* Circles. A palette entry is one indivisible tone, not an area of coverage, and
a row of dots says that where a row of tiles says "swatch book". */
.swatch {
width: 15px;
height: 15px;
padding: 0;
border: 1px solid var(--line);
border-radius: 50%;
cursor: pointer;
}
.swatch:hover:not(:disabled) { border-color: var(--fg); }
/* A swatch that cannot be picked still has to SHOW ITS COLOUR: the generic
disabled rule fades it towards the pane, which turned the background slot —
the darkest tone in the palette — into pale grey, i.e. into a lie about what
index 0 is. Slot 0 says what it is with a ring instead. */
.swatch:disabled { opacity: 1; }
.swatch.bg { cursor: default; box-shadow: inset 0 0 0 2px var(--pane); }
.swatch.on {
border-color: var(--fg);
box-shadow: 0 0 0 2px var(--pane), 0 0 0 3px var(--sel);
}
/* An empty slot has to read as "no colour here" rather than as a pale colour,
which a flat fill at this size cannot do. */
.swatch.empty {
cursor: default;
background: #fff;
border-style: dashed;
border-color: var(--hair);
}
.stage-area {
flex: 1;
min-height: 0;
display: grid;
place-items: center;
overflow: auto;
padding: 14px;
}
/* The stage sits ON the work area rather than filling it, as it does in every
tool of this shape: the picture has edges and they are part of what you are
judging. */
.stage-wrap {
position: relative;
line-height: 0;
box-shadow: 0 1px 5px rgba(0, 0, 0, .35);
outline: 1px solid rgba(0, 0, 0, .45);
}
.stage { display: block; background: var(--stage); }
.paint-overlay { position: absolute; inset: 0; touch-action: none; }
.paint-overlay.drawing { cursor: crosshair; }
.paint-overlay circle { cursor: grab; }
/* --------------------------------------------------------------------------
params */
.section { border-bottom: 1px solid var(--hair); padding: 6px; }
.section:last-child { border-bottom: 0; }
.section > h2 {
margin: 0 0 5px;
font: inherit;
font-weight: 600;
color: var(--dim);
text-transform: uppercase;
letter-spacing: .07em;
}
/* Label / value, once, for every read-only fact in the pane. */
.facts { display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; margin: 0; }
.facts dt { color: var(--dim); }
.facts dd { margin: 0; overflow: hidden; text-overflow: ellipsis; }
.knob { display: grid; gap: 1px; margin-bottom: 6px; }
.knob .top-line { display: flex; justify-content: space-between; gap: 8px; }
.knob .top-line .name { color: var(--dim); }
.regeneration-debug {
padding: 4px 5px;
background: var(--sunk);
border: 1px solid var(--hair);
border-radius: 2px;
}
/* --------------------------------------------------------------------------
timeline */
.time { overflow: hidden; }
.tl-body {
flex: 1;
min-height: 0;
display: flex;
overflow: auto;
}
.tl-labels {
flex: 0 0 var(--label);
/* A flex item's `min-width` defaults to `auto`, which floors its used size at
its MIN-CONTENT width — so a row labelled with a uuid widens the whole
column and pushes the tracks out of alignment with the ruler above them.
`0` is what makes the declared basis the actual width. */
min-width: 0;
position: sticky;
left: 0;
z-index: 1;
background: var(--pane);
border-right: 1px solid var(--line);
}
.tl-tracks {
flex: 1;
min-width: 340px;
position: relative;
background: #fff;
/* The frame grid, five frames to a division, as a background rather than as
an element per frame: a 900-frame take is 900 divs nobody needs in the DOM.
`--tick` is five frames as a percentage of the span, set from the component
because only it knows how long the clip is. */
background-image:
repeating-linear-gradient(90deg,
var(--grid-5) 0 1px, transparent 1px var(--tick, 10%));
}
.tl-label,
.tl-track {
height: var(--row);
border-bottom: 1px solid var(--hair);
}
/* The row is the containing block for its own span and keys. Without this they
resolve against `.tl-tracks` instead and every row's marks land stacked on the
first one, full height — which reads as "the timeline draws nothing" rather
than as a positioning bug. */
.tl-track { position: relative; }
.tl-label {
display: flex;
align-items: center;
gap: 3px;
padding-right: 6px;
cursor: default;
white-space: nowrap;
overflow: hidden;
}
.tl-label.on { background: var(--sel-bg); }
.tl-label:hover:not(.on) { background: #fff; }
.tl-label .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.tl-label .kind { color: var(--dim); }
/* A fixed-width cell whether or not there is a triangle in it, so names at the
same depth line up down the column. */
.tl-twist {
flex: 0 0 13px;
padding: 0;
border: 0;
border-radius: 0;
background: none;
color: var(--dim);
cursor: pointer;
text-align: center;
}
.tl-twist:hover:not(:disabled) { background: none; color: var(--fg); }
.tl-twist:disabled { opacity: 0; cursor: default; }
.tl-ruler {
position: relative;
height: var(--ruler);
border-bottom: 1px solid var(--line);
background: var(--chrome);
cursor: pointer;
user-select: none;
}
.tl-ruler .tick {
position: absolute;
top: 0;
bottom: 0;
border-left: 1px solid var(--line);
padding-left: 3px;
color: var(--dim);
font-size: 10px;
line-height: var(--ruler);
}
/* The span the node exists over. Drawn under the keys so a dot on the first
frame of a span is not half-hidden by its own bar. */
.tl-span {
position: absolute;
top: 3px;
bottom: 3px;
background: var(--span);
border: 1px solid var(--span-line);
border-radius: 1px;
}
.tl-span.dense {
/* Generated, one value per frame: hatched, because ticking every frame would
be a solid block that says less than the bar behind it does. */
background: repeating-linear-gradient(
-45deg, var(--span) 0 3px, #c3d4ea 3px 6px);
}
/* Flash's keyframe: a filled dot. */
.tl-key {
position: absolute;
top: 50%;
width: 7px;
height: 7px;
margin: -3.5px 0 0 -3.5px;
background: var(--key);
border-radius: 50%;
}
.tl-playhead {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--playhead);
pointer-events: none;
z-index: 3;
}
.tl-playhead::before {
content: "";
position: absolute;
top: 1px;
left: -4px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--playhead);
}
.tl-empty { padding: 9px; color: var(--dim); }