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
+49 -9
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ToolRail, type TabId } from './ui/ToolRail';
import { TABS, ToolRail, type TabId } from './ui/ToolRail';
import { ChipRow, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
import { ImageStage } from './ui/ImageStage';
import { StraightenRuler } from './ui/StraightenRuler';
@@ -369,6 +369,11 @@ export function Workspace() {
// Where on the photo that colour was read: the mixer's panel hangs there.
const [pickedAt, setPickedAt] = useState<{ fx: number; fy: number } | null>(null);
const [openGroup, setOpenGroup] = useState<StripKey | null>(null);
// The phone's bottom bar holds one strip at a time: the rail of tabs while it
// is false, and the picked tab's own chips in the same place while it is true
// (see .workspace.strip-open in app.css). Desktop reads the rail and the chips
// side by side and never looks at this.
const [stripOpen, setStripOpen] = useState(false);
// The TEMP preset the user last tapped. AUTO and DAYLIGHT are the same pair on
// the engine, so the pair alone cannot say which chip is lit — the phone keeps
// the same memory (wbChoice in AdjustmentPanel).
@@ -3013,19 +3018,31 @@ export function Workspace() {
...(masks.length ? [{ key: 'masks-clear', label: 'CLEAR', onClick: clearMasks }] : []),
];
// The mark a sub-chip wears on its tile on a phone (see .chip-icon and
// .col-sub .chip in app.css): the initials of a phrase — COLOR TEMP -> CT,
// PRO NEG HI -> PNH — and the opening letter of a single word. A table of
// hand-picked glyphs for every option the app owns would be a second set of
// names to keep, and the name under the mark is already there to read.
const chipIcon = (d: string): string => {
const words = d.split(/\s+/).filter(Boolean);
return words.length > 1 ? words.map((w) => w[0]).join('').slice(0, 3).toUpperCase() : d[0].toUpperCase();
};
const withIcon = (c: ChipDef): ChipDef => (c.divider || c.disabled ? c : { ...c, icon: chipIcon(c.label) });
const stripChips = (g: Exclude<StripKey, 'wm'>): ChipDef[] => {
// The two tool strips hold chips that arm the pointer rather than options to
// pick, so groupDefs has nothing to say about them.
if (g === 'fix') return fixChips;
if (g === 'gradient') return gradientChips;
return [
if (g === 'fix') return fixChips.map(withIcon);
if (g === 'gradient') return gradientChips.map(withIcon);
const out: ChipDef[] = [
{ key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} },
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick, g === 'style' ? simPreview : undefined).map((c) =>
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick, g === 'style' ? simPreview : undefined).map((c) => ({
...c,
// A hand-straightened frame is no longer on a clean quarter turn: the
// angle chips let go the moment the fine angle leaves 0, so the strip
// shows which of the two is actually steering the photo.
g === 'rotate' && straighten !== 0 && c.key !== 'rotate:reset' ? { ...c, active: false } : c
),
...(g === 'rotate' && straighten !== 0 && c.key !== 'rotate:reset' ? { active: false } : {}),
})),
// The frame is on the photo but the crop is not in force until APPLY.
...(g === 'crop' && cropEditing
? [
@@ -3072,6 +3089,7 @@ export function Workspace() {
]
: []),
];
return out.map(withIcon);
};
// RECIPES = this account's own files (SAVE RECENT) + what this session made
@@ -3273,8 +3291,19 @@ export function Workspace() {
</div>
) : null}
<div className="workspace">
<ToolRail tab={tab} onTab={setTab} onDropRecipe={favoriteById} signedIn={!!user} />
<div className={`workspace${stripOpen ? ' strip-open' : ''}`}>
{/* The tab opens its own strip in the bottom bar's place; the bar holds
one strip at a time, so a pick both switches the tab and hands the
bar over to it (see .strip-open in app.css). */}
<ToolRail
tab={tab}
onTab={(id) => {
setTab(id);
setStripOpen(true);
}}
onDropRecipe={favoriteById}
signedIn={!!user}
/>
<div className="chips">
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
@@ -3284,6 +3313,17 @@ export function Workspace() {
}`}
data-col="main"
>
{/* The phone's way back. The bar holds this tab's own chips, so the
rail that picked them is one tap away — carrying the glyph of
the tab standing open, since the rail is what it stands in for
and the rail is off the screen. A desktop reads the rail beside
the chips and never draws this (see .tabs-back in app.css). */}
<div className="chip-row tabs-back">
<button type="button" className="chip" data-key="tabs-back" onClick={() => setStripOpen(false)}>
<span className="chip-icon" aria-hidden="true">{TABS.find((e) => e.id === tab)?.glyph ?? '‹'}</span>
<span className="chip-label">{t('nav.tabs')}</span>
</button>
</div>
{tab === 'create' ? (
<RecipeCreatePanel
currentBase={recipe.baseFilter}