diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 2307da0..b962b3f 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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(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): 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() { ) : null} -
- +
+ {/* 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). */} + { + setTab(id); + setStripOpen(true); + }} + onDropRecipe={favoriteById} + signedIn={!!user} + />
{/* 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). */} +
+ +
{tab === 'create' ? ( *, .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 — diff --git a/docker/frontend/src/styles/tokens.css b/docker/frontend/src/styles/tokens.css index 212e59f..c7cb219 100644 --- a/docker/frontend/src/styles/tokens.css +++ b/docker/frontend/src/styles/tokens.css @@ -44,6 +44,10 @@ --text: #14181d; --text-dim: #5c6773; --danger: #c2402f; + /* The phone's ruler is drawn, not painted: the ticks are the page's own ink + at two weights — a dark ink on the light theme, a light one on the dark. */ + --tick: rgba(20, 24, 29, 0.2); + --tick-strong: rgba(20, 24, 29, 0.44); --accent-l: 42%; --accent-fg: #ffffff; --shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.06); @@ -58,6 +62,8 @@ --text: #f2f3f5; --text-dim: #9aa2ad; --danger: #ff6b57; + --tick: rgba(242, 243, 245, 0.2); + --tick-strong: rgba(242, 243, 245, 0.44); --accent-l: 60%; --accent-fg: #0a0a0a; --shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 10px 30px rgba(0, 0, 0, 0.45); diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index d6d3966..7b3ac44 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -17,6 +17,10 @@ export interface ChipDef { // CSS colour drawn as a dot on the chip's leading edge — the HSL mixer's band // chips name a colour, so they show it too. color?: string; + // The button's face on a phone, where a sub-chip is a tile: a short mark over + // the name (see .col-sub .chip in app.css). Desktop never paints it — the + // label alone is what a chip reads as in a column. + icon?: string; // The chip's own FILL and the text colour that fill can carry (WB's preset // table: the swatch the pick paints, printed on the button itself). `bg` wins // over the class's own background, so an active button keeps its swatch and @@ -99,6 +103,7 @@ export function ChipRow({ chips, grid }: { chips: ChipDef[]; grid?: boolean }) { }} > {chip.color ? : null} + {chip.icon ? : null} {/* The name is a box, not the bare text node it used to be: a flex container turns a loose text node into an anonymous item of its own, and on iOS that item stops being painted until the column diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx index 1edf55f..c0a2f59 100644 --- a/docker/frontend/src/ui/TopBar.tsx +++ b/docker/frontend/src/ui/TopBar.tsx @@ -126,7 +126,7 @@ export function TopBar({ ) : null} {t('nav.library')} - + - -
) : null}
+ {/* Last in the row on purpose: EXPORT is the end of the job, so it sits at + the end of the bar — the one button the eye lands on when the edit is + done. */} + ); }