@pramen/cms-editor 0.0.60 → 0.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,115 @@
1
- // Root layout: the persistent chrome (podoba Topbar + tab nav + global error banner)
2
- // wrapped around every route via <Outlet />. Tab highlighting is derived from the
3
- // current path, so a deep link or refresh lands with the right tab lit.
1
+ // Root layout: the persistent chrome (sidebar nav + global error banner) wrapped around
2
+ // every route via <Outlet />. The active section is derived from the current path, so a deep
3
+ // link or refresh lands with the right item lit.
4
+ //
5
+ // The nav is a SIDEBAR, not the topbar row it used to be. The podoba/Graphic Standard topbar
6
+ // is a brand-left / few-tabs-right bar, and this admin outgrew it: Pages, one tab per
7
+ // content type, N collections, Media, Menus, Taxonomies, Widgets, Redirects, Apps, Types,
8
+ // Users, Settings and any host links is a dozen-plus destinations, which on a 1280px screen
9
+ // left the tabs a dense unlabelled ribbon and below that a horizontal scroller — a nav whose
10
+ // items you have to scroll to discover. A column has room for all of them AT ONCE, plus an
11
+ // icon per row and headings that group them (see `navSections`), which is the same
12
+ // information in a shape that can be scanned rather than read.
13
+ //
14
+ // It is shaped like NOTION's rail, which is the shape this kind of nav has converged on and
15
+ // the one an editor already knows how to read:
16
+ //
17
+ // - a GROUND of its own — the rail is `surface-card`, the content `surface` — so the two
18
+ // regions separate without a rule between them. In DARK the two tokens are the same
19
+ // colour by design (podoba flattens the card onto the surface), so there the hairline
20
+ // does the separating; that is why the border is unconditional and the tone is not.
21
+ // - DENSE rows: 13px label, 15px icon, ~26px tall. The rail is a list to scan, not a strip
22
+ // of buttons to aim at, and the podoba pill (`rounded-full`, `py-2`) is the latter.
23
+ // - COLLAPSIBLE groups. This is the "hide some of it in a submenu" answer, without the
24
+ // click a submenu costs on the way IN: everything is one click away by default, and a
25
+ // deployment that never touches Site or System folds them away for good (persisted).
26
+ //
27
+ // Everything else stays podoba: rows are still `Button variant="ghost"`, the type is
28
+ // `text-compact`, and every colour is a token (`surface-card` / `surface-muted` / `fg-muted`
29
+ // / `border`). What changed is the chrome's LAYOUT and DENSITY, not the design system.
4
30
 
5
31
  import { Outlet, useNavigate, useRoute, useRouter } from "@buzola/router";
6
- import { Button, Card, MoonIcon, SunIcon, Text, Topbar } from "@podoba/react";
7
- import { useEffect, useState } from "react";
8
- import { useApp } from "../app-context";
32
+ import { Avatar, Button, Card, Text, UserMenu, UserMenuItem } from "@podoba/react";
33
+ import { useEffect, useState, type ReactNode } from "react";
34
+ import { useApp, type Me } from "../app-context";
35
+ import { BreadcrumbProvider } from "../breadcrumb";
9
36
  import { BRAND } from "../brand";
37
+ import { GroupFoldedIcon as CrumbSeparatorIcon } from "../icons";
10
38
  import { pagesHidden, splitsByType } from "../components";
39
+ import { APP_BAR_H } from "../chrome";
40
+ import { DarkThemeIcon, GroupFoldedIcon, GroupOpenIcon, LightThemeIcon, MenuToggleIcon, NAV_GLYPHS, RailToggleIcon, SettingsIcon, SignOutIcon } from "../icons";
11
41
  import { opensInSameTab } from "../mount";
12
- import { buildNav, type ExtraNavLink } from "../nav";
42
+ import { buildNav, NAV_SECTION_IDS, navSections, navSectionsAreLabelled, railIsNarrow, type ExtraNavLink, type NavIcon, type NavSectionId } from "../nav";
13
43
 
14
44
  const THEME_KEY = "pramen.cms.theme";
45
+ /** Which nav groups this browser has folded away. Per-browser, like the theme — it is a
46
+ * reading preference, not deployment configuration, and nothing server-side should carry it. */
47
+ const COLLAPSED_KEY = "pramen.cms.nav.collapsed";
48
+ /** …and whether the whole rail is down to icons. Same reasoning, separate key: folding a
49
+ * group and narrowing the rail are different decisions and a reader makes them separately. */
50
+ const RAIL_KEY = "pramen.cms.nav.rail";
51
+
52
+ /** Tailwind's `md`. The rail's narrow state is expressed entirely in `md:`-scoped classes, so
53
+ * JS has to agree with the same number or the two describe different rails. */
54
+ const RAIL_BREAKPOINT = "(min-width: 48rem)";
55
+
56
+ /**
57
+ * Whether a media query matches, kept in sync as the viewport changes.
58
+ *
59
+ * SSR-safe and paranoid about the API: `matchMedia` is absent in a non-browser render and has
60
+ * been present-but-partial (no `addEventListener`, only the deprecated `addListener`) in
61
+ * browsers this editor still meets. Defaults to TRUE, which is the desktop reading — the same
62
+ * direction the layout already fails in, and the one where every control is on screen.
63
+ */
64
+ function useMediaQuery(query: string): boolean {
65
+ const [matches, setMatches] = useState(() => {
66
+ try {
67
+ return globalThis.matchMedia?.(query).matches ?? true;
68
+ } catch {
69
+ return true;
70
+ }
71
+ });
72
+ useEffect(() => {
73
+ const mql = globalThis.matchMedia?.(query);
74
+ if (!mql) return;
75
+ const onChange = () => setMatches(mql.matches);
76
+ onChange();
77
+ mql.addEventListener?.("change", onChange);
78
+ return () => mql.removeEventListener?.("change", onChange);
79
+ }, [query]);
80
+ return matches;
81
+ }
82
+
83
+ /** Whether this browser last left the rail narrowed.
84
+ *
85
+ * `try`/`catch` and no `typeof` guard, matching `readCollapsed` below: referencing an absent
86
+ * `localStorage` throws a ReferenceError the catch already handles, and the catch additionally
87
+ * covers the case a `typeof` check cannot — a browser that HAS the object and throws on access
88
+ * (a private window with site data blocked). One shape for both readers. */
89
+ function readRail(): boolean {
90
+ try {
91
+ return localStorage.getItem(RAIL_KEY) === "narrow";
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /** The folded groups, tolerating every shape localStorage can be in (absent, another
98
+ * version's value, hand-edited, a private window that throws on read). A bad value costs an
99
+ * expanded rail, which is the state the reader can always fix.
100
+ *
101
+ * The read is a PARSE against the closed set of section ids, not a cast: it walks
102
+ * `NAV_SECTION_IDS` and keeps the ones the stored array mentions, so the result is section
103
+ * ids by construction and a stale or invented id simply is not in it. */
104
+ function readCollapsed(): NavSectionId[] {
105
+ try {
106
+ const raw = localStorage.getItem(COLLAPSED_KEY);
107
+ const parsed: unknown = raw ? JSON.parse(raw) : null;
108
+ return Array.isArray(parsed) ? NAV_SECTION_IDS.filter((id) => parsed.includes(id)) : [];
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
15
113
 
16
114
  /**
17
115
  * The slug segment under `prefix`, DECODED.
@@ -19,7 +117,7 @@ const THEME_KEY = "pramen.cms.theme";
19
117
  * `useRoute().pathname` comes off a `URL`, so it is percent-encoded; the slugs it is compared
20
118
  * against are the raw values the server stored. buzola encodes when it builds an href and
21
119
  * decodes into `params` when it matches, so routing is unaffected — only this comparison was,
22
- * and a content type called `články` navigated correctly to a tab bar with nothing lit.
120
+ * and a content type called `články` navigated correctly to a nav with nothing lit.
23
121
  *
24
122
  * A malformed sequence (`%zz`) throws in `decodeURIComponent`; that cannot match any slug
25
123
  * either way, so it degrades to no highlight rather than tearing down the chrome.
@@ -36,14 +134,14 @@ export function segmentAt(pathname: string, prefix: string): string | undefined
36
134
  }
37
135
 
38
136
  export default function RootLayout() {
39
- const { isAdmin, collections, adminPages, contentTypes, cms, error, reconfigure, confirmNavigation } = useApp();
137
+ const { isAdmin, me, collections, adminPages, contentTypes, cms, error, reconfigure, confirmNavigation } = useApp();
40
138
  const navigate = useNavigate();
41
139
  const { pathname } = useRoute();
42
140
 
43
141
  // Every chrome action here is a way OUT of the current screen, so it runs through that
44
142
  // screen's unsaved-changes guard first (the page editor registers one; with no guard
45
143
  // registered this is a pass-through). In-app navigation fires no `beforeunload`, so
46
- // without this the topbar silently discards unsaved edits. An `extraNav` link that opens a
144
+ // without this the chrome silently discards unsaved edits. An `extraNav` link that opens a
47
145
  // NEW tab leaves this document alone and needs no guard; one honoured as `_self` is a real
48
146
  // cross-document navigation, so it takes the guard too (below). `beforeunload` is not a
49
147
  // fallback for it — only `PageEditor` registers one, so a dirty CollectionEditor form would
@@ -57,6 +155,52 @@ export default function RootLayout() {
57
155
  localStorage.setItem(THEME_KEY, theme);
58
156
  }, [theme]);
59
157
 
158
+ // Below `md` the rail collapses to a disclosure under the brand row. A DISCLOSURE, not an
159
+ // overlay drawer: an overlay owes the reader a focus trap, a restore and an Esc handler,
160
+ // and the pieces to do that properly (react-aria's ModalOverlay) are podoba's dependency,
161
+ // not ours — a hand-rolled one would be a modal that keyboard focus walks straight out of.
162
+ // In the flow it is just a `<nav>` that is there or not.
163
+ const [menuOpen, setMenuOpen] = useState(false);
164
+ // Navigating is what a nav is for, so arriving somewhere closes it. Keyed on the path
165
+ // rather than on each item's handler so an in-page link, the brand button and the back
166
+ // button all close it too.
167
+ useEffect(() => { setMenuOpen(false); }, [pathname]);
168
+
169
+ // Folded groups, remembered. A deployment where nobody touches Site or System should not
170
+ // have to re-fold them every morning, and the state is small enough to keep as a list of
171
+ // ids — the sections are named, so an id that no longer exists simply matches nothing.
172
+ const [collapsed, setCollapsed] = useState<NavSectionId[]>(() => (typeof localStorage === "undefined" ? [] : readCollapsed()));
173
+ useEffect(() => {
174
+ try {
175
+ localStorage.setItem(COLLAPSED_KEY, JSON.stringify(collapsed));
176
+ } catch {
177
+ // A private window that refuses writes costs the memory of the fold, nothing else.
178
+ }
179
+ }, [collapsed]);
180
+ const toggleSection = (id: NavSectionId) =>
181
+ setCollapsed((ids) => (ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]));
182
+
183
+ // The rail, narrowed to its icon column. Remembered like the folds: someone who works in a
184
+ // three-column page editor wants the width back, and wants it back tomorrow too.
185
+ const [railChoice, setRailChoice] = useState(readRail);
186
+ useEffect(() => {
187
+ try {
188
+ localStorage.setItem(RAIL_KEY, railChoice ? "narrow" : "wide");
189
+ } catch {
190
+ /* a private window that refuses writes costs the memory of the choice, nothing else */
191
+ }
192
+ }, [railChoice]);
193
+ // Below `md` the rail is already a disclosure under the wordmark — a width it does not have
194
+ // there. Narrowing is a desktop affordance, and every class that expresses it is `md:`-scoped.
195
+ //
196
+ // Which is why the STORED CHOICE is not the same thing as being narrowed, and JS has to know
197
+ // the difference. The choice is per-browser and persisted, so a rail narrowed on a laptop
198
+ // came back narrowed on a phone — where the `md:` classes do nothing, so the rows kept their
199
+ // labels, but the JS gate below removed every group heading AND the hairline that stands in
200
+ // for one is `md:`-only too. That left one undifferentiated column of a dozen rows, with the
201
+ // toggle that would undo it `hidden md:inline-flex`: no way back from that viewport.
202
+ const railNarrow = railIsNarrow(railChoice, useMediaQuery(RAIL_BREAKPOINT));
203
+
60
204
  // The active collection slug, if we're under /collections/:slug(/...).
61
205
  const collectionSlug = segmentAt(pathname, "/collections/");
62
206
  // …and the active content type, under /types/:slug.
@@ -65,8 +209,8 @@ export default function RootLayout() {
65
209
  const appSlug = segmentAt(pathname, "/apps/");
66
210
 
67
211
  // "Pages" stays lit while editing a page (/pages/:id) too — but only on a deployment that
68
- // still HAS a pooled Pages tab. Split by type, the page editor lights nothing: the route
69
- // carries a page id and nothing else, so which type's tab to light isn't knowable here
212
+ // still HAS a pooled Pages entry. Split by type, the page editor lights nothing: the route
213
+ // carries a page id and nothing else, so which type's entry to light isn't knowable here
70
214
  // without fetching the page the editor is already fetching.
71
215
  const active = collectionSlug ? `col:${collectionSlug}`
72
216
  : typeSlug ? `type:${typeSlug}`
@@ -74,7 +218,7 @@ export default function RootLayout() {
74
218
  : pathname.startsWith("/pages") || pathname === "/" ? "pages"
75
219
  : pathname.startsWith("/media") ? "media"
76
220
  // `/schema` rather than `/types`, because `/types/:slug` is already one content type's
77
- // PAGE LIST — a different thing entirely, and the tab keyed `type:<slug>` above.
221
+ // PAGE LIST — a different thing entirely, and the entry keyed `type:<slug>` above.
78
222
  : pathname.startsWith("/schema") ? "types"
79
223
  : pathname.startsWith("/menus") ? "menus"
80
224
  : pathname.startsWith("/taxonomies") ? "taxonomies"
@@ -84,14 +228,6 @@ export default function RootLayout() {
84
228
  : pathname.startsWith("/settings") ? "settings"
85
229
  : "";
86
230
 
87
- // `aria-current` alongside the class: split by type the nav is N mutually-exclusive tabs
88
- // whose only "you are here" cue is a background tint, which is invisible to a screen reader
89
- // and marginal for anyone who cannot see the tint.
90
- const tabProps = (key: string) => ({
91
- className: active === key ? "bg-surface-muted text-fg" : "text-fg-muted",
92
- ...(active === key ? { "aria-current": "page" as const } : {}),
93
- });
94
-
95
231
  // Host-configured links to companion tools (e.g. a curation page), from /config.js.
96
232
  const extraNav: ExtraNavLink[] = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.extraNav ?? [] : [];
97
233
  // Collections-only deployments hide the block/page builder entirely.
@@ -99,101 +235,381 @@ export default function RootLayout() {
99
235
  // Same rule as the landing redirect and the page editor's back target — see `splitsByType`.
100
236
  const splitByType = splitsByType(contentTypes, cms, hidePages);
101
237
  const nav = buildNav({ collections, adminPages, contentTypes, cms, hidePages, splitByType, isAdmin, extraNav });
238
+ const sections = navSections(nav);
239
+ const labelled = navSectionsAreLabelled(sections);
240
+ // The section half of the breadcrumb, and the way back to its list. Taken from the entry
241
+ // that is LIT rather than re-derived from the path: the nav already answered "where am I",
242
+ // and a second answer computed differently is a second answer that can disagree.
243
+ const activeEntry = nav.find((e) => e.key === active);
244
+ const sectionCrumb = activeEntry?.kind === "route" ? activeEntry : undefined;
245
+ // …and the detail half, published by whatever screen is mounted (see `breadcrumb.tsx`).
246
+ const [crumb, setCrumb] = useState<string | null>(null);
102
247
 
103
- // See the extraNav comment below. The rules live in `mount.ts` beside the containment they
104
- // depend on; what this supplies is the URL the BROWSER will resolve a relative href
105
- // against — the current document, not the origin. Empty when there is no `window`, which
106
- // makes every href unparseable and so degrades to the safe new-tab default.
248
+ // See the extraNav comment in `NavLink`. The rules live in `mount.ts` beside the
249
+ // containment they depend on; what this supplies is the URL the BROWSER will resolve a
250
+ // relative href against — the current document, not the origin. Empty when there is no
251
+ // `window`, which makes every href unparseable and so degrades to the safe new-tab default.
107
252
  const basePath = useRouter().basePath;
108
253
  const documentUrl = typeof window !== "undefined" ? window.location.href : "";
109
254
 
110
255
  return (
111
- // Page-level surface so the whole viewport (not just the topbar + cards) flips
112
- // under `[data-theme="dark"]` — otherwise the body stays white in dark mode.
113
- <div className="min-h-screen bg-surface text-fg">
114
- <Topbar className="sticky top-0 z-10 bg-surface px-7">
115
- <Topbar.Brand>
116
- {/* The wordmark is the way back to the top of the admin, as it is on every
117
- other site a `button` (not an `<a>`) so the SPA router handles it. */}
118
- <button
119
- type="button"
120
- onClick={guarded(() => navigate("home"))}
121
- aria-label={`${BRAND.spoken} — home`}
122
- className="flex items-baseline gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-surface-muted"
123
- >
124
- <span className="text-callout font-bold tracking-[0.01em] text-fg">{BRAND.name}</span>
125
- {BRAND.suffix ? <span className="text-fg-subtle">· {BRAND.suffix}</span> : null}
126
- </button>
127
- </Topbar.Brand>
128
- <Topbar.Nav aria-label="Primary">
129
- {/* Order comes from `buildNav`, not from the sequence written here see nav.ts.
130
- A section can therefore sit BETWEEN two built-ins (a collection declaring
131
- `navOrder`, a host link declaring `order`) rather than only after Settings,
132
- which is what made a project-specific section structurally a bolted-on second
133
- app. Rendering stays here because navigation belongs to the layout: every
134
- route entry goes through the unsaved-changes guard, and an `extraNav` link
135
- that leaves the document takes the same guard. */}
136
- {nav.map((entry) =>
137
- entry.kind === "route" ? (
138
- <Button
139
- key={entry.key}
140
- variant="ghost"
141
- size="sm"
142
- {...tabProps(entry.key)}
143
- onPress={guarded(() => navigate(entry.page as never, entry.params ? ({ params: entry.params } as never) : undefined as never))}
256
+ // Page-level surface so the whole viewport (not just the rail + cards) flips under
257
+ // `[data-theme="dark"]` — otherwise the body stays white in dark mode. The rail is a grid
258
+ // COLUMN rather than a fixed overlay, so the content column needs no compensating offset
259
+ // and the document keeps its own scroll (which is what the page editor's full-height
260
+ // grid and every in-page anchor already assume).
261
+ <div className={`min-h-screen bg-surface text-fg md:grid ${railNarrow ? "md:grid-cols-[3.5rem_1fr]" : "md:grid-cols-[15rem_1fr]"}`}>
262
+ {/* The rail's own ground. `border-r` is unconditional and the tone is not, because in
263
+ DARK podoba maps `surface-card` onto `surface` — the hairline is the only separation
264
+ left there, and a border that appeared only in one theme would be a rule with no
265
+ reason a reader could see. */}
266
+ <aside
267
+ aria-label="Sidebar"
268
+ className="border-b border-border bg-surface-card md:sticky md:top-0 md:h-screen md:self-start md:overflow-y-auto md:border-b-0 md:border-r"
269
+ >
270
+ <div className="flex min-h-full flex-col">
271
+ <div className={`flex h-12 shrink-0 items-center gap-1 ${railNarrow ? "md:justify-center md:px-0" : ""} px-2`}>
272
+ {/* The workspace row: the wordmark is the way back to the top of the admin, as it
273
+ is on every other site — a `button` (not an `<a>`) so the SPA router handles
274
+ it and it reads as a row of the rail rather than a masthead above one. */}
275
+ <button
276
+ type="button"
277
+ onClick={guarded(() => navigate("home"))}
278
+ aria-label={`${BRAND.spoken} home`}
279
+ className={`flex min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors hover:bg-surface-muted ${railNarrow ? "md:flex-none md:px-1.5" : "flex-1 px-2"}`}
280
+ >
281
+ {/* The mark. One letter of the deployment's own name, which is the only glyph
282
+ available for a wordmark a host supplies at runtime. */}
283
+ <span
284
+ aria-hidden="true"
285
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] bg-fg text-[11px] font-bold leading-none text-fg-inverted"
144
286
  >
145
- {entry.label}
146
- </Button>
147
- ) : (
148
- <NavLink
149
- key={entry.key}
150
- link={entry.link}
151
- sameTab={opensInSameTab(entry.link.href, entry.link.target, basePath, documentUrl)}
152
- confirm={confirmNavigation}
153
- />
154
- ),
155
- )}
156
- </Topbar.Nav>
157
- <Topbar.Actions>
158
- {/* The tenant is deployment configuration, not something an editor acts on —
159
- it stays visible on the Settings page (Connection), not in the chrome. */}
287
+ {BRAND.name.slice(0, 1).toUpperCase()}
288
+ </span>
289
+ {/* The wordmark itself is what a 56px rail cannot hold; the mark stays, because a
290
+ narrowed rail still has to say whose admin this is. */}
291
+ <span className={`min-w-0 truncate text-compact font-medium leading-4 text-fg ${railNarrow ? "md:hidden" : ""}`}>{BRAND.name}</span>
292
+ {BRAND.suffix ? <span className={`shrink-0 text-caption text-fg-subtle ${railNarrow ? "md:hidden" : ""}`}>{BRAND.suffix}</span> : null}
293
+ </button>
294
+ <Button
295
+ variant="ghost"
296
+ size="sm"
297
+ aria-label={menuOpen ? "Hide navigation" : "Show navigation"}
298
+ aria-expanded={menuOpen}
299
+ aria-controls="pramen-cms-nav"
300
+ className="shrink-0 rounded-md px-2 py-2 text-fg-muted md:hidden"
301
+ onPress={() => setMenuOpen((open) => !open)}
302
+ >
303
+ <MenuToggleIcon className="h-4 w-4" />
304
+ </Button>
305
+ </div>
306
+
307
+ <div id="pramen-cms-nav" className={`${menuOpen ? "flex" : "hidden"} flex-1 flex-col md:flex`}>
308
+ <nav aria-label="Primary" className={`flex flex-col pb-2 ${railNarrow ? "md:px-1.5" : ""} px-2`}>
309
+ {sections.map((section) => {
310
+ // Only a LABELLED group can be folded: the header is the control, so without
311
+ // one there would be no way back — and an unlabelled rail is the
312
+ // single-group case, where there is nothing to fold away from anyway.
313
+ // Narrowed, a group cannot be folded: the heading IS the control, and at 56px
314
+ // there is no room for one. The rows show instead — hiding them behind a
315
+ // control that is not on screen would strand a section with no way back.
316
+ const foldable = labelled && !railNarrow;
317
+ const open = !foldable || !collapsed.includes(section.id);
318
+ return (
319
+ // The space between groups lives on the GROUP, not on its heading: a
320
+ // heading is the first child of its own wrapper, so `first:` there matches
321
+ // every one of them and the gap silently never appears.
322
+ <div
323
+ key={section.id}
324
+ className={`flex flex-col gap-px pt-4 first:pt-1 ${
325
+ // A hairline stands in for the heading at 56px: the grouping is still
326
+ // information, and dropping it would leave one undifferentiated column
327
+ // of a dozen icons.
328
+ railNarrow ? "md:mt-2 md:border-t md:border-border md:pt-2 md:first:mt-0 md:first:border-t-0 md:first:pt-1" : ""
329
+ }`}
330
+ >
331
+ {foldable ? (
332
+ <SectionHeader
333
+ label={section.label}
334
+ open={open}
335
+ controls={`nav-group-${section.id}`}
336
+ onPress={() => toggleSection(section.id)}
337
+ />
338
+ ) : null}
339
+ {/* `hidden` rather than not rendering: the group keeps its identity
340
+ across a fold, so `aria-controls` always points at a real element and
341
+ React does not tear down and rebuild every row on a toggle. */}
342
+ <div id={`nav-group-${section.id}`} className="flex flex-col gap-px" hidden={!open}>
343
+ {section.entries.map((entry) =>
344
+ entry.kind === "route" ? (
345
+ <NavItem
346
+ key={entry.key}
347
+ icon={entry.icon}
348
+ label={entry.label}
349
+ narrow={railNarrow}
350
+ active={active === entry.key}
351
+ onPress={guarded(() => navigate(entry.page as never, entry.params ? ({ params: entry.params } as never) : undefined as never))}
352
+ />
353
+ ) : (
354
+ <NavLink
355
+ key={entry.key}
356
+ link={entry.link}
357
+ icon={entry.icon}
358
+ narrow={railNarrow}
359
+ sameTab={opensInSameTab(entry.link.href, entry.link.target, basePath, documentUrl)}
360
+ confirm={confirmNavigation}
361
+ />
362
+ ),
363
+ )}
364
+ </div>
365
+ </div>
366
+ );
367
+ })}
368
+ </nav>
369
+
370
+ </div>
371
+ </div>
372
+ </aside>
373
+
374
+ {/* `min-w-0`, because a grid column's default `min-width: auto` lets a wide child (a
375
+ table, a long slug) push the column past its track and squeeze the rail. */}
376
+ <div className="min-w-0">
377
+ {/* The app bar. Everything here belongs to the SESSION rather than to the screen, which
378
+ is why it is not in the screen header — and why it survives a narrowed rail, where
379
+ the sign-out and theme controls used to live and no longer fit. Sticky at the top of
380
+ the content column; `page-header.tsx` pins the screen header directly beneath it,
381
+ both off the one height in `chrome.ts`. No rule under it, and the header below has no
382
+ padding above it: the two read as one block of chrome rather than a bar with a
383
+ panel parked beneath it. `bg-surface` is what keeps content from scrolling
384
+ through — the border was never what did that. */}
385
+ <div className={`sticky top-0 z-30 flex ${APP_BAR_H} items-center gap-2 bg-surface px-7`}>
160
386
  <Button
161
387
  variant="ghost"
162
388
  size="sm"
163
- aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"}
164
- onPress={() => setTheme(theme === "dark" ? "light" : "dark")}
389
+ aria-label={railNarrow ? "Expand the sidebar" : "Collapse the sidebar"}
390
+ aria-expanded={!railNarrow}
391
+ className="-ml-2 hidden rounded-md px-2 py-1.5 text-fg-muted hover:text-fg md:inline-flex"
392
+ onPress={() => setRailChoice((narrow) => !narrow)}
165
393
  >
166
- {theme === "dark" ? <SunIcon className="h-4 w-4" /> : <MoonIcon className="h-4 w-4" />}
394
+ <RailToggleIcon className="h-[17px] w-[17px]" />
167
395
  </Button>
168
- <Button variant="ghost" size="sm" onPress={guarded(reconfigure)}>
169
- sign out
170
- </Button>
171
- </Topbar.Actions>
172
- </Topbar>
173
- {error ? (
174
- <Card variant="outlined" padding="none" className="mx-7 mt-2 border-danger px-4 py-2.5">
175
- <Text size="small" className="text-danger">
176
- {error}
177
- </Text>
178
- </Card>
179
- ) : null}
180
- <Outlet />
396
+ {/* The trail. The section is a BUTTON while a detail crumb is showing and plain text
397
+ otherwise — a link to the page you are already on is a link that does nothing,
398
+ and the only thing more annoying than no breadcrumb is one that lies about being
399
+ navigable. */}
400
+ <nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1 text-compact">
401
+ {sectionCrumb ? (
402
+ crumb ? (
403
+ <Button
404
+ variant="ghost"
405
+ size="sm"
406
+ className="max-w-[220px] shrink-0 truncate rounded-md px-2 py-1 text-fg-muted hover:text-fg"
407
+ onPress={guarded(() =>
408
+ navigate(sectionCrumb.page as never, sectionCrumb.params ? ({ params: sectionCrumb.params } as never) : (undefined as never)),
409
+ )}
410
+ >
411
+ {sectionCrumb.label}
412
+ </Button>
413
+ ) : (
414
+ <span className="max-w-[220px] truncate px-2 py-1 text-fg-muted">{sectionCrumb.label}</span>
415
+ )
416
+ ) : null}
417
+ {crumb ? (
418
+ <>
419
+ {/* Only BETWEEN two crumbs. A screen can publish a detail crumb from a route
420
+ no nav entry matches (a per-content-type deployment's `/pages/:id`), and
421
+ the separator then led the trail with a stray "›". */}
422
+ {sectionCrumb ? <CrumbSeparatorIcon aria-hidden="true" className="h-3 w-3 shrink-0 text-fg-subtle" /> : null}
423
+ {/* `aria-current="page"` so the trailing crumb is announced as where you are,
424
+ not as one more thing to visit. */}
425
+ <span aria-current="page" className="min-w-0 truncate px-1 py-1 font-medium text-fg">
426
+ {crumb}
427
+ </span>
428
+ </>
429
+ ) : null}
430
+ </nav>
431
+ <div className="ml-auto pl-4">
432
+ <AccountMenu
433
+ me={me}
434
+ theme={theme}
435
+ onTheme={() => setTheme(theme === "dark" ? "light" : "dark")}
436
+ onSettings={guarded(() => navigate("settings"))}
437
+ onSignOut={guarded(reconfigure)}
438
+ />
439
+ </div>
440
+ </div>
441
+ {error ? (
442
+ <Card variant="outlined" padding="none" className="mx-7 mt-2 border-danger px-4 py-2.5">
443
+ <Text size="small" className="text-danger">
444
+ {error}
445
+ </Text>
446
+ </Card>
447
+ ) : null}
448
+ <BreadcrumbProvider publish={setCrumb}>
449
+ <Outlet />
450
+ </BreadcrumbProvider>
451
+ </div>
181
452
  </div>
182
453
  );
183
454
  }
184
455
 
456
+ /** The icon column of a nav row.
457
+ *
458
+ * A collection and a Block Kit page may declare their own icon server-side, which is an
459
+ * arbitrary string (an emoji, in practice). It used to be prepended to the LABEL, where it
460
+ * read as part of the words and wrapped with them; here both cases occupy the same 16px box,
461
+ * so a rail mixing declared emoji with built-in glyphs still has one aligned icon column.
462
+ */
463
+ function NavIconSlot({ icon }: { icon: NavIcon }): ReactNode {
464
+ if (icon.kind === "emoji") {
465
+ return (
466
+ <span aria-hidden="true" className="flex h-[15px] w-[15px] shrink-0 items-center justify-center text-[13px] leading-none">
467
+ {icon.char}
468
+ </span>
469
+ );
470
+ }
471
+ const Glyph = NAV_GLYPHS[icon.name];
472
+ return <Glyph className="h-[15px] w-[15px] shrink-0" />;
473
+ }
474
+
475
+ /** The shared shape of a rail row — a full-width ghost rect, icon then label.
476
+ *
477
+ * Three overrides of podoba's `Button`, all for the same reason: it is an ACTION button and
478
+ * this is a list row. `justify-start`, because a centred label gives a column with no left
479
+ * edge to read down (and sits differently from the host-link rows, which are plain anchors);
480
+ * `rounded-md`, because the pill is a shape you aim at and a rail is a shape you scan; and
481
+ * the tighter padding, which is what puts a dozen destinations on screen without scrolling.
482
+ *
483
+ * `truncate` rather than wrapping: a long collection label is recognisable from its first
484
+ * words, and a row that grows to two lines breaks the rhythm of the column it sits in (the
485
+ * `title` carries the full text for the one reader who needs it). */
486
+ const ROW = "flex w-full items-center justify-start gap-2 rounded-md px-2 py-[5px] text-left leading-4";
487
+
488
+ /** One group's heading, and the control that folds it.
489
+ *
490
+ * The chevron is always drawn, not revealed on hover: this rail is read by people who are
491
+ * not power users of it, and an affordance that only exists once you are already pointing at
492
+ * it is one nobody discovers. It points DOWN when open and RIGHT when folded, which is the
493
+ * one rotation every file tree has agreed on.
494
+ */
495
+ function SectionHeader({ label, open, controls, onPress }: { label: string; open: boolean; controls: string; onPress: () => void }) {
496
+ return (
497
+ <Button
498
+ variant="ghost"
499
+ size="sm"
500
+ aria-expanded={open}
501
+ aria-controls={controls}
502
+ className="flex w-full items-center justify-start gap-2 rounded-md px-2 py-1 text-left text-caption font-medium leading-4 text-fg-subtle hover:text-fg-muted"
503
+ onPress={onPress}
504
+ >
505
+ {/* The chevron sits in a box the width of a row's ICON, so the heading's label starts
506
+ on the same x as the labels it heads. A chevron sized to itself pulls the heading
507
+ 7px left of its own group, which reads as a stray indent rather than as a level. */}
508
+ <span aria-hidden="true" className="flex h-[15px] w-[15px] shrink-0 items-center justify-center">
509
+ {open ? <GroupOpenIcon className="h-3 w-3" /> : <GroupFoldedIcon className="h-3 w-3" />}
510
+ </span>
511
+ <span className="min-w-0 truncate">{label}</span>
512
+ </Button>
513
+ );
514
+ }
515
+
516
+ /** One route destination in the rail.
517
+ *
518
+ * `aria-current` alongside the tint: the rail is N mutually-exclusive destinations whose
519
+ * only "you are here" cue is a background, which is invisible to a screen reader and
520
+ * marginal for anyone who cannot see it. The active row also takes `font-medium` — podoba
521
+ * has ONE muted-surface token, so hover and active would otherwise be the same pixel, and
522
+ * "where am I" would vanish under the cursor.
523
+ */
524
+ function NavItem({ icon, label, narrow, active, onPress }: { icon: NavIcon; label: string; narrow: boolean; active: boolean; onPress: () => void }) {
525
+ return (
526
+ <Button
527
+ variant="ghost"
528
+ size="sm"
529
+ // Narrowed, the label is gone from the screen but not from the accessible name — the
530
+ // row is still "Media", not an unnamed button with a picture in it.
531
+ {...(narrow ? { "aria-label": label } : {})}
532
+ className={`${ROW} ${narrow ? "md:justify-center md:px-0" : ""} ${active ? "bg-surface-muted font-medium text-fg" : "text-fg-muted hover:text-fg"}`}
533
+ {...(active ? { "aria-current": "page" as const } : {})}
534
+ onPress={onPress}
535
+ >
536
+ <NavIconSlot icon={icon} />
537
+ {/* The tooltip rides the SPAN, not the button: react-aria's `Button` filters `title`
538
+ out of the DOM props it forwards, so it would silently never render. It is also the
539
+ only thing naming a narrowed row on hover, so it is not optional there. */}
540
+ <span title={label} className={`min-w-0 truncate ${narrow ? "md:hidden" : ""}`}>{label}</span>
541
+ </Button>
542
+ );
543
+ }
544
+
545
+ /**
546
+ * The account cluster in the app bar.
547
+ *
548
+ * podoba's `UserMenu` (a React Aria `Menu`), so the popover, roving focus, typeahead and
549
+ * dismissal are the design system's rather than three more hand-rolled handlers. It carries
550
+ * what the rail's foot used to: the theme, the way out, and now Settings and who you are —
551
+ * which is the half of this that a narrowed rail has no room for at all.
552
+ */
553
+ function AccountMenu({
554
+ me,
555
+ theme,
556
+ onTheme,
557
+ onSettings,
558
+ onSignOut,
559
+ }: {
560
+ me: Me | null;
561
+ theme: string;
562
+ onTheme: () => void;
563
+ onSettings: () => void;
564
+ onSignOut: () => void;
565
+ }) {
566
+ // The server-resolved identity, which is a username rather than a display name — this app
567
+ // has no profile. Falling back to "account" keeps the avatar's initials from reading as "?"
568
+ // in the window between boot and the `me` call landing.
569
+ const who = me?.userId ?? "account";
570
+ return (
571
+ <UserMenu
572
+ triggerLabel={`Account — ${who}`}
573
+ trigger={
574
+ <>
575
+ <Avatar name={who} size="sm" ring={false} />
576
+ <span className="max-w-[180px] truncate text-compact text-fg-muted max-[560px]:hidden">{who}</span>
577
+ </>
578
+ }
579
+ onAction={(key) => {
580
+ if (key === "theme") onTheme();
581
+ else if (key === "settings") onSettings();
582
+ else if (key === "signout") onSignOut();
583
+ }}
584
+ >
585
+ <UserMenuItem id="theme" className="gap-2.5">
586
+ {theme === "dark" ? <LightThemeIcon className="h-[15px] w-[15px]" /> : <DarkThemeIcon className="h-[15px] w-[15px]" />}
587
+ {theme === "dark" ? "Light theme" : "Dark theme"}
588
+ </UserMenuItem>
589
+ <UserMenuItem id="settings" className="gap-2.5">
590
+ <SettingsIcon className="h-[15px] w-[15px]" />
591
+ Settings
592
+ </UserMenuItem>
593
+ <UserMenuItem id="signout" className="gap-2.5">
594
+ <SignOutIcon className="h-[15px] w-[15px]" />
595
+ Sign out
596
+ </UserMenuItem>
597
+ </UserMenu>
598
+ );
599
+ }
600
+
185
601
  /** One host-configured link to a companion tool.
186
602
  *
187
603
  * `rel="noreferrer"` is on BOTH branches. `noopener` is genuinely moot in the same tab (no
188
604
  * new browsing context is created, so there is no `window.opener` to sever) but `noreferrer`
189
- * is not: without it a click from `/_pramen/admin/pages/<id>` hands that full url to the
605
+ * is not: without it a click from `/__admin/pages/<id>` hands that full url to the
190
606
  * destination as `Referer`, and `_self` is honoured for cross-origin destinations.
191
607
  *
192
608
  * The key pairs href with label, because two entries may legitimately point at the same href
193
609
  * and differ only in label or target — keyed on href alone React reconciles them together
194
610
  * and the rendered label can end up on the other one's anchor.
195
611
  */
196
- function NavLink({ link, sameTab, confirm }: { link: ExtraNavLink; sameTab: boolean; confirm: () => boolean }) {
612
+ function NavLink({ link, icon, narrow, sameTab, confirm }: { link: ExtraNavLink; icon: NavIcon; narrow: boolean; sameTab: boolean; confirm: () => boolean }) {
197
613
  return (
198
614
  <a
199
615
  href={link.href}
@@ -201,9 +617,11 @@ function NavLink({ link, sameTab, confirm }: { link: ExtraNavLink; sameTab: bool
201
617
  // Only the same-tab case unloads this document, so only it consults the guard.
202
618
  onClick={sameTab ? (e) => { if (!confirm()) e.preventDefault(); } : undefined}
203
619
  {...(sameTab ? {} : { target: "_blank" })}
204
- className="rounded-md px-2.5 py-1.5 text-small text-fg-muted transition-colors hover:bg-surface-muted hover:text-fg"
620
+ {...(narrow ? { "aria-label": link.label } : {})}
621
+ className={`${ROW} ${narrow ? "md:justify-center md:px-0" : ""} text-compact text-fg-muted no-underline transition-colors hover:bg-surface-muted hover:text-fg`}
205
622
  >
206
- {link.label}
623
+ <NavIconSlot icon={icon} />
624
+ <span title={link.label} className={`min-w-0 truncate ${narrow ? "md:hidden" : ""}`}>{link.label}</span>
207
625
  </a>
208
626
  );
209
627
  }