/* 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); } .palette-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; } /* The footage traced over: a reference above the picture, never part of it. */ .underlay { position: absolute; inset: 0; pointer-events: 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; } /* 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; } .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; } /* 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%; /* 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; } .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 .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, .palette-bar { gap: 7px; } .palette-bar button, .pane-head button, .top button { min-height: 24px; } /* A STRIP OF CONTROLS SCROLLS AS A STRIP. Flexbox's instinct when a row does not fit is to shrink every item and then push the last ones off the end — and the last ones here are the polygon tool, the timing commands and both zoom readouts, which would be unreachable rather than merely off-screen. So nothing shrinks, nothing wraps, and each strip scrolls. The `.spacer` and `.status` rules above still win on specificity, which is what keeps the right-hand groups right-hand while there IS room. */ .top > *, .pane-head > *, .palette-bar > * { flex-shrink: 0; } .top button, .pane-head button { white-space: nowrap; } .pane-head, .palette-bar { overflow-x: auto; } /* The one compressible thing in the palette bar is the swatches, so they scroll inside their own share instead of taking it from the commands. The tone's NAME goes: the ringed swatch already says which one is active, and it is the widest thing in the bar that nothing is lost by dropping. */ .palette-bar .swatches { flex: 1 1 130px; min-width: 56px; overflow-x: auto; } /* A swatch is a 15px circle or it is not a swatch: the strip scrolls, the dots do not get thinner. */ .palette-bar .swatches > * { flex-shrink: 0; } .palette-bar > .dim { display: none; } }