@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
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Snippet } from "svelte";
6
+ import { cn } from "@heyramzi/utils";
7
+ import * as Card from "../../shadcn/card/index.js";
8
+
9
+ // =========================
10
+ // TYPES
11
+ // =========================
12
+ interface Props {
13
+ title: string;
14
+ /** Controls at the far end of the header. */
15
+ actions?: Snippet;
16
+ /** No padding in the body, so a table runs edge to edge under the header. */
17
+ flush?: boolean;
18
+ class?: string;
19
+ children: Snippet;
20
+ }
21
+
22
+ // =========================
23
+ // PROPS
24
+ // =========================
25
+ let { title, actions, flush = false, class: className, children }: Props = $props();
26
+ </script>
27
+
28
+ <!-- =========================== -->
29
+ <!-- MARKUP -->
30
+ <!-- WHY a fixed header height: a card with an action in its header and one
31
+ without sat 12px apart on the same page. -->
32
+ <!-- =========================== -->
33
+ <Card.Root class={cn("gap-0 py-0", className)}>
34
+ <header class="flex min-h-14 items-center justify-between gap-3 border-b px-4 py-3 sm:px-6">
35
+ <Card.Title as="h3" class="text-base font-medium">{title}</Card.Title>
36
+ {#if actions}
37
+ <div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
38
+ {/if}
39
+ </header>
40
+ <div class={flush ? "" : "p-4 sm:p-6"}>
41
+ {@render children()}
42
+ </div>
43
+ </Card.Root>
@@ -0,0 +1 @@
1
+ export { default as SectionHeader } from "./section-header.svelte";
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Snippet } from "svelte";
6
+ import SectionLabel from "../list-page/section-label.svelte";
7
+
8
+ // =========================
9
+ // TYPES
10
+ // =========================
11
+ interface Props {
12
+ title: string;
13
+ /** How many things the section holds. Drawn as a chip next to the title. */
14
+ count?: number;
15
+ /** What the count counts, for a screen reader: "lines". */
16
+ countLabel?: string;
17
+ /** One line under the title. */
18
+ description?: string;
19
+ /** A control at the far end of the row. */
20
+ actions?: Snippet;
21
+ /** The heading level. Sections under a page title are 2. */
22
+ level?: 2 | 3;
23
+ }
24
+
25
+ // =========================
26
+ // PROPS
27
+ // =========================
28
+ let { title, count, countLabel = "items", description, actions, level = 2 }: Props = $props();
29
+ </script>
30
+
31
+ <!-- =========================== -->
32
+ <!-- MARKUP -->
33
+ <!-- =========================== -->
34
+ <header class="flex items-end justify-between gap-3">
35
+ <div class="min-w-0 space-y-1">
36
+ <svelte:element this={`h${level}`} class="flex items-center gap-2">
37
+ <SectionLabel>{title}</SectionLabel>
38
+ {#if count !== undefined}
39
+ <span
40
+ class="rounded-md bg-muted px-1.5 py-0.5 text-xs leading-none font-medium tabular-nums text-muted-foreground"
41
+ aria-label={`${count} ${countLabel}`}
42
+ >
43
+ {count}
44
+ </span>
45
+ {/if}
46
+ </svelte:element>
47
+ {#if description}
48
+ <p class="text-sm text-muted-foreground">{description}</p>
49
+ {/if}
50
+ </div>
51
+ {#if actions}
52
+ <div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
53
+ {/if}
54
+ </header>
@@ -0,0 +1,25 @@
1
+ import { flushSync, mount, unmount } from "svelte";
2
+ import { describe, expect, it } from "vite-plus/test";
3
+ import SectionHeader from "./section-header.svelte";
4
+
5
+ function render(props: Record<string, unknown>) {
6
+ const target = document.createElement("div");
7
+ document.body.appendChild(target);
8
+ const app = mount(SectionHeader, { target, props: { title: "Lines", ...props } });
9
+ flushSync();
10
+ return { target, done: () => { void unmount(app); target.remove(); } };
11
+ }
12
+
13
+ describe("SectionHeader", () => {
14
+ it("draws the count as a chip a screen reader can name", () => {
15
+ const { target, done } = render({ count: 3, countLabel: "lines" });
16
+ expect(target.querySelector("[aria-label='3 lines']")).not.toBeNull();
17
+ done();
18
+ });
19
+
20
+ it("takes its heading level", () => {
21
+ const { target, done } = render({ level: 3 });
22
+ expect(target.querySelector("h3")).not.toBeNull();
23
+ done();
24
+ });
25
+ });
@@ -47,3 +47,13 @@ describe("splitSegments", () => {
47
47
  expect(splitSegments(items, 2, 5).hidden).toEqual([]);
48
48
  });
49
49
  });
50
+
51
+ describe("resolveSegmentMax", () => {
52
+ it("turns none into a limit nothing reaches, so no tail folds", async () => {
53
+ const { resolveSegmentMax, splitSegments } = await import("./collapse.js");
54
+ const items = Array.from({ length: 20 }, (_, i) => i);
55
+ expect(resolveSegmentMax("none")).toBe(Number.POSITIVE_INFINITY);
56
+ expect(resolveSegmentMax(4)).toBe(4);
57
+ expect(splitSegments(items, resolveSegmentMax("none"), 15).hidden).toEqual([]);
58
+ });
59
+ });
@@ -37,3 +37,8 @@ export function splitSegments<S>(
37
37
  hidden: items.filter((_, i) => i >= slots - 1 && i !== activeIndex),
38
38
  };
39
39
  }
40
+
41
+ /** `"none"` draws every segment: the row scrolls sideways instead of folding a tail into a menu. */
42
+ export function resolveSegmentMax(max: number | "none"): number {
43
+ return max === "none" ? Number.POSITIVE_INFINITY : max;
44
+ }
@@ -7,7 +7,7 @@ import Segment from "./segment.svelte";
7
7
  import type { TabDef } from "./animated-tabs.svelte";
8
8
  import SegmentedTrack from "./segmented-track.svelte";
9
9
  import SegmentedOverflow from "./segmented-overflow.svelte";
10
- import { SEGMENT_MAX, splitSegments } from "./collapse.js";
10
+ import { SEGMENT_MAX, resolveSegmentMax, splitSegments } from "./collapse.js";
11
11
  import { segmentedTriggerClass } from "./segmented.js";
12
12
 
13
13
  // =========================
@@ -33,8 +33,9 @@ interface Props {
33
33
  /** Fill the row rather than sit at its natural width. On for the list-page
34
34
  * filter strip, off for a segment sitting in a toolbar beside others. */
35
35
  block?: boolean;
36
- /** Objects the row may draw before its tail becomes a menu. See `collapse.ts`. */
37
- max?: number;
36
+ /** Objects the row may draw before its tail becomes a menu. See `collapse.ts`.
37
+ * `"none"` never folds. */
38
+ max?: number | "none";
38
39
  /** What the menu segment says. Translate at the call site. */
39
40
  moreLabel?: string;
40
41
  class?: string;
@@ -70,7 +71,7 @@ let merged = $derived(
70
71
  let split = $derived(
71
72
  splitSegments(
72
73
  merged,
73
- max,
74
+ resolveSegmentMax(max),
74
75
  merged.findIndex((option) => option.value === value),
75
76
  ),
76
77
  );
@@ -174,6 +174,26 @@ let {
174
174
  box-shadow: inset 0 -2px 0 var(--segmented-rule, var(--color-foreground));
175
175
  }
176
176
 
177
+ /* On a coarse pointer the well is 44px and its segments fill it, so the hit area is the strip.
178
+ A clipped well can't hold a hit area that hangs outside it. The border becomes a ring drawn
179
+ over the segments, and the pill keeps its 2px gap inside that ring. */
180
+ @media (any-pointer: coarse) {
181
+ .track[data-variant="pill"]::after {
182
+ content: "";
183
+ position: absolute;
184
+ inset: 0;
185
+ z-index: 2;
186
+ border: 1px solid var(--color-border);
187
+ border-radius: inherit;
188
+ pointer-events: none;
189
+ }
190
+
191
+ .track[data-variant="pill"] .pill {
192
+ top: 3px;
193
+ bottom: 3px;
194
+ }
195
+ }
196
+
177
197
  @media (prefers-reduced-motion: reduce) {
178
198
  .pill {
179
199
  transition: opacity var(--duration-quick) var(--ease-smooth-out);
@@ -7,7 +7,7 @@ describe("segmentedTrackClass", () => {
7
7
  for (const variant of ["pill", "underline"] as const) {
8
8
  const classes = segmentedTrackClass({ variant }).split(" ");
9
9
  expect(classes).toContain("h-7");
10
- expect(classes).toContain("any-pointer-coarse:h-10");
10
+ expect(classes).toContain("any-pointer-coarse:h-11");
11
11
  }
12
12
  });
13
13
 
@@ -15,12 +15,12 @@ export const segmentedTrackClass = tv({
15
15
  // drew 30px beside a 28px button. One toolbar row came out at five heights.
16
16
 
17
17
  // WHY no size variant: `sm` drew 24px, and every tab control must share one height.
18
- base: "h-7 any-pointer-coarse:h-10 shrink-0 items-stretch",
18
+ base: "h-7 any-pointer-coarse:h-11 shrink-0 items-stretch",
19
19
  variants: {
20
20
  // `pill`: a bordered well with a travelling highlight. `underline`: bare
21
21
  // labels over one hairline, the active one marked by a rule that travels.
22
22
  variant: {
23
- pill: "overflow-hidden rounded-lg border border-border p-0.5",
23
+ pill: "overflow-hidden rounded-lg border border-border p-0.5 any-pointer-coarse:border-0 any-pointer-coarse:px-[3px] any-pointer-coarse:py-0",
24
24
  underline: "gap-5 border-b border-border",
25
25
  },
26
26
  // Fill the row rather than sit at its natural width. For a narrow rail.
@@ -0,0 +1,3 @@
1
+ export { default as TableView } from "./table-view.svelte";
2
+ export type { TableCell } from "./table-view.svelte";
3
+ export type { TableColumn } from "./types.js";
@@ -0,0 +1,103 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type { TableColumn } from "./types.js";
4
+
5
+ export type TableCell<Row> = { row: Row; column: TableColumn<Row>; value: unknown; text: string };
6
+ export type { TableColumn };
7
+ </script>
8
+
9
+ <script lang="ts" generics="Row extends object">
10
+ // =========================
11
+ // IMPORTS
12
+ // =========================
13
+ import { cn } from "@heyramzi/utils";
14
+ import * as Table from "../../shadcn/table/index.js";
15
+
16
+ // =========================
17
+ // TYPES
18
+ // =========================
19
+ interface Props {
20
+ columns: TableColumn<Row>[];
21
+ rows: Row[];
22
+ /** What names a row, so a filter keeps its rows in place. */
23
+ rowKey: (row: Row) => string;
24
+ /** What the table is, for a screen reader. */
25
+ label: string;
26
+ /** A totals row, drawn under the body in the same columns. */
27
+ footer?: Row;
28
+ /** Draws a cell its own way. Print `text` for the plain case. */
29
+ cell?: Snippet<[TableCell<Row>]>;
30
+ rowClass?: (row: Row) => string | undefined;
31
+ /** Below this width the table scrolls sideways inside its frame. */
32
+ minWidth?: string;
33
+ class?: string;
34
+ }
35
+
36
+ // =========================
37
+ // PROPS
38
+ // =========================
39
+ let { columns, rows, rowKey, label, footer, cell, rowClass, minWidth = "0", class: className }: Props = $props();
40
+
41
+ // =========================
42
+ // FUNCTIONS
43
+ // =========================
44
+ const key = (column: TableColumn<Row>) => column.key;
45
+
46
+ function textOf(column: TableColumn<Row>, row: Row): string {
47
+ const value = (row as Record<string, unknown>)[key(column)];
48
+ if (column.format) return column.format(value, row);
49
+ return value === null || value === undefined ? "" : String(value);
50
+ }
51
+ </script>
52
+
53
+ <!-- =========================== -->
54
+ <!-- MARKUP -->
55
+ <!-- =========================== -->
56
+ {#snippet body(row: Row)}
57
+ {#each columns as column (column.key)}
58
+ <Table.Cell
59
+ class={cn(
60
+ "align-top",
61
+ column.numeric ? "text-end tabular-nums" : "whitespace-normal",
62
+ column.muted && "text-muted-foreground",
63
+ )}
64
+ >
65
+ {#if cell}
66
+ {@render cell({ row, column, value: (row as Record<string, unknown>)[column.key], text: textOf(column, row) })}
67
+ {:else}
68
+ {textOf(column, row)}
69
+ {/if}
70
+ </Table.Cell>
71
+ {/each}
72
+ {/snippet}
73
+
74
+ <div class={cn("overflow-hidden rounded-xl border", className)}>
75
+ <Table.Root aria-label={label} style={`min-width: ${minWidth}`} class="min-w-0">
76
+ <Table.Header>
77
+ <Table.Row>
78
+ {#each columns as column (column.key)}
79
+ <Table.Head
80
+ class={cn("whitespace-normal", column.numeric && "text-end")}
81
+ style={column.width ? `width: ${column.width}` : undefined}
82
+ >
83
+ {column.label}
84
+ </Table.Head>
85
+ {/each}
86
+ </Table.Row>
87
+ </Table.Header>
88
+ <Table.Body>
89
+ {#each rows as row (rowKey(row))}
90
+ <Table.Row class={rowClass?.(row)}>
91
+ {@render body(row)}
92
+ </Table.Row>
93
+ {/each}
94
+ </Table.Body>
95
+ {#if footer}
96
+ <Table.Footer>
97
+ <Table.Row>
98
+ {@render body(footer)}
99
+ </Table.Row>
100
+ </Table.Footer>
101
+ {/if}
102
+ </Table.Root>
103
+ </div>
@@ -0,0 +1,53 @@
1
+ import { createRawSnippet, flushSync, mount, unmount } from "svelte";
2
+ import { describe, expect, it } from "vite-plus/test";
3
+ import TableView from "./table-view.svelte";
4
+
5
+ type Row = { id: string; name: string; total: number };
6
+ const rows: Row[] = [
7
+ { id: "a", name: "Audit", total: 1200 },
8
+ { id: "b", name: "Build", total: 4800 },
9
+ ];
10
+
11
+ function render(extra: Record<string, unknown> = {}) {
12
+ const target = document.createElement("div");
13
+ document.body.appendChild(target);
14
+ const app = mount(TableView<Row>, {
15
+ target,
16
+ props: {
17
+ label: "Lines",
18
+ rows,
19
+ rowKey: (row: Row) => row.id,
20
+ columns: [
21
+ { key: "name", label: "Name" },
22
+ { key: "total", label: "Total", numeric: true, format: (v: unknown) => `$${v}` },
23
+ ],
24
+ ...extra,
25
+ },
26
+ });
27
+ flushSync();
28
+ return { target, done: () => { void unmount(app); target.remove(); } };
29
+ }
30
+
31
+ describe("TableView", () => {
32
+ it("names the table and prints each row through its column's format", () => {
33
+ const { target, done } = render();
34
+ expect(target.querySelector("table")?.getAttribute("aria-label")).toBe("Lines");
35
+ const cells = [...target.querySelectorAll("tbody td")].map((td) => td.textContent?.trim());
36
+ expect(cells).toEqual(["Audit", "$1200", "Build", "$4800"]);
37
+ done();
38
+ });
39
+
40
+ it("right-aligns a numeric column and draws a footer in the same columns", () => {
41
+ const { target, done } = render({ footer: { id: "t", name: "Total", total: 6000 } });
42
+ expect(target.querySelector("thead th:last-child")?.className).toContain("text-end");
43
+ expect(target.querySelector("tfoot")?.textContent).toContain("$6000");
44
+ done();
45
+ });
46
+
47
+ it("lets a caller draw a cell", () => {
48
+ const cell = createRawSnippet(() => ({ render: () => `<b>x</b>` }));
49
+ const { target, done } = render({ cell });
50
+ expect(target.querySelectorAll("tbody b").length).toBe(4);
51
+ done();
52
+ });
53
+ });
@@ -0,0 +1,14 @@
1
+ /** One column of a `TableView`. */
2
+ export interface TableColumn<Row> {
3
+ /** The key of the value in the row. */
4
+ key: string;
5
+ label: string;
6
+ /** A number or an amount: right-aligned, tabular figures, never wraps. */
7
+ numeric?: boolean;
8
+ /** Turns the raw value into the text shown. A string value that has no `format` prints as it is. A money column passes its own currency formatter. */
9
+ format?: (value: unknown, row: Row) => string;
10
+ /** A column width such as `"30%"` or `"8rem"`. Leave it out and the table shares the rest. */
11
+ width?: string;
12
+ /** Draws the column in the muted colour. */
13
+ muted?: boolean;
14
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The one light and dark system: mode-watcher's store and no-flash script, behind one provider.
3
+ * Mount `ThemeProvider` once in the root layout, flip the mode with `toggleMode` or `setMode`, and
4
+ * read it from `mode.current` or `userPrefersMode.current`. `ThemeToggle` and `revealTheme`
5
+ * draw the control and the wipe.
6
+ */
7
+ export { default as ThemeProvider } from "./theme-provider.svelte";
8
+ export { revealTheme } from "../../lib/theme-transition.js";
9
+ export {
10
+ mode,
11
+ resetMode,
12
+ setMode,
13
+ systemPrefersMode,
14
+ toggleMode,
15
+ userPrefersMode,
16
+ type UserPrefersMode,
17
+ } from "mode-watcher";
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { ModeWatcher, userPrefersMode, type UserPrefersMode } from "mode-watcher";
6
+ import type { ComponentProps } from "svelte";
7
+
8
+ // =========================
9
+ // TYPES
10
+ // =========================
11
+ type WatcherProps = ComponentProps<typeof ModeWatcher>;
12
+
13
+ interface Props extends Omit<WatcherProps, "defaultMode"> {
14
+ /**
15
+ * The choice the server already knows, for a browser that has none stored. A stored
16
+ * choice wins, so it seeds a first visit and never overrides a click.
17
+ */
18
+ initial?: UserPrefersMode;
19
+ /**
20
+ * Fires when the person changes the mode, never on load. An app that saves the choice
21
+ * on its server posts it here.
22
+ */
23
+ onChange?: (mode: UserPrefersMode) => void;
24
+ }
25
+
26
+ // =========================
27
+ // PROPS
28
+ // =========================
29
+ let { initial = "system", onChange, synchronousModeChanges = true, ...watcher }: Props = $props();
30
+
31
+ // =========================
32
+ // EFFECTS
33
+ // =========================
34
+ // The first run is the mode read from storage on mount, which nobody chose.
35
+ let seen: UserPrefersMode | null = null;
36
+ $effect(() => {
37
+ const next = userPrefersMode.current;
38
+ if (seen !== null && next !== seen) onChange?.(next);
39
+ seen = next;
40
+ });
41
+ </script>
42
+
43
+ <!-- WHY it renders ModeWatcher and nothing else: its inline head script sets the class and
44
+ colour-scheme before the first paint, so there is no flash and no cookie to read.
45
+ WHY synchronous: `revealTheme` snapshots the page the moment its callback returns, so the
46
+ class has to be on the root by then. -->
47
+ <ModeWatcher defaultMode={initial} {synchronousModeChanges} {...watcher} />
@@ -0,0 +1,61 @@
1
+ import { flushSync, mount, unmount } from "svelte";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
+ import { setMode, toggleMode } from "mode-watcher";
4
+ import ThemeProvider from "./theme-provider.svelte";
5
+
6
+ function render(props: Record<string, unknown>) {
7
+ const target = document.createElement("div");
8
+ document.body.appendChild(target);
9
+ const app = mount(ThemeProvider, { target, props });
10
+ flushSync();
11
+ return () => {
12
+ void unmount(app);
13
+ target.remove();
14
+ };
15
+ }
16
+
17
+ describe("ThemeProvider", () => {
18
+ beforeEach(() => {
19
+ localStorage.clear();
20
+ // WHY: jsdom has no matchMedia, and the watcher asks it for the system mode on mount.
21
+ Object.defineProperty(window, "matchMedia", {
22
+ configurable: true,
23
+ value: (query: string) => ({
24
+ matches: false,
25
+ media: query,
26
+ addEventListener() {},
27
+ removeEventListener() {},
28
+ }),
29
+ });
30
+ });
31
+ afterEach(() => document.documentElement.classList.remove("dark"));
32
+
33
+ it("takes the server's choice when nothing is stored", () => {
34
+ const done = render({ initial: "dark", disableHeadScriptInjection: true });
35
+ flushSync();
36
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
37
+ done();
38
+ });
39
+
40
+ it("lets a stored choice beat the server's", () => {
41
+ localStorage.setItem("mode-watcher-mode", "light");
42
+ const done = render({ initial: "dark", disableHeadScriptInjection: true });
43
+ flushSync();
44
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
45
+ done();
46
+ });
47
+
48
+ it("reports a person's choice and not the load", () => {
49
+ const onChange = vi.fn();
50
+ const done = render({ initial: "light", onChange, disableHeadScriptInjection: true });
51
+ flushSync();
52
+ expect(onChange).not.toHaveBeenCalled();
53
+ setMode("dark");
54
+ flushSync();
55
+ expect(onChange).toHaveBeenLastCalledWith("dark");
56
+ toggleMode();
57
+ flushSync();
58
+ expect(onChange).toHaveBeenLastCalledWith("light");
59
+ done();
60
+ });
61
+ });
@@ -22,7 +22,6 @@
22
22
  <!-- =========================== -->
23
23
  <!-- MARKUP -->
24
24
  <!-- =========================== -->
25
- <!-- no-inline-svg-ignore: static decorative blob, animated via the spotlight keyframe -->
26
25
  <svg
27
26
  class={cn(
28
27
  "hr-spotlight pointer-events-none absolute z-[1] h-[169%] w-[138%] animate-spotlight opacity-0 lg:w-[84%]",
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { TONE_CLASS } from "./tone.js";
3
+
4
+ describe("TONE_CLASS", () => {
5
+ // Plain destructive text on its own 10% tint is 3.6:1. The ink mix clears 4.5:1.
6
+ it("sets destructive text in the ink, not the raw colour", () => {
7
+ expect(TONE_CLASS.destructive).toContain("text-destructive-ink");
8
+ expect(TONE_CLASS.destructive.split(" ")).not.toContain("text-destructive");
9
+ });
10
+ });
package/src/lib/tone.ts CHANGED
@@ -14,7 +14,7 @@ export const TONE_CLASS: Record<Tone, string> = {
14
14
  info: "border-info/30 bg-info/10 text-info-800 dark:text-info-400",
15
15
  success: "border-success/30 bg-success/10 text-success-800 dark:text-success-400",
16
16
  warning: "border-warning/30 bg-warning/10 text-warning-800 dark:text-warning-400",
17
- destructive: "border-destructive/30 bg-destructive/10 text-destructive",
17
+ destructive: "border-destructive/30 bg-destructive/10 text-destructive-ink",
18
18
  };
19
19
 
20
20
  /** The same scale as text, for a verdict inside a line of muted facts. */
@@ -11,7 +11,7 @@
11
11
  secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
12
12
  outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
13
13
  // For a consumer that wants one "this is wrong" badge without a tone prop.
14
- destructive: "bg-destructive/10 text-destructive [a]:hover:bg-destructive/20",
14
+ destructive: "bg-destructive/10 text-destructive-ink [a]:hover:bg-destructive/20",
15
15
  },
16
16
  },
17
17
  defaultVariants: {
@@ -18,6 +18,9 @@
18
18
  "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
19
19
  destructive:
20
20
  "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive dark:text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
21
+ // Quiet text that opens an editor or a menu: a ghost that wraps to as many lines as its
22
+ // label needs, aligned left, never a fixed-height square.
23
+ text: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
21
24
  // WHY a variant and not a class every call site writes: "add another
22
25
 
23
26
  // row", "add a step", "attach a call" is one affordance, and it was hand-drawn eleven times across the app with four radii, three text sizes and three different hover hues.
@@ -34,7 +37,7 @@
34
37
 
35
38
  // WHY sm carries a hit area: 28px is under the 32px desktop floor and the 40px coarse height is under 44. The ::after box stays the same 32px (44 on coarse) square whatever the visual size, so a dense toolbar keeps its look.
36
39
 
37
- // Icon buttons grow both axes because they have no label to widen them. any-pointer, not pointer: see the note in tokens.css.
40
+ // Icon buttons grow both axes because they have no label to widen them. any-pointer, not pointer: see the note in tokens/base.css.
38
41
  size: {
39
42
  sm: "relative after:absolute after:top-1/2 after:left-1/2 after:h-[max(100%,2rem)] after:w-[max(100%,2rem)] after:-translate-x-1/2 after:-translate-y-1/2 any-pointer-coarse:after:h-[max(100%,2.75rem)] any-pointer-coarse:after:w-[max(100%,2.75rem)] h-7 gap-1 px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 any-pointer-coarse:h-10 any-pointer-coarse:px-3",
40
43
  default:
@@ -45,6 +48,12 @@
45
48
  icon: { true: "", false: "" },
46
49
  },
47
50
  compoundVariants: [
51
+ // After `size` so its heights lose: a text button is as tall as its lines.
52
+ {
53
+ variant: "text",
54
+ class:
55
+ "h-auto min-h-7 justify-start py-1 text-left whitespace-normal any-pointer-coarse:h-auto any-pointer-coarse:min-h-11",
56
+ },
48
57
  {
49
58
  size: "sm",
50
59
  icon: true,
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { buttonVariants } from "./button.svelte";
3
+
4
+ describe("buttonVariants text", () => {
5
+ for (const size of ["sm", "default", "lg"] as const) {
6
+ it(`wraps and grows with its lines at size ${size}`, () => {
7
+ const classes = buttonVariants({ variant: "text", size }).split(" ");
8
+ expect(classes).toContain("h-auto");
9
+ expect(classes).toContain("whitespace-normal");
10
+ expect(classes).toContain("any-pointer-coarse:min-h-11");
11
+ expect(classes).not.toContain("whitespace-nowrap");
12
+ expect(classes).not.toContain("h-7");
13
+ expect(classes).not.toContain("h-8");
14
+ });
15
+ }
16
+ it("leaves the other variants fixed-height", () => {
17
+ expect(buttonVariants({ variant: "ghost" }).split(" ")).toContain("h-8");
18
+ });
19
+ });
@@ -16,7 +16,7 @@
16
16
  bind:ref
17
17
  data-slot="checkbox"
18
18
  class={cn(
19
- "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-sm border shadow-btn transition-[background-color,border-color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 any-pointer-coarse:after:-inset-y-3.5 any-pointer-coarse:after:-inset-3 disabled:cursor-not-allowed disabled:opacity-50",
19
+ "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-sm border shadow-btn transition-[background-color,border-color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 any-pointer-coarse:after:-inset-[15px] disabled:cursor-not-allowed disabled:opacity-50",
20
20
  className,
21
21
  )}
22
22
  bind:checked
@@ -12,6 +12,9 @@
12
12
  <ContextMenuPrimitive.Separator
13
13
  bind:ref
14
14
  data-slot="context-menu-separator"
15
- class={cn("bg-border -mx-1 my-1 h-px", className)}
15
+ class={cn(
16
+ "bg-border -mx-1 my-1 h-px first:hidden last:hidden [[data-slot=context-menu-separator]+&]:hidden",
17
+ className,
18
+ )}
16
19
  {...restProps}
17
20
  />