web: the phone's bar wears icons, stays at the top and ends on EXPORT — the four words wrapped the header to 135px over a 390px screen, 88px now, and the tab pills were 28px tall under a thumb, 44px now

LIBRARY, RESET, SAVE PHOTO and EXPORT are icons on a phone from here on. The
recipe is the one the file already used for the stage's toolbar: font-size 0
takes the word out of the paint, never out of the button, so every accessible
name is exactly the word that is no longer drawn, and a ::before carries the
glyph (▤ ⟲ ⤓ ↗ — all of them already spoken in this app). The row's own content
measured 990px wide with the words and 716px with the icons, and the bar it sat
in measured 135px tall on a 390px screen before this and 88px after: three rows
to two.

RESET also gains a data-key (`reset-look`) — the CSS has to be able to name it.

EXPORT moves to the end of the <header>, after the three menus. It is the end of
the job and now the end of the row, which is the button the eye should land on
once the edit is done.

SAVE PHOTO's PRO marker goes with the word: a 9px badge inside a 34px button is
a word in a box too small for it, and the count that shares the label has no room
either. The button keeps its title, and the count is the one thing the phone
loses here — the wide screen still counts.

The header is now `position: sticky; top: 0; z-index: 5` on a phone. The page
never scrolls (the stage does), so nothing moved before this and nothing moves
now — but that is a promise the layout can keep rather than a coincidence of who
scrolls. `.adm-bar` already carried the same three lines.

The tab strip at the foot grows to the size a thumb is: pills 10px on 8x12 and
28px tall become 12px on 0x16 with a 44px floor, and the bar's own padding goes
6x8 to 8x10. Measured: a pill 28px to 44px, the bar 40px to 61px.

This commit also lands the strip work from the session before it, which was
written and probed but never committed: under 860px the rail of tabs and the
tab's chips become one bar at a time (`.workspace.strip-open`), a TABS chip
stands the rail back up, the open chip's sub-chips stand over the bar as 60px
tiles, and the ruler is drawn as ticks on a bare input. It shares app.css with
the bar above, so the phone's block lands in one piece.

Checks:
- npm run build (tsc --noEmit + vite) clean.
- The ten browser-free image checks (tone-base, highlight-knee, half,
  white-level, auto-tone, preview-match, raw-develop, roll-walk, wb-table,
  mask-wb) all pass.
- Playwright at 390x844, light and dark (scratchpad topbar-probe.mjs): header
  sticky, top 0px, z-index 5, y=0 h=88, lastElementChild data-key
  export-photo; the four buttons 34x30 with font-size 0 and a 15px ::before;
  the rail at y=783 h=61 with a 93x44 pill at 12px; the theme popover inside
  the screen (x=47, right=382); every scroller set to 400 leaves headerY at 0;
  strip-open keeps headerY 0 with the rail display:none; no page errors.
- Playwright at 1280 (scratchpad topbar-desktop.mjs): header static, one row
  50px, the same four buttons at 14px with no ::before, EXPORT last.

Skipped: no new aria-label — the words the icons replace are still the buttons'
own text, so nothing needs one. Add one only if a label leaves the DOM.
This commit is contained in:
2026-10-01 06:21:52 +07:00
parent 91aeacbe46
commit 6bbf18dc44
7 changed files with 291 additions and 39 deletions
+221 -25
View File
@@ -114,6 +114,10 @@
/* 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); }
/* Two things that belong to the phone alone: the way back from a tab's strip to
the rail, and the mark a sub-chip wears on its tile. A desktop draws neither —
it reads the rail beside the chips, and a chip there is its label. */
.tabs-back, .chip-icon { display: none; }
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
/* The rule a strip is read across (the mixer's PICK, its bands, the three
knobs): a line of its own, so it breaks the row it is written into whichever
@@ -1442,60 +1446,99 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
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; }
/* The bar stays at the top of the screen: the page itself never scrolls (the
stage does), but the declaration is what makes that a promise rather than a
coincidence. Sticky, not fixed — a positioned box still anchors the menus. */
.header { position: sticky; top: 0; z-index: 5; }
.menu-wrap { position: static; }
.popover { right: 8px; max-width: calc(100vw - 16px); }
/* Four labelled buttons — LIBRARY, RESET, SAVE PHOTO, EXPORT — are most of what
wraps the bar onto a third row on a 390px screen: the row's own content is
990px wide with the words and 716px with icons, and the bar measured 135px
tall before this, 88px after. They wear icons instead. Font-size 0 hides the
text from the paint, not from the DOM: the accessible name each button
already had is the word that is no longer drawn. */
.header [data-key="nav-library"],
.header [data-key="reset-look"],
.header [data-key="save-photo"],
.header [data-key="export-photo"] {
width: 34px;
height: 30px;
padding: 0;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0;
}
.header [data-key="nav-library"]::before,
.header [data-key="reset-look"]::before,
.header [data-key="save-photo"]::before,
.header [data-key="export-photo"]::before { font-size: 15px; line-height: 1; }
/* The count is the label it was hiding in, so the PRO marker goes with it —
the badge would be a 9px word in a 34px button. */
.header [data-key="save-photo"] .pro { display: none; }
.header [data-key="nav-library"]::before { content: '▤'; }
.header [data-key="reset-look"]::before { content: '⟲'; }
.header [data-key="save-photo"]::before { content: '⤓'; }
.header [data-key="export-photo"]::before { content: '↗'; }
/* --- the phone's workspace ---------------------------------------------
One strip at a time in the bottom bar: the rail of tabs, or — once a tab
is picked — that tab's own chips standing in the rail's place (the bar
never grows a second row, see .strip-open). What sits above the bar is
built out of the columns themselves, so .chips stops being a box and its
columns stand in the workspace's own stack, ordered bottom-up: the tab's
chips in the bar, the open chip's sub-chips over them, the open ruler over
those, and the photo taking all that is left. */
.chips { display: contents; }
.workspace:not(.strip-open) .chips { display: none; }
.workspace.strip-open .rail { display: none; }
.stage-slot { order: 0; min-height: 0; }
.col[data-col="slider"] { order: 1; }
.col-sub { order: 2; }
.col-main { order: 3; }
.rail { order: 4; }
/* 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). */
text pills, no glyph, uppercase mono, the open tab amber. It is the bar at
the foot of the screen, so it carries the home-indicator inset itself. */
.rail {
order: 2;
width: auto;
flex-direction: row;
align-items: center;
gap: 8px;
gap: 10px;
border-right: none;
border-top: 1px solid var(--border-soft);
overflow-x: auto;
overflow-y: hidden;
padding: 6px 8px;
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
scrollbar-width: none;
}
.rail::-webkit-scrollbar { display: none; }
/* The column is a row here, and the row is the tabs: a build name at its foot
would sit past the end of a line that already scrolls. It goes. */
.rail-version { display: none; }
/* A pill the thumb can actually hit: 10px/8x12 rendered ~28px tall, under half
a finger. The floor is now a 44px target and the label steps up with it. */
.rail button {
flex: 0 0 auto;
flex-direction: row;
align-items: center;
justify-content: center;
min-height: 44px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-sunken);
padding: 8px 12px;
padding: 0 16px;
font-family: var(--mono);
font-weight: 700;
letter-spacing: 0.1em;
font-size: 10px;
font-size: 12px;
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);
}
.rail button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent-line); }
.col {
width: auto;
padding: 8px;
@@ -1507,7 +1550,160 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.col .chip-row { flex-direction: row; align-items: center; }
.col .chip { text-align: center; }
.col-slider, .col-create { width: auto; }
.stage { order: 0; flex: 1 1 auto; }
/* --- the tab's own chips: the bottom bar -------------------------------- */
/* A strip reads across, never down: what does not fit scrolls sideways, so
the bar keeps exactly one chip's height whatever the tab holds. */
.col-main {
flex-direction: row;
align-items: center;
gap: 6px;
max-height: 46vh;
overflow-x: auto;
overflow-y: hidden;
background: var(--bg);
border-top: 1px solid var(--border-soft);
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
}
/* The strip is as wide as its chips and the bar scrolls: a row that was
allowed to shrink would squeeze under its own contents and the chips would
paint over the ones beside them. */
.col-main > *, .col-sub:not(.wm-extra) > * { flex: 0 0 auto; }
.col-main:not(.col-create):not(.col-dev) .chip-row { flex-wrap: nowrap; }
.col-main .chip { flex: 0 0 auto; }
/* The rule a strip is read across runs the other way here: in a row of chips
it divides the line, it does not break it. */
.col-main .chip-rule { flex: 0 0 auto; width: 1px; height: 18px; margin: 0 3px; }
/* RESET is ruled off the tab's chips at the foot of a desktop column; in the
bar the rule moves to their side. */
.reset-row { margin: 0; padding: 0 0 0 8px; border-top: none; border-left: 1px solid var(--border); }
/* LIGHT's panels and CREATE's form are not a strip of chips: they keep the
column's shape and scroll down instead of across, in the bar's place. */
.col-main.col-create, .col-main.col-dev {
flex-direction: column;
align-items: stretch;
overflow-x: hidden;
overflow-y: auto;
}
/* The way back to the rail, which this strip is standing in for. */
.tabs-back { display: flex; }
/* --- a chip's own sub-chips: the row over the bar ----------------------- */
.col-sub:not(.wm-extra) {
flex-direction: row;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-top: 1px solid var(--border-soft);
overflow-x: auto;
overflow-y: hidden;
}
.col-sub:not(.wm-extra) .chip-row { flex-wrap: nowrap; }
/* A sub-chip whose options are buttons wears them as tiles — a mark over a
short name, in a bordered square — the way an icon set is read on a phone.
A list of NAMES (the recipes, a frame's history) keeps the pill: there the
name is the whole point and a square would cut it off. */
.col-sub[data-col="options"] .chip,
.col-sub[data-col="mask"] .chip {
position: relative;
flex: 0 0 auto;
width: 60px;
height: 60px;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 4px 3px;
border-radius: var(--radius-sm);
}
.chip-icon { display: block; font-family: var(--mono); font-size: 13px; font-weight: 700; color: var(--text-dim); }
.col-sub .chip.on .chip-icon { color: inherit; }
.col-sub[data-col="options"] .chip .chip-label,
.col-sub[data-col="mask"] .chip .chip-label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
letter-spacing: 0.04em;
}
/* PRO is a mark on the corner of the tile, not a third line in it. */
.col-sub[data-col="options"] .chip .pro,
.col-sub[data-col="mask"] .chip .pro { position: absolute; top: 2px; right: 3px; font-size: 7px; }
/* The strip's own name is a heading, not a pick: it keeps the leading edge of
the line and drops the button around it. */
.col-sub .chip[data-key^="hint-"] {
width: auto;
height: auto;
padding: 0 4px 0 0;
border: 0;
background: none;
opacity: 1;
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-dim);
}
/* --- the open ruler: the row over those -------------------------------- */
/* A ruler is the photo's own scale, not a panel: no box, no fill, just the
ticks. They are drawn in the theme's own ink — dark on the light theme,
light on the dark — at two weights, a finer mark every eighth of the track
and a heavier one every fourth. */
.col[data-col="slider"] {
width: auto;
padding: 2px 8px 6px;
background: none;
border: 0;
}
.col[data-col="slider"] .slider-row {
flex-direction: row;
align-items: center;
gap: 8px;
padding: 0;
border: 0;
background: none;
}
.col[data-col="slider"] input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 22px;
background: none;
}
/* One rule per engine: a grouped selector that names a pseudo-element the
engine does not know — `-moz-range-track` in Chromium — is dropped whole,
and the ticks went with it (measured: no track at all). */
.col[data-col="slider"] input[type="range"]::-webkit-slider-runnable-track {
height: 22px;
background-color: transparent;
background-image:
repeating-linear-gradient(90deg, var(--tick-strong) 0 1px, transparent 1px 32px),
repeating-linear-gradient(90deg, var(--tick) 0 1px, transparent 1px 8px);
}
.col[data-col="slider"] input[type="range"]::-moz-range-track {
height: 22px;
background-color: transparent;
background-image:
repeating-linear-gradient(90deg, var(--tick-strong) 0 1px, transparent 1px 32px),
repeating-linear-gradient(90deg, var(--tick) 0 1px, transparent 1px 8px);
}
.col[data-col="slider"] input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 3px;
height: 22px;
border: 0;
border-radius: 1px;
background: var(--accent);
}
.col[data-col="slider"] input[type="range"]::-moz-range-thumb {
-webkit-appearance: none;
appearance: none;
width: 3px;
height: 22px;
border: 0;
border-radius: 1px;
background: var(--accent);
}
/* The row under the photo wears its icons on a phone. Six labelled buttons wrap
to three lines (measured 141px, 17% of an 844px screen) and take the height
the picture is trying to use; the labels are what wraps, so the labels go —