@eifi1/ui-kit 0.32.0 → 0.32.2
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.
- package/dist/account/delete-account-setting.d.ts +1 -1
- package/dist/admin/admin-action-confirm.d.ts +1 -1
- package/dist/admin/admin-action-log.d.ts +1 -1
- package/dist/admin/admin-action-log.js +4 -2
- package/dist/admin/admin-action-log.js.map +1 -1
- package/dist/admin/invitations-panel.d.ts +1 -1
- package/dist/admin/reviewer-scope-editor.d.ts +1 -1
- package/dist/admin/role-select.d.ts +1 -1
- package/dist/admin/transfer-ownership-dialog.d.ts +1 -1
- package/dist/admin/user-roster.d.ts +1 -1
- package/dist/auth/register-form.d.ts +1 -1
- package/dist/billing/billing-banner.d.ts +1 -1
- package/dist/billing/billing-labels.d.ts +1 -1
- package/dist/billing/billing-labels.js +3 -1
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/billing-lock.d.ts +1 -1
- package/dist/billing/billing-standing.d.ts +1 -1
- package/dist/billing/plan-card.d.ts +1 -1
- package/dist/billing/plan-card.js +1 -1
- package/dist/billing/plan-card.js.map +1 -1
- package/dist/billing/plan-limit-notice.d.ts +1 -1
- package/dist/billing/subscription-actions.d.ts +1 -1
- package/dist/billing/subscription-status-chip.d.ts +1 -1
- package/dist/chart.d.ts +2 -2
- package/dist/chart.js.map +1 -1
- package/dist/components/alert-banner.js +1 -1
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +1 -1
- package/dist/components/appearance-labels.d.ts +6 -0
- package/dist/components/appearance-labels.js +2 -1
- package/dist/components/appearance-labels.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/button-group.js +7 -1
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/chip.js +7 -2
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.js +1 -1
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +1 -1
- package/dist/components/copy-button.d.ts +1 -1
- package/dist/components/danger-confirm.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +2 -1
- package/dist/components/data-table-pagination.js +15 -6
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table.js +2 -2
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +11 -1
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/facing-pair.d.ts +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/iban-input.d.ts +1 -1
- package/dist/components/inline-edit-field.js +4 -1
- package/dist/components/inline-edit-field.js.map +1 -1
- package/dist/components/language-select.d.ts +1 -1
- package/dist/components/legal-page.d.ts +1 -1
- package/dist/components/legal.d.ts +1 -1
- package/dist/components/money-field.d.ts +1 -1
- package/dist/components/month-picker.js +41 -34
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/number-field.d.ts +1 -1
- package/dist/components/number-input.d.ts +1 -1
- package/dist/components/numpad-sheet.d.ts +1 -1
- package/dist/components/page-header.js +6 -1
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/phone-input.d.ts +1 -1
- package/dist/components/phone-input.js +2 -2
- package/dist/components/phone-input.js.map +1 -1
- package/dist/components/popover.d.ts +1 -1
- package/dist/components/popover.js +2 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/components/row-actions.d.ts +1 -1
- package/dist/components/row-actions.js +97 -37
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/series-chart.d.ts +1 -1
- package/dist/components/settings-fields.d.ts +7 -3
- package/dist/components/settings-fields.js +2 -1
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/share-card.d.ts +1 -1
- package/dist/components/share-card.js +6 -2
- package/dist/components/share-card.js.map +1 -1
- package/dist/components/status-dot.js +9 -2
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/swipeable-row.js +8 -1
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/text-link.d.ts +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/toggle-group.d.ts +9 -2
- package/dist/components/toggle-group.js +3 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -1
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review.js +2 -2
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/components/ui.js +6 -6
- package/dist/components/ui.js.map +1 -1
- package/dist/components/wizard-stepper.js +20 -14
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/{delete-account-setting-BvdQdbv3.d.ts → delete-account-setting-Dy4kJgUm.d.ts} +53 -14
- package/dist/feedback/feedback-attachment.d.ts +1 -1
- package/dist/feedback/feedback-context.d.ts +1 -1
- package/dist/feedback/feedback-crash.d.ts +1 -1
- package/dist/feedback/feedback-dialog.d.ts +1 -1
- package/dist/feedback/feedback-inbox.d.ts +1 -1
- package/dist/feedback/feedback-labels.d.ts +1 -1
- package/dist/feedback/feedback-menu.d.ts +1 -1
- package/dist/feedback/feedback-record.d.ts +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +1 -1
- package/dist/feedback/feedback-status-undo.d.ts +1 -1
- package/dist/feedback/feedback-submit.d.ts +1 -1
- package/dist/feedback/feedback-swipe.d.ts +1 -1
- package/dist/feedback/feedback-table.d.ts +1 -1
- package/dist/feedback/feedback-thread.d.ts +1 -1
- package/dist/feedback.d.ts +1 -1
- package/dist/hooks/use-breakpoint.d.ts +19 -8
- package/dist/hooks/use-breakpoint.js +1 -1
- package/dist/hooks/use-breakpoint.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/german.d.ts +1 -1
- package/dist/i18n/german.js +7 -3
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/languages.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/en.d.ts +1 -1
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/es.js +7 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/fr.js +7 -3
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/hu.js +7 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/it.js +7 -3
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/i18n/locales/zh.js +7 -3
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/landing/landing-actions.d.ts +1 -1
- package/dist/landing/landing-sections.d.ts +1 -1
- package/dist/landing/landing-sections.js +7 -3
- package/dist/landing/landing-sections.js.map +1 -1
- package/dist/landing/public-header.d.ts +1 -1
- package/dist/landing/routing.d.ts +1 -1
- package/dist/rhf/fields.d.ts +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf.d.ts +1 -1
- package/dist/settings/settings-section.d.ts +1 -1
- package/dist/settings/use-account-appearance.d.ts +1 -1
- package/dist/settings/use-account-appearance.js.map +1 -1
- package/dist/shell/app-shell.d.ts +1 -1
- package/dist/shell/app-shell.js +2 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/theme/contrast.d.ts +3 -2
- package/dist/theme/contrast.js.map +1 -1
- package/dist/theme/text-size.d.ts +19 -3
- package/dist/theme/text-size.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard/wizard-summary.js +1 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +1 -1
- package/src/admin/admin-action-log.tsx +6 -1
- package/src/billing/billing-labels.ts +16 -0
- package/src/billing/plan-card.tsx +1 -1
- package/src/chart.ts +2 -0
- package/src/components/alert-banner.tsx +5 -1
- package/src/components/appearance-labels.ts +7 -0
- package/src/components/button-group.tsx +21 -0
- package/src/components/chip.tsx +34 -6
- package/src/components/choice-card.tsx +1 -1
- package/src/components/data-table-pagination.tsx +60 -31
- package/src/components/data-table.tsx +8 -4
- package/src/components/date-picker.tsx +32 -1
- package/src/components/inline-edit-field.tsx +20 -1
- package/src/components/month-picker.tsx +6 -1
- package/src/components/page-header.tsx +15 -1
- package/src/components/phone-input.tsx +7 -2
- package/src/components/popover.tsx +4 -0
- package/src/components/row-actions.tsx +156 -49
- package/src/components/settings-fields.tsx +8 -3
- package/src/components/share-card.tsx +21 -2
- package/src/components/status-dot.tsx +25 -2
- package/src/components/swipeable-row.tsx +8 -1
- package/src/components/toggle-group.tsx +26 -12
- package/src/components/translation-review-editor.tsx +1 -1
- package/src/components/translation-review.tsx +5 -4
- package/src/components/ui.tsx +28 -9
- package/src/components/wizard-stepper.tsx +5 -1
- package/src/hooks/use-breakpoint.ts +20 -9
- package/src/i18n/german.ts +4 -0
- package/src/i18n/locales/es.ts +4 -0
- package/src/i18n/locales/fr.ts +4 -0
- package/src/i18n/locales/hu.ts +4 -0
- package/src/i18n/locales/it.ts +4 -0
- package/src/i18n/locales/zh.ts +4 -0
- package/src/index.ts +4 -2
- package/src/landing/landing-sections.tsx +7 -3
- package/src/settings/use-account-appearance.ts +1 -1
- package/src/shell/app-shell.tsx +11 -6
- package/src/theme/contrast.ts +3 -2
- package/src/theme/text-size.ts +19 -3
- package/src/wizard/wizard-summary.tsx +4 -2
- package/tokens.css +15 -7
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/app-shell.tsx"],"sourcesContent":["import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Link, NavLink, matchPath, useHref, useLocation } from \"react-router\";\nimport { ChevronRight, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { scrollIntoStrip, useStripFade } from \"../lib/strip-fade\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { readStored, writeStored } from \"../lib/safe-storage\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { pickLinkRenderer } from \"../components/text-link\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { CompactControls } from \"../components/ui\";\nimport { useAnchoredRect } from \"../hooks/use-anchored-rect\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { useBreakpoint } from \"../hooks/use-breakpoint\";\nimport { useTextSize } from \"../theme/text-size\";\nimport type { TextSize } from \"../theme/text-size\";\nimport { DEFAULT_APP_SHELL_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\n\nexport interface AppShellSubItem {\n to: string;\n label: string;\n icon: LucideIcon;\n /** Passed to NavLink's `end` (exact match). Defaults to true. */\n end?: boolean;\n}\n\nexport interface AppShellNavItem {\n to: string;\n label: string;\n /** Optional shorter label for the MOBILE BOTTOM BAR only (the sidebar, its\n * tooltips and any palette built on this list keep `label`).\n *\n * The bar divides the viewport into `nav.length` equal cells, so a label's room\n * shrinks with every entry added — at six entries on a 406 px phone that is ~67 px,\n * and \"Import & Export\" wrapped to two lines and pushed the row taller than its\n * neighbours (keksdose feedback live #210). A per-item override is better than\n * shortening the real label, which the sidebar has ample room for and which is the\n * name the rest of the product uses. */\n shortLabel?: string;\n /** 0.31.0: leave this entry out of the MOBILE BOTTOM BAR; the sidebar keeps it.\n * For an entry a phone reaches another way — the brand link to the start page —\n * when one cell fewer is what keeps the others' labels whole (the bar divides its\n * width evenly, see `shortLabel`). Its group's sub-row still shows while one of its\n * pages is open. Named like `DataTable`'s column `mobileHidden`. */\n mobileHidden?: boolean;\n icon: LucideIcon;\n /** Passed to NavLink's `end` (exact match). Defaults to true. */\n end?: boolean;\n /** Optional `data-tour` value rendered on this item's link (both the desktop\n * sidebar and the mobile bottom bar), so a guided tour can spotlight ONE nav\n * entry precisely instead of matching by href (feedback #338). */\n dataTour?: string;\n /** Optional sub-items. When present, hovering (or focusing) the item opens a\n * flyout to the right of the sidebar with a header (this item's label) and the\n * sub-items as icon + title links. Works collapsed or expanded; the mobile\n * bottom bar ignores sub-items and links to `to`. */\n items?: AppShellSubItem[];\n}\n\n/**\n * One entry of the sidebar's bottom area ({@link AppShellProps.sidebarFooterItems}) —\n * the version link and the legal links kastlan hand-built in `sidebarFooter`.\n *\n * - `link` (default): one link. Expanded it shows `icon` + `text` (default `label`),\n * with `hint` as a tooltip if given; collapsed it is the icon alone, named and\n * tooltipped by `label`.\n * - `links`: a small group — the legal links. Expanded, a `<nav>` named `label` holding\n * the links as a wrapping row of small text; collapsed, ONE icon link to `to` (default\n * the first link's), named and tooltipped by `label`, since a column of icons for\n * \"Imprint\", \"Privacy\" and \"Terms\" would say nothing an icon can.\n */\nexport type AppShellSidebarFooterItem =\n | {\n kind?: \"link\";\n key: string;\n to: string;\n icon: LucideIcon;\n /** The link's name, and the tooltip when collapsed (\"What's new · v1.4.0\"). */\n label: string;\n /** Visible text when expanded (\"Kastlan v1.4.0\"). Default `label`. */\n text?: ReactNode;\n /** A tooltip on the expanded link (\"What's new\"). */\n hint?: string;\n /** Leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`. */\n external?: boolean;\n }\n | {\n kind: \"links\";\n key: string;\n icon: LucideIcon;\n /** The group's name — its `<nav>` label, and the collapsed icon's name (\"Legal\"). */\n label: string;\n /** The collapsed icon's target. Default the first link's `to`. */\n to?: string;\n links: { to: string; label: string; external?: boolean }[];\n };\n\n/**\n * The words of the phone bar's \"More\" cell and its sheet (0.32,\n * docs/text-size-harmonization.md §4 Navigation, §10.7) — the `appShellMore` namespace of\n * `<UiKitProvider labels>`, overridable per shell through `moreLabels`.\n */\nexport interface AppShellMoreLabels {\n /** The cell's visible label, under its icon — one short word. */\n more: string;\n /** The sheet's heading, and so its accessible name. */\n moreTitle: string;\n}\n\nexport const DEFAULT_APP_SHELL_MORE_LABELS: AppShellMoreLabels = {\n more: \"More\",\n moreTitle: \"More pages\",\n};\n\n/**\n * The most entries the phone bar shows before the rest go under \"More\", per text size\n * (§10.7): every entry at Normal, four at Large, three at Extra large. A fifth of a\n * 360 px phone at 150 % is 48 px of Normal-sized room, and the cells hold a word each.\n */\nexport const DEFAULT_MOBILE_BAR_MAX: Readonly<Record<TextSize, number>> = {\n normal: Number.POSITIVE_INFINITY,\n large: 4,\n xlarge: 3,\n};\n\n/**\n * Split the bar's entries into the cells shown and the ones under \"More\", in the app's\n * order (§10.7: the app puts its four most used first, or orders by role). One hidden\n * entry is no saving — the More cell would take its place — so the bar then shows them\n * all: the cell count is the same either way, and a real entry beats a detour.\n */\nexport function splitMobileBar<T>(entries: readonly T[], max: number): { shown: T[]; hidden: T[] } {\n const limit = Math.max(1, Math.floor(max));\n if (entries.length <= limit + 1) return { shown: [...entries], hidden: [] };\n return { shown: entries.slice(0, limit), hidden: entries.slice(limit) };\n}\n\n/**\n * Exported and `<div>`-shaped. This is the outermost element of every consuming app, so\n * there is nothing above it to hang an id, a landmark label or a `data-tour` anchor on —\n * and nothing a consumer can wrap it in either, since the root owns the `h-dvh` and\n * `overflow-hidden` the whole layout is built on (audit §api-design).\n */\nexport interface AppShellProps extends ComponentPropsWithoutRef<\"div\"> {\n /** Navigation entries — shared by the desktop sidebar and mobile bottom bar. */\n nav: AppShellNavItem[];\n /** The composed top bar (e.g. the shared TopBar with app-owned actions). */\n topBar: ReactNode;\n /** Main content — typically the router `<Outlet />`. */\n children: ReactNode;\n /** Desktop-only footer rendered below the content (hidden on mobile). */\n footer?: ReactNode;\n /** Extra sidebar content above the collapse toggle (e.g. a version link).\n * Receives the collapsed state so it can render compact vs. full. */\n sidebarFooter?: (collapsed: boolean) => ReactNode;\n /** The sidebar's bottom area as data — a version link, the legal links — drawn full\n * when expanded and as icon + tooltip when collapsed. Rendered above\n * `sidebarFooter`, which stays for anything the data cannot describe. */\n sidebarFooterItems?: AppShellSidebarFooterItem[];\n /** The router link for `sidebarFooterItems`. Default: the provider's\n * `linkComponent`, else react-router's `Link`. */\n renderLink?: KitLinkComponent;\n /** localStorage key for the persisted collapse state. */\n collapseStorageKey?: string;\n /** Default: `appShell.collapse` from the {@link UiKitProvider}, else English. */\n collapseLabel?: string;\n /** Default: `appShell.expand` from the {@link UiKitProvider}, else English. */\n expandLabel?: string;\n /** How an entry's `items` are revealed in the EXPANDED desktop sidebar.\n *\n * - `\"flyout\"` (default) — a panel beside the sidebar on hover/focus. Compact: the\n * sidebar stays one row per group however many pages a group holds.\n * - `\"inline\"` — a disclosure under the entry, the pattern of MUI's nested List and\n * most documentation sites. Every page is visible at once, and the group holding\n * the current page opens on its own, so the reader can see where they are without\n * hovering anything.\n *\n * The icon-only (collapsed) sidebar always uses the flyout: an indented list has no\n * room there. The mobile bottom bar ignores `items` in both modes. */\n subNav?: \"flyout\" | \"inline\";\n /** Accessible name of the inline disclosure button, given the group's label.\n * Only used with `subNav=\"inline\"`. Its expanded state is carried by\n * `aria-expanded`, so one wording serves both directions. Default:\n * `appShell.toggleGroup` from the {@link UiKitProvider}, else English. */\n toggleGroupLabel?: (groupLabel: string) => string;\n /** Below `md`: show the current group's `items` as a scrollable row above the\n * bottom bar — the phone's counterpart of the sidebar's second level. Default true;\n * `false` keeps the single bar, where a group entry only links to its own `to`. */\n mobileSubNav?: boolean;\n /**\n * How the phone sub-nav lays out a group with more pages than fit on one row.\n * `\"wrap\"` (default): every page in view, on as many rows as it takes — the bar\n * grows. `\"scroll\"`: one row that scrolls sideways, faded at a cut edge, with the\n * current page scrolled into view. A long group took four rows (~130px) of a 390px\n * screen above the bottom bar (showcase audit); scrolling gives that back at the\n * price of pages hidden past the edge, so it is the app's call, per shell.\n */\n mobileSubNavLayout?: \"wrap\" | \"scroll\";\n /**\n * The most entries the phone bar shows; the rest move into a \"More\" cell that opens a\n * sheet listing them, each group with its pages (0.32, docs/text-size-harmonization.md\n * §4, §10.7). The bar takes `nav` in the app's order — after `mobileHidden` — so the\n * app puts its most used entries first (or orders by role: kastlan's manager and\n * accountant see different firsts).\n *\n * Default {@link DEFAULT_MOBILE_BAR_MAX}: unlimited at Normal, 4 at Large, 3 at Extra\n * large, because the cells' words grow with the text and the phone does not. A number\n * replaces the default at every size; `{ large: 5 }` replaces it at one size only.\n * Never fewer than one entry; and one entry over the limit shows in full rather than\n * behind a More that would take the same cell.\n */\n mobileBarMax?: number | Partial<Record<TextSize, number>>;\n /** The More cell's words over the provider's `appShellMore`. */\n moreLabels?: Partial<AppShellMoreLabels>;\n /**\n * Lay the shell out inside its PARENT'S box instead of the viewport: `h-full` rather\n * than `h-dvh`, its own scroll container at every width, a bottom bar pinned to the\n * shell rather than the window, `--app-nav-h` published on the shell alone, and no\n * persisted collapse state unless `collapseStorageKey` is passed. For an app preview,\n * a device frame, a docs example.\n *\n * Implied for a shell rendered inside another AppShell, which is the case this\n * exists for: the inner one used to publish its own nav height on `<html>`, fight\n * the outer one over it, and remove it on unmount from under the outer app.\n */\n embedded?: boolean;\n}\n\n/** True below an AppShell — how a nested shell knows it is one. */\nconst AppShellNesting = createContext(false);\n\n/**\n * The bottom nav's own height, published as `--app-nav-h` so anything that has to sit\n * ON it can stop guessing.\n *\n * Keksdose live #314 is what this is for. Its rework — *\"Stick it to the bottom\n * touching the bottom icon bar with budget, accounts, … Currently there is a small gap\n * which confuses\"* — was a receipt footer pinned at `bottom-20`, because `<main>`\n * reserves `pb-20` for this nav and 20 looked like the nav's height. It is not: the\n * padding is generous CLEARANCE, and the nav is content-sized (icon + label + `py-2`,\n * about 56px), so the footer floated ~24px above the bar.\n *\n * Measured rather than named, because the height is not a constant anyone owns: it is\n * an icon, a translated label that can wrap or truncate, and whatever the platform does\n * with the safe-area inset. A hard-coded `bottom-14` would be the same guess one number\n * lower, and it would be wrong again the first time a label needed two lines.\n *\n * `0px` when the nav is not rendered — above `md` it is `display:none`, which observes\n * as a zero box — so a consumer can write `bottom-[var(--app-nav-h,0px)]` once and get\n * the right answer at both widths without a breakpoint of its own.\n */\nfunction useNavHeightVar(\n ref: React.RefObject<HTMLElement | null>,\n shellRef: React.RefObject<HTMLElement | null>,\n embedded: boolean,\n): void {\n // Re-run when the breakpoint flips. A ResizeObserver SKIPS an element that is not\n // being rendered, so the nav going `display:none` above `md` fires no callback and\n // would leave the last phone height published — a desktop footer would then sit 56px\n // off the bottom after a live resize. `useBreakpoint(\"md\")` is the nav's own `md:hidden`\n // at the text size in force (0.32), so the two cannot disagree about where the\n // boundary is — at Extra large both move to 72rem.\n const isMdUp = useBreakpoint(\"md\", false);\n useEffect(() => {\n const node = ref.current;\n const shell = shellRef.current;\n // Always on the shell's own root, so everything inside it — a nested shell's\n // content included — reads the nearest bar's height. On <html> as well only for\n // the OUTERMOST shell, for what it portals to <body> (a sheet docking on the bar).\n // The DOM check backs up the context for a shell mounted in a separate React root.\n const global = !embedded && !shell?.parentElement?.closest(\"[data-app-shell]\");\n const targets = [shell, global ? document.documentElement : null].filter(\n (el): el is HTMLElement => el !== null,\n );\n // `getBoundingClientRect().height`, rounded DOWN — not `offsetHeight`, and not\n // ceil. Measured on the running app at 406x816: the bar is **55.5px** tall, and\n // both `offsetHeight` and `Math.ceil` answer 56. That is why live #314 round four\n // was *\"No change. Still a pixel inbetween\"* — the previous fix was arithmetically\n // a no-op on the very value it was meant to correct.\n //\n // 56 is the wrong side. A sticky element with `bottom: X` puts its bottom edge X\n // above the viewport bottom, and the bar's TOP edge is at its own height: so\n // X = 56 lands the footer at y=760.0 against a bar starting at y=760.5, i.e. half\n // a CSS pixel of page showing through — about 1.4 device pixels on his phone,\n // which is exactly the hairline in the screenshot.\n //\n // The two errors are not symmetric, and I had them backwards the first time.\n // Too SMALL tucks the sticky element under a bar that is opaque and painted above\n // it (`z-30` against `z-10`): invisible. Too LARGE opens the seam. So floor, and\n // consumers subtract a further pixel (see `invoice-line-totals.tsx`) for the case\n // where the height lands on a whole pixel and floor leaves no overlap at all.\n const publish = () => {\n const value = `${node ? Math.floor(node.getBoundingClientRect().height) : 0}px`;\n for (const el of targets) el.style.setProperty(\"--app-nav-h\", value);\n };\n publish();\n // The observer is the refinement, not the mechanism: `publish()` above is already\n // right for a static nav, and jsdom has no ResizeObserver unless a test stubs one.\n // ONE cleanup for every branch, though — an early `return` on the no-observer path\n // left the variable behind on unmount, which a stale `bottom:` offset on whatever\n // rendered next would have inherited.\n const ro = node && typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(publish) : null;\n if (ro && node) ro.observe(node);\n return () => {\n ro?.disconnect();\n for (const el of targets) el.style.removeProperty(\"--app-nav-h\");\n };\n }, [ref, shellRef, embedded, isMdUp]);\n}\n\n/**\n * The responsive application shell: a top bar, a collapsible desktop sidebar\n * (icon-only when collapsed, with tooltips), a mobile bottom nav bar, the main\n * content area, and an optional desktop footer. Router-aware via react-router\n * `NavLink`; domain-free — nav items, brand, footer and the top bar's actions\n * are all supplied by the app.\n */\nexport function AppShell({\n nav,\n topBar,\n children,\n footer,\n sidebarFooter,\n sidebarFooterItems,\n renderLink,\n collapseStorageKey: collapseStorageKeyProp,\n collapseLabel,\n expandLabel,\n subNav = \"flyout\",\n mobileSubNav = true,\n mobileSubNavLayout = \"wrap\",\n mobileBarMax,\n moreLabels,\n toggleGroupLabel,\n embedded: embeddedProp,\n className,\n ...rest\n}: AppShellProps) {\n const embedded = useContext(AppShellNesting) || !!embeddedProp;\n const { size: textSize } = useTextSize();\n const large = textSize !== \"normal\";\n // The bottom bar's cells: every entry the phone does not reach another way, up to the\n // text size's limit; the rest go under More (§10.7).\n const barMax =\n typeof mobileBarMax === \"number\"\n ? mobileBarMax\n : (mobileBarMax?.[textSize] ?? DEFAULT_MOBILE_BAR_MAX[textSize]);\n const { shown: barNav, hidden: moreNav } = splitMobileBar(\n nav.filter((item) => !item.mobileHidden),\n barMax,\n );\n // An embedded shell sharing the default key would overwrite the outer app's sidebar\n // preference with its own — so it persists only when given a key of its own.\n const collapseStorageKey =\n collapseStorageKeyProp ?? (embedded ? null : \"appLayout.sidebarCollapsed\");\n // The three props are per-shell overrides of the provider's `appShell`; one left\n // undefined falls through to the provider rather than to English.\n const labels = useKitLabels(\"appShell\", DEFAULT_APP_SHELL_LABELS, {\n collapse: collapseLabel,\n expand: expandLabel,\n toggleGroup: toggleGroupLabel,\n });\n // Through the guarded helpers, not `window.localStorage` directly. The read runs\n // inside a `useState` initialiser — i.e. during render of the top-level shell — and\n // `localStorage` THROWS where site data is blocked (Safari private browsing, a\n // partitioned webview). Unguarded, that was not a lost sidebar preference: nothing\n // in the application mounted at all.\n const [collapsed, setCollapsed] = useState(\n () => collapseStorageKey !== null && readStored(collapseStorageKey) === \"1\",\n );\n // The inline sidebar is an ACCORDION: one group open at a time, keyed by its `to`.\n // Held here rather than per group so opening one can close the other — two lists\n // open at once pushed every entry below them off a laptop screen.\n const [openGroup, setOpenGroup] = useState<string | null>(null);\n // Publishes `--app-nav-h` for anything that has to sit on the bottom bar.\n const navRef = useRef<HTMLDivElement>(null);\n const rootRef = useRef<HTMLDivElement>(null);\n useNavHeightVar(navRef, rootRef, embedded);\n useEffect(() => {\n if (collapseStorageKey !== null) writeStored(collapseStorageKey, collapsed ? \"1\" : \"0\");\n }, [collapsed, collapseStorageKey]);\n // §4: at Large and Extra large the sidebar never collapses to icons only — a glyph\n // with its name in a tooltip is the very thing a reader who asked for bigger type\n // cannot use. The persisted preference is left alone, so Normal gets it back, and the\n // toggle goes while it could not do anything.\n const iconOnly = collapsed && !large;\n\n return (\n // The caller's attributes go on the shell's own root, never on the `data-tour=\"nav\"`\n // wrappers below — those are the kit's anchors for its own guided tour (#313/#322),\n // and a second element answering to the same selector is a tour that highlights the\n // wrong thing or nothing at all.\n <AppShellNesting.Provider value={true}>\n <div\n {...rest}\n ref={rootRef}\n data-app-shell=\"\"\n className={cn(\n \"relative flex flex-col\",\n embedded\n ? // `contain: layout` makes this box the containing block for the `fixed`\n // bottom bar below, so it pins to the shell rather than to the window.\n \"h-full min-h-0 overflow-hidden [contain:layout]\"\n : \"min-h-screen md:h-dvh md:overflow-hidden\",\n className,\n )}\n >\n {/* §4: \"the top bar keeps its essential icons\" — its IconButtons stay icons at\n Large (their labels the names and tooltips) and a locked one keeps its reason\n in the tooltip: a bar 3rem tall has no room for words under every glyph. */}\n <CompactControls>{topBar}</CompactControls>\n <div className=\"flex flex-1 min-h-0\">\n <aside\n className={cn(\n // `border-e`: the sidebar sits at the START, which is the right in RTL.\n \"hidden md:flex md:flex-col border-e border-[var(--border)] bg-[var(--bg-surface)] transition-[width] duration-200 ease-out overflow-hidden\",\n // Embedded, the row it sits in is already the shell's height; the sticky\n // offsets below are measured against the window's top bar.\n embedded ? \"md:self-stretch\" : \"md:sticky md:top-12 md:self-start md:h-[calc(100vh-3rem)]\",\n iconOnly ? \"md:w-14\" : \"md:w-60\",\n )}\n >\n {/* The `<nav>` is flex-1 so it fills the sidebar's height (with empty\n space below the items); the data-tour marker goes on the INNER,\n fit-content wrapper so the guided-tour spotlight hugs the actual nav\n items instead of the whole tall column (feedback #322). The marker\n also tags the mobile bottom bar below; the tour targets\n `[data-tour=\"nav\"]` and resolves to whichever is visible (#313). */}\n <nav className=\"flex-1 px-2 py-3 overflow-y-auto overflow-x-hidden\">\n <div data-tour=\"nav\" className=\"space-y-1\">\n {nav.map((item) => (\n <SidebarNavItem\n key={item.to}\n item={item}\n collapsed={iconOnly}\n inline={subNav === \"inline\"}\n toggleGroupLabel={labels.toggleGroup}\n openGroup={openGroup}\n setOpenGroup={setOpenGroup}\n />\n ))}\n </div>\n </nav>\n {!!sidebarFooterItems?.length && (\n <SidebarFooterItems items={sidebarFooterItems} collapsed={iconOnly} renderLink={renderLink} />\n )}\n {sidebarFooter?.(iconOnly)}\n {/* No toggle at Large: the sidebar cannot collapse there (see `iconOnly`). */}\n {!large && (\n <div className=\"border-t border-[var(--border)] p-2\">\n {collapsed ? (\n <Tooltip label={labels.expand} side=\"end\" portal className=\"block\">\n <button\n type=\"button\"\n onClick={() => setCollapsed(false)}\n aria-label={labels.expand}\n className=\"flex w-full items-center justify-center min-h-9 rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <PanelLeftOpen className=\"size-4 shrink-0 rtl:-scale-x-100\" />\n </button>\n </Tooltip>\n ) : (\n <button\n type=\"button\"\n onClick={() => setCollapsed(true)}\n aria-label={labels.collapse}\n className=\"flex w-full items-center gap-3 min-h-9 px-3 py-2 rounded-md text-sm text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <PanelLeftClose className=\"size-4 shrink-0 rtl:-scale-x-100\" />\n <span className=\"truncate\">{labels.collapse}</span>\n </button>\n )}\n </div>\n )}\n </aside>\n\n {/* Content column beside the sidebar. main grows to fill so the footer\n sits at the bottom — at the viewport edge when content is short,\n beside the sidebar rather than under it. */}\n <div className=\"flex flex-1 flex-col min-w-0 md:min-h-0\">\n {/* `scrollbar-gutter: stable` reserves the scrollbar's track whether or\n not it is currently needed. Without it this element — the app's only\n scroll container from md up — narrows its own client box by the\n scrollbar width the moment a page's content outgrows it, and widens\n it again when the next page fits. Every centred `mx-auto` container\n inside then jumps sideways by half a scrollbar, and any fluid-width\n content changes width outright. Keksdose feedback #403 caught it as\n \"slight width increase/decrease\" when switching to the notifications\n settings section and back: that section is the only one tall enough\n to scroll. Platform-dependent, which is why it is easy to miss —\n overlay scrollbars (macOS, most Linux builds) take no layout space,\n classic ones (Windows) take ~15px.\n\n `both-edges`, not the bare `stable`: a one-sided reservation keeps the\n width stable but moves the middle. On a page short enough not to\n scroll, nothing is painted into the reserved track, so every centred\n `mx-auto` container sits half a scrollbar left of the optical centre\n and every full-bleed child stops ~15px short on the right. Keksdose\n feedback #491 (/settings#data) and #498 (/budgets) both reported it as\n \"the left boundary looks smaller / cut off compared to the right\" on\n the one element per page whose frame is a saturated colour — the rose\n destructive-action cards. Reserving the track on both edges keeps the\n content centred whether or not the scrollbar is showing. */}\n {/* `relative` makes <main> the containing block for everything absolutely\n positioned inside it. Tailwind's `sr-only` is `position:absolute`; with no\n positioned ancestor it resolves against the INITIAL containing block, is\n laid out at its offset from the top of the DOCUMENT, and grows\n `<html>`'s scroll height past the viewport — a second scrollbar beside this\n one that scrolls into empty space. Guarding it per component (see\n sr-only-containment.test) could never be complete: any page's own\n `sr-only` span re-opened it. Here it is closed for every child at once. */}\n <main\n className={cn(\n \"relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+1.5rem)] md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]\",\n // Embedded there is no document scroll to lean on below `md` either.\n embedded && \"min-h-0 overflow-y-auto\",\n )}\n >\n {children}\n </main>\n {footer}\n </div>\n </div>\n\n {/* The phone navigation: the group bar, and above it — when the current page\n belongs to a group with pages of its own — the row of those pages. The pair\n is the phone's version of the sidebar's two levels; without the upper row a\n group's `items` were simply unreachable below `md`. ONE measured box, so\n `--app-nav-h` covers both rows and whatever docks on the bar sits on the\n whole of it. */}\n <div\n ref={navRef}\n className=\"md:hidden fixed bottom-0 inset-x-0 z-30 border-t border-[var(--border)] bg-[var(--bg-surface)]\"\n >\n {mobileSubNav && <MobileSubNav nav={nav} layout={mobileSubNavLayout} />}\n <nav\n data-tour=\"nav\"\n className=\"grid\"\n style={{ gridTemplateColumns: `repeat(${barNav.length + (moreNav.length ? 1 : 0)}, minmax(0, 1fr))` }}\n >\n {barNav.map((item) => (\n <MobileNavItem key={item.to} item={item} />\n ))}\n {moreNav.length > 0 && <MobileMoreCell hidden={moreNav} labels={moreLabels} />}\n </nav>\n </div>\n </div>\n </AppShellNesting.Provider>\n );\n}\n\nconst routerLink: KitLinkComponent = ({ href, ...p }) => <Link to={href} {...p} />;\nconst plainLink: KitLinkComponent = ({ children, ...p }) => <a {...p}>{children}</a>;\n\nconst FOOTER_ICON_LINK =\n \"flex min-h-8 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\";\n\n/** Calls the link renderer as a component, so a router link's hooks are its own. */\nfunction RenderedLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {\n return render(props);\n}\n\n/** The sidebar's bottom area, from {@link AppShellSidebarFooterItem}s. */\nfunction SidebarFooterItems({\n items,\n collapsed,\n renderLink,\n}: {\n items: AppShellSidebarFooterItem[];\n collapsed: boolean;\n renderLink?: KitLinkComponent;\n}) {\n const kitLink = useKitLink();\n const linkTo = (to: string, external: boolean | undefined, { children, ...props }: Omit<KitLinkProps, \"href\">) => {\n // The kit's one link rule (`pickLinkRenderer`): an external `to` or an in-page\n // `#anchor` is a plain `<a>` even without `external`; only `external` opens a tab.\n const InApp = pickLinkRenderer(renderLink, kitLink ?? routerLink, to) ?? plainLink;\n return external ? (\n <a href={to} target=\"_blank\" rel=\"noopener noreferrer\" {...props}>\n {children}\n </a>\n ) : (\n <RenderedLink render={InApp} href={to} {...props}>\n {children}\n </RenderedLink>\n );\n };\n\n return (\n <div data-slot=\"sidebar-footer-items\" className={cn(\"px-2 py-1\", collapsed ? \"space-y-1\" : \"space-y-1.5\")}>\n {items.map((item) => {\n if (collapsed) {\n const to = item.kind === \"links\" ? (item.to ?? item.links[0]?.to) : item.to;\n if (!to) return null;\n const external = item.kind === \"links\" ? item.links.find((l) => l.to === to)?.external : item.external;\n return (\n <Tooltip key={item.key} label={item.label} side=\"end\" portal className=\"block\">\n {linkTo(to, external, {\n \"aria-label\": item.label,\n className: FOOTER_ICON_LINK,\n children: <item.icon className=\"size-4\" aria-hidden />,\n })}\n </Tooltip>\n );\n }\n if (item.kind === \"links\") {\n return (\n <nav\n key={item.key}\n aria-label={item.label}\n className=\"flex flex-wrap justify-center gap-x-3 gap-y-0.5 text-caption text-[var(--text-muted)]\"\n >\n {item.links.map((l) => (\n <span key={l.to}>\n {linkTo(l.to, l.external, {\n className: \"rounded-sm hover:text-[var(--text-secondary)] hover:underline\",\n children: l.label,\n })}\n </span>\n ))}\n </nav>\n );\n }\n const link = linkTo(item.to, item.external, {\n className: cn(FOOTER_ICON_LINK, \"gap-1.5 text-xs font-medium\"),\n children: (\n <>\n <item.icon className=\"size-3.5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\">{item.text ?? item.label}</span>\n </>\n ),\n });\n return item.hint ? (\n <Tooltip key={item.key} label={item.hint} side=\"top\" portal className=\"block\">\n {link}\n </Tooltip>\n ) : (\n <div key={item.key}>{link}</div>\n );\n })}\n </div>\n );\n}\n\n/**\n * A bar cell's look, shared by the entries and the More cell. The active page needs a\n * clear marker, not just a subtle text shade (feedback #327): brand-accent icon + label,\n * bolder weight, and a top accent bar spanning the cell so it reads at a glance.\n */\nconst mobileCellClass = (active: boolean) =>\n cn(\n // `h-full`: the grid row stretches every cell to the tallest one's height, so a\n // label on two lines never leaves its neighbours a different shape.\n \"relative flex h-full w-full flex-col items-center justify-start py-2 text-caption gap-0.5 transition-colors\",\n \"before:absolute before:content-[''] before:inset-x-4 before:top-0 before:h-0.5 before:rounded-full before:transition-colors\",\n active\n ? \"font-semibold text-[var(--brand)] before:bg-[var(--brand)]\"\n : \"text-[var(--text-muted)] before:bg-transparent\",\n );\n\n/**\n * A bar cell's label (0.32, §10.7): up to TWO lines, never an ellipsis on one. At Large\n * a cell is a fifth of a phone holding type a quarter bigger, and one line truncated\n * \"Transactions\" to \"Trans…\" in every app (Kurvenschmiede). Two lines and then a clamp,\n * centred, a long word broken (and hyphenated where the page's `lang` allows) rather\n * than overflowing into the next cell. `shortLabel` still wins where the app has one.\n */\nfunction MobileCellLabel({ children }: { children: ReactNode }) {\n return (\n <span className=\"line-clamp-2 max-w-full break-words px-0.5 text-center leading-tight hyphens-auto\">\n {children}\n </span>\n );\n}\n\n/** One cell of the phone's group bar. Marked while the group's own page OR any of its\n * pages is current — NavLink alone only knows its own `to`, which left the bar with\n * nothing marked on every sub-page (the same defect the sidebar had). */\nfunction MobileNavItem({ item }: { item: AppShellNavItem }) {\n const subActive = useSubItemActive(item);\n return (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n className={({ isActive }) => mobileCellClass(isActive || subActive)}\n >\n <span className=\"relative\">\n <item.icon className=\"size-5\" />\n </span>\n <MobileCellLabel>{item.shortLabel ?? item.label}</MobileCellLabel>\n </NavLink>\n );\n}\n\n/** Whether the current route is `item`'s page or one of its pages. */\nfunction entryIsCurrent(item: AppShellNavItem, pathname: string): boolean {\n return (\n !!matchPath({ path: item.to, end: item.end ?? true }, pathname) ||\n !!item.items?.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname))\n );\n}\n\n/** A hidden entry's link, resolved as the router would write it (basename included). */\nfunction TourAnchor({ to, dataTour }: { to: string; dataTour?: string }) {\n const href = useHref(to);\n return (\n // eslint-disable-next-line jsx-a11y/anchor-has-content -- a tour anchor, aria-hidden and inert; the More button under it is the control (see MobileMoreCell)\n <a\n href={href}\n data-tour={dataTour}\n // Not a control: no name, no tab stop, no pointer — the More button under it\n // takes the tap. It exists only to be FOUND (see MobileMoreCell).\n aria-hidden\n tabIndex={-1}\n className=\"pointer-events-none absolute inset-0\"\n />\n );\n}\n\n/**\n * The bar's \"More\" cell (0.32, docs/text-size-harmonization.md §4, §10.7): the entries\n * past `mobileBarMax`, in a sheet — each entry as a link, and under a group its pages,\n * since a group the bar no longer shows has no sub-row to reach them by either. Marked\n * active while the current page is one of them, as the cell it replaced would be.\n *\n * **Tour anchors.** A guided tour finds a nav entry by `[data-tour=\"nav\"] a[href=\"/x\"]`\n * (keksdose's tours) or by the entry's own `dataTour`. An entry under More has no link\n * in the bar, and the sidebar's copy is `display:none` on a phone, so such a step would\n * spotlight nothing. So the More cell carries the hidden entries' links — one `<a>` per\n * entry and per page of a hidden group, with the entry's `href` and `dataTour` — laid\n * over the cell (`absolute inset-0`): both selectors resolve to a visible box, and that\n * box is the More cell, which is where the reader has to go next. They are\n * `aria-hidden`, out of the tab order and pointer-transparent, so neither a screen\n * reader nor a tap ever meets them; the button under them is the one control.\n */\nfunction MobileMoreCell({\n hidden,\n labels: labelsProp,\n}: {\n hidden: AppShellNavItem[];\n labels?: Partial<AppShellMoreLabels>;\n}) {\n const labels: AppShellMoreLabels = useKitLabels(\"appShellMore\", DEFAULT_APP_SHELL_MORE_LABELS, labelsProp);\n const { pathname } = useLocation();\n const [open, setOpen] = useState(false);\n const active = hidden.some((item) => entryIsCurrent(item, pathname));\n const close = () => setOpen(false);\n return (\n <div data-slot=\"app-shell-more\" className=\"relative min-w-0\">\n <button\n type=\"button\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n onClick={() => setOpen(true)}\n className={mobileCellClass(active)}\n >\n <span className=\"relative\">\n <MoreHorizontal aria-hidden className=\"size-5\" />\n </span>\n <MobileCellLabel>{labels.more}</MobileCellLabel>\n </button>\n {hidden.flatMap((item) => [\n <TourAnchor key={item.to} to={item.to} dataTour={item.dataTour} />,\n ...(item.items ?? []).map((sub) => <TourAnchor key={`${item.to} ${sub.to}`} to={sub.to} />),\n ])}\n <DialogFrame open={open} onClose={close} title={labels.moreTitle} closeButton bodyClassName=\"space-y-1\">\n <ul className=\"space-y-1\">\n {hidden.map((item) => (\n <li key={item.to}>\n <NavLink\n to={item.to}\n end={item.end ?? true}\n onClick={close}\n className={({ isActive }) =>\n cn(\n \"flex min-h-11 items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors\",\n isActive\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <item.icon aria-hidden className=\"size-5 shrink-0\" />\n <span className=\"min-w-0 break-words leading-snug\">{item.label}</span>\n </NavLink>\n {!!item.items?.length && (\n <ul aria-label={item.label} className=\"ms-[1.3rem] mt-0.5 space-y-0.5 border-s border-[var(--border)] ps-2\">\n {item.items.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n onClick={close}\n className={({ isActive }) =>\n cn(\n \"flex min-h-11 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors\",\n isActive\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon aria-hidden className=\"size-4 shrink-0 opacity-70\" />\n <span className=\"min-w-0 break-words leading-snug\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n )}\n </li>\n ))}\n </ul>\n </DialogFrame>\n </div>\n );\n}\n\n/**\n * The upper row of the phone navigation: the pages of the group the reader is in, as\n * a sideways-scrolling row of pills. Rendered only while such a group is current —\n * on a page outside every group the bar stays one row, rather than showing an empty\n * shelf or guessing a group.\n *\n * It slides in over 200ms (fade only under reduced motion) and wraps onto more rows\n * rather than scrolling, so every page of the group is in view at once.\n */\nfunction MobileSubNav({ nav, layout }: { nav: AppShellNavItem[]; layout: \"wrap\" | \"scroll\" }) {\n const { pathname } = useLocation();\n const scroll = layout === \"scroll\";\n const listRef = useRef<HTMLUListElement>(null);\n const fade = useStripFade(listRef);\n // The current page into view — the row only, never the page.\n useLayoutEffect(() => {\n const list = listRef.current;\n const current = list?.querySelector<HTMLElement>('[aria-current=\"page\"]');\n if (scroll && list && current) scrollIntoStrip(list, current);\n }, [scroll, pathname]);\n const group = nav.find(\n (item) =>\n !!item.items?.length &&\n (matchPath({ path: item.to, end: item.end ?? true }, pathname) ||\n item.items.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname))),\n );\n\n if (!group) return null;\n return (\n <nav\n aria-label={group.label}\n // Keyed by group so switching groups replays the entrance.\n key={group.to}\n className=\"animate-subnav border-b border-[var(--border)]\"\n >\n <ul\n ref={listRef}\n // `wrap` (default) takes as many rows as the group needs: a sideways row hides\n // pages past the edge, and every page of the group in view is the point of the\n // row. The bar grows with it — `--app-nav-h` measures it. `scroll` is the app's\n // opt-in trade: one row, the cut edge faded (see mobileSubNavLayout).\n className={cn(\n \"flex gap-1.5 px-3 py-2\",\n scroll ? \"overflow-x-auto overscroll-x-contain [scrollbar-width:none]\" : \"flex-wrap\",\n )}\n data-overflow={scroll ? fade.overflow : undefined}\n style={scroll && fade.mask ? { maskImage: fade.mask, WebkitMaskImage: fade.mask } : undefined}\n >\n {group.items!.map((sub) => (\n <li key={sub.to} className=\"shrink-0\">\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n className={({ isActive }) =>\n cn(\n \"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors\",\n isActive\n ? \"border-[var(--brand)] bg-[var(--brand)] font-medium text-[var(--brand-contrast)]\"\n : \"border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-3.5 shrink-0\" aria-hidden />\n {sub.label}\n </NavLink>\n </li>\n ))}\n </ul>\n </nav>\n );\n}\n\n/** `strong` is the current page; `trail` is the group that CONTAINS the current page\n * while the page itself is shown elsewhere (the inline list below it). */\nconst navLinkClass = (collapsed: boolean, forceActive: boolean, trail = false) =>\n ({ isActive }: { isActive: boolean }) =>\n `flex items-center gap-3 min-h-9 ${collapsed ? \"justify-center px-2\" : \"px-3\"} py-2 rounded-md text-sm transition-colors duration-150 ${\n isActive || (forceActive && !trail)\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : forceActive\n ? \"font-medium text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\"\n }`;\n\n/** Whether any of an entry's sub-items is the current route. NavLink only knows about\n * its OWN `to`, so without this a group whose pages live at sibling paths\n * (`/fields`, `/numbers`, …) lost its highlight the moment the reader left the one\n * page the group linked to — the sidebar then marked nothing at all, and the only\n * \"you are here\" left was inside a flyout that is closed unless hovered. */\nfunction useSubItemActive(item: AppShellNavItem): boolean {\n const { pathname } = useLocation();\n return !!item.items?.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname));\n}\n\n/** A single desktop sidebar entry. Plain link (with a tooltip when collapsed) —\n * unless it has sub-items, in which case hovering/focusing opens a flyout, or (with\n * `inline`) a disclosure lists them underneath. */\nfunction SidebarNavItem({\n item,\n collapsed,\n inline,\n toggleGroupLabel,\n openGroup,\n setOpenGroup,\n}: {\n item: AppShellNavItem;\n collapsed: boolean;\n inline: boolean;\n toggleGroupLabel: (groupLabel: string) => string;\n openGroup: string | null;\n setOpenGroup: (to: string | null) => void;\n}) {\n const hasSub = !!item.items?.length;\n const subActive = useSubItemActive(item);\n\n // In the EXPANDED sidebar a group is one component in both styles, so switching\n // between them is a transition of the same element (the page list folding away or\n // unfolding) rather than one tree replaced by another. The icon-only sidebar keeps\n // the plain flyout below: an indented list has no room there.\n if (hasSub && !collapsed) {\n return (\n <SidebarGroup\n item={item}\n subActive={subActive}\n inline={inline}\n toggleGroupLabel={toggleGroupLabel}\n open={openGroup === item.to}\n setOpenGroup={setOpenGroup}\n />\n );\n }\n\n const link = (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n className={navLinkClass(collapsed, subActive)}\n aria-haspopup={hasSub ? \"menu\" : undefined}\n >\n <span className=\"relative shrink-0\">\n <item.icon className=\"size-4\" />\n </span>\n {/* Wraps rather than truncates: in the sidebar a label cut to \"Checkbox, switch\n & sl…\" hides the one word that tells two pages apart, and there is height\n to spare. (The phone bar clamps at two lines instead, since 0.32 §10.7.) */}\n {!collapsed && <span className=\"min-w-0 flex-1 break-words leading-snug\">{item.label}</span>}\n {!collapsed && hasSub && <ChevronRight className=\"size-3.5 shrink-0 opacity-60 rtl:-scale-x-100\" />}\n </NavLink>\n );\n\n if (hasSub) return <SidebarFlyout item={item}>{link}</SidebarFlyout>;\n return collapsed ? (\n <Tooltip label={item.label} side=\"end\" portal className=\"block\">\n {link}\n </Tooltip>\n ) : (\n link\n );\n}\n\n/**\n * An entry with sub-items in the expanded sidebar.\n *\n * - `inline`: the entry's own link (its overview page) plus a chevron button that\n * shows or hides the sub-items indented beneath it. Two controls rather than one\n * because they do two different things — a row that both navigated AND toggled\n * would make \"open the overview\" and \"peek at the pages\" the same click.\n * - flyout: the link alone, with the sub-items in a panel beside the sidebar on\n * hover/focus. The inline list is still rendered, folded shut, so that switching\n * styles animates it rather than swapping it.\n *\n * THE FOLD is a `grid-template-rows` transition from `0fr` to `1fr` — the one way to\n * animate to a content-sized height without measuring it, so a translated label that\n * wraps to two lines unfolds exactly as far as it needs. `visibility` rides the same\n * transition so a shut list drops out of the accessibility tree only once it has\n * finished closing, and `inert` takes its links out of the tab order immediately.\n */\nfunction SidebarGroup({\n item,\n subActive,\n inline,\n toggleGroupLabel,\n open,\n setOpenGroup,\n}: {\n item: AppShellNavItem;\n subActive: boolean;\n inline: boolean;\n toggleGroupLabel: (groupLabel: string) => string;\n /** Whether this is the sidebar's one open group (see AppShell's `openGroup`). */\n open: boolean;\n setOpenGroup: (to: string | null) => void;\n}) {\n const listId = useId();\n const { pathname } = useLocation();\n const onOwnPage = !!matchPath({ path: item.to, end: item.end ?? true }, pathname);\n // Opening follows the reader: arriving on this group's overview or one of its pages\n // (by link, by the pager, by Back) opens it — and, the sidebar holding ONE open\n // group, closes whichever was open before. Both folds run at once, so the eye sees\n // one list hand over to the other. Run in an effect after mount, so the current\n // group UNFOLDS into view on load rather than appearing already open.\n const here = subActive || onOwnPage;\n useEffect(() => {\n if (here) setOpenGroup(item.to);\n }, [here, item.to, setOpenGroup]);\n const expanded = inline && open;\n\n const link = (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n aria-haspopup={inline ? undefined : \"menu\"}\n // `trail` (inline only): while a page of the group is current, the group row is\n // the breadcrumb, not the destination — the page's own row below carries the\n // strong marker, and two inverse rows would say \"you are here\" twice. In the\n // flyout style the pages are out of sight, so the group row IS the marker.\n className={(state) => cn(navLinkClass(false, subActive, inline)(state), \"min-w-0 flex-1\")}\n >\n <span className=\"relative shrink-0\">\n <item.icon className=\"size-4\" />\n </span>\n <span className=\"min-w-0 flex-1 break-words leading-snug\">{item.label}</span>\n {!inline && <ChevronRight className=\"size-3.5 shrink-0 opacity-60 rtl:-scale-x-100\" />}\n </NavLink>\n );\n\n return (\n <div>\n {inline ? (\n <div className=\"flex items-center gap-0.5\">\n {link}\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={listId}\n aria-label={toggleGroupLabel(item.label)}\n onClick={() => setOpenGroup(open ? null : item.to)}\n className=\"flex size-9 shrink-0 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <ChevronRight\n aria-hidden\n className={cn(\n \"size-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none rtl:-scale-x-100\",\n // Under dir=\"rtl\" the chevron is MIRRORED first (scale applies before rotate), so\n // it starts pointing left and a +90° turn would point it UP — hence −90° there.\n open && \"rotate-90 rtl:-rotate-90\",\n )}\n />\n </button>\n </div>\n ) : (\n <SidebarFlyout item={item}>{link}</SidebarFlyout>\n )}\n <div\n id={listId}\n inert={!expanded}\n className=\"grid transition-[grid-template-rows,visibility] duration-200 ease-out motion-reduce:transition-none\"\n style={{\n gridTemplateRows: expanded ? \"1fr\" : \"0fr\",\n visibility: expanded ? \"visible\" : \"hidden\",\n }}\n >\n <div\n className={cn(\n \"min-h-0 overflow-hidden transition-opacity duration-200 ease-out motion-reduce:transition-none\",\n expanded ? \"opacity-100\" : \"opacity-0\",\n )}\n >\n {/* The rule and indent live on the list INSIDE the clipping box: margin or\n padding on the element whose row is collapsing to 0fr would hold it open\n by that much. */}\n <ul className=\"ms-[1.3rem] mt-0.5 space-y-0.5 border-s border-[var(--border)] ps-2\">\n {item.items!.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n className={({ isActive }) =>\n cn(\n \"flex min-h-8 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors\",\n isActive\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-4 shrink-0 opacity-70\" aria-hidden />\n <span className=\"min-w-0 break-words leading-snug\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n </div>\n </div>\n </div>\n );\n}\n\n// Only ONE sidebar flyout should be on screen at a time. Each open flyout parks its\n// \"close now\" here so the next item to open can dismiss it immediately, instead of\n// leaving the previous panel up for the 140ms travel delay while the cursor moves\n// down the nav list — which showed two panels at once.\nlet activeFlyoutClose: (() => void) | null = null;\n\n/**\n * Wraps a nav item that has sub-items: on hover or keyboard focus it opens a\n * portalled flyout to the right of the sidebar (portalled so the sidebar's\n * `overflow-hidden` can't clip it) with the item's label as a header and each\n * sub-item as an icon + title link. A short close delay lets the cursor travel\n * from the item onto the panel; Escape / outside-move close it.\n */\nfunction SidebarFlyout({ item, children }: { item: AppShellNavItem; children: ReactNode }) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const [open, setOpen] = useState(false);\n // The flyout is portalled out of the subtree whose `dir` it inherited; read on open.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n\n // Anchor the flyout to the item's END edge — its right, or its left in RTL, where\n // the sidebar sits on the right of the window. The hook re-measures on\n // scroll/resize (the nav list can scroll).\n const rect = useAnchoredRect(wrapperRef, open);\n const pos = rect\n ? dir === \"rtl\"\n ? { top: rect.top, right: window.innerWidth - rect.left }\n : { top: rect.top, left: rect.right }\n : null;\n\n const cancelClose = useCallback(() => {\n if (closeTimer.current) {\n clearTimeout(closeTimer.current);\n closeTimer.current = null;\n }\n }, []);\n // Stable identity: the coordinator above holds this so a sibling item can close\n // THIS flyout without waiting out its travel delay.\n const closeNow = useCallback(() => {\n cancelClose();\n setOpen(false);\n }, [cancelClose]);\n const openMenu = useCallback(() => {\n cancelClose();\n if (activeFlyoutClose && activeFlyoutClose !== closeNow) activeFlyoutClose();\n activeFlyoutClose = closeNow;\n setDir(dirOf(wrapperRef.current));\n setOpen(true);\n }, [cancelClose, closeNow]);\n const scheduleClose = useCallback(() => {\n cancelClose();\n closeTimer.current = setTimeout(() => {\n setOpen(false);\n if (activeFlyoutClose === closeNow) activeFlyoutClose = null;\n }, 140);\n }, [cancelClose, closeNow]);\n\n // Close on Escape while open.\n useEscapeKey(() => setOpen(false), open);\n\n useEffect(\n () => () => {\n cancelClose();\n if (activeFlyoutClose === closeNow) activeFlyoutClose = null;\n },\n [cancelClose, closeNow],\n );\n\n return (\n // Not a control: hover and focus-within on the nav link inside. Focus (the\n // keyboard path) opens it exactly as hover does, Escape closes it.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- hover/focus-within wrapper around a real link\n <div\n ref={wrapperRef}\n className=\"relative\"\n onMouseEnter={openMenu}\n onMouseLeave={scheduleClose}\n onFocus={openMenu}\n onBlur={scheduleClose}\n >\n {children}\n {open &&\n pos &&\n createPortal(\n // Keeps the flyout open while the pointer crosses into it; keyboard users\n // reach it by focus, which the wrapper above already handles.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- pointer-only hover bridge\n <div\n dir={dir}\n style={{ position: \"fixed\", ...pos }}\n className=\"z-40 ps-1\"\n onMouseEnter={cancelClose}\n onMouseLeave={scheduleClose}\n >\n <div\n role=\"menu\"\n aria-label={item.label}\n className=\"animate-flyout min-w-52 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\"\n >\n <div className=\"border-b border-[var(--border)] px-3 py-2 text-caption font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {item.label}\n </div>\n <ul className=\"py-1\">\n {item.items!.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n className={({ isActive }) =>\n cn(\n \"flex items-center gap-2.5 px-3 py-2 text-sm\",\n isActive\n ? \"bg-[var(--bg-active)] font-medium text-[var(--text-primary)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-4 shrink-0 text-[var(--text-placeholder)]\" />\n <span className=\"min-w-0 break-words leading-snug\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n </div>\n </div>,\n document.body,\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/app-shell.tsx"],"sourcesContent":["import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Link, NavLink, matchPath, useHref, useLocation } from \"react-router\";\nimport { ChevronRight, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { scrollIntoStrip, useStripFade } from \"../lib/strip-fade\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { readStored, writeStored } from \"../lib/safe-storage\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { pickLinkRenderer } from \"../components/text-link\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { CompactControls } from \"../components/ui\";\nimport { useAnchoredRect } from \"../hooks/use-anchored-rect\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { useBreakpoint } from \"../hooks/use-breakpoint\";\nimport { useTextSize } from \"../theme/text-size\";\nimport type { TextSize } from \"../theme/text-size\";\nimport { DEFAULT_APP_SHELL_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\n\nexport interface AppShellSubItem {\n to: string;\n label: string;\n icon: LucideIcon;\n /** Passed to NavLink's `end` (exact match). Defaults to true. */\n end?: boolean;\n}\n\nexport interface AppShellNavItem {\n to: string;\n label: string;\n /** Optional shorter label for the MOBILE BOTTOM BAR only (the sidebar, its\n * tooltips and any palette built on this list keep `label`).\n *\n * The bar divides the viewport into `nav.length` equal cells, so a label's room\n * shrinks with every entry added — at six entries on a 406 px phone that is ~67 px,\n * and \"Import & Export\" wrapped to two lines and pushed the row taller than its\n * neighbours (keksdose feedback live #210). A per-item override is better than\n * shortening the real label, which the sidebar has ample room for and which is the\n * name the rest of the product uses. */\n shortLabel?: string;\n /** 0.31.0: leave this entry out of the MOBILE BOTTOM BAR; the sidebar keeps it.\n * For an entry a phone reaches another way — the brand link to the start page —\n * when one cell fewer is what keeps the others' labels whole (the bar divides its\n * width evenly, see `shortLabel`). Its group's sub-row still shows while one of its\n * pages is open. Named like `DataTable`'s column `mobileHidden`. */\n mobileHidden?: boolean;\n icon: LucideIcon;\n /** Passed to NavLink's `end` (exact match). Defaults to true. */\n end?: boolean;\n /** Optional `data-tour` value rendered on this item's link (both the desktop\n * sidebar and the mobile bottom bar), so a guided tour can spotlight ONE nav\n * entry precisely instead of matching by href (feedback #338). */\n dataTour?: string;\n /** Optional sub-items. When present, hovering (or focusing) the item opens a\n * flyout to the right of the sidebar with a header (this item's label) and the\n * sub-items as icon + title links. Works collapsed or expanded; the mobile\n * bottom bar ignores sub-items and links to `to`. */\n items?: AppShellSubItem[];\n}\n\n/**\n * One entry of the sidebar's bottom area ({@link AppShellProps.sidebarFooterItems}) —\n * the version link and the legal links kastlan hand-built in `sidebarFooter`.\n *\n * - `link` (default): one link. Expanded it shows `icon` + `text` (default `label`),\n * with `hint` as a tooltip if given; collapsed it is the icon alone, named and\n * tooltipped by `label`.\n * - `links`: a small group — the legal links. Expanded, a `<nav>` named `label` holding\n * the links as a wrapping row of small text; collapsed, ONE icon link to `to` (default\n * the first link's), named and tooltipped by `label`, since a column of icons for\n * \"Imprint\", \"Privacy\" and \"Terms\" would say nothing an icon can.\n */\nexport type AppShellSidebarFooterItem =\n | {\n kind?: \"link\";\n key: string;\n to: string;\n icon: LucideIcon;\n /** The link's name, and the tooltip when collapsed (\"What's new · v1.4.0\"). */\n label: string;\n /** Visible text when expanded (\"Kastlan v1.4.0\"). Default `label`. */\n text?: ReactNode;\n /** A tooltip on the expanded link (\"What's new\"). */\n hint?: string;\n /** Leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`. */\n external?: boolean;\n }\n | {\n kind: \"links\";\n key: string;\n icon: LucideIcon;\n /** The group's name — its `<nav>` label, and the collapsed icon's name (\"Legal\"). */\n label: string;\n /** The collapsed icon's target. Default the first link's `to`. */\n to?: string;\n links: { to: string; label: string; external?: boolean }[];\n };\n\n/**\n * The words of the phone bar's \"More\" cell and its sheet (0.32,\n * docs/text-size-harmonization.md §4 Navigation, §10.7) — the `appShellMore` namespace of\n * `<UiKitProvider labels>`, overridable per shell through `moreLabels`.\n */\nexport interface AppShellMoreLabels {\n /** The cell's visible label, under its icon — one short word. */\n more: string;\n /** The sheet's heading, and so its accessible name. */\n moreTitle: string;\n}\n\nexport const DEFAULT_APP_SHELL_MORE_LABELS: AppShellMoreLabels = {\n more: \"More\",\n moreTitle: \"More pages\",\n};\n\n/**\n * The most entries the phone bar shows before the rest go under \"More\", per text size\n * (§10.7): every entry at Normal, four at Large, three at Extra large. A fifth of a\n * 360 px phone at 150 % is 48 px of Normal-sized room, and the cells hold a word each.\n */\nexport const DEFAULT_MOBILE_BAR_MAX: Readonly<Record<TextSize, number>> = {\n normal: Number.POSITIVE_INFINITY,\n large: 4,\n xlarge: 3,\n};\n\n/**\n * Split the bar's entries into the cells shown and the ones under \"More\", in the app's\n * order (§10.7: the app puts its four most used first, or orders by role). One hidden\n * entry is no saving — the More cell would take its place — so the bar then shows them\n * all: the cell count is the same either way, and a real entry beats a detour.\n */\nexport function splitMobileBar<T>(entries: readonly T[], max: number): { shown: T[]; hidden: T[] } {\n const limit = Math.max(1, Math.floor(max));\n if (entries.length <= limit + 1) return { shown: [...entries], hidden: [] };\n return { shown: entries.slice(0, limit), hidden: entries.slice(limit) };\n}\n\n/**\n * Exported and `<div>`-shaped. This is the outermost element of every consuming app, so\n * there is nothing above it to hang an id, a landmark label or a `data-tour` anchor on —\n * and nothing a consumer can wrap it in either, since the root owns the `h-dvh` and\n * `overflow-hidden` the whole layout is built on (audit §api-design).\n */\nexport interface AppShellProps extends ComponentPropsWithoutRef<\"div\"> {\n /** Navigation entries — shared by the desktop sidebar and mobile bottom bar. */\n nav: AppShellNavItem[];\n /** The composed top bar (e.g. the shared TopBar with app-owned actions). */\n topBar: ReactNode;\n /** Main content — typically the router `<Outlet />`. */\n children: ReactNode;\n /** Desktop-only footer rendered below the content (hidden on mobile). */\n footer?: ReactNode;\n /** Extra sidebar content above the collapse toggle (e.g. a version link).\n * Receives the collapsed state so it can render compact vs. full. */\n sidebarFooter?: (collapsed: boolean) => ReactNode;\n /** The sidebar's bottom area as data — a version link, the legal links — drawn full\n * when expanded and as icon + tooltip when collapsed. Rendered above\n * `sidebarFooter`, which stays for anything the data cannot describe. */\n sidebarFooterItems?: AppShellSidebarFooterItem[];\n /** The router link for `sidebarFooterItems`. Default: the provider's\n * `linkComponent`, else react-router's `Link`. */\n renderLink?: KitLinkComponent;\n /** localStorage key for the persisted collapse state. */\n collapseStorageKey?: string;\n /** Default: `appShell.collapse` from the {@link UiKitProvider}, else English. */\n collapseLabel?: string;\n /** Default: `appShell.expand` from the {@link UiKitProvider}, else English. */\n expandLabel?: string;\n /** How an entry's `items` are revealed in the EXPANDED desktop sidebar.\n *\n * - `\"flyout\"` (default) — a panel beside the sidebar on hover/focus. Compact: the\n * sidebar stays one row per group however many pages a group holds.\n * - `\"inline\"` — a disclosure under the entry, the pattern of MUI's nested List and\n * most documentation sites. Every page is visible at once, and the group holding\n * the current page opens on its own, so the reader can see where they are without\n * hovering anything.\n *\n * The icon-only (collapsed) sidebar always uses the flyout: an indented list has no\n * room there. The mobile bottom bar ignores `items` in both modes. */\n subNav?: \"flyout\" | \"inline\";\n /** Accessible name of the inline disclosure button, given the group's label.\n * Only used with `subNav=\"inline\"`. Its expanded state is carried by\n * `aria-expanded`, so one wording serves both directions. Default:\n * `appShell.toggleGroup` from the {@link UiKitProvider}, else English. */\n toggleGroupLabel?: (groupLabel: string) => string;\n /** Below `md`: show the current group's `items` as a scrollable row above the\n * bottom bar — the phone's counterpart of the sidebar's second level. Default true;\n * `false` keeps the single bar, where a group entry only links to its own `to`. */\n mobileSubNav?: boolean;\n /**\n * How the phone sub-nav lays out a group with more pages than fit on one row.\n * `\"wrap\"`: every page in view, on as many rows as it takes — the bar grows.\n * `\"scroll\"`: one row that scrolls sideways, faded at a cut edge, with the current\n * page scrolled into view. A long group took four rows (~130px) of a 390px screen\n * above the bottom bar (showcase audit); scrolling gives that back at the price of\n * pages hidden past the edge, so it is the app's call, per shell.\n *\n * Unset (0.32.2, kastlan's 0.32.1 report): `\"wrap\"` at Normal and `\"scroll\"` at Large\n * and Extra large, where a wrapped group of eight pages took six or seven rows and\n * covered the page — the other half of what `mobileBarMax` does for the bar.\n */\n mobileSubNavLayout?: \"wrap\" | \"scroll\";\n /**\n * The most entries the phone bar shows; the rest move into a \"More\" cell that opens a\n * sheet listing them, each group with its pages (0.32, docs/text-size-harmonization.md\n * §4, §10.7). The bar takes `nav` in the app's order — after `mobileHidden` — so the\n * app puts its most used entries first (or orders by role: kastlan's manager and\n * accountant see different firsts).\n *\n * Default {@link DEFAULT_MOBILE_BAR_MAX}: unlimited at Normal, 4 at Large, 3 at Extra\n * large, because the cells' words grow with the text and the phone does not. A number\n * replaces the default at every size; `{ large: 5 }` replaces it at one size only.\n * Never fewer than one entry; and one entry over the limit shows in full rather than\n * behind a More that would take the same cell.\n */\n mobileBarMax?: number | Partial<Record<TextSize, number>>;\n /** The More cell's words over the provider's `appShellMore`. */\n moreLabels?: Partial<AppShellMoreLabels>;\n /**\n * Lay the shell out inside its PARENT'S box instead of the viewport: `h-full` rather\n * than `h-dvh`, its own scroll container at every width, a bottom bar pinned to the\n * shell rather than the window, `--app-nav-h` published on the shell alone, and no\n * persisted collapse state unless `collapseStorageKey` is passed. For an app preview,\n * a device frame, a docs example.\n *\n * Implied for a shell rendered inside another AppShell, which is the case this\n * exists for: the inner one used to publish its own nav height on `<html>`, fight\n * the outer one over it, and remove it on unmount from under the outer app.\n */\n embedded?: boolean;\n}\n\n/** True below an AppShell — how a nested shell knows it is one. */\nconst AppShellNesting = createContext(false);\n\n/**\n * The bottom nav's own height, published as `--app-nav-h` so anything that has to sit\n * ON it can stop guessing.\n *\n * Keksdose live #314 is what this is for. Its rework — *\"Stick it to the bottom\n * touching the bottom icon bar with budget, accounts, … Currently there is a small gap\n * which confuses\"* — was a receipt footer pinned at `bottom-20`, because `<main>`\n * reserves `pb-20` for this nav and 20 looked like the nav's height. It is not: the\n * padding is generous CLEARANCE, and the nav is content-sized (icon + label + `py-2`,\n * about 56px), so the footer floated ~24px above the bar.\n *\n * Measured rather than named, because the height is not a constant anyone owns: it is\n * an icon, a translated label that can wrap or truncate, and whatever the platform does\n * with the safe-area inset. A hard-coded `bottom-14` would be the same guess one number\n * lower, and it would be wrong again the first time a label needed two lines.\n *\n * `0px` when the nav is not rendered — above `md` it is `display:none`, which observes\n * as a zero box — so a consumer can write `bottom-[var(--app-nav-h,0px)]` once and get\n * the right answer at both widths without a breakpoint of its own.\n */\nfunction useNavHeightVar(\n ref: React.RefObject<HTMLElement | null>,\n shellRef: React.RefObject<HTMLElement | null>,\n embedded: boolean,\n): void {\n // Re-run when the breakpoint flips. A ResizeObserver SKIPS an element that is not\n // being rendered, so the nav going `display:none` above `md` fires no callback and\n // would leave the last phone height published — a desktop footer would then sit 56px\n // off the bottom after a live resize. `useBreakpoint(\"md\")` is the nav's own `md:hidden`\n // at the text size in force (0.32), so the two cannot disagree about where the\n // boundary is — at Extra large both move to 72rem.\n const isMdUp = useBreakpoint(\"md\", false);\n useEffect(() => {\n const node = ref.current;\n const shell = shellRef.current;\n // Always on the shell's own root, so everything inside it — a nested shell's\n // content included — reads the nearest bar's height. On <html> as well only for\n // the OUTERMOST shell, for what it portals to <body> (a sheet docking on the bar).\n // The DOM check backs up the context for a shell mounted in a separate React root.\n const global = !embedded && !shell?.parentElement?.closest(\"[data-app-shell]\");\n const targets = [shell, global ? document.documentElement : null].filter(\n (el): el is HTMLElement => el !== null,\n );\n // `getBoundingClientRect().height`, rounded DOWN — not `offsetHeight`, and not\n // ceil. Measured on the running app at 406x816: the bar is **55.5px** tall, and\n // both `offsetHeight` and `Math.ceil` answer 56. That is why live #314 round four\n // was *\"No change. Still a pixel inbetween\"* — the previous fix was arithmetically\n // a no-op on the very value it was meant to correct.\n //\n // 56 is the wrong side. A sticky element with `bottom: X` puts its bottom edge X\n // above the viewport bottom, and the bar's TOP edge is at its own height: so\n // X = 56 lands the footer at y=760.0 against a bar starting at y=760.5, i.e. half\n // a CSS pixel of page showing through — about 1.4 device pixels on his phone,\n // which is exactly the hairline in the screenshot.\n //\n // The two errors are not symmetric, and I had them backwards the first time.\n // Too SMALL tucks the sticky element under a bar that is opaque and painted above\n // it (`z-30` against `z-10`): invisible. Too LARGE opens the seam. So floor, and\n // consumers subtract a further pixel (see `invoice-line-totals.tsx`) for the case\n // where the height lands on a whole pixel and floor leaves no overlap at all.\n const publish = () => {\n const value = `${node ? Math.floor(node.getBoundingClientRect().height) : 0}px`;\n for (const el of targets) el.style.setProperty(\"--app-nav-h\", value);\n };\n publish();\n // The observer is the refinement, not the mechanism: `publish()` above is already\n // right for a static nav, and jsdom has no ResizeObserver unless a test stubs one.\n // ONE cleanup for every branch, though — an early `return` on the no-observer path\n // left the variable behind on unmount, which a stale `bottom:` offset on whatever\n // rendered next would have inherited.\n const ro = node && typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(publish) : null;\n if (ro && node) ro.observe(node);\n return () => {\n ro?.disconnect();\n for (const el of targets) el.style.removeProperty(\"--app-nav-h\");\n };\n }, [ref, shellRef, embedded, isMdUp]);\n}\n\n/**\n * The responsive application shell: a top bar, a collapsible desktop sidebar\n * (icon-only when collapsed, with tooltips), a mobile bottom nav bar, the main\n * content area, and an optional desktop footer. Router-aware via react-router\n * `NavLink`; domain-free — nav items, brand, footer and the top bar's actions\n * are all supplied by the app.\n */\nexport function AppShell({\n nav,\n topBar,\n children,\n footer,\n sidebarFooter,\n sidebarFooterItems,\n renderLink,\n collapseStorageKey: collapseStorageKeyProp,\n collapseLabel,\n expandLabel,\n subNav = \"flyout\",\n mobileSubNav = true,\n mobileSubNavLayout: mobileSubNavLayoutProp,\n mobileBarMax,\n moreLabels,\n toggleGroupLabel,\n embedded: embeddedProp,\n className,\n ...rest\n}: AppShellProps) {\n const embedded = useContext(AppShellNesting) || !!embeddedProp;\n const { size: textSize } = useTextSize();\n const large = textSize !== \"normal\";\n const mobileSubNavLayout = mobileSubNavLayoutProp ?? (large ? \"scroll\" : \"wrap\");\n // The bottom bar's cells: every entry the phone does not reach another way, up to the\n // text size's limit; the rest go under More (§10.7).\n const barMax =\n typeof mobileBarMax === \"number\"\n ? mobileBarMax\n : (mobileBarMax?.[textSize] ?? DEFAULT_MOBILE_BAR_MAX[textSize]);\n const { shown: barNav, hidden: moreNav } = splitMobileBar(\n nav.filter((item) => !item.mobileHidden),\n barMax,\n );\n // An embedded shell sharing the default key would overwrite the outer app's sidebar\n // preference with its own — so it persists only when given a key of its own.\n const collapseStorageKey =\n collapseStorageKeyProp ?? (embedded ? null : \"appLayout.sidebarCollapsed\");\n // The three props are per-shell overrides of the provider's `appShell`; one left\n // undefined falls through to the provider rather than to English.\n const labels = useKitLabels(\"appShell\", DEFAULT_APP_SHELL_LABELS, {\n collapse: collapseLabel,\n expand: expandLabel,\n toggleGroup: toggleGroupLabel,\n });\n // Through the guarded helpers, not `window.localStorage` directly. The read runs\n // inside a `useState` initialiser — i.e. during render of the top-level shell — and\n // `localStorage` THROWS where site data is blocked (Safari private browsing, a\n // partitioned webview). Unguarded, that was not a lost sidebar preference: nothing\n // in the application mounted at all.\n const [collapsed, setCollapsed] = useState(\n () => collapseStorageKey !== null && readStored(collapseStorageKey) === \"1\",\n );\n // The inline sidebar is an ACCORDION: one group open at a time, keyed by its `to`.\n // Held here rather than per group so opening one can close the other — two lists\n // open at once pushed every entry below them off a laptop screen.\n const [openGroup, setOpenGroup] = useState<string | null>(null);\n // Publishes `--app-nav-h` for anything that has to sit on the bottom bar.\n const navRef = useRef<HTMLDivElement>(null);\n const rootRef = useRef<HTMLDivElement>(null);\n useNavHeightVar(navRef, rootRef, embedded);\n useEffect(() => {\n if (collapseStorageKey !== null) writeStored(collapseStorageKey, collapsed ? \"1\" : \"0\");\n }, [collapsed, collapseStorageKey]);\n // §4: at Large and Extra large the sidebar never collapses to icons only — a glyph\n // with its name in a tooltip is the very thing a reader who asked for bigger type\n // cannot use. The persisted preference is left alone, so Normal gets it back, and the\n // toggle goes while it could not do anything.\n const iconOnly = collapsed && !large;\n\n return (\n // The caller's attributes go on the shell's own root, never on the `data-tour=\"nav\"`\n // wrappers below — those are the kit's anchors for its own guided tour (#313/#322),\n // and a second element answering to the same selector is a tour that highlights the\n // wrong thing or nothing at all.\n <AppShellNesting.Provider value={true}>\n <div\n {...rest}\n ref={rootRef}\n data-app-shell=\"\"\n className={cn(\n \"relative flex flex-col\",\n embedded\n ? // `contain: layout` makes this box the containing block for the `fixed`\n // bottom bar below, so it pins to the shell rather than to the window.\n \"h-full min-h-0 overflow-hidden [contain:layout]\"\n : \"min-h-screen md:h-dvh md:overflow-hidden\",\n className,\n )}\n >\n {/* §4: \"the top bar keeps its essential icons\" — its IconButtons stay icons at\n Large (their labels the names and tooltips) and a locked one keeps its reason\n in the tooltip: a bar 3rem tall has no room for words under every glyph. */}\n <CompactControls>{topBar}</CompactControls>\n <div className=\"flex flex-1 min-h-0\">\n <aside\n className={cn(\n // `border-e`: the sidebar sits at the START, which is the right in RTL.\n \"hidden md:flex md:flex-col border-e border-[var(--border)] bg-[var(--bg-surface)] transition-[width] duration-200 ease-out overflow-hidden\",\n // Embedded, the row it sits in is already the shell's height; the sticky\n // offsets below are measured against the window's top bar.\n embedded ? \"md:self-stretch\" : \"md:sticky md:top-12 md:self-start md:h-[calc(100vh-3rem)]\",\n iconOnly ? \"md:w-14\" : \"md:w-60\",\n )}\n >\n {/* The `<nav>` is flex-1 so it fills the sidebar's height (with empty\n space below the items); the data-tour marker goes on the INNER,\n fit-content wrapper so the guided-tour spotlight hugs the actual nav\n items instead of the whole tall column (feedback #322). The marker\n also tags the mobile bottom bar below; the tour targets\n `[data-tour=\"nav\"]` and resolves to whichever is visible (#313). */}\n <nav className=\"flex-1 px-2 py-3 overflow-y-auto overflow-x-hidden\">\n <div data-tour=\"nav\" className=\"space-y-1\">\n {nav.map((item) => (\n <SidebarNavItem\n key={item.to}\n item={item}\n collapsed={iconOnly}\n inline={subNav === \"inline\"}\n toggleGroupLabel={labels.toggleGroup}\n openGroup={openGroup}\n setOpenGroup={setOpenGroup}\n />\n ))}\n </div>\n </nav>\n {!!sidebarFooterItems?.length && (\n <SidebarFooterItems items={sidebarFooterItems} collapsed={iconOnly} renderLink={renderLink} />\n )}\n {sidebarFooter?.(iconOnly)}\n {/* No toggle at Large: the sidebar cannot collapse there (see `iconOnly`). */}\n {!large && (\n <div className=\"border-t border-[var(--border)] p-2\">\n {collapsed ? (\n <Tooltip label={labels.expand} side=\"end\" portal className=\"block\">\n <button\n type=\"button\"\n onClick={() => setCollapsed(false)}\n aria-label={labels.expand}\n className=\"flex w-full items-center justify-center min-h-9 rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <PanelLeftOpen className=\"size-4 shrink-0 rtl:-scale-x-100\" />\n </button>\n </Tooltip>\n ) : (\n <button\n type=\"button\"\n onClick={() => setCollapsed(true)}\n aria-label={labels.collapse}\n className=\"flex w-full items-center gap-3 min-h-9 px-3 py-2 rounded-md text-sm text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <PanelLeftClose className=\"size-4 shrink-0 rtl:-scale-x-100\" />\n <span className=\"truncate\">{labels.collapse}</span>\n </button>\n )}\n </div>\n )}\n </aside>\n\n {/* Content column beside the sidebar. main grows to fill so the footer\n sits at the bottom — at the viewport edge when content is short,\n beside the sidebar rather than under it. */}\n <div className=\"flex flex-1 flex-col min-w-0 md:min-h-0\">\n {/* `scrollbar-gutter: stable` reserves the scrollbar's track whether or\n not it is currently needed. Without it this element — the app's only\n scroll container from md up — narrows its own client box by the\n scrollbar width the moment a page's content outgrows it, and widens\n it again when the next page fits. Every centred `mx-auto` container\n inside then jumps sideways by half a scrollbar, and any fluid-width\n content changes width outright. Keksdose feedback #403 caught it as\n \"slight width increase/decrease\" when switching to the notifications\n settings section and back: that section is the only one tall enough\n to scroll. Platform-dependent, which is why it is easy to miss —\n overlay scrollbars (macOS, most Linux builds) take no layout space,\n classic ones (Windows) take ~15px.\n\n `both-edges`, not the bare `stable`: a one-sided reservation keeps the\n width stable but moves the middle. On a page short enough not to\n scroll, nothing is painted into the reserved track, so every centred\n `mx-auto` container sits half a scrollbar left of the optical centre\n and every full-bleed child stops ~15px short on the right. Keksdose\n feedback #491 (/settings#data) and #498 (/budgets) both reported it as\n \"the left boundary looks smaller / cut off compared to the right\" on\n the one element per page whose frame is a saturated colour — the rose\n destructive-action cards. Reserving the track on both edges keeps the\n content centred whether or not the scrollbar is showing. */}\n {/* `relative` makes <main> the containing block for everything absolutely\n positioned inside it. Tailwind's `sr-only` is `position:absolute`; with no\n positioned ancestor it resolves against the INITIAL containing block, is\n laid out at its offset from the top of the DOCUMENT, and grows\n `<html>`'s scroll height past the viewport — a second scrollbar beside this\n one that scrolls into empty space. Guarding it per component (see\n sr-only-containment.test) could never be complete: any page's own\n `sr-only` span re-opened it. Here it is closed for every child at once. */}\n <main\n className={cn(\n \"relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+1.5rem)] md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]\",\n // Embedded there is no document scroll to lean on below `md` either.\n embedded && \"min-h-0 overflow-y-auto\",\n )}\n >\n {children}\n </main>\n {footer}\n </div>\n </div>\n\n {/* The phone navigation: the group bar, and above it — when the current page\n belongs to a group with pages of its own — the row of those pages. The pair\n is the phone's version of the sidebar's two levels; without the upper row a\n group's `items` were simply unreachable below `md`. ONE measured box, so\n `--app-nav-h` covers both rows and whatever docks on the bar sits on the\n whole of it. */}\n <div\n ref={navRef}\n className=\"md:hidden fixed bottom-0 inset-x-0 z-30 border-t border-[var(--border)] bg-[var(--bg-surface)]\"\n >\n {mobileSubNav && <MobileSubNav nav={nav} layout={mobileSubNavLayout} />}\n <nav\n data-tour=\"nav\"\n className=\"grid\"\n style={{ gridTemplateColumns: `repeat(${barNav.length + (moreNav.length ? 1 : 0)}, minmax(0, 1fr))` }}\n >\n {barNav.map((item) => (\n <MobileNavItem key={item.to} item={item} />\n ))}\n {moreNav.length > 0 && <MobileMoreCell hidden={moreNav} labels={moreLabels} />}\n </nav>\n </div>\n </div>\n </AppShellNesting.Provider>\n );\n}\n\nconst routerLink: KitLinkComponent = ({ href, ...p }) => <Link to={href} {...p} />;\nconst plainLink: KitLinkComponent = ({ children, ...p }) => <a {...p}>{children}</a>;\n\nconst FOOTER_ICON_LINK =\n \"flex min-h-8 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\";\n\n/** Calls the link renderer as a component, so a router link's hooks are its own. */\nfunction RenderedLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {\n return render(props);\n}\n\n/** The sidebar's bottom area, from {@link AppShellSidebarFooterItem}s. */\nfunction SidebarFooterItems({\n items,\n collapsed,\n renderLink,\n}: {\n items: AppShellSidebarFooterItem[];\n collapsed: boolean;\n renderLink?: KitLinkComponent;\n}) {\n const kitLink = useKitLink();\n const linkTo = (to: string, external: boolean | undefined, { children, ...props }: Omit<KitLinkProps, \"href\">) => {\n // The kit's one link rule (`pickLinkRenderer`): an external `to` or an in-page\n // `#anchor` is a plain `<a>` even without `external`; only `external` opens a tab.\n const InApp = pickLinkRenderer(renderLink, kitLink ?? routerLink, to) ?? plainLink;\n return external ? (\n <a href={to} target=\"_blank\" rel=\"noopener noreferrer\" {...props}>\n {children}\n </a>\n ) : (\n <RenderedLink render={InApp} href={to} {...props}>\n {children}\n </RenderedLink>\n );\n };\n\n return (\n <div data-slot=\"sidebar-footer-items\" className={cn(\"px-2 py-1\", collapsed ? \"space-y-1\" : \"space-y-1.5\")}>\n {items.map((item) => {\n if (collapsed) {\n const to = item.kind === \"links\" ? (item.to ?? item.links[0]?.to) : item.to;\n if (!to) return null;\n const external = item.kind === \"links\" ? item.links.find((l) => l.to === to)?.external : item.external;\n return (\n <Tooltip key={item.key} label={item.label} side=\"end\" portal className=\"block\">\n {linkTo(to, external, {\n \"aria-label\": item.label,\n className: FOOTER_ICON_LINK,\n children: <item.icon className=\"size-4\" aria-hidden />,\n })}\n </Tooltip>\n );\n }\n if (item.kind === \"links\") {\n return (\n <nav\n key={item.key}\n aria-label={item.label}\n className=\"flex flex-wrap justify-center gap-x-3 gap-y-0.5 text-caption text-[var(--text-muted)]\"\n >\n {item.links.map((l) => (\n <span key={l.to}>\n {linkTo(l.to, l.external, {\n className: \"rounded-sm hover:text-[var(--text-secondary)] hover:underline\",\n children: l.label,\n })}\n </span>\n ))}\n </nav>\n );\n }\n const link = linkTo(item.to, item.external, {\n className: cn(FOOTER_ICON_LINK, \"gap-1.5 text-xs font-medium\"),\n children: (\n <>\n <item.icon className=\"size-3.5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\">{item.text ?? item.label}</span>\n </>\n ),\n });\n return item.hint ? (\n <Tooltip key={item.key} label={item.hint} side=\"top\" portal className=\"block\">\n {link}\n </Tooltip>\n ) : (\n <div key={item.key}>{link}</div>\n );\n })}\n </div>\n );\n}\n\n/**\n * A bar cell's look, shared by the entries and the More cell. The active page needs a\n * clear marker, not just a subtle text shade (feedback #327): brand-accent icon + label,\n * bolder weight, and a top accent bar spanning the cell so it reads at a glance.\n */\nconst mobileCellClass = (active: boolean) =>\n cn(\n // `h-full`: the grid row stretches every cell to the tallest one's height, so a\n // label on two lines never leaves its neighbours a different shape.\n \"relative flex h-full w-full flex-col items-center justify-start py-2 text-caption gap-0.5 transition-colors\",\n \"before:absolute before:content-[''] before:inset-x-4 before:top-0 before:h-0.5 before:rounded-full before:transition-colors\",\n active\n ? \"font-semibold text-[var(--brand)] before:bg-[var(--brand)]\"\n : \"text-[var(--text-muted)] before:bg-transparent\",\n );\n\n/**\n * A bar cell's label (0.32, §10.7): up to TWO lines, never an ellipsis on one. At Large\n * a cell is a fifth of a phone holding type a quarter bigger, and one line truncated\n * \"Transactions\" to \"Trans…\" in every app (Kurvenschmiede). Two lines and then a clamp,\n * centred, a long word broken (and hyphenated where the page's `lang` allows) rather\n * than overflowing into the next cell. `shortLabel` still wins where the app has one.\n */\nfunction MobileCellLabel({ children }: { children: ReactNode }) {\n return (\n <span className=\"line-clamp-2 max-w-full break-words px-0.5 text-center leading-tight hyphens-auto\">\n {children}\n </span>\n );\n}\n\n/** One cell of the phone's group bar. Marked while the group's own page OR any of its\n * pages is current — NavLink alone only knows its own `to`, which left the bar with\n * nothing marked on every sub-page (the same defect the sidebar had). */\nfunction MobileNavItem({ item }: { item: AppShellNavItem }) {\n const subActive = useSubItemActive(item);\n return (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n className={({ isActive }) => mobileCellClass(isActive || subActive)}\n >\n <span className=\"relative\">\n <item.icon className=\"size-5\" />\n </span>\n <MobileCellLabel>{item.shortLabel ?? item.label}</MobileCellLabel>\n </NavLink>\n );\n}\n\n/** Whether the current route is `item`'s page or one of its pages. */\nfunction entryIsCurrent(item: AppShellNavItem, pathname: string): boolean {\n return (\n !!matchPath({ path: item.to, end: item.end ?? true }, pathname) ||\n !!item.items?.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname))\n );\n}\n\n/** A hidden entry's link, resolved as the router would write it (basename included). */\nfunction TourAnchor({ to, dataTour }: { to: string; dataTour?: string }) {\n const href = useHref(to);\n return (\n // eslint-disable-next-line jsx-a11y/anchor-has-content -- a tour anchor, aria-hidden and inert; the More button under it is the control (see MobileMoreCell)\n <a\n href={href}\n data-tour={dataTour}\n // Not a control: no name, no tab stop, no pointer — the More button under it\n // takes the tap. It exists only to be FOUND (see MobileMoreCell).\n aria-hidden\n tabIndex={-1}\n className=\"pointer-events-none absolute inset-0\"\n />\n );\n}\n\n/**\n * The bar's \"More\" cell (0.32, docs/text-size-harmonization.md §4, §10.7): the entries\n * past `mobileBarMax`, in a sheet — each entry as a link, and under a group its pages,\n * since a group the bar no longer shows has no sub-row to reach them by either. Marked\n * active while the current page is one of them, as the cell it replaced would be.\n *\n * **Tour anchors.** A guided tour finds a nav entry by `[data-tour=\"nav\"] a[href=\"/x\"]`\n * (keksdose's tours) or by the entry's own `dataTour`. An entry under More has no link\n * in the bar, and the sidebar's copy is `display:none` on a phone, so such a step would\n * spotlight nothing. So the More cell carries the hidden entries' links — one `<a>` per\n * entry and per page of a hidden group, with the entry's `href` and `dataTour` — laid\n * over the cell (`absolute inset-0`): both selectors resolve to a visible box, and that\n * box is the More cell, which is where the reader has to go next. They are\n * `aria-hidden`, out of the tab order and pointer-transparent, so neither a screen\n * reader nor a tap ever meets them; the button under them is the one control.\n */\nfunction MobileMoreCell({\n hidden,\n labels: labelsProp,\n}: {\n hidden: AppShellNavItem[];\n labels?: Partial<AppShellMoreLabels>;\n}) {\n const labels: AppShellMoreLabels = useKitLabels(\"appShellMore\", DEFAULT_APP_SHELL_MORE_LABELS, labelsProp);\n const { pathname } = useLocation();\n const [open, setOpen] = useState(false);\n const active = hidden.some((item) => entryIsCurrent(item, pathname));\n const close = () => setOpen(false);\n return (\n <div data-slot=\"app-shell-more\" className=\"relative min-w-0\">\n <button\n type=\"button\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n onClick={() => setOpen(true)}\n className={mobileCellClass(active)}\n >\n <span className=\"relative\">\n <MoreHorizontal aria-hidden className=\"size-5\" />\n </span>\n <MobileCellLabel>{labels.more}</MobileCellLabel>\n </button>\n {hidden.flatMap((item) => [\n <TourAnchor key={item.to} to={item.to} dataTour={item.dataTour} />,\n ...(item.items ?? []).map((sub) => <TourAnchor key={`${item.to} ${sub.to}`} to={sub.to} />),\n ])}\n <DialogFrame open={open} onClose={close} title={labels.moreTitle} closeButton bodyClassName=\"space-y-1\">\n <ul className=\"space-y-1\">\n {hidden.map((item) => (\n <li key={item.to}>\n <NavLink\n to={item.to}\n end={item.end ?? true}\n onClick={close}\n className={({ isActive }) =>\n cn(\n \"flex min-h-11 items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors\",\n isActive\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <item.icon aria-hidden className=\"size-5 shrink-0\" />\n <span className=\"min-w-0 break-words leading-snug\">{item.label}</span>\n </NavLink>\n {!!item.items?.length && (\n <ul aria-label={item.label} className=\"ms-[1.3rem] mt-0.5 space-y-0.5 border-s border-[var(--border)] ps-2\">\n {item.items.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n onClick={close}\n className={({ isActive }) =>\n cn(\n \"flex min-h-11 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors\",\n isActive\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon aria-hidden className=\"size-4 shrink-0 opacity-70\" />\n <span className=\"min-w-0 break-words leading-snug\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n )}\n </li>\n ))}\n </ul>\n </DialogFrame>\n </div>\n );\n}\n\n/**\n * The upper row of the phone navigation: the pages of the group the reader is in, as\n * a sideways-scrolling row of pills. Rendered only while such a group is current —\n * on a page outside every group the bar stays one row, rather than showing an empty\n * shelf or guessing a group.\n *\n * It slides in over 200ms (fade only under reduced motion) and wraps onto more rows\n * rather than scrolling, so every page of the group is in view at once.\n */\nfunction MobileSubNav({ nav, layout }: { nav: AppShellNavItem[]; layout: \"wrap\" | \"scroll\" }) {\n const { pathname } = useLocation();\n const scroll = layout === \"scroll\";\n const listRef = useRef<HTMLUListElement>(null);\n const fade = useStripFade(listRef);\n // The current page into view — the row only, never the page.\n useLayoutEffect(() => {\n const list = listRef.current;\n const current = list?.querySelector<HTMLElement>('[aria-current=\"page\"]');\n if (scroll && list && current) scrollIntoStrip(list, current);\n }, [scroll, pathname]);\n const group = nav.find(\n (item) =>\n !!item.items?.length &&\n (matchPath({ path: item.to, end: item.end ?? true }, pathname) ||\n item.items.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname))),\n );\n\n if (!group) return null;\n return (\n <nav\n aria-label={group.label}\n // Keyed by group so switching groups replays the entrance.\n key={group.to}\n className=\"animate-subnav border-b border-[var(--border)]\"\n >\n <ul\n ref={listRef}\n // `wrap` (default) takes as many rows as the group needs: a sideways row hides\n // pages past the edge, and every page of the group in view is the point of the\n // row. The bar grows with it — `--app-nav-h` measures it. `scroll` is the app's\n // opt-in trade: one row, the cut edge faded (see mobileSubNavLayout).\n className={cn(\n \"flex gap-1.5 px-3 py-2\",\n scroll ? \"overflow-x-auto overscroll-x-contain [scrollbar-width:none]\" : \"flex-wrap\",\n )}\n data-overflow={scroll ? fade.overflow : undefined}\n style={scroll && fade.mask ? { maskImage: fade.mask, WebkitMaskImage: fade.mask } : undefined}\n >\n {group.items!.map((sub) => (\n <li key={sub.to} className=\"shrink-0\">\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n className={({ isActive }) =>\n cn(\n \"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors\",\n isActive\n ? \"border-[var(--brand)] bg-[var(--brand)] font-medium text-[var(--brand-contrast)]\"\n : \"border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-3.5 shrink-0\" aria-hidden />\n {sub.label}\n </NavLink>\n </li>\n ))}\n </ul>\n </nav>\n );\n}\n\n/** `strong` is the current page; `trail` is the group that CONTAINS the current page\n * while the page itself is shown elsewhere (the inline list below it). */\nconst navLinkClass = (collapsed: boolean, forceActive: boolean, trail = false) =>\n ({ isActive }: { isActive: boolean }) =>\n `flex items-center gap-3 min-h-9 ${collapsed ? \"justify-center px-2\" : \"px-3\"} py-2 rounded-md text-sm transition-colors duration-150 ${\n isActive || (forceActive && !trail)\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : forceActive\n ? \"font-medium text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\"\n }`;\n\n/** Whether any of an entry's sub-items is the current route. NavLink only knows about\n * its OWN `to`, so without this a group whose pages live at sibling paths\n * (`/fields`, `/numbers`, …) lost its highlight the moment the reader left the one\n * page the group linked to — the sidebar then marked nothing at all, and the only\n * \"you are here\" left was inside a flyout that is closed unless hovered. */\nfunction useSubItemActive(item: AppShellNavItem): boolean {\n const { pathname } = useLocation();\n return !!item.items?.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname));\n}\n\n/** A single desktop sidebar entry. Plain link (with a tooltip when collapsed) —\n * unless it has sub-items, in which case hovering/focusing opens a flyout, or (with\n * `inline`) a disclosure lists them underneath. */\nfunction SidebarNavItem({\n item,\n collapsed,\n inline,\n toggleGroupLabel,\n openGroup,\n setOpenGroup,\n}: {\n item: AppShellNavItem;\n collapsed: boolean;\n inline: boolean;\n toggleGroupLabel: (groupLabel: string) => string;\n openGroup: string | null;\n setOpenGroup: (to: string | null) => void;\n}) {\n const hasSub = !!item.items?.length;\n const subActive = useSubItemActive(item);\n\n // In the EXPANDED sidebar a group is one component in both styles, so switching\n // between them is a transition of the same element (the page list folding away or\n // unfolding) rather than one tree replaced by another. The icon-only sidebar keeps\n // the plain flyout below: an indented list has no room there.\n if (hasSub && !collapsed) {\n return (\n <SidebarGroup\n item={item}\n subActive={subActive}\n inline={inline}\n toggleGroupLabel={toggleGroupLabel}\n open={openGroup === item.to}\n setOpenGroup={setOpenGroup}\n />\n );\n }\n\n const link = (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n className={navLinkClass(collapsed, subActive)}\n aria-haspopup={hasSub ? \"menu\" : undefined}\n >\n <span className=\"relative shrink-0\">\n <item.icon className=\"size-4\" />\n </span>\n {/* Wraps rather than truncates: in the sidebar a label cut to \"Checkbox, switch\n & sl…\" hides the one word that tells two pages apart, and there is height\n to spare. (The phone bar clamps at two lines instead, since 0.32 §10.7.) */}\n {!collapsed && <span className=\"min-w-0 flex-1 break-words leading-snug\">{item.label}</span>}\n {!collapsed && hasSub && <ChevronRight className=\"size-3.5 shrink-0 opacity-60 rtl:-scale-x-100\" />}\n </NavLink>\n );\n\n if (hasSub) return <SidebarFlyout item={item}>{link}</SidebarFlyout>;\n return collapsed ? (\n <Tooltip label={item.label} side=\"end\" portal className=\"block\">\n {link}\n </Tooltip>\n ) : (\n link\n );\n}\n\n/**\n * An entry with sub-items in the expanded sidebar.\n *\n * - `inline`: the entry's own link (its overview page) plus a chevron button that\n * shows or hides the sub-items indented beneath it. Two controls rather than one\n * because they do two different things — a row that both navigated AND toggled\n * would make \"open the overview\" and \"peek at the pages\" the same click.\n * - flyout: the link alone, with the sub-items in a panel beside the sidebar on\n * hover/focus. The inline list is still rendered, folded shut, so that switching\n * styles animates it rather than swapping it.\n *\n * THE FOLD is a `grid-template-rows` transition from `0fr` to `1fr` — the one way to\n * animate to a content-sized height without measuring it, so a translated label that\n * wraps to two lines unfolds exactly as far as it needs. `visibility` rides the same\n * transition so a shut list drops out of the accessibility tree only once it has\n * finished closing, and `inert` takes its links out of the tab order immediately.\n */\nfunction SidebarGroup({\n item,\n subActive,\n inline,\n toggleGroupLabel,\n open,\n setOpenGroup,\n}: {\n item: AppShellNavItem;\n subActive: boolean;\n inline: boolean;\n toggleGroupLabel: (groupLabel: string) => string;\n /** Whether this is the sidebar's one open group (see AppShell's `openGroup`). */\n open: boolean;\n setOpenGroup: (to: string | null) => void;\n}) {\n const listId = useId();\n const { pathname } = useLocation();\n const onOwnPage = !!matchPath({ path: item.to, end: item.end ?? true }, pathname);\n // Opening follows the reader: arriving on this group's overview or one of its pages\n // (by link, by the pager, by Back) opens it — and, the sidebar holding ONE open\n // group, closes whichever was open before. Both folds run at once, so the eye sees\n // one list hand over to the other. Run in an effect after mount, so the current\n // group UNFOLDS into view on load rather than appearing already open.\n const here = subActive || onOwnPage;\n useEffect(() => {\n if (here) setOpenGroup(item.to);\n }, [here, item.to, setOpenGroup]);\n const expanded = inline && open;\n\n const link = (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n aria-haspopup={inline ? undefined : \"menu\"}\n // `trail` (inline only): while a page of the group is current, the group row is\n // the breadcrumb, not the destination — the page's own row below carries the\n // strong marker, and two inverse rows would say \"you are here\" twice. In the\n // flyout style the pages are out of sight, so the group row IS the marker.\n className={(state) => cn(navLinkClass(false, subActive, inline)(state), \"min-w-0 flex-1\")}\n >\n <span className=\"relative shrink-0\">\n <item.icon className=\"size-4\" />\n </span>\n <span className=\"min-w-0 flex-1 break-words leading-snug\">{item.label}</span>\n {!inline && <ChevronRight className=\"size-3.5 shrink-0 opacity-60 rtl:-scale-x-100\" />}\n </NavLink>\n );\n\n return (\n <div>\n {inline ? (\n <div className=\"flex items-center gap-0.5\">\n {link}\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={listId}\n aria-label={toggleGroupLabel(item.label)}\n onClick={() => setOpenGroup(open ? null : item.to)}\n className=\"flex size-9 shrink-0 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <ChevronRight\n aria-hidden\n className={cn(\n \"size-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none rtl:-scale-x-100\",\n // Under dir=\"rtl\" the chevron is MIRRORED first (scale applies before rotate), so\n // it starts pointing left and a +90° turn would point it UP — hence −90° there.\n open && \"rotate-90 rtl:-rotate-90\",\n )}\n />\n </button>\n </div>\n ) : (\n <SidebarFlyout item={item}>{link}</SidebarFlyout>\n )}\n <div\n id={listId}\n inert={!expanded}\n className=\"grid transition-[grid-template-rows,visibility] duration-200 ease-out motion-reduce:transition-none\"\n style={{\n gridTemplateRows: expanded ? \"1fr\" : \"0fr\",\n visibility: expanded ? \"visible\" : \"hidden\",\n }}\n >\n <div\n className={cn(\n \"min-h-0 overflow-hidden transition-opacity duration-200 ease-out motion-reduce:transition-none\",\n expanded ? \"opacity-100\" : \"opacity-0\",\n )}\n >\n {/* The rule and indent live on the list INSIDE the clipping box: margin or\n padding on the element whose row is collapsing to 0fr would hold it open\n by that much. */}\n <ul className=\"ms-[1.3rem] mt-0.5 space-y-0.5 border-s border-[var(--border)] ps-2\">\n {item.items!.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n className={({ isActive }) =>\n cn(\n \"flex min-h-8 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors\",\n isActive\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-4 shrink-0 opacity-70\" aria-hidden />\n <span className=\"min-w-0 break-words leading-snug\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n </div>\n </div>\n </div>\n );\n}\n\n// Only ONE sidebar flyout should be on screen at a time. Each open flyout parks its\n// \"close now\" here so the next item to open can dismiss it immediately, instead of\n// leaving the previous panel up for the 140ms travel delay while the cursor moves\n// down the nav list — which showed two panels at once.\nlet activeFlyoutClose: (() => void) | null = null;\n\n/**\n * Wraps a nav item that has sub-items: on hover or keyboard focus it opens a\n * portalled flyout to the right of the sidebar (portalled so the sidebar's\n * `overflow-hidden` can't clip it) with the item's label as a header and each\n * sub-item as an icon + title link. A short close delay lets the cursor travel\n * from the item onto the panel; Escape / outside-move close it.\n */\nfunction SidebarFlyout({ item, children }: { item: AppShellNavItem; children: ReactNode }) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const [open, setOpen] = useState(false);\n // The flyout is portalled out of the subtree whose `dir` it inherited; read on open.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n\n // Anchor the flyout to the item's END edge — its right, or its left in RTL, where\n // the sidebar sits on the right of the window. The hook re-measures on\n // scroll/resize (the nav list can scroll).\n const rect = useAnchoredRect(wrapperRef, open);\n const pos = rect\n ? dir === \"rtl\"\n ? { top: rect.top, right: window.innerWidth - rect.left }\n : { top: rect.top, left: rect.right }\n : null;\n\n const cancelClose = useCallback(() => {\n if (closeTimer.current) {\n clearTimeout(closeTimer.current);\n closeTimer.current = null;\n }\n }, []);\n // Stable identity: the coordinator above holds this so a sibling item can close\n // THIS flyout without waiting out its travel delay.\n const closeNow = useCallback(() => {\n cancelClose();\n setOpen(false);\n }, [cancelClose]);\n const openMenu = useCallback(() => {\n cancelClose();\n if (activeFlyoutClose && activeFlyoutClose !== closeNow) activeFlyoutClose();\n activeFlyoutClose = closeNow;\n setDir(dirOf(wrapperRef.current));\n setOpen(true);\n }, [cancelClose, closeNow]);\n const scheduleClose = useCallback(() => {\n cancelClose();\n closeTimer.current = setTimeout(() => {\n setOpen(false);\n if (activeFlyoutClose === closeNow) activeFlyoutClose = null;\n }, 140);\n }, [cancelClose, closeNow]);\n\n // Close on Escape while open.\n useEscapeKey(() => setOpen(false), open);\n\n useEffect(\n () => () => {\n cancelClose();\n if (activeFlyoutClose === closeNow) activeFlyoutClose = null;\n },\n [cancelClose, closeNow],\n );\n\n return (\n // Not a control: hover and focus-within on the nav link inside. Focus (the\n // keyboard path) opens it exactly as hover does, Escape closes it.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- hover/focus-within wrapper around a real link\n <div\n ref={wrapperRef}\n className=\"relative\"\n onMouseEnter={openMenu}\n onMouseLeave={scheduleClose}\n onFocus={openMenu}\n onBlur={scheduleClose}\n >\n {children}\n {open &&\n pos &&\n createPortal(\n // Keeps the flyout open while the pointer crosses into it; keyboard users\n // reach it by focus, which the wrapper above already handles.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- pointer-only hover bridge\n <div\n dir={dir}\n style={{ position: \"fixed\", ...pos }}\n className=\"z-40 ps-1\"\n onMouseEnter={cancelClose}\n onMouseLeave={scheduleClose}\n >\n <div\n role=\"menu\"\n aria-label={item.label}\n className=\"animate-flyout min-w-52 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\"\n >\n <div className=\"border-b border-[var(--border)] px-3 py-2 text-caption font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {item.label}\n </div>\n <ul className=\"py-1\">\n {item.items!.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n className={({ isActive }) =>\n cn(\n \"flex items-center gap-2.5 px-3 py-2 text-sm\",\n isActive\n ? \"bg-[var(--bg-active)] font-medium text-[var(--text-primary)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-4 shrink-0 text-[var(--text-placeholder)]\" />\n <span className=\"min-w-0 break-words leading-snug\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n </div>\n </div>,\n document.body,\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
|
-
import { e1 as KitLinkProps, e0 as KitLinkComponent } from '../delete-account-setting-
|
|
3
|
+
import { e1 as KitLinkProps, e0 as KitLinkComponent } from '../delete-account-setting-Dy4kJgUm.js';
|
|
4
4
|
import '../components/tooltip.js';
|
|
5
5
|
import '../hooks/use-anchored-rect.js';
|
|
6
6
|
import '../lib/clipping.js';
|
package/dist/shell.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { LanguageMenu, LanguageOption, PaletteMenu, TOPBAR_MENU_ITEM_CLASS, TOPBAR_TRIGGER_CLASS, ThemeToggle } from './shell/topbar-controls.js';
|
|
2
2
|
export { TopBar, TopBarProps } from './shell/top-bar.js';
|
|
3
|
-
export { bA as AppShell, bC as AppShellMoreLabels, bD as AppShellNavItem, bE as AppShellProps, bF as AppShellSidebarFooterItem, bG as AppShellSubItem, co as DEFAULT_APP_SHELL_MORE_LABELS, cG as DEFAULT_MOBILE_BAR_MAX, fZ as splitMobileBar } from './delete-account-setting-
|
|
3
|
+
export { bA as AppShell, bC as AppShellMoreLabels, bD as AppShellNavItem, bE as AppShellProps, bF as AppShellSidebarFooterItem, bG as AppShellSubItem, co as DEFAULT_APP_SHELL_MORE_LABELS, cG as DEFAULT_MOBILE_BAR_MAX, fZ as splitMobileBar } from './delete-account-setting-Dy4kJgUm.js';
|
|
4
4
|
export { OptionSwitcherMenu, OptionSwitcherOption } from './shell/option-switcher-menu.js';
|
|
5
5
|
export { RoleSwitcher, RoleSwitcherProps } from './shell/role-switcher.js';
|
|
6
6
|
export { TopBarActionMenu, TopBarMenuEntry, TopBarMenuHeader } from './shell/topbar-action-menu.js';
|
package/dist/theme/contrast.d.ts
CHANGED
|
@@ -54,9 +54,10 @@ declare function readPersistedContrast(storageKey: string): ContrastMode | null;
|
|
|
54
54
|
/**
|
|
55
55
|
* Pre-paint (§10.3, §10.5): this device's choice under `storageKey` — else the account's
|
|
56
56
|
* last known value, else "system" — resolved against `prefers-contrast` and applied
|
|
57
|
-
* BEFORE `createRoot`. Call it next to `applyPersistedPalette`, in
|
|
57
|
+
* BEFORE `createRoot`. Call it next to `applyPersistedPalette`, in EITHER order — the
|
|
58
58
|
* palette steps itself when `data-contrast` is already set, and this re-applies the
|
|
59
|
-
* palette's set when it is not
|
|
59
|
+
* palette's set when it is not; no app has to put one first (0.32.1, keksdose's 0.32
|
|
60
|
+
* report asked which). Returns the level applied.
|
|
60
61
|
*/
|
|
61
62
|
declare function applyPersistedContrast(storageKey: string, options?: PersistedAppearanceOptions): ContrastLevel;
|
|
62
63
|
interface ContrastState {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/theme/contrast.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { readStored } from \"../lib/safe-storage\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { CONTRAST_STEPPED_VARS, contrastStep, rememberedTokenSet } from \"./contrast-tokens\";\nimport { applyTokenSet, DEFAULT_PRESET } from \"./palette-presets\";\nimport type { PersistedAppearanceOptions } from \"./text-size\";\n\n/**\n * \"More contrast\" (docs/text-size-harmonization.md §2.4, §5, §10.5, §10.6): a switch\n * beside the text size, on by choice or — on \"System\" — when the device asks for more\n * contrast (`prefers-contrast: more`). Any text size.\n *\n * In force it is `<html data-contrast=\"more\">` (else `\"standard\"`), which does two things:\n * - the inline colour tokens are re-written one step stronger (`applyTokenSet`'s\n * contrast flag — see contrast-tokens.ts for what moves and why it is JS);\n * - tokens.css raises `--focus-ring-width` from 2 to 3 px and turns on the `contrast:`\n * variant for anything else a component wants to change.\n *\n * The names below are frozen like the text size's: server-kit's `CONTRAST_MODES`, the\n * stored format, the attribute.\n */\n\n/** The stored choices (§10.6): server-kit's `CONTRAST_MODES`. \"system\" IS a stored value\n * — the PATCH rule refuses an explicit null, so null means only \"never chosen\". */\nexport const CONTRAST_MODES = [\"system\", \"standard\", \"more\"] as const;\n\nexport type ContrastMode = (typeof CONTRAST_MODES)[number];\n\n/** What is in force on `<html data-contrast>`: a mode with \"system\" resolved. */\nexport type ContrastLevel = \"standard\" | \"more\";\n\nconst CONTRAST_ATTRIBUTE = \"data-contrast\";\nconst PREFERS_MORE_QUERY = \"(prefers-contrast: more)\";\n\nexport function isContrastMode(value: unknown): value is ContrastMode {\n return value === \"system\" || value === \"standard\" || value === \"more\";\n}\n\n/** The mode in force: **this device's own choice → the account's → \"system\"** (§2.6).\n * The one order the pre-paint call, the apply hook and `useAccountAppearance` share. */\nexport function resolveContrastMode(device: unknown, account?: unknown): ContrastMode {\n if (isContrastMode(device)) return device;\n if (isContrastMode(account)) return account;\n return \"system\";\n}\n\n/** Whether the device asks for more contrast now. False without `matchMedia`. */\nexport function prefersMoreContrast(): boolean {\n try {\n return typeof window !== \"undefined\" && typeof window.matchMedia === \"function\"\n ? window.matchMedia(PREFERS_MORE_QUERY).matches\n : false;\n } catch {\n return false;\n }\n}\n\n/** A mode to the level it puts on `<html>`; \"system\" asks the device (or takes\n * `prefersMore`, when the caller already knows). */\nexport function contrastLevel(mode: ContrastMode, prefersMore: boolean = prefersMoreContrast()): ContrastLevel {\n return mode === \"more\" || (mode === \"system\" && prefersMore) ? \"more\" : \"standard\";\n}\n\n/** The level `<html>` carries now; \"standard\" without one. */\nexport function readContrastLevel(): ContrastLevel {\n if (typeof document === \"undefined\") return \"standard\";\n return document.documentElement.getAttribute(CONTRAST_ATTRIBUTE) === \"more\" ? \"more\" : \"standard\";\n}\n\n/**\n * Re-write the contrast-dependent tokens on `el` for its current `data-contrast`.\n *\n * With a palette layer (`applyTokenSet` has written a set here), that set is applied\n * again and steps itself. Without one — an app whose base tokens come from tokens.css,\n * like Kurvenschmiede's — the four stepped tokens are written over the stylesheet for\n * the default preset in the current light/dark, or removed again.\n */\nfunction reapplyContrastTokens(el: HTMLElement): void {\n const written = rememberedTokenSet(el);\n if (written) {\n applyTokenSet(el, written);\n return;\n }\n for (const name of CONTRAST_STEPPED_VARS) el.style.removeProperty(name);\n if (el.getAttribute(CONTRAST_ATTRIBUTE) !== \"more\") return;\n const step = contrastStep(DEFAULT_PRESET[el.classList.contains(\"dark\") ? \"dark\" : \"light\"]);\n el.style.setProperty(\"--text-secondary\", step.textSecondary);\n el.style.setProperty(\"--text-muted\", step.textMuted);\n el.style.setProperty(\"--text-placeholder\", step.textPlaceholder);\n el.style.setProperty(\"--border\", step.border);\n}\n\n/** Put `level` on `<html>` and re-write the colour tokens for it. */\nexport function applyContrast(level: ContrastLevel): void {\n if (typeof document === \"undefined\") return;\n const root = document.documentElement;\n root.setAttribute(CONTRAST_ATTRIBUTE, level);\n reapplyContrastTokens(root);\n}\n\n/* ── Storage: the frozen format, the pre-paint call ─────────────────────── */\n\n/**\n * What a contrast store persists under its key — FROZEN, like the text size's:\n *\n * {\"state\":{\"contrast\":\"more\"},\"version\":1}\n *\n * `contrast` is one of {@link CONTRAST_MODES}, or `null` / absent when this device never\n * chose.\n */\nexport interface PersistedContrast {\n state?: { contrast?: ContrastMode | null };\n version?: number;\n}\n\n/** This device's own choice under `storageKey`, or `null` when it never chose. */\nexport function readPersistedContrast(storageKey: string): ContrastMode | null {\n try {\n const blob = JSON.parse(readStored(storageKey) ?? \"null\") as PersistedContrast | null;\n const mode = blob?.state?.contrast;\n return isContrastMode(mode) ? mode : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Pre-paint (§10.3, §10.5): this device's choice under `storageKey` — else the account's\n * last known value, else \"system\" — resolved against `prefers-contrast` and applied\n * BEFORE `createRoot`. Call it next to `applyPersistedPalette`, in either order: the\n * palette steps itself when `data-contrast` is already set, and this re-applies the\n * palette's set when it is not. Returns the level applied.\n */\nexport function applyPersistedContrast(storageKey: string, options: PersistedAppearanceOptions = {}): ContrastLevel {\n let account: unknown;\n try {\n account = options.account?.();\n } catch {\n account = undefined;\n }\n const level = contrastLevel(resolveContrastMode(readPersistedContrast(storageKey), account));\n applyContrast(level);\n return level;\n}\n\n/* ── The store ──────────────────────────────────────────────────────────── */\n\nexport interface ContrastState {\n /** This device's own choice (persisted), or `null` when it never chose — then the\n * account's value applies, else \"system\". */\n contrast: ContrastMode | null;\n /** Store this device's choice; `null` forgets it. */\n setContrast: (contrast: ContrastMode | null) => void;\n}\n\nexport interface ApplyContrastOptions {\n /** The account's value (`/auth/me`'s `contrast`): it applies while this device has no\n * choice of its own. */\n account?: string | null;\n}\n\n/**\n * Build a contrast store bound to a consumer-supplied localStorage key, shaped like\n * `createThemeStore` and {@link createTextSizeStore} (§6). It holds the DEVICE's choice;\n * the account's value comes in through `useApplyContrast({ account })`.\n *\n * `useApplyContrast` — mount it once near the root, beside `useApplyPalette`:\n * - puts the level on `<html>` and steps the tokens;\n * - on \"System\", follows `prefers-contrast: more` live (§10.5);\n * - without a palette layer, re-writes the stepped tokens when the light/dark class\n * changes, since they differ per theme.\n *\n * The persisted format is {@link PersistedContrast}, and frozen.\n */\nexport function createContrastStore(storageKey: string) {\n const useContrastStore = create<ContrastState>()(\n logger(\n persist(\n (set) => ({\n contrast: null,\n setContrast: (contrast) => set({ contrast: isContrastMode(contrast) ? contrast : null }),\n }),\n {\n name: storageKey,\n version: 1,\n partialize: (s) => ({ contrast: s.contrast }),\n merge: (persisted, current) => {\n const value = (persisted as { contrast?: unknown } | null)?.contrast;\n return { ...current, contrast: isContrastMode(value) ? value : null };\n },\n },\n ),\n \"contrast\",\n ),\n );\n\n function useApplyContrast({ account }: ApplyContrastOptions = {}): ContrastLevel {\n const device = useContrastStore((s) => s.contrast);\n // Subscribed whatever the mode (hooks cannot be skipped); only \"system\" reads it.\n const prefersMore = useMediaQuery(PREFERS_MORE_QUERY, false);\n const level = contrastLevel(resolveContrastMode(device, account), prefersMore);\n useEffect(() => {\n applyContrast(level);\n }, [level]);\n // Without a palette layer the stepped tokens are per theme: follow `.dark`.\n useEffect(() => {\n if (typeof MutationObserver === \"undefined\") return;\n const root = document.documentElement;\n const observer = new MutationObserver(() => {\n if (!rememberedTokenSet(root)) reapplyContrastTokens(root);\n });\n observer.observe(root, { attributes: true, attributeFilter: [\"class\"] });\n return () => observer.disconnect();\n }, []);\n return level;\n }\n\n return { useContrastStore, useApplyContrast };\n}\n\nexport type ContrastStore = ReturnType<typeof createContrastStore>;\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/theme/contrast.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { readStored } from \"../lib/safe-storage\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { CONTRAST_STEPPED_VARS, contrastStep, rememberedTokenSet } from \"./contrast-tokens\";\nimport { applyTokenSet, DEFAULT_PRESET } from \"./palette-presets\";\nimport type { PersistedAppearanceOptions } from \"./text-size\";\n\n/**\n * \"More contrast\" (docs/text-size-harmonization.md §2.4, §5, §10.5, §10.6): a switch\n * beside the text size, on by choice or — on \"System\" — when the device asks for more\n * contrast (`prefers-contrast: more`). Any text size.\n *\n * In force it is `<html data-contrast=\"more\">` (else `\"standard\"`), which does two things:\n * - the inline colour tokens are re-written one step stronger (`applyTokenSet`'s\n * contrast flag — see contrast-tokens.ts for what moves and why it is JS);\n * - tokens.css raises `--focus-ring-width` from 2 to 3 px and turns on the `contrast:`\n * variant for anything else a component wants to change.\n *\n * The names below are frozen like the text size's: server-kit's `CONTRAST_MODES`, the\n * stored format, the attribute.\n */\n\n/** The stored choices (§10.6): server-kit's `CONTRAST_MODES`. \"system\" IS a stored value\n * — the PATCH rule refuses an explicit null, so null means only \"never chosen\". */\nexport const CONTRAST_MODES = [\"system\", \"standard\", \"more\"] as const;\n\nexport type ContrastMode = (typeof CONTRAST_MODES)[number];\n\n/** What is in force on `<html data-contrast>`: a mode with \"system\" resolved. */\nexport type ContrastLevel = \"standard\" | \"more\";\n\nconst CONTRAST_ATTRIBUTE = \"data-contrast\";\nconst PREFERS_MORE_QUERY = \"(prefers-contrast: more)\";\n\nexport function isContrastMode(value: unknown): value is ContrastMode {\n return value === \"system\" || value === \"standard\" || value === \"more\";\n}\n\n/** The mode in force: **this device's own choice → the account's → \"system\"** (§2.6).\n * The one order the pre-paint call, the apply hook and `useAccountAppearance` share. */\nexport function resolveContrastMode(device: unknown, account?: unknown): ContrastMode {\n if (isContrastMode(device)) return device;\n if (isContrastMode(account)) return account;\n return \"system\";\n}\n\n/** Whether the device asks for more contrast now. False without `matchMedia`. */\nexport function prefersMoreContrast(): boolean {\n try {\n return typeof window !== \"undefined\" && typeof window.matchMedia === \"function\"\n ? window.matchMedia(PREFERS_MORE_QUERY).matches\n : false;\n } catch {\n return false;\n }\n}\n\n/** A mode to the level it puts on `<html>`; \"system\" asks the device (or takes\n * `prefersMore`, when the caller already knows). */\nexport function contrastLevel(mode: ContrastMode, prefersMore: boolean = prefersMoreContrast()): ContrastLevel {\n return mode === \"more\" || (mode === \"system\" && prefersMore) ? \"more\" : \"standard\";\n}\n\n/** The level `<html>` carries now; \"standard\" without one. */\nexport function readContrastLevel(): ContrastLevel {\n if (typeof document === \"undefined\") return \"standard\";\n return document.documentElement.getAttribute(CONTRAST_ATTRIBUTE) === \"more\" ? \"more\" : \"standard\";\n}\n\n/**\n * Re-write the contrast-dependent tokens on `el` for its current `data-contrast`.\n *\n * With a palette layer (`applyTokenSet` has written a set here), that set is applied\n * again and steps itself. Without one — an app whose base tokens come from tokens.css,\n * like Kurvenschmiede's — the four stepped tokens are written over the stylesheet for\n * the default preset in the current light/dark, or removed again.\n */\nfunction reapplyContrastTokens(el: HTMLElement): void {\n const written = rememberedTokenSet(el);\n if (written) {\n applyTokenSet(el, written);\n return;\n }\n for (const name of CONTRAST_STEPPED_VARS) el.style.removeProperty(name);\n if (el.getAttribute(CONTRAST_ATTRIBUTE) !== \"more\") return;\n const step = contrastStep(DEFAULT_PRESET[el.classList.contains(\"dark\") ? \"dark\" : \"light\"]);\n el.style.setProperty(\"--text-secondary\", step.textSecondary);\n el.style.setProperty(\"--text-muted\", step.textMuted);\n el.style.setProperty(\"--text-placeholder\", step.textPlaceholder);\n el.style.setProperty(\"--border\", step.border);\n}\n\n/** Put `level` on `<html>` and re-write the colour tokens for it. */\nexport function applyContrast(level: ContrastLevel): void {\n if (typeof document === \"undefined\") return;\n const root = document.documentElement;\n root.setAttribute(CONTRAST_ATTRIBUTE, level);\n reapplyContrastTokens(root);\n}\n\n/* ── Storage: the frozen format, the pre-paint call ─────────────────────── */\n\n/**\n * What a contrast store persists under its key — FROZEN, like the text size's:\n *\n * {\"state\":{\"contrast\":\"more\"},\"version\":1}\n *\n * `contrast` is one of {@link CONTRAST_MODES}, or `null` / absent when this device never\n * chose.\n */\nexport interface PersistedContrast {\n state?: { contrast?: ContrastMode | null };\n version?: number;\n}\n\n/** This device's own choice under `storageKey`, or `null` when it never chose. */\nexport function readPersistedContrast(storageKey: string): ContrastMode | null {\n try {\n const blob = JSON.parse(readStored(storageKey) ?? \"null\") as PersistedContrast | null;\n const mode = blob?.state?.contrast;\n return isContrastMode(mode) ? mode : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Pre-paint (§10.3, §10.5): this device's choice under `storageKey` — else the account's\n * last known value, else \"system\" — resolved against `prefers-contrast` and applied\n * BEFORE `createRoot`. Call it next to `applyPersistedPalette`, in EITHER order — the\n * palette steps itself when `data-contrast` is already set, and this re-applies the\n * palette's set when it is not; no app has to put one first (0.32.1, keksdose's 0.32\n * report asked which). Returns the level applied.\n */\nexport function applyPersistedContrast(storageKey: string, options: PersistedAppearanceOptions = {}): ContrastLevel {\n let account: unknown;\n try {\n account = options.account?.();\n } catch {\n account = undefined;\n }\n const level = contrastLevel(resolveContrastMode(readPersistedContrast(storageKey), account));\n applyContrast(level);\n return level;\n}\n\n/* ── The store ──────────────────────────────────────────────────────────── */\n\nexport interface ContrastState {\n /** This device's own choice (persisted), or `null` when it never chose — then the\n * account's value applies, else \"system\". */\n contrast: ContrastMode | null;\n /** Store this device's choice; `null` forgets it. */\n setContrast: (contrast: ContrastMode | null) => void;\n}\n\nexport interface ApplyContrastOptions {\n /** The account's value (`/auth/me`'s `contrast`): it applies while this device has no\n * choice of its own. */\n account?: string | null;\n}\n\n/**\n * Build a contrast store bound to a consumer-supplied localStorage key, shaped like\n * `createThemeStore` and {@link createTextSizeStore} (§6). It holds the DEVICE's choice;\n * the account's value comes in through `useApplyContrast({ account })`.\n *\n * `useApplyContrast` — mount it once near the root, beside `useApplyPalette`:\n * - puts the level on `<html>` and steps the tokens;\n * - on \"System\", follows `prefers-contrast: more` live (§10.5);\n * - without a palette layer, re-writes the stepped tokens when the light/dark class\n * changes, since they differ per theme.\n *\n * The persisted format is {@link PersistedContrast}, and frozen.\n */\nexport function createContrastStore(storageKey: string) {\n const useContrastStore = create<ContrastState>()(\n logger(\n persist(\n (set) => ({\n contrast: null,\n setContrast: (contrast) => set({ contrast: isContrastMode(contrast) ? contrast : null }),\n }),\n {\n name: storageKey,\n version: 1,\n partialize: (s) => ({ contrast: s.contrast }),\n merge: (persisted, current) => {\n const value = (persisted as { contrast?: unknown } | null)?.contrast;\n return { ...current, contrast: isContrastMode(value) ? value : null };\n },\n },\n ),\n \"contrast\",\n ),\n );\n\n function useApplyContrast({ account }: ApplyContrastOptions = {}): ContrastLevel {\n const device = useContrastStore((s) => s.contrast);\n // Subscribed whatever the mode (hooks cannot be skipped); only \"system\" reads it.\n const prefersMore = useMediaQuery(PREFERS_MORE_QUERY, false);\n const level = contrastLevel(resolveContrastMode(device, account), prefersMore);\n useEffect(() => {\n applyContrast(level);\n }, [level]);\n // Without a palette layer the stepped tokens are per theme: follow `.dark`.\n useEffect(() => {\n if (typeof MutationObserver === \"undefined\") return;\n const root = document.documentElement;\n const observer = new MutationObserver(() => {\n if (!rememberedTokenSet(root)) reapplyContrastTokens(root);\n });\n observer.observe(root, { attributes: true, attributeFilter: [\"class\"] });\n return () => observer.disconnect();\n }, []);\n return level;\n }\n\n return { useContrastStore, useApplyContrast };\n}\n\nexport type ContrastStore = ReturnType<typeof createContrastStore>;\n"],"mappings":"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","names":[]}
|
|
@@ -56,6 +56,11 @@ declare function useTextSize(): TextSizeInfo;
|
|
|
56
56
|
*
|
|
57
57
|
* `size` is one of {@link TEXT_SIZES}, or `null` / absent when this device never chose.
|
|
58
58
|
* A future change adds a key; it never renames `state.size` or changes its values.
|
|
59
|
+
*
|
|
60
|
+
* A hand-written seed (a test, a screenshot script) must carry `"version":1`: the store
|
|
61
|
+
* drops a stored state of another version as a migration it doesn't know, silently
|
|
62
|
+
* (keksdose's 0.32 report). The inline snippet reads `state.size` whatever the version,
|
|
63
|
+
* so the two would disagree. In the kit's showcase, `?text-size=` needs no seed.
|
|
59
64
|
*/
|
|
60
65
|
interface PersistedTextSize {
|
|
61
66
|
state?: {
|
|
@@ -103,9 +108,20 @@ declare function applyPersistedTextSize(storageKey: string, options?: PersistedA
|
|
|
103
108
|
* ```
|
|
104
109
|
*
|
|
105
110
|
* Paste the constant's value in place of `(function(k,a){…})`. It reads the frozen
|
|
106
|
-
* {@link PersistedTextSize} format, never throws, and sets only `data-text-size
|
|
107
|
-
*
|
|
108
|
-
*
|
|
111
|
+
* {@link PersistedTextSize} format, never throws, and sets only `data-text-size`.
|
|
112
|
+
*
|
|
113
|
+
* The splash sizes in rem, and needs the root rules inline beside it, since the app's
|
|
114
|
+
* stylesheet is a separate request and arrives after the splash paints (0.32.1,
|
|
115
|
+
* keksdose's 0.32 report: that is every app, not an edge case):
|
|
116
|
+
*
|
|
117
|
+
* ```html
|
|
118
|
+
* <style>html[data-text-size=large]{font-size:125%}html[data-text-size=xlarge]{font-size:150%}</style>
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* Under a hash-based Content Security Policy (`script-src 'sha256-…'`) this is a second
|
|
122
|
+
* inline script with its own hash: add it to the policy, and recompute it whenever the
|
|
123
|
+
* storage key or the account function in the call changes (keksdose added it to both
|
|
124
|
+
* its Caddyfiles, and its guard test hashes every inline script).
|
|
109
125
|
*/
|
|
110
126
|
declare const TEXT_SIZE_INLINE_SCRIPT: string;
|
|
111
127
|
interface TextSizeState {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/theme/text-size.ts"],"sourcesContent":["import { useEffect, useMemo, useSyncExternalStore } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { readStored } from \"../lib/safe-storage\";\n\n/**\n * The text size (docs/text-size-harmonization.md §2.1, §3.1, §10.3, §10.6).\n *\n * ONE scale on `<html>`: `data-text-size=\"normal|large|xlarge\"`, and `tokens.css` turns\n * it into `font-size: 100% / 125% / 150%` — percent, never px, so \"Normal\" keeps\n * whatever size the person set in the browser. Tailwind's spacing and type are rem, so\n * about 90 % of every screen grows with that one declaration; the breakpoints follow too\n * (tokens.css redefines `sm:` … `3xl:` per size, {@link useBreakpoint} answers the same\n * in JS).\n *\n * The names below are the mechanism's public vocabulary and are FROZEN: an app's\n * storage, its `/auth/me` column (server-kit's `TEXT_SIZES`) and the inline boot snippet\n * all spell them this way.\n */\n\n/** The three steps, in order (§2.1, §10.6): server-kit's `TEXT_SIZES`, value for value. */\nexport const TEXT_SIZES = [\"normal\", \"large\", \"xlarge\"] as const;\n\nexport type TextSize = (typeof TEXT_SIZES)[number];\n\n/** The root font size each step sets, as a factor of the browser's own (tokens.css). For\n * what CSS cannot reach: a canvas or SVG chart's px constants, a windowed list's row\n * estimate, a popover width in px (§3.2). */\nexport const TEXT_SCALE: Readonly<Record<TextSize, number>> = { normal: 1, large: 1.25, xlarge: 1.5 };\n\n/** The attribute on `<html>` that carries the size. */\nconst TEXT_SIZE_ATTRIBUTE = \"data-text-size\";\n\nexport function isTextSize(value: unknown): value is TextSize {\n return value === \"normal\" || value === \"large\" || value === \"xlarge\";\n}\n\n/**\n * The size in force: **this device's own choice → the account's → Normal** (§2.2, §2.6).\n * Each is taken only when it is one of {@link TEXT_SIZES}; anything else (a stale value,\n * `null` = never chosen) falls through to the next. The one order the pre-paint call, the\n * apply hook and `useAccountAppearance` all use, so the three can never disagree.\n */\nexport function resolveTextSize(device: unknown, account?: unknown): TextSize {\n if (isTextSize(device)) return device;\n if (isTextSize(account)) return account;\n return \"normal\";\n}\n\n/* ── <html data-text-size> and the subscribers that read it ─────────────── */\n\nconst listeners = new Set<() => void>();\nlet observer: MutationObserver | null = null;\n\nfunction notify(): void {\n for (const listener of listeners) listener();\n}\n\nfunction subscribe(listener: () => void): () => void {\n listeners.add(listener);\n // Also catches writers that are not this module — the inline boot snippet, an app\n // setting the attribute by hand, a test.\n if (!observer && typeof MutationObserver !== \"undefined\" && typeof document !== \"undefined\") {\n observer = new MutationObserver(notify);\n observer.observe(document.documentElement, { attributes: true, attributeFilter: [TEXT_SIZE_ATTRIBUTE] });\n }\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n observer?.disconnect();\n observer = null;\n }\n };\n}\n\n/** The size `<html>` carries now; Normal without one (or outside a browser). */\nexport function readTextSize(): TextSize {\n if (typeof document === \"undefined\") return \"normal\";\n const value = document.documentElement.getAttribute(TEXT_SIZE_ATTRIBUTE);\n return isTextSize(value) ? value : \"normal\";\n}\n\n/** Put `size` on `<html>`. Every {@link useTextSize} re-renders with it. */\nexport function applyTextSize(size: TextSize): void {\n if (typeof document === \"undefined\") return;\n const root = document.documentElement;\n if (root.getAttribute(TEXT_SIZE_ATTRIBUTE) === size) return;\n root.setAttribute(TEXT_SIZE_ATTRIBUTE, size);\n notify();\n}\n\nexport interface TextSizeInfo {\n size: TextSize;\n /** {@link TEXT_SCALE} for `size`: 1, 1.25 or 1.5. */\n scale: number;\n}\n\n/**\n * The size in force on this page, and its scale (§3.1) — read from `<html>`, so it needs\n * no store and works in every kit component: whoever applied the size (the app's store,\n * the pre-paint call, the inline snippet), this follows it.\n *\n * Only for what CSS cannot do. A class that should differ at Large is a `large:`\n * variant; a layout switch is a breakpoint, which already scales.\n */\nexport function useTextSize(): TextSizeInfo {\n const size = useSyncExternalStore(subscribe, readTextSize, () => \"normal\" as const);\n return useMemo(() => ({ size, scale: TEXT_SCALE[size] }), [size]);\n}\n\n/* ── Storage: the frozen format, the pre-paint call ─────────────────────── */\n\n/**\n * What a text-size store persists under its key — zustand's persist envelope, and FROZEN\n * (§10.3) because an app's inline `<head>` snippet reads it before any module loads:\n *\n * {\"state\":{\"size\":\"large\"},\"version\":1}\n *\n * `size` is one of {@link TEXT_SIZES}, or `null` / absent when this device never chose.\n * A future change adds a key; it never renames `state.size` or changes its values.\n */\nexport interface PersistedTextSize {\n state?: { size?: TextSize | null };\n version?: number;\n}\n\n/** This device's own choice under `storageKey`, or `null` when it never chose (or the\n * storage is blocked, malformed, or holds a value that is not a size). */\nexport function readPersistedTextSize(storageKey: string): TextSize | null {\n try {\n const blob = JSON.parse(readStored(storageKey) ?? \"null\") as PersistedTextSize | null;\n const size = blob?.state?.size;\n return isTextSize(size) ? size : null;\n } catch {\n return null;\n }\n}\n\nexport interface PersistedAppearanceOptions {\n /**\n * The account's last known value, for a device without a choice of its own (§10.3):\n * what the app persisted with its user (keksdose keeps `/auth/me` in localStorage), so\n * a cold boot paints the account's size instead of Normal and then jumping — offline\n * too. Called once, guarded: a throw counts as \"no account value\".\n */\n account?: () => string | null | undefined;\n}\n\nfunction readAccount(account: PersistedAppearanceOptions[\"account\"]): unknown {\n if (!account) return undefined;\n try {\n return account();\n } catch {\n return undefined;\n }\n}\n\n/**\n * Pre-paint (§3.1, §10.3): read this device's choice under `storageKey` — else the\n * account's last known value — and put it on `<html>` BEFORE `createRoot`, next to\n * `applyPersistedTheme`, so the first paint already has the right size. Returns the size\n * applied.\n *\n * ```ts\n * applyPersistedTextSize(\"keksdose-text-size\", {\n * account: () => readPersistedUser()?.text_size,\n * });\n * ```\n */\nexport function applyPersistedTextSize(storageKey: string, options: PersistedAppearanceOptions = {}): TextSize {\n const size = resolveTextSize(readPersistedTextSize(storageKey), readAccount(options.account));\n applyTextSize(size);\n return size;\n}\n\n/**\n * The same pre-paint as {@link applyPersistedTextSize}, as an inline `<head>` script for\n * a boot splash painted from `index.html` before any module runs (§10.3). A function\n * EXPRESSION: call it with the storage key and, optionally, a function returning the\n * account's last known value.\n *\n * ```html\n * <script>\n * (function(k,a){…})(\"keksdose-text-size\", function () {\n * return JSON.parse(localStorage.getItem(\"keksdose-auth\")).state.user.text_size;\n * });\n * </script>\n * ```\n *\n * Paste the constant's value in place of `(function(k,a){…})`. It reads the frozen\n * {@link PersistedTextSize} format, never throws, and sets only `data-text-size`; the\n * splash itself sizes in rem so the attribute reaches it (tokens.css, or the splash's own\n * `html[data-text-size=large]{font-size:125%}` if it paints before the stylesheet).\n */\nexport const TEXT_SIZE_INLINE_SCRIPT =\n \"(function(k,a){function ok(v){return v==='normal'||v==='large'||v==='xlarge'}\" +\n \"var v=null;try{var s=JSON.parse(localStorage.getItem(k)||'null');v=s&&s.state&&s.state.size}catch(e){}\" +\n \"if(!ok(v)){v=null;try{v=a?a():null}catch(e){}}\" +\n \"try{document.documentElement.setAttribute('data-text-size',ok(v)?v:'normal')}catch(e){}})\";\n\n/* ── The store ──────────────────────────────────────────────────────────── */\n\nexport interface TextSizeState {\n /** This device's own choice (persisted), or `null` when it never chose — then the\n * account's value applies (§2.2). */\n size: TextSize | null;\n /** Store this device's choice; `null` forgets it, so the account's applies again. */\n setSize: (size: TextSize | null) => void;\n}\n\nexport interface ApplyTextSizeOptions {\n /** The account's value (`/auth/me`'s `text_size`): it applies while this device has no\n * choice of its own. Leave it out where there is no account. */\n account?: string | null;\n}\n\n/**\n * Build a text-size store bound to a consumer-supplied localStorage key, shaped like\n * `createThemeStore` (§6). It holds the DEVICE's choice only; the account's value comes\n * in through `useApplyTextSize({ account })` and `useAccountAppearance`, and the size in\n * force is always {@link resolveTextSize} of the two.\n *\n * - `useTextSizeStore` — the zustand hook: `size` (device choice or null) and `setSize`.\n * - `useApplyTextSize({ account })` — the effect that puts the size in force on `<html>`.\n * Mount it once near the root, beside `useApplyTheme`.\n *\n * The persisted format is {@link PersistedTextSize}, and frozen.\n */\nexport function createTextSizeStore(storageKey: string) {\n const useTextSizeStore = create<TextSizeState>()(\n logger(\n persist(\n (set) => ({\n size: null,\n setSize: (size) => set({ size: isTextSize(size) ? size : null }),\n }),\n {\n name: storageKey,\n version: 1,\n partialize: (s) => ({ size: s.size }),\n // A hand-edited or future value must not reach <html> as an unknown size.\n merge: (persisted, current) => ({\n ...current,\n size: isTextSize((persisted as { size?: unknown } | null)?.size)\n ? ((persisted as { size: TextSize }).size)\n : null,\n }),\n },\n ),\n \"text-size\",\n ),\n );\n\n function useApplyTextSize({ account }: ApplyTextSizeOptions = {}): TextSize {\n const device = useTextSizeStore((s) => s.size);\n const size = resolveTextSize(device, account);\n useEffect(() => {\n applyTextSize(size);\n }, [size]);\n return size;\n }\n\n return { useTextSizeStore, useApplyTextSize };\n}\n\nexport type TextSizeStore = ReturnType<typeof createTextSizeStore>;\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/theme/text-size.ts"],"sourcesContent":["import { useEffect, useMemo, useSyncExternalStore } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { readStored } from \"../lib/safe-storage\";\n\n/**\n * The text size (docs/text-size-harmonization.md §2.1, §3.1, §10.3, §10.6).\n *\n * ONE scale on `<html>`: `data-text-size=\"normal|large|xlarge\"`, and `tokens.css` turns\n * it into `font-size: 100% / 125% / 150%` — percent, never px, so \"Normal\" keeps\n * whatever size the person set in the browser. Tailwind's spacing and type are rem, so\n * about 90 % of every screen grows with that one declaration; the breakpoints follow too\n * (tokens.css redefines `sm:` … `3xl:` per size, {@link useBreakpoint} answers the same\n * in JS).\n *\n * The names below are the mechanism's public vocabulary and are FROZEN: an app's\n * storage, its `/auth/me` column (server-kit's `TEXT_SIZES`) and the inline boot snippet\n * all spell them this way.\n */\n\n/** The three steps, in order (§2.1, §10.6): server-kit's `TEXT_SIZES`, value for value. */\nexport const TEXT_SIZES = [\"normal\", \"large\", \"xlarge\"] as const;\n\nexport type TextSize = (typeof TEXT_SIZES)[number];\n\n/** The root font size each step sets, as a factor of the browser's own (tokens.css). For\n * what CSS cannot reach: a canvas or SVG chart's px constants, a windowed list's row\n * estimate, a popover width in px (§3.2). */\nexport const TEXT_SCALE: Readonly<Record<TextSize, number>> = { normal: 1, large: 1.25, xlarge: 1.5 };\n\n/** The attribute on `<html>` that carries the size. */\nconst TEXT_SIZE_ATTRIBUTE = \"data-text-size\";\n\nexport function isTextSize(value: unknown): value is TextSize {\n return value === \"normal\" || value === \"large\" || value === \"xlarge\";\n}\n\n/**\n * The size in force: **this device's own choice → the account's → Normal** (§2.2, §2.6).\n * Each is taken only when it is one of {@link TEXT_SIZES}; anything else (a stale value,\n * `null` = never chosen) falls through to the next. The one order the pre-paint call, the\n * apply hook and `useAccountAppearance` all use, so the three can never disagree.\n */\nexport function resolveTextSize(device: unknown, account?: unknown): TextSize {\n if (isTextSize(device)) return device;\n if (isTextSize(account)) return account;\n return \"normal\";\n}\n\n/* ── <html data-text-size> and the subscribers that read it ─────────────── */\n\nconst listeners = new Set<() => void>();\nlet observer: MutationObserver | null = null;\n\nfunction notify(): void {\n for (const listener of listeners) listener();\n}\n\nfunction subscribe(listener: () => void): () => void {\n listeners.add(listener);\n // Also catches writers that are not this module — the inline boot snippet, an app\n // setting the attribute by hand, a test.\n if (!observer && typeof MutationObserver !== \"undefined\" && typeof document !== \"undefined\") {\n observer = new MutationObserver(notify);\n observer.observe(document.documentElement, { attributes: true, attributeFilter: [TEXT_SIZE_ATTRIBUTE] });\n }\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n observer?.disconnect();\n observer = null;\n }\n };\n}\n\n/** The size `<html>` carries now; Normal without one (or outside a browser). */\nexport function readTextSize(): TextSize {\n if (typeof document === \"undefined\") return \"normal\";\n const value = document.documentElement.getAttribute(TEXT_SIZE_ATTRIBUTE);\n return isTextSize(value) ? value : \"normal\";\n}\n\n/** Put `size` on `<html>`. Every {@link useTextSize} re-renders with it. */\nexport function applyTextSize(size: TextSize): void {\n if (typeof document === \"undefined\") return;\n const root = document.documentElement;\n if (root.getAttribute(TEXT_SIZE_ATTRIBUTE) === size) return;\n root.setAttribute(TEXT_SIZE_ATTRIBUTE, size);\n notify();\n}\n\nexport interface TextSizeInfo {\n size: TextSize;\n /** {@link TEXT_SCALE} for `size`: 1, 1.25 or 1.5. */\n scale: number;\n}\n\n/**\n * The size in force on this page, and its scale (§3.1) — read from `<html>`, so it needs\n * no store and works in every kit component: whoever applied the size (the app's store,\n * the pre-paint call, the inline snippet), this follows it.\n *\n * Only for what CSS cannot do. A class that should differ at Large is a `large:`\n * variant; a layout switch is a breakpoint, which already scales.\n */\nexport function useTextSize(): TextSizeInfo {\n const size = useSyncExternalStore(subscribe, readTextSize, () => \"normal\" as const);\n return useMemo(() => ({ size, scale: TEXT_SCALE[size] }), [size]);\n}\n\n/* ── Storage: the frozen format, the pre-paint call ─────────────────────── */\n\n/**\n * What a text-size store persists under its key — zustand's persist envelope, and FROZEN\n * (§10.3) because an app's inline `<head>` snippet reads it before any module loads:\n *\n * {\"state\":{\"size\":\"large\"},\"version\":1}\n *\n * `size` is one of {@link TEXT_SIZES}, or `null` / absent when this device never chose.\n * A future change adds a key; it never renames `state.size` or changes its values.\n *\n * A hand-written seed (a test, a screenshot script) must carry `\"version\":1`: the store\n * drops a stored state of another version as a migration it doesn't know, silently\n * (keksdose's 0.32 report). The inline snippet reads `state.size` whatever the version,\n * so the two would disagree. In the kit's showcase, `?text-size=` needs no seed.\n */\nexport interface PersistedTextSize {\n state?: { size?: TextSize | null };\n version?: number;\n}\n\n/** This device's own choice under `storageKey`, or `null` when it never chose (or the\n * storage is blocked, malformed, or holds a value that is not a size). */\nexport function readPersistedTextSize(storageKey: string): TextSize | null {\n try {\n const blob = JSON.parse(readStored(storageKey) ?? \"null\") as PersistedTextSize | null;\n const size = blob?.state?.size;\n return isTextSize(size) ? size : null;\n } catch {\n return null;\n }\n}\n\nexport interface PersistedAppearanceOptions {\n /**\n * The account's last known value, for a device without a choice of its own (§10.3):\n * what the app persisted with its user (keksdose keeps `/auth/me` in localStorage), so\n * a cold boot paints the account's size instead of Normal and then jumping — offline\n * too. Called once, guarded: a throw counts as \"no account value\".\n */\n account?: () => string | null | undefined;\n}\n\nfunction readAccount(account: PersistedAppearanceOptions[\"account\"]): unknown {\n if (!account) return undefined;\n try {\n return account();\n } catch {\n return undefined;\n }\n}\n\n/**\n * Pre-paint (§3.1, §10.3): read this device's choice under `storageKey` — else the\n * account's last known value — and put it on `<html>` BEFORE `createRoot`, next to\n * `applyPersistedTheme`, so the first paint already has the right size. Returns the size\n * applied.\n *\n * ```ts\n * applyPersistedTextSize(\"keksdose-text-size\", {\n * account: () => readPersistedUser()?.text_size,\n * });\n * ```\n */\nexport function applyPersistedTextSize(storageKey: string, options: PersistedAppearanceOptions = {}): TextSize {\n const size = resolveTextSize(readPersistedTextSize(storageKey), readAccount(options.account));\n applyTextSize(size);\n return size;\n}\n\n/**\n * The same pre-paint as {@link applyPersistedTextSize}, as an inline `<head>` script for\n * a boot splash painted from `index.html` before any module runs (§10.3). A function\n * EXPRESSION: call it with the storage key and, optionally, a function returning the\n * account's last known value.\n *\n * ```html\n * <script>\n * (function(k,a){…})(\"keksdose-text-size\", function () {\n * return JSON.parse(localStorage.getItem(\"keksdose-auth\")).state.user.text_size;\n * });\n * </script>\n * ```\n *\n * Paste the constant's value in place of `(function(k,a){…})`. It reads the frozen\n * {@link PersistedTextSize} format, never throws, and sets only `data-text-size`.\n *\n * The splash sizes in rem, and needs the root rules inline beside it, since the app's\n * stylesheet is a separate request and arrives after the splash paints (0.32.1,\n * keksdose's 0.32 report: that is every app, not an edge case):\n *\n * ```html\n * <style>html[data-text-size=large]{font-size:125%}html[data-text-size=xlarge]{font-size:150%}</style>\n * ```\n *\n * Under a hash-based Content Security Policy (`script-src 'sha256-…'`) this is a second\n * inline script with its own hash: add it to the policy, and recompute it whenever the\n * storage key or the account function in the call changes (keksdose added it to both\n * its Caddyfiles, and its guard test hashes every inline script).\n */\nexport const TEXT_SIZE_INLINE_SCRIPT =\n \"(function(k,a){function ok(v){return v==='normal'||v==='large'||v==='xlarge'}\" +\n \"var v=null;try{var s=JSON.parse(localStorage.getItem(k)||'null');v=s&&s.state&&s.state.size}catch(e){}\" +\n \"if(!ok(v)){v=null;try{v=a?a():null}catch(e){}}\" +\n \"try{document.documentElement.setAttribute('data-text-size',ok(v)?v:'normal')}catch(e){}})\";\n\n/* ── The store ──────────────────────────────────────────────────────────── */\n\nexport interface TextSizeState {\n /** This device's own choice (persisted), or `null` when it never chose — then the\n * account's value applies (§2.2). */\n size: TextSize | null;\n /** Store this device's choice; `null` forgets it, so the account's applies again. */\n setSize: (size: TextSize | null) => void;\n}\n\nexport interface ApplyTextSizeOptions {\n /** The account's value (`/auth/me`'s `text_size`): it applies while this device has no\n * choice of its own. Leave it out where there is no account. */\n account?: string | null;\n}\n\n/**\n * Build a text-size store bound to a consumer-supplied localStorage key, shaped like\n * `createThemeStore` (§6). It holds the DEVICE's choice only; the account's value comes\n * in through `useApplyTextSize({ account })` and `useAccountAppearance`, and the size in\n * force is always {@link resolveTextSize} of the two.\n *\n * - `useTextSizeStore` — the zustand hook: `size` (device choice or null) and `setSize`.\n * - `useApplyTextSize({ account })` — the effect that puts the size in force on `<html>`.\n * Mount it once near the root, beside `useApplyTheme`.\n *\n * The persisted format is {@link PersistedTextSize}, and frozen.\n */\nexport function createTextSizeStore(storageKey: string) {\n const useTextSizeStore = create<TextSizeState>()(\n logger(\n persist(\n (set) => ({\n size: null,\n setSize: (size) => set({ size: isTextSize(size) ? size : null }),\n }),\n {\n name: storageKey,\n version: 1,\n partialize: (s) => ({ size: s.size }),\n // A hand-edited or future value must not reach <html> as an unknown size.\n merge: (persisted, current) => ({\n ...current,\n size: isTextSize((persisted as { size?: unknown } | null)?.size)\n ? ((persisted as { size: TextSize }).size)\n : null,\n }),\n },\n ),\n \"text-size\",\n ),\n );\n\n function useApplyTextSize({ account }: ApplyTextSizeOptions = {}): TextSize {\n const device = useTextSizeStore((s) => s.size);\n const size = resolveTextSize(device, account);\n useEffect(() => {\n applyTextSize(size);\n }, [size]);\n return size;\n }\n\n return { useTextSizeStore, useApplyTextSize };\n}\n\nexport type TextSizeStore = ReturnType<typeof createTextSizeStore>;\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode, ReactElement } from 'react';
|
|
3
|
-
import { bO as ButtonVariant } from '../delete-account-setting-
|
|
3
|
+
import { bO as ButtonVariant } from '../delete-account-setting-Dy4kJgUm.js';
|
|
4
4
|
import { UseWizardReturn, WizardLabels } from './types.js';
|
|
5
5
|
import '../components/tooltip.js';
|
|
6
6
|
import '../hooks/use-anchored-rect.js';
|