@heyramzi/ui 1.53.0 → 1.54.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 (102) hide show
  1. package/COMPONENTS.md +6 -0
  2. package/package.json +2 -2
  3. package/scripts/lib/sync-core.mjs +1 -1
  4. package/scripts/sync-shadcn.mjs +11 -11
  5. package/scripts/sync-transitions.mjs +7 -7
  6. package/src/agents/agent-avatar.svelte +14 -15
  7. package/src/agents/agent-cast.svelte +2 -2
  8. package/src/agents/demo.svelte +1 -1
  9. package/src/agents/helpers.ts +10 -1
  10. package/src/agents/index.ts +2 -2
  11. package/src/agents/page-state.ts +1 -1
  12. package/src/agents/types.ts +3 -3
  13. package/src/components/copy/copy-button.test.ts +3 -1
  14. package/src/components/data-table/infinite-scroll.svelte +1 -1
  15. package/src/components/data-table/pagination.svelte +1 -1
  16. package/src/components/data-table/row-arrow.svelte +1 -1
  17. package/src/components/data-table/row-context-menu.svelte +1 -1
  18. package/src/components/data-table/sortable-head.svelte +2 -2
  19. package/src/components/data-table/table-skeleton.svelte +1 -1
  20. package/src/components/docs/doc-page.svelte +161 -0
  21. package/src/components/docs/index.ts +12 -0
  22. package/src/components/docs/on-this-page.svelte +217 -0
  23. package/src/components/docs/scroll-spy.svelte.ts +97 -0
  24. package/src/components/field-row/field-row.svelte +57 -0
  25. package/src/components/field-row/index.ts +1 -0
  26. package/src/components/hold-to-confirm/hold-to-confirm.svelte +2 -2
  27. package/src/components/icon/icon.svelte +1 -1
  28. package/src/components/list-page/detail-empty.svelte +1 -1
  29. package/src/components/list-page/detail-header.svelte +1 -1
  30. package/src/components/list-page/detail-panel.svelte +1 -1
  31. package/src/components/list-page/detail-row.svelte +1 -1
  32. package/src/components/list-page/detail-stack.svelte +1 -1
  33. package/src/components/list-page/filter-item.svelte +1 -1
  34. package/src/components/list-page/filter-popover.svelte +1 -1
  35. package/src/components/list-page/grouped-list.svelte +1 -1
  36. package/src/components/list-page/list-page-shell.svelte +1 -1
  37. package/src/components/list-page/master-detail.svelte +1 -1
  38. package/src/components/list-page/prev-next.svelte +1 -1
  39. package/src/components/list-page/rail-body.svelte +1 -1
  40. package/src/components/list-page/rail-head.svelte +1 -1
  41. package/src/components/list-page/rail-row.svelte +1 -1
  42. package/src/components/list-page/section-label.svelte +1 -1
  43. package/src/components/list-page/toolbar-search.svelte +2 -2
  44. package/src/components/list-page/toolbar-trigger.ts +1 -1
  45. package/src/components/locale-switch/locale-switch.svelte +1 -1
  46. package/src/components/mark/mark.svelte +1 -1
  47. package/src/components/picker/mark-picker.svelte +1 -1
  48. package/src/components/picker/picker.svelte +1 -1
  49. package/src/components/picker/trigger.ts +2 -2
  50. package/src/components/section-card/index.ts +1 -0
  51. package/src/components/section-card/section-card.svelte +43 -0
  52. package/src/components/section-header/index.ts +1 -0
  53. package/src/components/section-header/section-header.svelte +54 -0
  54. package/src/components/section-header/section-header.test.ts +25 -0
  55. package/src/components/segmented/animated-tabs.svelte +2 -2
  56. package/src/components/segmented/collapse.test.ts +10 -0
  57. package/src/components/segmented/collapse.ts +5 -0
  58. package/src/components/segmented/segment.svelte +2 -2
  59. package/src/components/segmented/segmented-filter.svelte +6 -5
  60. package/src/components/segmented/segmented-nav.svelte +1 -1
  61. package/src/components/segmented/segmented-overflow.svelte +1 -1
  62. package/src/components/segmented/segmented-track.svelte +29 -4
  63. package/src/components/segmented/segmented.test.ts +1 -1
  64. package/src/components/segmented/segmented.ts +2 -2
  65. package/src/components/star-rating-input/star-rating-input.svelte +11 -4
  66. package/src/components/table-view/index.ts +3 -0
  67. package/src/components/table-view/table-view.svelte +103 -0
  68. package/src/components/table-view/table-view.test.ts +53 -0
  69. package/src/components/table-view/types.ts +14 -0
  70. package/src/components/theme/index.ts +17 -0
  71. package/src/components/theme/theme-provider.svelte +47 -0
  72. package/src/components/theme/theme-provider.test.ts +61 -0
  73. package/src/effects/animated-beam/diagrams/BeamSvg.svelte +1 -1
  74. package/src/effects/animated-beam/path-calculator.svelte.ts +1 -1
  75. package/src/effects/border-beam/types.ts +1 -1
  76. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +1 -1
  77. package/src/effects/shader-gradient/shaders.ts +1 -1
  78. package/src/effects/spotlight/Spotlight.svelte +1 -2
  79. package/src/icons/trades.ts +3 -3
  80. package/src/lib/sliding-pill.ts +3 -3
  81. package/src/lib/tone.test.ts +10 -0
  82. package/src/lib/tone.ts +1 -1
  83. package/src/shadcn/badge/badge.svelte +1 -1
  84. package/src/shadcn/button/button.svelte +10 -1
  85. package/src/shadcn/button/button.test.ts +19 -0
  86. package/src/shadcn/checkbox/checkbox.svelte +1 -1
  87. package/src/shadcn/context-menu/context-menu-separator.svelte +4 -1
  88. package/src/shadcn/dropdown-menu/dropdown-menu-separator.svelte +4 -1
  89. package/src/shadcn/input/input.svelte +2 -2
  90. package/src/shadcn/progress/progress.svelte +8 -2
  91. package/src/shadcn/progress/tone.test.ts +13 -0
  92. package/src/shadcn/progress/tone.ts +15 -0
  93. package/src/shadcn/sidebar/sidebar-group-label.svelte +1 -1
  94. package/src/shadcn/table/table-cell.svelte +21 -1
  95. package/src/shadcn/table/table-cell.test.ts +16 -0
  96. package/src/tokens/base.css +183 -0
  97. package/src/tokens/craft.css +179 -0
  98. package/src/tokens/index.css +19 -0
  99. package/src/tokens/palette.css +410 -0
  100. package/src/tokens/primitives.css +223 -0
  101. package/scripts/relative-imports.mjs +0 -39
  102. 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.0",
3
+ "version": "1.54.0",
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",
@@ -15,7 +15,7 @@ const get = (url) => fetch(url, { headers: { "user-agent": "heyramzi-ui-sync" }
15
15
 
16
16
  export async function fetchText(url) {
17
17
  const res = await get(url);
18
- if (!res.ok) throw new Error(`${res.status} ${res.statusText} — ${url}`);
18
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${url}`);
19
19
  return await res.text();
20
20
  }
21
21
 
@@ -238,14 +238,14 @@ for (const { item, payload } of fetched) {
238
238
  };
239
239
 
240
240
  if (!moved.length && !gone.length) unchanged++;
241
- else if (fresh) buckets.added.push(`${item.name} — ${files.length} files, new primitive`);
241
+ else if (fresh) buckets.added.push(`${item.name}: ${files.length} files, new primitive`);
242
242
  else if (blocked)
243
- for (const s of moved) buckets.port.push(`${label(s.path)} — upstream moved, ${s.state} here`);
243
+ for (const s of moved) buckets.port.push(`${label(s.path)}: upstream moved, ${s.state} here`);
244
244
  else if (apply.length)
245
- buckets.synced.push(`${item.name} — ${apply.map((s) => basename(s.path)).join(", ")}`);
245
+ buckets.synced.push(`${item.name}: ${apply.map((s) => basename(s.path)).join(", ")}`);
246
246
 
247
247
  for (const g of gone)
248
- buckets.removed.push(`${label(g.path)} — gone upstream${g.ours ? ", kept (ours)" : ""}`);
248
+ buckets.removed.push(`${label(g.path)}: gone upstream${g.ours ? ", kept (ours)" : ""}`);
249
249
 
250
250
  if (CHECK) continue;
251
251
 
@@ -261,14 +261,14 @@ for (const { item, payload } of fetched) {
261
261
 
262
262
  for (const name of Object.keys(previous)) {
263
263
  if (entries[name]) continue;
264
- buckets.removed.push(`${name} — gone from the registry`);
264
+ buckets.removed.push(`${name}: gone from the registry`);
265
265
  if (!CHECK) await removeDeep(`${VENDOR}/${name}`);
266
266
  }
267
267
 
268
268
  const ours = readdirSync(LOCAL, { withFileTypes: true })
269
269
  .filter((e) => e.isDirectory() && !entries[e.name] && !LOCAL_ONLY.has(e.name))
270
270
  .map((e) => e.name);
271
- for (const name of ours) buckets.unported.push(`${name} — in src/shadcn, not in the registry`);
271
+ for (const name of ours) buckets.unported.push(`${name}: in src/shadcn, not in the registry`);
272
272
 
273
273
  /* The docs site's own demo for each primitive, vendored on the same three-way
274
274
  * rule. Upstream writes them; the site renders them. */
@@ -284,7 +284,7 @@ for (const name of demoNames) {
284
284
  fetchTextIf(`${DOCS}/content/components/${name}.md`),
285
285
  ]);
286
286
  if (raw === null) {
287
- buckets.unported.push(`${name} — no demo upstream`);
287
+ buckets.unported.push(`${name}: no demo upstream`);
288
288
  continue;
289
289
  }
290
290
 
@@ -296,12 +296,12 @@ for (const name of demoNames) {
296
296
  // The imports of the demo that renders: ours when kept, upstream's when written.
297
297
  const shown = state === "diverged" ? local : next;
298
298
 
299
- if (state === "dropped") buckets.removed.push(`${label(path)} — dropped here, kept out`);
299
+ if (state === "dropped") buckets.removed.push(`${label(path)}: dropped here, kept out`);
300
300
  else manifest.push({ name, ...meta(doc, name), imports: publicImports(shown) });
301
301
 
302
302
  if (base !== raw) {
303
- if (state === "diverged") buckets.port.push(`${label(path)} — upstream moved, ours here`);
304
- else if (state !== "current") buckets.synced.push(`${name} — demo`);
303
+ if (state === "diverged") buckets.port.push(`${label(path)}: upstream moved, ours here`);
304
+ else if (state !== "current") buckets.synced.push(`${name}: demo`);
305
305
  }
306
306
  if (CHECK) continue;
307
307
 
@@ -345,7 +345,7 @@ if (!CHECK) {
345
345
 
346
346
  process.exit(
347
347
  report({
348
- title: `shadcn-svelte — ${items.length} primitives`,
348
+ title: `shadcn-svelte: ${items.length} primitives`,
349
349
  buckets,
350
350
  unchanged,
351
351
  footer: `${Object.values(entries).filter((e) => e.adapted).length} adapted here, ${LOCAL_ONLY.size} ours`,
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /*
15
- * Paid ones are dropped here and never vendored — Ramzi, 14 Sep 2026: "the pro effects we don't need them".
15
+ * Paid ones are dropped here and never vendored (Ramzi, 14 Sep 2026: "the pro effects we don't need them").
16
16
  */
17
17
 
18
18
  /*
@@ -100,20 +100,20 @@ for (const { slug, name, css } of free) {
100
100
  const hash = sha256(css);
101
101
  entries[slug] = { name, sha256: hash, local: PORTED[slug] ?? null };
102
102
 
103
- if (!previous[slug]) buckets.added.push(`${slug} — ${name}`);
104
- else if (previous[slug].sha256 !== hash) buckets.changed.push(`${slug} — ${name}`);
103
+ if (!previous[slug]) buckets.added.push(`${slug}: ${name}`);
104
+ else if (previous[slug].sha256 !== hash) buckets.changed.push(`${slug}: ${name}`);
105
105
  else unchanged++;
106
106
 
107
- if (!PORTED[slug]) buckets.unported.push(`${slug} — nothing under src/ owns it`);
107
+ if (!PORTED[slug]) buckets.unported.push(`${slug}: nothing under src/ owns it`);
108
108
  else if (!existsSync(`${SRC}/${PORTED[slug]}`))
109
- buckets.unported.push(`${slug} — PORTED says ${PORTED[slug]}, which is missing`);
109
+ buckets.unported.push(`${slug}: PORTED says ${PORTED[slug]}, which is missing`);
110
110
 
111
111
  if (!CHECK) await writeFileDeep(`${VENDOR}/${slug}.css`, css);
112
112
  }
113
113
 
114
114
  for (const slug of Object.keys(previous)) {
115
115
  if (entries[slug]) continue;
116
- buckets.removed.push(`${slug} — gone upstream or moved to pro`);
116
+ buckets.removed.push(`${slug}: gone upstream or moved to pro`);
117
117
  if (!CHECK) await removeDeep(`${VENDOR}/${slug}.css`);
118
118
  }
119
119
 
@@ -138,7 +138,7 @@ if (!CHECK) {
138
138
 
139
139
  process.exit(
140
140
  report({
141
- title: `transitions.dev — ${free.length} free`,
141
+ title: `transitions.dev: ${free.length} free`,
142
142
  buckets,
143
143
  unchanged,
144
144
  footer: `${paid} pro skipped`,
@@ -4,15 +4,15 @@
4
4
  BotAvatarShading,
5
5
  BotAvatarState,
6
6
  BotAvatarType,
7
- } from './vendor/types';
8
- import type { AgentAvatarProps } from './types';
9
- import { botAvatarPresets, stateLabels } from './vendor/presets';
10
- import { SHAPE_PATHS, SHAPE_PARTS } from './vendor/shapes';
11
- import { autoInk, richer, shade } from './vendor/color';
12
- import { Sim, restPose } from './vendor/engine';
13
- import { draw, LIGHT_DEFAULTS, OVERSCAN, RISE, type DrawConfig } from './vendor/draw';
14
- import { bakesPending, hurry, onLanded, takeMissed, warmPlastic, type FurStyle } from './vendor/plastic';
15
- import { subscribe, pointer } from './vendor/ticker';
7
+ } from './vendor/types.js';
8
+ import type { AgentAvatarProps } from './types.js';
9
+ import { botAvatarPresets, stateLabels } from './vendor/presets.js';
10
+ import { SHAPE_PATHS, SHAPE_PARTS } from './vendor/shapes.js';
11
+ import { richer, shade } from './vendor/color.js';
12
+ import { Sim, restPose } from './vendor/engine.js';
13
+ import { draw, LIGHT_DEFAULTS, OVERSCAN, RISE, type DrawConfig } from './vendor/draw.js';
14
+ import { bakesPending, hurry, onLanded, takeMissed, warmPlastic, type FurStyle } from './vendor/plastic.js';
15
+ import { subscribe, pointer } from './vendor/ticker.js';
16
16
  import {
17
17
  SHOW_WITHIN,
18
18
  bodyPath,
@@ -21,21 +21,20 @@
21
21
  partPaths,
22
22
  showReady,
23
23
  waiters,
24
- } from './page-state';
24
+ } from './page-state.js';
25
25
  import {
26
26
  clamp,
27
27
  clampOpt,
28
28
  deviceDpr,
29
29
  hashSeed,
30
+ inkFor,
30
31
  lightsFor,
31
32
  pointerStrength,
32
33
  q,
33
34
  reducedMotion,
34
35
  resolveTheme as themeOf,
35
36
  warmIdle,
36
- } from './helpers';
37
-
38
- type Props = AgentAvatarProps;
37
+ } from './helpers.js';
39
38
 
40
39
  let {
41
40
  name,
@@ -106,7 +105,7 @@
106
105
  'aria-hidden': ariaHidden,
107
106
  onclick: userClick,
108
107
  ...rest
109
- }: Props = $props();
108
+ }: AgentAvatarProps = $props();
110
109
 
111
110
  const shapePath = (t: BotAvatarType) => SHAPE_PATHS[t] ?? SHAPE_PATHS.clover;
112
111
 
@@ -140,7 +139,7 @@
140
139
  vivid,
141
140
  ),
142
141
  );
143
- const inkColor = $derived(ink ?? autoInk(body));
142
+ const inkColor = $derived(ink ?? inkFor(body));
144
143
  const seedValue = $derived(Math.min(1, Math.max(0, seed ?? hashSeed(uid))));
145
144
  const stateKey = $derived((botState in stateLabels ? botState : 'default') as BotAvatarState);
146
145
  const frozen = $derived(paused || !(speed > 0));
@@ -1,8 +1,8 @@
1
1
  <script lang="ts">
2
2
  import AgentAvatar from './agent-avatar.svelte';
3
- import type { Agent, AgentState } from './types';
3
+ import type { Agent, AgentState } from './types.js';
4
4
 
5
- type Props = {
5
+ interface Props {
6
6
  /** The cast to lay out. */
7
7
  agents: Agent[];
8
8
  /** Selected agent id, when the cast is a picker. */
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import AgentAvatar from './agent-avatar.svelte';
3
3
  import AgentCast from './agent-cast.svelte';
4
- import type { Agent, AgentState } from './types';
4
+ import type { Agent, AgentState } from './types.js';
5
5
 
6
6
  const cast: Agent[] = [
7
7
  { id: 'ada', name: 'Ada', role: 'Researcher', shape: 'clover', color: '#41C4FF', group: 'Research' },
@@ -1,5 +1,14 @@
1
1
  /* Pure helpers for the avatar: numbers, theme, lights, idle scheduling. */
2
- import { stockLights } from './vendor/plastic';
2
+ import { DARK_INK, LIGHT_INK, luminance } from './vendor/color.js';
3
+ import { stockLights } from './vendor/plastic.js';
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
+ }
3
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));
@@ -1,6 +1,6 @@
1
1
  export { default as AgentAvatar } from './agent-avatar.svelte';
2
2
  export { default as AgentCast } from './agent-cast.svelte';
3
- export type { Agent, AgentAvatarProps, AgentShape } from './types';
3
+ export type { Agent, AgentAvatarProps, AgentShape } from './types.js';
4
4
  export {
5
5
  agentPresets,
6
6
  agentShapes,
@@ -8,4 +8,4 @@ export {
8
8
  agentStates,
9
9
  agentPalette,
10
10
  agentStateLabels,
11
- } from './types';
11
+ } from './types.js';
@@ -1,6 +1,6 @@
1
1
  /* What every avatar on the page shares: the batching shown-gate, so a row of
2
2
  them comes in together, the path caches, and one theme watcher. */
3
- import { bakesPending } from './vendor/plastic';
3
+ import { bakesPending } from './vendor/plastic.js';
4
4
 
5
5
  export const SHOW_WITHIN = 4000;
6
6
  export const waiters = new Map<object, { ready: () => boolean; show: () => void }>();
@@ -1,4 +1,4 @@
1
- import type { BotAvatarFace, BotAvatarProps, BotAvatarType } from './vendor/types';
1
+ import type { BotAvatarFace, BotAvatarProps, BotAvatarType } from './vendor/types.js';
2
2
 
3
3
  /** The body shapes an agent can be drawn as. */
4
4
  export type AgentShape = BotAvatarType;
@@ -11,7 +11,7 @@ export type {
11
11
  BotAvatarHat as AgentHat,
12
12
  BotAvatarGlasses as AgentGlasses,
13
13
  BotAvatarPreset as AgentPreset,
14
- } from './vendor/types';
14
+ } from './vendor/types.js';
15
15
 
16
16
  export {
17
17
  botAvatarPresets as agentPresets,
@@ -20,7 +20,7 @@ export {
20
20
  botAvatarStates as agentStates,
21
21
  botAvatarPalette as agentPalette,
22
22
  stateLabels as agentStateLabels,
23
- } from './vendor/presets';
23
+ } from './vendor/presets.js';
24
24
 
25
25
  /** One member of a cast: a name, a role, and the face they draw. */
26
26
  export type Agent = {
@@ -16,8 +16,10 @@ function render(props: Props) {
16
16
  document.body.appendChild(target);
17
17
  const app = mount(CopyButton, { target, props });
18
18
  flushSync();
19
+ const button = target.querySelector("button");
20
+ if (!button) throw new Error("CopyButton rendered no button");
19
21
  return {
20
- button: target.querySelector("button")!,
22
+ button,
21
23
  target,
22
24
  done: () => {
23
25
  void unmount(app);
@@ -4,7 +4,7 @@ import type { Snippet } from "svelte";
4
4
  import { Spinner } from "../../shadcn/spinner/index.js";
5
5
 
6
6
  // ========================= TYPES
7
- type Props = {
7
+ interface Props {
8
8
  items: T[];
9
9
  isDone: boolean;
10
10
  isLoading: boolean;
@@ -4,7 +4,7 @@ import { Button } from "../../shadcn/button/index.js";
4
4
  import { totalPages as calcTotalPages } from "./data-table.js";
5
5
 
6
6
  // ========================= TYPES
7
- type Props = {
7
+ interface Props {
8
8
  page: number;
9
9
  pageSize: number;
10
10
  total: number;
@@ -8,7 +8,7 @@ import { cn } from "@heyramzi/utils";
8
8
  // =========================
9
9
  // TYPES
10
10
  // =========================
11
- type Props = {
11
+ interface Props {
12
12
  class?: string;
13
13
  };
14
14
 
@@ -10,7 +10,7 @@ import type { Snippet } from "svelte";
10
10
  // =========================
11
11
  // TYPES
12
12
  // =========================
13
- type Props = {
13
+ interface Props {
14
14
  onDelete: () => void;
15
15
  href?: string;
16
16
  id?: string;
@@ -5,7 +5,7 @@ import { cn } from "@heyramzi/utils";
5
5
  import type { SortDirection, SortState } from "./data-table.js";
6
6
 
7
7
  // ========================= TYPES
8
- type Props = {
8
+ interface Props {
9
9
  key: keyof T & string;
10
10
  sort: SortState<T>;
11
11
  onSort: (key: keyof T & string) => void;
@@ -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>
@@ -10,7 +10,7 @@ type ColumnDef = {
10
10
  circle?: boolean;
11
11
  };
12
12
 
13
- type Props = {
13
+ interface Props {
14
14
  /** Number of skeleton rows to render */
15
15
  rows?: number;
16
16
  /** Column definitions control skeleton widths and alignment */
@@ -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";