@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,306 @@
1
+ <script lang="ts">
2
+ import { setContext, type Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { cn } from '../utils/cn.js';
5
+ import { TABS_CONTEXT, type TabsContext } from './tabs-context.js';
6
+
7
+ interface Props {
8
+ value?: string;
9
+ /**
10
+ * Accessible names for the two scroll arrows. Verbatim from
11
+ * assets/js/jelinek.js:502-503, which passes exactly these strings to
12
+ * `arrow()` and sets them as the buttons' `aria-label`. Czech by default,
13
+ * like every other user-facing string this kit ships (see ChapterNav's
14
+ * `label`).
15
+ */
16
+ prevLabel?: string;
17
+ nextLabel?: string;
18
+ class?: string;
19
+ children: Snippet;
20
+ }
21
+
22
+ let {
23
+ value = $bindable(''),
24
+ prevLabel = 'Posunout na předchozí položky',
25
+ nextLabel = 'Posunout na další položky',
26
+ class: className,
27
+ children,
28
+ ...rest
29
+ }: Props & HTMLAttributes<HTMLDivElement> = $props();
30
+
31
+ setContext<TabsContext>(TABS_CONTEXT, {
32
+ current: () => value,
33
+ select: (next) => (value = next)
34
+ });
35
+
36
+ // jelinek.css:834-838 (.tabs): display:flex, 6px gap. gap-1.5 = 6px —
37
+ // a fixed px, not 1.5 × a rem-relative default; see theme.css's
38
+ // `--spacing: 4px` pin (gen-tokens.js) for why that distinction matters
39
+ // under base.css's responsive root font-size (Task 20).
40
+ //
41
+ // TASK 31 — `.tabs` STOPPED WRAPPING AND BECAME A SIDEWAYS SCROLLER. The
42
+ // guide now reads `flex-wrap: nowrap; overflow-x: auto; scroll-behavior:
43
+ // smooth; scrollbar-width: none; -ms-overflow-style: none`, plus
44
+ // `.tabs::-webkit-scrollbar { display: none }` (:844-846, a rule shared with
45
+ // .chapter-nav__inner and .logo-tool__bgtabs, which get the same scroller
46
+ // declarations at :840-843). Its own comment states the intent: when the
47
+ // segments do not fit, they "NEZALOMÍ se do dalšího řádku, ale posouvají se
48
+ // do strany" — they no longer break onto a second line, they scroll
49
+ // sideways.
50
+ //
51
+ // This is a PAIRED change with Tab.svelte's `flex: 1 0 auto` (jelinek.css:880,
52
+ // was `flex: 1`). Do not touch one without the other: `flex: 1` carries a 0%
53
+ // basis, so segments would happily shrink below their own text inside a
54
+ // nowrap row and nothing would ever overflow — the scroller would never
55
+ // engage and the labels would just squash. `1 0 auto` grows to fill a wide
56
+ // row but never shrinks, which is what produces the overflow this rule
57
+ // scrolls.
58
+ // TASK 33 — the pill joined the surface system. The designer's 8d68770
59
+ // ("pilulky: stejný border a horní highlight jako ostatní povrchy") replaced
60
+ // `border: 0` with `border: 1px solid var(--border-subtle)` — the same
61
+ // hairline .panel/.card/.tile carry — and appended the shared dark-mode top
62
+ // highlight `inset 0 2px 1px -1px var(--box-primary)` to the dark inset
63
+ // shadow. The shadow's own two layers moved to HSL notation in b45c536 at
64
+ // bit-identical values (#00000040 = 0.251, #00000010 = 0.063, #ffffff40,
65
+ // #ffffff10 likewise), and they follow that notation here — in the
66
+ // SPACE-separated hsl() form, `hsl(0 0% 0% / 0.251)`, not the guide's
67
+ // comma-separated `hsl(0, 0%, 0%, 0.251)`. The two are the same colour in
68
+ // CSS Color 4; the difference is forced by Tailwind's arbitrary-value
69
+ // syntax, where commas inside `shadow-[…]` are the shadow-LAYER separator
70
+ // and a comma-form hsl() would be parsed as several broken layers. That is
71
+ // why the previous version of this line used #RRGGBBAA hex at all.
72
+ //
73
+ // --box-primary is a real passthrough token as of this task (it was promoted
74
+ // from a per-surface local property to one neutral :root value in 4ec82cd,
75
+ // see scripts/token-map.js), so it is referenced by name rather than
76
+ // substituted — the previous `var(--color-jelinek-black)` stand-in would now
77
+ // render a 95%-light highlight in dark mode instead of the intended 50% grey.
78
+ const BASE =
79
+ 'flex flex-nowrap gap-1.5 rounded-pill border border-border-subtle bg-transparent p-1.5 ' +
80
+ 'overflow-x-auto [scroll-behavior:smooth] [scrollbar-width:none] ' +
81
+ '[-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ' +
82
+ 'shadow-[inset_0_0px_5px_hsl(0_0%_0%_/_0.251),inset_0_0px_5px_hsl(0_0%_0%_/_0.063)] ' +
83
+ 'dark:shadow-[0_0_10px_hsl(0_0%_100%_/_0.251)_inset,inset_0_0px_50px_hsl(0_0%_100%_/_0.063),inset_0_2px_1px_-1px_var(--box-primary)]';
84
+
85
+ // jelinek.css:894-900 (shared segmented-switcher pill — selector list also
86
+ // covers .color-formats/.logo-tool__langs/.logo-tool__bgtabs): transparent
87
+ // background, no border, pill radius, 6px padding (p-1.5, same unit as the
88
+ // row gap), plus a two-layer inset shadow, above. Every appearance of tabs in
89
+ // the guide sits inside this pill — ui-old/komponenty.html:117 puts both
90
+ // classes (`class="tabs tabs--demo"`) on the very same element this component
91
+ // renders, and ui-old/barvy-v-akci.html:54 does the same for the division
92
+ // switcher. There is no bare-track tab style in the guide, so this pill IS
93
+ // Tabs' default appearance, not optional demo chrome. The shadow values are
94
+ // literals in the guide, not tokens, so they stay Tailwind arbitrary values
95
+ // here rather than named shadow-resting/interactive utilities.
96
+ //
97
+ // jelinek.css:255-258 (dark-mode counterpart, same selector list): a
98
+ // different, hand-written inset shadow (not a token) — applied through the
99
+ // kit's `dark:` variant (tokens.css:181).
100
+ //
101
+ // STILL NOT reproduced here: the width. That rule moved and became
102
+ // responsive in the merge — `.tabs--demo, .color-formats { width: 100%;
103
+ // margin-inline: auto }` with `@media (min-width: 640px) { width: 75% }`
104
+ // (jelinek.css:1067-1068) — but it is still page-layout centring for the
105
+ // guide's demo card rather than part of the switcher, so it stays out of the
106
+ // component on exactly the reasoning that kept the old flat `width: 75%`
107
+ // out. Callers apply it through the external `class` prop, and the showcase
108
+ // caller uses the mobile-first pair (see routes/komponenty/+page.svelte).
109
+ // Baking it in would be worse now than before: a component that forces
110
+ // itself to 75% of its parent cannot be used in a narrow column at all.
111
+
112
+ // ─── TASK 32 — THE SCROLL AFFORDANCE, AND WHY IT IS ARROWS ──────────────
113
+ //
114
+ // Task 31 shipped the scroller CSS without the thing that makes a hidden
115
+ // scrollbar usable, and said so: an overflowing row showed nothing at all.
116
+ // Two closable designs existed in the guide, and this component takes the
117
+ // one the guide applies to THIS element:
118
+ //
119
+ // - `.tabs` gets ARROW BUTTONS. assets/js/jelinek.js:477-521
120
+ // (`initTabScroll`) wraps every element matching `SCROLLERS` (:475 —
121
+ // `.tabs, .logo-tool__bgtabs, .tile-scroller`) in a `.tabs-scroll` div
122
+ // (jelinek.css:851) and injects `.tabs-scroll__prev/__next`, styled at
123
+ // jelinek.css:858-873.
124
+ // - `.chapter-nav__inner` gets GRADIENT EDGE FADES, and jelinek.js
125
+ // :465-474 goes out of its way to EXCLUDE it from that selector, in a
126
+ // comment that is explicit about why: "Dvě různá vodítka na jednom
127
+ // prvku … nenavrhl nikdo" (nobody designed two different affordances on
128
+ // one element), and "kdyby lišta měla mít šipky místo zastínění, vrať
129
+ // sem `.chapter-nav__inner` a smaž ::before/::after v CSS — obojí naráz
130
+ // ne".
131
+ //
132
+ // So the two treatments are a deliberate per-element choice, not a default
133
+ // and an exception, and copying ChapterNav's fades onto tabs would be a
134
+ // deviation from the guide rather than reuse of it. It would also not work:
135
+ // a fade is a `linear-gradient` from an OPAQUE surface colour, and
136
+ // ChapterNav can use `var(--color-surface)` because its bar is always the
137
+ // page background. `.tabs` is `background: transparent` sitting inside
138
+ // whatever surface a caller puts it on — a `<Demo variant="brown">`, a
139
+ // `<Panel variant="black">`, a photo behind `<Glass>` — so the same fade
140
+ // would paint a page-coloured smear over a coloured card. Arrows are
141
+ // self-contained (`bg-surface` pill + shadow) and carry no assumption about
142
+ // what is behind them.
143
+ //
144
+ // Written as real markup rather than injected on mount, because a component
145
+ // owns its markup — the same call PageHero makes about the guide's
146
+ // `<hr class="rule">`. That also fixes the guide's own ordering hazard: its
147
+ // arrows only exist after `initTabScroll()` runs, so a `.tabs` rendered
148
+ // later by an app's own JS never gets any.
149
+ //
150
+ // WHAT THIS DOES NOT REPRODUCE: jelinek.js:496-497's `try/catch` around
151
+ // `scrollBy({...})`, a fallback for embedded webviews that lack the object
152
+ // form. This kit's floor is Svelte 5 + `ResizeObserver`, both of which
153
+ // postdate `ScrollToOptions` everywhere, so the catch would be unreachable.
154
+ // Recorded as a decision, not an oversight.
155
+ //
156
+ // GEOMETRY NOTE. The guide pins its arrows to the WRAPPER's edges
157
+ // (`left: 2px` / `right: 2px`, jelinek.css:867-868), not the row's, and so
158
+ // does this. That is exact whenever the row fills its wrapper — which is
159
+ // every phone width, since the guide's own call-site width is `100%` below
160
+ // 640px and overflow is a narrow-screen condition. It is also why `class`
161
+ // keeps landing on the ROW rather than on the wrapper: the cn() contract and
162
+ // every existing caller depend on it. A caller that needs the WRAPPER to be
163
+ // a flex item with `flex: 1` (the guide does this for `.color-formats`)
164
+ // cannot express that through `class` today; no caller needs it yet.
165
+ const WRAP = 'relative min-w-0';
166
+
167
+ // jelinek.css:858-866 + :872-873, one to one: 30px circle, no border, no padding,
168
+ // centred content, surface fill + --text-muted1 glyph, --shadow-resting, and
169
+ // the two-property transition on the guide's own duration/easing tokens.
170
+ // Hover inverts to the neutral accent pair (--JELINEK-BLACK /
171
+ // --ON-JELINEK-BLACK), which flips itself in dark mode.
172
+ const ARROW =
173
+ 'absolute top-1/2 flex h-[30px] w-[30px] -translate-y-1/2 items-center justify-center ' +
174
+ 'rounded-pill border-0 p-0 bg-surface text-fg-muted1 shadow-resting cursor-pointer ' +
175
+ 'transition-[background-color,color] duration-[var(--dur-base)] ease-[var(--ease)] ' +
176
+ 'hover:bg-jelinek-black hover:text-on-jelinek-black';
177
+
178
+ // State comes from the scroller, never from a breakpoint: a two-segment row
179
+ // does not overflow at any width and correctly shows no arrow at all. The
180
+ // thresholds are the guide's own (jelinek.js:506-508 uses +2/-2 px slack, so
181
+ // sub-pixel layout never leaves a permanently-lit arrow that cannot move).
182
+ // Both start at 0, so server-rendered HTML contains no buttons and the
183
+ // hydrated page adds them only if the row really does overflow.
184
+ let scroller = $state<HTMLElement | null>(null);
185
+ let prevButton = $state<HTMLButtonElement | null>(null);
186
+ let nextButton = $state<HTMLButtonElement | null>(null);
187
+ let scrollLeft = $state(0);
188
+ let scrollMax = $state(0);
189
+ const showPrev = $derived(scrollMax > 2 && scrollLeft > 2);
190
+ const showNext = $derived(scrollMax > 2 && scrollLeft < scrollMax - 2);
191
+
192
+ $effect(() => {
193
+ const el = scroller;
194
+ if (!el) return;
195
+ function measure() {
196
+ if (!el) return;
197
+ scrollLeft = el.scrollLeft;
198
+ scrollMax = el.scrollWidth - el.clientWidth;
199
+ }
200
+ measure();
201
+ el.addEventListener('scroll', measure, { passive: true });
202
+ const ro = new ResizeObserver(measure);
203
+ ro.observe(el);
204
+ return () => {
205
+ el.removeEventListener('scroll', measure);
206
+ ro.disconnect();
207
+ };
208
+ });
209
+
210
+ // jelinek.js:493-497 — 80 % of the visible width per press, smooth.
211
+ //
212
+ // THE FOCUS HAND-OFF is this component's own addition, and it is why `nudge`
213
+ // is not simply a `scrollBy`. The guide's arrows are `display: none` the
214
+ // moment their direction is exhausted, so a keyboard user who presses Enter
215
+ // on "next" until the row ends has the button pulled out from under them and
216
+ // focus falls to <body> — measured, on this very page: one Enter at 320px
217
+ // takes scrollLeft from 0 to its 73px maximum, "next" unmounts, and
218
+ // `document.activeElement` becomes BODY, i.e. the next Tab press restarts
219
+ // from the top of the document.
220
+ //
221
+ // It cannot be fixed inline in this handler. At the moment of the press the
222
+ // opposite arrow does not exist yet — at scrollLeft 0 there is no "prev"
223
+ // button to hand focus to — and it only mounts once a scroll event reports
224
+ // the new position, part-way through the smooth animation. So the intent is
225
+ // recorded here and the effect below performs the focus when the element
226
+ // actually appears.
227
+ let handoff = $state<'prev' | 'next' | null>(null);
228
+
229
+ function nudge(dir: 'prev' | 'next') {
230
+ const el = scroller;
231
+ if (!el) return;
232
+ const step = Math.round(el.clientWidth * 0.8) * (dir === 'prev' ? -1 : 1);
233
+ const target = Math.max(0, Math.min(el.scrollLeft + step, scrollMax));
234
+ const exhausts = dir === 'prev' ? target <= 2 : target >= scrollMax - 2;
235
+ // If this press exhausts the direction, the pressed button is about to
236
+ // unmount; claim the opposite one, which by definition becomes reachable
237
+ // in the same move. Otherwise cancel any stale claim.
238
+ handoff = exhausts ? (dir === 'prev' ? 'next' : 'prev') : null;
239
+ el.scrollBy({ left: step, behavior: 'smooth' });
240
+ }
241
+
242
+ $effect(() => {
243
+ if (!handoff) return;
244
+ const target = handoff === 'prev' ? prevButton : nextButton;
245
+ if (!target) return; // not mounted yet; this effect re-runs when it is
246
+ target.focus();
247
+ handoff = null;
248
+ });
249
+
250
+ // jelinek.js:459-463 (`chevron()`), byte for byte: 16×16 in a 24-unit box, stroked in
251
+ // currentColor at 2.5 with round joins, so it inherits the button's hover
252
+ // colour change rather than needing its own rule.
253
+ const CHEVRON = { prev: 'M15 6l-6 6 6 6', next: 'M9 6l6 6-6 6' };
254
+ </script>
255
+
256
+ <div class={WRAP}>
257
+ <div bind:this={scroller} {...rest} role={rest.role ?? 'tablist'} class={cn(BASE, className)}>
258
+ {@render children()}
259
+ </div>
260
+ {#if showPrev}
261
+ <button
262
+ bind:this={prevButton}
263
+ type="button"
264
+ class={cn(ARROW, 'left-0.5')}
265
+ aria-label={prevLabel}
266
+ onclick={() => nudge('prev')}
267
+ >
268
+ <svg
269
+ width="16"
270
+ height="16"
271
+ viewBox="0 0 24 24"
272
+ fill="none"
273
+ stroke="currentColor"
274
+ stroke-width="2.5"
275
+ stroke-linecap="round"
276
+ stroke-linejoin="round"
277
+ aria-hidden="true"
278
+ >
279
+ <path d={CHEVRON.prev} />
280
+ </svg>
281
+ </button>
282
+ {/if}
283
+ {#if showNext}
284
+ <button
285
+ bind:this={nextButton}
286
+ type="button"
287
+ class={cn(ARROW, 'right-0.5')}
288
+ aria-label={nextLabel}
289
+ onclick={() => nudge('next')}
290
+ >
291
+ <svg
292
+ width="16"
293
+ height="16"
294
+ viewBox="0 0 24 24"
295
+ fill="none"
296
+ stroke="currentColor"
297
+ stroke-width="2.5"
298
+ stroke-linecap="round"
299
+ stroke-linejoin="round"
300
+ aria-hidden="true"
301
+ >
302
+ <path d={CHEVRON.next} />
303
+ </svg>
304
+ </button>
305
+ {/if}
306
+ </div>
@@ -0,0 +1,20 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ interface Props {
4
+ value?: string;
5
+ /**
6
+ * Accessible names for the two scroll arrows. Verbatim from
7
+ * assets/js/jelinek.js:502-503, which passes exactly these strings to
8
+ * `arrow()` and sets them as the buttons' `aria-label`. Czech by default,
9
+ * like every other user-facing string this kit ships (see ChapterNav's
10
+ * `label`).
11
+ */
12
+ prevLabel?: string;
13
+ nextLabel?: string;
14
+ class?: string;
15
+ children: Snippet;
16
+ }
17
+ type $$ComponentProps = Props & HTMLAttributes<HTMLDivElement>;
18
+ declare const Tabs: import("svelte").Component<$$ComponentProps, {}, "value">;
19
+ type Tabs = ReturnType<typeof Tabs>;
20
+ export default Tabs;
@@ -0,0 +1,71 @@
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
+
6
+ type Variant = 'solid' | 'soft' | 'fill' | 'instock';
7
+
8
+ interface Props {
9
+ variant?: Variant;
10
+ /** Hue 0-360, for the soft and fill variants. */
11
+ hue?: number;
12
+ /** Saturation 0-100, for the soft and fill variants. */
13
+ saturation?: number;
14
+ class?: string;
15
+ children: Snippet;
16
+ }
17
+
18
+ let {
19
+ variant = 'solid',
20
+ hue,
21
+ saturation,
22
+ class: className,
23
+ children,
24
+ ...rest
25
+ }: Props & HTMLAttributes<HTMLSpanElement> = $props();
26
+
27
+ // jelinek.css:581-584 (.tag): display:inline-block (not flex), 3px/12px
28
+ // padding (py-1 would be 4px, not 3px), font-weight 700 (not 500), and a
29
+ // fixed 0.78rem font-size that is NOT on the --step scale (its nearest
30
+ // rung, --step-down1, is 0.875rem since Task 25 — a flat value now, but
31
+ // still not this one) — kept as an arbitrary value.
32
+ const BASE = 'inline-block rounded-pill px-3 py-[3px] font-secondary text-[0.78rem] font-bold';
33
+
34
+ // `solid` has no CSS counterpart — bare `.tag` sets no background/color of
35
+ // its own in jelinek.css (real usages either add `.tag--instock` or an
36
+ // inline `style="background:..."`). This default is this component's own
37
+ // judgment call for a neutral resting tag, not a literal translation.
38
+ const VARIANT: Record<Variant, string> = {
39
+ solid: 'bg-surface-gray text-fg',
40
+ instock: 'bg-sem-success-strong text-fg-inverse',
41
+ // The soft and fill shades are computed from --tag-h / --tag-s, which are
42
+ // set inline. The lightness steps (90 / 54 / 24) and their light-vs-dark
43
+ // swap live in jelinek.css lines 592-596: dark mode re-lightens the
44
+ // *background* for soft but the *text* for fill — not the same property.
45
+ soft: 'bg-[hsl(var(--tag-h),var(--tag-s),90%)] text-[hsl(var(--tag-h),var(--tag-s),54%)] dark:bg-[hsl(var(--tag-h),var(--tag-s),24%)]',
46
+ fill: 'bg-[hsl(var(--tag-h),var(--tag-s),54%)] text-[hsl(var(--tag-h),var(--tag-s),90%)] dark:text-[hsl(var(--tag-h),var(--tag-s),24%)]'
47
+ };
48
+
49
+ const tunable = $derived(variant === 'soft' || variant === 'fill');
50
+
51
+ const style = $derived(tunable ? `--tag-h: ${hue ?? 0}; --tag-s: ${saturation ?? 0}%;` : undefined);
52
+
53
+ // Composes rather than overwrites: the previous version set the bare
54
+ // `style` shorthand unconditionally after {...rest}, which is `undefined`
55
+ // for the solid/instock variants — erasing a consumer's own `style` to
56
+ // `null` on those variants instead of leaving it alone. Trailing
57
+ // semicolons are stripped from each piece before rejoining: `style`
58
+ // already ends in `;`, so a naive join produced a double `;;`, which
59
+ // `CSSStyleDeclaration.cssText`'s parser treats as a malformed
60
+ // declaration and silently drops everything after it.
61
+ const mergedStyle = $derived(
62
+ [style, rest.style]
63
+ .filter((s): s is string => Boolean(s))
64
+ .map((s) => s.trim().replace(/;+$/, ''))
65
+ .join('; ') || undefined
66
+ );
67
+ </script>
68
+
69
+ <span {...rest} style={mergedStyle} class={cn(BASE, VARIANT[variant], className)}>
70
+ {@render children()}
71
+ </span>
@@ -0,0 +1,16 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type Variant = 'solid' | 'soft' | 'fill' | 'instock';
4
+ interface Props {
5
+ variant?: Variant;
6
+ /** Hue 0-360, for the soft and fill variants. */
7
+ hue?: number;
8
+ /** Saturation 0-100, for the soft and fill variants. */
9
+ saturation?: number;
10
+ class?: string;
11
+ children: Snippet;
12
+ }
13
+ type $$ComponentProps = Props & HTMLAttributes<HTMLSpanElement>;
14
+ declare const Tag: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ type Tag = ReturnType<typeof Tag>;
16
+ export default Tag;
@@ -0,0 +1,100 @@
1
+ <script lang="ts">
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import { cn } from '../utils/cn.js';
4
+ import Label from './Label.svelte';
5
+
6
+ interface Props {
7
+ value?: string;
8
+ label?: string;
9
+ id?: string;
10
+ /**
11
+ * Invalid state. Reproduces the guide's `.input.is-error`
12
+ * (jelinek.css:988-991) — new in the Task 31 merge — and sets
13
+ * `aria-invalid` alongside it, so the state is announced and not only
14
+ * coloured. See ERROR below.
15
+ */
16
+ error?: boolean;
17
+ class?: string;
18
+ }
19
+
20
+ let {
21
+ value = $bindable(''),
22
+ label,
23
+ id,
24
+ error = false,
25
+ class: className,
26
+ ...rest
27
+ }: Props & Omit<HTMLInputAttributes, 'value'> = $props();
28
+
29
+ // $props.id() gives a stable per-instance id, so a label can be associated
30
+ // without the consumer having to invent one.
31
+ const uid = $props.id();
32
+ const inputId = $derived(id ?? uid);
33
+
34
+ // jelinek.css:966-975 (`.input, .select`), corrected against the draft brief:
35
+ // - border is 1px solid var(--text-muted2) (border-fg-muted2), NOT
36
+ // border-border-subtle (that token is --border-subtle, a different,
37
+ // lighter grey used elsewhere, e.g. Card).
38
+ // - padding is 11px 14px, NOT the 16px/12px py-3 px-4 the brief used.
39
+ // - font-size is a fixed 0.98rem literal (not on the --step scale, where
40
+ // --step0 is a constant 1rem unrelated to this value) — kept as
41
+ // text-[0.98rem], not text-step0.
42
+ // width:100%, font-family:--font-secondary, background:--bg-white,
43
+ // color:--text-black and border-radius:--radius-button all check out as
44
+ // w-full/font-secondary/bg-surface/text-fg/rounded-button.
45
+ //
46
+ // TASK 31 — THE FOCUS RING IS TOKENISED, AND THAT FIXES A DARK-MODE BUG.
47
+ // jelinek.css:972-975 now reads `outline: var(--focus-width) solid
48
+ // var(--focus-color); outline-offset: 1px; border-color: var(--focus-color)`.
49
+ // --focus-color is NOT simply --JELINEK-BROWN: it is --JELINEK-BROWN in
50
+ // light and the lighter --JELINEK-BROWN-HIGHLIGHT in dark
51
+ // (jelinek.css:139, :229), precisely so the ring stays visible against the
52
+ // dark canvas. The old `focus:outline-jelinek-brown focus:border-jelinek-brown`
53
+ // therefore drew a too-dark ring in dark mode. Referenced as var() rather
54
+ // than translated to a named token because --focus-color/--focus-width are
55
+ // emitted into :root by gen-tokens but are not in the @theme inline map, so
56
+ // no `outline-focus-color` utility exists; the var() reference also keeps
57
+ // this tracking the token if the designer retunes it.
58
+ // The width needs the `[length:…]` hint — `outline-[var(--focus-width)]`
59
+ // alone is ambiguous and Tailwind reads it as a COLOUR (verified against
60
+ // compiled output).
61
+ // Note the guide uses `:focus`, not `:focus-visible`, for form fields, so a
62
+ // mouse click shows the ring too — kept as `focus:`. That is deliberate on
63
+ // the guide's part: base.css's new global ring is `:focus-visible`, and
64
+ // `.input:focus` (specificity 0,2,0) outranks it either way.
65
+ const BASE =
66
+ 'w-full rounded-button border border-fg-muted2 bg-surface px-[14px] py-[11px] ' +
67
+ 'font-secondary text-[0.98rem] text-fg placeholder:text-fg-muted2 ' +
68
+ 'focus:outline-[length:var(--focus-width)] focus:outline-offset-1 ' +
69
+ 'focus:outline-[var(--focus-color)] focus:border-[var(--focus-color)]';
70
+
71
+ // jelinek.css:987-991 — NEW in the merge. `.input.is-error` recolours the
72
+ // border to --sem-error-strong, and `.input.is-error:focus` recolours the
73
+ // focus ring to match while leaving its width and offset alone. That token
74
+ // IS in the @theme map (--color-sem-error-strong), so these are named
75
+ // utilities; it also lightens in dark mode (hsl(7,100%,45%) -> 62%) to stay
76
+ // legible, which the named utility follows for free.
77
+ // Merged AFTER BASE so it wins on source order within the same
78
+ // tailwind-merge groups, and BEFORE `className` so a consumer override still
79
+ // beats both — the kit's standing cn() contract.
80
+ //
81
+ // NOT reproduced: the guide's `.field-error` message element
82
+ // (jelinek.css:992-995). That is markup plus `aria-describedby` wiring, i.e.
83
+ // a prop-surface addition rather than a rule to copy, and `.field-error` is
84
+ // not a guarded class so the lock makes no claim about it. Noted in the Task
85
+ // 31 report as a candidate follow-up.
86
+ const ERROR =
87
+ 'border-sem-error-strong focus:border-sem-error-strong focus:outline-sem-error-strong';
88
+ </script>
89
+
90
+ {#if label}
91
+ <Label for={inputId}>{label}</Label>
92
+ {/if}
93
+ <input
94
+ {...rest}
95
+ id={inputId}
96
+ type={rest.type ?? 'text'}
97
+ aria-invalid={error ? 'true' : rest['aria-invalid']}
98
+ bind:value
99
+ class={cn(BASE, error && ERROR, className)}
100
+ />
@@ -0,0 +1,18 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ interface Props {
3
+ value?: string;
4
+ label?: string;
5
+ id?: string;
6
+ /**
7
+ * Invalid state. Reproduces the guide's `.input.is-error`
8
+ * (jelinek.css:988-991) — new in the Task 31 merge — and sets
9
+ * `aria-invalid` alongside it, so the state is announced and not only
10
+ * coloured. See ERROR below.
11
+ */
12
+ error?: boolean;
13
+ class?: string;
14
+ }
15
+ type $$ComponentProps = Props & Omit<HTMLInputAttributes, 'value'>;
16
+ declare const TextField: import("svelte").Component<$$ComponentProps, {}, "value">;
17
+ type TextField = ReturnType<typeof TextField>;
18
+ export default TextField;
@@ -0,0 +1,122 @@
1
+ <script lang="ts">
2
+ import type { HTMLButtonAttributes } from 'svelte/elements';
3
+ import { cn } from '../utils/cn.js';
4
+
5
+ type Theme = 'light' | 'dark';
6
+
7
+ interface Props {
8
+ theme?: Theme;
9
+ class?: string;
10
+ label?: string;
11
+ }
12
+
13
+ let {
14
+ theme = $bindable(),
15
+ // Literal copy of partials/header.html:10's aria-label, not the
16
+ // brief's shorter invented "Tmavý režim".
17
+ label = 'Přepnout světlý/tmavý režim',
18
+ class: className,
19
+ ...rest
20
+ }: Props & Omit<HTMLButtonAttributes, 'type'> = $props();
21
+
22
+ // Seed from whatever is already on <html> instead of writing blind. A
23
+ // consumer that persists the user's choice (localStorage, a cookie, an
24
+ // SSR-rendered attribute) sets `data-theme` on the document element
25
+ // before this component ever mounts; unconditionally writing `theme`'s
26
+ // default ('light') back onto it on mount clobbered that on every load.
27
+ // Falls back to prefers-color-scheme, then 'light', only when the
28
+ // consumer hasn't bound `theme` AND nothing is already on the document.
29
+ $effect(() => {
30
+ if (theme === undefined) {
31
+ const existing = document.documentElement.dataset.theme;
32
+ theme =
33
+ existing === 'dark' || existing === 'light'
34
+ ? existing
35
+ : window.matchMedia?.('(prefers-color-scheme: dark)').matches
36
+ ? 'dark'
37
+ : 'light';
38
+ return;
39
+ }
40
+ if (document.documentElement.dataset.theme !== theme) {
41
+ document.documentElement.setAttribute('data-theme', theme);
42
+ }
43
+ });
44
+
45
+ const dark = $derived(theme === 'dark');
46
+
47
+ // jelinek.css:536-541 (.theme-toggle): relative, no shrink/grow, 52px x
48
+ // 28px (w-[52px] — not on Tailwind's default scale, which has no 13
49
+ // (52px) stop, jumping 12→14; h-7 = 28px is exact), no padding,
50
+ // cursor-pointer, a 2px solid border in text-black (border-2 border-fg —
51
+ // the brief's `border border-border-subtle` was a much lighter 1px
52
+ // subtle-grey border, wrong on both width and colour), transparent
53
+ // background (the brief filled it with bg-surface-gray), rounded-pill.
54
+ const TRACK =
55
+ 'relative h-7 w-[52px] flex-none cursor-pointer rounded-pill border-2 border-fg bg-transparent p-0';
56
+
57
+ // jelinek.css:542-550 (.theme-toggle__knob / .is-dark / __icon): 20x20px
58
+ // circle (h-5 w-5, exact), 2px inset (top-0.5 left-0.5, exact),
59
+ // background var(--bg-white) → bg-surface (the brief's bg-jelinek-black
60
+ // was a solid BLACK knob, the opposite of the guide's light one),
61
+ // shadow-resting, rounded-full (a literal 50%, not the pill radius
62
+ // token). `.is-dark .theme-toggle__knob { transform: translateX(24px) }`
63
+ // (:549) → translate-x-6 = 24px, NOT the brief's translate-x-5 (20px).
64
+ //
65
+ // Task 31 — the knob's slide is `transform var(--dur-slow) var(--ease)`
66
+ // (jelinek.css:547). This is the one transition in the merge whose DURATION
67
+ // genuinely moved, not just its spelling: it was a literal `0.18s`, and the
68
+ // designer folded it into the shared --dur-slow (0.2s) with the other
69
+ // large-surface animations. A bare `transition-transform` would have run at
70
+ // Tailwind's 150ms default on Tailwind's own easing curve — wrong on both
71
+ // counts now — so both are named explicitly. See Button.svelte's comment for
72
+ // why this is not solved once globally in extras.css.
73
+ const KNOB =
74
+ 'absolute top-0.5 left-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-surface shadow-resting ' +
75
+ 'transition-transform duration-[var(--dur-slow)] ease-[var(--ease)]';
76
+
77
+ // jelinek.css:550 only declares `.theme-toggle__icon`'s size/colour
78
+ // (13x13px, color:var(--text-black)) — the actual sun/moon markup lives
79
+ // in assets/js/jelinek.js:259-260 (SUN_ICON/MOON_ICON), not in the CSS.
80
+ // Reproduced here verbatim as native Svelte conditionals keyed off
81
+ // `dark` (reactive) instead of the guide's imperative innerHTML swap —
82
+ // this is the one addition in this task not literally sourced from
83
+ // jelinek.css, flagged in the Task 9 report since the CSS only implies
84
+ // an icon exists, it doesn't specify one.
85
+ const ICON = 'block h-[13px] w-[13px] text-fg';
86
+ </script>
87
+
88
+ <button
89
+ {...rest}
90
+ type="button"
91
+ role={rest.role ?? 'switch'}
92
+ aria-checked={dark}
93
+ aria-label={label}
94
+ onclick={(event) => {
95
+ theme = dark ? 'light' : 'dark';
96
+ rest.onclick?.(event);
97
+ }}
98
+ class={cn(TRACK, className)}
99
+ >
100
+ <span class={cn(KNOB, dark && 'translate-x-6')}>
101
+ {#if dark}
102
+ <svg class={ICON} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
103
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
104
+ </svg>
105
+ {:else}
106
+ <svg
107
+ class={ICON}
108
+ viewBox="0 0 24 24"
109
+ fill="none"
110
+ stroke="currentColor"
111
+ stroke-width="2"
112
+ stroke-linecap="round"
113
+ aria-hidden="true"
114
+ >
115
+ <circle cx="12" cy="12" r="4" />
116
+ <path
117
+ d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
118
+ />
119
+ </svg>
120
+ {/if}
121
+ </span>
122
+ </button>
@@ -0,0 +1,11 @@
1
+ import type { HTMLButtonAttributes } from 'svelte/elements';
2
+ type Theme = 'light' | 'dark';
3
+ interface Props {
4
+ theme?: Theme;
5
+ class?: string;
6
+ label?: string;
7
+ }
8
+ type $$ComponentProps = Props & Omit<HTMLButtonAttributes, 'type'>;
9
+ declare const ThemeToggle: import("svelte").Component<$$ComponentProps, {}, "theme">;
10
+ type ThemeToggle = ReturnType<typeof ThemeToggle>;
11
+ export default ThemeToggle;