Files
RecipesCam/docker/frontend/src/styles/app.css
T
3dtours 56d4b9df67 web: give LIGHT a tone curve, edited on the graph drawn over the photo
The LIGHT rail was sliders only, so the one control that describes a tone
mapping rather than a scalar had nowhere to live. It now has a TONE CURVE chip;
pressing it puts a curve graph on the photo itself — four channels, RGB plus R,
G and B, exactly the shape Lightroom's point curve has — and dragging a point
bends the picture under it while you drag.

A recipe carries the curve as `adjustments.toneCurve`, an optional map from
channel to point list, `Partial<Record<'rgb'|'r'|'g'|'b', [number, number][]>>`.
The field is optional and the API stores the recipe JSON opaquely, so every
recipe and session written before this commit loads unchanged and simply has no
curve; nothing on the API or in the database moved.

The renderer never sees the points. `shared/utils/toneCurve.ts` turns them into
a 256-entry table per channel and the shader looks the table up in a 256x1
texture: SkSL indexes uniform arrays by constant only, so a per-pixel lookup
has to come from a texture, and a table is the cheaper shape anyway — one
`lut.eval(vec2(v * 255 + 0.5, 0.5))` per channel. The interpolation between
points is a monotone cubic (Fritsch–Carlson) rather than a natural spline,
because a spline overshoots between two close points and that overshoot is the
classic tone-curve tell, a bright halo beside a lifted shadow; a monotone cubic
through the points bends through them and never turns back on itself. The table
is built per channel and then composited through the master, the order the graph
draws it in, so an R point in the shadows survives an RGB contrast S and both
land where the lines say.

Render passes: the curve rides the existing `renderPhoto`, as pass 3e, last —
after the stock, the matrix, the mixer and the seasonal grade, so a point placed
on the graph is the last word on that pixel. Preview and export both call
`renderPhoto`, so the two agree by construction rather than by two matching
implementations. The pass wraps whatever shader the pipeline had built
(`paintShader ?? imageShaderOf()`) as a child of the curve shader, and counts
towards `graded` for the same reason the tone shader does: the curve reads the
matrix's output, so when there is a matrix it has to be in the pixels the curve
samples. Turning the curve on costs one extra render pass and nothing else; off,
`curveIsActive` is false and the pass is not built at all.

That pass is also where this spent its time being invisible. The curve data
reached the recipe and the pixels did not move: `Skia.Image.MakeImage` does not
exist in the shim, so the call threw a TypeError inside the render, the preview
effect's catch swallowed it into `setError('err.generic')`, and the chip, the
graph and the recipe all looked healthy while the canvas kept the old frame. The
fix is in `skiaShim.ts`: CanvasKit keeps that factory top-level (`Skia.MakeImage`)
and only puts the encoded and lazy ones under `Image.`, and its ImageInfo insists
on an explicit `colorSpace` where RN Skia's does not — everything this pipeline
builds is sRGB, so the shim fills it in and the call site keeps RN Skia's shape.
Reproduced in Node first (`curve-skia-lab.cjs`, scratchpad): the shim's call
throws, the translated one returns a 256x1 image.

`ToneCurvePanel.tsx` is the graph: a 224px SVG over the photo's layout box, no
zoom transform, grid plus a dashed diagonal, the composite drawn as a ghost
behind a channel line so a channel edit is still visible against the other
three. Ends are pinned to x 0 and 1, a point cannot be dragged past its
neighbours (2% of the axis is the closest they may sit) and cannot be dragged
out of the square, so the graph can never describe a curve the renderer cannot
apply. One pointerdown grabs the nearest point inside 11px or adds one on the
line under the cursor and keeps dragging, so a click is a point and a drag is a
bend. Deleting a point is the graph's own double-click, not the circle's, and it
has to be: grabbing a point takes pointer capture, so the click that follows is
delivered to the SVG rather than the circle under the cursor.

RESET clears the whole graph, all four channels, and hands back an empty object
that `App.tsx` maps to `undefined` so the recipe drops the field rather than
keeping a `toneCurve: {}` — the field's presence is what "this picture has a
curve" means, and an empty map that means the same as no map is a state two
pieces of code would eventually disagree about. One undo step per visit to the
graph, the rule the ruler and the watermark box already ride: a drag is one
edit, not one per pointer move.

No new i18n keys: the chip and the panel labels are literal uppercase, the same
as EXPOSURE and STRAIGHTEN beside them. Not PRO-gated — the curve is a LIGHT
control like the rest of the tab.

Verified:
  tone-curve-probe.cjs (new, scratchpad) — a 256x256 greyscale ramp uploaded to
    http://localhost:8090, pixels read back off the built app. 33 PASS, 0 FAIL,
    no page errors. The ramp is a ramp before (9..246), a flat curve is two
    points and no pass, the graph is drawn on the photo (graph 729,280 240x291
    against photo 719,325 256x256), every stop of the ramp lands on the drawn
    curve (worst deviation 1), black lifts to 132 while white holds 246 -> 252,
    a point dragged up bends the line itself (M0.00 112.00 L3.50 110.2...), the
    R tab takes the graph over while the composite stays visible behind it and R
    drives red at black to 255 with G and B still on the composite (133,132
    against 132), the recipe carries toneCurve, it survives a reload (254 -> 254,
    chip still amber), a click adds a point and a double-click removes it again,
    RESET returns the ramp to its start (worst 0) and drops the field, and close
    takes the graph off the photo.
  tone-curve-math.cjs (new, scratchpad) — the panel's and the table's own
    arithmetic, 11/11: the ends pin and sort, a dragged point lifts where the
    graph says, a steeper segment never turns back on itself, a channel curve
    runs before the composite, a click lands on the line, two points cannot
    share a spot, an end cannot leave the axis, and the two ends survive a
    delete where a middle point does not.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10.
  web tsc --noEmit clean.

ponytail: the graph is anchored over the photo, not draggable — it sits at the
photo's own layout box the way the crop frame and the straighten ruler do, and
the one time it would want to move it is when the photo under it is small, at
which point a token drag offset is cheaper than the second positioning system.
Parametric curves (Lightroom's shadows/highlights/darks/lights) are not here:
the point curve is the one the request asked for, and a parametric curve is a
second graph, not a second line on this one — add it as another channel row when
someone asks. The LUT is a texture rather than Skia's table colour filter
because CanvasKit 0.42 has no ColorFilter.MakeTable. The panel's graph size and
hit radius are literals, since exactly one graph exists.
2026-09-23 20:07:51 +07:00

944 lines
38 KiB
CSS

/* 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; }
.recipe-name {
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
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); }
/* --- 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; }
/* 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); }
/* 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 .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; }
/* --- 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; }
.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; }
.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; }
.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;
}
/* 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; 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; }
/* --- 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); }
.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); }
/* 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; }
/* --- 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; }
.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; }
}