fold-ng 0.1.0 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -8,6 +8,43 @@ All notable changes to **fold-ng** are documented here. The format follows
8
8
 
9
9
  _Nothing yet._
10
10
 
11
+ ## [0.2.1] - 2026-07-24
12
+
13
+ ### Fixed
14
+
15
+ - **`fold-app-shell`** now sets the drawer's background `inert` via an attribute
16
+ binding (`[attr.inert]`) rather than a property binding. No behaviour change —
17
+ AOT was always clean — but it silences an `NG0303` "unknown property" warning
18
+ raised by the JIT element-schema in dev/test runtimes.
19
+ - **Published tarball** no longer ships `tsconfig.lib.tsbuildinfo` — a ~100 kB
20
+ TypeScript incremental-build cache that `finalize-dist` now prunes. Smaller
21
+ install (14 → 13 files).
22
+
23
+ ## [0.2.0] - 2026-07-24
24
+
25
+ ### Added
26
+
27
+ - **`fold-app-shell` — the mobile drawer is now a real modal.** While open, the
28
+ off-canvas primary rail is a named `role="dialog"` + `aria-modal="true"` (new
29
+ `drawerLabel` input, default `"Menu"`), and every other region is `inert` so a
30
+ screen reader can't wander behind it. New `drawerId` — exposed via
31
+ `exportAs="foldAppShell"` — lets the app point its hamburger's `aria-controls`
32
+ at the drawer (see the trigger contract in the component docs).
33
+
34
+ ### Fixed
35
+
36
+ - **`fold-app-shell` skip-link** moves focus to `<main>` directly and prevents
37
+ the fragment navigation, so it works under hash routing (`withHashLocation`),
38
+ where a `#id` jump would otherwise be treated as a route change.
39
+
40
+ ### Changed
41
+
42
+ - **`FoldComponentPanelDescriptor.ariaLabel`** is now typed `string | undefined`
43
+ (was `string`) — a non-breaking widening; the descriptor always carries the key.
44
+ - **Internals hardened**, no API change: the library now compiles under
45
+ `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `strictStandalone`,
46
+ `typeCheckHostBindings`, and the full `strictTypeChecked` ESLint preset.
47
+
11
48
  ## [0.1.0] - 2026-07-24
12
49
 
13
50
  First public release line — **production-quality, pre-1.0 (`0.x`)**: fully
@@ -39,5 +76,7 @@ design-token stylesheet.
39
76
  `currentColor`; `prefers-reduced-motion` + `forced-colors` are respected;
40
77
  strings localise via inputs / providers (`provideFoldPanelLabels`).
41
78
 
42
- [unreleased]: https://github.com/hugoheynard/fold-ng/compare/v0.1.0...HEAD
79
+ [unreleased]: https://github.com/hugoheynard/fold-ng/compare/v0.2.1...HEAD
80
+ [0.2.1]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.2.1
81
+ [0.2.0]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.2.0
43
82
  [0.1.0]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.1.0
package/README.md CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/fold-ng.svg)](https://www.npmjs.com/package/fold-ng)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/fold-ng.svg)](https://www.npmjs.com/package/fold-ng)
5
- [![minzipped size](https://img.shields.io/bundlephobia/minzip/fold-ng)](https://bundlephobia.com/package/fold-ng)
5
+ [![install size](https://packagephobia.com/badge?p=fold-ng)](https://packagephobia.com/result?p=fold-ng)
6
6
  [![types included](https://img.shields.io/npm/types/fold-ng.svg)](https://www.npmjs.com/package/fold-ng)
7
7
  [![license](https://img.shields.io/npm/l/fold-ng.svg)](./LICENSE)
8
8
  [![status: pre-1.0](https://img.shields.io/badge/status-pre--1.0-2ea043)](./CHANGELOG.md)
9
9
  [![live demo](https://img.shields.io/badge/live_demo-gallery-6f42c1)](https://hugoheynard.github.io/fold-ng/)
10
+ [![Sponsor](https://img.shields.io/github/sponsors/hugoheynard?logo=githubsponsors&label=Sponsor&color=ea4aaa)](https://github.com/sponsors/hugoheynard)
10
11
 
11
12
  **fold-ng** is an accessible, **dark-first Angular 22 UI component library and
12
13
  design system** — signals-first, standalone, zoneless, and SSR-ready. It ships a
@@ -35,6 +36,12 @@ npm install fold-ng
35
36
  Angular 22 (`@angular/core`, `@angular/common`, `@angular/forms`,
36
37
  `@angular/platform-browser`) is a peer dependency.
37
38
 
39
+ > **Bundle size.** Components are standalone and the package is side-effect-free
40
+ > except its CSS (`sideEffects: ["**/*.css"]`), so you only ship what you import —
41
+ > not the whole library. (No Bundlephobia badge: it can't measure an Angular
42
+ > partial-Ivy package, which needs the Angular linker to become final JS — the
43
+ > install-size badge above is the honest figure.)
44
+
38
45
  ## Quickstart
39
46
 
40
47
  Import the tokens once, then use any standalone component directly:
@@ -63,6 +63,9 @@ class FocusTrapDirective {
63
63
  }
64
64
  const first = focusables[0];
65
65
  const last = focusables[focusables.length - 1];
66
+ if (!first || !last) {
67
+ return;
68
+ }
66
69
  const active = this.document.activeElement;
67
70
  if (event.shiftKey && active === first) {
68
71
  event.preventDefault();
@@ -304,7 +307,38 @@ const MOBILE_BREAKPOINT = 768;
304
307
  * ## Accessibility
305
308
  * The shell renders a **skip-link** as its first Tab stop — jumping keyboard
306
309
  * users past the rails straight to the `<main>` content (`skipLinkLabel` sets
307
- * its text). The `<main>` is focusable (`tabindex="-1"`) so the jump lands.
310
+ * its text). The `<main>` is focusable (`tabindex="-1"`) so the jump lands; the
311
+ * link moves focus directly rather than following the fragment, so it works
312
+ * under hash routing too.
313
+ *
314
+ * The mobile **drawer is a real modal**, matching the panel host's bar: while
315
+ * open it is a named `role="dialog"` + `aria-modal="true"` (`drawerLabel`), the
316
+ * focus-trap moves focus in and restores it to the trigger on close, and every
317
+ * other region (header, content, footer, second rail) is `inert` — so a
318
+ * screen-reader's virtual cursor can't wander behind the open drawer.
319
+ *
320
+ * **Trigger contract (app-owned).** The shell owns the drawer/dialog; the app
321
+ * owns the hamburger that opens it, so the shell can't set ARIA on that button —
322
+ * it exposes what's needed and documents the wiring. For the pairing to be fully
323
+ * announced, the hamburger should carry `[attr.aria-expanded]="navOpen()"`,
324
+ * `aria-haspopup="dialog"`, and `[attr.aria-controls]="shell.drawerId"` (read the
325
+ * shell via a template ref, `#shell="foldAppShell"`):
326
+ *
327
+ * ```html
328
+ * <fold-app-shell #shell="foldAppShell" [(mobileNavOpen)]="navOpen">
329
+ * <app-menu railPrimary />
330
+ * <header header>
331
+ * <button
332
+ * class="hamburger"
333
+ * aria-haspopup="dialog"
334
+ * [attr.aria-expanded]="navOpen()"
335
+ * [attr.aria-controls]="shell.drawerId"
336
+ * (click)="navOpen.set(!navOpen())"
337
+ * >☰</button>
338
+ * </header>
339
+ * <router-outlet />
340
+ * </fold-app-shell>
341
+ * ```
308
342
  *
309
343
  * @selector `fold-app-shell`
310
344
  *
@@ -359,8 +393,21 @@ class FoldAppShellComponent {
359
393
  * rails straight to `<main>`. Override for a non-English app. */
360
394
  skipLinkLabel = input("Skip to content", /* @ts-ignore */
361
395
  ...(ngDevMode ? [{ debugName: "skipLinkLabel" }] : /* istanbul ignore next */ []));
396
+ /** The mobile drawer's accessible name — it becomes a `role="dialog"` while
397
+ * open, so it must be named. `"Menu"` by default (not `"Navigation"`, which
398
+ * would echo the `<nav>` landmark inside). Override for a non-English app. */
399
+ drawerLabel = input("Menu", /* @ts-ignore */
400
+ ...(ngDevMode ? [{ debugName: "drawerLabel" }] : /* istanbul ignore next */ []));
401
+ document = inject(DOCUMENT);
362
402
  /** SSR-safe id linking the skip-link to the `<main>` it targets. */
363
403
  contentId = inject(FoldIdService).next("fold-shell-content");
404
+ /**
405
+ * SSR-safe id on the mobile drawer (the primary rail). Public and reachable
406
+ * via `exportAs="foldAppShell"` so the app's hamburger can point
407
+ * `aria-controls` at the dialog it opens — see the trigger contract in the
408
+ * class docs.
409
+ */
410
+ drawerId = inject(FoldIdService).next("fold-shell-drawer");
364
411
  /**
365
412
  * How the primary rail's navigation is reached on mobile:
366
413
  * - `"drawer"` (default) — the built-in off-canvas drawer slides the projected
@@ -422,6 +469,16 @@ class FoldAppShellComponent {
422
469
  closeMobileNav() {
423
470
  this.mobileNavOpen.set(false);
424
471
  }
472
+ /**
473
+ * Skip-link activation. Moves focus to `<main>` directly instead of letting
474
+ * the browser follow the `#id` fragment — a hash-routing app (`withHashLocation`)
475
+ * would treat that fragment as a route change. `preventDefault` keeps the URL
476
+ * clean; the anchor keeps its `href` for link semantics and no-JS fallback.
477
+ */
478
+ skipToContent(event) {
479
+ event.preventDefault();
480
+ this.document.getElementById(this.contentId)?.focus();
481
+ }
425
482
  /* Map each input to its CSS var — `null` when unset so the stylesheet
426
483
  variable (or its fallback default) keeps winning; a set input overrides it. */
427
484
  railWidthVar = computed(() => pxVar(this.railWidth()), /* @ts-ignore */
@@ -431,11 +488,11 @@ class FoldAppShellComponent {
431
488
  headerHeightMobileVar = computed(() => pxVar(this.headerHeightMobile()), /* @ts-ignore */
432
489
  ...(ngDevMode ? [{ debugName: "headerHeightMobileVar" }] : /* istanbul ignore next */ []));
433
490
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: FoldAppShellComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
434
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: FoldAppShellComponent, isStandalone: true, selector: "fold-app-shell", inputs: { railWidth: { classPropertyName: "railWidth", publicName: "railWidth", isSignal: true, isRequired: false, transformFunction: null }, headerHeight: { classPropertyName: "headerHeight", publicName: "headerHeight", isSignal: true, isRequired: false, transformFunction: null }, headerHeightMobile: { classPropertyName: "headerHeightMobile", publicName: "headerHeightMobile", isSignal: true, isRequired: false, transformFunction: null }, headerLayout: { classPropertyName: "headerLayout", publicName: "headerLayout", isSignal: true, isRequired: false, transformFunction: null }, footerLayout: { classPropertyName: "footerLayout", publicName: "footerLayout", isSignal: true, isRequired: false, transformFunction: null }, footerBehavior: { classPropertyName: "footerBehavior", publicName: "footerBehavior", isSignal: true, isRequired: false, transformFunction: null }, contentScroll: { classPropertyName: "contentScroll", publicName: "contentScroll", isSignal: true, isRequired: false, transformFunction: null }, skipLinkLabel: { classPropertyName: "skipLinkLabel", publicName: "skipLinkLabel", isSignal: true, isRequired: false, transformFunction: null }, mobileNav: { classPropertyName: "mobileNav", publicName: "mobileNav", isSignal: true, isRequired: false, transformFunction: null }, mobileNavOpen: { classPropertyName: "mobileNavOpen", publicName: "mobileNavOpen", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { mobileNavOpen: "mobileNavOpenChange" }, host: { listeners: { "document:keydown.escape": "onEscape()" }, properties: { "style.--fold-shell-rail-width": "railWidthVar()", "style.--fold-shell-header-height": "headerHeightVar()", "style.--fold-shell-header-height-mobile": "headerHeightMobileVar()", "class.header-full": "headerLayout() === \"full\"", "class.footer-full": "footerLayout() === \"full\"", "class.footer-scroll": "footerBehavior() === \"scroll\"", "class.mobile-drawer": "mobileNav() === \"drawer\"", "class.mobile-nav-open": "drawerOpen()", "class.content-auto": "contentScroll() === \"auto\"" } }, ngImport: i0, template: "<!-- Skip-link: the first Tab stop \u2014 visually hidden until focused, then jumps\n keyboard users past the rails to <main> (which is focusable, tabindex=-1). -->\n<a class=\"skip-link\" [href]=\"'#' + contentId\">{{ skipLinkLabel() }}</a>\n\n<div\n class=\"rail-primary\"\n foldSurface=\"chrome\"\n [foldFocusTrap]=\"drawerOpen()\"\n tabindex=\"-1\"\n>\n <ng-content select=\"[railPrimary]\" />\n</div>\n<div class=\"rail-secondary\" foldSurface=\"chrome\">\n <ng-content select=\"[railSecondary]\" />\n</div>\n\n<!-- Mobile drawer scrim \u2014 only rendered while the drawer is actually open (the\n shell is narrow AND mobileNavOpen). A darkening veil that dismisses on\n click; Escape and widening close it too. aria-hidden: the focus-trapped\n drawer + Escape are the accessible path, the scrim is a mouse convenience. -->\n@if (drawerOpen()) {\n <div class=\"mobile-scrim\" (click)=\"closeMobileNav()\" aria-hidden=\"true\"></div>\n}\n<header class=\"header\" foldSurface=\"chrome\">\n <ng-content select=\"[header]\" />\n</header>\n\n<!-- One footer, stamped in one of two positions \u2014 a grid row (pinned, always in\n sight) or inside the content scroll (revealed on the way down). A single\n <ng-content> in a template avoids duplicate-selector projection loss. -->\n<ng-template #footerTpl>\n <footer\n class=\"footer\"\n [class.footer-inflow]=\"footerBehavior() === 'scroll'\"\n foldSurface=\"chrome\"\n >\n <ng-content select=\"[footer]\" />\n </footer>\n</ng-template>\n\n<main class=\"content\" foldSurface=\"page\" [id]=\"contentId\" tabindex=\"-1\">\n <ng-content />\n @if (footerBehavior() === \"scroll\") {\n <ng-container [ngTemplateOutlet]=\"footerTpl\" />\n }\n</main>\n@if (footerBehavior() !== \"scroll\") {\n <ng-container [ngTemplateOutlet]=\"footerTpl\" />\n}\n", styles: ["@charset \"UTF-8\";:host{display:grid;height:100%;width:100%;position:relative;box-sizing:border-box;overflow:hidden;background:var(--fold-color-bg-page);grid-template-columns:auto auto minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height, 56px) 1fr;grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\"}.skip-link{position:absolute;top:0;left:0;z-index:60;margin:var(--fold-space-sm, 8px);padding:8px 14px;border:1px solid var(--fold-color-border);border-radius:var(--fold-radius-md);background:var(--fold-color-surface-card);color:var(--fold-color-text);font-size:var(--fold-text-sm);font-weight:600;text-decoration:none;transform:translateY(-150%);transition:transform .12s ease}.skip-link:focus{transform:translateY(0)}@media(prefers-reduced-motion:reduce){.skip-link{transition:none}}:host(.header-full){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\"}:host(:not(.footer-scroll):has([footer])){grid-template-rows:var(--fold-shell-header-height, 56px) 1fr auto;grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\" \"rail-primary rail-secondary footer\"}:host(.header-full:not(.footer-scroll):has([footer])){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\" \"rail-primary rail-secondary footer\"}:host(.footer-full:not(.footer-scroll):has([footer])){grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\" \"footer footer footer\"}:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\" \"footer footer footer\"}.rail-primary{grid-area:rail-primary;min-height:0}.rail-secondary{grid-area:rail-secondary;min-height:0}.header{grid-area:header;min-width:0}.footer{grid-area:footer;min-width:0;min-height:0}.content{grid-area:content;position:relative;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}.content:focus{outline:none}:host(.footer-scroll) .content,:host(.content-auto) .content{overflow:auto}.footer-inflow{margin-top:auto}.rail-primary:has([data-elevated]),.rail-secondary:has([data-elevated]),.header:has([data-elevated]),.footer:has([data-elevated]),.content:has([data-elevated]){padding:var(--fold-surface-inset, var(--fold-space-md))}@media(max-width:768px){:host,:host(.header-full){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr;grid-template-areas:\"header\" \"content\"}:host(:not(.footer-scroll):has([footer])),:host(.header-full:not(.footer-scroll):has([footer])),:host(.footer-full:not(.footer-scroll):has([footer])),:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr auto;grid-template-areas:\"header\" \"content\" \"footer\"}.rail-primary,.rail-secondary{display:none}:host(.mobile-drawer) .rail-primary{display:block;position:absolute;top:var(--fold-shell-header-height-mobile, 52px);bottom:0;left:0;z-index:41;width:auto;max-width:85%;overflow-y:auto;visibility:hidden;transform:translate(-100%);transition:transform .24s cubic-bezier(.4,0,.2,1),visibility .24s}:host(.mobile-drawer.mobile-nav-open) .rail-primary{visibility:visible;transform:translate(0)}.mobile-scrim{position:absolute;inset:var(--fold-shell-header-height-mobile, 52px) 0 0 0;z-index:40;border:0;padding:0;background:var(--fold-color-scrim);animation:fold-shell-scrim-in .24s ease}:host(.mobile-nav-open) .content{overflow:hidden}}@container (max-width: 1024px){.rail-secondary{display:none}}@container (max-width: 768px){:host,:host(.header-full){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr;grid-template-areas:\"header\" \"content\"}:host(:not(.footer-scroll):has([footer])),:host(.header-full:not(.footer-scroll):has([footer])),:host(.footer-full:not(.footer-scroll):has([footer])),:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr auto;grid-template-areas:\"header\" \"content\" \"footer\"}.rail-primary,.rail-secondary{display:none}:host(.mobile-drawer) .rail-primary{display:block;position:absolute;top:var(--fold-shell-header-height-mobile, 52px);bottom:0;left:0;z-index:41;width:auto;max-width:85%;overflow-y:auto;visibility:hidden;transform:translate(-100%);transition:transform .24s cubic-bezier(.4,0,.2,1),visibility .24s}:host(.mobile-drawer.mobile-nav-open) .rail-primary{visibility:visible;transform:translate(0)}.mobile-scrim{position:absolute;inset:var(--fold-shell-header-height-mobile, 52px) 0 0 0;z-index:40;border:0;padding:0;background:var(--fold-color-scrim);animation:fold-shell-scrim-in .24s ease}:host(.mobile-nav-open) .content{overflow:hidden}}@keyframes fold-shell-scrim-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.rail-primary,.mobile-scrim{transition:none;animation:none}}\n"], dependencies: [{ kind: "directive", type: FoldSurfaceDirective, selector: "[foldSurface]", inputs: ["foldSurface"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: FocusTrapDirective, selector: "[foldFocusTrap]", inputs: ["foldFocusTrap"] }] });
491
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: FoldAppShellComponent, isStandalone: true, selector: "fold-app-shell", inputs: { railWidth: { classPropertyName: "railWidth", publicName: "railWidth", isSignal: true, isRequired: false, transformFunction: null }, headerHeight: { classPropertyName: "headerHeight", publicName: "headerHeight", isSignal: true, isRequired: false, transformFunction: null }, headerHeightMobile: { classPropertyName: "headerHeightMobile", publicName: "headerHeightMobile", isSignal: true, isRequired: false, transformFunction: null }, headerLayout: { classPropertyName: "headerLayout", publicName: "headerLayout", isSignal: true, isRequired: false, transformFunction: null }, footerLayout: { classPropertyName: "footerLayout", publicName: "footerLayout", isSignal: true, isRequired: false, transformFunction: null }, footerBehavior: { classPropertyName: "footerBehavior", publicName: "footerBehavior", isSignal: true, isRequired: false, transformFunction: null }, contentScroll: { classPropertyName: "contentScroll", publicName: "contentScroll", isSignal: true, isRequired: false, transformFunction: null }, skipLinkLabel: { classPropertyName: "skipLinkLabel", publicName: "skipLinkLabel", isSignal: true, isRequired: false, transformFunction: null }, drawerLabel: { classPropertyName: "drawerLabel", publicName: "drawerLabel", isSignal: true, isRequired: false, transformFunction: null }, mobileNav: { classPropertyName: "mobileNav", publicName: "mobileNav", isSignal: true, isRequired: false, transformFunction: null }, mobileNavOpen: { classPropertyName: "mobileNavOpen", publicName: "mobileNavOpen", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { mobileNavOpen: "mobileNavOpenChange" }, host: { listeners: { "document:keydown.escape": "onEscape()" }, properties: { "style.--fold-shell-rail-width": "railWidthVar()", "style.--fold-shell-header-height": "headerHeightVar()", "style.--fold-shell-header-height-mobile": "headerHeightMobileVar()", "class.header-full": "headerLayout() === \"full\"", "class.footer-full": "footerLayout() === \"full\"", "class.footer-scroll": "footerBehavior() === \"scroll\"", "class.mobile-drawer": "mobileNav() === \"drawer\"", "class.mobile-nav-open": "drawerOpen()", "class.content-auto": "contentScroll() === \"auto\"" } }, exportAs: ["foldAppShell"], ngImport: i0, template: "<!-- Skip-link: the first Tab stop \u2014 visually hidden until focused, then jumps\n keyboard users past the rails to <main> (which is focusable, tabindex=-1). -->\n<a class=\"skip-link\" [href]=\"'#' + contentId\" (click)=\"skipToContent($event)\">{{\n skipLinkLabel()\n}}</a>\n\n<!-- The primary rail. While the mobile drawer is open it is a real modal dialog\n (named, aria-modal, focus-trapped) and every sibling region is inert \u2014 the\n same bar the panel host holds; on desktop it's a plain nav rail. -->\n<div\n class=\"rail-primary\"\n [id]=\"drawerId\"\n foldSurface=\"chrome\"\n [foldFocusTrap]=\"drawerOpen()\"\n [attr.role]=\"drawerOpen() ? 'dialog' : null\"\n [attr.aria-modal]=\"drawerOpen() ? 'true' : null\"\n [attr.aria-label]=\"drawerOpen() ? drawerLabel() : null\"\n tabindex=\"-1\"\n>\n <ng-content select=\"[railPrimary]\" />\n</div>\n<div\n class=\"rail-secondary\"\n foldSurface=\"chrome\"\n [attr.inert]=\"drawerOpen() ? '' : null\"\n>\n <ng-content select=\"[railSecondary]\" />\n</div>\n\n<!-- Mobile drawer scrim \u2014 only rendered while the drawer is actually open (the\n shell is narrow AND mobileNavOpen). A darkening veil that dismisses on\n click; Escape and widening close it too. aria-hidden: the focus-trapped\n drawer + Escape are the accessible path, the scrim is a mouse convenience. -->\n@if (drawerOpen()) {\n <div class=\"mobile-scrim\" (click)=\"closeMobileNav()\" aria-hidden=\"true\"></div>\n}\n<header\n class=\"header\"\n foldSurface=\"chrome\"\n [attr.inert]=\"drawerOpen() ? '' : null\"\n>\n <ng-content select=\"[header]\" />\n</header>\n\n<!-- One footer, stamped in one of two positions \u2014 a grid row (pinned, always in\n sight) or inside the content scroll (revealed on the way down). A single\n <ng-content> in a template avoids duplicate-selector projection loss. -->\n<ng-template #footerTpl>\n <footer\n class=\"footer\"\n [class.footer-inflow]=\"footerBehavior() === 'scroll'\"\n foldSurface=\"chrome\"\n [attr.inert]=\"drawerOpen() ? '' : null\"\n >\n <ng-content select=\"[footer]\" />\n </footer>\n</ng-template>\n\n<main\n class=\"content\"\n foldSurface=\"page\"\n [id]=\"contentId\"\n tabindex=\"-1\"\n [attr.inert]=\"drawerOpen() ? '' : null\"\n>\n <ng-content />\n @if (footerBehavior() === \"scroll\") {\n <ng-container [ngTemplateOutlet]=\"footerTpl\" />\n }\n</main>\n@if (footerBehavior() !== \"scroll\") {\n <ng-container [ngTemplateOutlet]=\"footerTpl\" />\n}\n", styles: ["@charset \"UTF-8\";:host{display:grid;height:100%;width:100%;position:relative;box-sizing:border-box;overflow:hidden;background:var(--fold-color-bg-page);grid-template-columns:auto auto minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height, 56px) 1fr;grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\"}.skip-link{position:absolute;top:0;left:0;z-index:60;margin:var(--fold-space-sm, 8px);padding:8px 14px;border:1px solid var(--fold-color-border);border-radius:var(--fold-radius-md);background:var(--fold-color-surface-card);color:var(--fold-color-text);font-size:var(--fold-text-sm);font-weight:600;text-decoration:none;transform:translateY(-150%);transition:transform .12s ease}.skip-link:focus{transform:translateY(0)}@media(prefers-reduced-motion:reduce){.skip-link{transition:none}}:host(.header-full){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\"}:host(:not(.footer-scroll):has([footer])){grid-template-rows:var(--fold-shell-header-height, 56px) 1fr auto;grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\" \"rail-primary rail-secondary footer\"}:host(.header-full:not(.footer-scroll):has([footer])){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\" \"rail-primary rail-secondary footer\"}:host(.footer-full:not(.footer-scroll):has([footer])){grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\" \"footer footer footer\"}:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\" \"footer footer footer\"}.rail-primary{grid-area:rail-primary;min-height:0}.rail-secondary{grid-area:rail-secondary;min-height:0}.header{grid-area:header;min-width:0}.footer{grid-area:footer;min-width:0;min-height:0}.content{grid-area:content;position:relative;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}.content:focus{outline:none}:host(.footer-scroll) .content,:host(.content-auto) .content{overflow:auto}.footer-inflow{margin-top:auto}.rail-primary:has([data-elevated]),.rail-secondary:has([data-elevated]),.header:has([data-elevated]),.footer:has([data-elevated]),.content:has([data-elevated]){padding:var(--fold-surface-inset, var(--fold-space-md))}@media(max-width:768px){:host,:host(.header-full){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr;grid-template-areas:\"header\" \"content\"}:host(:not(.footer-scroll):has([footer])),:host(.header-full:not(.footer-scroll):has([footer])),:host(.footer-full:not(.footer-scroll):has([footer])),:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr auto;grid-template-areas:\"header\" \"content\" \"footer\"}.rail-primary,.rail-secondary{display:none}:host(.mobile-drawer) .rail-primary{display:block;position:absolute;top:var(--fold-shell-header-height-mobile, 52px);bottom:0;left:0;z-index:41;width:auto;max-width:85%;overflow-y:auto;visibility:hidden;transform:translate(-100%);transition:transform .24s cubic-bezier(.4,0,.2,1),visibility .24s}:host(.mobile-drawer.mobile-nav-open) .rail-primary{visibility:visible;transform:translate(0)}.mobile-scrim{position:absolute;inset:var(--fold-shell-header-height-mobile, 52px) 0 0 0;z-index:40;border:0;padding:0;background:var(--fold-color-scrim);animation:fold-shell-scrim-in .24s ease}:host(.mobile-nav-open) .content{overflow:hidden}}@container (max-width: 1024px){.rail-secondary{display:none}}@container (max-width: 768px){:host,:host(.header-full){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr;grid-template-areas:\"header\" \"content\"}:host(:not(.footer-scroll):has([footer])),:host(.header-full:not(.footer-scroll):has([footer])),:host(.footer-full:not(.footer-scroll):has([footer])),:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr auto;grid-template-areas:\"header\" \"content\" \"footer\"}.rail-primary,.rail-secondary{display:none}:host(.mobile-drawer) .rail-primary{display:block;position:absolute;top:var(--fold-shell-header-height-mobile, 52px);bottom:0;left:0;z-index:41;width:auto;max-width:85%;overflow-y:auto;visibility:hidden;transform:translate(-100%);transition:transform .24s cubic-bezier(.4,0,.2,1),visibility .24s}:host(.mobile-drawer.mobile-nav-open) .rail-primary{visibility:visible;transform:translate(0)}.mobile-scrim{position:absolute;inset:var(--fold-shell-header-height-mobile, 52px) 0 0 0;z-index:40;border:0;padding:0;background:var(--fold-color-scrim);animation:fold-shell-scrim-in .24s ease}:host(.mobile-nav-open) .content{overflow:hidden}}@keyframes fold-shell-scrim-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.rail-primary,.mobile-scrim{transition:none;animation:none}}\n"], dependencies: [{ kind: "directive", type: FoldSurfaceDirective, selector: "[foldSurface]", inputs: ["foldSurface"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: FocusTrapDirective, selector: "[foldFocusTrap]", inputs: ["foldFocusTrap"] }] });
435
492
  }
436
493
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: FoldAppShellComponent, decorators: [{
437
494
  type: Component,
438
- args: [{ selector: "fold-app-shell", standalone: true, imports: [FoldSurfaceDirective, NgTemplateOutlet, FocusTrapDirective], host: {
495
+ args: [{ selector: "fold-app-shell", standalone: true, exportAs: "foldAppShell", imports: [FoldSurfaceDirective, NgTemplateOutlet, FocusTrapDirective], host: {
439
496
  "[style.--fold-shell-rail-width]": "railWidthVar()",
440
497
  "[style.--fold-shell-header-height]": "headerHeightVar()",
441
498
  "[style.--fold-shell-header-height-mobile]": "headerHeightMobileVar()",
@@ -445,8 +502,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
445
502
  "[class.mobile-drawer]": 'mobileNav() === "drawer"',
446
503
  "[class.mobile-nav-open]": "drawerOpen()",
447
504
  "[class.content-auto]": 'contentScroll() === "auto"',
448
- }, template: "<!-- Skip-link: the first Tab stop \u2014 visually hidden until focused, then jumps\n keyboard users past the rails to <main> (which is focusable, tabindex=-1). -->\n<a class=\"skip-link\" [href]=\"'#' + contentId\">{{ skipLinkLabel() }}</a>\n\n<div\n class=\"rail-primary\"\n foldSurface=\"chrome\"\n [foldFocusTrap]=\"drawerOpen()\"\n tabindex=\"-1\"\n>\n <ng-content select=\"[railPrimary]\" />\n</div>\n<div class=\"rail-secondary\" foldSurface=\"chrome\">\n <ng-content select=\"[railSecondary]\" />\n</div>\n\n<!-- Mobile drawer scrim \u2014 only rendered while the drawer is actually open (the\n shell is narrow AND mobileNavOpen). A darkening veil that dismisses on\n click; Escape and widening close it too. aria-hidden: the focus-trapped\n drawer + Escape are the accessible path, the scrim is a mouse convenience. -->\n@if (drawerOpen()) {\n <div class=\"mobile-scrim\" (click)=\"closeMobileNav()\" aria-hidden=\"true\"></div>\n}\n<header class=\"header\" foldSurface=\"chrome\">\n <ng-content select=\"[header]\" />\n</header>\n\n<!-- One footer, stamped in one of two positions \u2014 a grid row (pinned, always in\n sight) or inside the content scroll (revealed on the way down). A single\n <ng-content> in a template avoids duplicate-selector projection loss. -->\n<ng-template #footerTpl>\n <footer\n class=\"footer\"\n [class.footer-inflow]=\"footerBehavior() === 'scroll'\"\n foldSurface=\"chrome\"\n >\n <ng-content select=\"[footer]\" />\n </footer>\n</ng-template>\n\n<main class=\"content\" foldSurface=\"page\" [id]=\"contentId\" tabindex=\"-1\">\n <ng-content />\n @if (footerBehavior() === \"scroll\") {\n <ng-container [ngTemplateOutlet]=\"footerTpl\" />\n }\n</main>\n@if (footerBehavior() !== \"scroll\") {\n <ng-container [ngTemplateOutlet]=\"footerTpl\" />\n}\n", styles: ["@charset \"UTF-8\";:host{display:grid;height:100%;width:100%;position:relative;box-sizing:border-box;overflow:hidden;background:var(--fold-color-bg-page);grid-template-columns:auto auto minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height, 56px) 1fr;grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\"}.skip-link{position:absolute;top:0;left:0;z-index:60;margin:var(--fold-space-sm, 8px);padding:8px 14px;border:1px solid var(--fold-color-border);border-radius:var(--fold-radius-md);background:var(--fold-color-surface-card);color:var(--fold-color-text);font-size:var(--fold-text-sm);font-weight:600;text-decoration:none;transform:translateY(-150%);transition:transform .12s ease}.skip-link:focus{transform:translateY(0)}@media(prefers-reduced-motion:reduce){.skip-link{transition:none}}:host(.header-full){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\"}:host(:not(.footer-scroll):has([footer])){grid-template-rows:var(--fold-shell-header-height, 56px) 1fr auto;grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\" \"rail-primary rail-secondary footer\"}:host(.header-full:not(.footer-scroll):has([footer])){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\" \"rail-primary rail-secondary footer\"}:host(.footer-full:not(.footer-scroll):has([footer])){grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\" \"footer footer footer\"}:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\" \"footer footer footer\"}.rail-primary{grid-area:rail-primary;min-height:0}.rail-secondary{grid-area:rail-secondary;min-height:0}.header{grid-area:header;min-width:0}.footer{grid-area:footer;min-width:0;min-height:0}.content{grid-area:content;position:relative;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}.content:focus{outline:none}:host(.footer-scroll) .content,:host(.content-auto) .content{overflow:auto}.footer-inflow{margin-top:auto}.rail-primary:has([data-elevated]),.rail-secondary:has([data-elevated]),.header:has([data-elevated]),.footer:has([data-elevated]),.content:has([data-elevated]){padding:var(--fold-surface-inset, var(--fold-space-md))}@media(max-width:768px){:host,:host(.header-full){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr;grid-template-areas:\"header\" \"content\"}:host(:not(.footer-scroll):has([footer])),:host(.header-full:not(.footer-scroll):has([footer])),:host(.footer-full:not(.footer-scroll):has([footer])),:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr auto;grid-template-areas:\"header\" \"content\" \"footer\"}.rail-primary,.rail-secondary{display:none}:host(.mobile-drawer) .rail-primary{display:block;position:absolute;top:var(--fold-shell-header-height-mobile, 52px);bottom:0;left:0;z-index:41;width:auto;max-width:85%;overflow-y:auto;visibility:hidden;transform:translate(-100%);transition:transform .24s cubic-bezier(.4,0,.2,1),visibility .24s}:host(.mobile-drawer.mobile-nav-open) .rail-primary{visibility:visible;transform:translate(0)}.mobile-scrim{position:absolute;inset:var(--fold-shell-header-height-mobile, 52px) 0 0 0;z-index:40;border:0;padding:0;background:var(--fold-color-scrim);animation:fold-shell-scrim-in .24s ease}:host(.mobile-nav-open) .content{overflow:hidden}}@container (max-width: 1024px){.rail-secondary{display:none}}@container (max-width: 768px){:host,:host(.header-full){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr;grid-template-areas:\"header\" \"content\"}:host(:not(.footer-scroll):has([footer])),:host(.header-full:not(.footer-scroll):has([footer])),:host(.footer-full:not(.footer-scroll):has([footer])),:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr auto;grid-template-areas:\"header\" \"content\" \"footer\"}.rail-primary,.rail-secondary{display:none}:host(.mobile-drawer) .rail-primary{display:block;position:absolute;top:var(--fold-shell-header-height-mobile, 52px);bottom:0;left:0;z-index:41;width:auto;max-width:85%;overflow-y:auto;visibility:hidden;transform:translate(-100%);transition:transform .24s cubic-bezier(.4,0,.2,1),visibility .24s}:host(.mobile-drawer.mobile-nav-open) .rail-primary{visibility:visible;transform:translate(0)}.mobile-scrim{position:absolute;inset:var(--fold-shell-header-height-mobile, 52px) 0 0 0;z-index:40;border:0;padding:0;background:var(--fold-color-scrim);animation:fold-shell-scrim-in .24s ease}:host(.mobile-nav-open) .content{overflow:hidden}}@keyframes fold-shell-scrim-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.rail-primary,.mobile-scrim{transition:none;animation:none}}\n"] }]
449
- }], ctorParameters: () => [], propDecorators: { railWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "railWidth", required: false }] }], headerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "headerHeight", required: false }] }], headerHeightMobile: [{ type: i0.Input, args: [{ isSignal: true, alias: "headerHeightMobile", required: false }] }], headerLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "headerLayout", required: false }] }], footerLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "footerLayout", required: false }] }], footerBehavior: [{ type: i0.Input, args: [{ isSignal: true, alias: "footerBehavior", required: false }] }], contentScroll: [{ type: i0.Input, args: [{ isSignal: true, alias: "contentScroll", required: false }] }], skipLinkLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "skipLinkLabel", required: false }] }], mobileNav: [{ type: i0.Input, args: [{ isSignal: true, alias: "mobileNav", required: false }] }], mobileNavOpen: [{ type: i0.Input, args: [{ isSignal: true, alias: "mobileNavOpen", required: false }] }, { type: i0.Output, args: ["mobileNavOpenChange"] }], onEscape: [{
505
+ }, template: "<!-- Skip-link: the first Tab stop \u2014 visually hidden until focused, then jumps\n keyboard users past the rails to <main> (which is focusable, tabindex=-1). -->\n<a class=\"skip-link\" [href]=\"'#' + contentId\" (click)=\"skipToContent($event)\">{{\n skipLinkLabel()\n}}</a>\n\n<!-- The primary rail. While the mobile drawer is open it is a real modal dialog\n (named, aria-modal, focus-trapped) and every sibling region is inert \u2014 the\n same bar the panel host holds; on desktop it's a plain nav rail. -->\n<div\n class=\"rail-primary\"\n [id]=\"drawerId\"\n foldSurface=\"chrome\"\n [foldFocusTrap]=\"drawerOpen()\"\n [attr.role]=\"drawerOpen() ? 'dialog' : null\"\n [attr.aria-modal]=\"drawerOpen() ? 'true' : null\"\n [attr.aria-label]=\"drawerOpen() ? drawerLabel() : null\"\n tabindex=\"-1\"\n>\n <ng-content select=\"[railPrimary]\" />\n</div>\n<div\n class=\"rail-secondary\"\n foldSurface=\"chrome\"\n [attr.inert]=\"drawerOpen() ? '' : null\"\n>\n <ng-content select=\"[railSecondary]\" />\n</div>\n\n<!-- Mobile drawer scrim \u2014 only rendered while the drawer is actually open (the\n shell is narrow AND mobileNavOpen). A darkening veil that dismisses on\n click; Escape and widening close it too. aria-hidden: the focus-trapped\n drawer + Escape are the accessible path, the scrim is a mouse convenience. -->\n@if (drawerOpen()) {\n <div class=\"mobile-scrim\" (click)=\"closeMobileNav()\" aria-hidden=\"true\"></div>\n}\n<header\n class=\"header\"\n foldSurface=\"chrome\"\n [attr.inert]=\"drawerOpen() ? '' : null\"\n>\n <ng-content select=\"[header]\" />\n</header>\n\n<!-- One footer, stamped in one of two positions \u2014 a grid row (pinned, always in\n sight) or inside the content scroll (revealed on the way down). A single\n <ng-content> in a template avoids duplicate-selector projection loss. -->\n<ng-template #footerTpl>\n <footer\n class=\"footer\"\n [class.footer-inflow]=\"footerBehavior() === 'scroll'\"\n foldSurface=\"chrome\"\n [attr.inert]=\"drawerOpen() ? '' : null\"\n >\n <ng-content select=\"[footer]\" />\n </footer>\n</ng-template>\n\n<main\n class=\"content\"\n foldSurface=\"page\"\n [id]=\"contentId\"\n tabindex=\"-1\"\n [attr.inert]=\"drawerOpen() ? '' : null\"\n>\n <ng-content />\n @if (footerBehavior() === \"scroll\") {\n <ng-container [ngTemplateOutlet]=\"footerTpl\" />\n }\n</main>\n@if (footerBehavior() !== \"scroll\") {\n <ng-container [ngTemplateOutlet]=\"footerTpl\" />\n}\n", styles: ["@charset \"UTF-8\";:host{display:grid;height:100%;width:100%;position:relative;box-sizing:border-box;overflow:hidden;background:var(--fold-color-bg-page);grid-template-columns:auto auto minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height, 56px) 1fr;grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\"}.skip-link{position:absolute;top:0;left:0;z-index:60;margin:var(--fold-space-sm, 8px);padding:8px 14px;border:1px solid var(--fold-color-border);border-radius:var(--fold-radius-md);background:var(--fold-color-surface-card);color:var(--fold-color-text);font-size:var(--fold-text-sm);font-weight:600;text-decoration:none;transform:translateY(-150%);transition:transform .12s ease}.skip-link:focus{transform:translateY(0)}@media(prefers-reduced-motion:reduce){.skip-link{transition:none}}:host(.header-full){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\"}:host(:not(.footer-scroll):has([footer])){grid-template-rows:var(--fold-shell-header-height, 56px) 1fr auto;grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\" \"rail-primary rail-secondary footer\"}:host(.header-full:not(.footer-scroll):has([footer])){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\" \"rail-primary rail-secondary footer\"}:host(.footer-full:not(.footer-scroll):has([footer])){grid-template-areas:\"rail-primary rail-secondary header\" \"rail-primary rail-secondary content\" \"footer footer footer\"}:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-areas:\"header header header\" \"rail-primary rail-secondary content\" \"footer footer footer\"}.rail-primary{grid-area:rail-primary;min-height:0}.rail-secondary{grid-area:rail-secondary;min-height:0}.header{grid-area:header;min-width:0}.footer{grid-area:footer;min-width:0;min-height:0}.content{grid-area:content;position:relative;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}.content:focus{outline:none}:host(.footer-scroll) .content,:host(.content-auto) .content{overflow:auto}.footer-inflow{margin-top:auto}.rail-primary:has([data-elevated]),.rail-secondary:has([data-elevated]),.header:has([data-elevated]),.footer:has([data-elevated]),.content:has([data-elevated]){padding:var(--fold-surface-inset, var(--fold-space-md))}@media(max-width:768px){:host,:host(.header-full){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr;grid-template-areas:\"header\" \"content\"}:host(:not(.footer-scroll):has([footer])),:host(.header-full:not(.footer-scroll):has([footer])),:host(.footer-full:not(.footer-scroll):has([footer])),:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr auto;grid-template-areas:\"header\" \"content\" \"footer\"}.rail-primary,.rail-secondary{display:none}:host(.mobile-drawer) .rail-primary{display:block;position:absolute;top:var(--fold-shell-header-height-mobile, 52px);bottom:0;left:0;z-index:41;width:auto;max-width:85%;overflow-y:auto;visibility:hidden;transform:translate(-100%);transition:transform .24s cubic-bezier(.4,0,.2,1),visibility .24s}:host(.mobile-drawer.mobile-nav-open) .rail-primary{visibility:visible;transform:translate(0)}.mobile-scrim{position:absolute;inset:var(--fold-shell-header-height-mobile, 52px) 0 0 0;z-index:40;border:0;padding:0;background:var(--fold-color-scrim);animation:fold-shell-scrim-in .24s ease}:host(.mobile-nav-open) .content{overflow:hidden}}@container (max-width: 1024px){.rail-secondary{display:none}}@container (max-width: 768px){:host,:host(.header-full){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr;grid-template-areas:\"header\" \"content\"}:host(:not(.footer-scroll):has([footer])),:host(.header-full:not(.footer-scroll):has([footer])),:host(.footer-full:not(.footer-scroll):has([footer])),:host(.header-full.footer-full:not(.footer-scroll):has([footer])){grid-template-columns:minmax(0,1fr);grid-template-rows:var(--fold-shell-header-height-mobile, 52px) 1fr auto;grid-template-areas:\"header\" \"content\" \"footer\"}.rail-primary,.rail-secondary{display:none}:host(.mobile-drawer) .rail-primary{display:block;position:absolute;top:var(--fold-shell-header-height-mobile, 52px);bottom:0;left:0;z-index:41;width:auto;max-width:85%;overflow-y:auto;visibility:hidden;transform:translate(-100%);transition:transform .24s cubic-bezier(.4,0,.2,1),visibility .24s}:host(.mobile-drawer.mobile-nav-open) .rail-primary{visibility:visible;transform:translate(0)}.mobile-scrim{position:absolute;inset:var(--fold-shell-header-height-mobile, 52px) 0 0 0;z-index:40;border:0;padding:0;background:var(--fold-color-scrim);animation:fold-shell-scrim-in .24s ease}:host(.mobile-nav-open) .content{overflow:hidden}}@keyframes fold-shell-scrim-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.rail-primary,.mobile-scrim{transition:none;animation:none}}\n"] }]
506
+ }], ctorParameters: () => [], propDecorators: { railWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "railWidth", required: false }] }], headerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "headerHeight", required: false }] }], headerHeightMobile: [{ type: i0.Input, args: [{ isSignal: true, alias: "headerHeightMobile", required: false }] }], headerLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "headerLayout", required: false }] }], footerLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "footerLayout", required: false }] }], footerBehavior: [{ type: i0.Input, args: [{ isSignal: true, alias: "footerBehavior", required: false }] }], contentScroll: [{ type: i0.Input, args: [{ isSignal: true, alias: "contentScroll", required: false }] }], skipLinkLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "skipLinkLabel", required: false }] }], drawerLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "drawerLabel", required: false }] }], mobileNav: [{ type: i0.Input, args: [{ isSignal: true, alias: "mobileNav", required: false }] }], mobileNavOpen: [{ type: i0.Input, args: [{ isSignal: true, alias: "mobileNavOpen", required: false }] }, { type: i0.Output, args: ["mobileNavOpenChange"] }], onEscape: [{
450
507
  type: HostListener,
451
508
  args: ["document:keydown.escape"]
452
509
  }] } });
@@ -568,7 +625,10 @@ class FoldPaletteRegistry {
568
625
  /** Deterministic colour for a seed, from the active palette. Reactive. */
569
626
  colorFor(seed) {
570
627
  const palette = this._current();
571
- return palette[Math.abs(foldHashSeed(seed)) % palette.length];
628
+ const idx = Math.abs(foldHashSeed(seed)) % palette.length;
629
+ // `_current` always holds a non-empty palette (foldResolvePalette falls back
630
+ // to `vivid`), so the modulo index resolves; the fallbacks are for the type.
631
+ return palette[idx] ?? palette[0] ?? "transparent";
572
632
  }
573
633
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: FoldPaletteRegistry, deps: [], target: i0.ɵɵFactoryTarget.Service });
574
634
  static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.8", ngImport: i0, type: FoldPaletteRegistry });
@@ -662,9 +722,9 @@ class FoldAvatarComponent {
662
722
  if (!n) {
663
723
  return "?";
664
724
  }
665
- const words = n.split(/\s+/);
666
- if (words.length >= 2) {
667
- return (words[0][0] + words[1][0]).toUpperCase();
725
+ const [first = "", second = ""] = n.split(/\s+/);
726
+ if (second) {
727
+ return (first.charAt(0) + second.charAt(0)).toUpperCase();
668
728
  }
669
729
  return n.substring(0, 2).toUpperCase();
670
730
  }, /* @ts-ignore */
@@ -2712,7 +2772,7 @@ class FoldNavLauncherComponent {
2712
2772
  resolvedCols = computed(() => {
2713
2773
  const cols = this.columns();
2714
2774
  if (cols !== "auto") {
2715
- return Number(cols);
2775
+ return cols;
2716
2776
  }
2717
2777
  return this.tiles().length <= 4 ? 2 : 3;
2718
2778
  }, /* @ts-ignore */
@@ -4666,6 +4726,12 @@ const HYSTERESIS = 32;
4666
4726
  * - `[tabNav]` → the tab bar (an `fold-tab-nav`, or anything else).
4667
4727
  * - default slot → the content for the active tab.
4668
4728
  *
4729
+ * **A11y — this owns placement, not the ARIA tabs pattern.** The projected bar
4730
+ * carries the `tablist`/`tab` roles; the content is yours. If the tabs switch
4731
+ * in-page panels, give that content `role="tabpanel"` named by the active tab;
4732
+ * if they navigate between routes, keep plain nav semantics and add no tab
4733
+ * roles (the tabs pattern is for panel-switching, not navigation).
4734
+ *
4669
4735
  * A side rail needs a *vertical* bar, a folded one a *horizontal* bar. Rather
4670
4736
  * than guess, the layout exposes {@link stacked} (`exportAs`) so the projected
4671
4737
  * nav follows the layout in one binding:
@@ -4780,7 +4846,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
4780
4846
  * @selector `fold-page-layout`
4781
4847
  */
4782
4848
  class FoldPageLayoutComponent {
4783
- /** The page heading. Omit for a header-less page. */
4849
+ /** The page heading (`<h1>`). Omit for a header-less page — or when the page
4850
+ * already carries its `<h1>` elsewhere (e.g. a leading `fold-hero-section`),
4851
+ * so a page never ends up with two top-level headings. */
4784
4852
  title = input(/* @ts-ignore */
4785
4853
  ...(ngDevMode ? [undefined, { debugName: "title" }] : /* istanbul ignore next */ []));
4786
4854
  /** An optional leading icon shown beside the title. */
@@ -5638,14 +5706,13 @@ class FoldPanelHostComponent {
5638
5706
  /** Only the last-opened panel traps focus, so stacked panels don't fight. */
5639
5707
  isTopMost(panel) {
5640
5708
  const panels = this.panels();
5641
- return panels.length > 0 && panels[panels.length - 1].id === panel.id;
5709
+ const top = panels[panels.length - 1];
5710
+ return top?.id === panel.id;
5642
5711
  }
5643
5712
  /** Escape closes the top-most (last-opened) panel. */
5644
5713
  onEscape() {
5645
- const panels = this.panels();
5646
- if (panels.length > 0) {
5647
- panels[panels.length - 1].onClose();
5648
- }
5714
+ const top = this.panels().at(-1);
5715
+ top?.onClose();
5649
5716
  }
5650
5717
  onBackdrop(event, panel) {
5651
5718
  if (event.target === event.currentTarget) {