arthur/static/arthur/app.css
Your Name 357ff1f3b9 Move a peg's pivot without moving what hangs off it
Two bugs in the handles, and the second one meant a peg still had no movable
pivot — which is the complaint pegs exist to answer.

`.peg-grab` was drawn LAST, on top of the four scale corners, and at r=7 against
corners spanning 5.2 to 8.8 from the middle it swallowed the inner half of each
one. It is drawn first now, at r=5, so it stops short of them.

And dragging a peg's cross is a TRANSLATE, not a new pivot. It writes the peg's
`pos`, and a peg is a parent, so everything under it comes along — `peg.mjs` said
so all along: "dragging the peg cross moved its child — dx=18.000". That is the
right behaviour for the drag and no way to say "put the pivot here", so a peg
could be made with its pivot wherever `nest/peg` happened to put it and never
moved again.

⌥-drag now relocates it, which is After Effects' pan-behind split. `nest/repivot`
moves the peg and solves

    pinv(child)' = local'⁻¹ · local · pinv(child)

for each child, because only `local(peg) · pinv(child)` reaches a child, so
preserving that product holds the picture exactly still — verified as a drift of
0, both in `nest-test` and through real ⌥-pointer events in `peg.mjs`. The
child's own channels are never touched, so this works over a measured child,
which is the case the peg exists for.

Refused on a peg whose position is animated, rather than quietly wrong: the
compensation depends on the peg's own transform, so a keyed position wants a
different `pinv` on every frame and one stored matrix is not it. The message says
to put a peg over it instead.

One edit at the end of the drag, not per pointermove: a repivot moves nothing on
screen by construction, so only the cross needs to follow the pointer.

603 CLJS tests, and peg.mjs and onion.mjs pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 19:25:04 -04:00

1815 lines
64 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 SECOND accent because selection and effective creation target are two
facts that are often true of one node at the same time.
Violet rather than another blue: in the chrome it has to be told apart
from --sel, and on the stage from the warm gold the handles are drawn in.
Colour is not the only channel either — the target outline is SOLID where the
selection's box is dashed, and it carries no handles at all, because there
is nothing about being a creation parent that you could drag. --target-stage is the
same hue lifted to read against --stage, which is the one dark surface. */
--target: #8d4bd6;
--target-stage: #c79bf2;
/* A THIRD, and the last. SELECTED IN THE TIMELINE had been --sel-bg, which is
#cfe0f5 against spans drawn in #dfe8f4: two pale blues a hair apart, in the
one place where which row and which clip is selected decides what the pane
is showing — an expanded lane opens exactly the selected clip. The fix is
not a stronger blue, which stays in the family the spans already own, but a
different hue entirely. Ochre: warm against every cool surface in the pane,
clear of --target's violet, --live's green and the playhead's red, and dark
enough at full strength to carry white type on a 21px block. */
--pick: #a85c00;
--pick-bg: #ffd9a0;
--pick-fg: #4a2800;
/* Auto-key is a recording state, distinct from selection and creation target. */
--live: #239447;
--live-bg: #e2f5e7;
/* 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 */
/* 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%;
--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 var(--grip-time) var(--time-h);
grid-template-areas:
"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);
}
.top { grid-area: top; }
.pool { grid-area: pool; }
.view { grid-area: view; }
.params { grid-area: params; }
.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
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;
/* One line, always. A strip of controls that reflows to two lines resizes the
pane under it, and the pane under this one is the timeline. */
white-space: nowrap;
}
.pane-head .readout { color: var(--fg); }
/* A pick-one that looks like the other pick-ones. A native select in this strip
draws a tall white well and a chevron of the browser's choosing, which reads
as a form field among buttons; the app's own idiom for "choose one of these"
is `label ▾`, and this IS one of those. So: the select keeps the behaviour and
the keyboard, and borrows the look. The arrow is the wrapper's, because an
option list cannot carry one. */
.pick { position: relative; display: inline-flex; align-items: center; }
.pick > select {
appearance: none;
background: var(--pane);
padding: 1px 15px 1px 6px;
cursor: pointer;
}
.pick > select:hover { background: #fff; }
.pick::after {
content: "▾";
position: absolute;
right: 5px;
color: var(--dim);
pointer-events: none;
}
.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); }
/* Buttons that are one control: a transport, a stepper, a mode picker. They
share their borders, so the group reads as a single object with parts rather
than as several things that happen to be adjacent — which is the whole claim
a segmented control makes. `.seg` is `.group` with that meaning; both are drawn
because the difference is what the buttons do, not how they look.
The negative margin collapses the doubled border between two buttons into
one hairline. A pressed button then has to be raised, or the neighbour's
plain border covers half of its accent one. */
.group, .seg { display: flex; align-items: center; }
.group > button, .seg > button { border-radius: 0; margin-left: -1px; }
.group > button:first-child, .seg > button:first-child { margin-left: 0; border-radius: 2px 0 0 2px; }
.group > button:last-child, .seg > button:last-child { border-radius: 0 2px 2px 0; }
.group > button.on, .seg > button.on { position: relative; z-index: 1; }
/* The rule between two groups of unrelated controls. Shorter than the strip, so
it reads as a division of the row rather than as a wall across it. */
.sep { flex: 0 0 1px; width: 1px; height: 13px; background: var(--line); }
/* An icon button. The glyph is inked in `currentColor`, so `.on` colours it
with the same rule that colours a word. 11px to sit on 11px type. */
button.ico { padding: 2px 5px; line-height: 0; }
button.ico > svg { display: block; width: 11px; height: 11px; }
/* Play is the one control in the strip you aim at without looking. */
button.ico-play { padding-left: 8px; padding-right: 8px; }
/* A number that changes every frame. Tabular figures stop it twitching, and
stop the controls after it being nudged about as the count passes 9 and 99. */
.readout { font-variant-numeric: tabular-nums; }
/* The answer to a refusal rather than a standing command — `lane-retry` only
exists because something was declined and named this as the way through. */
button.retry { border-color: var(--sel); color: var(--sel); }
/* --------------------------------------------------------------------------
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);
}
.project-title { max-width: 22vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-name { width: 180px; }
.export-scrim { position: fixed; inset: 0; z-index: 50; background: rgba(0, 0, 0, .45); }
.export-dialog {
position: fixed; z-index: 51; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(360px, calc(100vw - 32px)); padding: 14px;
background: var(--pane); border: 1px solid var(--line); border-radius: 3px;
box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
}
.export-dialog h2 { margin: 0 0 12px; font: inherit; font-weight: 600; }
.export-field { display: grid; gap: 4px; margin: 8px 0; color: var(--dim); }
.export-field select { color: var(--fg); }
/* --------------------------------------------------------------------------
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); }
/* Rows about the document rather than choices in it: who can edit. */
.menu-item.static { display: flex; align-items: center; gap: 6px; cursor: default; }
.menu-item.static:hover { background: none; }
.menu-item.static .sub { display: inline; margin-left: auto; }
.menu-item.static button { margin-left: auto; }
.menu .row { margin: 4px 0; flex-wrap: nowrap; }
.menu .row input { flex: 1; min-width: 0; }
.menu p { margin: 4px 0; }
.account { display: grid; gap: 4px; }
button.link { border: 0; background: none; padding: 0; color: var(--sel); cursor: pointer; }
/* `/`: your projects, over the editor. */
.index { position: fixed; inset: 0; z-index: 30; display: flex; flex-direction: column;
background: var(--pane); }
.index-body { width: min(640px, 100%); margin: 0 auto; padding: 24px 16px; overflow: auto; }
.index-body h1 { font: inherit; font-size: 18px; font-weight: 600; margin: 0 0 12px; }
.index-head { display: flex; align-items: baseline; justify-content: space-between; }
.index-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--hair); }
.index-list li { display: flex; justify-content: space-between; gap: 12px;
padding: 7px 2px; border-bottom: 1px solid var(--hair); }
.index-list a { color: var(--fg); text-decoration: none; font-weight: 600; }
.index-list a:hover { color: var(--sel); }
a.brand { text-decoration: none; }
/* Undo, its list, redo: one group. */
.undo { display: flex; gap: 0; }
.undo > button { border-radius: 0; }
.undo > button:first-child { border-radius: 2px 0 0 2px; }
.undo > .undo-list { padding: 0 4px; margin-right: 4px; border-left: 0; border-radius: 0 2px 2px 0; }
.menu.menu-left { left: 0; right: auto; }
/* A menu dropping out of a PANE HEAD. Fixed and placed by `ui/menu` against the
button, because a pane clips its own overflow and an absolutely positioned
panel would be cut off at the pane's edge — invisibly, and more of it the
longer the menu gets. `top`/`bottom`/`left`/`max-height` all come from there.
Wide enough that a command's one-line explanation is one line. At 215px every
`.sub` in the cel menu wrapped, which doubled the panel's height for no gain:
the explanations are the reason these are menu rows and not buttons, so they
get the width to be read in. */
.menu.menu-drop {
position: fixed;
/* BOTH offsets on each axis are reset, and `ui/menu` sets exactly one of each
inline. Leaving `.menu`'s own `top` standing alongside an inline `bottom`
over-constrains the box, and a fixed box given both offsets takes its HEIGHT
from them — which, from a toolbar near the foot of the window, computes
negative and collapses the panel to its padding with the commands still
inside it. */
top: auto;
bottom: auto;
left: auto;
right: auto;
width: 272px;
overflow: auto;
}
.menu-note { padding: 1px 6px 5px; }
/* A disabled row still says what the command is FOR. That is the whole trade:
a greyed button hides its reason in a `title`, a greyed row prints it. */
.menu-item:disabled { opacity: .5; cursor: default; }
.menu-item:disabled:hover { background: none; }
.menu-item { padding: 3px 6px; }
.menu-item .sub { font-size: 10px; line-height: 1.3; white-space: normal; }
/* The one way to hand the document to somebody else, so it looks like one. */
button.share-button { background: var(--sel); color: #fff; border-color: var(--sel); font-weight: 600; padding: 1px 12px; }
button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
/* Who else has this document open. */
.roster { display: flex; gap: 2px; align-items: center; }
.peer {
width: 18px; height: 18px; border-radius: 50%;
display: inline-grid; place-items: center;
background: var(--sel); color: #fff; font-size: 10px; font-weight: 600;
}
.peer.guest { background: var(--dim); }
/* --------------------------------------------------------------------------
media pool
A FILE LIST, AND IT IS 210px WIDE. Every row is one line — a picture at the
stage's own proportions, a name, one number — because the pane under this one
is the timeline and a row that costs two lines costs timeline rows. The row is
--row tall, which is a timeline row, so a pool of symbols and the rows they
end up on are the same size object.
One exception, deliberately: the main timeline. See `.pool-row.main`. */
/* The search strip, between the pane head and the tabs. Its own band rather
than crowded into the head, which is one line and already holds the + . */
.pool-find {
position: sticky;
top: 21px;
z-index: 2;
display: flex;
align-items: center;
gap: 4px;
padding: 3px 5px;
background: var(--chrome);
border-bottom: 1px solid var(--hair);
}
.pool-search {
flex: 1;
min-width: 0;
height: 17px;
padding: 0 5px;
font: inherit;
color: var(--fg);
background: var(--pane);
border: 1px solid var(--line);
border-radius: 2px;
}
/* Safari draws its own clear button inside a search input, in its own idiom and
at its own size. The one next to it is this file's. */
.pool-search::-webkit-search-decoration,
.pool-search::-webkit-search-cancel-button { -webkit-appearance: none; }
.pool-clear {
flex: 0 0 auto;
width: 17px;
height: 17px;
padding: 0;
border: 0;
border-radius: 2px;
background: none;
color: var(--dim);
cursor: pointer;
}
.pool-clear:hover { background: var(--hair); color: var(--fg); }
/* The scope tabs, in the vocabulary `ui/tabs` already established for symbols —
the same object, so two rows of tabs on one screen read as one idea. */
.pool > .tabs { top: 45px; position: sticky; z-index: 2; }
/* A tally, shown only on the tab you are NOT looking at and only while a search
is running: it says "what you asked for is over here". */
.tab .count {
padding: 0 4px;
border-radius: 7px;
background: var(--sel-bg);
color: var(--sel);
font-variant-numeric: tabular-nums;
}
/* MEDIA, SOUNDS, TIMELINES. A heading is one line and it sticks, so scrolling a
long list never leaves you looking at rows with no idea what kind they are. */
.pool-section { margin-bottom: 7px; }
.pool-section > h2 {
position: sticky;
top: 69px;
z-index: 1;
margin: 0 0 2px;
padding: 1px 5px;
font: inherit;
font-weight: 600;
color: var(--dim);
text-transform: uppercase;
letter-spacing: .07em;
background: var(--pane);
}
.pool-section > .dim { padding: 0 5px 2px; }
.pool-rows { display: flex; flex-direction: column; }
/* What a search found nothing to say. */
.pool-empty { padding: 6px 5px; color: var(--dim); }
.pool-project { margin: 0 0 1px 5px; }
.pool-project > summary { cursor: pointer; color: var(--dim); padding: 1px 0; }
.pool-project > .pool-rows { margin-left: 8px; }
/* THE ROW IS THE BUTTON. Click selects, double-click opens, and drag starts
anywhere across it — the whole strip, not the few characters of the name. The
wrapper carries the state classes and the frame; the button inside it fills
that frame edge to edge, so there is no dead strip anywhere along the row. */
.pool-row {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
height: var(--row);
padding: 0 4px;
border: 1px solid transparent;
border-radius: 2px;
}
.pool-row:hover:not(.disabled) { background: #fff; }
.pool-row.on { background: var(--sel-bg); border-color: var(--sel); }
/* WHICH ONE IS OPEN, as a mark and not as the word "open" — a list this narrow
cannot spend twenty pixels of every name on a word that is true of one row.
The accent, because selection is the only thing it is ever used for and the
open symbol is what the rest of the screen is showing. */
.pool-row.open { box-shadow: inset 2px 0 0 var(--sel); }
.pool-item {
display: flex;
align-items: center;
gap: 5px;
flex: 1;
min-width: 0;
padding: 0;
text-align: left;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: grab;
}
/* Shrinks to the name, so the pencil sits against the end of the word rather
than out at the edge of the pane — and ellipses rather than pushing the
number off the row. */
.pool-item .text { display: flex; flex-direction: column; flex: 0 1 auto; min-width: 0; }
.pool-item .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The number, at the far edge and never moving: `auto` on the left is what
pushes it there, and tabular figures are what stop it shuffling as the rows
scroll past. */
.pool-item > .sub {
flex: 0 0 auto;
margin-left: auto;
padding-left: 4px;
color: var(--dim);
font-variant-numeric: tabular-nums;
}
/* Every row leads with a picture, and they are all this box, so the names line
up down the list whatever is beside them. 16:10 is the stage's own ratio at
320x200 — a thumbnail that is not the picture's shape is a thumbnail you have
to think about. */
.pool-item .thumb {
flex: 0 0 32px;
width: 32px;
height: 20px;
object-fit: cover;
background: var(--stage);
border-radius: 1px;
pointer-events: none;
}
.pool-item .thumb.sound {
display: flex;
align-items: center;
justify-content: center;
color: #d9d9d9;
}
/* THE MAIN TIMELINE, under the PROJECT heading and at the top of the pane.
`clip/opens-on`'s answer: the longest symbol nothing else places, which is
where the work happens. The one row in the pane given size, a second line and
a surface of its own — spent here rather than spread over every row, which is
what keeps the list below it a list. */
.pool-row.main {
height: auto;
margin-bottom: 5px;
padding: 4px;
background: var(--sunk);
border-color: var(--hair);
}
.pool-row.main .thumb {
flex: 0 0 64px;
width: 64px;
height: 40px;
}
.pool-row.main .name { font-weight: 600; }
/* Both facts on one line under the name. Beside a 64px picture there is room,
and this is the one row where a person wants more than the length before
they click. */
.pool-row.main .detail {
color: var(--dim);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The pencil. A span and not a button — see `ui/pool`'s `row` — so it needs its
own centring and cursor rather than inheriting a control's.
Hidden until the row is under the pointer or holds focus, because a column of
twenty pencils is a column of twenty pencils; `visibility` rather than
`display`, so it keeps its width and the name beside it does not reflow as the
pointer crosses the list. */
.pool-rename {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 15px;
width: 15px;
height: 15px;
border-radius: 2px;
color: var(--dim);
line-height: 1;
cursor: pointer;
visibility: hidden;
}
.pool-row:hover .pool-rename,
.pool-row:focus-within .pool-rename { visibility: visible; }
.pool-rename:hover { background: var(--hair); color: var(--fg); }
/* Renaming. The input REPLACES the row at the same height, so a list does not
jump while you type in it. */
.pool-name {
flex: 1;
min-width: 0;
height: 17px;
padding: 0 4px;
font: inherit;
color: var(--fg);
background: #fff;
border: 1px solid var(--sel);
border-radius: 2px;
}
/* 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); }
.options-bar {
display: flex;
align-items: center;
gap: 9px;
padding: 4px 8px;
background: var(--chrome);
border-bottom: 1px solid var(--line);
}
.auto-key.on {
color: #145c2b;
background: var(--live-bg);
border-color: var(--live);
box-shadow: 0 0 7px rgba(35, 148, 71, .72), inset 0 0 0 1px rgba(255, 255, 255, .7);
}
.swatches { display: flex; gap: 3px; }
.palette-slot { display: flex; align-items: center; }
.palette-picker { display: none; }
.thumb i { display: block; min-width: 1px; min-height: 1px; }
/* 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: flex;
flex-direction: column;
/* `safe`, because the stage zooms: a centred 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. */
align-items: safe center;
justify-content: safe center;
overflow: auto;
padding: 14px;
}
.stage-target {
flex: 0 0 auto;
margin-top: -1px;
padding: 3px 9px 4px;
border: 1px solid var(--line);
border-top: 0;
border-radius: 0 0 3px 3px;
background: var(--pane);
color: var(--dim);
}
.stage-target strong { color: var(--fg); font-weight: 600; }
/* 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; }
.stage-overlay-wrap {
position: absolute;
left: -100%; top: -100%;
width: 300%; height: 300%;
}
.stage-shade {
position: absolute;
inset: 0;
pointer-events: none;
outline: 1px solid #aaa;
}
/* The footage traced over: a reference above the picture, never part of it. */
.tracing { position: absolute; inset: 0; pointer-events: none; }
/* 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; }
/* The footage slider shares its row with the word "opacity", so it takes what is
left rather than the full width the generic range rule gives it. */
.trace-opacity { flex: 1; min-width: 0; }
.section > h2 {
margin: 0 0 5px;
font: inherit;
font-weight: 600;
color: var(--dim);
text-transform: uppercase;
letter-spacing: .07em;
}
.inspector-form { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 7px; }
.inspector-field { display: grid; gap: 2px; color: var(--dim); }
.inspector-field input { width: 100%; color: var(--fg); }
.correction-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.correction-grid select, .correction-grid input { width: 100%; color: var(--fg); }
.correction-list, .correction-conflicts { display: grid; gap: 4px; margin-top: 7px; }
.correction-item { display: flex; align-items: center; gap: 4px; min-width: 0; }
.correction-item > span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 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;
}
/* --------------------------------------------------------------------------
the location bar
Where you are, above the timeline that draws it. Chrome-coloured like a pane
head, because that is what it is to the two temporal views below it — but it
is not one of their heads, since it says the same thing whichever is showing. */
.loc {
display: flex;
align-items: center;
gap: 7px;
padding: 0 7px;
min-width: 0;
background: var(--chrome);
color: var(--dim);
white-space: nowrap;
overflow: hidden;
}
.crumbs { display: flex; align-items: center; gap: 1px; min-width: 0; overflow: hidden; }
/* A crumb is a place, not a command: no border and no fill until it is pointed
at. The trail has to read as one sentence, and five outlined buttons in a row
read as five things to press. */
.crumb {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
padding: 1px 4px;
border: 1px solid transparent;
border-radius: 2px;
background: none;
color: var(--dim);
}
.crumb:hover:not(:disabled) { background: var(--sel-bg); color: var(--fg); }
/* The last crumb is the selection itself. Weight and full contrast say so; the
fill and accent border `button.on` would otherwise give it are undone here,
because a filled crumb reads as a pressed control and the thing it marks is
where you ARE, not something switched on. */
.crumb.on { color: var(--fg); font-weight: 600; background: none; border-color: transparent; }
.crumb.lane::before { content: "≡ "; color: var(--dim); font-weight: 400; }
.crumb-sep { flex: 0 0 auto; color: var(--line); }
.loc-fact { flex: 0 0 auto; }
.loc-fact::before, .loc-shared::before { content: "· "; color: var(--line); }
.loc-shared { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 5px; }
.loc .spacer { flex: 1; }
.creation-controls button { display: inline-flex; align-items: center; gap: 4px; }
.creation-controls svg { width: 11px; height: 11px; }
/* --------------------------------------------------------------------------
timeline */
.time { overflow: hidden; }
.tl-body {
flex: 1;
min-height: 0;
display: flex;
/* Not `stretch`: that sizes both columns to the body's height, the rows then
overflow them, and the sticky ruler goes out with its column. Each column is
as tall as its rows instead, and at least as tall as the body. */
align-items: flex-start;
overflow: auto;
/* THE GUTTER IS ALWAYS THERE, whether or not there is a scrollbar in it. Every
mark in this pane is a percentage of the tracks column, and the column is
what is left of the body after the labels -- so a vertical scrollbar
appearing took 15px out of the width and moved the ruler, the frame grid,
every clip and the playhead sideways, mid-gesture. Expanding a lane's portal
adds seven rows at once, which is exactly when a person is looking at the
frame they are aiming for. */
scrollbar-gutter: stable;
}
.tl-labels,
.tl-tracks { min-height: 100%; }
.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;
/* Over the ruler and the playhead, which both pass under it when the tracks
scroll sideways. */
z-index: 4;
background: var(--pane);
border-right: 1px solid var(--line);
}
.tl-tracks {
flex: 1;
/* 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 major frame grid, using the same whole-second interval as the numbered
ruler. It is a background rather than an element per mark; `--tick` is set
by the component because only it knows the open symbol's length and FPS. */
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 {
/* Scrolled to from the stage: clear of the sticky ruler above. */
scroll-margin-top: var(--ruler);
display: flex;
align-items: center;
gap: 3px;
padding-right: 6px;
cursor: default;
white-space: nowrap;
overflow: hidden;
}
/* Selected, in the timeline's own colour. The bar down the left edge is the
half that survives at a glance: a row 21px tall scrolled past in a column of
twenty reads its left edge before it reads its fill. */
.tl-label.on {
background: var(--pick-bg);
box-shadow: inset 3px 0 0 var(--pick);
color: var(--pick-fg);
}
.tl-label.on .kind { color: var(--pick); }
.tl-label.target { box-shadow: inset 0 0 0 2px var(--target); }
/* Both at once, and they must still be two readable facts: the target ring sits
inside the pick's edge rather than replacing it. */
.tl-label.on.target { box-shadow: inset 3px 0 0 var(--pick), inset 0 0 0 2px var(--target); }
.tl-track.target::after {
content: "";
position: absolute;
inset: 0;
border: 2px solid var(--target);
pointer-events: none;
z-index: 4;
}
.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 lane's row: the strip glyph, a faint warm ground under the name, and a
track behind its blocks — enough that a lane is told from the instance rows
around it while scanning, and not so much that it shouts. */
.tl-ico {
flex: 0 0 12px;
line-height: 0;
color: var(--dim);
}
.tl-ico > svg { display: block; width: 11px; height: 11px; }
.tl-label.lane { background: #edeae2; }
.tl-label.lane .name { font-weight: 600; }
.tl-label.lane.on { background: var(--pick-bg); }
.tl-label.lane.on .tl-ico { color: var(--pick); }
.tl-label.lane:hover:not(.on) { background: #f8f6f0; }
.tl-track.lane { background: rgba(0, 0, 0, 0.035); }
.tl-name-input { min-width: 0; flex: 1; font: inherit; }
.tl-rename { margin-left: auto; padding: 0 3px; border: 0; background: none; color: var(--dim); }
.tl-rename:hover { color: var(--fg); }
/* 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-delete { margin-left: auto; padding: 0 4px; border: 0; background: none; color: var(--dim); }
.tl-delete:hover { color: var(--fg); }
.tl-solo, .tl-trace { margin-left: auto; padding: 0 4px; border: 0; background: none; color: var(--dim); font-size: 10px; }
.tl-solo:hover, .tl-trace:hover { color: var(--fg); }
.tl-solo.on, .tl-trace.on { color: var(--sel); font-weight: 600; }
.tl-solo + .tl-delete, .tl-trace + .tl-solo { margin-left: 0; }
/* The ruler and the corner above the labels stay at the top of the body while
the rows scroll under them: the ruler is the scrubber, and scrolling down to
a row is no reason to lose it. */
.tl-corner,
.tl-ruler {
position: sticky;
top: 0;
z-index: 2;
height: var(--ruler);
border-bottom: 1px solid var(--line);
background: var(--chrome);
}
.tl-ruler {
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 heading between the picture's rows and the sounds', as an editor puts
its audio tracks under its video tracks. */
.tl-label.tl-section,
.tl-track.tl-section { background: var(--chrome); border-top: 1px solid var(--line); }
.tl-label.tl-section { padding-left: 6px; color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
/* A sound's bar, told apart from the picture's at a glance. */
.tl-span.sound { background: #e3efdf; border-color: #a6c49b; }
/* A tracing layer is a reference, never exported: hatched and dashed so it
cannot be read as a drawing. */
.tl-span.trace, .tl-cel.trace {
background: repeating-linear-gradient(135deg, transparent 0 4px, rgba(0, 0, 0, 0.07) 4px 8px);
border: 1px dashed var(--span-line);
}
.pool-item .thumb.sound { display: flex; align-items: center; justify-content: center; color: #d9d9d9; }
/* 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, .tl-cel.ghost { pointer-events: none; }
.tl-cel.ghost { background: transparent; border: 1px dashed var(--sel); color: var(--sel); }
/* A row being dragged over another: into an instance, or grouped with a node,
by its middle; in front of it or behind it, by its top or bottom edge. */
.tl-label.drop-into { box-shadow: inset 0 0 0 2px var(--sel); background: var(--sel-bg); }
.tl-label.drop-group { outline: 2px dashed var(--sel); outline-offset: -2px; }
.tl-label.drop-front { box-shadow: inset 0 2px 0 var(--sel); }
.tl-label.drop-back { box-shadow: inset 0 -2px 0 var(--sel); }
/* Selected: the clip block and the plain bar alike, in the pick colour. A clip
block had no selected state at all, which is why the portal under an expanded
lane looked like it opened at random. */
.tl-cel.on {
background: var(--pick);
border-color: var(--pick);
color: #fff;
font-weight: 600;
}
.tl-cel.on:hover:not(:disabled) { background: var(--pick); filter: brightness(1.12); }
.tl-span.on { background: var(--pick-bg); border-color: var(--pick); }
.tl-cel.target, .tl-span.target { outline: 2px solid var(--target); outline-offset: -2px; }
.tl-span.on.dense {
background: repeating-linear-gradient(
-45deg, var(--pick-bg) 0 3px, #f0bf77 3px 6px);
}
/* A node's bar slides it along its symbol's time. */
.tl-span.movable { cursor: ew-resize; touch-action: none; }
.tl-span.movable.sliding { border-color: var(--sel); }
.tl-edge {
position: absolute;
top: -1px;
bottom: -1px;
width: 7px;
cursor: e-resize;
touch-action: none;
z-index: 2;
}
.tl-edge.out { right: -3px; }
.tl-edge.in { left: -3px; }
.tl-junction {
position: absolute;
top: -1px;
/* Centred on the CUT, which is the clips' shared border-box edge. A clip block
is a button with a 1px border, so `left: 0` is one pixel inside it and the
handle sat a pixel to the right of the line it drags. */
left: -1px;
bottom: -1px;
width: 12px;
transform: translateX(-50%);
cursor: col-resize;
touch-action: none;
z-index: 3;
}
/* `nowrap` is load-bearing. A block narrower than its name wrapped it at the
hyphen — "symbol-" then "8" — and the button is `overflow: visible` so that
second line spilled out below the track as a row of loose digits under the
lane, which read as corrupt timing rather than as a long name in a short
block. One line, clipped. */
.tl-cel-label {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
}
.tl-span.ghost {
background: transparent;
border: 1px dashed var(--sel);
}
.tl-label.ghost { color: var(--sel); font-style: italic; }
/* Passive animation ticks leave the clip underneath available to grab. */
.tl-key {
position: absolute;
top: 50%;
width: 2px;
height: 6px;
margin: -3px 0 0 -1px;
background: var(--key);
opacity: 0.5;
border-radius: 1px;
/* So grabbing a dot grabs the bar under it. */
pointer-events: none;
}
.tl-playhead {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--playhead);
pointer-events: none;
z-index: 3;
}
/* The playhead's head, in the ruler rather than on the line, so it stays at the
top with the ruler while the rows scroll. */
.tl-knob {
position: absolute;
pointer-events: none;
margin-left: -4px;
top: 1px;
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);
}
/* A transform or visibility channel: its key button, then a field per component. */
.facts dd.channel { display: flex; gap: 3px; align-items: center; overflow: visible; }
.facts dd.channel input[type=number] { width: 0; flex: 1; min-width: 0; }
.facts dd.channel .key { padding: 0 3px; color: var(--dim); }
.facts dd.channel .key.keyed { color: var(--fg); }
.facts dd.channel .key.on { color: var(--sel); }
.facts dd.channel .channel-swatches {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 3px;
min-width: 0;
}
.facts dd.channel .channel-swatches .swatch { flex: 0 0 15px; }
.facts dd.channel.live {
margin: -2px;
padding: 2px;
border-radius: 3px;
background: var(--live-bg);
box-shadow: 0 0 0 1px rgba(35, 148, 71, .45), 0 0 6px rgba(35, 148, 71, .28);
}
.facts dd.channel.live input { border-color: var(--live); }
/* The selected node's transform box, in stage pixels (the SVG's viewBox). */
.paint-overlay .handles .box { fill: none; stroke: #e6ca8b; stroke-width: 0.5; stroke-dasharray: 2 1; pointer-events: none; }
.paint-overlay .handles .knob-arm { stroke: #e6ca8b; stroke-width: 0.5; pointer-events: none; }
.paint-overlay .handles .knob { fill: #161820; stroke: #fff1be; stroke-width: 0.6; cursor: grab; }
.paint-overlay .handles .corner { fill: #fff1be; stroke: #161820; stroke-width: 0.5; cursor: nwse-resize; }
.paint-overlay .handles .pivot { stroke: #fff1be; stroke-width: 0.6; pointer-events: none; }
/* A peg draws nothing, so the cross is the only thing to grab it by. */
.paint-overlay .handles .peg-grab { fill: transparent; stroke: none; cursor: move; }
/* While an alt-drag is placing it. */
.paint-overlay .handles .pivot.moving { stroke: #7ad7a0; stroke-width: 0.9; }
.paint-overlay .marquee { fill: rgba(230, 202, 139, 0.12); stroke: #e6ca8b; stroke-width: 0.6; stroke-dasharray: 2 1; pointer-events: none; }
/* The creation-target outline and its name tag. Solid, no handles, and a tag pinned just
outside the bottom-right corner — see `ui/stage/creation-box` for why the box
rotates with the node and the tag does not. Sizes are in user units because
this SVG's viewBox is the stage's 320x200 scaled by `zoom`. */
.paint-overlay .creation-target { pointer-events: none; }
.paint-overlay .selected-paint-outline { fill: none; stroke: #fbbf24; stroke-width: 0.8; vector-effect: non-scaling-stroke; pointer-events: none; }
.paint-overlay .creation-target .creation-target-box { fill: none; stroke: var(--target-stage); stroke-width: 0.7; }
.paint-overlay .creation-target .creation-target-tag-bg { fill: var(--target-stage); }
.paint-overlay .creation-target .creation-target-tag {
fill: #1b1030;
font: 600 3.4px ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.04px;
}
.paint-overlay:focus { outline: none; }
/* What a drag in flight would do, beside the pointer: a clip body moves in
time, and with shift it goes inside the clip under the pointer. Said next to
the cursor because that is where the eye already is mid-gesture. */
.tl-hint {
position: fixed;
z-index: 60;
padding: 2px 6px;
border-radius: 3px;
background: var(--fg);
color: var(--bg);
font-size: 11px;
white-space: nowrap;
pointer-events: none;
}
.tl-hint.nesting { background: var(--sel); color: #fff; }
/* The clip a shift-drag would nest into. Inset, so it reads as "into this"
rather than as a boundary between rows. */
.tl-cel.nest-target { outline: 2px solid var(--sel); outline-offset: -2px; }
/* A row inside a held clip: shown because the node is on screen for the whole
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, .options-bar { gap: 7px; }
.options-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 > *, .options-bar > * { flex-shrink: 0; }
.top button, .pane-head button { white-space: nowrap; }
.pane-head, .options-bar { overflow-x: auto; }
/* The hints go first: the tool's name and its controls are what is needed. */
.options-bar .hint { display: none; }
}
/* --------------------------------------------------------------------------
the tools: a strip down the left of the stage, an options bar above it.
Photoshop's arrangement, which Illustrator and Flash share. The palette is a
grid in the strip under the tools, as Deluxe Paint's and Animator Pro's were:
a fixed table of slots, shown as one. */
.options-bar {
gap: 8px;
min-height: 29px;
padding: 3px 8px;
}
.options-bar .tool-name { font-weight: 600; min-width: 46px; }
.options-bar .hint { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 0 1 auto; }
.options-bar .spacer { flex: 1; }
.option { display: flex; align-items: center; gap: 6px; color: var(--dim); white-space: nowrap; }
.option input[type="range"] { width: 110px; }
.option .value { color: var(--fg); min-width: 30px; font-variant-numeric: tabular-nums; }
.palette-assets { display: flex; align-items: center; gap: 3px; }
.workspace { flex: 1; min-height: 0; display: flex; }
.workspace > .stage-area { flex: 1; min-width: 0; position: relative; }
.toolbox {
flex: 0 0 52px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 7px 0;
background: var(--chrome);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.tools { display: grid; gap: 2px; }
.tool {
width: 34px; height: 30px;
display: grid; place-items: center;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: none;
color: var(--fg);
}
.tool:hover { background: var(--sunk); border-color: var(--hair); }
.tool.on { background: var(--sel-bg); border-color: var(--sel); color: var(--sel); }
.tool svg { fill: currentColor; stroke: none; }
.tool svg .cut { fill: none; stroke: var(--chrome); stroke-width: 1.2; }
.tool.on svg .cut { stroke: var(--sel-bg); }
.palette { display: grid; gap: 7px; justify-items: center; padding-top: 9px; border-top: 1px solid var(--hair); }
/* The colour a new shape gets: Photoshop's foreground chip. */
.palette .chip {
width: 36px; height: 36px;
display: grid; place-items: end start;
border: 1px solid var(--fg);
border-radius: 3px;
box-shadow: inset 0 0 0 2px var(--pane);
}
.palette .chip span {
margin: 0 0 2px 3px; padding: 0 3px;
border-radius: 2px;
background: rgba(255, 255, 255, .85);
color: var(--fg); font-size: 9px; line-height: 12px;
}
.palette .cells { display: grid; grid-template-columns: repeat(2, 17px); gap: 2px; }
.palette .slot { display: contents; }
.palette .cell {
width: 17px; height: 17px;
padding: 0;
border: 1px solid rgba(0, 0, 0, .35);
border-radius: 2px;
cursor: pointer;
}
.palette .cell:hover { border-color: var(--fg); transform: scale(1.12); }
.palette .cell.on { border-color: var(--fg); box-shadow: 0 0 0 1px var(--pane), 0 0 0 2px var(--sel); position: relative; z-index: 1; }
/* Clear is no colour: a knockout. Checkered, as transparency is everywhere. */
.palette .clear,
.palette .chip.clear {
background-color: #fff;
background-image:
linear-gradient(45deg, #c9c9c5 25%, transparent 25%, transparent 75%, #c9c9c5 75%),
linear-gradient(45deg, #c9c9c5 25%, transparent 25%, transparent 75%, #c9c9c5 75%);
background-size: 8px 8px;
background-position: 0 0, 4px 4px;
}
/* --------------------------------------------------------------------------
on the stage: the tools' marks, in stage pixels (the SVG's viewBox), and their
cursors. Hairlines, so the pixels under them can be judged. */
.paint-overlay.tool-pen {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22'%3E%3Cpath d='M2 2 L9 5 L14 11 L11 14 L5 9 Z' fill='%23111' stroke='%23fff' stroke-width='1.2' stroke-linejoin='round'/%3E%3Cpath d='M2 2 L7 7' stroke='%23fff' stroke-width='1'/%3E%3C/svg%3E") 2 2, crosshair;
}
.paint-overlay.tool-pen.closing {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22'%3E%3Cpath d='M2 2 L9 5 L14 11 L11 14 L5 9 Z' fill='%23111' stroke='%23fff' stroke-width='1.2' stroke-linejoin='round'/%3E%3Ccircle cx='17' cy='17' r='3' fill='none' stroke='%23111' stroke-width='1.6'/%3E%3Ccircle cx='17' cy='17' r='3' fill='none' stroke='%23fff' stroke-width='.6'/%3E%3C/svg%3E") 2 2, crosshair;
}
.paint-overlay.tool-brush, .paint-overlay.tool-eraser { cursor: none; }
.paint-overlay .footprint { fill: none; stroke: #fff1be; stroke-width: 0.5; vector-effect: non-scaling-stroke; pointer-events: none; }
.paint-overlay .footprint.eraser { stroke-dasharray: 3 2; }
.paint-overlay .draft polyline { fill: none; stroke: #fff1be; stroke-width: 1; vector-effect: non-scaling-stroke; pointer-events: none; }
.paint-overlay .draft .first { fill: #161820; stroke: #fff1be; stroke-width: 0.5; pointer-events: none; }
.paint-overlay .draft .first.closing { fill: #fff1be; r: 2.6; }
.paint-overlay .points .outline { fill: none; stroke: #fff1be; stroke-width: 1; stroke-opacity: .55; vector-effect: non-scaling-stroke; pointer-events: none; }
.paint-overlay .points .vertex { fill: #fff1be; stroke: #161820; stroke-width: 0.5; cursor: move; }
.paint-overlay .points .vertex:hover { fill: #fff; r: 2.4; }
.paint-overlay .points .insert { fill: #161820; stroke: #fff1be; stroke-width: 0.5; pointer-events: none; }
/* Blender's Adjust Last Operation: the last stroke's settings, in the corner of
the view it was made in, until something else is done. */
.adjust-last {
position: absolute;
left: 10px; bottom: 10px;
display: flex; align-items: center; gap: 10px;
padding: 5px 9px;
background: var(--pane);
border: 1px solid var(--line);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, .25);
}
/* Remap: the map swatch, and the inspector's grid for a remap shape. A slot is
shown as what is under the shape will be drawn in; the original is the chip
in its corner. */
.palette .cell.map,
.palette .chip.map {
background: linear-gradient(135deg, #3a3f52 0 50%, #e8d9a8 50% 100%);
color: #fff; font-size: 11px; line-height: 1; text-shadow: 0 1px 1px rgba(0, 0, 0, .6);
}
.palette .cell { position: relative; }
.palette .cell .badge {
position: absolute; right: -2px; bottom: -2px;
width: 7px; height: 7px;
border: 1px solid var(--pane); border-radius: 50%;
}
.remap-head { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.remap, .remap-pick { display: grid; grid-template-columns: repeat(8, 22px); gap: 3px; }
.remap-pick { margin-top: 7px; padding-top: 7px; border-top: 1px solid var(--hair); }
.remap-pick > .dim { grid-column: 1 / -1; }
.remap .cell, .remap-pick .cell {
position: relative;
width: 22px; height: 22px;
padding: 0;
border: 1px solid rgba(0, 0, 0, .35);
border-radius: 3px;
cursor: pointer;
}
.remap .cell:hover, .remap-pick .cell:hover { border-color: var(--fg); }
.remap .cell.on, .remap-pick .cell.on { box-shadow: 0 0 0 1px var(--pane), 0 0 0 2px var(--sel); }
.remap .cell.mapped i {
position: absolute; left: 2px; top: 2px;
width: 8px; height: 8px;
border: 1px solid rgba(255, 255, 255, .8);
border-radius: 2px;
}
/* The palette's own settings, at the foot of the strip: drawn as a tool button,
because it is one more thing in the strip you press. */
.palette-assets > button {
width: 34px; height: 30px; padding: 0;
display: grid; place-items: center;
border: 1px solid transparent; border-radius: 4px; background: none;
}
.palette-assets > button:hover:not(:disabled) { background: var(--sunk); border-color: var(--hair); }
.palette-assets > button.on { background: var(--sel-bg); border-color: var(--sel); }
.palette-assets svg path { fill: var(--fg); }
.palette-assets svg circle { fill: var(--chrome); }
.palette-assets > button.on svg circle { fill: var(--sel-bg); }
/* A popover's panel: settings, not commands, so narrower than a menu and laid
out as a form. Placed and dismissed by `ui/menu`. */
.menu.popout { width: 240px; min-width: 0; padding: 10px; line-height: 1.4; }
.menu.popout:has(.palette-popout) { width: 280px; }
.palette-popout { display: grid; gap: 8px; }
.palette-popout .row { margin: 0; }
/* Every palette as its strip and its name: what you would switch to, seen
before you switch. The open one is the selected row. */
.palette-list { display: grid; gap: 1px; max-height: 50vh; overflow: auto; }
.palette-row { display: flex; align-items: center; gap: 2px; border-radius: 3px; }
.palette-row.on { background: var(--sel-bg); }
.palette-row .choose {
flex: 1; min-width: 0;
display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 3px 8px;
padding: 5px 6px; border: 1px solid transparent; border-radius: 3px; background: none; text-align: left;
}
.palette-row button.choose:hover:not(:disabled) { background: var(--sunk); border-color: var(--hair); }
.palette-row.on button.choose { border-color: var(--sel); }
.palette-row.on button.choose:hover { background: var(--sel-bg); }
.palette-row .strip { grid-column: 1 / -1; }
.palette-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.palette-row.on .name { font-weight: 600; color: #14395f; }
.palette-row .palette-name { grid-column: 1 / -1; width: 100%; min-width: 0; }
.palette-row .rename { align-self: stretch; padding: 0 7px; border-color: transparent; background: none; }
.palette-row .rename:hover:not(:disabled) { border-color: var(--sel); background: #fff; }
.strip { display: flex; height: 14px; border: 1px solid rgba(0, 0, 0, .35); border-radius: 2px; overflow: hidden; }
.strip i { flex: 1; }
/* On the stage at the playhead. */
.on-stage { width: 7px; height: 7px; border-radius: 50%; background: var(--live); }
/* A palette being edited that the stage is not drawing in here. Information,
not alarm: the accent of a selection, not of a warning. */
.palette-assets-wrap { position: relative; }
.palette-info {
position: absolute; top: -3px; right: -5px;
width: 13px; height: 13px; border-radius: 50%;
display: grid; place-items: center;
background: var(--sel); color: #fff;
font: italic 600 9px/1 Georgia, serif;
cursor: help;
box-shadow: 0 0 0 1.5px var(--chrome);
}
.info-card {
position: fixed; z-index: 60;
width: 240px; padding: 8px 10px;
display: grid; gap: 3px;
background: var(--pane); color: var(--fg);
font: 11px/1.45 "Lucida Grande", "Segoe UI", system-ui, sans-serif;
border: 1px solid var(--sel); border-left-width: 3px; border-radius: 3px;
box-shadow: 0 3px 10px rgba(0, 0, 0, .25);
cursor: default;
}
.info-card strong { color: #14395f; }
.view-popout label { display: block; margin-bottom: 8px; }
.view-popout label:last-child { margin-bottom: 0; }
.view-popout input, .view-popout select { width: 100%; }
/* An overlay switch and its settings: one object, two parts. */
.overlay > .menu-wrap > button { margin-left: -1px; padding: 1px 5px; border-radius: 0 2px 2px 0; }
.overlay > button { border-radius: 2px 0 0 2px; }
.stage-wrap { flex: 0 0 auto; }
.onion-skin { position: absolute; inset: 0; pointer-events: none; image-rendering: pixelated; }
/* Expanded automation lanes expose editable keyframe dots. */
button.tl-key { pointer-events: auto; padding: 0; border: 0; width: 11px; height: 11px; margin: -5.5px; cursor: ew-resize; z-index: 4; opacity: 1; border-radius: 50%; }
button.tl-key.selected { background: var(--pick); box-shadow: 0 0 0 2px var(--pick); }