From 2c486e68328f0719dca93f4a73fc23450910def7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 28 Sep 2026 21:22:46 +0200 Subject: [PATCH] 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. --- client/src/components/Studio/StudioExport.tsx | 168 ++++++++---------- client/src/pages/AtlasPage.tsx | 6 +- client/src/styles/studio.css | 112 ------------ .../tests/unit/studio/StudioExport.test.tsx | 12 +- 4 files changed, 90 insertions(+), 208 deletions(-) diff --git a/client/src/components/Studio/StudioExport.tsx b/client/src/components/Studio/StudioExport.tsx index c8d90798d..ba1021273 100644 --- a/client/src/components/Studio/StudioExport.tsx +++ b/client/src/components/Studio/StudioExport.tsx @@ -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(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 ( -
-
-
- {t('journey.studio.export')} - -
- -
- - 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} + <> + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={onClose} + title={t('journey.studio.export')} /> - -
- {t('journey.studio.exportFinishing')} - + )} + footer={( + + + {t('common.cancel')} + setBuilding(true)} disabled={building} icon={}> + {t('journey.studio.exportOpen')} + + + )} + > + +
+
+
-

- {t('journey.studio.exportNote', { - sheets: sheets.length, - width: round1(box.width), - height: round1(box.height), - })} -

-
+ + -
- - -
-
+

+ {t('journey.studio.exportNote', { + sheets: sheets.length, + width: round1(box.width), + height: round1(box.height), + })} +

+ {/* 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 && (
)} -
+ ) } const round1 = (n: number) => Math.round(n * 10) / 10 -function Choice({ - 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 ( -
- {label} - {options.map(opt => ( - - ))} -
+ ) } diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index f9f2af0ad..3d2676532 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -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 <>
- {countryCodeToFlag(selectedCountry)} + {/* Twemoji where the platform has no flag glyphs: Windows draws the emoji as two letters. */} + {/^[A-Za-z]{2}$/.test(selectedCountry) + ? + : {countryCodeToFlag(selectedCountry)}}

{resolveName(selectedCountry)} diff --git a/client/src/styles/studio.css b/client/src/styles/studio.css index 32a7f5d7b..45ca9344b 100644 --- a/client/src/styles/studio.css +++ b/client/src/styles/studio.css @@ -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. */ diff --git a/client/tests/unit/studio/StudioExport.test.tsx b/client/tests/unit/studio/StudioExport.test.tsx index 0a82a84c8..1621ea94b 100644 --- a/client/tests/unit/studio/StudioExport.test.tsx +++ b/client/tests/unit/studio/StudioExport.test.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', () => {