@jelinek/ui 0.3.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 (84) hide show
  1. package/README.md +492 -0
  2. package/dist/components/Alert.svelte +59 -0
  3. package/dist/components/Alert.svelte.d.ts +13 -0
  4. package/dist/components/Button.svelte +178 -0
  5. package/dist/components/Button.svelte.d.ts +19 -0
  6. package/dist/components/Card.svelte +68 -0
  7. package/dist/components/Card.svelte.d.ts +11 -0
  8. package/dist/components/ChapterNav.svelte +290 -0
  9. package/dist/components/ChapterNav.svelte.d.ts +18 -0
  10. package/dist/components/Checkbox.svelte +52 -0
  11. package/dist/components/Checkbox.svelte.d.ts +11 -0
  12. package/dist/components/CodeCopy.svelte +294 -0
  13. package/dist/components/CodeCopy.svelte.d.ts +51 -0
  14. package/dist/components/Container.svelte +34 -0
  15. package/dist/components/Container.svelte.d.ts +10 -0
  16. package/dist/components/ContentLogo.svelte +151 -0
  17. package/dist/components/ContentLogo.svelte.d.ts +37 -0
  18. package/dist/components/Demo.svelte +111 -0
  19. package/dist/components/Demo.svelte.d.ts +12 -0
  20. package/dist/components/Eyebrow.svelte +70 -0
  21. package/dist/components/Eyebrow.svelte.d.ts +12 -0
  22. package/dist/components/Glass.svelte +75 -0
  23. package/dist/components/Glass.svelte.d.ts +10 -0
  24. package/dist/components/Grid.svelte +87 -0
  25. package/dist/components/Grid.svelte.d.ts +11 -0
  26. package/dist/components/Label.svelte +37 -0
  27. package/dist/components/Label.svelte.d.ts +11 -0
  28. package/dist/components/PageHero.svelte +312 -0
  29. package/dist/components/PageHero.svelte.d.ts +55 -0
  30. package/dist/components/Pager.svelte +98 -0
  31. package/dist/components/Pager.svelte.d.ts +13 -0
  32. package/dist/components/Panel.svelte +89 -0
  33. package/dist/components/Panel.svelte.d.ts +12 -0
  34. package/dist/components/ProductCard.svelte +81 -0
  35. package/dist/components/ProductCard.svelte.d.ts +18 -0
  36. package/dist/components/Radio.svelte +54 -0
  37. package/dist/components/Radio.svelte.d.ts +11 -0
  38. package/dist/components/RadioGroup.svelte +42 -0
  39. package/dist/components/RadioGroup.svelte.d.ts +12 -0
  40. package/dist/components/Select.svelte +65 -0
  41. package/dist/components/Select.svelte.d.ts +20 -0
  42. package/dist/components/SiteFooter.svelte +121 -0
  43. package/dist/components/SiteFooter.svelte.d.ts +13 -0
  44. package/dist/components/SiteHeader.svelte +613 -0
  45. package/dist/components/SiteHeader.svelte.d.ts +80 -0
  46. package/dist/components/Slider.svelte +55 -0
  47. package/dist/components/Slider.svelte.d.ts +14 -0
  48. package/dist/components/Swatch.svelte +174 -0
  49. package/dist/components/Swatch.svelte.d.ts +58 -0
  50. package/dist/components/Tab.svelte +88 -0
  51. package/dist/components/Tab.svelte.d.ts +11 -0
  52. package/dist/components/Tabs.svelte +306 -0
  53. package/dist/components/Tabs.svelte.d.ts +20 -0
  54. package/dist/components/Tag.svelte +71 -0
  55. package/dist/components/Tag.svelte.d.ts +16 -0
  56. package/dist/components/TextField.svelte +100 -0
  57. package/dist/components/TextField.svelte.d.ts +18 -0
  58. package/dist/components/ThemeToggle.svelte +122 -0
  59. package/dist/components/ThemeToggle.svelte.d.ts +11 -0
  60. package/dist/components/Tile.svelte +115 -0
  61. package/dist/components/Tile.svelte.d.ts +14 -0
  62. package/dist/components/TileScroller.svelte +199 -0
  63. package/dist/components/TileScroller.svelte.d.ts +25 -0
  64. package/dist/components/container-context.d.ts +1 -0
  65. package/dist/components/container-context.js +6 -0
  66. package/dist/components/deer-mark-path.d.ts +16 -0
  67. package/dist/components/deer-mark-path.js +16 -0
  68. package/dist/components/nav.d.ts +25 -0
  69. package/dist/components/nav.js +17 -0
  70. package/dist/components/radio-context.d.ts +6 -0
  71. package/dist/components/radio-context.js +1 -0
  72. package/dist/components/tabs-context.d.ts +5 -0
  73. package/dist/components/tabs-context.js +1 -0
  74. package/dist/index.d.ts +37 -0
  75. package/dist/index.js +37 -0
  76. package/dist/theme/base.css +439 -0
  77. package/dist/theme/extras.css +2 -0
  78. package/dist/theme/fonts.css +107 -0
  79. package/dist/theme/tokens.css +208 -0
  80. package/dist/utils/chapter-nav-dock.svelte.d.ts +23 -0
  81. package/dist/utils/chapter-nav-dock.svelte.js +65 -0
  82. package/dist/utils/cn.d.ts +2 -0
  83. package/dist/utils/cn.js +27 -0
  84. package/package.json +79 -0
@@ -0,0 +1,613 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { cn } from '../utils/cn.js';
5
+ import Button from './Button.svelte';
6
+ import { createChapterNavDock } from '../utils/chapter-nav-dock.svelte.js';
7
+ import { DEER_MARK_PATH } from './deer-mark-path.js';
8
+ import { isActive, type NavItem } from './nav.js';
9
+
10
+ interface Props {
11
+ /**
12
+ * Escape hatch: a snippet rendering custom mark content in place of the
13
+ * bundled, re-theming deer mark (e.g. a division-specific logo). Most
14
+ * consumers should leave this unset and get the guide's own mark.
15
+ */
16
+ logo?: Snippet;
17
+ logoAlt?: string;
18
+ /**
19
+ * Where the logo/mark links to. Defaults to `/`, the guide's own
20
+ * literal href (partials/header.html:3) — set this when the consuming
21
+ * app is not rooted at `/`, e.g. mounted under a base path.
22
+ */
23
+ logoHref?: string;
24
+ subtitle?: string;
25
+ items?: NavItem[];
26
+ active?: string;
27
+ /**
28
+ * Set when a `ChapterNav` is rendered directly below this header, to
29
+ * reproduce the guide's joined-panel seam (jelinek.css:604-620): the
30
+ * header loses its bottom border/corners/shadow so it flows into the
31
+ * nav strip, until the reader scrolls past 50px and it detaches into
32
+ * its own rounded, shadowed bar (assets/js/jelinek.js:354). SiteHeader
33
+ * tracks that scroll threshold itself — a consumer never has to wire
34
+ * up a listener. Leave unset (default `false`) for the normal,
35
+ * always-detached header, e.g. the rozcestník, which has no chapter
36
+ * nav at all.
37
+ */
38
+ hasChapterNav?: boolean;
39
+ /**
40
+ * Accessible name for the `<nav>` landmark holding the top-level
41
+ * links. Czech by default, like every other user-facing string this
42
+ * kit ships (see Pager's `prevLabel`/`nextLabel`).
43
+ */
44
+ navLabel?: string;
45
+ /**
46
+ * Chapter links to repeat inside the phone menu panel. Below 640px the
47
+ * guide's chapter bar is `display: none` (see ChapterNav.svelte), so the
48
+ * chapters have nowhere else to live on a phone; the guide's own script
49
+ * clones them out of the rendered bar, and this prop is that same content
50
+ * passed declaratively instead. Leave unset on pages with no chapter nav
51
+ * (the rozcestník) — the divider and the block disappear with it. They
52
+ * are hidden again from 640px up, where the bar itself is back.
53
+ */
54
+ chapterItems?: NavItem[];
55
+ /**
56
+ * Accessible name for the phone hamburger button while the menu is SHUT.
57
+ * It has no visible text — it is three bars that fold into a cross — so
58
+ * this is the only name a screen reader gets. See BURGER below.
59
+ */
60
+ menuLabel?: string;
61
+ /** Accessible name for the same button while the menu is OPEN. */
62
+ menuLabelClose?: string;
63
+ /**
64
+ * Task 37 — TRAILING CONTENT INSIDE THE NAV, which the guide requires and
65
+ * this component previously refused.
66
+ *
67
+ * partials/header.html:6-13 nests the theme toggle as the LAST CHILD of
68
+ * `.site-header__nav`, so it sits in the nav row on a desktop and moves
69
+ * into the burger panel on a phone along with the links. The earlier
70
+ * decision here was to keep ThemeToggle separate and let a consumer
71
+ * "render both together in its own layout" — but a consumer cannot put
72
+ * anything inside this component's nav from outside it, so what that
73
+ * actually produced was a toggle stranded in the page body, at a different
74
+ * place from the guide and gone from the phone menu entirely.
75
+ *
76
+ * Rendered into BOTH navs, after the links, exactly like `items`. Keep it
77
+ * to controls that belong to the header (the toggle, a division switch) —
78
+ * it is not a general slot.
79
+ */
80
+ actions?: Snippet;
81
+ class?: string;
82
+ }
83
+
84
+ let {
85
+ logo,
86
+ logoAlt = 'JELÍNEK',
87
+ logoHref = '/',
88
+ subtitle,
89
+ items = [],
90
+ active,
91
+ hasChapterNav = false,
92
+ chapterItems = [],
93
+ navLabel = 'Hlavní navigace',
94
+ menuLabel = 'Otevřít menu',
95
+ menuLabelClose = 'Zavřít menu',
96
+ actions,
97
+ class: className,
98
+ ...rest
99
+ }: Props & HTMLAttributes<HTMLElement> = $props();
100
+
101
+ // Task 15 — see chapter-nav-dock.svelte.ts for the full reasoning (the
102
+ // :root:has()/scroll-linked-<html>-class trick this replaces, and why the
103
+ // scroll tracking lives in its own module). `dock.docked` is true only
104
+ // while BOTH a chapter nav is present (hasChapterNav) AND the page hasn't
105
+ // scrolled past 50px — reproduces
106
+ // `:root:has(.chapter-nav):not(.is-subnav-scrolled)` without reaching
107
+ // outside this component.
108
+ const dock = createChapterNavDock(() => hasChapterNav);
109
+
110
+ // Task 26 — phone disclosure state. See MENU_BUTTON below for why the
111
+ // links collapse at all. `$props.id()` gives the panel a per-instance id
112
+ // so `aria-controls` still resolves when a page renders more than one
113
+ // header (a showcase or a docs page embedding the component).
114
+ const uid = $props.id();
115
+ const panelId = `${uid}-links`;
116
+ let menuOpen = $state(false);
117
+ let menuButton = $state<HTMLButtonElement | null>(null);
118
+ let linksEl = $state<HTMLElement | null>(null);
119
+
120
+ // Closing the panel while focus is still inside it would drop focus onto
121
+ // <body>, restarting keyboard navigation from the top of the page. Every
122
+ // close path goes through here so that cannot happen — Escape, a click
123
+ // outside, and the breakpoint crossing below.
124
+ function closeMenu() {
125
+ if (linksEl?.contains(document.activeElement)) {
126
+ const button = menuButton;
127
+ button?.focus();
128
+ // A pointerdown-driven close is not finished when this runs: the
129
+ // browser's own mousedown focus handling comes next and overwrites
130
+ // what we just set — with the clicked element if it is focusable, or
131
+ // with <body> if it is not. Reclaim focus on the following task, but
132
+ // only from <body>: that is the actual defect (keyboard navigation
133
+ // restarting from the top of the document), and a real click on a
134
+ // real control should keep the focus it earned.
135
+ setTimeout(() => {
136
+ if (document.activeElement === document.body) button?.focus();
137
+ }, 0);
138
+ }
139
+ menuOpen = false;
140
+ }
141
+
142
+ // Dismissal, matching assets/js/jelinek.js's own initHeaderMenu().
143
+ // `pointerdown`, not `click`: iOS Safari does not propagate a click from an
144
+ // element that is not itself clickable up to `document`, so a tap on body
145
+ // text would leave the panel open. Listeners exist only while the menu is
146
+ // open — nothing is attached on a desktop render, or on a phone render with
147
+ // the menu shut.
148
+ $effect(() => {
149
+ if (!menuOpen) return;
150
+ function onPointerDown(event: PointerEvent) {
151
+ const target = event.target as Node | null;
152
+ if (target && (linksEl?.contains(target) || menuButton?.contains(target))) return;
153
+ closeMenu();
154
+ }
155
+ function onKeyDown(event: KeyboardEvent) {
156
+ if (event.key !== 'Escape') return;
157
+ menuButton?.focus();
158
+ menuOpen = false;
159
+ }
160
+ document.addEventListener('pointerdown', onPointerDown);
161
+ document.addEventListener('keydown', onKeyDown);
162
+ return () => {
163
+ document.removeEventListener('pointerdown', onPointerDown);
164
+ document.removeEventListener('keydown', onKeyDown);
165
+ };
166
+ });
167
+
168
+ // From 640px up the button is display:none and the links are back in the
169
+ // row. Leaving `menuOpen` true across that boundary means the panel springs
170
+ // open again unasked on the way back down, and `aria-expanded="true"`
171
+ // lingers on a hidden button in between.
172
+ $effect(() => {
173
+ const wide = window.matchMedia('(min-width: 640px)');
174
+ // Plain write, not closeMenu(), for the mount case: on mount nothing can
175
+ // have focus inside a panel that has never been open, and calling
176
+ // closeMenu() here would read `linksEl`/`menuButton` inside the effect
177
+ // body and make this effect re-run every time those bindings settle.
178
+ if (wide.matches) menuOpen = false;
179
+ function onChange() {
180
+ if (wide.matches) closeMenu();
181
+ }
182
+ wide.addEventListener('change', onChange);
183
+ return () => wide.removeEventListener('change', onChange);
184
+ });
185
+
186
+ // jelinek.css:442-476 (.site-header and its descendants) plus the dark
187
+ // treatment at :276-278. Per direction, the guide is reproduced as this
188
+ // component's own default — floating positioning, blur and all — not
189
+ // softened into something a consumer must opt into. A consumer who needs
190
+ // different positioning has `class` (merged last via cn()) to override
191
+ // `absolute`/`fixed`/`top-0`/etc.
192
+ //
193
+ // The `min-[620.02px]:` epsilon this file used to carry is GONE, and has been
194
+ // since Task 26: it only ever existed to convert the guide's INCLUSIVE
195
+ // `@media (max-width: 620px)` into a mobile-first `min-width` without leaving
196
+ // a gap (Tailwind's `max-[620px]:` compiles to the exclusive
197
+ // `width < 620px`). The guide declares the phone layout unconditionally and
198
+ // enhances it in `@media (min-width: 640px)`, so `min-[640px]:` is an EXACT
199
+ // translation. Task 30 moved the boundary from 621 to 640, onto the
200
+ // designer's 640/1024/1440 ladder.
201
+ //
202
+ // TASK 33 — THE PHONE HEADER IS THE DESIGNER'S, AND IT IS A DIFFERENT SHAPE.
203
+ // Three of his commits land here (36354b9, 6750ee8, 74ef043, a430f9c) and
204
+ // they supersede this component's previous phone treatment wholesale:
205
+ //
206
+ // POSITION. `absolute inset-x-0 top-0 z-50`, not `sticky`. His 74ef043 made
207
+ // this change deliberately and says why: with the bar in flow (or sticky at
208
+ // the top) the hero's grey band started BELOW it, leaving a visible seam in
209
+ // the page background right above the bar. Absolute takes the bar out of
210
+ // flow so the band runs under it. The cost is that the bar scrolls away on a
211
+ // phone (it did not while it was sticky) and that the hero must now reserve
212
+ // `--header-h` at every width — both accepted with the design; PageHero and
213
+ // +layout.svelte carry the matching padding change.
214
+ //
215
+ // SIZE. There is no separate smaller phone bar any more. `--logo-mark` is
216
+ // 46px at EVERY width (it was 36px on a phone), the outer gutter is ZERO on a
217
+ // phone (`p-0`, so the bar spans the full viewport edge to edge) and 12px/24px
218
+ // from 640px up, and the inner padding is a flat `12px 24px` throughout. Bar
219
+ // height on a phone is therefore 46 + 2×12 + 1 (bottom border) = 71px, which
220
+ // is what `--header-h: 70px` reserves.
221
+ //
222
+ // FRAME. On a phone the bar is a full-width strip, not a floating card: no
223
+ // max-width (`max-w-none`), square corners, and only a BOTTOM hairline
224
+ // (`border-x-0 border-t-0`) — his a430f9c. From 640px up it becomes the
225
+ // floating card again: `max-w-page`, all four borders, `rounded-card`.
226
+ //
227
+ // INNER (.site-header__inner): flex gap computed from --logo-mark
228
+ // (`calc(var(--logo-mark) * 107 / 400)` — the mark's own right-paroh
229
+ // margin), centred with `mx-auto`, its OWN padding (a distinct box from the
230
+ // outer gutter — both are real), and the glass.
231
+ //
232
+ // Task 31 — THE GLASS IS THE SHARED TOKEN SET, not this component's own
233
+ // values: `background: var(--glass-fill); backdrop-filter:
234
+ // var(--glass-filter); border: 1px solid var(--glass-border)`, the same three
235
+ // tokens .glassmorphism, .chapter-nav__inner and .is-sticky use. Every value
236
+ // moved there (fill from a hand-written rgba pair per theme to one flipping
237
+ // token, filter from `blur(20px) brightness(150%)`/`brightness(40%)` to
238
+ // `blur(25px) saturate(140%)` in both themes, border from --border-subtle to
239
+ // the deliberately lighter --glass-border). See Glass.svelte for why the
240
+ // filter is written as an arbitrary property pair rather than
241
+ // backdrop-blur-*/backdrop-saturate-* (token tracking — a literal here would
242
+ // drift with no gate to catch it). The dark hairline got softer again in his
243
+ // 54082b9 (--glass-border 25% white in dark); that is inside the token, so
244
+ // nothing changes here.
245
+ //
246
+ // THE JOINED-PANEL SEAM IS NOW ≥640px ONLY. jelinek.css:611-628 moved the
247
+ // whole `:root:has(.chapter-nav):not(.is-subnav-scrolled)` block inside
248
+ // `@media (min-width: 640px)`, because on a phone there is nothing to join
249
+ // to: the chapter bar is hidden and the header is already a square-cornered
250
+ // full-width strip. So INNER_JOINED/INNER_DETACHED are min-[640px]-scoped,
251
+ // and the phone frame above is unconditional.
252
+ //
253
+ // The dark inset top highlight (`inset 0 2px 1px -1px var(--box-primary)`,
254
+ // :278) still applies in EITHER case at every width — see :627, where the
255
+ // dark joined case keeps this same inset instead of dropping to
256
+ // `shadow: none`. TASK 33: --box-primary is now a real passthrough token
257
+ // (his 4ec82cd promoted it to one neutral `:root` value, hsl(0,0%,50%), and
258
+ // deleted the `.site-header__inner { --box-primary: var(--JELINEK-BLACK) }`
259
+ // local this file used to substitute). The old stand-in,
260
+ // var(--color-jelinek-black), is now WRONG rather than equivalent:
261
+ // --JELINEK-BLACK inverts to hsl(0,0%,95%) in dark mode, so it would paint a
262
+ // near-white hairline where the guide asks for 50% grey.
263
+ //
264
+ // Space reservation below this header is still the consuming app's job —
265
+ // SiteHeader/ChapterNav never add `padding-top` to a sibling
266
+ // (`--header-h`/`--subnav-h` are exported passthrough tokens precisely so a
267
+ // consumer's own layout can do that). PageHero and the showcase's
268
+ // `+layout.svelte` model exactly that.
269
+ const OUTER =
270
+ 'absolute inset-x-0 top-0 z-50 p-0 [--logo-mark:46px] ' +
271
+ 'min-[640px]:fixed min-[640px]:px-6 min-[640px]:py-3';
272
+ const INNER_BASE =
273
+ 'mx-auto flex max-w-none items-center gap-[calc(var(--logo-mark)*107/400)] ' +
274
+ 'border border-x-0 border-t-0 border-[var(--glass-border)] rounded-none ' +
275
+ 'bg-[var(--glass-fill)] px-6 py-3 shadow-resting ' +
276
+ 'dark:shadow-[var(--shadow-resting),inset_0_2px_1px_-1px_var(--box-primary)] ' +
277
+ '[-webkit-backdrop-filter:var(--glass-filter)] [backdrop-filter:var(--glass-filter)] ' +
278
+ 'min-[640px]:max-w-page min-[640px]:border-x min-[640px]:border-t ' +
279
+ 'min-[640px]:rounded-card';
280
+ const INNER_JOINED =
281
+ 'min-[640px]:rounded-b-none min-[640px]:border-b-0 min-[640px]:shadow-none ' +
282
+ 'min-[640px]:dark:shadow-[inset_0_2px_1px_-1px_var(--box-primary)]';
283
+ const INNER_DETACHED = '';
284
+ const inner = $derived(cn(INNER_BASE, dock.docked ? INNER_JOINED : INNER_DETACHED));
285
+
286
+ // jelinek.css:444-445 (.site-header__logo, .site-header__logo img /
287
+ // .site-header__mark): inline-flex, no shrink/grow, line-height:0
288
+ // (leading-none), border-radius:radius-button, overflow hidden — the
289
+ // mark itself is a square var(--logo-mark) box.
290
+ const LOGO_LINK = 'inline-flex flex-none leading-none rounded-button overflow-hidden';
291
+ const MARK = 'block h-[var(--logo-mark)] w-[var(--logo-mark)]';
292
+
293
+ // jelinek.css:447 (.site-header__title): font-primary (GT Walsheim),
294
+ // weight 700, line-height 1, size proportional to the mark
295
+ // (calc(logo-mark * 71/170)), letter-spacing -0.01em. The brief's draft
296
+ // dropped this text entirely and rendered only the subtitle — the real
297
+ // markup (partials/header.html:5) is always `JELÍNEK<span>{subtitle}</span>`,
298
+ // the brand name is never itself a prop (every real usage renders the
299
+ // same literal word), so it's hardcoded here and only the trailing
300
+ // <span> is conditional on the `subtitle` prop. No whitespace is placed
301
+ // between "JELÍNEK" and the {#if} block in the markup below — Svelte
302
+ // collapses template whitespace, and the source has none there either
303
+ // (the visual line break between them comes entirely from the span's
304
+ // own `display:block`, not from a text-level space).
305
+ //
306
+ // jelinek.css:448-454 (.site-header__title span, the subtitle): colour
307
+ // text-muted1, an explicit "Avenir Next LT Pro"/"Avenir Next" stack —
308
+ // NOT the --font-secondary token (which currently also resolves to GT
309
+ // Walsheim Pro in this guide's fonts, see tokens.css) — kept as a literal
310
+ // inline `style`, same technique Tag.svelte uses for its own
311
+ // custom-property values, since Tailwind's bracket syntax for a
312
+ // comma-separated, quoted font stack is fragile. Weight 600 (Avenir
313
+ // Demi), size calc(logo-mark*35/170), tracking 0.064em, uppercase,
314
+ // margin-top 0.3em, line-height 1.1, display block.
315
+ //
316
+ // Task 26 added `min-w-0` here, reproducing the guide's
317
+ // `:root.has-js .site-header__title { min-width: 0 }`. TASK 33 REMOVES IT,
318
+ // because the guide's rule is gone with the layout that needed it: it existed
319
+ // only so the lockup could shrink instead of pushing a `flex-nowrap` row of
320
+ // three link pills plus a theme toggle onto a second line. The phone row now
321
+ // holds the lockup and a 44px hamburger and nothing else — measured at a
322
+ // 320px viewport: 46px mark + 12px gap + ~75px min-content lockup + 44px
323
+ // button + 48px inner padding = 225px, with 95px to spare — so nothing needs
324
+ // to be crushed, and keeping `min-w-0` would shrink the lockup for no reason
325
+ // on the narrowest phones. Verify at 320px if the burger ever grows a label.
326
+ const TITLE = 'font-primary text-[calc(var(--logo-mark)*71/170)] leading-none font-bold tracking-[-0.01em]';
327
+ const SUBTITLE =
328
+ 'mt-[0.3em] block text-[calc(var(--logo-mark)*35/170)] leading-[1.1] font-semibold ' +
329
+ 'tracking-[0.064em] text-fg-muted1 uppercase';
330
+ const SUBTITLE_STYLE = "font-family: 'Avenir Next LT Pro', 'Avenir Next', Arial, sans-serif;";
331
+
332
+ // TASK 33 — THE PHONE MENU IS THE DESIGNER'S HAMBURGER PANEL, AND THE NAV
333
+ // ITEMS ARE BUTTONS. Both halves are his (6750ee8, 36354b9) and both replace
334
+ // what this component did before, rather than sitting alongside it.
335
+ //
336
+ // WHY IT EXISTS AT ALL. Below 640px the top-level links do not fit beside the
337
+ // mark. Measured in the guide, in-browser, at the real font: the three pills
338
+ // come to 273px and the theme toggle to another 60px, against 270px of usable
339
+ // width inside the bar at a 320px viewport. There is no one-row arrangement,
340
+ // so the links collapse — that finding (audit M3) still stands; only the
341
+ // mechanism changed.
342
+ //
343
+ // WHAT REPLACED THE "Menu" BUTTON + DROPDOWN. His version is a labelless
344
+ // 44px hamburger (three 2px bars that fold into a cross) and a FULL-WIDTH
345
+ // GLASS PANEL hanging off the bottom edge of the bar, not a 200px solid
346
+ // dropdown inset inside it. This component's previous panel was a deliberate
347
+ // deviation — a solid `bg-surface` sheet, because the header's own 60%-white
348
+ // glass left links unreadable over page text. That deviation is dropped: he
349
+ // solved the same problem differently, by hanging the panel off the OUTER
350
+ // header rather than nesting it inside the glass bar (glass inside glass has
351
+ // nothing to blur, so content showed through sharp), and by using the shared
352
+ // --glass-fill, which is a heavier 50%/60% fill than the value that was
353
+ // unreadable. His arrangement is the live one and it is his call.
354
+ //
355
+ // `display: contents` IS GONE TOO. It was how the panel dissolved back into
356
+ // the nav row from 640px up. His CSS instead styles the `<nav>` ITSELF as the
357
+ // panel and returns it to a plain `position: static` flex row at 640px, so
358
+ // there is no wrapper element to dissolve. That is why NAV below carries two
359
+ // completely different rule sets rather than a wrapper plus a modifier.
360
+ //
361
+ // HIS REPARENTING IS REPRODUCED AS TWO ELEMENTS, NOT AS DOM SURGERY, and this
362
+ // is the one place the kit's shape differs from his markup. His script moves
363
+ // the single `<nav>` between `.site-header__inner` (≥640px) and
364
+ // `.site-header` (phone) on a matchMedia change, and that move is LOAD-
365
+ // BEARING, not tidiness: `.site-header__inner` has `backdrop-filter`, which
366
+ // makes it a BACKDROP ROOT, and a `backdrop-filter` descendant of a backdrop
367
+ // root has nothing left to filter. MEASURED, after a first attempt that
368
+ // nested the panel inside `__inner` and reasoned it would be fine because
369
+ // `backdrop-filter` also makes an element a containing block: at 375px, over
370
+ // scrolled page content, the nested panel rendered with NO blur AND NO
371
+ // visible fill — page text read straight through the links. Moving that exact
372
+ // same element onto `<header>` in the same page, with no CSS change, restored
373
+ // both. His comment says as much ("sklo vnořené ve skle nemá co rozostřovat")
374
+ // and it is right.
375
+ //
376
+ // A component cannot do the move itself. Svelte owns this subtree; relocating
377
+ // a node it created out from under its `{#if}` block leaves the block's
378
+ // first/last node range pointing into a sibling chain the node has left, so
379
+ // teardown either misses it or throws. So the panel and the row are two
380
+ // separate `<nav>` elements in their two correct parents, and only ever ONE of
381
+ // them is displayed:
382
+ // - the ROW lives inside `__inner`, `hidden` below 640px;
383
+ // - the PANEL is a sibling of `__inner`, hidden FROM 640px up.
384
+ // The `display: none` one is out of the accessibility tree entirely, so there
385
+ // is exactly one nav landmark at any width, and the link markup is not
386
+ // duplicated either — both render the same `{#snippet navLinks}`.
387
+ //
388
+ // NO `.has-js` EQUIVALENT, and that is deliberate rather than an omission.
389
+ // The guide gates its whole collapse on a `.has-js` class its inline header
390
+ // script sets, so a visitor without JS keeps the links in the bar instead of
391
+ // behind a button that cannot open — this merge added that guard to the
392
+ // guide, since his version locked the menu shut without JS. A Svelte
393
+ // component only renders interactively where JS runs, so there is nothing to
394
+ // guard here. A consumer server-rendering this kit and caring about the no-JS
395
+ // case must mirror the guide's own `.has-js` rules.
396
+ //
397
+ // PANEL (the phone half of jelinek.css:498-509): `absolute`, hung off
398
+ // `top-full` on the outer `<header>` (which is `absolute`, so it is the
399
+ // containing block), full viewport width, column layout, 12px padding,
400
+ // bottom-rounded, the shared glass, the resting shadow.
401
+ //
402
+ // ROW (the ≥640px half, jelinek.css:589-596): a plain right-aligned flex row
403
+ // with an 8px gap and none of the panel chrome. It carries no phone styling at
404
+ // all — it is simply not displayed there — which is why this is two constants
405
+ // rather than one with a pile of `min-[640px]:` resets.
406
+ // Task 37 — THE PANEL HAS TO SCROLL, and until now it silently could not.
407
+ // It is `absolute`, so its height is whatever its content needs and nothing
408
+ // clips it. With three top-level links, ten chapters and the theme toggle it
409
+ // measured 674px tall on a 375×720 phone, starting at y=71 — bottom edge at
410
+ // 745, i.e. the toggle and the last chapter were 25px BELOW the fold with no
411
+ // way to reach them: the page behind cannot scroll the panel, because the
412
+ // panel is not part of its flow.
413
+ //
414
+ // `max-h` is the space between the header and the bottom of the viewport
415
+ // (minus 12px so it never sits flush against the edge), and `overflow-y-auto`
416
+ // makes the surplus reachable. `dvh` rather than `vh` because on iOS Safari
417
+ // `vh` is the tallest-possible viewport, which is exactly the amount by which
418
+ // the URL bar would hide the end of the list. `overscroll-contain` keeps a
419
+ // flick at the end of the list from scrolling the page underneath instead.
420
+ const NAV_PANEL =
421
+ 'absolute top-full inset-x-0 z-[48] flex-col items-stretch gap-1 p-3 ' +
422
+ 'rounded-b-card border border-x-0 border-t-0 border-[var(--glass-border)] ' +
423
+ 'bg-[var(--glass-fill)] shadow-resting min-[640px]:hidden ' +
424
+ 'max-h-[calc(100dvh_-_var(--header-h)_-_12px)] overflow-y-auto overscroll-contain ' +
425
+ '[-webkit-backdrop-filter:var(--glass-filter)] [backdrop-filter:var(--glass-filter)]';
426
+ const NAV_ROW = 'hidden ml-auto items-center gap-2 min-[640px]:flex';
427
+ const navPanel = $derived(cn(NAV_PANEL, menuOpen ? 'flex' : 'hidden'));
428
+
429
+ // NAV LINKS (jelinek.css:472 + partials/header.html:6-8). His 36354b9 turned
430
+ // them into TERTIARY BUTTONS: the markup is literally
431
+ // `class="btn btn--ghost btn--black"` with `is-active` for the current page,
432
+ // so appearance, hover and the full-fill active state all come from the
433
+ // button system now. The previous bespoke pill (`rounded-pill px-3.5 py-2`,
434
+ // `bg-surface-black` when active) is deleted rather than kept — reproducing it
435
+ // alongside would leave the kit with a nav-link style the guide no longer has.
436
+ //
437
+ // Rendered through this kit's own <Button href variant="ghost" color="black">
438
+ // for the same reason: it IS `.btn--ghost.btn--black`, and duplicating those
439
+ // classes by hand here is exactly the drift the css-lock gate exists to
440
+ // catch. `active` maps to `is-active`.
441
+ //
442
+ // The three declarations left in the guide's own rule are the size trim, and
443
+ // they are passed as the external `class` (cn() merges it last, so it beats
444
+ // Button's own `px-[26px] py-[11px] text-[0.98rem]`): `padding: 9px
445
+ // var(--space-2)` → `px-4 py-[9px]`, `font-size: 0.95rem`, and
446
+ // `text-align: left` — which is invisible on a content-width row but does
447
+ // matter in the phone panel, where `items-stretch` makes every link
448
+ // full-width.
449
+ // `justify-start` carries the guide's `text-align: left` across this kit's
450
+ // one documented Button deviation: the guide's `.btn` is `display:
451
+ // inline-block; text-align: center`, Button.svelte is `inline-flex ...
452
+ // justify-center` (so it can hold an icon), and on a flex box
453
+ // `text-align` no longer moves the label — `justify-*` does. Both are
454
+ // invisible on the ≥640px row, where the button is content-width; it only
455
+ // shows in the panel, where `items-stretch` makes every entry full-width.
456
+ // `leading-[1.2]` is re-asserted here even though Button's BASE already has
457
+ // it: tailwind-merge treats a font-size utility as also owning line-height
458
+ // (Tailwind's text-* sizes set both), so the `text-[0.95rem]` override
459
+ // silently strips Button's `leading-[1.2]` and the link falls back to the
460
+ // inherited 1.6 — six extra pixels of height vs. the guide's header.
461
+ const NAV_LINK = 'justify-start px-4 py-[9px] text-[0.95rem] leading-[1.2] text-left';
462
+
463
+ // BURGER (jelinek.css:481-497). A 44px square — his comment calls it out as
464
+ // a comfortable finger target — holding three 2px bars with a 5px gap, no
465
+ // border, no background. Open, the outer two rotate 45° into a cross and the
466
+ // middle one fades out. `ml-auto` pushes it to the right of the lockup, the
467
+ // job the nav row's own `ml-auto` does from 640px up. Hidden from 640px up.
468
+ //
469
+ // It has NO VISIBLE LABEL — the previous version's "Menu" text is gone with
470
+ // his design — so `aria-label` is the only accessible name it has, and it
471
+ // changes with state exactly as his script changes it (`Otevřít menu` /
472
+ // `Zavřít menu`, the `menuLabel`/`menuLabelClose` props).
473
+ const BURGER =
474
+ 'ml-auto inline-flex h-11 w-11 flex-none cursor-pointer flex-col justify-center gap-[5px] ' +
475
+ 'rounded-button border-0 bg-transparent px-2.5 min-[640px]:hidden';
476
+ const BAR =
477
+ 'block h-0.5 rounded-[2px] bg-fg ' +
478
+ 'transition-[transform,opacity] duration-[var(--dur-base)] ease-[var(--ease)]';
479
+ const bar1 = $derived(cn(BAR, menuOpen && 'translate-y-[7px] rotate-45'));
480
+ const bar2 = $derived(cn(BAR, menuOpen && 'opacity-0'));
481
+ const bar3 = $derived(cn(BAR, menuOpen && '-translate-y-[7px] -rotate-45'));
482
+
483
+ // CHAPTERS INSIDE THE PANEL (jelinek.css:504-509, `.site-menu__chapters`).
484
+ // The chapter bar is `display: none` below 640px now (ChapterNav.svelte), so
485
+ // without this a phone reader has no route to the chapters at all. The guide
486
+ // solves it in script, by cloning the anchors out of the rendered
487
+ // `.chapter-nav__inner` and appending them to the panel; the kit takes them as
488
+ // the `chapterItems` prop instead — same content, declared rather than
489
+ // cloned, and it works under SSR where there is no rendered bar to read.
490
+ //
491
+ // A 1px divider above, 8px of separation, 2px between entries, and a smaller
492
+ // size trim on the buttons (`font-size: 0.9rem; padding: 7px var(--space-2)`).
493
+ // Hidden from 640px up, where the bar itself is back.
494
+ const CHAPTERS =
495
+ 'mt-2 flex flex-col gap-0.5 border-t border-[var(--glass-border)] pt-2 min-[640px]:hidden';
496
+ // Same leading-[1.2] re-assertion as NAV_LINK — text-[0.9rem] would strip it.
497
+ const CHAPTER_LINK = 'justify-start px-4 py-[7px] text-[0.9rem] leading-[1.2] text-left';
498
+
499
+ // The `actions` block inside the phone panel. Its own divider, matching the
500
+ // one CHAPTERS draws above the chapter list, so the toggle reads as a separate
501
+ // control rather than as one more menu entry. Left-aligned with the links.
502
+ const PANEL_ACTIONS =
503
+ 'mt-2 flex items-center border-t border-[var(--glass-border)] px-4 pt-3 min-[640px]:hidden';
504
+
505
+ // The theme toggle (and anything else that belongs in the nav) arrives
506
+ // through the `actions` snippet — see its prop doc for why this stopped being
507
+ // "render it yourself outside the component".
508
+ </script>
509
+
510
+ <!-- One definition of the top-level links, rendered into both navs. `linkClass`
511
+ exists only so a future caller of this snippet can trim differently; today
512
+ both call sites pass NAV_LINK. -->
513
+ {#snippet navLinks(linkClass: string)}
514
+ {#each items as item (item.href)}
515
+ <Button
516
+ href={item.href}
517
+ variant="ghost"
518
+ color="black"
519
+ active={isActive(item, active)}
520
+ aria-current={isActive(item, active) ? 'page' : undefined}
521
+ class={linkClass}
522
+ >
523
+ {item.label}
524
+ </Button>
525
+ {/each}
526
+ {/snippet}
527
+
528
+ <header {...rest} class={cn(OUTER, className)}>
529
+ <div class={inner}>
530
+ <a href={logoHref} aria-label={logoAlt} class={LOGO_LINK}>
531
+ {#if logo}
532
+ {@render logo()}
533
+ {:else}
534
+ <!-- Path data from the shared deer-mark-path.js module (Task 23 —
535
+ extracted so this and ContentLogo.svelte's own copy of the
536
+ SVG mark can't silently drift apart; see that module's own
537
+ comment for the verification that both were byte-identical to
538
+ partials/header.html:4 before the extraction). The source
539
+ re-themes via inline style="fill:var(--JELINEK-BLACK)" (plus a
540
+ static fill="#000" fallback attribute); reproduced here with the
541
+ kit's own fill-jelinek-black/fill-jelinek-white utilities
542
+ instead — they resolve to the exact same
543
+ var(--JELINEK-BLACK)/var(--JELINEK-WHITE), and every other
544
+ token-driven colour in this kit goes through a Tailwind utility
545
+ rather than an inline style, so the static fallback attribute
546
+ (meaningful only without CSS custom-property support) is dropped
547
+ as unneeded for this target stack. -->
548
+ <svg
549
+ class={MARK}
550
+ viewBox="0 0 400 400"
551
+ xmlns="http://www.w3.org/2000/svg"
552
+ aria-hidden="true"
553
+ >
554
+ <rect width="400" height="400" class="fill-jelinek-black" />
555
+ <path d={DEER_MARK_PATH} class="fill-jelinek-white" />
556
+ </svg>
557
+ {/if}
558
+ </a>
559
+ <div class={TITLE}>JELÍNEK{#if subtitle}<span class={SUBTITLE} style={SUBTITLE_STYLE}>{subtitle}</span>{/if}</div>
560
+ {#if items.length > 0}
561
+ <!-- The burger precedes the panel in source order so Tab from it lands on
562
+ the first link it just revealed, and because that is the order in
563
+ partials/header.html:6-7. -->
564
+ <button
565
+ type="button"
566
+ bind:this={menuButton}
567
+ class={BURGER}
568
+ aria-expanded={menuOpen}
569
+ aria-controls={panelId}
570
+ aria-label={menuOpen ? menuLabelClose : menuLabel}
571
+ onclick={() => (menuOpen = !menuOpen)}
572
+ >
573
+ <span class={bar1}></span>
574
+ <span class={bar2}></span>
575
+ <span class={bar3}></span>
576
+ </button>
577
+ <!-- The ≥640px row. Inside the glass bar, so it is a flex item of it. -->
578
+ <nav class={NAV_ROW} aria-label={navLabel}>
579
+ {@render navLinks(NAV_LINK)}
580
+ {#if actions}{@render actions()}{/if}
581
+ </nav>
582
+ {/if}
583
+ </div>
584
+ {#if items.length > 0}
585
+ <!-- The phone panel. A SIBLING of the glass bar, not a child — see
586
+ NAV_PANEL's comment for the measurement that forced that. Hidden from
587
+ 640px up, so only one of the two navs is ever in the a11y tree. -->
588
+ <nav id={panelId} bind:this={linksEl} class={navPanel} aria-label={navLabel}>
589
+ {@render navLinks(NAV_LINK)}
590
+ {#if chapterItems.length > 0}
591
+ <div class={CHAPTERS}>
592
+ {#each chapterItems as chapter (chapter.href)}
593
+ <Button
594
+ href={chapter.href}
595
+ variant="ghost"
596
+ color="black"
597
+ active={isActive(chapter, active)}
598
+ aria-current={isActive(chapter, active) ? 'page' : undefined}
599
+ class={CHAPTER_LINK}
600
+ >
601
+ {chapter.label}
602
+ </Button>
603
+ {/each}
604
+ </div>
605
+ {/if}
606
+ <!-- Last in the panel, as in the guide's nav: on a phone the toggle is
607
+ inside the burger menu, not floating in the page. -->
608
+ {#if actions}
609
+ <div class={PANEL_ACTIONS}>{@render actions()}</div>
610
+ {/if}
611
+ </nav>
612
+ {/if}
613
+ </header>