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:
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { ChipRow, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
|
||||||
import { ImageStage } from './ui/ImageStage';
|
import { ImageStage } from './ui/ImageStage';
|
||||||
import { StraightenRuler } from './ui/StraightenRuler';
|
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.
|
// 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 [pickedAt, setPickedAt] = useState<{ fx: number; fy: number } | null>(null);
|
||||||
const [openGroup, setOpenGroup] = useState<StripKey | 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 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 engine, so the pair alone cannot say which chip is lit — the phone keeps
|
||||||
// the same memory (wbChoice in AdjustmentPanel).
|
// the same memory (wbChoice in AdjustmentPanel).
|
||||||
@@ -3013,19 +3018,31 @@ export function Workspace() {
|
|||||||
...(masks.length ? [{ key: 'masks-clear', label: 'CLEAR', onClick: clearMasks }] : []),
|
...(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[] => {
|
const stripChips = (g: Exclude<StripKey, 'wm'>): ChipDef[] => {
|
||||||
// The two tool strips hold chips that arm the pointer rather than options to
|
// The two tool strips hold chips that arm the pointer rather than options to
|
||||||
// pick, so groupDefs has nothing to say about them.
|
// pick, so groupDefs has nothing to say about them.
|
||||||
if (g === 'fix') return fixChips;
|
if (g === 'fix') return fixChips.map(withIcon);
|
||||||
if (g === 'gradient') return gradientChips;
|
if (g === 'gradient') return gradientChips.map(withIcon);
|
||||||
return [
|
const out: ChipDef[] = [
|
||||||
{ key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} },
|
{ 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
|
// 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
|
// angle chips let go the moment the fine angle leaves 0, so the strip
|
||||||
// shows which of the two is actually steering the photo.
|
// 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.
|
// The frame is on the photo but the crop is not in force until APPLY.
|
||||||
...(g === 'crop' && cropEditing
|
...(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
|
// RECIPES = this account's own files (SAVE RECENT) + what this session made
|
||||||
@@ -3273,8 +3291,19 @@ export function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="workspace">
|
<div className={`workspace${stripOpen ? ' strip-open' : ''}`}>
|
||||||
<ToolRail tab={tab} onTab={setTab} onDropRecipe={favoriteById} signedIn={!!user} />
|
{/* 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">
|
<div className="chips">
|
||||||
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
|
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
|
||||||
@@ -3284,6 +3313,17 @@ export function Workspace() {
|
|||||||
}`}
|
}`}
|
||||||
data-col="main"
|
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' ? (
|
{tab === 'create' ? (
|
||||||
<RecipeCreatePanel
|
<RecipeCreatePanel
|
||||||
currentBase={recipe.baseFilter}
|
currentBase={recipe.baseFilter}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const en: Dict = {
|
|||||||
'nav.account': 'Account',
|
'nav.account': 'Account',
|
||||||
'nav.theme': 'Theme',
|
'nav.theme': 'Theme',
|
||||||
'nav.language': 'Language',
|
'nav.language': 'Language',
|
||||||
|
// The phone's way back from a tab's strip to the rail that opened it.
|
||||||
|
'nav.tabs': 'TABS',
|
||||||
'nav.back': 'Back to home',
|
'nav.back': 'Back to home',
|
||||||
'nav.guest': 'Guest',
|
'nav.guest': 'Guest',
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const vi = {
|
|||||||
'nav.account': 'Tài khoản',
|
'nav.account': 'Tài khoản',
|
||||||
'nav.theme': 'Giao diện',
|
'nav.theme': 'Giao diện',
|
||||||
'nav.language': 'Ngôn ngữ',
|
'nav.language': 'Ngôn ngữ',
|
||||||
|
'nav.tabs': 'TAB',
|
||||||
'nav.back': 'Về trang chủ',
|
'nav.back': 'Về trang chủ',
|
||||||
'nav.admin': 'Quản trị',
|
'nav.admin': 'Quản trị',
|
||||||
'nav.photos': 'Ảnh của tôi',
|
'nav.photos': 'Ảnh của tôi',
|
||||||
|
|||||||
@@ -114,6 +114,10 @@
|
|||||||
/* RESET is the way out of an edit, not one more chip: a rule and some air keep
|
/* 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. */
|
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); }
|
.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; }
|
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
/* The rule a strip is read across (the mixer's PICK, its bands, the three
|
/* 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
|
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
|
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
|
left of the screen (measured: x=-280 of 390px). Anchor it to the full-width
|
||||||
header instead. */
|
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; }
|
.menu-wrap { position: static; }
|
||||||
.popover { right: 8px; max-width: calc(100vw - 16px); }
|
.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
|
/* 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.
|
text pills, no glyph, uppercase mono, the open tab amber. It is the bar at
|
||||||
Android's own rail is exactly this (src/components/ToolRail.tsx). */
|
the foot of the screen, so it carries the home-indicator inset itself. */
|
||||||
.rail {
|
.rail {
|
||||||
order: 2;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-top: 1px solid var(--border-soft);
|
border-top: 1px solid var(--border-soft);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
padding: 6px 8px;
|
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.rail::-webkit-scrollbar { display: none; }
|
.rail::-webkit-scrollbar { display: none; }
|
||||||
/* The column is a row here, and the row is the tabs: a build name at its foot
|
/* 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. */
|
would sit past the end of a line that already scrolls. It goes. */
|
||||||
.rail-version { display: none; }
|
.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 {
|
.rail button {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--bg-sunken);
|
background: var(--bg-sunken);
|
||||||
padding: 8px 12px;
|
padding: 0 16px;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.rail button .glyph { display: none; }
|
.rail button .glyph { display: none; }
|
||||||
.rail button[aria-pressed="true"] { font-size: 13px; }
|
.rail button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent-line); }
|
||||||
/* 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 {
|
.col {
|
||||||
width: auto;
|
width: auto;
|
||||||
padding: 8px;
|
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-row { flex-direction: row; align-items: center; }
|
||||||
.col .chip { text-align: center; }
|
.col .chip { text-align: center; }
|
||||||
.col-slider, .col-create { width: auto; }
|
.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
|
/* 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
|
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 —
|
the picture is trying to use; the labels are what wraps, so the labels go —
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
--text: #14181d;
|
--text: #14181d;
|
||||||
--text-dim: #5c6773;
|
--text-dim: #5c6773;
|
||||||
--danger: #c2402f;
|
--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-l: 42%;
|
||||||
--accent-fg: #ffffff;
|
--accent-fg: #ffffff;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.06);
|
--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: #f2f3f5;
|
||||||
--text-dim: #9aa2ad;
|
--text-dim: #9aa2ad;
|
||||||
--danger: #ff6b57;
|
--danger: #ff6b57;
|
||||||
|
--tick: rgba(242, 243, 245, 0.2);
|
||||||
|
--tick-strong: rgba(242, 243, 245, 0.44);
|
||||||
--accent-l: 60%;
|
--accent-l: 60%;
|
||||||
--accent-fg: #0a0a0a;
|
--accent-fg: #0a0a0a;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 10px 30px rgba(0, 0, 0, 0.45);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 10px 30px rgba(0, 0, 0, 0.45);
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface ChipDef {
|
|||||||
// CSS colour drawn as a dot on the chip's leading edge — the HSL mixer's band
|
// 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.
|
// chips name a colour, so they show it too.
|
||||||
color?: string;
|
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
|
// 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
|
// 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
|
// 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 ? <span className="chip-dot" data-color={chip.color} style={{ background: chip.color }} /> : null}
|
{chip.color ? <span className="chip-dot" data-color={chip.color} style={{ background: chip.color }} /> : null}
|
||||||
|
{chip.icon ? <span className="chip-icon" aria-hidden="true">{chip.icon}</span> : null}
|
||||||
{/* The name is a box, not the bare text node it used to be: a flex
|
{/* 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
|
container turns a loose text node into an anonymous item of its
|
||||||
own, and on iOS that item stops being painted until the column
|
own, and on iOS that item stops being painted until the column
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function TopBar({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<a className="btn ghost" data-key="nav-library" href="/library">{t('nav.library')}</a>
|
<a className="btn ghost" data-key="nav-library" href="/library">{t('nav.library')}</a>
|
||||||
<button type="button" className="btn ghost" onClick={onReset}>{t('act.reset')}</button>
|
<button type="button" className="btn ghost" data-key="reset-look" onClick={onReset}>{t('act.reset')}</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn ghost"
|
className="btn ghost"
|
||||||
@@ -144,10 +144,6 @@ export function TopBar({
|
|||||||
: t('save.photo', { n: photoCount, max: photoMax })}
|
: t('save.photo', { n: photoCount, max: photoMax })}
|
||||||
{!pro ? <span className="pro">PRO</span> : null}
|
{!pro ? <span className="pro">PRO</span> : null}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn primary" data-key="export-photo" disabled={exporting} onClick={onExport}>
|
|
||||||
{exporting ? t('act.exporting') : t('act.export')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="menu-wrap">
|
<div className="menu-wrap">
|
||||||
<button type="button" className="btn ghost" title={t('nav.theme')} onClick={() => setMenu(menu === 'theme' ? null : 'theme')}>
|
<button type="button" className="btn ghost" title={t('nav.theme')} onClick={() => setMenu(menu === 'theme' ? null : 'theme')}>
|
||||||
◐
|
◐
|
||||||
@@ -262,6 +258,12 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 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. */}
|
||||||
|
<button type="button" className="btn primary" data-key="export-photo" disabled={exporting} onClick={onExport}>
|
||||||
|
{exporting ? t('act.exporting') : t('act.export')}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user