The backend from #303 could store an operator's font but nothing could choose one: no list endpoint outside the Site-Admin routes, no @font-face rules for a family that only exists at runtime, and no management UI. Found while wiring it up — a real defect in #303, invisible to its tests: `fontStack` cannot tell an uploaded family from a deleted one, so the PDF exporter embedded the face and then never named it. Every export of a pond using an operator font rendered in the system font while the job reported success. Both `fontStack` call sites now take the uploaded families (`buildPdfHtml`, `pondFontVariables`); `pdf-html.test.ts` pins the regression from both sides. Verified against a real Gotenberg: with the families the PDF embeds PlayfairDisplay-Bold, without them NotoSans-Bold — that was the whole bug, in one diff of two PDFs. - `GET /fonts/custom` is readable by any signed-in user, not Site Admins only: the pickers, the licence page and the injected `@font-face` rules all need it, and gating it would have forced a second, admin-only UI. - Bundled and uploaded families are told apart by their `<optgroup>`, not by a badge — the grouping is then part of the control's semantics, so a screen reader announces it and the native mobile select keeps it. Within each source the catalog's category grouping is preserved. - The delete confirmation names how many ponds use the family and what happens to them; focus moves to it and back on cancel. Deletion stays unblocked (the api's decision, #303) — the ponds degrade, they do not break. - The licence page grew a second table. That is what makes an attribution obligation satisfiable: a commercial licence that requires naming the foundry needs a page to name it on. Verified in the browser end to end (upload two weights → listed and rendered in its own font → chosen in a pond → page renders in it → deleted → pond falls back): api suite for fonts/export 77 passed, a11y pack 11/11 locally in both schemes, lint/typecheck/i18n:check green.
100 lines
4.1 KiB
TypeScript
100 lines
4.1 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Outlet } from 'react-router-dom';
|
|
|
|
import { CustomFontFaces } from '../fonts/CustomFontFaces';
|
|
import { usePersistentState } from '../lib/use-persistent-state';
|
|
import { Footer } from './Footer';
|
|
import { PageActionsSlotContext } from './page-actions';
|
|
import { SidebarChromeContext } from './sidebar-chrome';
|
|
import { Sidebar } from './Sidebar';
|
|
import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_REM, SidebarResizer } from './SidebarResizer';
|
|
import { TopBar } from './TopBar';
|
|
|
|
export function AppLayout(): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false);
|
|
const [sidebarWidth, setSidebarWidth] = usePersistentState(
|
|
'ui.sidebar.width',
|
|
SIDEBAR_DEFAULT_WIDTH_REM,
|
|
);
|
|
const [forcedHidden, setForcedHidden] = useState(false);
|
|
const collapsed = sidebarCollapsed || forcedHidden;
|
|
// Clamp on read too — the persisted value may predate a bounds change.
|
|
const widthRem = clampSidebarWidth(sidebarWidth);
|
|
// The TopBar registers its page-scoped elements here; the active page
|
|
// portals its icon actions (#101) and presence strip (#102) into them.
|
|
const [actionsElement, setActionsElement] = useState<HTMLElement | null>(null);
|
|
const [presenceElement, setPresenceElement] = useState<HTMLElement | null>(null);
|
|
const [statusElement, setStatusElement] = useState<HTMLElement | null>(null);
|
|
const actionsSlot = useMemo(
|
|
() => ({
|
|
element: actionsElement,
|
|
setElement: setActionsElement,
|
|
presenceElement,
|
|
setPresenceElement,
|
|
statusElement,
|
|
setStatusElement,
|
|
}),
|
|
[actionsElement, presenceElement, statusElement],
|
|
);
|
|
|
|
// Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of
|
|
// which element has focus.
|
|
useEffect(() => {
|
|
function handleKeyDown(event: KeyboardEvent): void {
|
|
if ((event.ctrlKey || event.metaKey) && event.key === '\\') {
|
|
event.preventDefault();
|
|
setSidebarCollapsed(!sidebarCollapsed);
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [sidebarCollapsed, setSidebarCollapsed]);
|
|
|
|
return (
|
|
<SidebarChromeContext.Provider value={setForcedHidden}>
|
|
<PageActionsSlotContext.Provider value={actionsSlot}>
|
|
<div className="app">
|
|
{/* Declares the operator-uploaded families (#304) for every screen
|
|
below — pickers, previews, editor and read view alike. */}
|
|
<CustomFontFaces />
|
|
{/* First tab stop: jump over topbar + sidebar (#166, WCAG 2.4.1). */}
|
|
<a className="skip-link" href="#main">
|
|
{t('layout.skipToContent')}
|
|
</a>
|
|
<TopBar
|
|
sidebarCollapsed={collapsed}
|
|
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
/>
|
|
<div
|
|
className="app-body"
|
|
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
|
|
>
|
|
<Sidebar
|
|
collapsed={collapsed}
|
|
// Inside the nav landmark so no content sits outside landmarks
|
|
// (#166); hidden with the sidebar as before.
|
|
resizer={
|
|
!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />
|
|
}
|
|
/>
|
|
<div className="main-column">
|
|
{/* tabIndex: the main area is the app's scroll container; on
|
|
pages without focusable content (e.g. legal texts) keyboard
|
|
users could otherwise not scroll it (#165, WCAG 2.1.1).
|
|
id: skip-link target (#166). */}
|
|
<main className="main" id="main" tabIndex={0}>
|
|
<Outlet />
|
|
</main>
|
|
{/* Outside the scroll container: always visible at the bottom
|
|
edge of the window (M10 follow-up 3). */}
|
|
<Footer />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PageActionsSlotContext.Provider>
|
|
</SidebarChromeContext.Provider>
|
|
);
|
|
}
|