@jelinek/ui 0.3.0 → 0.4.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/README.md CHANGED
@@ -26,7 +26,7 @@ Packages for a package that is no longer published there:
26
26
  ```json
27
27
  {
28
28
  "dependencies": {
29
- "@jelinek/ui": "^0.3.0"
29
+ "@jelinek/ui": "^0.4.0"
30
30
  }
31
31
  }
32
32
  ```
@@ -465,15 +465,38 @@ The `publish` job is separate and gated on a tag matching `ui-v*` — it
465
465
  does not run on ordinary merges to `master`, only when someone pushes a
466
466
  `ui-v*` tag, and it also depends on `verify` passing first. It publishes
467
467
  to **npmjs.org with `--access public`**, authenticating with the
468
- `NPM_TOKEN` repo secret (a granular automation token for the `jelinek`
469
- npm org — a different namespace from the GitHub org, which is named
470
- `JELINEK-nabytek-a-matrace`). Two things to know about that token: npm
471
- expires granular tokens, so a publish failing with a 401 after months of
472
- silence means it needs regenerating; and npm is phasing out tokens that
473
- bypass 2FA (account changes Aug 2026, direct publishing Jan 2027), whose
474
- replacement is trusted publishing via GitHub Actions OIDC. No provenance
475
- (`--provenance`) is generated — npm rejects it for builds from a private
476
- repository, and this repo is private.
468
+ `NPM_TOKEN` repo secret (a granular token for the `jelinek` npm org — a
469
+ different namespace from the GitHub org, which is named
470
+ `JELINEK-nabytek-a-matrace`). No provenance (`--provenance`) is
471
+ generated: npm rejects it for builds from a private repository.
472
+
473
+ **That job does not currently work, and 0.3.0 was released by hand.** npm
474
+ requires a second factor for every publish, and a granular token only
475
+ satisfies that if "Bypass two-factor authentication (2FA)" was ticked
476
+ *when the token was created* — the setting cannot be added to an existing
477
+ token, and npm is in the middle of restricting bypass tokens outright
478
+ (account changes Aug 2026, direct publishing Jan 2027). Without it the
479
+ registry answers a `PUT` with a misleading `E404 Not Found` in CI, and a
480
+ clearer `E403 … Two-factor authentication or granular access token with
481
+ bypass 2fa enabled is required` locally.
482
+
483
+ So the working release procedure is manual, from a machine that can reach
484
+ a browser:
485
+
486
+ ```
487
+ cd svelte && npm publish --access public --auth-type=web
488
+ ```
489
+
490
+ That prints a `npmjs.com/auth/cli/…` URL; approving it there with a
491
+ passkey (or an authenticator app) completes the publish. `--otp=<code>`
492
+ works too, but only with an authenticator app — a passkey produces no
493
+ code. Push the `ui-v*` tag anyway: `verify` still gates the release, and
494
+ the tag is what records which commit a version came from.
495
+
496
+ Fixing CI means either a bypass-enabled token while npm still issues
497
+ them, or trusted publishing via GitHub Actions OIDC — the latter is
498
+ untested here and may be unavailable for the same reason provenance is,
499
+ namely that this repo is private.
477
500
 
478
501
  ## Known design debt
479
502
 
@@ -53,6 +53,12 @@
53
53
  * real `.page-hero` in the guide has this; kept optional since
54
54
  * jelinek.css itself allows a bare eyebrow+heading (brand/znacka.html). */
55
55
  children?: Snippet;
56
+ /** Full-bleed background medium — a photo or video behind the hero.
57
+ * Render a bare `<img>` or `<video>`; the wrapper stretches it across
58
+ * the WHOLE band (edge to edge of the window, like the `--hero-bg`
59
+ * colour) and crops it with object-fit: cover. Mirrors the guide's
60
+ * `.page-hero__media`. The text content is lifted above it. */
61
+ media?: Snippet;
56
62
  class?: string;
57
63
  }
58
64
 
@@ -64,6 +70,7 @@
64
70
  eyebrow,
65
71
  heading,
66
72
  children,
73
+ media,
67
74
  class: className,
68
75
  ...rest
69
76
  }: Props & HTMLAttributes<HTMLElement> = $props();
@@ -92,10 +99,28 @@
92
99
  // padding-bottom is the passthrough token --space-section (token-map.js
93
100
  // keeps space-* off the Tailwind theme on purpose — see its own comment —
94
101
  // so this stays an arbitrary var() reference, not a spacing utility).
102
+ // Task 44 — THE BAND'S SURFACE IS ONE VARIABLE. The colour used to be
103
+ // written twice (background + the 100vmax shadow), so recolouring the band
104
+ // took two edits that could disagree — the reader proved it in dev tools:
105
+ // changing `background` alone painted only the container-width rectangle
106
+ // red while the bleed stayed grey. Both now read --hero-bg (default: the
107
+ // same --color-surface-gray as before, so it still flips in dark mode);
108
+ // one `[--hero-bg:...]` override — or `style="--hero-bg: …"` — recolours
109
+ // the whole band. `relative` anchors the `media` layer below.
95
110
  const OUTER =
96
- 'bg-surface-gray shadow-[0_0_0_100vmax_var(--color-surface-gray)] ' +
111
+ 'relative [--hero-bg:var(--color-surface-gray)] bg-[var(--hero-bg)] ' +
112
+ 'shadow-[0_0_0_100vmax_var(--hero-bg)] ' +
97
113
  '[clip-path:inset(0_-100vmax)] pb-[var(--space-section)]';
98
114
 
115
+ // The guide's `.page-hero__media`: a photo/video stretched across the whole
116
+ // full-bleed band. Centred 100vw wide (translateX — measured: adds NO
117
+ // horizontal scrollbar), clipped by the section's own clip-path. The `*:`
118
+ // variant sizes whatever single element the snippet renders (img/video) to
119
+ // cover the layer.
120
+ const MEDIA =
121
+ 'pointer-events-none absolute inset-y-0 left-1/2 w-screen -translate-x-1/2 select-none ' +
122
+ '*:h-full *:w-full *:object-cover';
123
+
99
124
  // jelinek.css:339 (.container) — jelinek.css:672 gives `.page-hero`
100
125
  // itself explicit ZERO horizontal padding, relying ENTIRELY on
101
126
  // `.page-hero` always being a direct child of `<main class="container">`
@@ -302,10 +327,19 @@
302
327
  {/snippet}
303
328
 
304
329
  <section {...rest} class={cn(OUTER, paddingTop, className)}>
330
+ {#if media}
331
+ <!-- aria-hidden: the medium is decoration behind the text, same as the
332
+ band's colour. Content is lifted above it via `relative` below. -->
333
+ <div class={MEDIA} aria-hidden="true">{@render media()}</div>
334
+ {/if}
305
335
  {#if isContained}
306
- {@render content()}
336
+ {#if media}
337
+ <div class="relative">{@render content()}</div>
338
+ {:else}
339
+ {@render content()}
340
+ {/if}
307
341
  {:else}
308
- <div class={INNER}>
342
+ <div class={cn(INNER, media && 'relative')}>
309
343
  {@render content()}
310
344
  </div>
311
345
  {/if}
@@ -47,6 +47,12 @@ interface Props {
47
47
  * real `.page-hero` in the guide has this; kept optional since
48
48
  * jelinek.css itself allows a bare eyebrow+heading (brand/znacka.html). */
49
49
  children?: Snippet;
50
+ /** Full-bleed background medium — a photo or video behind the hero.
51
+ * Render a bare `<img>` or `<video>`; the wrapper stretches it across
52
+ * the WHOLE band (edge to edge of the window, like the `--hero-bg`
53
+ * colour) and crops it with object-fit: cover. Mirrors the guide's
54
+ * `.page-hero__media`. The text content is lifted above it. */
55
+ media?: Snippet;
50
56
  class?: string;
51
57
  }
52
58
  type $$ComponentProps = Props & HTMLAttributes<HTMLElement>;
@@ -8,6 +8,14 @@
8
8
  slogan?: string;
9
9
  meta?: string;
10
10
  stamp?: string;
11
+ /**
12
+ * Task 45 — HTML of the changelog shown in a floating <dialog> when the
13
+ * reader clicks the deploy stamp. The showcase passes the build-time
14
+ * inline of partials/changelog.html (__CHANGELOG_HTML__, see
15
+ * vite.config.ts) — the same file the hand-written pages SSI-include.
16
+ * Without it (or without `stamp`) the stamp stays a plain line.
17
+ */
18
+ changelog?: string;
11
19
  class?: string;
12
20
  }
13
21
 
@@ -17,10 +25,35 @@
17
25
  slogan,
18
26
  meta,
19
27
  stamp,
28
+ changelog,
20
29
  class: className,
21
30
  ...rest
22
31
  }: Props & HTMLAttributes<HTMLElement> = $props();
23
32
 
33
+ let dialogEl = $state<HTMLDialogElement | null>(null);
34
+
35
+ // Mirrors the guide's .changelog dialog (jelinek.css) — a card over the
36
+ // dimmed page. The native <dialog> handles Escape and focus itself; a
37
+ // click landing on the dialog ELEMENT (i.e. its ::backdrop area) closes.
38
+ const STAMP_BUTTON =
39
+ 'ml-auto cursor-pointer border-0 bg-transparent p-0 font-serif text-[0.85rem] text-fg-muted2 ' +
40
+ 'transition-[color] duration-[var(--dur-base)] ease-[var(--ease)] hover:text-fg-inverse';
41
+ const DIALOG =
42
+ 'm-auto w-[min(560px,calc(100vw-48px))] max-h-[min(70vh,640px)] p-0 ' +
43
+ 'rounded-card border border-border-subtle bg-surface text-fg shadow-hover ' +
44
+ 'backdrop:bg-black/40';
45
+ const DIALOG_HEAD =
46
+ 'sticky top-0 flex items-center justify-between bg-surface px-6 pt-4';
47
+ const DIALOG_CLOSE =
48
+ 'inline-flex h-11 w-11 flex-none cursor-pointer items-center justify-center ' +
49
+ 'rounded-button border-0 bg-transparent p-0 text-fg';
50
+ // The changelog HTML carries the guide's own class names (.changelog__entry
51
+ // etc.) that mean nothing to Tailwind — style its plain tags contextually.
52
+ const DIALOG_BODY =
53
+ 'px-6 pt-2 pb-6 ' +
54
+ '[&_h3]:mt-4 [&_h3]:mb-1.5 [&_h3]:text-[0.95rem] ' +
55
+ '[&_ul]:mb-2 [&_ul]:pl-[1.2em] [&_ul]:list-disc [&_li]:my-1';
56
+
24
57
  // jelinek.css:759-770 (.site-footer, .site-footer__inner). `margin-top:
25
58
  // 60px` (:760) is kept — unlike Card/Alert (Tasks 5/6), which deliberately
26
59
  // drop their own external margins on the reasoning that a reusable
@@ -99,7 +132,15 @@
99
132
  {/if}
100
133
  </div>
101
134
  {/if}
102
- {#if stamp}
135
+ {#if stamp && changelog}
136
+ <!-- The stamp opens the changelog dialog — same classes-worth of
137
+ treatment as the plain stamp below, plus the pointer/hover.
138
+ partials/footer.html does the identical thing with
139
+ #changelog-open + initChangelog() in jelinek.js. -->
140
+ <button type="button" class={STAMP_BUTTON} aria-haspopup="dialog" onclick={() => dialogEl?.showModal()}>
141
+ {stamp}
142
+ </button>
143
+ {:else if stamp}
103
144
  <!-- jelinek.css:770 (`.site-footer .meta`): margin-left:auto pins it
104
145
  to the far right of `.site-footer__inner`'s flex row — so this
105
146
  has to be a sibling of the logo/slogan-wrapper above, not nested
@@ -118,4 +159,22 @@
118
159
  <div class="ml-auto font-serif text-[0.85rem] text-fg-muted2">{stamp}</div>
119
160
  {/if}
120
161
  </div>
162
+ {#if stamp && changelog}
163
+ <dialog
164
+ bind:this={dialogEl}
165
+ class={DIALOG}
166
+ aria-labelledby="changelog-title"
167
+ onclick={(e) => e.target === dialogEl && dialogEl?.close()}
168
+ >
169
+ <div class={DIALOG_HEAD}>
170
+ <h2 id="changelog-title" class="m-0 font-primary text-step3 font-bold">Co je nového</h2>
171
+ <button type="button" class={DIALOG_CLOSE} aria-label="Zavřít" onclick={() => dialogEl?.close()}>
172
+ <svg class="h-[18px] w-[18px]" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 3l10 10M13 3L3 13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
173
+ </button>
174
+ </div>
175
+ <!-- The changelog is repo content (partials/changelog.html), inlined at
176
+ build time — not user input; {@html} is safe here. -->
177
+ <div class={DIALOG_BODY}>{@html changelog}</div>
178
+ </dialog>
179
+ {/if}
121
180
  </footer>
@@ -5,6 +5,14 @@ interface Props {
5
5
  slogan?: string;
6
6
  meta?: string;
7
7
  stamp?: string;
8
+ /**
9
+ * Task 45 — HTML of the changelog shown in a floating <dialog> when the
10
+ * reader clicks the deploy stamp. The showcase passes the build-time
11
+ * inline of partials/changelog.html (__CHANGELOG_HTML__, see
12
+ * vite.config.ts) — the same file the hand-written pages SSI-include.
13
+ * Without it (or without `stamp`) the stamp stays a plain line.
14
+ */
15
+ changelog?: string;
8
16
  class?: string;
9
17
  }
10
18
  type $$ComponentProps = Props & HTMLAttributes<HTMLElement>;
@@ -5,7 +5,7 @@
5
5
  import Button from './Button.svelte';
6
6
  import { createChapterNavDock } from '../utils/chapter-nav-dock.svelte.js';
7
7
  import { DEER_MARK_PATH } from './deer-mark-path.js';
8
- import { isActive, type NavItem } from './nav.js';
8
+ import { isActive, type NavGroup, type NavItem } from './nav.js';
9
9
 
10
10
  interface Props {
11
11
  /**
@@ -78,6 +78,22 @@
78
78
  * it is not a general slot.
79
79
  */
80
80
  actions?: Snippet;
81
+ /**
82
+ * Controls that must stay visible IN THE BAR on a phone — an e-shop's
83
+ * cart button, a search trigger. `actions` cannot serve them: it lives
84
+ * inside the nav, which below 640px is the burger drawer, so anything
85
+ * in it costs a reader two taps and disappears from the bar entirely.
86
+ * The guide's own header has no such controls (its one nav-adjacent
87
+ * control, the theme toggle, belongs in `actions`), so this slot adds
88
+ * a consumer seam rather than reproducing guide markup.
89
+ *
90
+ * Rendered between the lockup and the burger, right-aligned, and
91
+ * HIDDEN from 640px up — on a desktop the same controls belong in
92
+ * `actions`, inside the nav row, and rendering both would double
93
+ * them. A consumer using this slot typically renders the same
94
+ * snippet content in both props.
95
+ */
96
+ barActions?: Snippet;
81
97
  class?: string;
82
98
  }
83
99
 
@@ -94,6 +110,7 @@
94
110
  menuLabel = 'Otevřít menu',
95
111
  menuLabelClose = 'Zavřít menu',
96
112
  actions,
113
+ barActions,
97
114
  class: className,
98
115
  ...rest
99
116
  }: Props & HTMLAttributes<HTMLElement> = $props();
@@ -116,6 +133,62 @@
116
133
  let menuOpen = $state(false);
117
134
  let menuButton = $state<HTMLButtonElement | null>(null);
118
135
  let linksEl = $state<HTMLElement | null>(null);
136
+ let headerEl = $state<HTMLElement | null>(null);
137
+
138
+ // MEGA-MENU (≥640px): which section's panel is slid out, keyed by item
139
+ // href. Opens on hover or keyboard focus of the section link; closes with
140
+ // a 150ms grace on mouseleave — the pointer's path from the link down into
141
+ // the panel crosses the bar's bottom padding, and an immediate close would
142
+ // snap the panel shut before it arrives. Mirrors initSiteMenu() in
143
+ // assets/js/jelinek.js (same delay, same open-on-focus).
144
+ let openSub = $state<string | null>(null);
145
+ let subCloseTimer: ReturnType<typeof setTimeout> | null = null;
146
+ function cancelSubClose() {
147
+ if (subCloseTimer) {
148
+ clearTimeout(subCloseTimer);
149
+ subCloseTimer = null;
150
+ }
151
+ }
152
+ function openSubmenu(href: string) {
153
+ cancelSubClose();
154
+ openSub = href;
155
+ }
156
+ function closeSubmenu() {
157
+ cancelSubClose();
158
+ openSub = null;
159
+ }
160
+ function scheduleSubClose() {
161
+ cancelSubClose();
162
+ subCloseTimer = setTimeout(() => (openSub = null), 150);
163
+ }
164
+
165
+ // Escape closes the open panel and hands focus back to its section link
166
+ // (without that, focus inside the now-invisible panel drops to <body> and
167
+ // keyboard navigation restarts from the top — same WCAG 2.4.3 reasoning as
168
+ // closeMenu above). Focus leaving the header entirely closes it too.
169
+ $effect(() => {
170
+ if (!openSub) return;
171
+ function onKeyDown(event: KeyboardEvent) {
172
+ if (event.key !== 'Escape') return;
173
+ const i = items.findIndex((it) => it.href === openSub);
174
+ closeSubmenu();
175
+ if (i >= 0) document.getElementById(`${uid}-link-${i}`)?.focus();
176
+ }
177
+ function onFocusIn(event: FocusEvent) {
178
+ const target = event.target as Node | null;
179
+ if (target && headerEl && !headerEl.contains(target)) closeSubmenu();
180
+ }
181
+ document.addEventListener('keydown', onKeyDown);
182
+ document.addEventListener('focusin', onFocusIn);
183
+ return () => {
184
+ document.removeEventListener('keydown', onKeyDown);
185
+ document.removeEventListener('focusin', onFocusIn);
186
+ };
187
+ });
188
+
189
+ // Phone accordion state (which sections are unfolded in the drawer),
190
+ // keyed by item href. Survives closing the drawer, like the guide's.
191
+ let openGroups = $state<Record<string, boolean>>({});
119
192
 
120
193
  // Closing the panel while focus is still inside it would drop focus onto
121
194
  // <body>, restarting keyboard navigation from the top of the page. Every
@@ -269,19 +342,35 @@
269
342
  const OUTER =
270
343
  'absolute inset-x-0 top-0 z-50 p-0 [--logo-mark:46px] ' +
271
344
  'min-[640px]:fixed min-[640px]:px-6 min-[640px]:py-3';
345
+ // Task 43 — the glass card no longer IS the flex row: the row (logo,
346
+ // lockup, burger, nav) moved into its own .site-header__bar-equivalent
347
+ // wrapper (BAR below), so the card can grow BELOW the row by the open
348
+ // mega-menu panel — one background, one border, one shadow for the bar and
349
+ // the submenu together, exactly like partials/header.html's
350
+ // .site-header__inner / .site-header__bar split.
272
351
  const INNER_BASE =
273
- 'mx-auto flex max-w-none items-center gap-[calc(var(--logo-mark)*107/400)] ' +
352
+ 'mx-auto max-w-none ' +
274
353
  'border border-x-0 border-t-0 border-[var(--glass-border)] rounded-none ' +
275
354
  'bg-[var(--glass-fill)] px-6 py-3 shadow-resting ' +
276
355
  'dark:shadow-[var(--shadow-resting),inset_0_2px_1px_-1px_var(--box-primary)] ' +
277
356
  '[-webkit-backdrop-filter:var(--glass-filter)] [backdrop-filter:var(--glass-filter)] ' +
278
357
  'min-[640px]:max-w-page min-[640px]:border-x min-[640px]:border-t ' +
279
358
  'min-[640px]:rounded-card';
359
+ const BAR_ROW = 'flex items-center gap-[calc(var(--logo-mark)*107/400)]';
280
360
  const INNER_JOINED =
281
361
  'min-[640px]:rounded-b-none min-[640px]:border-b-0 min-[640px]:shadow-none ' +
282
362
  'min-[640px]:dark:shadow-[inset_0_2px_1px_-1px_var(--box-primary)]';
283
363
  const INNER_DETACHED = '';
284
- const inner = $derived(cn(INNER_BASE, dock.docked ? INNER_JOINED : INNER_DETACHED));
364
+ // While a mega-menu is open the card has stretched OVER the chapter bar, so
365
+ // even a docked header gets its detached rounded bottom back (mirrors the
366
+ // guide's `.site-header.is-submenu-open .site-header__inner` rule). Placed
367
+ // after INNER_JOINED in cn() so tailwind-merge lets it win.
368
+ const INNER_SUBOPEN =
369
+ 'min-[640px]:rounded-card min-[640px]:border-b min-[640px]:shadow-resting ' +
370
+ 'min-[640px]:dark:shadow-[var(--shadow-resting),inset_0_2px_1px_-1px_var(--box-primary)]';
371
+ const inner = $derived(
372
+ cn(INNER_BASE, dock.docked ? INNER_JOINED : INNER_DETACHED, openSub ? INNER_SUBOPEN : '')
373
+ );
285
374
 
286
375
  // jelinek.css:444-445 (.site-header__logo, .site-header__logo img /
287
376
  // .site-header__mark): inline-flex, no shrink/grow, line-height:0
@@ -417,14 +506,91 @@
417
506
  // `vh` is the tallest-possible viewport, which is exactly the amount by which
418
507
  // the URL bar would hide the end of the list. `overscroll-contain` keeps a
419
508
  // 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)]';
509
+ // Task 43 — THE PHONE MENU IS A RIGHT-SIDE DRAWER, not a dropdown hung off
510
+ // the bar: full viewport height, slides in from the right (where the burger
511
+ // is), over a dimmed page. Mirrors jelinek.css's phone
512
+ // `:root.has-js .site-header__nav`. Closed, it is translated fully off the
513
+ // right edge AND `invisible`, so nothing inside is focusable; the
514
+ // visibility flip is delayed by the slide duration on the way out (the
515
+ // arbitrary [transition:…] pair below) so the exit animation stays visible.
516
+ const DRAWER =
517
+ 'fixed inset-y-0 right-0 left-auto z-[3] flex w-[min(84vw,340px)] flex-col items-stretch gap-1 p-4 ' +
518
+ 'border-l border-[var(--glass-border)] bg-[var(--glass-fill)] shadow-resting ' +
519
+ '[-webkit-backdrop-filter:var(--glass-filter)] [backdrop-filter:var(--glass-filter)] ' +
520
+ 'overflow-y-auto overscroll-contain min-[640px]:hidden';
521
+ const DRAWER_CLOSED =
522
+ 'translate-x-full invisible ' +
523
+ '[transition:transform_var(--dur-slow)_var(--ease),visibility_0s_var(--dur-slow)]';
524
+ const DRAWER_OPEN = 'translate-x-0 visible [transition:transform_var(--dur-slow)_var(--ease)]';
525
+ const drawer = $derived(cn(DRAWER, menuOpen ? DRAWER_OPEN : DRAWER_CLOSED));
526
+
527
+ // The page dim under the open drawer. A real element rather than the
528
+ // guide's `.site-header::before` (a component can attach handlers to an
529
+ // element; a pseudo needs the guide's target===header trick). A tap on it
530
+ // is already "a pointerdown outside linksEl/menuButton", so the dismissal
531
+ // $effect above closes the drawer without a handler here.
532
+ const BACKDROP =
533
+ 'fixed inset-0 z-[1] bg-black/30 min-[640px]:hidden ' +
534
+ 'transition-opacity duration-[var(--dur-slow)] ease-[var(--ease)]';
535
+ const backdrop = $derived(cn(BACKDROP, menuOpen ? 'opacity-100' : 'pointer-events-none opacity-0'));
536
+
537
+ // Drawer head: small uppercase "Menu" + a 44px close cross. The drawer
538
+ // covers the bar (and the burger with it), so it closes itself.
539
+ const DRAWER_HEAD = 'flex items-center justify-between pb-2 pl-4 min-[640px]:hidden';
540
+ const DRAWER_LABEL = 'text-[0.8rem] font-semibold tracking-[0.08em] uppercase text-fg-muted1';
541
+ const DRAWER_CLOSE =
542
+ 'inline-flex h-11 w-11 flex-none cursor-pointer items-center justify-center ' +
543
+ 'rounded-button border-0 bg-transparent p-0 text-fg';
544
+
545
+ // Accordion (drawer): section row = link + a chevron that unfolds the
546
+ // chapters in place. Height animates via grid-template-rows 0fr → 1fr —
547
+ // the one way to animate to an automatic height.
548
+ const GROUP_ROW = 'flex items-center';
549
+ const CHEVRON =
550
+ 'inline-flex h-10 w-11 flex-none cursor-pointer items-center justify-center ' +
551
+ 'rounded-button border-0 bg-transparent p-0 text-fg';
552
+ const CHEVRON_ICON = 'h-4 w-4 transition-transform duration-[var(--dur-base)] ease-[var(--ease)]';
553
+ // Same closed/open mechanics as MEGA below (and the guide's shared
554
+ // .site-menu__panel rule): closed is ALSO invisible, so the clipped links
555
+ // drop out of the tab order; the flip back is delayed by the animation
556
+ // length so the fold-up stays visible.
557
+ const ACC_PANEL = 'grid overflow-hidden';
558
+ const ACC_CLOSED =
559
+ 'invisible grid-rows-[0fr] ' +
560
+ '[transition:grid-template-rows_var(--dur-base)_var(--ease),visibility_0s_var(--dur-base)]';
561
+ const ACC_OPEN =
562
+ 'visible grid-rows-[1fr] [transition:grid-template-rows_var(--dur-base)_var(--ease)]';
563
+ const ACC_INNER = 'flex min-h-0 flex-col gap-0.5 overflow-hidden pl-4';
564
+
565
+ // MEGA-MENU PANEL (≥640px): a child of the glass card itself, rendered
566
+ // below the BAR row — NOT a second glass surface hung outside it. The
567
+ // reader asked for the submenu to read as the bar stretching smoothly
568
+ // downward: one background, one border, one shadow. Opening animates
569
+ // grid-template-rows 0fr → 1fr (the accordion mechanic — the one way to
570
+ // animate to an automatic height), so the card grows with it. The panel
571
+ // carries NO glass of its own: glass nested in glass has nothing to blur.
572
+ // Closed it is also `invisible`, so its clipped links drop out of the tab
573
+ // order; the flip back is delayed by the animation length on the way out.
574
+ const MEGA = 'hidden overflow-hidden min-[640px]:grid';
575
+ const MEGA_CLOSED =
576
+ 'invisible grid-rows-[0fr] ' +
577
+ '[transition:grid-template-rows_var(--dur-base)_var(--ease),visibility_0s_var(--dur-base)]';
578
+ const MEGA_OPEN =
579
+ 'visible grid-rows-[1fr] [transition:grid-template-rows_var(--dur-base)_var(--ease)]';
580
+ // The breathing room above the columns appears WITH the opening and
581
+ // animates alongside the height — a permanent padding-top would hold the
582
+ // card taller even closed (0fr crushes content, padding it cannot).
583
+ const MEGA_COLS =
584
+ 'grid min-h-0 grid-cols-3 gap-x-6 gap-y-2 pt-0 ' +
585
+ 'transition-[padding-top] duration-[var(--dur-base)] ease-[var(--ease)]';
586
+ const MEGA_COLS_OPEN = 'pt-4';
587
+ const MEGA_COL = 'flex flex-col gap-0.5';
588
+ // Column heading (shared by the mega-menu and the accordion): the same
589
+ // small uppercase Avenir treatment as the header subtitle.
590
+ const HEADING =
591
+ 'px-4 pt-2.5 pb-0.5 text-[0.72rem] font-semibold tracking-[0.08em] uppercase text-fg-muted1';
592
+
426
593
  const NAV_ROW = 'hidden ml-auto items-center gap-2 min-[640px]:flex';
427
- const navPanel = $derived(cn(NAV_PANEL, menuOpen ? 'flex' : 'hidden'));
428
594
 
429
595
  // NAV LINKS (jelinek.css:472 + partials/header.html:6-8). His 36354b9 turned
430
596
  // them into TERTIARY BUTTONS: the markup is literally
@@ -473,6 +639,14 @@
473
639
  const BURGER =
474
640
  'ml-auto inline-flex h-11 w-11 flex-none cursor-pointer flex-col justify-center gap-[5px] ' +
475
641
  'rounded-button border-0 bg-transparent px-2.5 min-[640px]:hidden';
642
+
643
+ // The `barActions` slot (see its prop doc). `ml-auto` claims the bar's free
644
+ // space so the controls sit against the burger on the right; the burger then
645
+ // drops its OWN `ml-auto` (via the cn() override below) — two auto margins
646
+ // in one flex row would each take half the free space and strand the
647
+ // controls mid-bar. Phone-only: from 640px up the nav row's `ml-auto` owns
648
+ // the right side and these controls belong in `actions`.
649
+ const BAR_ACTIONS = 'ml-auto flex items-center gap-1 min-[640px]:hidden';
476
650
  const BAR =
477
651
  'block h-0.5 rounded-[2px] bg-fg ' +
478
652
  'transition-[transform,opacity] duration-[var(--dur-base)] ease-[var(--ease)]';
@@ -507,26 +681,27 @@
507
681
  // "render it yourself outside the component".
508
682
  </script>
509
683
 
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)}
684
+ <!-- One definition of a submenu column's links, rendered into both the
685
+ mega-menu columns (≥640px) and the drawer accordion (phone). -->
686
+ {#snippet subLinks(group: NavGroup)}
687
+ {#if group.title}<div class={HEADING} style={SUBTITLE_STYLE}>{group.title}</div>{/if}
688
+ {#each group.items as chapter (chapter.href)}
515
689
  <Button
516
- href={item.href}
690
+ href={chapter.href}
517
691
  variant="ghost"
518
692
  color="black"
519
- active={isActive(item, active)}
520
- aria-current={isActive(item, active) ? 'page' : undefined}
521
- class={linkClass}
693
+ active={isActive(chapter, active)}
694
+ aria-current={isActive(chapter, active) ? 'page' : undefined}
695
+ class={CHAPTER_LINK}
522
696
  >
523
- {item.label}
697
+ {chapter.label}
524
698
  </Button>
525
699
  {/each}
526
700
  {/snippet}
527
701
 
528
- <header {...rest} class={cn(OUTER, className)}>
702
+ <header {...rest} bind:this={headerEl} class={cn(OUTER, className)}>
529
703
  <div class={inner}>
704
+ <div class={BAR_ROW}>
530
705
  <a href={logoHref} aria-label={logoAlt} class={LOGO_LINK}>
531
706
  {#if logo}
532
707
  {@render logo()}
@@ -557,6 +732,9 @@
557
732
  {/if}
558
733
  </a>
559
734
  <div class={TITLE}>JELÍNEK{#if subtitle}<span class={SUBTITLE} style={SUBTITLE_STYLE}>{subtitle}</span>{/if}</div>
735
+ {#if barActions}
736
+ <div class={BAR_ACTIONS}>{@render barActions()}</div>
737
+ {/if}
560
738
  {#if items.length > 0}
561
739
  <!-- The burger precedes the panel in source order so Tab from it lands on
562
740
  the first link it just revealed, and because that is the order in
@@ -564,7 +742,7 @@
564
742
  <button
565
743
  type="button"
566
744
  bind:this={menuButton}
567
- class={BURGER}
745
+ class={cn(BURGER, barActions !== undefined && 'ml-0')}
568
746
  aria-expanded={menuOpen}
569
747
  aria-controls={panelId}
570
748
  aria-label={menuOpen ? menuLabelClose : menuLabel}
@@ -574,19 +752,153 @@
574
752
  <span class={bar2}></span>
575
753
  <span class={bar3}></span>
576
754
  </button>
577
- <!-- The ≥640px row. Inside the glass bar, so it is a flex item of it. -->
755
+ <!-- The ≥640px row. Inside the glass bar, so it is a flex item of it.
756
+ Section links with a submenu open their mega-menu panel on hover or
757
+ keyboard focus; the click still navigates (to the section's first
758
+ chapter), so the link works without a mouse and on touch. -->
578
759
  <nav class={NAV_ROW} aria-label={navLabel}>
579
- {@render navLinks(NAV_LINK)}
760
+ {#each items as item, i (item.href)}
761
+ {#if item.submenu}
762
+ <!-- A BUTTON, not a link — the owner's call: the first level
763
+ never navigates. Hover/focus slides the panel out; click
764
+ only opens it too (not a toggle — the focus that precedes
765
+ a click already opened it, so a toggle would snap it shut).
766
+ Esc / mouse-out / clicking elsewhere closes. -->
767
+ <Button
768
+ variant="ghost"
769
+ color="black"
770
+ active={isActive(item, active)}
771
+ aria-current={isActive(item, active) ? 'page' : undefined}
772
+ aria-expanded={openSub === item.href}
773
+ aria-controls={`${uid}-sub-${i}`}
774
+ id={`${uid}-link-${i}`}
775
+ class={NAV_LINK}
776
+ onmouseenter={() => openSubmenu(item.href)}
777
+ onmouseleave={scheduleSubClose}
778
+ onfocus={() => openSubmenu(item.href)}
779
+ onclick={() => openSubmenu(item.href)}
780
+ >
781
+ {item.label}
782
+ </Button>
783
+ {:else}
784
+ <Button
785
+ href={item.href}
786
+ variant="ghost"
787
+ color="black"
788
+ active={isActive(item, active)}
789
+ aria-current={isActive(item, active) ? 'page' : undefined}
790
+ class={NAV_LINK}
791
+ >
792
+ {item.label}
793
+ </Button>
794
+ {/if}
795
+ {/each}
580
796
  {#if actions}{@render actions()}{/if}
581
797
  </nav>
582
798
  {/if}
799
+ </div>
800
+ <!-- Mega-menu panels (≥640px): children of the glass card, below the bar
801
+ row — the card stretches down with the open panel, so the bar and the
802
+ submenu share one background, border and shadow. Exactly one can be
803
+ open at a time (openSub). -->
804
+ {#each items as item, i (item.href)}
805
+ {#if item.submenu}
806
+ <!-- role="presentation": the wrapper is a hover surface; the links
807
+ inside carry the semantics (the keyboard path never needs the
808
+ mouse handlers — focus opens/closes the panel via the link). -->
809
+ <div
810
+ id={`${uid}-sub-${i}`}
811
+ role="presentation"
812
+ class={cn(MEGA, openSub === item.href ? MEGA_OPEN : MEGA_CLOSED)}
813
+ onmouseenter={cancelSubClose}
814
+ onmouseleave={scheduleSubClose}
815
+ >
816
+ <!-- Columns = the section's group count. The guide's own rule is a fixed
817
+ repeat(3, 1fr) (jelinek.css .site-menu__cols) because both its sections
818
+ have exactly three groups; a consumer's section may have four or two, and
819
+ a fixed 3 would wrap the surplus group onto a lonely second row. The
820
+ inline style computes to the identical value for the guide's own pages. -->
821
+ <div
822
+ class={cn(MEGA_COLS, openSub === item.href && MEGA_COLS_OPEN)}
823
+ style={`grid-template-columns:repeat(${item.submenu.length},minmax(0,1fr))`}
824
+ >
825
+ {#each item.submenu as group, gi (group.title ?? gi)}
826
+ <div class={MEGA_COL}>
827
+ {@render subLinks(group)}
828
+ </div>
829
+ {/each}
830
+ </div>
831
+ </div>
832
+ {/if}
833
+ {/each}
583
834
  </div>
584
835
  {#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
836
+ <!-- The page dim under the open drawer (phone only). Its own element so
837
+ it can sit between the page and the drawer; a tap on it is a
838
+ pointerdown outside linksEl, so the dismissal effect closes the menu. -->
839
+ <div class={backdrop} aria-hidden="true"></div>
840
+ <!-- The phone drawer. A SIBLING of the glass bar, not a child — see
841
+ DRAWER's comment for the measurement that forced that. Hidden from
587
842
  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)}
843
+ <nav id={panelId} bind:this={linksEl} class={drawer} aria-label={navLabel}>
844
+ <div class={DRAWER_HEAD}>
845
+ <span class={DRAWER_LABEL} style={SUBTITLE_STYLE}>Menu</span>
846
+ <button type="button" class={DRAWER_CLOSE} aria-label={menuLabelClose} onclick={closeMenu}>
847
+ <svg class="h-[18px] w-[18px]" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 3l10 10M13 3L3 13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
848
+ </button>
849
+ </div>
850
+ {#each items as item, i (item.href)}
851
+ {#if item.submenu}
852
+ <div>
853
+ <div class={GROUP_ROW}>
854
+ <!-- The label is a BUTTON too: tapping it unfolds the
855
+ chapters, same as the chevron (the owner's call — a
856
+ navigating first level was easy to hit by mistake on
857
+ a phone). The chevron stays as the visual cue. -->
858
+ <Button
859
+ variant="ghost"
860
+ color="black"
861
+ active={isActive(item, active)}
862
+ aria-current={isActive(item, active) ? 'page' : undefined}
863
+ aria-expanded={!!openGroups[item.href]}
864
+ aria-controls={`${uid}-acc-${i}`}
865
+ class={cn(NAV_LINK, 'grow')}
866
+ onclick={() => (openGroups[item.href] = !openGroups[item.href])}
867
+ >
868
+ {item.label}
869
+ </Button>
870
+ <button
871
+ type="button"
872
+ class={CHEVRON}
873
+ aria-expanded={!!openGroups[item.href]}
874
+ aria-controls={`${uid}-acc-${i}`}
875
+ aria-label={`Rozbalit kapitoly sekce ${item.label}`}
876
+ onclick={() => (openGroups[item.href] = !openGroups[item.href])}
877
+ >
878
+ <svg class={cn(CHEVRON_ICON, openGroups[item.href] && 'rotate-180')} viewBox="0 0 16 16" aria-hidden="true"><path d="m3.5 6 4.5 4.5L12.5 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
879
+ </button>
880
+ </div>
881
+ <div id={`${uid}-acc-${i}`} class={cn(ACC_PANEL, openGroups[item.href] ? ACC_OPEN : ACC_CLOSED)}>
882
+ <div class={ACC_INNER}>
883
+ {#each item.submenu as group, gi (group.title ?? gi)}
884
+ {@render subLinks(group)}
885
+ {/each}
886
+ </div>
887
+ </div>
888
+ </div>
889
+ {:else}
890
+ <Button
891
+ href={item.href}
892
+ variant="ghost"
893
+ color="black"
894
+ active={isActive(item, active)}
895
+ aria-current={isActive(item, active) ? 'page' : undefined}
896
+ class={NAV_LINK}
897
+ >
898
+ {item.label}
899
+ </Button>
900
+ {/if}
901
+ {/each}
590
902
  {#if chapterItems.length > 0}
591
903
  <div class={CHAPTERS}>
592
904
  {#each chapterItems as chapter (chapter.href)}
@@ -72,6 +72,22 @@ interface Props {
72
72
  * it is not a general slot.
73
73
  */
74
74
  actions?: Snippet;
75
+ /**
76
+ * Controls that must stay visible IN THE BAR on a phone — an e-shop's
77
+ * cart button, a search trigger. `actions` cannot serve them: it lives
78
+ * inside the nav, which below 640px is the burger drawer, so anything
79
+ * in it costs a reader two taps and disappears from the bar entirely.
80
+ * The guide's own header has no such controls (its one nav-adjacent
81
+ * control, the theme toggle, belongs in `actions`), so this slot adds
82
+ * a consumer seam rather than reproducing guide markup.
83
+ *
84
+ * Rendered between the lockup and the burger, right-aligned, and
85
+ * HIDDEN from 640px up — on a desktop the same controls belong in
86
+ * `actions`, inside the nav row, and rendering both would double
87
+ * them. A consumer using this slot typically renders the same
88
+ * snippet content in both props.
89
+ */
90
+ barActions?: Snippet;
75
91
  class?: string;
76
92
  }
77
93
  type $$ComponentProps = Props & HTMLAttributes<HTMLElement>;
@@ -62,9 +62,16 @@
62
62
  // its solid styling even while hovered — reproduced here by only adding the
63
63
  // hover: classes to the unselected branch, never to the selected one.
64
64
  // Neither branch needs an explicit dark: override: bg-jelinek-black /
65
- // text-on-jelinek-black / text-fg-muted1 / bg-fg-muted1 / text-fg-inverse
65
+ // text-on-jelinek-black / bg-fg-muted1 / text-fg-inverse
66
66
  // all resolve through theme-aware CSS custom properties that already flip
67
67
  // under [data-theme="dark"]/.dark.
68
+ //
69
+ // The INACTIVE segment is --JELINEK-BLACK (text-jelinek-black), not
70
+ // --text-muted1 — the guide changed .tab to match its tertiary buttons
71
+ // (.btn--ghost.btn--black) after the muted grey washed out over the
72
+ // logo-tool photo. Marked provisional in jelinek.css: the pill/button
73
+ // relationship gets its final definition with the button-system design
74
+ // (docs/chybejici-komponenty.md).
68
75
  </script>
69
76
 
70
77
  <button
@@ -80,7 +87,7 @@
80
87
  BASE,
81
88
  selected
82
89
  ? 'bg-jelinek-black text-on-jelinek-black'
83
- : 'bg-transparent text-fg-muted1 hover:bg-fg-muted1 hover:text-fg-inverse',
90
+ : 'bg-transparent text-jelinek-black hover:bg-fg-muted1 hover:text-fg-inverse',
84
91
  className
85
92
  )}
86
93
  >
@@ -13,4 +13,4 @@
13
13
  * fill="currentColor" so only the antlers — not the square behind them —
14
14
  * follow the content division), only the path data itself is identical.
15
15
  */
16
- export declare const DEER_MARK_PATH = "M216.7,187.9h26.5c1,0,2,0,3,0,12.6,0,26.9.1,36.7-9.8,6.8-6.9,10.1-17.4,10.1-32h-12.7c0,10.8-2.1,18.5-6.4,22.8-6.1,6.1-17,6.1-27.7,6-1,0-2.1,0-3.1,0h-13.7v-33.5h-12.7v33.5h-23.2l-55-31v-48.2h-12.7v41l-9.1-5.1h-.3c-.5-.4-53.6-27.6-54.2-69.3l-12.7.2c.7,49,57.1,78.5,61.2,80.5l56.5,31.8h-41.4v6.5c0,12.5,5,19.7,11.5,25.4,6.5,5.7,22.9,5.7,22.9,5.7h19.3v-13h-22.9c-9.6,0-15.4-3.9-17.4-11.7h51l79.9,42.3v27.2l-32.6-12.5-2.4-.5-99,53.6v13.7l2.6-.3,99.6-53.6,34,13.2,10.4-4.7v-39.9l-3.3-5.7-63-32.8Z";
16
+ export declare const DEER_MARK_PATH = "M220.3,196.1h23c.9,0,1.8,0,2.6,0,10.9,0,23.3.1,31.8-8.5,5.9-6,8.8-15.1,8.8-27.7h-11c0,9.4-1.9,16-5.5,19.8-5.3,5.3-14.8,5.3-24,5.2-.9,0-1.8,0-2.7,0h-11.9v-29.1h-11v29.1h-20.1l-47.7-26.9v-41.8h-11v35.6l-7.9-4.4h-.2c-.5-.4-46.5-23.9-47-60.1l-11,.2c.6,42.5,49.6,68.1,53.1,69.9l49,27.6h-35.9v5.6c0,10.9,4.4,17.1,10,22.1,5.6,5,19.9,4.9,19.9,4.9h16.8v-11.2h-19.9c-8.3,0-13.4-3.4-15.1-10.1h44.3l69.3,36.7v23.6l-28.3-10.9-2.1-.4-85.9,46.5v11.9l2.3-.3,86.5-46.5,29.5,11.5,9.1-4.1v-34.7l-2.9-4.9-54.7-28.5Z";
@@ -13,4 +13,8 @@
13
13
  * fill="currentColor" so only the antlers — not the square behind them —
14
14
  * follow the content division), only the path data itself is identical.
15
15
  */
16
- export const DEER_MARK_PATH = 'M216.7,187.9h26.5c1,0,2,0,3,0,12.6,0,26.9.1,36.7-9.8,6.8-6.9,10.1-17.4,10.1-32h-12.7c0,10.8-2.1,18.5-6.4,22.8-6.1,6.1-17,6.1-27.7,6-1,0-2.1,0-3.1,0h-13.7v-33.5h-12.7v33.5h-23.2l-55-31v-48.2h-12.7v41l-9.1-5.1h-.3c-.5-.4-53.6-27.6-54.2-69.3l-12.7.2c.7,49,57.1,78.5,61.2,80.5l56.5,31.8h-41.4v6.5c0,12.5,5,19.7,11.5,25.4,6.5,5.7,22.9,5.7,22.9,5.7h19.3v-13h-22.9c-9.6,0-15.4-3.9-17.4-11.7h51l79.9,42.3v27.2l-32.6-12.5-2.4-.5-99,53.6v13.7l2.6-.3,99.6-53.6,34,13.2,10.4-4.7v-39.9l-3.3-5.7-63-32.8Z';
16
+ // Refreshed 2026-07-29 from the new master files the user uploaded to
17
+ // assets/logos/mark/Digital/ (path taken verbatim from Mark-RGB_NOBKG-BLK.svg;
18
+ // every variant in the set carries the identical path). partials/header.html:4
19
+ // was updated in the same change — the two must keep matching.
20
+ export const DEER_MARK_PATH = 'M220.3,196.1h23c.9,0,1.8,0,2.6,0,10.9,0,23.3.1,31.8-8.5,5.9-6,8.8-15.1,8.8-27.7h-11c0,9.4-1.9,16-5.5,19.8-5.3,5.3-14.8,5.3-24,5.2-.9,0-1.8,0-2.7,0h-11.9v-29.1h-11v29.1h-20.1l-47.7-26.9v-41.8h-11v35.6l-7.9-4.4h-.2c-.5-.4-46.5-23.9-47-60.1l-11,.2c.6,42.5,49.6,68.1,53.1,69.9l49,27.6h-35.9v5.6c0,10.9,4.4,17.1,10,22.1,5.6,5,19.9,4.9,19.9,4.9h16.8v-11.2h-19.9c-8.3,0-13.4-3.4-15.1-10.1h44.3l69.3,36.7v23.6l-28.3-10.9-2.1-.4-85.9,46.5v11.9l2.3-.3,86.5-46.5,29.5,11.5,9.1-4.1v-34.7l-2.9-4.9-54.7-28.5Z';
@@ -16,6 +16,27 @@ export type NavItem = {
16
16
  * special-cased to an exact compare — see isActive() in SiteHeader.
17
17
  */
18
18
  match?: 'exact' | 'prefix';
19
+ /**
20
+ * Compare `active` against this path instead of `href`. For a section link
21
+ * whose href jumps straight to the section's first chapter (the way
22
+ * partials/header.html sends "Brand guide" to /brand/znacka.html) but which
23
+ * must stay lit on every page of the section, not just that chapter.
24
+ */
25
+ matchHref?: string;
26
+ /**
27
+ * Chapters of this section, split into columns. On a desktop the columns
28
+ * make up the section's mega-menu panel (slides out from under the header
29
+ * bar on hover/focus); on a phone they flatten into one accordion list
30
+ * under the section's row in the drawer. A column's `title` renders as a
31
+ * small uppercase heading above its links (the UI Kit groups its pages;
32
+ * the brand guide's plain chapter list leaves it unset). Mirrors the
33
+ * `.site-menu__group`/`.site-menu__panel` markup in partials/header.html.
34
+ */
35
+ submenu?: NavGroup[];
36
+ };
37
+ export type NavGroup = {
38
+ title?: string;
39
+ items: NavItem[];
19
40
  };
20
41
  /**
21
42
  * Does `pathname` count as being on `item`? Exported because SiteHeader renders
@@ -6,11 +6,12 @@
6
6
  export function isActive(item, pathname) {
7
7
  if (pathname === undefined)
8
8
  return false;
9
+ const target = item.matchHref ?? item.href;
9
10
  if (item.match !== 'prefix')
10
- return item.href === pathname;
11
+ return target === pathname;
11
12
  // A bare "/" prefix would match every path, which is never what a section
12
13
  // link means. Compare exactly instead.
13
- const base = item.href.replace(/\/$/, '');
14
+ const base = target.replace(/\/$/, '');
14
15
  if (base === '')
15
16
  return pathname === '/' || pathname === '';
16
17
  return pathname === base || pathname.startsWith(`${base}/`);
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export { default as Eyebrow } from './components/Eyebrow.svelte';
14
14
  export { default as Glass } from './components/Glass.svelte';
15
15
  export { default as Grid } from './components/Grid.svelte';
16
16
  export { default as Label } from './components/Label.svelte';
17
- export { isActive, type NavItem } from './components/nav.js';
17
+ export { isActive, type NavItem, type NavGroup } from './components/nav.js';
18
18
  export { default as PageHero } from './components/PageHero.svelte';
19
19
  export { default as Pager } from './components/Pager.svelte';
20
20
  export { default as Panel } from './components/Panel.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jelinek/ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "JELÍNEK Svelte 5 component kit, generated from the brand guide",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",