feat(studio): the head band in the dialogs' shape, tools grouped by what they do, book view beside the zoom

This commit is contained in:
Maurice
2026-10-24 09:09:28 +02:00
parent 240c224014
commit 2e2b06943d
7 changed files with 577 additions and 443 deletions
+1 -1
View File
@@ -1300,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => p.locator('.st-back'),
target: p => p.locator('.st-head-close'),
},
],
},
+1 -1
View File
@@ -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
}
@@ -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 (
<header className="st-bar st-head">
<span className="st-head-tile">{s.coverUrl ? <img src={s.coverUrl} alt="" /> : null}</span>
<div className="st-head-text">
<div className="st-head-eyebrow">
<StudioWordmark style={{ height: 17 }} />
<span className="st-beta">{s.t('journey.studio.beta')}</span>
</div>
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
<div className="st-head-sub">
<FormatPicker s={s} />
<SaveIndicator
state={s.saveState}
t={s.t}
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
onKeepMine={s.keepMine}
onRetry={() => {
void s.saveNow();
}}
/>
</div>
</div>
<div className="st-head-tools">
<PeerBadges peers={s.peers} t={s.t} />
<div className="st-head-group">
<IconTool label={s.t('journey.studio.undo')} disabled={!s.canUndo} onClick={s.undo}>
<Undo2 size={15} />
</IconTool>
<IconTool label={s.t('journey.studio.redo')} disabled={!s.canRedo} onClick={s.redo}>
<Redo2 size={15} />
</IconTool>
<PasteButton s={s} />
</div>
<div className="st-head-group">
<AutoLayoutButton s={s} />
{/* The book's assistant; only where it can run for this user. */}
<StudioAssistantToggle assistant={assistant} />
</div>
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
<HelpButton className="st-tool is-icon" />
<button
type="button"
className="st-tool is-primary"
onClick={onExport}
disabled={!s.doc}
title={s.t('journey.studio.export')}
>
<Download size={14} />
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
</button>
<Tooltip label={s.t('journey.studio.backToJourney')}>
<button
type="button"
onClick={s.close}
className="st-head-close"
aria-label={s.t('journey.studio.backToJourney')}
>
<X size={16} strokeWidth={2.2} />
</button>
</Tooltip>
</div>
</header>
);
}
function IconTool({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Tooltip label={label}>
<button type="button" className="st-tool is-icon" disabled={disabled} onClick={onClick} aria-label={label}>
{children}
</button>
</Tooltip>
);
}
/**
* 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<HTMLElement | null>, 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 (
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
<button
type="button"
className="st-tool is-icon"
disabled={!clipboard || !s.canEdit}
onClick={() => paste(s.activeSpread)}
aria-label={label}
>
<ClipboardPaste size={15} />
</button>
</Tooltip>
);
}
/**
* 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<HTMLDivElement>(null);
useDismiss(box, open, setOpen);
return (
<div className="st-picker" ref={box}>
<button
type="button"
className="st-tool"
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
title={s.t('journey.studio.autoLayout')}
>
<Sparkles size={14} />
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
<ChevronDown size={13} style={{ opacity: 0.5 }} />
</button>
{open && (
<div className="st-menu" role="menu">
<button
type="button"
className="st-menu-item"
role="menuitem"
disabled={!s.canRelayoutSpread}
onClick={() => {
s.relayoutCurrentSpread();
setOpen(false);
}}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
<span className="st-menu-dim">
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
</span>
</span>
</button>
<button
type="button"
className="st-menu-item"
role="menuitem"
onClick={() => {
s.relayoutBook();
setOpen(false);
}}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
</span>
</button>
</div>
)}
</div>
);
}
/**
* 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 <select> 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 chip, 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<HTMLDivElement>(null);
useDismiss(box, open, setOpen);
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210'];
const width = s.preset === 'custom' ? s.page.pageWidth : active.pageWidthMm;
const height = s.preset === 'custom' ? s.page.pageHeight : active.pageHeightMm;
return (
<div className="st-picker" ref={box}>
<button
type="button"
className="st-head-chip"
onClick={() => setOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={s.t('journey.studio.format')}
>
<span
className="st-head-chip-shape"
style={
width >= height
? { width: 12, height: Math.max(6, Math.round((12 * height) / width)) }
: { height: 12, width: Math.max(6, Math.round((12 * width) / height)) }
}
/>
<span>
{s.preset === 'custom'
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
: s.t(active.labelKey)}
</span>
<ChevronDown size={12} style={{ opacity: 0.55 }} />
</button>
{open && (
<div className="st-menu st-head-menu" role="listbox">
{PAGE_PRESET_ORDER.map((id) => {
const p = PAGE_PRESETS[id];
const on = id === s.preset;
return (
<button
type="button"
key={id}
role="option"
aria-selected={on}
className={`st-menu-item ${on ? 'is-active' : ''}`}
onClick={() => {
s.setPreset(id);
setOpen(false);
}}
>
<span className="st-menu-tile">
<span
className="st-menu-shape"
style={
p.pageWidthMm >= p.pageHeightMm
? { width: 24, height: Math.round((24 * p.pageHeightMm) / p.pageWidthMm) }
: { height: 24, width: Math.round((24 * p.pageWidthMm) / p.pageHeightMm) }
}
/>
</span>
<span className="st-menu-text">
<span className="st-menu-name">{s.t(p.labelKey)}</span>
<span className="st-menu-dim">
{p.pageWidthMm} × {p.pageHeightMm} mm
</span>
</span>
{on && <Check size={14} />}
</button>
);
})}
{/*
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.
*/}
<div className="st-menu-sep" />
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.width')}
value={s.page.pageWidth}
onCommit={(v) => s.setPageSize('w', v)}
/>
<span className="st-menu-times">×</span>
<TrimField
label={s.t('journey.studio.height')}
value={s.page.pageHeight}
onCommit={(v) => s.setPageSize('h', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
{/*
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.
*/}
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.bleed')}
value={s.page.bleed}
min={0}
max={20}
step={0.5}
onCommit={(v) => s.setPageEdge('bleed', v)}
/>
<span className="st-menu-times" />
<TrimField
label={s.t('journey.studio.safeArea')}
value={s.page.safe}
min={0}
max={40}
step={0.5}
onCommit={(v) => s.setPageEdge('safe', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
</div>
)}
</div>
);
}
+19 -355
View File
@@ -1,12 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router'
import {
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
Redo2, Sparkles, Undo2,
} from 'lucide-react'
import { BookOpen, Download, Maximize2, Minus, Plus } from 'lucide-react'
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
import { foliosOf } from './bookSheets'
import { StudioSidebar } from './StudioSidebar'
import type { RailSection } from './sidebar/railSections'
@@ -15,21 +11,15 @@ import { useAuthStore } from '../../store/authStore'
import { StudioCanvas } from './StudioCanvas'
import { StudioInspector } from './StudioInspector'
import { StudioPaletteScope } from './paint/StudioPalette'
import { StudioWordmark } from './StudioWordmark'
import { SaveIndicator } from './SaveIndicator'
import { downloadSpread } from './spreadFile'
import { StudioExport } from './StudioExport'
import { PeerBadges } from './PeerBadges'
import { TrimField } from './TrimField'
import { Tooltip } from '../shared/Tooltip'
import { useStudioStore } from '../../store/studioStore'
import { StudioAssistantColumn, StudioAssistantToggle, StudioPageStage } from './StudioAssistantPanel'
import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
import { StudioAssistantColumn, StudioPageStage } from './StudioAssistantPanel'
import { useStudioAssistant } from './useStudioAssistant'
import { StudioHeader } from './StudioHeader'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
import './bookFontFaces'
import { useWarmBookFaces } from './bookFontWarm'
import HelpButton from '../Help/HelpButton'
/**
* The Studio shell: top bar, page rail, workbench, inspector.
@@ -93,13 +83,7 @@ export default function StudioShell() {
aria-modal="true"
aria-label={s.t('journey.studio.title')}
>
<StudioBar
s={s}
bookView={bookView}
setBookView={setBookView}
onExport={() => setExporting(true)}
assistant={assistant}
/>
<StudioHeader s={s} onExport={() => setExporting(true)} assistant={assistant} />
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
{s.providerPick && s.journey && (
<ProviderPicker
@@ -132,7 +116,7 @@ export default function StudioShell() {
section={railSection}
onSection={setRailSection}
/>
<Workbench s={s} bookView={bookView} />
<Workbench s={s} bookView={bookView} setBookView={setBookView} />
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
<StudioInspector
spreadIndex={s.activeSpread}
@@ -164,339 +148,9 @@ export default function StudioShell() {
)
}
type Studio = ReturnType<typeof useJourneyStudio>
export type Studio = ReturnType<typeof useJourneyStudio>
function StudioBar({
s, bookView, setBookView, onExport, assistant,
}: {
s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void
assistant: StudioAssistant
}) {
return (
<div className="st-bar">
<button type="button" onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
<ArrowLeft size={16} />
{s.coverUrl
? <img src={s.coverUrl} alt="" className="st-cover" />
: <span className="st-cover" />}
<span className="st-back-label">{s.journey?.title || s.t('journey.title')}</span>
</button>
<div className="st-sep" />
{/*
The lockup, not a name field. The book is the journey's, and it was
already titled after it — a second place to type the same name is a
field whose only job is to be left alone. What the bar is short of is
somewhere to say what this thing *is*.
*/}
<div className="st-brand">
<StudioWordmark style={{ height: 26 }} />
<span className="st-beta">{s.t('journey.studio.beta')}</span>
</div>
<PeerBadges peers={s.peers} t={s.t} />
<SaveIndicator
state={s.saveState}
t={s.t}
onAcceptTheirs={current => s.loadDoc(s.acceptTheirs(current))}
onKeepMine={s.keepMine}
onRetry={() => { void s.saveNow() }}
/>
<div className="st-bar-group">
<button type="button"
className={`st-tool ${bookView ? 'is-on' : ''}`}
onClick={() => setBookView(!bookView)}
title={s.t('journey.studio.bookView')}
>
<BookOpen size={14} />
<span className="st-tool-label">{s.t('journey.studio.bookView')}</span>
</button>
<div className="st-sep" />
<FormatPicker s={s} />
<div className="st-sep" />
<button type="button"
className="st-tool is-icon"
disabled={!s.canUndo}
onClick={s.undo}
title={s.t('journey.studio.undo')}
aria-label={s.t('journey.studio.undo')}
>
<Undo2 size={15} />
</button>
<button type="button"
className="st-tool is-icon"
disabled={!s.canRedo}
onClick={s.redo}
title={s.t('journey.studio.redo')}
aria-label={s.t('journey.studio.redo')}
>
<Redo2 size={15} />
</button>
<PasteButton s={s} />
<div className="st-sep" />
<AutoLayoutButton s={s} />
{/* The book's assistant, as a column beside the page; only where it can run for this user. */}
<StudioAssistantToggle assistant={assistant} />
<button type="button"
className="st-tool is-primary"
onClick={onExport}
disabled={!s.doc}
title={s.t('journey.studio.export')}
>
<Download size={14} />
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
</button>
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
<HelpButton className="st-tool is-icon" />
</div>
</div>
)
}
/**
* 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.
*/
/**
* 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 (
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
<button type="button"
className="st-tool is-icon"
disabled={!clipboard || !s.canEdit}
onClick={() => paste(s.activeSpread)}
aria-label={label}
>
<ClipboardPaste size={15} />
</button>
</Tooltip>
)
}
function AutoLayoutButton({ s }: { s: Studio }) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
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)
}
}, [open])
return (
<div className="st-picker" ref={box}>
<button type="button"
className="st-tool"
onClick={() => setOpen(o => !o)}
aria-haspopup="menu"
aria-expanded={open}
title={s.t('journey.studio.autoLayout')}
>
<Sparkles size={14} />
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
<ChevronDown size={13} style={{ opacity: .5 }} />
</button>
{open && (
<div className="st-menu" role="menu">
<button type="button"
className="st-menu-item"
role="menuitem"
disabled={!s.canRelayoutSpread}
onClick={() => { s.relayoutCurrentSpread(); setOpen(false) }}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
<span className="st-menu-dim">
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
</span>
</span>
</button>
<button type="button"
className="st-menu-item"
role="menuitem"
onClick={() => { s.relayoutBook(); setOpen(false) }}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
</span>
</button>
</div>
)}
</div>
)
}
/**
* The format picker.
*
* A native <select> 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<HTMLDivElement>(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 (
<div className="st-picker" ref={box}>
<button type="button"
className="st-tool"
onClick={() => setOpen(o => !o)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={s.t('journey.studio.format')}
>
<span className="st-tool-label">
{s.preset === 'custom'
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
: s.t(active.labelKey)}
</span>
<ChevronDown size={13} style={{ opacity: .5 }} />
</button>
{open && (
<div className="st-menu" role="listbox">
{PAGE_PRESET_ORDER.map(id => {
const p = PAGE_PRESETS[id]
const on = id === s.preset
return (
<button type="button"
key={id}
role="option"
aria-selected={on}
className={`st-menu-item ${on ? 'is-active' : ''}`}
onClick={() => { s.setPreset(id); setOpen(false) }}
>
<span className="st-menu-tile">
<span
className="st-menu-shape"
style={
p.pageWidthMm >= p.pageHeightMm
? { width: 24, height: Math.round(24 * p.pageHeightMm / p.pageWidthMm) }
: { height: 24, width: Math.round(24 * p.pageWidthMm / p.pageHeightMm) }
}
/>
</span>
<span className="st-menu-text">
<span className="st-menu-name">{s.t(p.labelKey)}</span>
<span className="st-menu-dim">{p.pageWidthMm} × {p.pageHeightMm} mm</span>
</span>
{on && <Check size={14} />}
</button>
)
})}
{/*
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.
*/}
<div className="st-menu-sep" />
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.width')}
value={s.page.pageWidth}
onCommit={v => s.setPageSize('w', v)}
/>
<span className="st-menu-times">×</span>
<TrimField
label={s.t('journey.studio.height')}
value={s.page.pageHeight}
onCommit={v => s.setPageSize('h', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
{/*
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.
*/}
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.bleed')}
value={s.page.bleed}
min={0}
max={20}
step={0.5}
onCommit={v => s.setPageEdge('bleed', v)}
/>
<span className="st-menu-times" />
<TrimField
label={s.t('journey.studio.safeArea')}
value={s.page.safe}
min={0}
max={40}
step={0.5}
onCommit={v => s.setPageEdge('safe', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
</div>
)}
</div>
)
}
function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
function Workbench({ s, bookView, setBookView }: { s: Studio; bookView: boolean; setBookView: (on: boolean) => void }) {
return (
<div className="st-work-wrap">
<div className="st-work" ref={s.workRef}>
@@ -538,6 +192,16 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
<button type="button" onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
<Maximize2 size={14} />
</button>
{/* How the page is looked at, so with the zoom rather than in the head band. */}
<button type="button"
onClick={() => setBookView(!bookView)}
aria-pressed={bookView}
aria-label={s.t('journey.studio.bookView')}
title={s.t('journey.studio.bookView')}
className={bookView ? 'is-on' : undefined}
>
<BookOpen size={14} />
</button>
{/*
The page, as a file.
+2 -11
View File
@@ -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;
}
+173
View File
@@ -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);
}
+2 -75
View File
@@ -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 {