arthur/static/arthur/app.css
Olive Vaughn 41b4bdf110 Make a symbol from a dropped video
Dropping footage on the stage or the timeline asks which frames and what name:
a dialog plays the video with start and end handles that seek it. Detection
runs on that range only (decoding stops at the end, frames before the start
are skipped) and the range is part of the analysis address, so a partial
analysis is never served as a whole one. The frozen take comes in as one
named symbol, carrying its sound as an audio node, placed where it was
dropped; tracking and tuning come with it when the document has no analysis
of its own.

clip/adopt copies symbols between documents, renaming ids that collide, and
clip/audio-tracks carries sounds out of nested instances so a placed symbol
is heard where it is placed.

Fixes the stage going blank after a conversion: ::store recomputed only when
the clip id changed, so blocks merged into the loaded entry were invisible to
the resolver. And the paint loop now schedules its next frame before
painting and reports a frame it cannot draw instead of stopping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:21:08 -04:00

727 lines
18 KiB
CSS

/* 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 */
/* Two folders, THIS PROJECT and ALL ASSETS, each holding groups. */
.pool-folder { margin-bottom: 8px; }
.pool-folder > summary {
cursor: pointer;
padding: 2px 0 4px;
font-weight: 600;
color: var(--fg);
}
.pool-folder > .pool-group { padding-left: 8px; }
.pool-project { margin-bottom: 2px; }
.pool-project > summary { cursor: pointer; color: var(--dim); padding: 1px 0; }
.pool-project > .pool-item { margin-left: 10px; width: calc(100% - 10px); }
.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); }
.pool-item .text { display: block; overflow: hidden; text-overflow: ellipsis; }
/* A media row leads with one frame of the video. */
.pool-item.media { display: flex; align-items: center; gap: 6px; }
.pool-item .thumb {
flex: 0 0 40px;
width: 40px;
height: 30px;
max-width: 40px;
max-height: 30px;
object-fit: cover;
background: var(--stage);
border-radius: 2px;
pointer-events: none;
}
/* 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;
}
/* One tab per open symbol: flat, on the chrome, with the open one marked by the
selection colour underneath rather than by a raised shape. */
.tabs {
display: flex;
align-items: stretch;
height: 24px;
flex: 0 0 24px;
padding: 0 4px;
background: var(--chrome);
border-bottom: 1px solid var(--line);
overflow-x: auto;
}
.tab {
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px 0 10px;
color: var(--dim);
border-right: 1px solid var(--hair);
cursor: pointer;
white-space: nowrap;
}
.tab:hover { color: var(--fg); background: var(--sunk); }
.tab.on { color: var(--fg); background: var(--pane); box-shadow: inset 0 -2px 0 var(--sel); }
.tab .close {
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 2px;
background: none;
color: var(--dim);
line-height: 16px;
visibility: hidden;
}
.tab:hover .close, .tab.on .close { visibility: visible; }
.tab .close:hover { background: var(--hair); color: var(--fg); }
.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; }
/* Where a drag out of the pool would land. Dashed and unfilled, so it reads as
"not there yet" over whatever is already drawn. */
/* Never a hit target: it is drawn under the pointer, and anything under the
pointer that changes mid-drag gets a dragenter of its own. */
.paint-overlay .ghost { pointer-events: none; }
.paint-overlay .ghost > * {
fill: none;
stroke: #fff1be;
stroke-width: 1;
stroke-dasharray: 3 2;
vector-effect: non-scaling-stroke;
}
/* --------------------------------------------------------------------------
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);
}
/* The preview row of a drop in flight: its own length, where it would start. */
.tl-span.ghost, .tl-label.ghost { pointer-events: none; }
.tl-span.ghost {
background: transparent;
border: 1px dashed var(--sel);
}
.tl-label.ghost { color: var(--sel); font-style: italic; }
/* 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); }
/* --------------------------------------------------------------------------
the video -> symbol dialog */
.convert-scrim {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background: rgba(0, 0, 0, .35);
}
.convert {
width: min(560px, calc(100vw - 32px));
background: var(--pane);
border: 1px solid var(--line);
border-radius: 3px;
box-shadow: 0 6px 24px rgba(0, 0, 0, .3);
}
.convert-body { display: grid; gap: 8px; padding: 10px; }
.convert-video {
width: 100%;
max-height: 46vh;
background: var(--stage);
border-radius: 2px;
}
/* The range: the kept frames lit, a handle at each end. */
.convert-range {
position: relative;
height: 18px;
margin: 0 7px;
background: var(--sunk);
border: 1px solid var(--hair);
border-radius: 2px;
touch-action: none;
}
.convert-kept {
position: absolute;
top: 0;
bottom: 0;
background: var(--sel-bg);
border-left: 1px solid var(--sel);
border-right: 1px solid var(--sel);
}
.convert-handle {
position: absolute;
top: -3px;
bottom: -3px;
width: 10px;
margin-left: -5px;
background: var(--sel);
border-radius: 2px;
cursor: ew-resize;
}
.convert-name { display: flex; align-items: center; gap: 8px; }
.convert-name input { flex: 1; }
.convert-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--hair);
}