@heyramzi/ui 2.21.0 → 2.22.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.
@@ -0,0 +1,26 @@
1
+ import { getContext, setContext } from "svelte";
2
+ // WHY context: the shell owns the measurement and 26 pages render their tabs
3
+ // through a snippet, so a prop would be an edit on every page.
4
+ const KEY = Symbol("list-page.toolbar-density");
5
+ export function setToolbarDensity(density) {
6
+ setContext(KEY, density);
7
+ }
8
+ /** Undefined outside a list-page toolbar, where nothing ever folds. */
9
+ export function getToolbarDensity() {
10
+ return getContext(KEY);
11
+ }
12
+ /**
13
+ * The width a row needs to sit on one line with every child at its natural
14
+ * size. Read in one synchronous pass, so the browser never paints the probe.
15
+ */
16
+ // WHY `max-content` on a probe, not the row's own width: the row is sized by
17
+ // its container, so reading it back always says "it fits" (see collapse.ts).
18
+ export function naturalRowWidth(row) {
19
+ const { width, flexWrap } = row.style;
20
+ row.style.width = "max-content";
21
+ row.style.flexWrap = "nowrap";
22
+ const needed = row.getBoundingClientRect().width;
23
+ row.style.width = width;
24
+ row.style.flexWrap = flexWrap;
25
+ return needed;
26
+ }
@@ -0,0 +1,9 @@
1
+ import { tv } from "tailwind-variants";
2
+ export const alertVariants = tv({
3
+ base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
4
+ variants: { variant: {
5
+ default: "bg-card text-card-foreground",
6
+ destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current"
7
+ } },
8
+ defaultVariants: { variant: "default" }
9
+ });
@@ -1,7 +1,7 @@
1
1
  import Root from "../../../src/shadcn/alert/alert.svelte";
2
2
  import Description from "../../../src/shadcn/alert/alert-description.svelte";
3
3
  import Title from "../../../src/shadcn/alert/alert-title.svelte";
4
- export { alertVariants } from "../../../src/shadcn/alert/alert.svelte";
4
+ export { alertVariants } from "./alert-variants.js";
5
5
  export { Root, Description, Title,
6
6
  //
7
7
  Root as Alert, Description as AlertDescription, Title as AlertTitle };
@@ -0,0 +1,17 @@
1
+ import { tv } from "tailwind-variants";
2
+ export const badgeVariants = tv({
3
+ // The chip radius, not a pill: DESIGN.md keeps `rounded-full` for status and counts,
4
+ // which `StatusBadge` owns. Tabular figures so a count that ticks doesn't shift.
5
+ base: "h-5 gap-1 rounded-sm border border-transparent px-2 py-0.5 text-xs font-medium tabular-nums has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit max-w-full shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-[background-color,border-color,color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] [&>svg]:pointer-events-none",
6
+ variants: {
7
+ // Three weights a label can carry: filled, tinted, outlined. Severity is the
8
+ // `tone` prop, which reads `TONE_CLASS`, not a variant.
9
+ variant: {
10
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
11
+ secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
12
+ outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
13
+ // For a consumer that wants one "this is wrong" badge without a tone prop.
14
+ destructive: "bg-destructive/10 text-destructive-ink [a]:hover:bg-destructive/20"
15
+ } },
16
+ defaultVariants: { variant: "default" }
17
+ });
@@ -1,2 +1,2 @@
1
1
  export { default as Badge } from "../../../src/shadcn/badge/badge.svelte";
2
- export { badgeVariants } from "../../../src/shadcn/badge/badge.svelte";
2
+ export { badgeVariants } from "./badge-variants.js";
@@ -1,5 +1,5 @@
1
1
  import Root from "../../../src/shadcn/toggle/toggle.svelte";
2
- export { toggleVariants } from "../../../src/shadcn/toggle/toggle.svelte";
2
+ export { toggleVariants } from "./toggle-variants.js";
3
3
  export { Root,
4
4
  //
5
5
  Root as Toggle };
@@ -0,0 +1,20 @@
1
+ import { tv } from "tailwind-variants";
2
+ export const toggleVariants = tv({
3
+ base: "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)] hover:bg-muted hover:text-muted-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[background-color,color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
4
+ variants: {
5
+ variant: {
6
+ default: "bg-transparent",
7
+ outline: "border-input hover:bg-accent hover:text-accent-foreground border bg-transparent shadow-btn"
8
+ },
9
+ size: {
10
+ // Same steps as Button: sm 28, default 32, lg 36. 40 went with the scale.
11
+ default: "h-8 min-w-8 px-2",
12
+ sm: "h-7 min-w-7 px-1.5",
13
+ lg: "h-9 min-w-9 px-2.5"
14
+ }
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ size: "default"
19
+ }
20
+ });
@@ -2,4 +2,5 @@ export { default as StatusBadge } from "../../src/status/status-badge.svelte";
2
2
  export { default as StatusDot } from "../../src/status/status-dot.svelte";
3
3
  export { default as StatusIcon } from "../../src/status/status-icon.svelte";
4
4
  export { defineStatuses } from "./define-statuses.js";
5
- export { default as StatusPill, PILL_TINT } from "../../src/status/status-pill.svelte";
5
+ export { default as StatusPill } from "../../src/status/status-pill.svelte";
6
+ export { PILL_TINT } from "./pill-tint.js";
@@ -0,0 +1,13 @@
1
+ // A quiet tint and no border: the icon and the text carry the tone, the fill only groups
2
+ // them. Neutral reads off the foreground, not `bg-muted`, because muted sits 0.005 L from
3
+ // the canvas and vanishes there (tokens board, "what fights the new scale").
4
+ // It lives in a .ts module, not status-pill.svelte's module script, so a plain tsc consumer
5
+ // can import it: tsc's `*.svelte` shim only knows a default export.
6
+ export const PILL_TINT = {
7
+ neutral: "bg-foreground/6 text-muted-foreground",
8
+ accent: "bg-[color-mix(in_oklab,var(--tone-accent,var(--color-primary))_12%,transparent)] text-[color:var(--tone-accent,var(--color-primary))]",
9
+ info: "bg-info/12 text-info-800 dark:text-info-400",
10
+ success: "bg-success/12 text-success-800 dark:text-success-400",
11
+ warning: "bg-warning/14 text-warning-800 dark:text-warning-400",
12
+ destructive: "bg-destructive/10 text-destructive-ink"
13
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
5
5
  "homepage": "https://ui.heyramzi.com",
6
6
  "license": "MIT",
@@ -387,29 +387,29 @@
387
387
  "dependencies": {
388
388
  "@dagrejs/dagre": "^3.1.3",
389
389
  "@shadcn-svelte/primitives": "^0.0.1",
390
- "@tiptap/core": "^3.32.0",
391
- "@tiptap/extension-drag-handle": "^3.32.0",
392
- "@tiptap/extension-image": "^3.32.0",
393
- "@tiptap/extension-list": "^3.32.0",
394
- "@tiptap/extension-placeholder": "^3.32.0",
395
- "@tiptap/extension-table": "^3.32.0",
396
- "@tiptap/pm": "^3.32.0",
397
- "@tiptap/starter-kit": "^3.32.0",
398
- "@zag-js/svelte": "^1.46.0",
399
- "@zag-js/tour": "^1.46.0",
390
+ "@tiptap/core": "^3.32.2",
391
+ "@tiptap/extension-drag-handle": "^3.32.2",
392
+ "@tiptap/extension-image": "^3.32.2",
393
+ "@tiptap/extension-list": "^3.32.2",
394
+ "@tiptap/extension-placeholder": "^3.32.2",
395
+ "@tiptap/extension-table": "^3.32.2",
396
+ "@tiptap/pm": "^3.32.2",
397
+ "@tiptap/starter-kit": "^3.32.2",
398
+ "@zag-js/svelte": "^1.47.0",
399
+ "@zag-js/tour": "^1.47.0",
400
400
  "clsx": "^2.1.1",
401
401
  "cuelume": "^0.2.8",
402
- "d3-scale": "^4.0.2",
403
- "d3-shape": "^3.2.0",
404
- "layerchart": "^2.9.1",
402
+ "d3-scale": "^4.0.3",
403
+ "d3-shape": "^3.2.1",
404
+ "layerchart": "^2.9.2",
405
405
  "markdown-it": "^15.0.3",
406
- "marked": "^18.1.2",
406
+ "marked": "^18.1.3",
407
407
  "perfect-freehand": "^1.2.3",
408
408
  "phosphor-svelte": "^3.1.1",
409
409
  "roughjs": "^4.6.6",
410
- "shiki": "^4.5.0",
410
+ "shiki": "^4.6.0",
411
411
  "tailwind-merge": "^3.7.0",
412
- "tailwind-variants": "^3.3.1",
412
+ "tailwind-variants": "^4.0.1",
413
413
  "zod": "^4.6.7"
414
414
  },
415
415
  "devDependencies": {
@@ -417,16 +417,16 @@
417
417
  "@sveltejs/vite-plugin-svelte": "^7.3.1",
418
418
  "@types/d3-scale": "^4.0.9",
419
419
  "@types/d3-shape": "^3.2.0",
420
- "@types/node": "^26.6.5",
421
- "@xyflow/svelte": "^1.7.0",
422
- "bits-ui": "^2.20.2",
420
+ "@types/node": "^26.6.6",
421
+ "@xyflow/svelte": "^1.7.1",
422
+ "bits-ui": "^2.20.3",
423
423
  "jsdom": "^30.1.3",
424
424
  "mode-watcher": "^1.1.0",
425
425
  "svelte": "^5.57.3",
426
- "svelte-check": "^4.7.7",
427
- "svelte-sonner": "^1.2.1",
426
+ "svelte-check": "^4.7.8",
427
+ "svelte-sonner": "^1.2.3",
428
428
  "typescript": "~7.0.2",
429
- "vite-plus": "1.4.0"
429
+ "vite-plus": "1.5.0"
430
430
  },
431
431
  "peerDependencies": {
432
432
  "@internationalized/date": "^3.12.4",
@@ -8,6 +8,7 @@
8
8
  import { pageGutterClass } from "../../lib/layout.js";
9
9
  import { getSectionHost } from "./section-host.js";
10
10
  import SectionRuns from "../segmented/section-runs.svelte";
11
+ import { naturalRowWidth, setToolbarDensity } from "./toolbar-density.js";
11
12
  import type { PhosphorIcon, IconWeight } from "../../lib/icon-types.js";
12
13
 
13
14
  // =========================
@@ -67,6 +68,16 @@
67
68
  // =========================
68
69
  // Null off an app shell, where the toolbar just has no section strip to draw.
69
70
  const section = getSectionHost();
71
+ let row = $state<HTMLElement>();
72
+ let tabsBox = $state<HTMLElement>();
73
+ // True when the row at full size would wrap. The tabs then draw as one
74
+ // dropdown, the first control with a smaller self that still does its job.
75
+ let compact = $state(false);
76
+ setToolbarDensity({
77
+ get compact() {
78
+ return compact;
79
+ },
80
+ });
70
81
 
71
82
  // =========================
72
83
  // DERIVED
@@ -90,6 +101,37 @@
90
101
  if (!hasToolbar) return;
91
102
  return section?.claim();
92
103
  });
104
+ // One row, never two (Ramzi, 9 Nov 2026): /tasks at 1500px put its status
105
+ // tabs and actions on a second line under search and filters.
106
+ // WHY the tabs' full width is remembered: once folded, the row only holds the
107
+ // dropdown, and a probe of it would say the full strip fits when it doesn't.
108
+ $effect(() => {
109
+ if (!row) return;
110
+ const el = row;
111
+ let fullTabs = 0;
112
+ let frame = 0;
113
+ const measure = () => {
114
+ frame = 0;
115
+ const needed = naturalRowWidth(el);
116
+ const tabs = tabsBox?.getBoundingClientRect().width ?? 0;
117
+ if (!compact) fullTabs = tabs;
118
+ const full = compact ? needed - tabs + fullTabs : needed;
119
+ compact = full > el.clientWidth + 0.5;
120
+ };
121
+ // A frame's delay folds a burst of resizes into one probe and keeps the
122
+ // observer from looping on its own state change.
123
+ const schedule = () => (frame ||= requestAnimationFrame(measure));
124
+ const observer = new ResizeObserver(schedule);
125
+ observer.observe(el);
126
+ for (const child of el.children) observer.observe(child);
127
+ // Scheduled, not called: a direct call would make this effect track
128
+ // `compact` and rerun on every fold, dropping the remembered width.
129
+ schedule();
130
+ return () => {
131
+ observer.disconnect();
132
+ cancelAnimationFrame(frame);
133
+ };
134
+ });
93
135
  // The shared gutter from `lib/layout.ts`. A typed one put the search field
94
136
  // 8px left of the section tabs above it.
95
137
  let outerClass = $derived(
@@ -127,7 +169,16 @@
127
169
  <div
128
170
  class="@container/toolbar sticky top-0 z-20 -mx-4 -mt-4 bg-card/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-card/70"
129
171
  >
130
- <div class={cn("flex flex-wrap items-center gap-2", toolbarClass)}>
172
+ <!-- Folded and still short of room, the row stops wrapping and the
173
+ filters strip scrolls, since it's the one part that already can. -->
174
+ <div
175
+ bind:this={row}
176
+ class={cn(
177
+ "flex flex-wrap items-center gap-2",
178
+ compact && "@3xl/toolbar:flex-nowrap",
179
+ toolbarClass,
180
+ )}
181
+ >
131
182
  <!-- The section tabs lead: which page comes before which slice of it.
132
183
  One row replaces two rows that held four controls. -->
133
184
  {#if section?.strip}
@@ -148,10 +199,14 @@
148
199
  class={cn(
149
200
  "order-2 flex min-w-0 flex-wrap items-center gap-2 @3xl/toolbar:order-3",
150
201
  hasLeading ? "@3xl/toolbar:ms-auto" : "flex-1",
202
+ compact && "@3xl/toolbar:shrink-0",
151
203
  )}
152
204
  >
153
205
  {#if tabs}
154
- <div class={cn("flex min-w-0 items-center", !hasLeading && "me-auto")}>
206
+ <div
207
+ bind:this={tabsBox}
208
+ class={cn("flex min-w-0 items-center", !hasLeading && "me-auto")}
209
+ >
155
210
  {@render tabs()}
156
211
  </div>
157
212
  {/if}
@@ -0,0 +1,38 @@
1
+ import { getContext, setContext } from "svelte";
2
+
3
+ /**
4
+ * Whether the list-page toolbar has run out of room. `ListPageShell` measures
5
+ * its row and sets this; a control inside it reads it and draws a smaller self.
6
+ */
7
+ export interface ToolbarDensity {
8
+ readonly compact: boolean;
9
+ }
10
+
11
+ // WHY context: the shell owns the measurement and 26 pages render their tabs
12
+ // through a snippet, so a prop would be an edit on every page.
13
+ const KEY = Symbol("list-page.toolbar-density");
14
+
15
+ export function setToolbarDensity(density: ToolbarDensity): void {
16
+ setContext(KEY, density);
17
+ }
18
+
19
+ /** Undefined outside a list-page toolbar, where nothing ever folds. */
20
+ export function getToolbarDensity(): ToolbarDensity | undefined {
21
+ return getContext<ToolbarDensity | undefined>(KEY);
22
+ }
23
+
24
+ /**
25
+ * The width a row needs to sit on one line with every child at its natural
26
+ * size. Read in one synchronous pass, so the browser never paints the probe.
27
+ */
28
+ // WHY `max-content` on a probe, not the row's own width: the row is sized by
29
+ // its container, so reading it back always says "it fits" (see collapse.ts).
30
+ export function naturalRowWidth(row: HTMLElement): number {
31
+ const { width, flexWrap } = row.style;
32
+ row.style.width = "max-content";
33
+ row.style.flexWrap = "nowrap";
34
+ const needed = row.getBoundingClientRect().width;
35
+ row.style.width = width;
36
+ row.style.flexWrap = flexWrap;
37
+ return needed;
38
+ }
@@ -15,6 +15,10 @@
15
15
  import { segmentedTriggerClass } from "./segmented.js";
16
16
  import { Icon } from "../icon/index.js";
17
17
  import { Mark } from "../mark/index.js";
18
+ import { CaretDown } from "phosphor-svelte";
19
+ import { Picker } from "../picker/index.js";
20
+ import { toolbarTriggerClass } from "../toolbar/toolbar-trigger.js";
21
+ import { getToolbarDensity } from "../list-page/toolbar-density.js";
18
22
 
19
23
  // =========================
20
24
  // TYPES
@@ -87,6 +91,20 @@
87
91
  ),
88
92
  );
89
93
 
94
+ // Inside a list-page toolbar that ran out of room, the strip folds into one
95
+ // dropdown that names the current option. See `ListPageShell`.
96
+ const density = getToolbarDensity();
97
+ let folded = $derived(density?.compact === true);
98
+ let current = $derived(merged.find((option) => option.value === value) ?? merged[0]);
99
+ let pickerOptions = $derived(
100
+ merged.map((option) => ({
101
+ value: option.value,
102
+ label: option.label,
103
+ hint: option.count != null ? String(option.count) : undefined,
104
+ icon: option.icon,
105
+ })),
106
+ );
107
+
90
108
  function pick(next: T) {
91
109
  value = next;
92
110
  onValueChange?.(next);
@@ -98,53 +116,71 @@
98
116
  <!-- =========================== -->
99
117
  <!-- WHY scroll, not wrap: it's one object. Past `max` the tail collapses into
100
118
  a menu first, so scrolling is the last resort. -->
101
- <div
102
- class={cn("no-scrollbar flex max-w-full min-w-0 overflow-x-auto", className)}
103
- role="group"
104
- aria-label={ariaLabel}
105
- >
106
- <SegmentedTrack fill={block}>
107
- {#each split.visible as option (option.value)}
108
- {@const active = value === option.value}
109
- {@const markOnly = iconOnly && Boolean(option.image || option.icon)}
110
- <button
111
- type="button"
112
- class={segmentedTriggerClass({ fill: block, active })}
113
- onclick={() => pick(option.value)}
114
- aria-pressed={active}
115
- aria-label={markOnly ? option.label : undefined}
116
- title={markOnly ? option.label : undefined}
117
- >
118
- <!-- WHY the mark is drawn here, not by `Segment`: `Segment` always
119
+ {#if folded && current}
120
+ <Picker
121
+ options={pickerOptions}
122
+ {value}
123
+ onSelect={pick}
124
+ triggerClass={toolbarTriggerClass({ className: "group/segment text-foreground" })}
125
+ triggerLabel={`${ariaLabel}: ${current.label}`}
126
+ align="end"
127
+ >
128
+ {#snippet trigger()}
129
+ <Segment label={current.label} count={current.count} icon={current.icon} active />
130
+ <CaretDown
131
+ class="size-3 shrink-0 opacity-60 transition-transform duration-150 group-data-[state=open]/segment:rotate-180"
132
+ />
133
+ {/snippet}
134
+ </Picker>
135
+ {:else}
136
+ <div
137
+ class={cn("no-scrollbar flex max-w-full min-w-0 overflow-x-auto", className)}
138
+ role="group"
139
+ aria-label={ariaLabel}
140
+ >
141
+ <SegmentedTrack fill={block}>
142
+ {#each split.visible as option (option.value)}
143
+ {@const active = value === option.value}
144
+ {@const markOnly = iconOnly && Boolean(option.image || option.icon)}
145
+ <button
146
+ type="button"
147
+ class={segmentedTriggerClass({ fill: block, active })}
148
+ onclick={() => pick(option.value)}
149
+ aria-pressed={active}
150
+ aria-label={markOnly ? option.label : undefined}
151
+ title={markOnly ? option.label : undefined}
152
+ >
153
+ <!-- WHY the mark is drawn here, not by `Segment`: `Segment` always
119
154
  writes the label, and a count or dot beside a bare mark reads as noise. -->
120
- {#if markOnly && option.image}
121
- <Mark
122
- src={option.image}
123
- label={option.label}
124
- class={cn(
125
- "opacity-75 transition-opacity duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] group-hover/segment:opacity-100",
126
- active && "opacity-100",
127
- )}
128
- />
129
- {:else if markOnly && option.icon}
130
- <Icon icon={option.icon} size={16} class="shrink-0" />
131
- {:else}
132
- <Segment
133
- label={option.label}
134
- count={option.count}
135
- icon={option.icon}
136
- image={option.image}
137
- done={option.done}
138
- badge={option.badge}
139
- {active}
140
- />
155
+ {#if markOnly && option.image}
156
+ <Mark
157
+ src={option.image}
158
+ label={option.label}
159
+ class={cn(
160
+ "opacity-75 transition-opacity duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] group-hover/segment:opacity-100",
161
+ active && "opacity-100",
162
+ )}
163
+ />
164
+ {:else if markOnly && option.icon}
165
+ <Icon icon={option.icon} size={16} class="shrink-0" />
166
+ {:else}
167
+ <Segment
168
+ label={option.label}
169
+ count={option.count}
170
+ icon={option.icon}
171
+ image={option.image}
172
+ done={option.done}
173
+ badge={option.badge}
174
+ {active}
175
+ />
176
+ {/if}
177
+ </button>
178
+ {/each}
179
+ {#snippet after()}
180
+ {#if split.hidden.length > 0}
181
+ <SegmentedOverflow items={split.hidden} onSelect={pick} label={moreLabel} fill={block} />
141
182
  {/if}
142
- </button>
143
- {/each}
144
- {#snippet after()}
145
- {#if split.hidden.length > 0}
146
- <SegmentedOverflow items={split.hidden} onSelect={pick} label={moreLabel} fill={block} />
147
- {/if}
148
- {/snippet}
149
- </SegmentedTrack>
150
- </div>
183
+ {/snippet}
184
+ </SegmentedTrack>
185
+ </div>
186
+ {/if}
@@ -51,7 +51,11 @@
51
51
 
52
52
  <!-- A record's side rail with tabs: the fields in one, a thread or a log in the next,
53
53
  so the thread sits beside the record and not a screen below it. One tab draws no strip. -->
54
- <Tabs.Root value={open} onValueChange={(next) => (value = next)} class={cn("min-w-0 gap-0", className)}>
54
+ <Tabs.Root
55
+ value={open}
56
+ onValueChange={(next) => (value = next)}
57
+ class={cn("min-w-0 gap-0", className)}
58
+ >
55
59
  {#if tabs.length > 1}
56
60
  <div class={cn("pb-3", listClass)}>
57
61
  <Tabs.List size="sm" class="w-full" aria-label={label}>
@@ -0,0 +1,17 @@
1
+ import { type VariantProps, tv } from "tailwind-variants";
2
+
3
+ export const alertVariants = tv({
4
+ base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
5
+ variants: {
6
+ variant: {
7
+ default: "bg-card text-card-foreground",
8
+ destructive:
9
+ "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
10
+ },
11
+ },
12
+ defaultVariants: {
13
+ variant: "default",
14
+ },
15
+ });
16
+
17
+ export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
@@ -1,21 +1,7 @@
1
1
  <script lang="ts" module>
2
- import { type VariantProps, tv } from "tailwind-variants";
2
+ import { alertVariants, type AlertVariant } from "./alert-variants.js";
3
3
 
4
- export const alertVariants = tv({
5
- base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
6
- variants: {
7
- variant: {
8
- default: "bg-card text-card-foreground",
9
- destructive:
10
- "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
11
- },
12
- },
13
- defaultVariants: {
14
- variant: "default",
15
- },
16
- });
17
-
18
- export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
4
+ export { alertVariants, type AlertVariant };
19
5
  </script>
20
6
 
21
7
  <script lang="ts">
@@ -1,7 +1,7 @@
1
1
  import Root from "./alert.svelte";
2
2
  import Description from "./alert-description.svelte";
3
3
  import Title from "./alert-title.svelte";
4
- export { alertVariants, type AlertVariant } from "./alert.svelte";
4
+ export { alertVariants, type AlertVariant } from "./alert-variants.js";
5
5
 
6
6
  export {
7
7
  Root,
@@ -0,0 +1,23 @@
1
+ import { type VariantProps, tv } from "tailwind-variants";
2
+
3
+ export const badgeVariants = tv({
4
+ // The chip radius, not a pill: DESIGN.md keeps `rounded-full` for status and counts,
5
+ // which `StatusBadge` owns. Tabular figures so a count that ticks doesn't shift.
6
+ base: "h-5 gap-1 rounded-sm border border-transparent px-2 py-0.5 text-xs font-medium tabular-nums has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit max-w-full shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-[background-color,border-color,color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] [&>svg]:pointer-events-none",
7
+ variants: {
8
+ // Three weights a label can carry: filled, tinted, outlined. Severity is the
9
+ // `tone` prop, which reads `TONE_CLASS`, not a variant.
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
12
+ secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
13
+ outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
14
+ // For a consumer that wants one "this is wrong" badge without a tone prop.
15
+ destructive: "bg-destructive/10 text-destructive-ink [a]:hover:bg-destructive/20",
16
+ },
17
+ },
18
+ defaultVariants: {
19
+ variant: "default",
20
+ },
21
+ });
22
+
23
+ export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
@@ -1,27 +1,7 @@
1
1
  <script lang="ts" module>
2
- import { type VariantProps, tv } from "tailwind-variants";
2
+ import { badgeVariants, type BadgeVariant } from "./badge-variants.js";
3
3
 
4
- export const badgeVariants = tv({
5
- // The chip radius, not a pill: DESIGN.md keeps `rounded-full` for status and counts,
6
- // which `StatusBadge` owns. Tabular figures so a count that ticks doesn't shift.
7
- base: "h-5 gap-1 rounded-sm border border-transparent px-2 py-0.5 text-xs font-medium tabular-nums has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit max-w-full shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-[background-color,border-color,color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] [&>svg]:pointer-events-none",
8
- variants: {
9
- // Three weights a label can carry: filled, tinted, outlined. Severity is the
10
- // `tone` prop, which reads `TONE_CLASS`, not a variant.
11
- variant: {
12
- default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
13
- secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
14
- outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
15
- // For a consumer that wants one "this is wrong" badge without a tone prop.
16
- destructive: "bg-destructive/10 text-destructive-ink [a]:hover:bg-destructive/20",
17
- },
18
- },
19
- defaultVariants: {
20
- variant: "default",
21
- },
22
- });
23
-
24
- export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
4
+ export { badgeVariants, type BadgeVariant };
25
5
  </script>
26
6
 
27
7
  <script lang="ts">
@@ -1,2 +1,2 @@
1
1
  export { default as Badge } from "./badge.svelte";
2
- export { badgeVariants, type BadgeVariant } from "./badge.svelte";
2
+ export { badgeVariants, type BadgeVariant } from "./badge-variants.js";
@@ -4,7 +4,7 @@ export {
4
4
  type ToggleSize,
5
5
  type ToggleVariant,
6
6
  type ToggleVariants,
7
- } from "./toggle.svelte";
7
+ } from "./toggle-variants.js";
8
8
 
9
9
  export {
10
10
  Root,
@@ -0,0 +1,26 @@
1
+ import { type VariantProps, tv } from "tailwind-variants";
2
+
3
+ export const toggleVariants = tv({
4
+ base: "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)] hover:bg-muted hover:text-muted-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[background-color,color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
5
+ variants: {
6
+ variant: {
7
+ default: "bg-transparent",
8
+ outline:
9
+ "border-input hover:bg-accent hover:text-accent-foreground border bg-transparent shadow-btn",
10
+ },
11
+ size: {
12
+ // Same steps as Button: sm 28, default 32, lg 36. 40 went with the scale.
13
+ default: "h-8 min-w-8 px-2",
14
+ sm: "h-7 min-w-7 px-1.5",
15
+ lg: "h-9 min-w-9 px-2.5",
16
+ },
17
+ },
18
+ defaultVariants: {
19
+ variant: "default",
20
+ size: "default",
21
+ },
22
+ });
23
+
24
+ export type ToggleVariant = VariantProps<typeof toggleVariants>["variant"];
25
+ export type ToggleSize = VariantProps<typeof toggleVariants>["size"];
26
+ export type ToggleVariants = VariantProps<typeof toggleVariants>;
@@ -1,30 +1,12 @@
1
1
  <script lang="ts" module>
2
- import { type VariantProps, tv } from "tailwind-variants";
2
+ import {
3
+ toggleVariants,
4
+ type ToggleSize,
5
+ type ToggleVariant,
6
+ type ToggleVariants,
7
+ } from "./toggle-variants.js";
3
8
 
4
- export const toggleVariants = tv({
5
- base: "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)] hover:bg-muted hover:text-muted-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground focus-visible:shadow-(--ring-focus) aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[background-color,color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-smooth-out)] outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
6
- variants: {
7
- variant: {
8
- default: "bg-transparent",
9
- outline:
10
- "border-input hover:bg-accent hover:text-accent-foreground border bg-transparent shadow-btn",
11
- },
12
- size: {
13
- // Same steps as Button: sm 28, default 32, lg 36. 40 went with the scale.
14
- default: "h-8 min-w-8 px-2",
15
- sm: "h-7 min-w-7 px-1.5",
16
- lg: "h-9 min-w-9 px-2.5",
17
- },
18
- },
19
- defaultVariants: {
20
- variant: "default",
21
- size: "default",
22
- },
23
- });
24
-
25
- export type ToggleVariant = VariantProps<typeof toggleVariants>["variant"];
26
- export type ToggleSize = VariantProps<typeof toggleVariants>["size"];
27
- export type ToggleVariants = VariantProps<typeof toggleVariants>;
9
+ export { toggleVariants, type ToggleSize, type ToggleVariant, type ToggleVariants };
28
10
  </script>
29
11
 
30
12
  <script lang="ts">
@@ -9,4 +9,5 @@ export type {
9
9
  StatusMap,
10
10
  Statuses,
11
11
  } from "./status-types.js";
12
- export { default as StatusPill, PILL_TINT } from "./status-pill.svelte";
12
+ export { default as StatusPill } from "./status-pill.svelte";
13
+ export { PILL_TINT } from "./pill-tint.js";
@@ -0,0 +1,16 @@
1
+ import type { Tone } from "../lib/tone.js";
2
+
3
+ // A quiet tint and no border: the icon and the text carry the tone, the fill only groups
4
+ // them. Neutral reads off the foreground, not `bg-muted`, because muted sits 0.005 L from
5
+ // the canvas and vanishes there (tokens board, "what fights the new scale").
6
+ // It lives in a .ts module, not status-pill.svelte's module script, so a plain tsc consumer
7
+ // can import it: tsc's `*.svelte` shim only knows a default export.
8
+ export const PILL_TINT: Record<Tone, string> = {
9
+ neutral: "bg-foreground/6 text-muted-foreground",
10
+ accent:
11
+ "bg-[color-mix(in_oklab,var(--tone-accent,var(--color-primary))_12%,transparent)] text-[color:var(--tone-accent,var(--color-primary))]",
12
+ info: "bg-info/12 text-info-800 dark:text-info-400",
13
+ success: "bg-success/12 text-success-800 dark:text-success-400",
14
+ warning: "bg-warning/14 text-warning-800 dark:text-warning-400",
15
+ destructive: "bg-destructive/10 text-destructive-ink",
16
+ };
@@ -1,18 +1,8 @@
1
1
  <script lang="ts" module>
2
2
  import type { Tone } from "../lib/tone.js";
3
+ import { PILL_TINT } from "./pill-tint.js";
3
4
 
4
- // A quiet tint and no border: the icon and the text carry the tone, the fill only groups
5
- // them. Neutral reads off the foreground, not `bg-muted`, because muted sits 0.005 L from
6
- // the canvas and vanishes there (tokens board, "what fights the new scale").
7
- export const PILL_TINT: Record<Tone, string> = {
8
- neutral: "bg-foreground/6 text-muted-foreground",
9
- accent:
10
- "bg-[color-mix(in_oklab,var(--tone-accent,var(--color-primary))_12%,transparent)] text-[color:var(--tone-accent,var(--color-primary))]",
11
- info: "bg-info/12 text-info-800 dark:text-info-400",
12
- success: "bg-success/12 text-success-800 dark:text-success-400",
13
- warning: "bg-warning/14 text-warning-800 dark:text-warning-400",
14
- destructive: "bg-destructive/10 text-destructive-ink",
15
- };
5
+ export { PILL_TINT };
16
6
  </script>
17
7
 
18
8
  <script lang="ts">
@@ -0,0 +1,43 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { describe, expect, it } from "vite-plus/test";
4
+
5
+ // A plain tsc consumer (upsys app-cli) reads this package's src through a `*.svelte` shim that
6
+ // only knows a default export. A .ts file that re-exports a named value from a .svelte module
7
+ // fails there, as PILL_TINT did in 2.21.0. Named values live in a .ts module the component imports.
8
+ const root = import.meta.dirname;
9
+
10
+ function tsFiles(dir: string): string[] {
11
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
12
+ const path = join(dir, entry.name);
13
+ if (entry.isDirectory()) return tsFiles(path);
14
+ return entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") ? [path] : [];
15
+ });
16
+ }
17
+
18
+ const reexport =
19
+ /export\s+(type\s+)?(\*|\{[^}]*\})\s*(?:as\s+\w+\s*)?from\s*["']([^"']+\.svelte)["']/g;
20
+
21
+ describe("re-exports from .svelte modules", () => {
22
+ it("carry only the default export or types", () => {
23
+ const offenders: string[] = [];
24
+ for (const file of tsFiles(root)) {
25
+ for (const [, typeOnly, list, from] of readFileSync(file, "utf8").matchAll(reexport)) {
26
+ if (typeOnly) continue;
27
+ const names =
28
+ list === "*"
29
+ ? ["*"]
30
+ : list
31
+ .slice(1, -1)
32
+ .split(",")
33
+ .map((n) => n.trim());
34
+ const values = names.filter(
35
+ (n) => n && n !== "default" && !/^(type\s|default\s+as\s)/.test(n),
36
+ );
37
+ if (values.length)
38
+ offenders.push(`${relative(root, file)}: ${values.join(", ")} from ${from}`);
39
+ }
40
+ }
41
+ expect(offenders).toEqual([]);
42
+ });
43
+ });