fix(journey): the Studio export dialog in the new look, and the Atlas country card draws its flag

The export dialog was the last popup on the old card: it is now the shared
dialog frame with its options as rows, and it takes Escape for itself so
the key no longer reaches Studio behind it. Its stylesheet goes.

The Atlas country card printed the flag emoji, which Windows draws as the
two letters of the country code; it uses the shared CountryFlag now.
This commit is contained in:
Maurice
2026-09-28 21:22:46 +02:00
parent acd003ea67
commit 2c486e6832
4 changed files with 90 additions and 208 deletions
+79 -89
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { BookOpen, FileText, Printer, Scissors, X } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { BookOpen, FileText, Printer, Scissors } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import type { BookDocument } from '@trek/shared'
import { BookSheetsView } from './BookSheetsView'
import { sheetBox, sheetsFor, type SheetMode } from './bookSheets'
@@ -38,6 +39,7 @@ export function StudioExport({
/** Set once the user has asked for it — this is what triggers the render. */
const [building, setBuilding] = useState(false)
const stage = useRef<HTMLDivElement>(null)
const labelId = useId()
const sheets = sheetsFor(doc, mode)
@@ -76,78 +78,71 @@ export function StudioExport({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [building])
// Studio closes itself on Escape. While this dialog is open the key is its own,
// so it is taken in the capture phase before the editor's handler sees it.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.stopPropagation()
onClose()
}
document.addEventListener('keydown', onKey, true)
return () => document.removeEventListener('keydown', onKey, true)
}, [onClose])
return (
<div className="st-ex" role="dialog" aria-modal="true" aria-label={t('journey.studio.export')}>
<div className="st-ex-card">
<div className="st-ex-head">
<span>{t('journey.studio.export')}</span>
<button type="button" className="st-ex-x" onClick={onClose} aria-label={t('common.close')}>
<X size={15} />
</button>
</div>
<div className="st-ex-body">
<Choice<SheetMode>
label={t('journey.studio.exportLayout')}
options={[
{
id: 'pages',
icon: FileText,
name: t('journey.studio.exportPages'),
hint: t('journey.studio.exportPagesHint'),
},
{
id: 'spreads',
icon: BookOpen,
name: t('journey.studio.exportSpreads'),
hint: t('journey.studio.exportSpreadsHint'),
},
]}
value={mode}
onPick={setMode}
<>
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><Printer size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('journey.studio.export')}
/>
<div className="st-ex-field">
<span className="st-ex-label">{t('journey.studio.exportFinishing')}</span>
<button type="button"
className={`st-ex-opt ${marks ? 'is-on' : ''}`}
onClick={() => setMarks(!marks)}
aria-pressed={marks}
>
<Scissors size={15} />
<span className="st-ex-text">
<span className="st-ex-name">{t('journey.studio.exportMarks')}</span>
<span className="st-ex-hint">
{t('journey.studio.exportMarksHint', { bleed: doc.page.bleed })}
</span>
</span>
</button>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={() => setBuilding(true)} disabled={building} icon={<Printer size={14} strokeWidth={2} />}>
{t('journey.studio.exportOpen')}
</DialogButton>
</DialogFooter>
)}
>
<DialogSection label={t('journey.studio.exportLayout')}>
<div className="flex flex-col gap-2">
<Option icon={FileText} name={t('journey.studio.exportPages')} hint={t('journey.studio.exportPagesHint')} on={mode === 'pages'} onClick={() => setMode('pages')} />
<Option icon={BookOpen} name={t('journey.studio.exportSpreads')} hint={t('journey.studio.exportSpreadsHint')} on={mode === 'spreads'} onClick={() => setMode('spreads')} />
</div>
</DialogSection>
<p className="st-ex-note">
{t('journey.studio.exportNote', {
sheets: sheets.length,
width: round1(box.width),
height: round1(box.height),
})}
</p>
</div>
<DialogSection label={t('journey.studio.exportFinishing')}>
<Option icon={Scissors} name={t('journey.studio.exportMarks')} hint={t('journey.studio.exportMarksHint', { bleed: doc.page.bleed })} on={marks} onClick={() => setMarks(!marks)} />
</DialogSection>
<div className="st-ex-foot">
<button type="button" className="st-ex-btn" onClick={onClose}>{t('common.cancel')}</button>
<button type="button" className="st-ex-btn is-primary" onClick={() => setBuilding(true)} disabled={building}>
<Printer size={14} />
<span>{t('journey.studio.exportOpen')}</span>
</button>
</div>
</div>
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('journey.studio.exportNote', {
sheets: sheets.length,
width: round1(box.width),
height: round1(box.height),
})}
</p>
</DialogShell>
{/*
The sheets, rendered where nobody can see them.
Off screen rather than `display: none`: a hidden subtree lays nothing
out, and these are measured in millimetres by the same CSS that will
print them — a book built from an unlaid-out tree is a book of empty
boxes. `aria-hidden` keeps the whole thing out of the reading order.
print them, a book built from an unlaid-out tree is a book of empty
boxes. They stay inside Studio's tree, where that CSS applies, while the
dialog itself is portalled over it. `aria-hidden` keeps them out of the
reading order.
*/}
{building && (
<div
@@ -158,37 +153,32 @@ export function StudioExport({
<BookSheetsView doc={doc} mode={mode} marks={marks} />
</div>
)}
</div>
</>
)
}
const round1 = (n: number) => Math.round(n * 10) / 10
function Choice<T extends string>({
label, options, value, onPick,
}: {
label: string
options: { id: T; icon: typeof FileText; name: string; hint: string }[]
value: T
onPick: (id: T) => void
/** One choice of the dialog: an icon, its name and what it means, pressed while it holds. */
function Option({ icon: Icon, name, hint, on, onClick }: {
icon: typeof FileText
name: string
hint: string
on: boolean
onClick: () => void
}) {
return (
<div className="st-ex-field">
<span className="st-ex-label">{label}</span>
{options.map(opt => (
<button type="button"
key={opt.id}
className={`st-ex-opt ${value === opt.id ? 'is-on' : ''}`}
onClick={() => onPick(opt.id)}
aria-pressed={value === opt.id}
>
<opt.icon size={15} />
<span className="st-ex-text">
<span className="st-ex-name">{opt.name}</span>
<span className="st-ex-hint">{opt.hint}</span>
</span>
</button>
))}
</div>
<button
type="button"
onClick={onClick}
aria-pressed={on}
className={`flex w-full items-start gap-3 rounded-[12px] border px-3 py-2.5 text-left transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge-faint bg-surface-card hover:bg-surface-hover'}`}
>
<Icon size={16} strokeWidth={1.9} className={`mt-0.5 flex-none ${on ? 'text-content' : 'text-content-muted'}`} />
<span className="min-w-0 flex-1">
<span className="block font-semibold text-content" style={fs(13, 'body')}>{name}</span>
<span className="block text-content-muted" style={fs(11.5)}>{hint}</span>
</span>
</button>
)
}
+5 -1
View File
@@ -11,6 +11,7 @@ import { Tooltip } from '../components/shared/Tooltip'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell'
import { EditorField, GRID_2 } from '../components/shared/dialogParts'
import DawarichAtlasSidePanel from '../components/Dawarich/DawarichAtlasSidePanel'
import CountryFlag from '../components/shared/CountryFlag'
import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { continentForCountry } from '@trek/shared'
import { useAtlas } from './atlas/useAtlas'
@@ -787,7 +788,10 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
<>
<div style={{ width: 2, background: bg(0.08), margin: '12px 0' }} />
<div className="flex items-center gap-3 px-6 py-4">
<span className="text-3xl">{countryCodeToFlag(selectedCountry)}</span>
{/* Twemoji where the platform has no flag glyphs: Windows draws the emoji as two letters. */}
{/^[A-Za-z]{2}$/.test(selectedCountry)
? <CountryFlag code={selectedCountry} size={30} />
: <span className="text-3xl">{countryCodeToFlag(selectedCountry)}</span>}
<div>
<p className="text-sm font-bold" style={{ color: tp }}>
{resolveName(selectedCountry)}
-112
View File
@@ -1662,118 +1662,6 @@
@keyframes st-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .trek-dash .st-spin { animation: none; } }
/* ── Export dialog (#1973) ────────────────────────────────────────────────
Its own prefix rather than `.st-field` and friends: those already mean a
labelled row in the toolbar, and one class doing two jobs is how a toolbar
tweak silently reflows a dialog. */
.trek-dash .st-ex {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: color-mix(in oklch, var(--ink) 42%, transparent);
backdrop-filter: blur(2px);
}
.trek-dash .st-ex-card {
width: 100%;
max-width: 380px;
/* Opaque for the same reason as .st-menu — see there. */
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: 14px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
overflow: hidden;
}
.trek-dash .st-ex-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-3);
}
.trek-dash .st-ex-x {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 7px;
color: var(--ink-3);
}
.trek-dash .st-ex-x:hover { background: color-mix(in oklch, var(--ink) 8%, transparent); color: var(--ink); }
.trek-dash .st-ex-body { padding: 14px; display: flex; flex-direction: column; gap: 14px; }
.trek-dash .st-ex-field { display: flex; flex-direction: column; gap: 6px; }
.trek-dash .st-ex-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-3);
}
.trek-dash .st-ex-opt {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px 11px;
text-align: left;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-2);
color: var(--ink-2);
}
.trek-dash .st-ex-opt:hover { border-color: color-mix(in oklch, var(--accent) 40%, var(--line)); }
.trek-dash .st-ex-opt.is-on {
border-color: var(--accent);
background: color-mix(in oklch, var(--accent) 9%, var(--surface-2));
color: var(--ink);
}
.trek-dash .st-ex-opt svg { margin-top: 1px; flex: 0 0 auto; }
.trek-dash .st-ex-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.trek-dash .st-ex-name { font-size: 12.5px; font-weight: 600; color: var(--ink); }
.trek-dash .st-ex-hint { font-size: 11px; line-height: 1.35; color: var(--ink-2); }
.trek-dash .st-ex-note {
margin: 0;
font-size: 11px;
line-height: 1.45;
color: var(--ink-3);
}
.trek-dash .st-ex-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid var(--line);
}
.trek-dash .st-ex-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 13px;
border-radius: 9px;
border: 1px solid var(--line-2);
background: var(--surface-2);
font-size: 12.5px;
font-weight: 600;
color: var(--ink);
}
.trek-dash .st-ex-btn:hover { background: color-mix(in oklch, var(--ink) 6%, var(--surface-2)); }
.trek-dash .st-ex-btn.is-primary {
border-color: transparent;
background: var(--accent);
color: var(--accent-text, #fff);
}
.trek-dash .st-ex-btn.is-primary:hover { filter: brightness(1.06); }
.trek-dash .st-ex-btn[disabled] { opacity: 0.55; }
/* ── Presence and pointers (#1973) ────────────────────────────────────────
The arrow is positioned in the sheet's millimetres and scales itself back
out, so it stays one size at every zoom — see PeerCursors.tsx. */
@@ -42,8 +42,8 @@ afterEach(() => { vi.useRealTimers() })
describe('the choices', () => {
it('starts on single pages, which is what a printer takes', () => {
open()
expect(screen.getByText('journey.studio.exportPages').closest('button')!.className)
.toContain('is-on')
expect(screen.getByText('journey.studio.exportPages').closest('button')!.getAttribute('aria-pressed'))
.toBe('true')
})
it('starts with crop marks on', () => {
@@ -55,10 +55,10 @@ describe('the choices', () => {
it('switches to spreads when asked', () => {
open()
fireEvent.click(screen.getByText('journey.studio.exportSpreads'))
expect(screen.getByText('journey.studio.exportSpreads').closest('button')!.className)
.toContain('is-on')
expect(screen.getByText('journey.studio.exportPages').closest('button')!.className)
.not.toContain('is-on')
expect(screen.getByText('journey.studio.exportSpreads').closest('button')!.getAttribute('aria-pressed'))
.toBe('true')
expect(screen.getByText('journey.studio.exportPages').closest('button')!.getAttribute('aria-pressed'))
.toBe('false')
})
it('turns the marks off again', () => {