From 2e2b06943d16788dd2788febd6df3115a864904c Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 24 Oct 2026 09:09:28 +0200 Subject: [PATCH] feat(studio): the head band in the dialogs' shape, tools grouped by what they do, book view beside the zoom --- client/e2e/help/journey.guide.ts | 2 +- client/scripts/size-baseline.json | 2 +- client/src/components/Studio/StudioHeader.tsx | 379 ++++++++++++++++++ client/src/components/Studio/StudioShell.tsx | 374 +---------------- client/src/styles/studio-assistant.css | 13 +- client/src/styles/studio-head.css | 173 ++++++++ client/src/styles/studio.css | 77 +--- 7 files changed, 577 insertions(+), 443 deletions(-) create mode 100644 client/src/components/Studio/StudioHeader.tsx create mode 100644 client/src/styles/studio-head.css diff --git a/client/e2e/help/journey.guide.ts b/client/e2e/help/journey.guide.ts index 40210e72c..640f514a3 100644 --- a/client/e2e/help/journey.guide.ts +++ b/client/e2e/help/journey.guide.ts @@ -1300,7 +1300,7 @@ const SCRIPTS: Record = { }, }, { - target: p => p.locator('.st-back'), + target: p => p.locator('.st-head-close'), }, ], }, diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 20ecc0adc..ce5d5cb38 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -26,5 +26,5 @@ "src/pages/atlas/useAtlas.ts": 1211, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3270, - "src/styles/studio.css": 2009 + "src/styles/studio.css": 1936 } diff --git a/client/src/components/Studio/StudioHeader.tsx b/client/src/components/Studio/StudioHeader.tsx new file mode 100644 index 000000000..a3e34b0bc --- /dev/null +++ b/client/src/components/Studio/StudioHeader.tsx @@ -0,0 +1,379 @@ +import { Check, ChevronDown, ClipboardPaste, Download, Redo2, Sparkles, Undo2, X } from 'lucide-react'; +import { useEffect, useRef, useState, type RefObject } from 'react'; +import { useStudioStore } from '../../store/studioStore'; +import '../../styles/studio-head.css'; +import HelpButton from '../Help/HelpButton'; +import { Tooltip } from '../shared/Tooltip'; +import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'; +import { PeerBadges } from './PeerBadges'; +import { SaveIndicator } from './SaveIndicator'; +import { StudioAssistantToggle } from './StudioAssistantPanel'; +import type { Studio } from './StudioShell'; +import { StudioWordmark } from './StudioWordmark'; +import { TrimField } from './TrimField'; +import type { StudioAssistant } from './useStudioAssistant'; + +/** + * The Studio's head band, in the shape every TREK dialog has: a raised tile on + * the left, a small line above the title, the title, a line under it, and the + * round close button at the far end. + * + * The tile is the journey's cover and the title its name, so it is always clear + * whose book this is. What the book is printed as and whether it is saved sit + * under the title, because they describe it. The tools on the right are grouped + * by what they do: who else is here, editing (undo, redo, paste), making + * (auto layout, the assistant), and getting the book out (help, export). How + * the page is looked at, the book view, lives with the zoom under the page. + */ +export function StudioHeader({ + s, + onExport, + assistant, +}: { + s: Studio; + onExport: () => void; + assistant: StudioAssistant; +}) { + return ( +
+ {s.coverUrl ? : null} + +
+
+ + {s.t('journey.studio.beta')} +
+

{s.journey?.title || s.t('journey.title')}

+
+ + s.loadDoc(s.acceptTheirs(current))} + onKeepMine={s.keepMine} + onRetry={() => { + void s.saveNow(); + }} + /> +
+
+ +
+ + +
+ + + + + + + +
+ +
+ + {/* The book's assistant; only where it can run for this user. */} + +
+ + {/* Studio has no navbar, so the help center's button rides on its own bar. */} + + + + + + +
+
+ ); +} + +function IconTool({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + + + ); +} + +/** + * Closes a popover on a pointer press outside it and on Escape. Escape is + * swallowed, or the shell's own handler would close Studio itself. + */ +function useDismiss(box: RefObject, open: boolean, setOpen: (open: boolean) => void) { + useEffect(() => { + if (!open) return; + const onDown = (e: PointerEvent) => { + if (!box.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + setOpen(false); + } + }; + document.addEventListener('pointerdown', onDown); + document.addEventListener('keydown', onKey, true); + return () => { + document.removeEventListener('pointerdown', onDown); + document.removeEventListener('keydown', onKey, true); + }; + }, [box, open, setOpen]); +} + +/** + * Paste what was copied onto the page on screen (#2316). Beside undo and redo + * because it is the same kind of thing, and there so the keyboard shortcut is + * not the only way to find it. + */ +function PasteButton({ s }: { s: Studio }) { + const clipboard = useStudioStore((st) => st.clipboard); + const paste = useStudioStore((st) => st.paste); + const label = s.t('journey.studio.paste'); + return ( + + + + ); +} + +/** + * Lay out again: this spread, or the whole book. + * + * A split button rather than two: they are the same action at two scopes, and + * the spread is the one you want nine times out of ten. Rebuilding the whole + * book is behind the chevron, where it cannot be hit by accident, and both are + * ordinary undo steps. + */ +function AutoLayoutButton({ s }: { s: Studio }) { + const [open, setOpen] = useState(false); + const box = useRef(null); + useDismiss(box, open, setOpen); + + return ( +
+ + + {open && ( +
+ + +
+ )} +
+ ); +} + +/** + * The format, as a chip under the book's name: it says what the book is + * printed as, and opening it changes that. + * + * A native was the first draft and it was wrong: the browser draws the - * list in its own chrome, so it ignores the theme entirely and drops a list that - * runs off the bottom of the screen. This is a small popover instead — themed, - * anchored under the bar, and it shows each format's proportions rather than - * only naming them. - */ -function FormatPicker({ s }: { s: Studio }) { - const [open, setOpen] = useState(false) - const box = useRef(null) - - useEffect(() => { - if (!open) return - const onDown = (e: PointerEvent) => { - if (!box.current?.contains(e.target as Node)) setOpen(false) - } - const onKey = (e: KeyboardEvent) => { - // Swallow it, or the shell's Escape handler would close Studio itself. - if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) } - } - document.addEventListener('pointerdown', onDown) - document.addEventListener('keydown', onKey, true) - return () => { - document.removeEventListener('pointerdown', onDown) - document.removeEventListener('keydown', onKey, true) - } - }, [open]) - - const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210'] - - return ( -
- - - {open && ( -
- {PAGE_PRESET_ORDER.map(id => { - const p = PAGE_PRESETS[id] - const on = id === s.preset - return ( - - ) - })} - - {/* - The two fields live under the list rather than behind a "custom" - entry that opens a second popover: picking the custom row and then - typing into the fields it reveals is two steps for one decision, and - typing into them *is* picking custom — the hook switches the preset - for you. - */} -
-
- s.setPageSize('w', v)} - /> - × - s.setPageSize('h', v)} - /> - mm -
- - {/* - What the press takes off, and what it must not print into. - - Under the trim size because they answer the same question — how big - is this book, really — and because a vendor states all three in one - breath. They were fixed at 3 and 5, which is what most photo-book - printers ask for and is useless to the one that asks for 5 and 10. - */} -
- s.setPageEdge('bleed', v)} - /> - - s.setPageEdge('safe', v)} - /> - mm -
-
- )} -
- ) -} - -function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) { +function Workbench({ s, bookView, setBookView }: { s: Studio; bookView: boolean; setBookView: (on: boolean) => void }) { return (
@@ -538,6 +192,16 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) { + {/* How the page is looked at, so with the zoom rather than in the head band. */} + {/* The page, as a file. diff --git a/client/src/styles/studio-assistant.css b/client/src/styles/studio-assistant.css index 86e92225a..1c57532cc 100644 --- a/client/src/styles/studio-assistant.css +++ b/client/src/styles/studio-assistant.css @@ -3,9 +3,8 @@ inspector keep all their room. */ /* The toggle is the bar's newest tool, so it gives up its label first: from 1360 - to 1519 it is an icon while the others keep theirs, and the bar fits where it - did before. The journey's name gives way too rather than push the bar past - the window. */ + to 1519 it is an icon while the others keep theirs, and the head band fits + where it did before. */ @media (max-width: 1519px) { .trek-dash .st-assistant-toggle .st-tool-label { display: none; @@ -15,14 +14,6 @@ } } -.trek-dash .st-bar .st-back { - min-width: 0; -} - -.trek-dash .st-bar .st-back > svg { - flex: none; -} - .trek-dash .st-assistant-notice { margin: 8px 12px 0; } diff --git a/client/src/styles/studio-head.css b/client/src/styles/studio-head.css new file mode 100644 index 000000000..fed32ec6f --- /dev/null +++ b/client/src/styles/studio-head.css @@ -0,0 +1,173 @@ +/* ----------------- head band (StudioHeader.tsx) ----------------- * + * The shape every TREK dialog head has: a tinted band, a raised tile, a small + * line above the title and a round close button at the end. The band keeps the + * glass of the old bar under its tint, so the journey still shows through. + */ +.trek-dash .st-head { + /* Its own stacking context, above the rails: the panels below carry z-index 1, + and without this the format menu would open behind them. */ + position: relative; + z-index: 5; + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding-inline: 14px 16px; + background: + linear-gradient( + color-mix(in srgb, var(--accent) 7%, transparent), + color-mix(in srgb, var(--accent) 7%, transparent) + ), + var(--glass-bg); + border-bottom: 1px solid var(--glass-border); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); +} + +/* The journey's cover, so it is always clear whose book this is. */ +.trek-dash .st-head-tile { + width: 44px; + height: 44px; + flex: none; + overflow: hidden; + border-radius: 13px; + background: var(--surface); + box-shadow: var(--sh-sm); +} +.trek-dash .st-head-tile img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.trek-dash .st-head-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1 1 auto; +} + +/* The wordmark and the beta mark: what this screen is, quietly, above its name. */ +.trek-dash .st-head-eyebrow { + display: flex; + align-items: center; + gap: 7px; + height: 17px; + color: var(--ink-2); +} +.trek-dash .st-head-eyebrow svg { + flex: none; +} + +.trek-dash .st-head-title { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ink); + font-size: 16px; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.25; +} + +.trek-dash .st-head-sub { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +/* The format, as a chip that opens the format menu. */ +.trek-dash .st-head-chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 22px; + padding-inline: 7px 8px; + border-radius: 999px; + background: var(--surface); + box-shadow: var(--sh-xs); + color: var(--ink-2); + font-size: 11.5px; + font-weight: 500; + white-space: nowrap; + transition: + color 0.16s, + box-shadow 0.16s; +} +.trek-dash .st-head-chip:hover { + color: var(--ink); + box-shadow: var(--sh-sm); +} +.trek-dash .st-head-chip:focus { + outline: none; +} +.trek-dash .st-head-chip:focus-visible { + box-shadow: + 0 0 0 2px var(--accent-soft), + 0 0 0 3px var(--accent); +} +.trek-dash .st-head-chip-shape { + flex: none; + border: 1.5px solid currentColor; + border-radius: 2px; + opacity: 0.7; +} +/* The chip sits on the left, so its menu opens towards the page, not off the window. */ +.trek-dash .st-head-menu { + inset-inline-start: 0; + inset-inline-end: auto; +} + +.trek-dash .st-head-tools { + display: flex; + align-items: center; + gap: 8px; + flex: none; + margin-inline-start: auto; +} + +/* Tools that belong together share one raised group, like a segmented control. */ +.trek-dash .st-head-group { + display: flex; + align-items: center; + gap: 2px; + padding: 3px; + border-radius: 12px; + background: var(--surface); + box-shadow: var(--sh-xs); +} + +.trek-dash .st-head-close { + display: grid; + place-items: center; + width: 36px; + height: 36px; + flex: none; + margin-inline-start: 2px; + border-radius: 999px; + background: var(--surface); + box-shadow: var(--sh-sm); + color: var(--ink-3); + transition: color 0.16s; +} +.trek-dash .st-head-close:hover { + color: var(--ink); +} +.trek-dash .st-head-close:focus { + outline: none; +} +.trek-dash .st-head-close:focus-visible { + box-shadow: + 0 0 0 2px var(--accent-soft), + 0 0 0 3px var(--accent); +} + +/* The book view moved to the zoom, where the page's view lives; on reads as pressed. */ +.trek-dash .st-zoom button.is-on { + background: color-mix(in oklch, var(--accent) 14%, transparent); + color: var(--ink); +} diff --git a/client/src/styles/studio.css b/client/src/styles/studio.css index ad4d63b9e..b9ad0a5cf 100644 --- a/client/src/styles/studio.css +++ b/client/src/styles/studio.css @@ -25,7 +25,7 @@ z-index: 9999; inset: 16px; display: grid; - grid-template-rows: 56px minmax(0, 1fr); + grid-template-rows: 72px minmax(0, 1fr); border-radius: var(--r-xl); overflow: hidden; border: 1px solid var(--glass-border); @@ -88,66 +88,7 @@ .st-exit { animation-name: st-fade-out; } } -/* ----------------- top bar ----------------- */ -.trek-dash .st-bar { - /* Its own stacking context, above the rails: the panels below carry z-index 1, - and without this the format menu would open behind them. */ - position: relative; - z-index: 5; - display: flex; - align-items: center; - gap: 14px; - padding-block: 0; - padding-inline: 10px 18px; - min-width: 0; - background: var(--glass-bg); - border-bottom: 1px solid var(--glass-border); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); -} - -.trek-dash .st-back { - display: flex; - align-items: center; - gap: 8px; - height: 36px; - padding: 0 12px 0 8px; - border-radius: 999px; - color: var(--ink-2); - font-size: 13px; - font-weight: 500; - letter-spacing: -0.01em; - max-width: 240px; - transition: background .18s, color .18s; -} -.trek-dash .st-back:hover { background: var(--accent-soft); color: var(--ink); } -.trek-dash .st-back .st-back-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* The journey's own cover, so you never lose track of whose book this is. */ -.trek-dash .st-cover { - width: 26px; - height: 26px; - border-radius: 7px; - object-fit: cover; - flex: none; - border: 1px solid var(--line-2); - background: var(--surface-2); -} - -.trek-dash .st-bar-group { - display: flex; - align-items: center; - gap: 7px; - flex: none; - margin-inline-start: auto; -} -/* The undo/redo pair belongs together; everything else in the group breathes. */ -.trek-dash .st-bar-group .st-tool.is-icon + .st-tool.is-icon { margin-inline-start: -5px; } - +/* ----------------- tools (head band and elsewhere) ----------------- */ .trek-dash .st-tool { display: inline-flex; align-items: center; @@ -1506,20 +1447,6 @@ color: var(--ink); } -/* ----------------- brand ----------------- * - * The lockup takes the bar's ink through `currentColor`, so it is dark on a - * light theme and light on a dark one from one copy of the artwork. - */ -.trek-dash .st-brand { - display: flex; - align-items: center; - gap: 9px; - flex: 0 0 auto; - color: var(--ink); - min-width: 0; -} -.trek-dash .st-brand svg { flex: none; } - /* Small, quiet and set apart — a beta mark states a fact about the feature; it is not a call to action and should not read as one. */ .trek-dash .st-beta {