@danjelp/ngx-app-shell 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Danjel Peqini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,559 @@
1
+ # @danjelp/ngx-app-shell
2
+
3
+ [![npm](https://img.shields.io/npm/v/@danjelp/ngx-app-shell)](https://www.npmjs.com/package/@danjelp/ngx-app-shell)
4
+ [![license](https://img.shields.io/npm/l/@danjelp/ngx-app-shell)](https://github.com/dpeqini/ngx-app-shell/blob/main/LICENSE)
5
+
6
+ A reusable Angular navigational shell: **sidebar + topbar + main + footer**.
7
+
8
+ - Layout comes from CSS custom properties.
9
+ - Chrome comes from named slots that react to the space *they* have, not the window.
10
+ - The sidebar's collapse policy is a swappable strategy that can change at runtime.
11
+
12
+ ```
13
+ projects/app-shell/
14
+ ├── styles/_tokens.scss public Sass entry (@forward)
15
+ └── src/
16
+ ├── public-api.ts the only import surface
17
+ └── lib/
18
+ ├── app-shell.imports.ts APP_SHELL convenience array
19
+ ├── core/
20
+ │ ├── shell.types.ts SidebarState, ShellSlotName, ShellApi …
21
+ │ ├── shell.config.ts ShellConfig, provideAppShell(), mergeShellConfig()
22
+ │ ├── shell.store.ts the one source of truth (root, signals)
23
+ │ ├── shell-state-storage.ts persistence port (localStorage by default)
24
+ │ └── shell.ids.ts
25
+ ├── sidebar/
26
+ │ ├── sidebar-behavior.ts SidebarBehavior interface + DI token
27
+ │ ├── sidebar-states.ts drawerState / railState / panelState / hiddenState
28
+ │ ├── responsive-sidebar-behavior.ts default policy
29
+ │ ├── manual-sidebar-behavior.ts user decides, never auto-collapses
30
+ │ └── drawer-sidebar-behavior.ts always an off-canvas drawer
31
+ ├── responsive/
32
+ │ ├── shell-size.ts buckets, resolveSize, atLeast/atMost/isBelow
33
+ │ ├── observe-width.ts ResizeObserver → signal
34
+ │ ├── measure-region.ts width + bucket for any element
35
+ │ ├── shell-region.ts SHELL_REGION: "the space I actually have"
36
+ │ └── container-size.directive.ts [shellContainerSize] for your own UI
37
+ ├── slots/ shellSlot directive, registry, outlet, hasSlot()
38
+ ├── layout/ app-shell, topbar, sidebar, subheader, footer, overflow menu
39
+ ├── nav/ shell-nav, shell-nav-item, ShellNavRegistry, types
40
+ ├── breadcrumbs/ ShellBreadcrumbStore, shell-breadcrumb, types
41
+ ├── directives/sidebar-toggle.directive.ts
42
+ ├── a11y/focus-trap.ts
43
+ └── styles/_tokens.scss the token map (single source of truth)
44
+ ```
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ npm install @danjelp/ngx-app-shell
50
+ ```
51
+
52
+ The peer dependencies are `@angular/core`, `@angular/common` and `@angular/router` (19–22), plus
53
+ `rxjs` 7.
54
+
55
+ ## Quick start
56
+
57
+ ```ts
58
+ // app.config.ts
59
+ providers: [
60
+ provideRouter(routes),
61
+ provideAppShell({
62
+ layout: 'topbar-full',
63
+ sidebar: { collapseBelow: 'lg', overlayBelow: 'md', resizable: true, toggleShortcut: 'b' },
64
+ }),
65
+ ];
66
+ ```
67
+
68
+ ```scss
69
+ // styles.scss — optional: every token has an inline fallback
70
+ @use '@danjelp/ngx-app-shell/styles/tokens' as shell;
71
+ @include shell.tokens;
72
+ @include shell.dark-tokens(':root[data-theme="dark"]');
73
+
74
+ :root {
75
+ --shell-topbar-height: 64px;
76
+ --shell-sidebar-width: 250px;
77
+ --shell-main-padding: 24px;
78
+ }
79
+ ```
80
+
81
+ ```html
82
+ <!-- app.html -->
83
+ <app-shell>
84
+ <ng-template shellSlot="brand"><a routerLink="/">Acme</a></ng-template>
85
+ <ng-template shellSlot="sidebar-nav">
86
+ <shell-nav [groups]="navigation" [iconTemplate]="icon" />
87
+ </ng-template>
88
+ <ng-template #icon let-name><my-icon [name]="name" /></ng-template>
89
+
90
+ <router-outlet />
91
+ </app-shell>
92
+ ```
93
+
94
+ ```ts
95
+ @Component({ imports: [APP_SHELL, RouterOutlet, RouterLink], … })
96
+ ```
97
+
98
+ ### Mapping of the original layout variables
99
+
100
+ | original | shell |
101
+ | -------------------------- | ---------------------------------------------------------------------- |
102
+ | `--topbar-height` | `--shell-topbar-height` |
103
+ | `--sidebar-width` | `--shell-sidebar-width` |
104
+ | `--sidebar-top-offset` | derived from the layout (`--shell-sidebar-top-offset`, read-only) |
105
+ | `--topbar-left-offset` | derived from the layout — the topbar is its own grid area |
106
+ | `--main-container-padding` | `--shell-main-padding`, no topbar compensation — `main` is its own row |
107
+
108
+ ## 1. Layout: a grid driven by custom properties
109
+
110
+ There are three named grid areas and one runtime value. Switching arrangement swaps only
111
+ `grid-template-areas`, so no component needs to know where it sits.
112
+
113
+ ```
114
+ topbar-full sidebar-full stacked
115
+ ┌───────────────┐ ┌────┬──────────┐ ┌───────────────┐
116
+ │ topbar │ │ s │ topbar │ │ topbar │
117
+ ├────┬──────────┤ │ i ├──────────┤ ├────┬──────────┤
118
+ │ sb │ main │ │ d │ main │ │ sb │ main │
119
+ ├────┼──────────┤ │ e ├──────────┤ ├────┴──────────┤
120
+ │ sb │ footer │ │ b │ footer │ │ footer │
121
+ └────┴──────────┘ └────┴──────────┘ └───────────────┘
122
+ ```
123
+
124
+ `inset` puts the topbar, content and footer together in one rounded card (the *frame*). The
125
+ sidebar sits directly on the shell background, with no surface, border or shadow; the drawer
126
+ keeps its own.
127
+
128
+ ```
129
+ inset
130
+ ┌────┬───────────────────┐
131
+ │ │╭─────────────────╮│
132
+ │ sb ││ topbar ││
133
+ │ ││ main ││
134
+ │ ││ footer ││
135
+ │ │╰─────────────────╯│
136
+ └────┴───────────────────┘
137
+ ```
138
+
139
+ Inside the card, `scroll` chooses what moves:
140
+ - `'main'`: only the content scrolls, between a pinned topbar and footer.
141
+ - `'page'`: the card itself scrolls. The topbar sticks to the top of the card, and the footer
142
+ follows the content.
143
+
144
+ The card never leaves the screen and the sidebar never moves. Style the card with the `frame-*`
145
+ tokens, and the area around it with `--shell-bg`.
146
+
147
+ The only layout value TypeScript writes is `--shell-sidebar-track`, bound from the resolved
148
+ sidebar state. With `scroll: 'main'` only `main` scrolls, and the shell scrolls it back to the
149
+ top after each navigation (`scrollTopOnNavigate`). With `scroll: 'page'` the document scrolls,
150
+ and the topbar and sidebar stick.
151
+
152
+ ## 2. Adding items: slots and nav data
153
+
154
+ There are two mechanisms, for two kinds of content.
155
+
156
+ ### Slots — templates that register themselves
157
+
158
+ `<ng-content select>` can't move content between places, and a shell needs exactly that:
159
+ search collapses to an icon, and actions move into an overflow menu. So each slot is an
160
+ `ng-template` that registers with a root `ShellSlotRegistry` when it is created and unregisters
161
+ when it is destroyed. Each region renders the slots it owns with `<shell-slot-outlet>`.
162
+
163
+ ```html
164
+ <ng-template shellSlot="actions" slotOrder="1" slotMinSize="md" slotOverflow let-overflow="overflow">
165
+ <button [class]="overflow ? 'menu-row' : 'icon-btn'">Notifications</button>
166
+ </ng-template>
167
+ ```
168
+
169
+ | input | meaning |
170
+ | -------------- | ------------------------------------------------------------------------ |
171
+ | `shellSlot` | target slot name |
172
+ | `slotOrder` | ascending order within the slot (several templates per slot is fine) |
173
+ | `slotMinSize` | render only when the *region* is at least this size |
174
+ | `slotMaxSize` | render only up to this size (narrow-only affordances) |
175
+ | `slotOverflow` | when it doesn't fit, move it to the ⋯ menu instead of dropping it (only slots in `config.overflowSlots`; dev mode warns otherwise) |
176
+
177
+ Template context: `let-sidebar` (resolved `SidebarState`), `let-size` (shell size),
178
+ `let-region` (this region's size), `let-overflow` (true in the ⋯ menu) and `let-shell`
179
+ (`ShellApi`).
180
+
181
+ Because registration is a service call rather than content projection, a **routed page can
182
+ contribute chrome** — its own actions or title — and it disappears when the page is destroyed.
183
+
184
+ ### Navigation — data in, markup out
185
+
186
+ `<shell-nav>` renders `ShellNavGroup[]` (or a flat `ShellNavItem[]`) and handles:
187
+ - rail labels: hidden, but still read by screen readers and shown as tooltips
188
+ - badges turning into dots on the rail
189
+ - nested sections that open themselves when they contain the active route
190
+ - collapsible groups and `aria-current`
191
+
192
+ The same array can feed a command palette.
193
+
194
+ | input | purpose |
195
+ | ---------------- | --------------------------------------------------------------------------- |
196
+ | `groups`/`items` | the data |
197
+ | `iconTemplate` | turns `item.icon` keys into icons (`let-name`) |
198
+ | `itemTemplate` | replaces the **content** of each row. The row element — link, section button, active state, children — stays the library's. Context: `item`, `depth`, `labels`, `expanded` |
199
+ | `labels` | overrides the sidebar's label state |
200
+ | `headings` | `false` for a dense list without group headings |
201
+
202
+ On the rail, an item without an icon shows the first letter of its label, so no row is ever
203
+ blank.
204
+
205
+ ### Feature-contributed nav items — `ShellNavRegistry`
206
+
207
+ The app declares the groups, and features fill them by group `id`. A group with no items and no
208
+ contributions isn't rendered, so an app can reserve an empty group for plugins.
209
+
210
+ ```ts
211
+ // app-lifetime: feature-owned link, registered at startup
212
+ providers: [provideShellNavItems('workspace', [{ label: 'Roadmap', route: '/roadmap' }], { order: 10 })];
213
+
214
+ // component-lifetime: context links while a page is open
215
+ contributeNavItems('workspace', computed(() => [{ label: 'Inbox', route: '/inbox', badge: unread() }]));
216
+
217
+ // merged tree for your own consumers (palette, breadcrumbs)
218
+ inject(ShellNavRegistry).merge(groups);
219
+ ```
220
+
221
+ ## 3. Regions measure themselves, not the window
222
+
223
+ Every region measures its own width and provides `SHELL_REGION` with it. **Expanding the sidebar
224
+ from a rail to a full panel takes ~200px away from the topbar without the window resizing.** The
225
+ topbar notices, drops to a smaller size, and collapses its slots exactly as it would on a phone.
226
+
227
+ The sizes are based on container width, not device width: `xs 0` · `sm 520` · `md 768` · `lg 1080` · `xl 1440`.
228
+ Every region also exposes `data-shell-size` and a named CSS container (`shell-topbar`,
229
+ `shell-main`, `shell-footer`), so plain CSS can react too:
230
+
231
+ ```scss
232
+ @container shell-topbar (max-width: 820px) { .search__kbd { display: none; } }
233
+ ```
234
+
235
+ `[shellContainerSize]` gives your own components the same trick. The sidebar is the deliberate
236
+ exception: it reports a size based on what it shows — `xs` on a rail, `md` when labels show —
237
+ so `slotMinSize="sm"` in the sidebar means "only when labels show".
238
+
239
+ ## 4. The sidebar's behaviour is a strategy
240
+
241
+ The state is computed from four inputs: the shell size, the user's **intent** (`auto`,
242
+ `expanded`, `collapsed` or `hidden`; persisted), a temporary **drawer-open** flag (never
243
+ persisted, and reset whenever the size changes), and the peek flag.
244
+
245
+ ```ts
246
+ export const railFirst: SidebarBehavior = {
247
+ resolve({ size, intent, drawerOpen, peeking, config: { sidebar } }) {
248
+ if (isBelow(size, sidebar.overlayBelow)) return drawerState(drawerOpen, sidebar);
249
+ if (intent === 'hidden') return hiddenState();
250
+ return intent === 'expanded' ? panelState(sidebar) : railState(peeking, sidebar);
251
+ },
252
+ };
253
+
254
+ provideAppShell({}, { behavior: railFirst }); // at startup
255
+ inject(ShellStore).setBehavior(railFirst); // or at runtime
256
+ ```
257
+
258
+ Shipped policies: `responsiveSidebarBehavior` (default), `manualSidebarBehavior` and
259
+ `drawerSidebarBehavior`.
260
+
261
+ A drawer toggles open and closed. A persistent panel asks `nextIntent`, which defaults to
262
+ expanded ⇄ collapsed.
263
+
264
+ | mode | open | modal | labels | notes |
265
+ | ----------- | ---- | ----- | ------- | ---------------------------------------------- |
266
+ | `expanded` | ✓ | | ✓ | in-flow panel, optional drag-to-resize |
267
+ | `collapsed` | ✓ | | on peek | icon rail; peek widens it over the content |
268
+ | `overlay` | ✗/✓ | ✓ | ✓ | drawer, scrim, focus trap, Escape |
269
+ | `hidden` | ✗ | | | not displayed, zero-width track |
270
+
271
+ `trackWidth` and `panelWidth` are separate so that peek doesn't reflow the page under the
272
+ pointer.
273
+
274
+ `<button shellSidebarToggle>` works anywhere and keeps `aria-expanded`, `aria-controls` and its
275
+ label in sync with the mode.
276
+
277
+ ## 5. The breadcrumb row
278
+
279
+ A row between the topbar and `main`, holding the trail on the start side and page-level actions
280
+ on the end side:
281
+
282
+ ```
283
+ Projects › Mobile app v3 › Settings [subheader-start] ········ Updated 2m ago · Share · [New issue] ⋯
284
+ ```
285
+
286
+ **The trail comes from your routes.** Each route that consumes URL segments is one level. Its
287
+ label comes from, in order:
288
+ 1. a page override
289
+ 2. the route's own `data.breadcrumb` (a string, or produced by `resolve: { breadcrumb }`)
290
+ 3. the route's own `title`
291
+
292
+ `data: { breadcrumb: false }` skips a level, and empty-path routes never add one. Only what a
293
+ route declares itself counts; values Angular passes down from a parent don't.
294
+
295
+ ```ts
296
+ { path: 'projects', data: { breadcrumb: 'Projects' }, children: [
297
+ { path: '', component: ProjectsPage },
298
+ { path: ':id', component: ProjectPage, children: [
299
+ { path: 'settings', title: 'Settings', component: ProjectSettings },
300
+ ]},
301
+ ]}
302
+ ```
303
+
304
+ **The placeholder.** For names only the page knows, call `contributeBreadcrumbLabel(nameSignal)`
305
+ in a routed component. It labels *that component's* level, including while a child tab is
306
+ current.
307
+ - While the signal is `undefined`, the crumb is a skeleton and the list is `aria-busy`.
308
+ - The skeleton appears only after `skeletonDelay` (400ms), so fast labels never flash.
309
+ - The row's height is reserved, so nothing moves when the name arrives.
310
+ - Labels from resolvers are ready before the page renders and never need a placeholder.
311
+
312
+ **When the row shows** (`subheader.enabled: 'auto'`):
313
+ - when the page is at least two levels deep, or a page fills a subheader slot
314
+ - `data: { subheader: true | false }` on a route forces it
315
+ - the decision is made per navigation, so the row never appears or disappears within a page
316
+
317
+ **The end side.**
318
+ - `subheader-start` sits right after the trail; `subheader-end` holds actions.
319
+ - `slotOverflow` on `subheader-end` moves actions into the row's own ⋯ menu.
320
+ - Keep to one primary action and up to three secondary ones, and only actions that apply to the
321
+ whole page.
322
+
323
+ **Narrow widths.** The trail measures its own width:
324
+ 1. the full trail; beyond `maxItems`, the middle levels fold into a "…" menu
325
+ 2. below `compactBelow`: first › … › parent › current
326
+ 3. below `backBelow`: a single "‹ Parent" link
327
+
328
+ **Scrolling.**
329
+ - With `scroll: 'main'` the row is pinned.
330
+ - With `scroll: 'page'` it sticks under the topbar, unless `subheader.sticky` is `false`.
331
+ - In `inset` it sits inside the card.
332
+
333
+ `<shell-breadcrumb>` also works on its own, for example inside a page: `[items]`, `[display]`,
334
+ `[maxItems]`, `[itemTemplate]`.
335
+
336
+ ## Slot map
337
+
338
+ ```
339
+ ┌──────────────────────────────────────────────────────────────────────────────┐
340
+ │ ☰ topbar-start brand primary-nav title ··· search actions ⋯ account topbar-end│
341
+ ├──────────────────┬───────────────────────────────────────────────────────────┤
342
+ │ sidebar-header │ breadcrumb · subheader-start ······· subheader-end ⋯ │
343
+ │ sidebar-action ├───────────────────────────────────────────────────────────┤
344
+ │ sidebar-search │ main (<ng-content>) │
345
+ │ sidebar-nav ↕ │ │
346
+ │ sidebar-secondary│ │
347
+ │ sidebar-footer ‹ │ │
348
+ ├──────────────────┴───────────────────────────────────────────────────────────┤
349
+ │ footer-start footer footer-end │
350
+ └──────────────────────────────────────────────────────────────────────────────┘
351
+ ```
352
+
353
+ The sidebar slots follow what Linear, GitHub, Slack, Notion, Vercel, VS Code, Gmail, Jira and
354
+ Shopify agree on:
355
+ 1. identity or workspace switcher
356
+ 2. one primary action
357
+ 3. quick find (usually a command-palette launcher)
358
+ 4. grouped, scrollable navigation — the only part that scrolls
359
+ 5. secondary links (settings, help, upgrade)
360
+ 6. the user card, sharing its row with the collapse control
361
+
362
+ ## Tokens
363
+
364
+ The tokens are defined once as a Sass map. They're emitted as CSS custom properties (`@include shell.tokens`) **and**
365
+ inlined as `var()` fallbacks, so the shell renders without any global stylesheet. You can override
366
+ them anywhere in the cascade. `shell.dark-tokens($selector)` emits a dark palette.
367
+
368
+ | group | tokens |
369
+ | --------- | ------ |
370
+ | structure | `topbar-height` `topbar-padding-inline` `topbar-gap` `sidebar-width` `sidebar-collapsed-width` `sidebar-overlay-width` `sidebar-padding` `sidebar-gap` `footer-min-height` `main-padding` `main-max-width` |
371
+ | colour | `color-scheme` `surface` `surface-raised` `sidebar-surface` `topbar-surface` `fg` `fg-muted` `border` `hover-surface` `active-surface` `accent` `accent-fg` `accent-surface` `focus-ring` `scrim` `shadow-panel` |
372
+ | shape | `radius` `radius-sm` `nav-item-height` `nav-item-gap` `nav-icon-size` `font-family` `font-size` `font-size-sm` |
373
+ | motion | `transition-duration` `transition-easing` |
374
+ | stacking | `z-main` `z-topbar` `z-scrim` `z-sidebar` `z-flyout` `z-skip-link` |
375
+
376
+ ## Theming each part
377
+
378
+ There are three layers, and each one overrides the layer below it:
379
+
380
+ 1. **Global tokens** — `--shell-surface`, `--shell-accent` and the rest of the table above.
381
+ 2. **Component tokens** — `--shell-<part>-<property>`. Each defaults to a global token, so you
382
+ only set what's different:
383
+
384
+ | part | tokens |
385
+ | ------------- | ------ |
386
+ | shell | `bg` (the container behind every part — shows around inset or transparent parts) |
387
+ | topbar | `topbar-bg` `topbar-fg` `topbar-border-width` `topbar-border-style` `topbar-border-color` `topbar-radius` `topbar-shadow` `topbar-blur` |
388
+ | sidebar | `sidebar-bg` `sidebar-fg` `sidebar-border-color` `sidebar-shadow` `sidebar-radius` `sidebar-inset` `sidebar-peek-shadow` |
389
+ | drawer | `drawer-bg` `drawer-shadow` `drawer-radius` `drawer-scrim` `drawer-scrim-blur` |
390
+ | nav | `nav-item-fg` `nav-item-hover-bg` `nav-item-active-bg` `nav-item-active-fg` `nav-item-radius` `nav-indicator-color` `nav-indicator-size` `nav-heading-fg` `nav-heading-size` |
391
+ | ⋯ menu | `menu-bg` `menu-border-color` `menu-shadow` `menu-radius` |
392
+ | main | `main-bg` `main-fg` `main-border-width` `main-border-style` `main-border-color` `main-radius` `main-shadow` `main-margin` |
393
+ | footer | `footer-bg` `footer-fg` `footer-border-color` `footer-shadow` `footer-font-size` |
394
+ | frame (`inset` card) | `frame-bg` `frame-border-width` `frame-border-style` `frame-border-color` `frame-radius` `frame-shadow` `frame-inset` |
395
+ | subheader (breadcrumb row) | `subheader-height` (a minimum) `subheader-bg` `subheader-fg` `subheader-border-width` `subheader-border-style` `subheader-border-color` `subheader-radius` `subheader-margin` `subheader-padding-inline` `subheader-shadow` `subheader-font-size` |
396
+ | breadcrumb | `breadcrumb-fg` `breadcrumb-hover-fg` `breadcrumb-hover-bg` `breadcrumb-current-fg` `breadcrumb-current-weight` `breadcrumb-separator-color` `breadcrumb-gap` `breadcrumb-font-size` `breadcrumb-label-max` `breadcrumb-skeleton-bg` |
397
+
398
+ ```scss
399
+ :root {
400
+ --shell-topbar-bg: #0b1020;
401
+ --shell-topbar-fg: #f5f6fa;
402
+ --shell-sidebar-shadow: 0 10px 30px -12px rgb(0 0 0 / 30%);
403
+ --shell-drawer-duration: 260ms;
404
+
405
+ /* Border tokens take the CSS shorthand: 1–4 values, top right bottom left. */
406
+ --shell-topbar-border-width: 0 0 2px; /* bottom only (the default is 0 0 1px) */
407
+ --shell-topbar-border-color: #6366f1;
408
+ --shell-main-border-width: 1px; /* all four sides */
409
+ --shell-main-border-color: #e4e4e7;
410
+ --shell-main-radius: 12px;
411
+ --shell-main-margin: 0 8px 8px 0; /* inset "card" look */
412
+ --shell-topbar-radius: 0 0 12px 12px; /* bottom corners only */
413
+ --shell-bg: #eef0f4; /* behind everything, shows around the card */
414
+
415
+ /* Breadcrumb row as an inset pill, with a bolder current page. */
416
+ --shell-subheader-margin: 8px 8px 0;
417
+ --shell-subheader-radius: 10px;
418
+ --shell-subheader-border-width: 1px;
419
+ --shell-breadcrumb-current-fg: #4f46e5;
420
+ --shell-breadcrumb-current-weight: 700;
421
+ }
422
+ ```
423
+
424
+ Give them concrete values. Don't alias them on `:root`, as in
425
+ `--shell-topbar-bg: var(--shell-surface)`. An alias is resolved where it's declared, so it
426
+ would stop following theme changes made lower in the page. The shell keeps its own fallbacks
427
+ inside each component for exactly this reason.
428
+ 3. **Presets** — the `appearance` config picks a look for each part. Presets only change the
429
+ tokens' *defaults*, so a component token always wins.
430
+
431
+ ### Motion per part
432
+
433
+ - **Timing:** `--shell-<scope>-duration` and `--shell-<scope>-easing`. Each falls back to
434
+ `--shell-transition-duration` / `-easing`. The scopes are:
435
+ - `topbar`, `sidebar`, `footer`
436
+ - `collapse` (rail ⇄ panel)
437
+ - `drawer` (drawer and scrim)
438
+ - `nav` and `nav-expand`
439
+ - `menu`
440
+ - `subheader` (breadcrumb row and trail)
441
+ - **Kind and on/off:** the `motion` config.
442
+ - **One multiplier:** every duration is multiplied by `--shell-motion-scale`. That's `1 / speed`,
443
+ or 0 when `motion.enabled` is false, so the switch also covers durations you set yourself.
444
+ `prefers-reduced-motion` still wins.
445
+
446
+ ```ts
447
+ provideAppShell({
448
+ appearance: { topbar: 'elevated', sidebar: 'floating', navIndicator: 'bar' },
449
+ motion: { drawer: 'fade', overflowMenu: 'scale', speed: 1.5, navExpand: false },
450
+ });
451
+ ```
452
+
453
+ The scrim and the ⋯ menu stay in the page and toggle `data-open`, so they animate out as well as
454
+ in. This is plain CSS, with no animation library.
455
+
456
+ ## Configuration
457
+
458
+ `provideAppShell(config, { behavior })` at startup; `ShellStore.configure(patch)` at runtime.
459
+
460
+ | key | default | notes |
461
+ | -------------------------- | ---------------------------------- | ---------------------------------------------- |
462
+ | `layout` | `topbar-full` | `sidebar-full`, `stacked`, `inset` (card) |
463
+ | `scroll` | `main` | `page` makes the document scroll |
464
+ | `scrollTopOnNavigate` | `true` | resets `main` scroll after navigation |
465
+ | `initialSize` | `lg` | assumed before the first measurement and on SSR |
466
+ | `breakpoints` | see above | container widths |
467
+ | `topbar.enabled` | `true` | |
468
+ | `topbar.sidebarToggle` | `always` | `auto` = only for drawer/hidden, `never` |
469
+ | `footer.enabled` | `false` | the footer also shows when a footer slot is filled |
470
+ | `sidebar.collapseBelow` | `lg` | rail below this size |
471
+ | `sidebar.overlayBelow` | `md` | drawer below this size |
472
+ | `sidebar.peek`/`peekDelay` | `true` / `180` | hover/focus widens the rail |
473
+ | `sidebar.closeOnNavigate` | `true` | drawer closes on `NavigationEnd` |
474
+ | `sidebar.resizable` | `false` | drag or keyboard; `minWidth`/`maxWidth`; double-click resets |
475
+ | `sidebar.toggleShortcut` | `null` | e.g. `'b'` → Ctrl/⌘+B, ignored while typing |
476
+ | `sidebar.persist` | `true` | intent + width under `<storageKey>.intent/.width` |
477
+ | `appearance.topbar` | `solid` | `elevated`, `blur`, `transparent` (blur/transparent need `scroll: 'page'`) |
478
+ | `appearance.sidebar` | `solid` | `floating`, `borderless` |
479
+ | `appearance.footer` | `solid` | `minimal` |
480
+ | `appearance.navIndicator` | `pill` | `bar`, `none` |
481
+ | `appearance.subheader` | `solid` | `transparent` (blends into the page), `elevated` (shadow instead of the rule) |
482
+ | `motion.enabled` / `speed` | `true` / `1` | master switch; 2 = twice as fast |
483
+ | `motion.drawer` | `slide` | `fade`, `scale`, `none` |
484
+ | `motion.overflowMenu` | `fade` | `scale`, `slide`, `none` |
485
+ | `motion.scrim` | `fade` | `none` |
486
+ | `motion.collapse` | `true` | animate rail ⇄ panel |
487
+ | `motion.navExpand` | `true` | animate nav sections |
488
+ | `subheader.enabled` | `'auto'` | `true` / `false` force it; auto = at least two levels deep, or a filled subheader slot |
489
+ | `subheader.sticky` | `true` | stick under the topbar with `scroll: 'page'` |
490
+ | `subheader.overflowSlots` | `subheader-end` | slots the row's ⋯ menu serves |
491
+ | `breadcrumbs.includeHome` / `homeLabel` / `homeUrl` | `false` / `Home` / `/` | prepend a home crumb |
492
+ | `breadcrumbs.includeCurrent` | `true` | `false` ends the trail at the parent |
493
+ | `breadcrumbs.maxItems` | `4` | beyond it, the middle folds into "…" |
494
+ | `breadcrumbs.separator` | `chevron` | `slash`, `dot` — always skipped by screen readers |
495
+ | `breadcrumbs.compactBelow` / `backBelow` | `480` / `280` | px of the trail's own width |
496
+ | `breadcrumbs.useRouteTitle` / `skeletonDelay` | `true` / `400` | title fallback; placeholder delay in ms |
497
+ | `overflowSlots` | `primary-nav`, `search`, `actions` | slots the topbar's ⋯ menu serves |
498
+ | `labels.*` | English | every string the shell renders |
499
+
500
+ Per-instance overrides: `<app-shell layout="sidebar-full" scroll="page" footer>`.
501
+
502
+ ## `ShellStore` (root, inject anywhere)
503
+
504
+ `size()`, `shellWidth()`, `sidebar()`, `sidebarIntent()`, `sidebarWidth()`, `config()`,
505
+ `behavior()` · `toggleSidebar()`, `openSidebar()`, `closeSidebar()`, `setSidebarIntent()`,
506
+ `setSidebarWidth(px | null)`, `configure(patch)`, `setBehavior(behavior)`.
507
+
508
+ ## Accessibility
509
+
510
+ - A skip link focuses `main`; it doesn't navigate, so it can't trigger the router.
511
+ - Landmarks: `role="banner"` on the topbar, `<nav aria-label>` in the sidebar, `main`, and
512
+ `role="contentinfo"` on the footer.
513
+ - The drawer is `role="dialog" aria-modal="true"`. It traps focus, restores focus on close, and
514
+ closes on Escape, a scrim click or navigation. The page behind it can't scroll.
515
+ - A closed drawer and collapsed nav children are `inert`.
516
+ - Rail items keep a visually hidden label and a tooltip.
517
+ - The breadcrumb follows the WAI-ARIA pattern:
518
+ - a `<nav aria-label="Breadcrumb">` containing an ordered list
519
+ - the current page is text with `aria-current="page"`
520
+ - the separators are CSS, so screen readers don't read them
521
+ - the row sits before `main`, so the skip link jumps past it
522
+ - The resize handle is a `role="separator"` with arrow keys, Home and End.
523
+ - `prefers-reduced-motion` is respected, and the layout supports RTL via logical properties.
524
+ - SSR-safe: `localStorage` sits behind a swappable port, and unmeasured regions render at
525
+ `initialSize`.
526
+
527
+ ## Limits
528
+
529
+ - **One shell per application.** The store, the registries and the element ids are app-wide
530
+ singletons.
531
+ - **Topbar fitting uses the sizes you declare; it doesn't measure content.** The topbar doesn't
532
+ measure each item's width, so unexpectedly long content (translations, many links) can clip.
533
+ There's no "fit as many as possible" algorithm.
534
+ - **Overflow moves whole templates.** For per-link overflow, use one template per link. The
535
+ template must style its own overflow version (`let-overflow`). The ⋯ panel is a simple
536
+ show/hide panel, not an ARIA menu with arrow-key navigation.
537
+ - **Contributions add, they never replace.** A page can't hide the app's contribution to the same
538
+ slot. `slotOrder` numbers are global, so features must agree on them.
539
+ - **Components in a slot template resolve dependencies from where the template is written, not
540
+ where it's rendered.** `inject(SHELL_REGION)` inside a slot template doesn't return the topbar.
541
+ Use `let-region`.
542
+ - **Nav contributions only land in groups with a matching `id`.** Lazy-route providers only run
543
+ after the route has loaded. With `itemTemplate`, the template handles the rail case (`labels`
544
+ false).
545
+ - **Group and section open state isn't persisted.** Active-section detection treats string routes
546
+ as absolute paths.
547
+ - **Breadcrumbs follow the primary router outlet only.** Call `contributeBreadcrumbLabel()` from
548
+ a routed component: it labels that component's route level.
549
+ - **Server-side rendering can shift the layout.** Regions render at `initialSize` until the
550
+ browser measures them.
551
+
552
+ ## Requirements
553
+
554
+ Angular ≥ 19 (`linkedSignal`, `@let`, signal inputs, `provideEnvironmentInitializer`). The
555
+ router is a peer dependency, injected optionally.
556
+
557
+ ## License
558
+
559
+ MIT © Danjel Peqini