Files
RecipesCam/docker/frontend/src/styles/app.css
T
3dtours 2f34c36131 The room opens folded: the studio's panels and the catalogue's tree both start shut and come back the way they were left
Four asks, one shape: the LIGHT column had a chip that opened the same two
knobs DETAIL & EFFECTS already shows, the WB table was reading as one long
line per preset, the disclosure carets were too small to be the affordance
they are, and both folds — the studio's panels and the catalogue's folder tree
— opened fully every time. What the fold is for is the same in both places: a
column of a dozen controls is a wall to scroll past, and the control being
looked for is named by the head it sits under. So both open folded, and both
remember what was opened.

GRAIN's chip is gone from the LIGHT row. Its strip held a knob `grain` and the
"size" behind it, and both are rows of DETAIL & EFFECTS now — MONOCHROME GRAIN
and GRAIN SIZE — so the chip was a second way to one pair. The strip machinery
stays (`GroupKey 'grain'`, `groupDefs.grain`, `grainInch()`): it is reachable
from nothing today, and taking it out is a diff of its own. What went with the
chip is the `/INCH` readout, which only the strip drew.

The WB presets read as a name on one line and its kelvin under it: the label is
the chip's own label and the kelvin moved from the label into the value slot,
which the grid's rule stacks (`flex-direction: column`, `font-size: 11px`,
`white-space: nowrap`). Seven buttons, 147x39 each, name
box 14px — one line at 11px/1.25 — kelvin at 10px, `scrollWidth` equal to
`clientWidth` on every one of them. The kelvin rides a swatch, so it wears the
fill's own ink rather than the panel's dim grey (`.chip.tinted .val { color:
inherit }`): readableInk already picks #0b0e12 or #ffffff off that fill for
4.5:1, and dimming it a step costs contrast on a mid grey fill. Measured off
the running page: 4.88:1 (CLOUDY) to 5.00:1 (TUNGSTEN), name and kelvin alike.

The panels: `recipescam.develop.open` holds the slugs that are open, comma-
joined, and a slug no panel answers to is dropped on the way in, so a renamed
or removed panel leaves no hole. A visit that touches nothing writes nothing —
the column opens folded from an empty store — and a click on a head writes the
list as it stands. The caret is drawn a size up from the label it sits beside
(17px against an 11px head) and takes full opacity under the pointer, because
a line of uppercase does not read as a control on its own.

The tree: a row's click still opens it and folds it again, but the set it moves
in is inverted — what is *drawn open* rather than what is folded away — which
is what makes the state storable at all (a folded set is the whole tree minus
what is open, and the whole tree is not in that state to be stored). The
column opens with every folder shut, and the remembered set comes back over
it. A first visit under the new rule has no stored set and can still have a
folder remembered from before, so that one case seeds its ancestors: a screen
that opens on a node shrunk away behind a shut row is worse than a
remembered fold. COLLAPSE ALL folds the top-level row too now, which is what
makes it the way back to the column's rest — it was written when a top-level
row could not be folded, and the item is disabled when there is nothing open.
The caret is a leaf on a row with nothing under it, as it was.

`remember.ts` is the two functions both screens now share; Library had its own
copy of them.

The two checks that read these screens were wrong about the new fold and are
right about it now, not the other way round. `library-check` gains the rest
state (`the tree opens with the folders folded away — 1 rows`), walks the
tree a level per click, and reads the visit it left behind off a reload:
`lib-node-CheckRoll:true lib-node-CheckRoll/2026:true .../04:- .../Empty:-`,
with the screen marked on the subfolder. Two of its steps were stale
expectations rather than a fault: the fold-the-tree claim is one click over
four rows once the rows are opened for it, and the folder the screen reopens
on is the last row clicked, not the one clicked before it. `scan-nav-check`
asks the same tree for its deepest folder, so it opens the two rows above it
and folds them back — the reading it is watching does not move with rows.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-T721P-Nv.js` 711.33 kB, `dist/assets/index-Co_dQD6t.css`
65.69 kB). `scripts/library-check.mjs` — "all checks passed", 55 steps, none
failing — including `the tree opens with the folders folded away — 1 rows,
aria-expanded=false`, `a row draws its own children, not the whole branch — 3
rows`, `and one click folds every row, leaving the frames where they were — 4
rows → 1, lib-node-CheckRoll:false, 2 tiles kept`, `the tree reopens with the
rows that were left open`, `the screen reopens on the folder it was left on —
lib-node-CheckRoll/2026`, `the screen reopens on the frame that was raised`,
and the column `198px, then 198px`. `scripts/scan-nav-check.mjs` — all steps
passed, its tree walk reading
`["lib-node-SlowRoll","lib-node-SlowRoll/2026","lib-node-SlowRoll/2026/04"]`.
In the running page: 5 panel heads and 0 panel bodies at rest with
`recipescam.develop.open` unset, caret `17px` `▸`; the WB table's seven rows
at 147x39 with name 14px one line, kelvin below at 10px, `nowrap`, no
overflow, ink 4.88–5.00:1; the LIGHT chip row `["auto","curve","fix",
"gradient-mask","mono","wb-temp:*","reset-all"]` — no `grp-grain` — with
MONOCHROME GRAIN and GRAIN SIZE still the two rows of DETAIL & EFFECTS; two
heads opened, `wb,effects` stored, and the same two open after a reload with
the other three shut. The deployed bundle was read too: `docker compose build
frontend && docker compose up -d frontend`, and `index-DpYWAhJ3.js` off
`127.0.0.1:8090` carries `recipescam.develop.open` and
`recipescam.library.open` and no `grp-grain`, with the same probe readings
taken against it.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 21:25:18 +07:00

1491 lines
61 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Workspace layout: header strip on top, then rail | chips | stage.
Under 860px the rail becomes a bottom bar and the chips a horizontal strip,
which is the same shape the phone app uses. */
.shell { display: flex; flex-direction: column; height: 100%; }
.header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-soft);
background: var(--bg-elev);
flex-wrap: wrap;
}
.brand { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; letter-spacing: 0.04em; color: var(--text); text-decoration: none; }
.brand img { border-radius: 8px; }
.brand em { font-style: normal; font-weight: 400; color: var(--accent); }
/* The page name, top-left beside the mark — the same pair /admin and /profile
carry, so every screen says where the visitor is. */
.page-name { font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim); }
.header .spacer { flex: 1 1 auto; }
/* The preset the studio has open. It wears the theme's own accent rather than
the dim text colour: it is the one label up there that changes with the
look, and the same colour the open tab and the brand already carry. */
.recipe-name {
font-family: var(--mono);
font-size: 12px;
color: var(--accent);
border: 1px solid var(--border-soft);
border-radius: 999px;
padding: 3px 10px;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-wrap { position: relative; }
.workspace { display: flex; flex: 1 1 auto; min-height: 0; }
/* --- tool rail ---------------------------------------------------------- */
.rail {
width: var(--rail-w);
flex: 0 0 auto;
border-right: 1px solid var(--border-soft);
background: var(--bg-sunken);
display: flex;
flex-direction: column;
padding: 6px;
gap: 4px;
overflow-y: auto;
}
.rail button {
border: 1px solid transparent;
background: transparent;
border-radius: var(--radius-sm);
padding: 9px 4px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 10px;
letter-spacing: 0.06em;
color: var(--text-dim);
}
.rail button .glyph { font-size: 17px; line-height: 1; }
.rail button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }
.rail button:hover { color: var(--text); }
/* The build behind the page. It sits at the foot of the column, quiet enough to
read past, and wraps rather than widening the rail when a name is long. */
.rail-version {
margin-top: auto;
padding: 8px 2px 2px;
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.02em;
color: var(--text-dim);
text-align: center;
overflow-wrap: anywhere;
}
/* --- panel columns ------------------------------------------------------- */
/* The panel is a cascade: the rail holds the tabs, then one column per open
level — the tab's chips, the open chip's sub-chips, the open ruler. A child
column never hides the column it came from, so the path reads left to right. */
.chips {
flex: 0 0 auto;
max-width: 62vw;
display: flex;
flex-direction: row;
align-items: stretch;
border-right: 1px solid var(--border-soft);
background: var(--bg);
overflow-x: auto;
overflow-y: hidden;
}
.col {
width: var(--col-w);
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
overflow-y: auto;
border-right: 1px solid var(--border-soft);
}
.col:last-child { border-right: none; }
.col-slider { width: var(--slider-w); }
/* CREATE RECIPES draws a two-up form, so its column is the wide one. */
.col-create { width: 300px; }
.col-sub { background: var(--bg-elev); }
/* RESET is the way out of an edit, not one more chip: a rule and some air keep
it apart from the tab's own buttons at the foot of the column. */
.reset-row { margin-top: 6px; padding-top: 12px; border-top: 1px solid var(--border); }
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
/* In a column the chips stack: one chip per row, full width. */
.col .chip-row { flex-direction: column; align-items: stretch; }
.col .chip { justify-content: space-between; text-align: left; }
/* A row of one KIND of value read against each other, not a strip to scan: WB's
presets, laid out as a table of buttons instead of a wrapping row. */
.chip-row.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.chip-row.grid .chip {
justify-content: center; text-align: center;
/* Two of these share the column's width, so each is its own small card: the
standard's name on one line — never wrapped, the names are the point — and
the kelvin under it, both a size down from a chip's label. */
flex-direction: column; gap: 1px;
padding: 4px 6px;
font-size: 11px; line-height: 1.25;
white-space: nowrap;
}
/* UNDO/REDO are one glyph each, so the pair reads as a single control. */
.header .btn.icon { padding: 4px 9px; font-size: 15px; line-height: 1.2; }
/* WATERMARK's own column: its chips carry the longest labels in the app
("GPS WATERMARK OFF", "CUSTOM WATERMARK OFF" + PRO), which wrap in the
default column width. */
.wm-extra { border-right-color: var(--accent-line); width: 200px; }
/* The watermark panel is two collapses, one per mark: each header chip stands
on its own controls (colour, face, switches, fields), and a rule keeps the
two apart while both marks are on. */
.wm-sec { display: flex; flex-direction: column; gap: 6px; }
.wm-sec + .wm-sec { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
.chip {
border: 1px solid var(--border);
background: var(--bg-elev);
border-radius: 999px;
padding: 5px 11px;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.col-sub .chip { background: var(--bg); }
/* The consent tick in MY PHOTOS' history column: a plain checkbox, not a chip —
it is a permission, and it reads as one. */
.consent {
display: flex; align-items: flex-start; gap: 7px;
margin-top: 10px; padding-top: 10px;
border-top: 1px solid var(--border);
font-size: 12px; line-height: 1.4; color: var(--text-dim);
cursor: pointer;
}
.consent input { flex: 0 0 auto; margin: 1px 0 0; accent-color: var(--accent); }
/* SAVE RECENT's own files: the name fills the row, the ✕ that deletes it sits
beside it — the account's recipe list is the one place a file can be thrown
away from the studio. */
.saved-list { display: flex; flex-direction: column; gap: 6px; }
.saved-row { display: flex; gap: 6px; align-items: stretch; }
.saved-row .chip:first-child { flex: 1; justify-content: space-between; text-align: left; }
.chip[aria-pressed="true"], .chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
/* A chip painted with the value it picks (a WB preset's cast, the same swatch
the ruler paints). The fill and the ink are inline — they ARE the value, not
the theme — so both are stated by the caller; the border is the chip's own
ink, which is visible against the fill it was chosen for, and the accent once
the preset is on, since the fill can no longer say so. */
.chip.tinted {
border: 2px solid currentColor;
border-radius: 8px;
font-weight: 600;
}
.chip.tinted.on { border-color: var(--accent); }
/* The kelvin rides on a swatch, whose ink was picked for that fill to clear
4.5:1 — so a value slot there wears that ink and not the panel's dim grey,
which is what a mid grey button cannot carry. It is set a size down from the
name instead, which is separation that costs no contrast. */
.chip.tinted .val { color: inherit; }
/* Edited value, strip closed: the text glows amber, the border does not. */
.chip.amber, .chip.amber .val { color: var(--accent); }
.chip .val { color: var(--text-dim); font-family: var(--mono); font-size: 11px; }
.chip-row.grid .chip .val { font-size: 10px; }
.chip .pro, .btn .pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
/* A button is not a flex row the way a chip is, so the marker carries its own
gap off the label it qualifies. */
.btn .pro { margin-left: 6px; }
.chip:disabled { opacity: 0.4; cursor: not-allowed; }
/* The font group as a dropdown: one row that shows the current pairing instead
of three chips that all have to fit. */
.font-select {
display: block;
width: 100%;
margin-top: 8px;
padding-right: 26px;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%239aa2ad' stroke-width='1.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
.font-select option { background: var(--bg-elev); color: var(--text); }
/* The ruler column: "<" back button, label + live value, slider, colour swatch
for the knobs that drive a hue (WB's TEMPERATURE / TINT). */
.slider-row { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; display: flex; align-items: center; gap: 10px; }
.col .slider-row { flex-direction: column; align-items: stretch; gap: 6px; }
.slider-row .ruler { flex: 1 1 auto; min-width: 0; }
.slider-row .head { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: 0.1em; color: var(--text-dim); }
.slider-row .head b { color: var(--text); font-family: var(--mono); letter-spacing: 0; }
.slider-row .head b.amber { color: var(--accent); }
.slider-row .swatch {
height: 26px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
}
.slider-row .swatch span {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: #fff;
mix-blend-mode: difference;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.slider-row .back {
flex: 0 0 auto;
align-self: flex-start;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text-dim);
border-radius: 999px;
padding: 4px 10px;
font-family: var(--mono);
font-weight: 700;
cursor: pointer;
}
input[type="range"] { width: 100%; accent-color: var(--accent); }
.hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; }
.hint.ok { color: var(--accent); }
/* --- PRO gate bar ------------------------------------------------------- */
/* One line between the header and the workspace, shown only while a signed-in
account has not proven its address — it is served as a guest until it does. */
.verify-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 6px 12px;
background: var(--accent-soft);
border-bottom: 1px solid var(--accent-line);
font-size: 12px;
}
.verify-bar .btn { padding: 3px 10px; font-size: 11px; }
/* The trial notice: the verify bar's shape in the plain surface rather than the
accent — it is news, and it offers nothing to click. */
.notice-bar {
padding: 6px 12px;
text-align: center;
font-size: 12px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
color: var(--text);
}
/* --- CREATE RECIPES form (the phone's RecipeCreateModal, unrolled) ------- */
.create-form {
border-top: 1px solid var(--border-soft);
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.create-label {
font-size: 10px;
letter-spacing: 0.12em;
color: var(--text-dim);
font-weight: 600;
}
.create-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
/* Collapsible groups: native <details>, so only the caret is ours. */
.create-sec { display: flex; flex-direction: column; gap: 6px; }
.create-sec > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; user-select: none; }
.create-sec > summary::-webkit-details-marker { display: none; }
.create-sec > summary::before { content: '▸'; font-size: 9px; transition: transform 0.15s ease; }
.create-sec[open] > summary::before { transform: rotate(90deg); }
.create-num { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.create-num span { font-size: 10px; letter-spacing: 0.12em; color: var(--text-dim); font-weight: 600; }
.create-num.wide { grid-column: 1 / -1; }
.create-shift { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.create-shift .field { width: 64px; }
.create-actions { display: flex; gap: 6px; margin-top: 4px; }
.create-actions .btn { flex: 1 1 auto; }
/* --- stage -------------------------------------------------------------- */
.stage {
flex: 1 1 auto;
min-width: 0;
background: var(--bg-sunken);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
position: relative;
}
.dropzone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 40px 28px;
text-align: center;
color: var(--text-dim);
cursor: pointer;
background: var(--bg);
max-width: 520px;
}
.dropzone.over { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
.dropzone strong { display: block; color: var(--text); margin-bottom: 6px; font-size: 16px; }
/* The camera sits beside the picker's own hint, so it needs a line of its own
rather than riding on the sentence above it. The click on this button must
not also open the file dialog the dropzone itself listens for. */
.dropzone .btn { margin-top: 14px; }
.canvas-wrap { position: relative; display: flex; align-items: center; justify-content: center; min-height: 0; flex: 1 1 auto; width: 100%; overflow: hidden; }
.canvas-wrap img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow); }
/* Crop editor: the layer is the photo's own box, the rect is the framing and
its shadow is what shades the part being cut away. */
/* Compare: the original left of the split, the grade right of it. The layer is
the photo's own box and carries the stage's transform, so the bar stays put
on the photo whatever the zoom or pan. */
.compare-layer {
position: absolute;
overflow: hidden;
border-radius: var(--radius-sm);
cursor: ew-resize;
touch-action: none;
}
.compare-orig { position: absolute; inset: 0; width: 100%; height: 100%; box-shadow: none; }
.compare-bar {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.compare-bar::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 30px;
height: 30px;
transform: translate(-50%, -50%);
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
}
.crop-layer { position: absolute; overflow: hidden; border-radius: var(--radius-sm); pointer-events: none; }
.crop-rect { position: absolute; border: 1px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); cursor: move; pointer-events: auto; touch-action: none; }
.crop-handle { position: absolute; width: 16px; height: 16px; background: var(--accent); border: 2px solid #fff; border-radius: 3px; touch-action: none; }
.crop-handle.tl { left: 0; top: 0; transform: translate(-50%, -50%); cursor: nwse-resize; }
.crop-handle.tr { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; }
.crop-handle.bl { left: 0; bottom: 0; transform: translate(-50%, 50%); cursor: nesw-resize; }
.crop-handle.br { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; }
/* LIGHT's TONE CURVE: the graph hung on the photo. The layer is the photo's own
box and takes no pointer events; the card sits at its bottom-left corner and
takes them all, because every gesture in it is about the curve. */
.curve-layer { position: absolute; pointer-events: none; }
.curve-panel {
position: absolute;
left: 10px;
bottom: 10px;
pointer-events: auto;
touch-action: none;
width: 240px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
user-select: none;
}
.curve-head { display: flex; align-items: center; justify-content: space-between; cursor: grab; }
.curve-panel.dragging .curve-head { cursor: grabbing; }
.curve-title { font-size: 11px; letter-spacing: 0.1em; color: var(--muted); }
.curve-close {
border: none;
background: none;
color: var(--muted);
font-size: 12px;
line-height: 1;
padding: 2px 4px;
cursor: pointer;
}
.curve-close:hover { color: var(--fg); }
.curve-tabs { display: flex; gap: 4px; align-items: center; }
.curve-tab {
display: flex;
align-items: center;
gap: 5px;
flex: 1 1 0;
justify-content: center;
padding: 3px 0;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.curve-tab.on { border-color: var(--accent-line); color: var(--fg); }
/* The dot carries the channel's own colour, so the tab says which line it draws;
it goes amber once that channel has left the diagonal. */
.curve-dot { width: 7px; height: 7px; border-radius: 999px; }
.curve-tab.moved .curve-dot { box-shadow: 0 0 0 2px var(--accent); }
.curve-reset {
flex: 0 0 auto;
padding: 3px 7px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--muted);
background: none;
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.curve-reset:hover { color: var(--fg); border-color: var(--accent-line); }
.curve-graph { display: block; background: rgba(0, 0, 0, 0.35); border: 1px solid var(--border); border-radius: 3px; cursor: crosshair; }
/* The picture's own distribution, the floor the graph is read against. Light on
both themes because the graph itself is always the dark box over a photo. */
.curve-hist { fill: rgba(255, 255, 255, 0.22); }
.curve-grid { stroke: rgba(255, 255, 255, 0.1); stroke-width: 1; }
.curve-diagonal { stroke: rgba(255, 255, 255, 0.28); stroke-width: 1; stroke-dasharray: 3 3; }
/* The line the active tab draws, and — on a channel tab — the composite curve
under it, so the two moves can be told apart. */
.curve-line { fill: none; stroke-width: 2; stroke-linecap: round; }
.curve-line.ghost { stroke: rgba(255, 255, 255, 0.35); stroke-width: 1; stroke-dasharray: 4 3; }
.curve-point { fill: var(--bg-elev); stroke-width: 2; cursor: grab; }
.curve-point:active { cursor: grabbing; }
/* FRAME's custom watermark box: drag the body to move, the corner to scale. */
.wm-layer { position: absolute; border-radius: var(--radius-sm); pointer-events: none; }
.wm-rect { position: absolute; border: 1px dashed var(--accent); cursor: move; pointer-events: auto; touch-action: none; }
.wm-handle { position: absolute; width: 14px; height: 14px; background: var(--accent); border: 2px solid #fff; border-radius: 3px; touch-action: none; }
.wm-handle.br { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; }
/* A watermark's colour: the preset chips are a shortcut, the well picks any. */
.color-well { display: flex; align-items: center; gap: 8px; padding: 6px 8px; cursor: pointer; }
.color-well input[type="color"] { width: 34px; height: 22px; padding: 0; border: 1px solid var(--accent-line); border-radius: 3px; background: none; cursor: pointer; }
.color-well .val { font-size: 11px; letter-spacing: 0.08em; color: var(--muted); }
/* Eyedropper: the photo's own box, left transparent, with a crosshair cursor. */
.pick-layer {
position: absolute;
border-radius: var(--radius-sm);
cursor: crosshair;
touch-action: none;
}
/* FX's brush — HEAL and MOSAIC, one layer. It takes the pointer the way the
eyedropper's does, so a click paints and a drag paints a run instead of
panning; the circle under the cursor is the size the wheel has set, the trail
is the stroke being drawn, and every spot on the photo keeps its own circle.
A HEAL spot shows the pair the renderer works with — the hole it fills, and
the patch it borrowed; a MOSAIC spot has nothing beside it, since what it
covers is gone. */
.brush-layer {
position: absolute;
border-radius: var(--radius-sm);
cursor: none;
touch-action: none;
}
.brush-set { position: absolute; inset: 0; pointer-events: none; }
.brush-cursor,
.brush-spot,
.brush-source {
position: absolute;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.brush-cursor { border: 1px solid rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55); }
/* The stroke in progress is one band of the brush's own width — a path with
round ends and round joins — so a drag reads as a brush being drawn across
the photo rather than as the beads a row of overlapping circles looks like.
It wears the accent, so the run reads as something about to happen rather
than a spot already laid. */
.brush-trail {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.brush-trail path {
fill: none;
stroke: var(--accent);
stroke-linecap: round;
stroke-linejoin: round;
stroke-opacity: 0.45;
}
.brush-spot { border: 1px solid rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); }
.brush-source { border: 1px dashed rgba(255, 255, 255, 0.7); }
/* The spots a stroke left are drawn as the one mark it was painted as: a band
of the brush's own width through them, in the same translucent white with the
same dark hairline the circles wear, so the band reads on a white photo too.
The dashed one is the patch HEAL borrowed, which is a band of its own and is
meant to look borrowed. A stroke is one path, so where it crosses itself its
translucency is laid once rather than twice — that doubling-up is exactly
what a row of overlapping circles looks like. */
.brush-band {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.45));
}
.brush-band path {
fill: none;
stroke: rgba(255, 255, 255, 0.18);
stroke-linecap: round;
stroke-linejoin: round;
}
.brush-band.band-src path { stroke: rgba(255, 255, 255, 0.12); stroke-dasharray: 5 5; }
/* A spot the band stands for: its box on the photo is still the spot's, its own
edge is the band's now. */
.brush-spot[data-band],
.brush-source[data-band] { border-color: transparent; box-shadow: none; }
/* A repair the pointer is not on and no press has chosen: the ring would be one
more circle on a photo that already carries as many repairs as the user laid,
so an idle spot is the patch it borrowed and a faint print of the place it
mended — soft, because it is a record of an edit rather than something to
aim at, and it is the ring under the pointer that says the spot can be taken
hold of again. */
.brush-spot[data-idle] {
border-color: transparent;
background: rgba(127, 127, 127, 0.14);
box-shadow: 0 0 7px 2px rgba(0, 0, 0, 0.25);
filter: blur(1px);
}
/* A run the band already stands for needs no print of its own under it. */
.brush-spot[data-idle][data-band] { background: none; box-shadow: none; filter: none; }
/* In hand, one of the run's spots is the circle it would be taken hold of by, so
it wears that circle again over the band — the ring is what says the spot is
under the pointer. */
.brush-spot[data-band]:not([data-idle]) { border-color: rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); }
/* The × on the spot the pointer chose. It sits on the spot's corner and is the
one thing on this layer the pointer is meant to click, so it takes the
pointer back off the paint. */
.brush-del {
position: absolute;
transform: translate(-50%, -50%);
width: 18px;
height: 18px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font: 12px/1 sans-serif;
cursor: pointer;
pointer-events: auto;
}
.brush-del:hover { background: var(--accent); }
/* FX's gradient masks — LINEAR and RADIAL GRADIENT, one layer. It takes the
pointer while a mask tool is armed, so a drag draws the next shape instead of
panning the photo; the shapes already on the photo answer only through their
own pin and handles, which is what lets a press mean "take hold of this one"
rather than "draw another". */
.mask-layer {
position: absolute;
border-radius: var(--radius-sm);
cursor: crosshair;
touch-action: none;
}
/* The shape's own outline, over the photo: the pin and the handles are the
things on this layer to press, and the chosen shape's edge is one more — the
line a ramp falls along, or an ellipse's rim. Invisible and a finger wide, so
a press on the edge turns the shape without having to find a handle for it;
non-scaling, so the band is the same width however the photo is zoomed. */
.mask-shape {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.45));
}
.mask-line { stroke: rgba(255, 255, 255, 0.85); stroke-width: 1; }
.mask-edge {
fill: none;
stroke: rgba(255, 255, 255, 0.01);
stroke-width: 16;
pointer-events: stroke;
vector-effect: non-scaling-stroke;
cursor: grab;
}
/* An ellipse's rim, and inside it the ellipse its feather fades to — so the
hand the shape has on the photo is visible before the knobs are moved. */
.mask-ring { fill: none; stroke: rgba(255, 255, 255, 0.85); stroke-width: 1; }
.mask-ring.feather { stroke: rgba(255, 255, 255, 0.5); stroke-dasharray: 4 4; }
/* The pin that moves the whole shape, and the handles that resize it. Both are
the pointer's own, and both keep a screen size of their own whatever the zoom
(the layer's transform is taken back out of them). */
.mask-pin,
.mask-handle {
position: absolute;
width: 13px;
height: 13px;
border: 1px solid rgba(0, 0, 0, 0.55);
background: rgba(255, 255, 255, 0.9);
pointer-events: auto;
cursor: move;
}
.mask-pin { border-radius: 50%; }
.mask-handle { border-radius: 2px; }
.mask-handle.rot { border-radius: 50%; cursor: grab; }
/* The mixer's readout: the sampled swatch, its RGB and HSL, and the band the
ruler is pointed at. */
.hsl-readout {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.hsl-swatch { display: block; height: 34px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
.hsl-line { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
/* A chip that names a colour shows it: the dot rides the leading edge. */
.chip-dot { width: 10px; height: 10px; border-radius: 999px; border: 1px solid var(--border); flex: 0 0 auto; }
/* The eyedropper's own icon, riding the cursor inside the pick layer. Its hot
spot is the tip, so it is nudged up and left of the pointer. */
.pick-icon { position: absolute; pointer-events: none; transform: translate(-2px, -21px); filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.65)); }
.pick-icon-body { fill: #fff; stroke: #111; stroke-width: 1.2; }
.pick-icon-tip { fill: var(--accent); stroke: #111; stroke-width: 1.2; }
/* The mixer's panel for the colour just sampled: it hangs on the photo at the
point the eyedropper read, its swatch on top and HUE/SAT/LUM in one row
under it. The layer is transparent to the pointer, the card is not. */
.hsl-panel-layer { position: absolute; pointer-events: none; }
.hsl-panel {
position: absolute;
pointer-events: auto;
/* Dragged by its body to a clear patch of photo; the knobs and the close
button keep the pointer to themselves. */
cursor: grab;
touch-action: none;
width: 248px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
.hsl-card-head { display: flex; align-items: center; gap: 8px; }
.hsl-card-swatch { width: 28px; height: 28px; flex: 0 0 auto; border: 1px solid var(--border); border-radius: var(--radius-sm); }
.hsl-card-name { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; font-size: 11px; letter-spacing: 0.08em; color: var(--text-dim); }
.hsl-card-name b { font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--text); }
.hsl-card-close { border: 1px solid var(--border); background: var(--bg); color: var(--text-dim); border-radius: 999px; padding: 2px 7px; font-size: 11px; cursor: pointer; }
.hsl-card-knobs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.mini-slider { min-width: 0; }
.mini-head { display: flex; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); }
.mini-head b { font-family: var(--mono); letter-spacing: 0; color: var(--text); }
.mini-head b.amber { color: var(--accent); }
/* FRAME's STRAIGHTEN scale: a graduated ruler lying along the foot of the
photo. The layer is transparent to the pointer so the photo still answers a
drag outside the card; the card itself takes the drag. */
.straighten-layer { position: absolute; z-index: 3; pointer-events: none; }
.straighten-ruler {
position: absolute;
left: 10px;
right: 10px;
bottom: 10px;
padding: 6px 14px 8px;
pointer-events: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
user-select: none;
touch-action: none;
}
.straighten-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); text-shadow: 0 1px 2px var(--bg); }
.straighten-head b { font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--text); font-variant-numeric: tabular-nums; }
.straighten-head b.amber { color: var(--accent); }
.straighten-scale { position: relative; height: 30px; cursor: ew-resize; filter: drop-shadow(0 0 2px var(--bg)); }
.straighten-tick { position: absolute; bottom: 12px; width: 1px; height: 7px; background: var(--border); transform: translateX(-50%); }
.straighten-tick.major { height: 12px; background: var(--text-dim); }
.straighten-tick.zero { height: 16px; background: var(--text); }
.straighten-tick i { position: absolute; top: 13px; left: 0; transform: translateX(-50%); font-style: normal; font-size: 9px; color: var(--text-dim); }
.straighten-needle { position: absolute; bottom: 4px; width: 2px; height: 20px; background: var(--accent); transform: translateX(-50%); pointer-events: none; }
/* Histogram overlay: a small translucent frame on the photo, dragged by its
header. The luma curve is a grey fill behind the three channel curves, which
screen over each other the way a Lightroom histogram reads. */
.hist {
position: absolute;
z-index: 4;
width: 236px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg) 86%, transparent);
backdrop-filter: blur(6px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
user-select: none;
touch-action: none;
}
.hist-head { display: flex; align-items: center; gap: 4px; padding: 3px 4px 3px 8px; cursor: grab; }
.hist.dragging .hist-head { cursor: grabbing; }
.hist-title { flex: 1 1 auto; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); }
.hist-close {
width: 18px; height: 18px; padding: 0; line-height: 1;
font-size: 13px; color: var(--text-dim);
background: none; border: 0; border-radius: 4px; cursor: pointer;
}
.hist-close:hover { background: var(--bg-sunken); color: var(--text); }
.hist-plot { display: block; width: 100%; height: 84px; }
.hist-lum { fill: var(--text-dim); opacity: 0.38; }
.hist-ch { fill: none; stroke-width: 1.1; }
/* Screen is how the three curves add up like light on a dark panel — but it is
measured against the backdrop, so on the light theme (a white plot) it
screens every channel straight to white and the histogram reads as empty. */
[data-theme='dark'] .hist-ch { mix-blend-mode: screen; }
.hist-r { stroke: #ff5a5a; }
.hist-g { stroke: #4ade80; }
.hist-b { stroke: #5aa2ff; }
.hist-axis {
display: flex; justify-content: space-between;
padding: 1px 6px 4px;
font-family: var(--mono); font-size: 9px; color: var(--text-dim);
}
.busy {
position: absolute;
inset: auto 0 12px 0;
margin: 0 auto;
width: fit-content;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 999px;
padding: 5px 12px;
font-size: 12px;
color: var(--text-dim);
box-shadow: var(--shadow);
}
.stage .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
/* The photo's own size, first in that row: monospaced so the digits do not
shuffle the buttons sideways while a crop is being dragged. */
.stage .toolbar .dims { font-family: var(--mono); white-space: nowrap; }
.err { color: var(--danger); font-size: 12px; max-width: 520px; text-align: center; }
/* The stage and, over it, the live viewfinder: the slot they share in the
workspace. The columns beside it are the app's own — a slider there moves the
look on the frames the camera is grading right now. */
.stage-slot { flex: 1 1 auto; min-width: 0; display: flex; position: relative; }
/* --- live viewfinder ---------------------------------------------------- */
/* Covers the stage, not the app: black whatever the theme, because a viewfinder
is something the user looks through, but the tabs stay in reach — that is the
whole point of framing through the recipe. */
.camera-view {
position: absolute;
inset: 0;
z-index: 5;
background: #000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px;
}
/* The graded frame itself. `object-fit: contain` keeps the whole picture on
screen at any window shape, which is the composition the export writes. */
.camera-feed {
flex: 1 1 auto;
min-height: 0;
max-width: 100%;
max-height: 100%;
object-fit: contain;
background: #000;
}
.camera-bar,
.camera-deck {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 900px;
}
.camera-bar .hint { color: #a9a9a9; font-family: var(--mono); }
.camera-bar .spacer { flex: 1 1 auto; }
.camera-deck .camera-spacer { width: 44px; }
.camera-err { max-width: 900px; }
/* The <video> the live frames are read from. It has to be IN the document —
Safari paints blank frames from a detached video — but never seen: 1px, faint
and out of the way, behind the black backdrop. Not `display: none`: that stops
the frames as surely as detaching it. */
.camera-source-host {
position: fixed;
left: 0;
bottom: 0;
width: 1px;
height: 1px;
opacity: 0.01;
overflow: hidden;
pointer-events: none;
}
.camera-source-host video { width: 1px; height: 1px; }
/* The shutter: the app's one round control, because it is the one control whose
shape says what it does. */
.shutter {
width: 62px;
height: 62px;
margin: 0 auto;
padding: 0;
border: 3px solid #fff;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.65);
cursor: pointer;
}
.shutter:disabled { opacity: 0.4; cursor: default; }
/* --- auth dialog -------------------------------------------------------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 60;
padding: 16px;
}
.modal {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
width: 100%;
max-width: 360px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 10px;
}
.modal h2 { margin: 0; font-size: 17px; }
/* The one row in the export menu that is not a plain button: a number to type
and the button that takes it. */
.export-custom {
display: flex;
align-items: center;
gap: 8px;
}
.export-custom .field { width: 96px; }
/* --- strip moderation (/admin) ------------------------------------------ */
.adm { height: 100%; overflow-y: auto; padding: 0 22px 40px; }
/* Sticky: the mark and the page name stay in the top-left corner while the
photo grid scrolls under them, like the studio's header and the landing nav. */
.adm-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 5;
margin: 0 -22px 16px;
padding: 12px 22px;
background: var(--bg);
border-bottom: 1px solid var(--border-soft);
}
.adm-title { font-size: 15px; letter-spacing: 0.02em; }
/* The mark and the page name, top-left, always a way back to the landing page —
the same pair the studio's TopBar carries. */
.adm-brand { display: flex; align-items: center; gap: 6px; font-weight: 650; letter-spacing: -0.01em; color: var(--text); text-decoration: none; }
.adm-brand em { font-style: normal; font-weight: 400; color: var(--accent); }
.adm-brand img { border-radius: 8px; }
/* Curating pictures: two album columns of the same shape — the uploads (one
album per account) and the landing (one album per section). Each column
lists its albums and then draws the open album's frames. */
.adm-cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
@media (max-width: 1100px) { .adm-cols { grid-template-columns: minmax(0, 1fr); } }
.adm-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
/* The filters over the album list: one name box, two pickers. */
.adm-input {
width: 100%;
font: inherit;
font-size: 12px;
color: var(--text);
background: var(--bg-sunken);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 8px;
}
.adm-filters { display: flex; gap: 6px; }
.adm-filters .adm-input { min-width: 0; }
/* A shelf of albums: it scrolls on its own so the frames below stay reachable
however many accounts or sections there are. */
.adm-album-list { display: flex; flex-direction: column; gap: 4px; max-height: 46vh; overflow-y: auto; padding-right: 2px; }
.adm-album {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font: inherit;
font-size: 12px;
text-align: left;
color: var(--text-dim);
background: var(--bg-sunken);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 9px;
cursor: pointer;
}
.adm-album:hover { border-color: var(--accent-line); }
.adm-album.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.adm-album-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.adm-album-count { font-family: var(--mono); font-size: 11px; }
/* One album column: the shelf of albums down the left, the frame that is up
beside it, the album's strip of thumbnails across the bottom. */
.adm-split { display: grid; grid-template-columns: minmax(150px, 200px) minmax(0, 1fr); gap: 12px; align-items: start; }
.adm-stage {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
padding: 10px;
}
.adm-stage-img { width: 100%; aspect-ratio: 4 / 3; object-fit: contain; border-radius: var(--radius-sm); background: var(--bg-sunken); }
/* The album's strip: thumbnails in one row, scrolled sideways when they
overflow the column rather than wrapping into a wall of pictures. */
.adm-filmstrip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.adm-thumb {
flex: 0 0 132px;
margin: 0;
padding: 0;
font: inherit;
cursor: pointer;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-sunken);
color: var(--text-dim);
}
.adm-thumb:hover { border-color: var(--accent-line); }
.adm-thumb.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
.adm-thumb-cap { display: block; padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; }
/* The three destinations sit on one line under the photo, each an independent
checkbox — tick all three and the photo shows in all three sections. */
.adm-choices { display: flex; flex-wrap: wrap; gap: 5px; }
.adm-choice {
display: inline-flex;
align-items: center;
gap: 5px;
font: inherit;
font-size: 10.5px;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 4px 8px;
cursor: pointer;
color: var(--text-dim);
background: var(--bg-sunken);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.adm-choice:hover { border-color: var(--accent-line); }
.adm-choice:has(input:checked) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.adm-choice:has(input:disabled) { opacity: 0.5; cursor: default; }
.adm-choice input { margin: 0; accent-color: var(--accent); }
.adm-row-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
font-size: 11.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.adm-row-count { font-family: var(--mono); color: var(--text); }
.adm-row-empty { margin: 0; }
/* The QR section's real artefact is the code, not the photo. Drawn on white:
a scanner needs the quiet zone, and the dark theme has none. */
.adm-qr { display: flex; align-items: center; justify-content: center; min-height: 88px; padding: 6px; background: #fff; }
.adm-qr img { width: auto; aspect-ratio: 1 / 1; }
.adm-qr span { color: #6b7280; font-size: 10.5px; text-align: center; }
.adm-spacer { flex: 1 1 auto; }
.adm-note { margin: 12px 0; }
.adm-sub { margin: 0 0 14px; }
.adm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
.adm-card {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elev);
padding: 10px;
}
.adm-card img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius-sm); background: var(--bg-sunken); }
/* A frame whose RAW carries no preview of its own: the tile stands in for the
picture and opens the file just the same. */
.lib-noimg {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 4 / 3;
border-radius: var(--radius-sm);
background: var(--bg-sunken);
color: var(--text-dim);
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.14em;
cursor: pointer;
}
.adm-meta { display: flex; flex-direction: column; gap: 2px; font-size: 11.5px; color: var(--text-dim); min-width: 0; }
.adm-meta b { color: var(--text); font-family: var(--mono); }
.adm-meta span { overflow-wrap: anywhere; }
.adm-danger { color: var(--danger); border-color: var(--border); }
.adm-danger:hover:not(:disabled) { border-color: var(--danger); }
/* The catalogue is one screen and never grows: the frame on the stage, the tree
beside it, and the strip pinned to the bottom edge. Whatever overflows scrolls
inside its own panel, so the page itself has no scrollbar to hide the strip
below the fold. Scoped to `.lib-root` — /admin shares the .adm-* blocks and
still scrolls as a page. */
.lib-root { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding-bottom: 12px; }
.lib-root .adm-frame { flex: 1 1 auto; min-height: 0; }
.lib-root .adm-pane { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.lib-root .adm-note { flex: 0 0 auto; }
/* The column: title and the folder's own chips stay put, the split takes what
is left, the strip keeps its natural height on the bottom. */
.lib-root .adm-col { flex: 1 1 auto; min-height: 0; }
/* The column of folders is a list of names and a count — the stage is the point
of the screen, so the column takes the least it can and gives the rest away.
Its width is a variable the divider writes, and the divider itself is the middle
track, thin enough to read as the seam between the two panels. */
.lib-root .adm-col > .adm-split {
flex: 1 1 auto;
min-height: 0;
align-items: stretch;
grid-template-columns: var(--lib-col, 118px) 9px minmax(0, 1fr);
gap: 0;
}
.lib-grip { cursor: col-resize; border-radius: 999px; touch-action: none; }
.lib-grip:hover, .lib-grip:focus-visible { background: var(--accent-soft); outline: none; }
.lib-root .adm-album { padding: 6px 8px; gap: 6px; justify-content: flex-start; }
.lib-root .adm-album-name { flex: 1 1 auto; min-width: 0; }
/* The caret column: pointing right where a row's children are folded away, down
where they are drawn, and empty on a leaf so names at one depth line up. */
.lib-caret {
flex: 0 0 auto;
width: 0;
height: 0;
border-left: 4.5px solid currentColor;
border-top: 3.5px solid transparent;
border-bottom: 3.5px solid transparent;
opacity: 0.5;
transition: transform 0.15s ease;
}
.lib-caret.open { transform: rotate(90deg); }
.lib-caret.leaf { border-left-color: transparent; }
/* A roll being read turns a ring on its own row. */
.lib-spin {
flex: 0 0 auto;
width: 11px;
height: 11px;
border-radius: 999px;
border: 2px solid var(--border);
border-top-color: var(--accent);
animation: lib-spin 0.7s linear infinite;
}
@keyframes lib-spin { to { transform: rotate(360deg); } }
/* The same ring, out in the header: the reading belongs to this tab, not to a
screen, so the studio says so too while it runs. */
.lib-scan { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.lib-spin { animation-duration: 2.4s; }
}
.lib-root .adm-col > .adm-filmstrip { flex: 0 0 auto; }
.lib-root .adm-album-list { max-height: none; flex: 1 1 auto; min-height: 0; }
.lib-root .adm-stage { min-height: 0; overflow: hidden; }
/* The frame fills the stage and the stage fits the window: `object-fit` letterboxes
it inside that box, so a 3:2 or a 4:3 frame stays whole and never pushes the
strip off screen. */
.lib-root .adm-stage-img { flex: 1 1 auto; min-height: 0; width: 100%; aspect-ratio: auto; object-fit: contain; }
.lib-root .adm-stage > .lib-noimg { flex: 1 1 auto; min-height: 0; aspect-ratio: auto; }
.lib-root .adm-stage > .adm-meta,
.lib-root .adm-stage > .chip-row { flex: 0 0 auto; }
/* All thumbs: the wall scrolls down inside the stage, and its own scrollbar on
the right edge is what reaches the frames below. */
.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; }
/* What the thumbnail wall is read through: the four selects and the count of what
is left. One row above the wall, and it does not scroll with it. */
.lib-filters { flex: 0 0 auto; align-items: center; margin-bottom: 8px; }
.lib-filters .adm-input { width: auto; min-width: 0; padding: 4px 8px; font-size: 12px; }
.lib-filters .hint { margin-left: auto; }
/* The frame's own score: five glyphs, lit up to the score it has. The star the
click lands on is the score it gives, so the row is the control. */
.lib-stars { display: flex; gap: 1px; align-items: center; }
.lib-star {
border: 1px solid transparent;
background: none;
border-radius: 6px;
padding: 2px 3px;
font-size: 15px;
line-height: 1.1;
color: var(--text-dim);
cursor: pointer;
}
.lib-star:hover { border-color: var(--border); }
.lib-star.on { color: var(--accent); }
/* One toolbar, not two rows of chrome: ADD FOLDER, the hint, and the pair of view
icons. The hint gives up its width first and ellipsises — the whole sentence is
still there under the pointer. */
.lib-bar { flex: 0 0 auto; align-items: center; margin-bottom: 10px; }
.lib-bar .btn { padding: 5px 10px; font-size: 12.5px; }
.lib-bar-hint { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The views are a glyph each (a frame, a wall of thumbs), so the row stays a row
when the window is narrow. */
.lib-icon { padding: 5px 8px; justify-content: center; }
/* A folder's own menu, on a right click. Fixed, so the tree's own scroll cannot
clip it; the position is clamped to the window when it is drawn. */
.lib-menu {
position: fixed;
z-index: 40;
display: flex;
flex-direction: column;
min-width: 170px;
padding: 4px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.lib-menu-item {
border: 0;
border-radius: var(--radius-sm);
background: none;
padding: 7px 10px;
font-size: 12px;
letter-spacing: 0.04em;
text-align: left;
cursor: pointer;
/* Each command leads with a mark of its own — rename, scan, forget, fold — the
way the tool rail and the view switch do, so a menu read at a glance is a
column of marks rather than a block of uppercase. */
display: flex;
align-items: center;
gap: 8px;
}
.lib-menu-icon { flex: 0 0 auto; width: 12px; text-align: center; opacity: 0.85; }
.lib-menu-item:hover:not(:disabled) { background: var(--accent-soft); color: var(--accent); }
.lib-menu-item:disabled { opacity: 0.45; cursor: not-allowed; }
/* Settings drawer: the tabs that organise the page, plus the three panels. */
/* The management frame: a bordered box whose left column is the menu and whose
right side is the pane for the chosen section. */
.adm-frame {
display: flex;
align-items: stretch;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elev);
overflow: hidden;
}
.adm-side {
flex: 0 0 200px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 10px;
border-right: 1px solid var(--border);
background: var(--bg-sunken);
}
.adm-side-title {
padding: 0 8px 8px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.adm-side-item {
display: block;
padding: 8px 10px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.adm-side-item:hover { background: var(--bg-elev); }
.adm-side-item.on { background: var(--accent); color: var(--accent-fg); font-weight: 600; }
.adm-pane { flex: 1 1 auto; min-width: 0; padding: 14px 16px; }
.adm-table-wrap { overflow-x: auto; }
/* Bulk selection: the three controls above the users table, and the checkbox
column at its head (narrow so the picture column keeps the width). */
.adm-bulk { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
.adm-bulk .btn { margin: 0; }
.adm-check { width: 1%; white-space: nowrap; }
.adm-check input { display: block; cursor: pointer; }
.adm-face {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
overflow: hidden;
background: var(--bg-sunken);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 11px;
font-weight: 600;
}
.adm-face img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* Moderation state next to the account name, and the row's own actions: the
three controls sit side by side and the pane scrolls when it runs out. */
.adm-badge-warn { margin-left: 6px; color: var(--danger); font-size: 11px; }
.adm-table td:last-child { white-space: nowrap; }
.adm-table td:last-child .btn { margin-right: 6px; }
.adm-form { display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
.field-label { font-size: 11.5px; color: var(--text-dim); }
/* --- profile picture ---------------------------------------------------- */
/* One face for every control that names the signed-in user: the workspace
TopBar, the landing chip and the profile form. No picture = initial. */
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 999px;
overflow: hidden;
background: var(--accent-soft);
border: 1px solid var(--accent-line);
color: var(--accent);
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.avatar-sm { width: 20px; height: 20px; font-size: 10px; }
.avatar-row { display: flex; align-items: center; gap: 10px; }
.avatar-row .avatar { width: 46px; height: 46px; font-size: 17px; }
.account-btn { display: inline-flex; align-items: center; gap: 7px; }
/* The signed-in name rides the accent with the face beside it. */
.btn.account-btn { color: var(--accent); }
/* --- stats (admin) ------------------------------------------------------- */
/* The traffic screen: one SVG chart and a column of proportional bar lists.
No charting dependency — a couple of polylines and styled divs are enough. */
.stats-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 14px; }
.stats-bar .hint { margin: 0; }
.stats-kpis { display: flex; flex-wrap: wrap; gap: 12px; margin: 0 0 16px; }
.stats-kpi {
display: flex; flex-direction: column; gap: 2px; min-width: 104px;
padding: 10px 14px; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--bg-elev);
}
.stats-kpi b { font-size: 22px; font-variant-numeric: tabular-nums; }
.stats-kpi span { font-size: 11px; letter-spacing: 0.06em; color: var(--text-dim); }
.stats-chart {
display: block; width: 100%; height: 180px;
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-sunken);
}
.stats-area { fill: var(--accent); opacity: 0.12; }
.stats-line { fill: none; stroke: var(--accent); stroke-width: 1.5; }
.stats-line-2 { stroke: var(--text-dim); }
.stats-legend { display: flex; gap: 14px; margin: 8px 0 18px; font-size: 11px; color: var(--text-dim); }
.stats-legend i { display: inline-block; width: 10px; height: 3px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
.stats-axis { margin: 0 0 18px; }
.stats-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px 26px; }
.stats-h { margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: 0.08em; color: var(--text-dim); }
.stats-list { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
.stats-row { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 12px; }
.stats-key { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.stats-track { height: 8px; border-radius: 4px; background: var(--bg-sunken); overflow: hidden; }
.stats-track i { display: block; height: 100%; border-radius: 4px; background: var(--accent); }
.stats-n { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* --- studio sidebar (DevelopPanels) ------------------------------------- */
/* Lightroom's right column: every panel's knobs are on screen at once, each
panel a disclosure that folds its own rows away. Colour comes from the tokens
only, so a theme or accent change lands here with no rules of its own. */
/* The sidebar (DevelopPanels) draws rows, not chips, so its column is the wide
one: the same width CREATE RECIPES takes, and the chips that are not knobs
(AUTO, TONE CURVE) stack over it, wrapped in the column's own .chip-row. */
.col-dev { width: 320px; }
.dev-panels { display: flex; flex-direction: column; gap: 10px; }
.dev-panel { border-top: 1px solid var(--border-soft); padding-top: 8px; }
.dev-panel-head {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 0; border: 0; background: transparent;
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text); cursor: pointer;
}
.dev-panel-head:hover { color: var(--accent); }
/* The caret is the panel's whole affordance — the head is a line of uppercase
and says nothing about being a control — so it is drawn larger than the label
it sits beside, and takes the accent while the pointer is on the head. */
.dev-caret { font-size: 17px; line-height: 1; opacity: 0.75; }
.dev-panel-head:hover .dev-caret { opacity: 1; }
.dev-panel-body { display: flex; flex-direction: column; gap: 10px; padding-top: 8px; }
/* A row that cannot be edited at all: a PRO knob on the LITE build. It reads as
a row, not as a button — the tag is what says the row is not a slider. */
.dev-row { display: flex; flex-direction: column; gap: 4px; }
.dev-row.locked {
flex-direction: row; align-items: center; gap: 6px;
width: 100%; padding: 0; border: 0; background: transparent;
text-align: left; opacity: 0.5; cursor: not-allowed;
}
.dev-pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
/* WB's two knobs: the track is the hue the value moves towards, the way
Lightroom's temperature and tint sliders are painted. Everything else keeps
the plain input's accent color. */
.track-temp, .track-tint {
height: 4px; border-radius: 999px;
appearance: none; -webkit-appearance: none;
}
.track-temp { background: linear-gradient(90deg, #3f6fd8, #b9c2cc 50%, #e8a33d); }
.track-tint { background: linear-gradient(90deg, #3f9d55, #b9c2cc 50%, #c04ec4); }
.track-temp::-webkit-slider-thumb, .track-tint::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: 12px; height: 12px; border-radius: 50%;
background: var(--text); border: none; cursor: pointer;
}
/* --- responsive --------------------------------------------------------- */
@media (max-width: 860px) {
.workspace { flex-direction: column; }
/* The frame's left column becomes a wrapping row above the pane. */
.adm-frame { flex-direction: column; }
.adm-side { flex: 0 0 auto; flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--border); }
/* The header wraps on narrow screens, so a menu-wrap can land at the start of
a row — the popover is right-anchored to its button and would hang off the
left of the screen (measured: x=-280 of 390px). Anchor it to the full-width
header instead. */
.header { position: relative; }
.menu-wrap { position: static; }
.popover { right: 8px; max-width: calc(100vw - 16px); }
/* The rail takes the shape the Android app gives it: one scrolling row of
text pills, no glyph, uppercase mono, the open tab amber and a step larger.
Android's own rail is exactly this (src/components/ToolRail.tsx). */
.rail {
order: 2;
width: auto;
flex-direction: row;
align-items: center;
gap: 8px;
border-right: none;
border-top: 1px solid var(--border-soft);
overflow-x: auto;
overflow-y: hidden;
padding: 6px 8px;
scrollbar-width: none;
}
.rail::-webkit-scrollbar { display: none; }
/* The column is a row here, and the row is the tabs: a build name at its foot
would sit past the end of a line that already scrolls. It goes. */
.rail-version { display: none; }
.rail button {
flex: 0 0 auto;
flex-direction: row;
align-items: center;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-sunken);
padding: 8px 12px;
font-family: var(--mono);
font-weight: 700;
letter-spacing: 0.1em;
font-size: 10px;
text-transform: uppercase;
}
.rail button .glyph { display: none; }
.rail button[aria-pressed="true"] { font-size: 13px; }
/* The panel takes the phone's own shape: the columns stack into one scroll,
and inside each the chips run as a row again — the phone's chip strip is a
line of pills, never a ladder. What was a left-to-right cascade now reads
top to bottom, which is the same order the Android panel shows. */
.chips {
order: 1;
flex-direction: column;
width: auto;
max-width: none;
max-height: 42vh;
overflow-x: hidden;
overflow-y: auto;
border-right: none;
border-bottom: 1px solid var(--border-soft);
}
.col {
width: auto;
padding: 8px;
gap: 6px;
overflow: visible;
border-right: none;
}
/* One chip per line is the desktop column's shape; a phone wants the row. */
.col .chip-row { flex-direction: row; align-items: center; }
.col .chip { text-align: center; }
.col-slider, .col-create { width: auto; }
.stage { order: 0; flex: 1 1 auto; }
/* The row under the photo wears its icons on a phone. Six labelled buttons wrap
to three lines (measured 141px, 17% of an 844px screen) and take the height
the picture is trying to use; the labels are what wraps, so the labels go —
out of the paint, not out of the button, which keeps the accessible name it
always had. The hint at the end of the row follows them out: it is a
sentence, and a sentence was most of the wrapping. */
.stage .toolbar { gap: 5px; }
.stage .toolbar .btn {
width: 34px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0;
}
.stage .toolbar .btn::before { font-size: 14px; line-height: 1; }
.stage .toolbar .btn .pro { display: none; }
.stage .toolbar .hint:not(.dims) { display: none; }
.stage .toolbar [data-key="clear-photo"]::before { content: '✕'; }
.stage .toolbar [data-key="open-photo"]::before { content: '↻'; }
.stage .toolbar [data-key="open-camera"]::before { content: '◉'; }
.stage .toolbar [data-key="save-original"]::before { content: '⤓'; }
.stage .toolbar [data-key="compare-toggle"]::before { content: '◐'; }
.stage .toolbar [data-key="hist-toggle"]::before { content: '▥'; }
/* The crop frame's four corner nodes. The square stays 16px — that is the size
a corner reads at — but a finger is ~44, so the touch target grows inward
from the node's own centre: the half that would grow outward is the photo's
own edge, which the layer clips anyway (measured before this: a node was
9x16px to a touch, and a thumb a few pixels off it landed on the rect and
MOVED the frame instead of resizing it). The drag maths reads the pointer
against the photo, not against the node, so a larger target does not shift
the corner. */
.crop-handle::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
}