@heyramzi/ui 1.53.1 → 1.54.1

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 (55) hide show
  1. package/COMPONENTS.md +6 -0
  2. package/package.json +2 -2
  3. package/src/agents/agent-avatar.svelte +3 -2
  4. package/src/agents/helpers.ts +9 -0
  5. package/src/components/data-table/sortable-head.svelte +1 -1
  6. package/src/components/docs/doc-page.svelte +161 -0
  7. package/src/components/docs/index.ts +12 -0
  8. package/src/components/docs/on-this-page.svelte +217 -0
  9. package/src/components/docs/scroll-spy.svelte.ts +97 -0
  10. package/src/components/field-row/field-row.svelte +59 -0
  11. package/src/components/field-row/index.ts +1 -0
  12. package/src/components/list-page/toolbar-search.svelte +1 -1
  13. package/src/components/list-page/toolbar-trigger.ts +1 -1
  14. package/src/components/picker/trigger.ts +2 -2
  15. package/src/components/section-card/index.ts +1 -0
  16. package/src/components/section-card/section-card.svelte +43 -0
  17. package/src/components/section-header/index.ts +1 -0
  18. package/src/components/section-header/section-header.svelte +54 -0
  19. package/src/components/section-header/section-header.test.ts +25 -0
  20. package/src/components/segmented/collapse.test.ts +10 -0
  21. package/src/components/segmented/collapse.ts +5 -0
  22. package/src/components/segmented/segmented-filter.svelte +5 -4
  23. package/src/components/segmented/segmented-track.svelte +20 -0
  24. package/src/components/segmented/segmented.test.ts +1 -1
  25. package/src/components/segmented/segmented.ts +2 -2
  26. package/src/components/table-view/index.ts +3 -0
  27. package/src/components/table-view/table-view.svelte +103 -0
  28. package/src/components/table-view/table-view.test.ts +53 -0
  29. package/src/components/table-view/types.ts +14 -0
  30. package/src/components/theme/index.ts +17 -0
  31. package/src/components/theme/theme-provider.svelte +47 -0
  32. package/src/components/theme/theme-provider.test.ts +61 -0
  33. package/src/effects/spotlight/Spotlight.svelte +0 -1
  34. package/src/lib/tone.test.ts +10 -0
  35. package/src/lib/tone.ts +1 -1
  36. package/src/shadcn/badge/badge.svelte +1 -1
  37. package/src/shadcn/button/button.svelte +10 -1
  38. package/src/shadcn/button/button.test.ts +19 -0
  39. package/src/shadcn/checkbox/checkbox.svelte +1 -1
  40. package/src/shadcn/context-menu/context-menu-separator.svelte +4 -1
  41. package/src/shadcn/dropdown-menu/dropdown-menu-separator.svelte +4 -1
  42. package/src/shadcn/input/input.svelte +2 -2
  43. package/src/shadcn/progress/progress.svelte +8 -2
  44. package/src/shadcn/progress/tone.test.ts +13 -0
  45. package/src/shadcn/progress/tone.ts +15 -0
  46. package/src/shadcn/sidebar/sidebar-group-label.svelte +1 -1
  47. package/src/shadcn/table/table-cell.svelte +21 -1
  48. package/src/shadcn/table/table-cell.test.ts +16 -0
  49. package/src/tokens/base.css +183 -0
  50. package/src/tokens/craft.css +179 -0
  51. package/src/tokens/index.css +19 -0
  52. package/src/tokens/palette.css +410 -0
  53. package/src/tokens/primitives.css +223 -0
  54. package/scripts/relative-imports.mjs +0 -39
  55. package/src/tokens.css +0 -993
package/COMPONENTS.md CHANGED
@@ -71,7 +71,13 @@ Generic pieces built on the primitives, imported from `@heyramzi/ui/components/<
71
71
  - `mark` — Mark, a logo or photo glyph with an initial fallback
72
72
  - `open-in` — OpenIn, a link out to a tool that takes the tool's name, label and mark as a prop
73
73
  - `picker` — Picker, MarkPicker, pickerTriggerClass, a command palette at popover scale
74
+ - `docs` — DocPage, OnThisPage, ScrollSpy: one page anatomy for documentation, an icon, title and summary header, a reading column and a contents rail that becomes a bar on a narrow pane
75
+ - `section-card` — SectionCard, a card with a fixed-height header, an h3 title, optional actions and a line under it
76
+ - `section-header` — SectionHeader, a section label with a count chip, a line of description and actions
77
+ - `field-row` — FieldRow, a labelled control with its action button at the control's height
78
+ - `table-view` — TableView, a read-only table from columns and rows: numeric columns, footer row, custom cell
74
79
  - `segmented` — AnimatedTabs (pill or underline), SegmentedFilter, SegmentedNav, SectionRuns, one bordered track with a sliding pill, a badge note and an overflow menu
80
+ - `theme` — ThemeProvider, mode, userPrefersMode, setMode, toggleMode, resetMode: the one light and dark system (mode-watcher, optional peer). Mount `<ThemeProvider initial onChange themeColors modeStorageKey />` once; flip with `toggleMode()`, read `mode.current`. Pair with `theme-toggle`'s `revealTheme(event, toggleMode)` for the wipe
75
81
  - `star-rating-input` — StarRatingInput
76
82
  - `svg` — Svg, an SVG file imported with `?raw`
77
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "1.53.1",
3
+ "version": "1.54.1",
4
4
  "description": "Svelte 5 UI primitives: effects, animations, and shared component CSS.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "types": "./src/index.ts",
29
29
  "default": "./src/index.ts"
30
30
  },
31
- "./tokens.css": "./src/tokens.css",
31
+ "./tokens.css": "./src/tokens/index.css",
32
32
  "./lib/*.js": {
33
33
  "svelte": "./src/lib/*.ts",
34
34
  "types": "./src/lib/*.ts",
@@ -8,7 +8,7 @@
8
8
  import type { AgentAvatarProps } from './types.js';
9
9
  import { botAvatarPresets, stateLabels } from './vendor/presets.js';
10
10
  import { SHAPE_PATHS, SHAPE_PARTS } from './vendor/shapes.js';
11
- import { autoInk, richer, shade } from './vendor/color.js';
11
+ import { richer, shade } from './vendor/color.js';
12
12
  import { Sim, restPose } from './vendor/engine.js';
13
13
  import { draw, LIGHT_DEFAULTS, OVERSCAN, RISE, type DrawConfig } from './vendor/draw.js';
14
14
  import { bakesPending, hurry, onLanded, takeMissed, warmPlastic, type FurStyle } from './vendor/plastic.js';
@@ -27,6 +27,7 @@
27
27
  clampOpt,
28
28
  deviceDpr,
29
29
  hashSeed,
30
+ inkFor,
30
31
  lightsFor,
31
32
  pointerStrength,
32
33
  q,
@@ -138,7 +139,7 @@
138
139
  vivid,
139
140
  ),
140
141
  );
141
- const inkColor = $derived(ink ?? autoInk(body));
142
+ const inkColor = $derived(ink ?? inkFor(body));
142
143
  const seedValue = $derived(Math.min(1, Math.max(0, seed ?? hashSeed(uid))));
143
144
  const stateKey = $derived((botState in stateLabels ? botState : 'default') as BotAvatarState);
144
145
  const frozen = $derived(paused || !(speed > 0));
@@ -1,6 +1,15 @@
1
1
  /* Pure helpers for the avatar: numbers, theme, lights, idle scheduling. */
2
+ import { DARK_INK, LIGHT_INK, luminance } from './vendor/color.js';
2
3
  import { stockLights } from './vendor/plastic.js';
3
4
 
5
+ const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
6
+
7
+ /** Off-black or off-white eyes, whichever reads stronger on the body: the vendor's fixed cutoff left 2:1 eyes on mid blues. */
8
+ export function inkFor(body: string): string {
9
+ const l = luminance(body);
10
+ return contrast(l, luminance(DARK_INK)) >= contrast(l, luminance(LIGHT_INK)) ? DARK_INK : LIGHT_INK;
11
+ }
12
+
4
13
  export const clamp = (v: number, lo: number, hi: number) =>
5
14
  Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 1));
6
15
  export const q = (v: number, lo: number, hi: number) => Math.round(clamp(v, lo, hi) * 20) / 20;
@@ -50,7 +50,7 @@ function handleKeydown(e: KeyboardEvent) {
50
50
  {:else if direction === "desc"}
51
51
  <CaretDown class="size-3.5 text-foreground" weight="bold" />
52
52
  {:else}
53
- <CaretUpDown class="size-3.5 text-muted-foreground/50" />
53
+ <CaretUpDown class="size-3.5 text-muted-foreground/75" />
54
54
  {/if}
55
55
  </span>
56
56
  </th>
@@ -0,0 +1,161 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Component, Snippet } from "svelte";
6
+ import OnThisPage from "./on-this-page.svelte";
7
+ import {
8
+ ScrollSpy,
9
+ type DocHeading,
10
+ type LocateHeading,
11
+ } from "./scroll-spy.svelte.js";
12
+
13
+ // =========================
14
+ // TYPES
15
+ // =========================
16
+ interface Props {
17
+ title: string;
18
+ /** One line under the title. Optional: a page with nothing to add leaves it out. */
19
+ summary?: string;
20
+ icon?: Component<{ class?: string; weight?: "bold" }>;
21
+ /** Where the page lives, as a reader would say it: "Marketing / Reports". */
22
+ section?: string;
23
+ /** Already worded and localised: "Updated 30 Jul 2026". */
24
+ updated?: string;
25
+ /** Room at the right of the title for a page's own controls. */
26
+ actions?: Snippet;
27
+ /** The document's h2s, which the contents rail walks. Under two, there's no rail. */
28
+ headings?: DocHeading[];
29
+ /** The rail's name: "On this page". */
30
+ contentsLabel: string;
31
+ /** Supplied by a document that lives in a frame; the default reads the DOM by id. */
32
+ locate?: LocateHeading;
33
+ /** Changes when the page is swapped without navigation, so the pane returns to the top. */
34
+ resetKey?: string;
35
+ /** The reading column's width. `wide` is for a document that brings tables and boards. */
36
+ measure?: "prose" | "wide";
37
+ /** The scroll box, for a document that has to place things inside it. */
38
+ scroller?: HTMLElement | null;
39
+ children: Snippet;
40
+ }
41
+
42
+ // =========================
43
+ // PROPS
44
+ // =========================
45
+ let {
46
+ title,
47
+ summary,
48
+ icon: Icon,
49
+ section,
50
+ updated,
51
+ actions,
52
+ headings = [],
53
+ contentsLabel,
54
+ locate,
55
+ resetKey,
56
+ measure = "prose",
57
+ scroller = $bindable(null),
58
+ children,
59
+ }: Props = $props();
60
+
61
+ // =========================
62
+ // STATE
63
+ // =========================
64
+ const spy = new ScrollSpy();
65
+
66
+ // DERIVED
67
+ let hasRail = $derived(headings.length > 1);
68
+ let meta = $derived([section, updated].filter(Boolean) as string[]);
69
+
70
+ // =========================
71
+ // EFFECTS
72
+ // =========================
73
+ $effect(() => spy.attach(scroller, headings, locate));
74
+
75
+ $effect(() => {
76
+ void resetKey;
77
+ scroller?.scrollTo({ top: 0 });
78
+ });
79
+ </script>
80
+
81
+ <!-- =========================== -->
82
+ <!-- MARKUP -->
83
+ <!-- =========================== -->
84
+ <!-- The anatomy, once: header, then the reading column with its contents rail.
85
+ A surface supplies content and never lays this out, so two pages can't
86
+ arrive with different layouts. The pane is its own container, since the
87
+ room left after the map is what decides the rail, not the window. -->
88
+ <div
89
+ bind:this={scroller}
90
+ class="doc-page @container/page thin-scrollbar relative min-h-0 flex-1 overflow-y-auto"
91
+ >
92
+ {#if hasRail}
93
+ <!-- Below the rail's width the contents sit on a strip that stays put. -->
94
+ <div class="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur @3xl/page:hidden">
95
+ <OnThisPage
96
+ variant="bar"
97
+ items={headings}
98
+ active={spy.active}
99
+ label={contentsLabel}
100
+ onselect={(id) => spy.jump(id)}
101
+ />
102
+ </div>
103
+ {/if}
104
+
105
+ <div
106
+ class="mx-auto flex w-full gap-10 px-5 @2xl/page:px-8 {measure === 'wide'
107
+ ? 'max-w-[84rem]'
108
+ : 'max-w-[68rem]'}"
109
+ >
110
+ <article class="min-w-0 flex-1 pb-20 pt-8 @2xl/page:pt-10">
111
+ <header class="mb-8 border-b border-border pb-6">
112
+ <div class="flex items-start gap-4">
113
+ {#if Icon}
114
+ <span
115
+ class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-brand-indigo dark:bg-indigo-800/30 dark:text-brand-indigo-light"
116
+ >
117
+ <Icon class="size-5" weight="bold" />
118
+ </span>
119
+ {/if}
120
+ <div class="min-w-0 flex-1">
121
+ <h1
122
+ class="font-heading text-2xl font-semibold leading-tight tracking-tight text-balance text-foreground @2xl/page:text-[1.75rem]"
123
+ >
124
+ {title}
125
+ </h1>
126
+ {#if summary}
127
+ <p class="mt-1.5 max-w-[60ch] text-[15px] leading-relaxed text-muted-foreground text-pretty">
128
+ {summary}
129
+ </p>
130
+ {/if}
131
+ {#if meta.length > 0}
132
+ <p class="mt-3 flex flex-wrap items-center gap-x-2 text-xs tabular-nums text-muted-foreground">
133
+ {#each meta as item, i (item)}
134
+ {#if i > 0}<span aria-hidden="true" class="size-0.5 rounded-full bg-current opacity-50"></span>{/if}
135
+ <span>{item}</span>
136
+ {/each}
137
+ </p>
138
+ {/if}
139
+ </div>
140
+ {#if actions}
141
+ <div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
142
+ {/if}
143
+ </div>
144
+ </header>
145
+
146
+ {@render children()}
147
+ </article>
148
+
149
+ {#if hasRail}
150
+ <!-- `self-start`, since a sticky child only travels inside its own box. -->
151
+ <aside class="hidden w-48 shrink-0 self-start pt-10 @3xl/page:sticky @3xl/page:top-0 @3xl/page:block">
152
+ <OnThisPage
153
+ items={headings}
154
+ active={spy.active}
155
+ label={contentsLabel}
156
+ onselect={(id) => spy.jump(id)}
157
+ />
158
+ </aside>
159
+ {/if}
160
+ </div>
161
+ </div>
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The documentation anatomy: one page component and the contents rail inside it.
3
+ * A surface supplies a title and a body, and never lays a document out itself.
4
+ */
5
+ export { default as DocPage } from "./doc-page.svelte";
6
+ export { default as OnThisPage } from "./on-this-page.svelte";
7
+ export {
8
+ ScrollSpy,
9
+ READ_LINE,
10
+ type DocHeading,
11
+ type LocateHeading,
12
+ } from "./scroll-spy.svelte.js";
@@ -0,0 +1,217 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { tick } from "svelte";
6
+ import { CaretDown, List } from "phosphor-svelte";
7
+ import type { DocHeading } from "./scroll-spy.svelte.js";
8
+
9
+ // =========================
10
+ // TYPES
11
+ // =========================
12
+ interface Props {
13
+ items: DocHeading[];
14
+ /** The id the reader is in. */
15
+ active: string;
16
+ /** What the rail is called: "On this page". Passed in so the library holds no copy. */
17
+ label: string;
18
+ onselect: (id: string) => void;
19
+ /**
20
+ * `rail` is the column beside the document. `bar` is the strip that takes
21
+ * its place on a narrow pane: it names where you are and opens the list.
22
+ */
23
+ variant?: "rail" | "bar";
24
+ }
25
+
26
+ // =========================
27
+ // PROPS
28
+ // =========================
29
+ let { items, active, label, onselect, variant = "rail" }: Props = $props();
30
+
31
+ // =========================
32
+ // STATE
33
+ // =========================
34
+ let list = $state<HTMLElement | null>(null);
35
+ let open = $state(false);
36
+ let marker = $state({ y: 0, h: 0, ready: false });
37
+ // Plain, not state: the effect below writes `marker`, so it can't also read it.
38
+ let placed = false;
39
+ let scrollBox = $state<HTMLElement | null>(null);
40
+ let overflowing = $state(false);
41
+
42
+ // =========================
43
+ // DERIVED
44
+ // =========================
45
+ let activeIndex = $derived(Math.max(0, items.findIndex((item) => item.id === active)));
46
+ let activeLabel = $derived(items.find((item) => item.id === active)?.label ?? items[0]?.label ?? "");
47
+ // How far through the document: the bar's hairline, and the number a reader
48
+ // reads at a glance.
49
+ let progress = $derived(items.length > 1 ? activeIndex / (items.length - 1) : 0);
50
+
51
+ // =========================
52
+ // EFFECTS
53
+ // =========================
54
+ // One marker that slides to the active row, rather than a border that blinks
55
+ // from one row to the next.
56
+ $effect(() => {
57
+ void active;
58
+ void items;
59
+ if (variant !== "rail" || !list) return;
60
+ overflowing = !!scrollBox && scrollBox.scrollHeight > scrollBox.clientHeight + 1;
61
+ const row = list.querySelector<HTMLElement>("[data-current='true']");
62
+ if (!row) return;
63
+ const y = row.offsetTop;
64
+ const h = row.offsetHeight;
65
+ // First placement lands with no slide, or it would glide in from the top.
66
+ const wasPlaced = placed;
67
+ placed = true;
68
+ marker = { y, h, ready: wasPlaced };
69
+ if (!wasPlaced) return;
70
+ // A long list scrolls inside the rail, so keep the current row in view.
71
+ const box = list.parentElement;
72
+ if (box && (y < box.scrollTop || y + h > box.scrollTop + box.clientHeight)) {
73
+ box.scrollTo({ top: y - box.clientHeight / 2, behavior: "auto" });
74
+ }
75
+ });
76
+
77
+ function pick(id: string) {
78
+ open = false;
79
+ onselect(id);
80
+ void tick();
81
+ }
82
+ </script>
83
+
84
+ <svelte:window
85
+ onkeydown={(event) => {
86
+ if (event.key === "Escape") open = false;
87
+ }}
88
+ onpointerdown={(event) => {
89
+ if (!open) return;
90
+ if (!(event.target as HTMLElement).closest("[data-otp-bar]")) open = false;
91
+ }}
92
+ />
93
+
94
+ <!-- =========================== -->
95
+ <!-- MARKUP -->
96
+ <!-- =========================== -->
97
+ {#if variant === "rail"}
98
+ <nav aria-label={label} class="flex max-h-[calc(100svh-8rem)] flex-col gap-3">
99
+ <!-- Set like the map's own headings on the left, so the two rails read as one. -->
100
+ <p class="font-heading text-xs font-semibold uppercase tracking-wider text-muted-foreground">
101
+ {label}
102
+ </p>
103
+ <div
104
+ bind:this={scrollBox}
105
+ class="thin-scrollbar -mr-2 min-h-0 overflow-y-auto pr-2"
106
+ class:otp-fade={overflowing}
107
+ >
108
+ <div bind:this={list} class="relative border-l border-border">
109
+ <!-- The marker sits on the track's own line, so the active row reads as
110
+ the part of the line you're standing on. -->
111
+ <span
112
+ aria-hidden="true"
113
+ class="otp-marker absolute -left-px top-0 w-0.5 rounded-full bg-brand-indigo dark:bg-brand-indigo-light"
114
+ class:is-ready={marker.ready}
115
+ style="transform: translateY({marker.y}px); height: {marker.h}px;"
116
+ ></span>
117
+ <ul class="flex flex-col">
118
+ {#each items as item (item.id)}
119
+ {@const current = item.id === active}
120
+ <li>
121
+ <button
122
+ type="button"
123
+ data-current={current}
124
+ aria-current={current ? "location" : undefined}
125
+ onclick={() => pick(item.id)}
126
+ class="otp-row block w-full py-1.5 pl-3 pr-1 text-left text-[13px] leading-snug transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] any-pointer-coarse:py-2.5 {current
127
+ ? 'font-medium text-foreground'
128
+ : 'text-muted-foreground hover:text-foreground'}"
129
+ >
130
+ {item.label}
131
+ </button>
132
+ </li>
133
+ {/each}
134
+ </ul>
135
+ </div>
136
+ </div>
137
+ </nav>
138
+ {:else}
139
+ <div data-otp-bar class="relative">
140
+ <button
141
+ type="button"
142
+ aria-expanded={open}
143
+ aria-label={label}
144
+ onclick={() => (open = !open)}
145
+ class="flex h-11 w-full items-center gap-2.5 px-4 text-left transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] hover:bg-muted/50"
146
+ >
147
+ <List size={16} class="shrink-0 text-muted-foreground" aria-hidden="true" />
148
+ <span class="shrink-0 text-xs font-medium text-muted-foreground">{label}</span>
149
+ <span class="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{activeLabel}</span>
150
+ <CaretDown
151
+ size={14}
152
+ class="shrink-0 text-muted-foreground transition-transform duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] {open
153
+ ? 'rotate-180'
154
+ : ''}"
155
+ />
156
+ </button>
157
+ <!-- How far through the reader is. The only motion here, and it is the state. -->
158
+ <span aria-hidden="true" class="absolute inset-x-0 bottom-0 h-px bg-border">
159
+ <span
160
+ class="otp-progress block h-full origin-left bg-brand-indigo dark:bg-brand-indigo-light"
161
+ style="transform: scaleX({progress});"
162
+ ></span>
163
+ </span>
164
+
165
+ {#if open}
166
+ <nav
167
+ aria-label={label}
168
+ class="thin-scrollbar absolute inset-x-2 top-full z-20 mt-1 max-h-[60svh] overflow-y-auto rounded-xl border border-border bg-popover p-1.5 shadow-lg"
169
+ >
170
+ <ul class="flex flex-col">
171
+ {#each items as item (item.id)}
172
+ {@const current = item.id === active}
173
+ <li>
174
+ <button
175
+ type="button"
176
+ aria-current={current ? "location" : undefined}
177
+ onclick={() => pick(item.id)}
178
+ class="block w-full rounded-lg px-3 py-2.5 text-left text-sm leading-snug transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] {current
179
+ ? 'bg-muted font-medium text-foreground'
180
+ : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}"
181
+ >
182
+ {item.label}
183
+ </button>
184
+ </li>
185
+ {/each}
186
+ </ul>
187
+ </nav>
188
+ {/if}
189
+ </div>
190
+ {/if}
191
+
192
+ <!-- =========================== -->
193
+ <!-- STYLE -->
194
+ <!-- =========================== -->
195
+ <style>
196
+ .otp-marker.is-ready {
197
+ transition:
198
+ transform var(--duration-medium) var(--ease-smooth-out),
199
+ height var(--duration-medium) var(--ease-smooth-out);
200
+ }
201
+
202
+ .otp-progress {
203
+ transition: transform var(--duration-medium) var(--ease-smooth-out);
204
+ }
205
+
206
+ /* A long list fades into its edges instead of ending on a hard cut. */
207
+ .otp-fade {
208
+ mask-image: linear-gradient(to bottom, transparent, #000 0.5rem, #000 calc(100% - 1.5rem), transparent);
209
+ }
210
+
211
+ @media (prefers-reduced-motion: reduce) {
212
+ .otp-marker.is-ready,
213
+ .otp-progress {
214
+ transition: none;
215
+ }
216
+ }
217
+ </style>
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Which heading the reader is in, for a box that scrolls. One spy feeds the
3
+ * side rail and the compact bar, so the two can never disagree.
4
+ */
5
+
6
+ export type DocHeading = { id: string; label: string };
7
+
8
+ /**
9
+ * Where a heading sits inside the scroller, in scroller content coordinates.
10
+ * The default reads the live DOM by id. A framed document supplies its own,
11
+ * since its headings live in another document.
12
+ */
13
+ export type LocateHeading = (id: string) => number | null;
14
+
15
+ /** How far below the scroller's top edge a heading counts as "reached" and lands. */
16
+ export const READ_LINE = 80;
17
+
18
+ export class ScrollSpy {
19
+ active = $state("");
20
+
21
+ #scroller: HTMLElement | null = null;
22
+ #items: DocHeading[] = [];
23
+ #locate: LocateHeading | undefined;
24
+ #frame = 0;
25
+
26
+ /** Point the spy at a scroller and a list. Returns the cleanup. */
27
+ attach(scroller: HTMLElement | null, items: DocHeading[], locate?: LocateHeading) {
28
+ this.#scroller = scroller;
29
+ this.#items = items;
30
+ this.#locate = locate;
31
+ if (!scroller || items.length === 0) {
32
+ this.active = "";
33
+ return () => {};
34
+ }
35
+
36
+ const schedule = () => {
37
+ this.#frame ||= requestAnimationFrame(() => {
38
+ this.#frame = 0;
39
+ this.update();
40
+ });
41
+ };
42
+ scroller.addEventListener("scroll", schedule, { passive: true });
43
+ const observer = new ResizeObserver(schedule);
44
+ observer.observe(scroller);
45
+ // A document that grows after load (a diagram, a chart) moves every
46
+ // heading below it, so watch the content too.
47
+ for (const child of scroller.children) observer.observe(child);
48
+ this.update();
49
+
50
+ return () => {
51
+ scroller.removeEventListener("scroll", schedule);
52
+ observer.disconnect();
53
+ if (this.#frame) cancelAnimationFrame(this.#frame);
54
+ this.#frame = 0;
55
+ };
56
+ }
57
+
58
+ topOf(id: string): number | null {
59
+ const scroller = this.#scroller;
60
+ if (!scroller) return null;
61
+ if (this.#locate) return this.#locate(id);
62
+ const el = scroller.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
63
+ if (!el) return null;
64
+ return (
65
+ el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop
66
+ );
67
+ }
68
+
69
+ update() {
70
+ const scroller = this.#scroller;
71
+ if (!scroller || this.#items.length === 0) return;
72
+ // At the very bottom the last heading wins, or a short final section could
73
+ // never be the current one.
74
+ const atEnd = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
75
+ if (atEnd && scroller.scrollTop > 0) {
76
+ this.active = this.#items[this.#items.length - 1].id;
77
+ return;
78
+ }
79
+ const line = scroller.scrollTop + READ_LINE + 1;
80
+ let current = this.#items[0].id;
81
+ for (const item of this.#items) {
82
+ const top = this.topOf(item.id);
83
+ if (top !== null && top <= line) current = item.id;
84
+ }
85
+ this.active = current;
86
+ }
87
+
88
+ /** Scroll so the heading lands on the read line, instant under reduced motion. */
89
+ jump(id: string) {
90
+ const scroller = this.#scroller;
91
+ const top = this.topOf(id);
92
+ if (!scroller || top === null) return;
93
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
94
+ this.active = id;
95
+ scroller.scrollTo({ top: Math.max(0, top - READ_LINE), behavior: reduced ? "auto" : "smooth" });
96
+ }
97
+ }
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Snippet } from "svelte";
6
+ import { cn } from "@heyramzi/utils";
7
+ import * as Field from "../../shadcn/field/index.js";
8
+
9
+ // =========================
10
+ // TYPES
11
+ // =========================
12
+ interface Props {
13
+ label: string;
14
+ /** Id of the control, so the label focuses it. */
15
+ for: string;
16
+ description?: string;
17
+ error?: string;
18
+ class?: string;
19
+ /** The control: an Input, a Select trigger. It takes the width the action leaves. */
20
+ children: Snippet;
21
+ /** The button that acts on the control. Same height as the control, bottom of the row. */
22
+ action?: Snippet;
23
+ }
24
+
25
+ // =========================
26
+ // PROPS
27
+ // =========================
28
+ let { label, for: htmlFor, description, error, class: className, children, action }: Props = $props();
29
+ </script>
30
+
31
+ <!-- =========================== -->
32
+ <!-- MARKUP -->
33
+ <!-- WHY a measure of its own: a settings card fills the page, but a text field
34
+ stretched over 1200px is hard to read and to aim at. 36rem, left aligned. -->
35
+ <!-- WHY the coarse-pointer height: Button grows to 44px on touch and Input
36
+ does not, so a row built from the two drew a 12px step. -->
37
+ <!-- =========================== -->
38
+ <Field.Simple
39
+ {label}
40
+ {description}
41
+ {error}
42
+ for={htmlFor}
43
+ class={cn("max-w-xl", className)}
44
+ >
45
+ <div
46
+ class="flex items-start gap-2 any-pointer-coarse:[&_[data-slot=input]]:h-11"
47
+ data-slot="field-row"
48
+ >
49
+ <div class="min-w-0 flex-1">{@render children()}</div>
50
+ {#if action}
51
+ <!-- A disabled action reads as a muted button, not a half-faded one. -->
52
+ <div
53
+ class="shrink-0 [&_button:disabled]:bg-muted [&_button:disabled]:text-muted-foreground [&_button:disabled]:opacity-100 [&_button:disabled]:shadow-none"
54
+ >
55
+ {@render action()}
56
+ </div>
57
+ {/if}
58
+ </div>
59
+ </Field.Simple>
@@ -0,0 +1 @@
1
+ export { default as FieldRow } from "./field-row.svelte";
@@ -57,7 +57,7 @@ let {
57
57
  class={cn(
58
58
  "w-full",
59
59
  size === "sm" ? "h-7 pl-7 pr-2.5 text-xs" : "h-7 pl-7 pr-2.5 text-xs",
60
- "any-pointer-coarse:h-10 any-pointer-coarse:text-base",
60
+ "any-pointer-coarse:h-11 any-pointer-coarse:text-base",
61
61
  )}
62
62
  />
63
63
  </div>
@@ -16,7 +16,7 @@ export function toolbarTriggerClass({
16
16
  }: ToolbarTriggerOpts = {}): string {
17
17
  return cn(
18
18
  "inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-border font-medium transition-colors active:translate-y-px",
19
- "h-7 px-2.5 text-xs any-pointer-coarse:h-10 any-pointer-coarse:px-3",
19
+ "h-7 px-2.5 text-xs any-pointer-coarse:h-11 any-pointer-coarse:px-3",
20
20
  active ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground",
21
21
  className,
22
22
  );
@@ -25,8 +25,8 @@ export const pickerTriggerClass = tv({
25
25
  // Heights match `segmentedTrackClass`, so a strip and a picker line up.
26
26
  // Padding is 6px leading, 10px trailing: an even 8px shunted icon rows right.
27
27
  size: {
28
- sm: "h-6 ps-1.5 pe-2 any-pointer-coarse:h-9 any-pointer-coarse:pe-2.5",
29
- default: "h-7 ps-1.5 pe-2.5 any-pointer-coarse:h-10",
28
+ sm: "h-6 ps-1.5 pe-2 any-pointer-coarse:h-11 any-pointer-coarse:pe-2.5",
29
+ default: "h-7 ps-1.5 pe-2.5 any-pointer-coarse:h-11",
30
30
  },
31
31
  /**
32
32
  * Fill the row. Off by default: Linear's property rows sit at natural width
@@ -0,0 +1 @@
1
+ export { default as SectionCard } from "./section-card.svelte";