Files
RecipesCam/docker/frontend/src/styles/app.css
T
3dtours 01af863fd8 web: let a photo's repairs read as the marks they are, not as a field of rings
HEAL draws every repair it holds as the circle the shader fills — the brush's
own size at the moment it was laid — so a photo with two repairs has two rings
and a photo with twenty has twenty. Each one is loud enough to be the loudest
thing on the picture, and none of them is the one the user is looking for: the
speck that was mended, and the patch borrowed to mend it, are both inside the
ring that covers them. What the ring is for is the spot that is about to be
taken hold of, and there is only ever one of those.

A repair now wears its circle only while it is the spot in hand — the one the
pointer is over, the one just laid, which is the one the user is watching, or
the one a press chose, which is the one wearing the ×. The moment the pointer
walks elsewhere the ring goes, and what is left is the pair the renderer works
with: the patch it borrowed, still dashed, and a soft print of the place it
mended. The ring comes back under the pointer, which is where the spot is taken
hold of again; the grab was always the geometry — the circle plus a few pixels
of slop — and never depended on the ring being drawn, so an idle spot is as easy
to move as a ringed one (measured: a 60,40px drag on an idle spot moved it
970.2,390.7 -> 1030.3,430.7).

The run a stroke left is the mark of that stroke, so the spots the band stands
for keep their boxes and give up their own edges as before, and no print is laid
under the band: a row of blurred discs under one translucent band is a second,
blurrier band. The one spot of the run that is in hand wears its circle again
over the band, because that is the spot a press would take hold of.

MOSAIC keeps its rings. Its spots are not repairs to be placed and moved — the
circle is the only thing that says where the cover is, and the cover is the
edit.

ponytail: the print is a blurred translucent disc (14% grey, a soft dark
shadow, 1px of blur) rather than a tint read off the pixels under it, so it
reads on a photo of any tone without a second pass over the render — the
upgrade path, if a print that sits on the repaired pixels themselves is wanted,
is the shader the repair already runs through. The ring under the pointer is
reported from the pointer's own move events rather than from a hit test per
frame, so the one case it does not cover is the pointer that has not moved since
the repair landed; that case is covered by the spot just laid being in hand, and
a twitch of the mouse covers it everywhere else. The idle/hover split is HEAL's
only: MOSAIC's spots keep the border they always had, which is the asymmetry
this tool set already had about moving them.

Verified: heal-idle-probe.cjs (new, 23 checks) 23 PASS / 0 FAIL on :5199 and on
:8090 after deploy — a press on the speck lays one repair and that repair is in
hand, so it wears its circle (rgba(255,255,255,0.85)); a repair the pointer has
left is idle with the ring given up (rgba(0,0,0,0)), the print of the place it
mended left (rgba(127,127,127,0.14)) and the patch it borrowed still dashed and
in the same place (640.3,297.9 vs 640.4,297.9); the ring comes back under the
pointer; a press chooses it, puts its × on the photo and the × stays while the
pointer walks off; laying the next repair takes the choice and the × off the
first and gives its ring up; hovering the second leaves the first idle; a drag
still takes hold of an idle spot; a run of 13 spots shows the band, no print
under it and no ring while the pointer is away, and the spot of the run under
the pointer wears its circle again; a MOSAIC spot keeps its ring; 0 page errors.
brush-edit-probe.cjs 33/0 — its "every spot of the run gave up its own edge" now
reads "but the one in hand", which is the rule this commit adds, and its "a spot
with no neighbour keeps its own circle" passes off the repair just laid being in
hand. heal-zoom-drag-probe 28/0 (the wheel, the pan, the × and taking hold of a
repair, at the fit and at x1.52, unchanged), heal-blotch-lab 12, heal-edge-lab 9,
heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49,
heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all
green on :8090. Regression: landing-test 172/0, pro-gate-test 27/0,
award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0.
Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean.
2026-09-24 08:10:27 +07:00

1056 lines
43 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; }
.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; 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); }
/* 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; }
}