@mystravil/hardpoint 0.0.0-stage → 0.1.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.
Files changed (112) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +25 -0
  3. package/README.md +64 -3
  4. package/dist/base.css +346 -0
  5. package/dist/components/AppShell.svelte +698 -0
  6. package/dist/components/AppShell.svelte.d.ts +77 -0
  7. package/dist/components/Button.svelte +174 -0
  8. package/dist/components/Button.svelte.d.ts +20 -0
  9. package/dist/components/Checkbox.svelte +198 -0
  10. package/dist/components/Checkbox.svelte.d.ts +16 -0
  11. package/dist/components/CheckboxGroup.svelte +99 -0
  12. package/dist/components/CheckboxGroup.svelte.d.ts +25 -0
  13. package/dist/components/Combobox.svelte +824 -0
  14. package/dist/components/Combobox.svelte.d.ts +63 -0
  15. package/dist/components/Dialog.svelte +356 -0
  16. package/dist/components/Dialog.svelte.d.ts +43 -0
  17. package/dist/components/Disclosure.svelte +201 -0
  18. package/dist/components/Disclosure.svelte.d.ts +27 -0
  19. package/dist/components/EmptyState.svelte +88 -0
  20. package/dist/components/EmptyState.svelte.d.ts +22 -0
  21. package/dist/components/ErrorState.svelte +135 -0
  22. package/dist/components/ErrorState.svelte.d.ts +26 -0
  23. package/dist/components/KeyValueList.svelte +114 -0
  24. package/dist/components/KeyValueList.svelte.d.ts +43 -0
  25. package/dist/components/LoadRegion.svelte +219 -0
  26. package/dist/components/LoadRegion.svelte.d.ts +27 -0
  27. package/dist/components/MediaFrame.svelte +110 -0
  28. package/dist/components/MediaFrame.svelte.d.ts +20 -0
  29. package/dist/components/MediaImage.svelte +256 -0
  30. package/dist/components/MediaImage.svelte.d.ts +16 -0
  31. package/dist/components/Menu.svelte +258 -0
  32. package/dist/components/Menu.svelte.d.ts +36 -0
  33. package/dist/components/MenuCheckboxItem.svelte +119 -0
  34. package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
  35. package/dist/components/MenuGroup.svelte +50 -0
  36. package/dist/components/MenuGroup.svelte.d.ts +15 -0
  37. package/dist/components/MenuItem.svelte +94 -0
  38. package/dist/components/MenuItem.svelte.d.ts +19 -0
  39. package/dist/components/MenuRadioGroup.svelte +62 -0
  40. package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
  41. package/dist/components/MenuRadioItem.svelte +110 -0
  42. package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
  43. package/dist/components/MenuSeparator.svelte +28 -0
  44. package/dist/components/MenuSeparator.svelte.d.ts +6 -0
  45. package/dist/components/NativeSelect.svelte +119 -0
  46. package/dist/components/NativeSelect.svelte.d.ts +46 -0
  47. package/dist/components/Pagination.svelte +132 -0
  48. package/dist/components/Pagination.svelte.d.ts +18 -0
  49. package/dist/components/Popover.svelte +222 -0
  50. package/dist/components/Popover.svelte.d.ts +35 -0
  51. package/dist/components/Progress.svelte +157 -0
  52. package/dist/components/Progress.svelte.d.ts +34 -0
  53. package/dist/components/Select.svelte +405 -0
  54. package/dist/components/Select.svelte.d.ts +42 -0
  55. package/dist/components/Skeleton.svelte +149 -0
  56. package/dist/components/Skeleton.svelte.d.ts +24 -0
  57. package/dist/components/Spinner.svelte +117 -0
  58. package/dist/components/Spinner.svelte.d.ts +13 -0
  59. package/dist/components/Stat.svelte +253 -0
  60. package/dist/components/Stat.svelte.d.ts +46 -0
  61. package/dist/components/Steps.svelte +189 -0
  62. package/dist/components/Steps.svelte.d.ts +31 -0
  63. package/dist/components/Switch.svelte +222 -0
  64. package/dist/components/Switch.svelte.d.ts +18 -0
  65. package/dist/components/Table.svelte +504 -0
  66. package/dist/components/Table.svelte.d.ts +92 -0
  67. package/dist/components/Tabs.svelte +246 -0
  68. package/dist/components/Tabs.svelte.d.ts +44 -0
  69. package/dist/components/Tag.svelte +40 -0
  70. package/dist/components/Tag.svelte.d.ts +15 -0
  71. package/dist/components/TextField.svelte +256 -0
  72. package/dist/components/TextField.svelte.d.ts +32 -0
  73. package/dist/components/Timeline.svelte +156 -0
  74. package/dist/components/Timeline.svelte.d.ts +30 -0
  75. package/dist/components/Toaster.svelte +411 -0
  76. package/dist/components/Toaster.svelte.d.ts +11 -0
  77. package/dist/components/Tooltip.svelte +200 -0
  78. package/dist/components/Tooltip.svelte.d.ts +38 -0
  79. package/dist/components/TooltipGroup.svelte +46 -0
  80. package/dist/components/TooltipGroup.svelte.d.ts +29 -0
  81. package/dist/components/internal/Scrim.svelte +52 -0
  82. package/dist/components/internal/Scrim.svelte.d.ts +15 -0
  83. package/dist/components/internal/Sheet.svelte +263 -0
  84. package/dist/components/internal/Sheet.svelte.d.ts +36 -0
  85. package/dist/components/internal/fade-in.d.ts +10 -0
  86. package/dist/components/internal/fade-in.js +20 -0
  87. package/dist/components/internal/resolve.d.ts +8 -0
  88. package/dist/components/internal/resolve.js +247 -0
  89. package/dist/components/internal/swipe-dismiss.d.ts +47 -0
  90. package/dist/components/internal/swipe-dismiss.js +220 -0
  91. package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
  92. package/dist/components/internal/visual-viewport.svelte.js +79 -0
  93. package/dist/components/toast.svelte.d.ts +57 -0
  94. package/dist/components/toast.svelte.js +84 -0
  95. package/dist/fonts/OFL-geist-pixel.txt +92 -0
  96. package/dist/fonts/OFL.txt +93 -0
  97. package/dist/fonts/geist-latin-variable.woff2 +0 -0
  98. package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
  99. package/dist/fonts/geist-pixel-square.woff2 +0 -0
  100. package/dist/fonts.css +71 -0
  101. package/dist/index.d.ts +39 -0
  102. package/dist/index.js +39 -0
  103. package/dist/motion/Motion.svelte +15 -0
  104. package/dist/motion/Motion.svelte.d.ts +9 -0
  105. package/dist/motion/context.d.ts +18 -0
  106. package/dist/motion/context.js +26 -0
  107. package/dist/motion/index.d.ts +3 -0
  108. package/dist/motion/index.js +3 -0
  109. package/dist/motion/transitions.d.ts +31 -0
  110. package/dist/motion/transitions.js +112 -0
  111. package/dist/tokens.css +279 -0
  112. package/package.json +76 -6
@@ -0,0 +1,698 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ /**
6
+ * One place the app's navigation leads to. The same list serves every
7
+ * navigation form: the bottom bar or the drawer on compact widths, the
8
+ * rail and the sidebar from 600px.
9
+ */
10
+ export type AppShellDestination = {
11
+ /** Where the link goes. A plain `<a href>`, so any router handles it. */
12
+ href: string;
13
+ /**
14
+ * A short fixed label, set in caps. About six letters fit five across a
15
+ * 320px phone and the 80px rail; a longer label ends in an ellipsis.
16
+ */
17
+ label: string;
18
+ /**
19
+ * A glyph beside or above the label: an inline `<svg>` drawn in
20
+ * `currentColor` with `aria-hidden="true"`. It fills a 20px square, so
21
+ * give it a `viewBox` and no `width` or `height`.
22
+ */
23
+ icon?: Snippet;
24
+ };
25
+
26
+ /**
27
+ * The navigation below 600px: a bottom bar of the destinations, or a
28
+ * drawer that a menu button at the left of the header opens.
29
+ */
30
+ export type AppShellCompactNav = 'bar' | 'drawer';
31
+
32
+ /**
33
+ * The navigation from 900px: a sidebar of rows, or the rail that every
34
+ * width from 600 to 899px shows.
35
+ */
36
+ export type AppShellWideNav = 'sidebar' | 'rail';
37
+
38
+ export type AppShellProps = Omit<HTMLAttributes<HTMLDivElement>, 'title' | 'children'> & {
39
+ /** The view title: a fixed string the app defines, set in caps in the header. */
40
+ title: string;
41
+ /**
42
+ * The left of the header: the app's wordmark, usually a link home. Give
43
+ * a link here at least `--hit-target` height.
44
+ */
45
+ brand?: Snippet;
46
+ /**
47
+ * Page controls: filters or the row of page actions. At the right of the
48
+ * header row from 1200px; below that, their own row under the header.
49
+ */
50
+ controls?: Snippet;
51
+ /**
52
+ * Three to five destinations: the bottom bar or the drawer below 600px,
53
+ * the rail from 600px, the sidebar or the rail from 900px.
54
+ */
55
+ destinations?: AppShellDestination[];
56
+ /**
57
+ * The `href` of the current destination; it gets `aria-current="page"`.
58
+ * The app decides which destination a nested page belongs to.
59
+ */
60
+ current?: string;
61
+ /** Below 600px: `bar` (default) or `drawer`. */
62
+ compactNav?: AppShellCompactNav;
63
+ /** From 900px: `sidebar` (default) or `rail`. */
64
+ wideNav?: AppShellWideNav;
65
+ /** Whether the drawer is open. It closes when the window reaches 600px. */
66
+ drawerOpen?: boolean;
67
+ /** The accessible name of the navigation. */
68
+ navLabel?: string;
69
+ /** The menu button's accessible name and the drawer's caps title. */
70
+ menuLabel?: string;
71
+ /** The accessible name of the drawer's close button. */
72
+ closeLabel?: string;
73
+ /** The id of `<main>`, the skip link's target. */
74
+ mainId?: string;
75
+ /** The skip link's text, shown on keyboard focus. */
76
+ skipLabel?: string;
77
+ /** The view's content, rendered inside `<main>`. */
78
+ children: Snippet;
79
+ };
80
+ </script>
81
+
82
+ <script lang="ts">
83
+ import { Dialog } from 'bits-ui';
84
+ import { MediaQuery } from 'svelte/reactivity';
85
+ import { drawer as slide } from '../motion/index.js';
86
+ import Scrim from './internal/Scrim.svelte';
87
+ import { swipeDismiss } from './internal/swipe-dismiss.js';
88
+
89
+ const uid = $props.id();
90
+
91
+ let {
92
+ title,
93
+ brand,
94
+ controls,
95
+ destinations = [],
96
+ current,
97
+ compactNav = 'bar',
98
+ wideNav = 'sidebar',
99
+ drawerOpen = $bindable(false),
100
+ navLabel = 'Main',
101
+ menuLabel = 'Menu',
102
+ closeLabel = 'Close',
103
+ mainId = 'main',
104
+ skipLabel = 'Skip to content',
105
+ class: className,
106
+ children,
107
+ ...rest
108
+ }: AppShellProps = $props();
109
+
110
+ const nav = $derived(destinations.length > 0);
111
+ const bar = $derived(nav && compactNav === 'bar');
112
+ const withDrawer = $derived(nav && compactNav === 'drawer');
113
+ const drawerTitleId = `${uid}-drawer-title`;
114
+
115
+ // From 600px the rail or the sidebar holds the destinations and the menu
116
+ // button is gone, so a drawer left open closes.
117
+ const wide = new MediaQuery('min-width: 600px', false);
118
+ $effect(() => {
119
+ if (wide.current && drawerOpen) drawerOpen = false;
120
+ });
121
+
122
+ let side = $state<HTMLElement | null>(null);
123
+
124
+ // Closing gives focus back to the menu button, except when the drawer
125
+ // closed because the window reached 600px: that button is hidden, so
126
+ // focus moves to the current destination in the rail or sidebar, or to
127
+ // its first link when none is current.
128
+ function onCloseAutoFocus(e: Event) {
129
+ if (!wide.current || !side) return;
130
+ e.preventDefault();
131
+ (side.querySelector<HTMLElement>('[aria-current="page"]') ?? side.querySelector<HTMLElement>('a'))?.focus();
132
+ }
133
+
134
+ function closeDrawer() {
135
+ drawerOpen = false;
136
+ }
137
+ </script>
138
+
139
+ <!--
140
+ The app frame (BRIEF, visual rule 9): one header row that stays in place,
141
+ with the brand at the left and the view title in caps centred on the page;
142
+ page controls at the right of that row from 1200px and on their own row
143
+ under it below; the view's content in <main>; and the destinations. Below
144
+ 600px they form a bottom bar, or a drawer from a menu button before the
145
+ brand; from 600px a rail under the header at the left; from 900px a
146
+ sidebar there unless the app asks for the rail. Page changes cut: the
147
+ shell stays and only the title, controls and content change.
148
+ -->
149
+ <div
150
+ {...rest}
151
+ class={['hp-app-shell', className]}
152
+ data-controls={controls ? true : undefined}
153
+ data-bar={bar || undefined}
154
+ data-drawer={withDrawer || undefined}
155
+ >
156
+ <a class="skip hp-label" href="#{mainId}">{skipLabel}</a>
157
+
158
+ <header class="hp-app-shell-header">
159
+ <div class="brand">
160
+ {#if withDrawer}
161
+ <Dialog.Root bind:open={drawerOpen}>
162
+ <Dialog.Trigger>
163
+ {#snippet child({ props })}
164
+ <button {...props} type="button" class="hp-app-shell-menu" aria-label={menuLabel}>
165
+ <svg
166
+ viewBox="0 0 16 16"
167
+ fill="none"
168
+ stroke="currentColor"
169
+ stroke-width="1.5"
170
+ stroke-linecap="square"
171
+ aria-hidden="true"
172
+ >
173
+ <path d="M2 3.5h12M2 8h12M2 12.5h12" />
174
+ </svg>
175
+ </button>
176
+ {/snippet}
177
+ </Dialog.Trigger>
178
+ <Dialog.Portal>
179
+ <Dialog.Content forceMount aria-labelledby={drawerTitleId} {onCloseAutoFocus}>
180
+ {#snippet child({ props, open: shown })}
181
+ <Scrim open={shown} layer="dialog" />
182
+ {#if shown}
183
+ <div
184
+ {...props}
185
+ class="hp-app-shell-drawer"
186
+ transition:slide
187
+ {@attach swipeDismiss(closeDrawer, { direction: 'left' })}
188
+ >
189
+ <div class="drawer-head">
190
+ <h2 id={drawerTitleId} class="drawer-title hp-view-title">{menuLabel}</h2>
191
+ <button type="button" class="drawer-close" aria-label={closeLabel} onclick={closeDrawer}>
192
+ <svg
193
+ viewBox="0 0 16 16"
194
+ fill="none"
195
+ stroke="currentColor"
196
+ stroke-width="1.5"
197
+ stroke-linecap="square"
198
+ aria-hidden="true"
199
+ >
200
+ <path d="M3 3l10 10M13 3L3 13" />
201
+ </svg>
202
+ </button>
203
+ </div>
204
+ <nav class="drawer-nav" aria-label={navLabel}>
205
+ {@render links('drawer')}
206
+ </nav>
207
+ </div>
208
+ {/if}
209
+ {/snippet}
210
+ </Dialog.Content>
211
+ </Dialog.Portal>
212
+ </Dialog.Root>
213
+ {/if}
214
+ {@render brand?.()}
215
+ </div>
216
+ <h1 class="hp-app-shell-title hp-view-title">{title}</h1>
217
+ </header>
218
+
219
+ {#if nav}
220
+ <nav bind:this={side} class="hp-app-shell-side" aria-label={navLabel} data-wide={wideNav}>
221
+ {@render links('side')}
222
+ </nav>
223
+ {/if}
224
+
225
+ {#if controls}
226
+ <div class="hp-app-shell-controls">{@render controls()}</div>
227
+ {/if}
228
+
229
+ <main id={mainId} class="hp-app-shell-main" tabindex="-1">
230
+ {@render children()}
231
+ </main>
232
+
233
+ {#if bar}
234
+ <nav class="hp-app-shell-bar" aria-label={navLabel}>
235
+ {@render links('bar')}
236
+ </nav>
237
+ {/if}
238
+ </div>
239
+
240
+ <!--
241
+ The destinations in one form. The selectors below stay flat (a class and
242
+ attributes on the element itself), because these elements render inside
243
+ snippets, some of them in Bits UI's portal.
244
+ -->
245
+ {#snippet links(form: 'bar' | 'side' | 'drawer')}
246
+ <ul class="links" data-form={form}>
247
+ {#each destinations as destination (destination.href)}
248
+ <li>
249
+ <a
250
+ class="link hp-focus-inset"
251
+ data-form={form}
252
+ data-wide={form === 'side' ? wideNav : undefined}
253
+ href={destination.href}
254
+ aria-current={destination.href === current ? 'page' : undefined}
255
+ onclick={form === 'drawer' ? closeDrawer : undefined}
256
+ >
257
+ {#if destination.icon}
258
+ <span class="icon">{@render destination.icon()}</span>
259
+ {/if}
260
+ <span class="text hp-label">{destination.label}</span>
261
+ </a>
262
+ </li>
263
+ {/each}
264
+ </ul>
265
+ {/snippet}
266
+
267
+ <style>
268
+ /* Layers (tokens.css): the header row, the bottom bar and the rail or
269
+ sidebar sit on --layer-shell, over the table's sticky band and under
270
+ every overlay. The controls in the header row (from 1200px) sit one
271
+ step up and the skip link two; the drawer and its scrim are on
272
+ --layer-dialog. */
273
+
274
+ /* ---- Scroll padding: the root element, while a shell is on the page */
275
+
276
+ /* Focus moved to an element stays clear of the pinned bars: the header
277
+ row at every width, the bottom bar on compact. scroll-padding works
278
+ only on the scroller, the root element, so these two rules are the one
279
+ named exception to the :global() convention (user decision
280
+ 2026-11-10). They apply before any script runs, and :where() keeps
281
+ their specificity at 0, so an app's plain html {} rule overrides them
282
+ without !important. */
283
+ :global(:where(html:has(.hp-app-shell))) {
284
+ scroll-padding-top: calc(var(--header-height) + env(safe-area-inset-top) + var(--space-4));
285
+ }
286
+ @media (max-width: 599px) {
287
+ :global(:where(html:has(.hp-app-shell[data-bar]))) {
288
+ scroll-padding-bottom: calc(var(--header-height) + env(safe-area-inset-bottom) + var(--space-4));
289
+ }
290
+ }
291
+
292
+ .hp-app-shell {
293
+ display: grid;
294
+ /* Equal side columns centre the title. Their minimums come from the
295
+ brand and the controls; main and the controls row keep
296
+ min-width: 0, so wide content never stretches the header. */
297
+ grid-template-columns: 1fr auto 1fr;
298
+ grid-template-rows: auto auto 1fr auto;
299
+ grid-template-areas:
300
+ 'header header header'
301
+ 'controls controls controls'
302
+ 'main main main'
303
+ 'bar bar bar';
304
+ /* Phones: no column gaps, since the empty third column would cost the
305
+ title a second one; the title's own padding keeps it off the
306
+ brand. */
307
+ column-gap: 0;
308
+ min-height: 100dvh;
309
+ padding: 0 calc(var(--gutter) + env(safe-area-inset-right)) 0
310
+ calc(var(--gutter) + env(safe-area-inset-left));
311
+ }
312
+ @media (min-width: 600px) {
313
+ .hp-app-shell {
314
+ column-gap: var(--space-8);
315
+ }
316
+ }
317
+ @media (min-width: 1200px) {
318
+ .hp-app-shell {
319
+ column-gap: var(--space-12);
320
+ }
321
+ }
322
+
323
+ /* ---- Skip link: off screen until it takes focus ------------------- */
324
+
325
+ .skip {
326
+ position: fixed;
327
+ top: calc(-2 * var(--space-16));
328
+ left: calc(var(--gutter) + env(safe-area-inset-left));
329
+ z-index: calc(var(--layer-shell) + 2);
330
+ display: flex;
331
+ align-items: center;
332
+ min-height: var(--hit-target);
333
+ padding: var(--space-2) var(--space-4);
334
+ border: 1px solid var(--border-control);
335
+ background: var(--canvas);
336
+ text-decoration: none;
337
+ }
338
+ .skip:focus {
339
+ top: calc(var(--space-2) + env(safe-area-inset-top));
340
+ }
341
+
342
+ /* ---- Header row: pinned, black edge to edge, no rule under it ----- */
343
+
344
+ /* A subgrid on the shell's three columns, so the brand and the title
345
+ share their tracks with the controls. The negative margin takes the
346
+ black into the gutters and safe areas; the padding gives that width
347
+ back, so the items keep the page edges. The header spans the whole
348
+ width over the rail or sidebar, so the title centres on the page. */
349
+ .hp-app-shell-header {
350
+ grid-area: header;
351
+ position: sticky;
352
+ top: 0;
353
+ z-index: var(--layer-shell);
354
+ display: grid;
355
+ grid-template-columns: subgrid;
356
+ align-items: center;
357
+ height: calc(var(--header-height) + env(safe-area-inset-top));
358
+ margin: 0 calc(-1 * (var(--gutter) + env(safe-area-inset-right))) 0
359
+ calc(-1 * (var(--gutter) + env(safe-area-inset-left)));
360
+ padding: env(safe-area-inset-top) calc(var(--gutter) + env(safe-area-inset-right)) 0
361
+ calc(var(--gutter) + env(safe-area-inset-left));
362
+ background: var(--canvas);
363
+ }
364
+
365
+ /* The brand row's 56px leaves even free space around a Geist Pixel
366
+ wordmark's 32px line, so its module lands on whole pixels. */
367
+ .brand {
368
+ grid-column: 1;
369
+ display: flex;
370
+ align-items: center;
371
+ }
372
+
373
+ /* The title truncates before it pushes the side columns. */
374
+ .hp-app-shell-title {
375
+ grid-column: 2;
376
+ min-width: 0;
377
+ overflow: hidden;
378
+ text-overflow: ellipsis;
379
+ white-space: nowrap;
380
+ }
381
+ /* Phones: when there is no room to centre it, the title follows the
382
+ brand at this distance. */
383
+ @media (max-width: 599px) {
384
+ .hp-app-shell-title {
385
+ padding-left: var(--space-5);
386
+ }
387
+ }
388
+
389
+ /* ---- Menu button and drawer: compact widths, when asked ---------- */
390
+
391
+ /* Before the brand. The glyph, not the box, keeps the page edge, so the
392
+ box reaches into the gutter. */
393
+ .hp-app-shell-menu,
394
+ .drawer-close {
395
+ display: grid;
396
+ flex: none;
397
+ place-items: center;
398
+ /* 36px on fine pointers, 44px on coarse. */
399
+ width: var(--control-height);
400
+ height: var(--control-height);
401
+ padding: 0;
402
+ border: 0;
403
+ background: transparent;
404
+ color: var(--text);
405
+ cursor: pointer;
406
+ -webkit-tap-highlight-color: transparent;
407
+ }
408
+ .hp-app-shell-menu {
409
+ margin: 0 var(--space-2) 0 calc((var(--space-4) - var(--control-height)) / 2);
410
+ }
411
+ .hp-app-shell-menu svg,
412
+ .drawer-close svg {
413
+ width: var(--space-4);
414
+ height: var(--space-4);
415
+ }
416
+ .hp-app-shell-menu:active,
417
+ .drawer-close:active {
418
+ background: var(--surface-raised);
419
+ }
420
+ @media (hover: hover) and (pointer: fine) {
421
+ .hp-app-shell-menu,
422
+ .drawer-close {
423
+ transition: background-color var(--dur-hover) var(--settle);
424
+ }
425
+ .hp-app-shell-menu:hover,
426
+ .drawer-close:hover {
427
+ background: var(--surface-hover);
428
+ }
429
+ .hp-app-shell-menu:active,
430
+ .drawer-close:active {
431
+ background: var(--surface-raised);
432
+ transition-duration: var(--dur-press);
433
+ }
434
+ }
435
+ /* From 600px the rail or the sidebar takes over. */
436
+ @media (min-width: 600px) {
437
+ .hp-app-shell-menu {
438
+ display: none;
439
+ }
440
+ }
441
+
442
+ /* A black panel from the left edge, full height, leaving a strip of the
443
+ page beside it to tap; a 3:1 boundary on its open side, like the
444
+ centred dialog's. It slides in at the sheet timing (motion/drawer). A
445
+ swipe to the left closes it (internal/swipe-dismiss.ts): the browser
446
+ keeps vertical scrolling and pinch zoom, and sideways moves reach the
447
+ swipe. */
448
+ .hp-app-shell-drawer {
449
+ position: fixed;
450
+ top: 0;
451
+ bottom: 0;
452
+ left: 0;
453
+ z-index: var(--layer-dialog);
454
+ display: flex;
455
+ flex-direction: column;
456
+ box-sizing: border-box;
457
+ width: min(
458
+ var(--drawer-width) + env(safe-area-inset-left),
459
+ 100% - var(--header-height)
460
+ );
461
+ padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom) env(safe-area-inset-left);
462
+ border-right: 1px solid var(--border-control);
463
+ background: var(--canvas);
464
+ color: var(--text);
465
+ overscroll-behavior: contain;
466
+ touch-action: pan-y pinch-zoom;
467
+ /* Bits UI may have turned pointer events off on <body>. */
468
+ pointer-events: auto;
469
+ }
470
+
471
+ /* The header row's height: the caps title at the page edge, the close
472
+ button at the end. */
473
+ .drawer-head {
474
+ flex: none;
475
+ display: grid;
476
+ grid-template-columns: minmax(0, 1fr) auto;
477
+ align-items: center;
478
+ gap: var(--space-2);
479
+ min-height: var(--header-height);
480
+ padding: 0 var(--space-1) 0 var(--gutter);
481
+ }
482
+ .drawer-title {
483
+ overflow: hidden;
484
+ text-overflow: ellipsis;
485
+ white-space: nowrap;
486
+ }
487
+
488
+ /* The list scrolls inside the panel when it is taller than the screen. */
489
+ .drawer-nav {
490
+ flex: 1 1 auto;
491
+ min-height: 0;
492
+ padding: var(--space-2) 0 var(--space-4);
493
+ overflow-y: auto;
494
+ overscroll-behavior: contain;
495
+ }
496
+
497
+ /* ---- Rail and sidebar: from 600px -------------------------------- */
498
+
499
+ /* Pinned under the header row at the left, down to the bottom of the
500
+ window; the content keeps a gutter to its right. Nothing frames it:
501
+ the space between it and the content groups it. */
502
+ .hp-app-shell-side {
503
+ display: none;
504
+ }
505
+ @media (min-width: 600px) {
506
+ .hp-app-shell-side {
507
+ position: fixed;
508
+ top: calc(var(--header-height) + env(safe-area-inset-top));
509
+ bottom: 0;
510
+ left: 0;
511
+ z-index: var(--layer-shell);
512
+ display: block;
513
+ box-sizing: border-box;
514
+ width: calc(var(--rail-width) + env(safe-area-inset-left));
515
+ padding: var(--space-4) 0 calc(var(--space-4) + env(safe-area-inset-bottom))
516
+ env(safe-area-inset-left);
517
+ overflow-y: auto;
518
+ overscroll-behavior: contain;
519
+ background: var(--canvas);
520
+ }
521
+ .hp-app-shell-side ~ .hp-app-shell-controls,
522
+ .hp-app-shell-side ~ .hp-app-shell-main {
523
+ margin-left: var(--rail-width);
524
+ }
525
+ }
526
+ @media (min-width: 900px) {
527
+ .hp-app-shell-side[data-wide='sidebar'] {
528
+ width: calc(var(--sidebar-width) + env(safe-area-inset-left));
529
+ }
530
+ .hp-app-shell-side[data-wide='sidebar'] ~ .hp-app-shell-controls,
531
+ .hp-app-shell-side[data-wide='sidebar'] ~ .hp-app-shell-main {
532
+ margin-left: var(--sidebar-width);
533
+ }
534
+ }
535
+
536
+ /* ---- Page controls: their own row under the header below 1200px -- */
537
+
538
+ .hp-app-shell-controls {
539
+ grid-area: controls;
540
+ min-width: 0;
541
+ padding-bottom: var(--space-4);
542
+ }
543
+ @media (min-width: 1200px) {
544
+ /* In the header row's right column, pinned with it and painted over
545
+ its black (later in the DOM, one layer up). */
546
+ .hp-app-shell-controls,
547
+ .hp-app-shell-side ~ .hp-app-shell-controls,
548
+ .hp-app-shell-side[data-wide='sidebar'] ~ .hp-app-shell-controls {
549
+ grid-area: 1 / 3;
550
+ position: sticky;
551
+ top: 0;
552
+ z-index: calc(var(--layer-shell) + 1);
553
+ display: flex;
554
+ align-items: center;
555
+ justify-content: flex-end;
556
+ height: calc(var(--header-height) + env(safe-area-inset-top));
557
+ margin-left: 0;
558
+ padding: env(safe-area-inset-top) 0 0;
559
+ min-width: auto;
560
+ }
561
+ }
562
+
563
+ /* ---- Main -------------------------------------------------------- */
564
+
565
+ .hp-app-shell-main {
566
+ grid-area: main;
567
+ min-width: 0;
568
+ padding: var(--space-4) 0 calc(var(--space-16) + env(safe-area-inset-bottom));
569
+ }
570
+ .hp-app-shell-main:focus {
571
+ outline: none;
572
+ }
573
+
574
+ /* ---- Bottom bar: compact widths only ----------------------------- */
575
+
576
+ /* The last grid row, pinned to the bottom of the viewport while the
577
+ page scrolls. Black edge to edge like the header row, with the
578
+ bottom and side safe areas inside it. */
579
+ .hp-app-shell-bar {
580
+ grid-area: bar;
581
+ position: sticky;
582
+ bottom: 0;
583
+ z-index: var(--layer-shell);
584
+ margin: 0 calc(-1 * (var(--gutter) + env(safe-area-inset-right))) 0
585
+ calc(-1 * (var(--gutter) + env(safe-area-inset-left)));
586
+ padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
587
+ background: var(--canvas);
588
+ }
589
+ /* The bar carries the bottom safe area, so main does not. */
590
+ @media (max-width: 599px) {
591
+ .hp-app-shell[data-bar] .hp-app-shell-main {
592
+ padding-bottom: var(--space-16);
593
+ }
594
+ }
595
+ /* From 600px the rail or the sidebar takes over. */
596
+ @media (min-width: 600px) {
597
+ .hp-app-shell-bar {
598
+ display: none;
599
+ }
600
+ }
601
+
602
+ /* ---- Destinations, in every form --------------------------------- */
603
+
604
+ .links {
605
+ margin: 0;
606
+ padding: 0;
607
+ list-style: none;
608
+ }
609
+ .links[data-form='bar'] {
610
+ display: grid;
611
+ grid-auto-columns: minmax(0, 1fr);
612
+ grid-auto-flow: column;
613
+ }
614
+
615
+ .link {
616
+ display: flex;
617
+ align-items: center;
618
+ color: var(--muted);
619
+ text-decoration: none;
620
+ -webkit-tap-highlight-color: transparent;
621
+ }
622
+
623
+ /* Bottom bar and rail: a whole cell, the header row's height (56px),
624
+ with the icon above the label. In the bar each cell is an equal
625
+ share of the width, so every target is at least 44px on a 320px
626
+ phone with five destinations; in the rail it is the rail's 80px. */
627
+ .link[data-form='bar'],
628
+ .link[data-form='side'] {
629
+ flex-direction: column;
630
+ justify-content: center;
631
+ gap: var(--space-1);
632
+ min-height: var(--header-height);
633
+ padding: var(--space-2) var(--space-1);
634
+ }
635
+
636
+ /* Drawer and sidebar: a row, the icon on the page edge (under the
637
+ brand) and the label after it; the row a control's height. */
638
+ .link[data-form='drawer'] {
639
+ gap: var(--space-3);
640
+ min-height: var(--control-height);
641
+ padding: var(--space-2) var(--gutter);
642
+ }
643
+ @media (min-width: 900px) {
644
+ .link[data-form='side'][data-wide='sidebar'] {
645
+ flex-direction: row;
646
+ justify-content: flex-start;
647
+ gap: var(--space-3);
648
+ min-height: var(--control-height);
649
+ padding: var(--space-2) var(--gutter);
650
+ }
651
+ }
652
+
653
+ /* A 20px square; the app's <svg> fills it (base.css makes it a block of
654
+ at most 100% width, and its viewBox sets the height). */
655
+ .icon {
656
+ display: block;
657
+ flex: none;
658
+ width: var(--space-5);
659
+ height: var(--space-5);
660
+ }
661
+
662
+ /* A label too long for its cell or row ends in an ellipsis rather than
663
+ widening the navigation. */
664
+ .text {
665
+ min-width: 0;
666
+ max-width: 100%;
667
+ overflow: hidden;
668
+ text-overflow: ellipsis;
669
+ white-space: nowrap;
670
+ }
671
+
672
+ /* The current destination: the selection mark, a 3px titanium edge and
673
+ the selected tint. In the bar the edge is on the top, facing the
674
+ content; in the vertical lists it is on the left, as on a selected
675
+ table row. It snaps: nothing on the navigation transitions. */
676
+ .link[aria-current='page'] {
677
+ background: var(--surface-selected);
678
+ box-shadow: inset 3px 0 0 var(--accent);
679
+ color: var(--text);
680
+ }
681
+ .link[data-form='bar'][aria-current='page'] {
682
+ box-shadow: inset 0 3px 0 var(--accent);
683
+ }
684
+
685
+ @media (hover: hover) and (pointer: fine) {
686
+ .link:hover {
687
+ color: var(--text);
688
+ }
689
+ .link:not([aria-current='page']):hover {
690
+ background: var(--surface-hover);
691
+ }
692
+ }
693
+
694
+ /* Press: one step past hover, as on buttons, so it reads on a phone. */
695
+ .link:active {
696
+ background: var(--surface-raised);
697
+ }
698
+ </style>