@heyramzi/ui 2.12.0 → 2.13.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.
@@ -16,3 +16,4 @@ export { default as AnimatedValue } from "../../src/charts/animated-value.svelte
16
16
  export { default as EdgeFade } from "../../src/charts/edge-fade.svelte";
17
17
  export { resolveEdgeFade, EDGE_FADE_DEFAULT } from "./edge-fade.js";
18
18
  export { ChartContainer, ChartTooltip, ChartStyle, getPayloadConfigFromPayload, useChart, setChartContext, THEMES } from "./primitives/index.js";
19
+ export { smoothSeries, smoothMode, ticksForWidth } from "./smoothing.js";
@@ -0,0 +1,93 @@
1
+ /** The tooltip's name for a drawn point, beside its date. */
2
+ export const ROLLING_LABEL = "7-day average";
3
+ export const weekOf = (start) => `Week of ${start}`;
4
+ const DAY = 864e5;
5
+ const WINDOW = 7;
6
+ // =========================
7
+ // FUNCTIONS
8
+ // =========================
9
+ /** About 30 to 60 marks across a chart: raw to a week, a 7-day average to a quarter, whole weeks past it. */
10
+ export function smoothMode(rangeDays) {
11
+ if (rangeDays <= 14) return "raw";
12
+ if (rangeDays <= 120) return "rolling";
13
+ return "weekly";
14
+ }
15
+ function timeOf(value) {
16
+ return value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
17
+ }
18
+ /** The bucket's start in the same form the series uses, so the axis and the formatter see no change. */
19
+ function sameForm(sample, time) {
20
+ if (sample instanceof Date) return new Date(time);
21
+ const iso = new Date(time).toISOString();
22
+ return String(sample).length === 10 ? iso.slice(0, 10) : iso;
23
+ }
24
+ /**
25
+ * The line a chart draws: a trailing 7-day mean, or 7-day buckets counted back from the last day.
26
+ * Totals stay the caller's raw sums; this only moves the drawn points.
27
+ */
28
+ export function smoothSeries(data, xKey, yKeys, { rangeDays, kind = "count" }) {
29
+ const mode = smoothMode(rangeDays);
30
+ if (mode === "raw" || data.length < WINDOW) return {
31
+ points: data,
32
+ mode: "raw"
33
+ };
34
+ const times = data.map((d) => timeOf(d[xKey]));
35
+ if (mode === "rolling") {
36
+ // A level has no daily noise to average, and a mean draws slopes between its steps.
37
+ if (kind === "level") return {
38
+ points: data,
39
+ mode: "raw"
40
+ };
41
+ let from = 0;
42
+ const sums = Object.fromEntries(yKeys.map((k) => [k, 0]));
43
+ const points = data.map((d, i) => {
44
+ for (const k of yKeys) sums[k] += Number(d[k] ?? 0);
45
+ while (times[i] - times[from] >= WINDOW * DAY) {
46
+ for (const k of yKeys) sums[k] -= Number(data[from][k] ?? 0);
47
+ from += 1;
48
+ }
49
+ // Divided by the days in the window, so a missing day counts as zero, not skipped.
50
+ const days = Math.min(WINDOW, Math.round((times[i] - times[0]) / DAY) + 1);
51
+ const row = { ...d };
52
+ for (const k of yKeys) row[k] = sums[k] / days;
53
+ return row;
54
+ });
55
+ return {
56
+ points,
57
+ mode
58
+ };
59
+ }
60
+ // Weeks end on the last day, so only the oldest can be partial, and a partial week is dropped:
61
+ // a 3-day sum would draw a dip that never happened.
62
+ const last = times[times.length - 1];
63
+ const buckets = new Map();
64
+ data.forEach((d, i) => {
65
+ const index = Math.floor(Math.round((last - times[i]) / DAY) / WINDOW);
66
+ buckets.set(index, [...buckets.get(index) ?? [], d]);
67
+ });
68
+ const oldest = Math.max(...buckets.keys());
69
+ const oldestStart = last - (oldest * WINDOW + WINDOW - 1) * DAY;
70
+ if (times[0] > oldestStart && buckets.size > 1) buckets.delete(oldest);
71
+ const points = [...buckets.entries()].toSorted(([a], [b]) => b - a).map(([index, rows]) => {
72
+ const row = { ...rows[rows.length - 1] };
73
+ row[xKey] = sameForm(data[0][xKey], last - (index * WINDOW + WINDOW - 1) * DAY);
74
+ for (const k of yKeys) {
75
+ row[k] = kind === "level" ? Number(rows[rows.length - 1][k] ?? 0) : rows.reduce((sum, r) => sum + Number(r[k] ?? 0), 0);
76
+ }
77
+ return row;
78
+ });
79
+ return {
80
+ points,
81
+ mode
82
+ };
83
+ }
84
+ /** Labels at least `spacing` pixels apart, the way a time axis reads at any width. */
85
+ export function ticksForWidth(max, spacing = 72) {
86
+ return (scale) => {
87
+ const [start, end] = scale.range();
88
+ const count = Math.max(2, Math.min(max, Math.floor(Math.abs(end - start) / spacing)));
89
+ const ticks = scale.ticks?.(count) ?? [];
90
+ const step = Math.ceil(ticks.length / count);
91
+ return step > 1 ? ticks.filter((_, i) => i % step === 0) : ticks;
92
+ };
93
+ }
@@ -0,0 +1 @@
1
+ export { default as TagChip } from "../../../src/components/chip/tag-chip.svelte";
@@ -0,0 +1,6 @@
1
+ export { default as CountChip } from "../../../src/components/work-parts/count-chip.svelte";
2
+ export { default as GroupHead } from "../../../src/components/work-parts/group-head.svelte";
3
+ export { default as PropertyRow } from "../../../src/components/work-parts/property-row.svelte";
4
+ export { default as WorkCard } from "../../../src/components/work-parts/work-card.svelte";
5
+ export { default as WorkWell } from "../../../src/components/work-parts/work-well.svelte";
6
+ export { docLooks, ganttLooks, workLooks } from "./looks.js";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The shapes the work organisms (kanban, task list, gantt) put on wrappers they don't render
3
+ * themselves: a held card's hole, the focus ring around a consumer's card or row. The organism
4
+ * picks the role, this file owns the radius, so a token edit moves every view at once.
5
+ */
6
+ export const workLooks = {
7
+ /** A card-sized hole: a drop slot, the held card's own place, an empty column. */
8
+ hole: "rounded-xl bg-foreground/[0.03] outline-1 outline-dashed outline-border -outline-offset-1",
9
+ /** The wrapper around a consumer's card, so its focus ring follows the card's corners. */
10
+ card: "rounded-xl",
11
+ /** A list row or a rail entry: control radius. */
12
+ row: "rounded-lg",
13
+ /** A one-line row: 32px, 44px on touch (DESIGN.md, Patterns: list rows). The snippet stretches to fill it; a two-line row grows. */
14
+ rowHeight: "grid min-h-8 any-pointer-coarse:min-h-11",
15
+ /** A row-sized hole: an empty list group. */
16
+ rowHole: "rounded-lg outline-1 outline-dashed outline-border -outline-offset-1",
17
+ /** A link wrapped round a chip: the focus ring takes the chip's corners. */
18
+ chipLink: "rounded-sm outline-none focus-visible:shadow-(--ring-focus)",
19
+ /** A quiet grey box inside a card: a command, a quote. Chip radius, no edge. */
20
+ inset: "rounded-sm bg-muted/50",
21
+ /** A surface that holds a whole list: the card step, whose shadow draws its own hairline. */
22
+ panel: "rounded-xl bg-card text-card-foreground shadow-card"
23
+ };
24
+ /**
25
+ * The gantt's marks. A bar is a control-sized shape, a mark inside a row is chip-sized, and
26
+ * the two round ones are a progress track and today's date number, which DESIGN rule 15
27
+ * allows as a count.
28
+ */
29
+ export const ganttLooks = {
30
+ bar: "rounded-lg",
31
+ mark: "rounded-sm",
32
+ /** The readout that follows a dragged bar. */
33
+ tip: "rounded-sm bg-foreground px-2 py-1 text-[11px] font-medium whitespace-nowrap text-background tabular-nums shadow-raised",
34
+ /** A title that didn't fit its bar, set beside it. */
35
+ outsideLabel: "rounded-sm bg-card/90 px-1 text-xs text-muted-foreground",
36
+ track: "rounded-full",
37
+ day: "rounded-full"
38
+ };
39
+ /**
40
+ * The doc pages' shapes. Their icon buttons and history button are controls, so they take the
41
+ * Button atom's radius; the edited chip is a chip; the page's icon tile and the demo's frame are
42
+ * card-sized.
43
+ */
44
+ export const docLooks = {
45
+ /** A 28px icon button or a full-width row button: control radius, as `Button`. */
46
+ control: "rounded-lg",
47
+ /** The "Edited" chip that opens the page's history. */
48
+ chip: "rounded-sm",
49
+ /** The icon tile above a page title. */
50
+ tile: "rounded-xl",
51
+ /** The frame a demo draws round a whole doc page. */
52
+ frame: "rounded-xl"
53
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "2.12.0",
3
+ "version": "2.13.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",
@@ -13,6 +13,13 @@
13
13
  import { DOMAIN_MOTION, TOOLTIP_GAP, TOOLTIP_TOP } from "./motion.js";
14
14
  import Reveal from "./reveal.svelte";
15
15
  import ReferenceLine, { type ChartReference } from "./reference-line.svelte";
16
+ import {
17
+ ROLLING_LABEL,
18
+ smoothSeries,
19
+ weekOf,
20
+ ticksForWidth,
21
+ type Smoothing,
22
+ } from "./smoothing.js";
16
23
 
17
24
  // =========================
18
25
  // TYPES
@@ -47,6 +54,8 @@
47
54
  reference?: ChartReference;
48
55
  /** The headline while no point is hovered, like a period total. Defaults to the last point. */
49
56
  resting?: { value: number; label: string };
57
+ /** Smooths the drawn line by the window shown; the resting figure stays the caller's total. */
58
+ smoothing?: Smoothing;
50
59
  class?: string;
51
60
  }
52
61
 
@@ -54,7 +63,7 @@
54
63
  // PROPS
55
64
  // =========================
56
65
  let {
57
- data,
66
+ data: input,
58
67
  xKey,
59
68
  yKey,
60
69
  series: seriesInput,
@@ -68,6 +77,7 @@
68
77
  xTicks = 6,
69
78
  reference,
70
79
  resting,
80
+ smoothing,
71
81
  class: className,
72
82
  }: Props = $props();
73
83
 
@@ -83,7 +93,13 @@
83
93
  queueMicrotask(() => {
84
94
  active = d;
85
95
  });
86
- return formatX(d[xKey]);
96
+ return pointLabel(d);
97
+ }
98
+
99
+ /** What a drawn point is: a day, the week it opens, or the end of a 7-day average. */
100
+ function pointLabel(d: TData): string {
101
+ const x = formatX(d[xKey]);
102
+ return mode === "weekly" ? weekOf(x) : x;
87
103
  }
88
104
 
89
105
  // =========================
@@ -101,6 +117,20 @@
101
117
  ? [{ key: yKey, label: yKey, color }]
102
118
  : [],
103
119
  );
120
+ const smoothed = $derived(
121
+ smoothing
122
+ ? smoothSeries(
123
+ input,
124
+ xKey,
125
+ series.map((s) => s.key),
126
+ smoothing,
127
+ )
128
+ : { points: input, mode: "raw" as const },
129
+ );
130
+ const data = $derived(smoothed.points);
131
+ const mode = $derived(smoothed.mode);
132
+ // The series label in the readout, unless the point is an average of the week before it.
133
+ const averageLabel = $derived(mode === "rolling" ? ROLLING_LABEL : null);
104
134
  const dataMax = $derived.by(() => {
105
135
  if (stacked) {
106
136
  return Math.max(0, ...data.map((d) => series.reduce((s, k) => s + Number(d[k.key] ?? 0), 0)));
@@ -126,7 +156,11 @@
126
156
  }
127
157
  const figureRows = $derived(
128
158
  display
129
- ? series.map((s) => ({ value: Number(display[s.key]), label: s.label, color: s.color }))
159
+ ? series.map((s) => ({
160
+ value: Number(display[s.key]),
161
+ label: averageLabel ?? s.label,
162
+ color: s.color,
163
+ }))
130
164
  : null,
131
165
  );
132
166
  </script>
@@ -148,7 +182,7 @@
148
182
  size="md"
149
183
  />
150
184
  {:else if figureRows && display}
151
- <Figure series={figureRows} eyebrow={formatX(display[xKey])} format={formatValue} size="md" />
185
+ <Figure series={figureRows} eyebrow={pointLabel(display)} format={formatValue} size="md" />
152
186
  {:else}
153
187
  <Figure value="—" eyebrow="Latest" size="md" />
154
188
  {/if}
@@ -169,7 +203,7 @@
169
203
  >
170
204
  <Svg>
171
205
  <Axis placement="left" format={(v) => valueTick(Number(v))} ticks={4} />
172
- <Axis placement="bottom" format={(v) => formatX(v)} ticks={xTicks} />
206
+ <Axis placement="bottom" format={(v) => formatX(v)} ticks={ticksForWidth(xTicks)} />
173
207
  <g class="hr-area-grid">
174
208
  <Axis placement="left" grid rule={false} ticks={4} format={() => ""} />
175
209
  </g>
@@ -210,7 +244,7 @@
210
244
  <Tooltip.List>
211
245
  {#each series as s (s.key)}
212
246
  <Tooltip.Item
213
- label={s.label}
247
+ label={averageLabel ?? s.label}
214
248
  value={formatValue(Number(d[s.key]))}
215
249
  color={s.color}
216
250
  />
@@ -29,3 +29,11 @@ export {
29
29
  setChartContext,
30
30
  THEMES,
31
31
  } from "./primitives/index.js";
32
+ export {
33
+ smoothSeries,
34
+ smoothMode,
35
+ ticksForWidth,
36
+ type Smoothing,
37
+ type SeriesKind,
38
+ type SmoothMode,
39
+ } from "./smoothing.js";
@@ -0,0 +1,114 @@
1
+ // =========================
2
+ // TYPES
3
+ // =========================
4
+ /** A count sums per bucket (visitors, clicks); a level is a reading at a moment (MRR), never summed. */
5
+ export type SeriesKind = "count" | "level";
6
+
7
+ export interface Smoothing {
8
+ /** The window the chart shows, in days. It picks how much the line is smoothed. */
9
+ rangeDays: number;
10
+ kind?: SeriesKind;
11
+ }
12
+
13
+ export type SmoothMode = "raw" | "rolling" | "weekly";
14
+
15
+ /** The tooltip's name for a drawn point, beside its date. */
16
+ export const ROLLING_LABEL = "7-day average";
17
+ export const weekOf = (start: string) => `Week of ${start}`;
18
+
19
+ const DAY = 86_400_000;
20
+ const WINDOW = 7;
21
+
22
+ // =========================
23
+ // FUNCTIONS
24
+ // =========================
25
+ /** About 30 to 60 marks across a chart: raw to a week, a 7-day average to a quarter, whole weeks past it. */
26
+ export function smoothMode(rangeDays: number): SmoothMode {
27
+ if (rangeDays <= 14) return "raw";
28
+ if (rangeDays <= 120) return "rolling";
29
+ return "weekly";
30
+ }
31
+
32
+ function timeOf(value: unknown): number {
33
+ return value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
34
+ }
35
+
36
+ /** The bucket's start in the same form the series uses, so the axis and the formatter see no change. */
37
+ function sameForm(sample: unknown, time: number): unknown {
38
+ if (sample instanceof Date) return new Date(time);
39
+ const iso = new Date(time).toISOString();
40
+ return String(sample).length === 10 ? iso.slice(0, 10) : iso;
41
+ }
42
+
43
+ /**
44
+ * The line a chart draws: a trailing 7-day mean, or 7-day buckets counted back from the last day.
45
+ * Totals stay the caller's raw sums; this only moves the drawn points.
46
+ */
47
+ export function smoothSeries<TData extends Record<string, unknown>>(
48
+ data: TData[],
49
+ xKey: keyof TData & string,
50
+ yKeys: (keyof TData & string)[],
51
+ { rangeDays, kind = "count" }: Smoothing,
52
+ ): { points: TData[]; mode: SmoothMode } {
53
+ const mode = smoothMode(rangeDays);
54
+ if (mode === "raw" || data.length < WINDOW) return { points: data, mode: "raw" };
55
+ const times = data.map((d) => timeOf(d[xKey]));
56
+
57
+ if (mode === "rolling") {
58
+ // A level has no daily noise to average, and a mean draws slopes between its steps.
59
+ if (kind === "level") return { points: data, mode: "raw" };
60
+ let from = 0;
61
+ const sums = Object.fromEntries(yKeys.map((k) => [k, 0])) as Record<string, number>;
62
+ const points = data.map((d, i) => {
63
+ for (const k of yKeys) sums[k] += Number(d[k] ?? 0);
64
+ while (times[i] - times[from] >= WINDOW * DAY) {
65
+ for (const k of yKeys) sums[k] -= Number(data[from][k] ?? 0);
66
+ from += 1;
67
+ }
68
+ // Divided by the days in the window, so a missing day counts as zero, not skipped.
69
+ const days = Math.min(WINDOW, Math.round((times[i] - times[0]) / DAY) + 1);
70
+ const row: Record<string, unknown> = { ...d };
71
+ for (const k of yKeys) row[k] = sums[k] / days;
72
+ return row as TData;
73
+ });
74
+ return { points, mode };
75
+ }
76
+
77
+ // Weeks end on the last day, so only the oldest can be partial, and a partial week is dropped:
78
+ // a 3-day sum would draw a dip that never happened.
79
+ const last = times[times.length - 1];
80
+ const buckets = new Map<number, TData[]>();
81
+ data.forEach((d, i) => {
82
+ const index = Math.floor(Math.round((last - times[i]) / DAY) / WINDOW);
83
+ buckets.set(index, [...(buckets.get(index) ?? []), d]);
84
+ });
85
+ const oldest = Math.max(...buckets.keys());
86
+ const oldestStart = last - (oldest * WINDOW + WINDOW - 1) * DAY;
87
+ if (times[0] > oldestStart && buckets.size > 1) buckets.delete(oldest);
88
+
89
+ const points = [...buckets.entries()]
90
+ .toSorted(([a], [b]) => b - a)
91
+ .map(([index, rows]) => {
92
+ const row: Record<string, unknown> = { ...rows[rows.length - 1] };
93
+ row[xKey] = sameForm(data[0][xKey], last - (index * WINDOW + WINDOW - 1) * DAY);
94
+ for (const k of yKeys) {
95
+ row[k] =
96
+ kind === "level"
97
+ ? Number(rows[rows.length - 1][k] ?? 0)
98
+ : rows.reduce((sum, r) => sum + Number(r[k] ?? 0), 0);
99
+ }
100
+ return row as TData;
101
+ });
102
+ return { points, mode };
103
+ }
104
+
105
+ /** Labels at least `spacing` pixels apart, the way a time axis reads at any width. */
106
+ export function ticksForWidth(max: number, spacing = 72) {
107
+ return (scale: { range: () => number[]; ticks?: (count: number) => unknown[] }) => {
108
+ const [start, end] = scale.range();
109
+ const count = Math.max(2, Math.min(max, Math.floor(Math.abs(end - start) / spacing)));
110
+ const ticks = scale.ticks?.(count) ?? [];
111
+ const step = Math.ceil(ticks.length / count);
112
+ return step > 1 ? ticks.filter((_, i) => i % step === 0) : ticks;
113
+ };
114
+ }
@@ -6,6 +6,7 @@
6
6
  import { Area, Chart, Highlight, LinearGradient, Points, Spline, Svg, Tooltip } from "layerchart";
7
7
  import { curveMonotoneX } from "d3-shape";
8
8
  import Reveal from "./reveal.svelte";
9
+ import { ROLLING_LABEL, smoothSeries, weekOf, type Smoothing } from "./smoothing.js";
9
10
 
10
11
  // =========================
11
12
  // TYPES
@@ -31,6 +32,10 @@
31
32
  */
32
33
  bare?: boolean;
33
34
  formatValue?: (v: number) => string;
35
+ /** The tooltip's date. */
36
+ formatX?: (v: unknown) => string;
37
+ /** Smooths the drawn line by the window shown, so a year doesn't draw as a barcode. */
38
+ smoothing?: Smoothing;
34
39
  class?: string;
35
40
  }
36
41
 
@@ -38,7 +43,7 @@
38
43
  // PROPS
39
44
  // =========================
40
45
  let {
41
- data,
46
+ data: input,
42
47
  xKey,
43
48
  yKey,
44
49
  color = "var(--chart-1)",
@@ -48,6 +53,8 @@
48
53
  showLast = false,
49
54
  bare = false,
50
55
  formatValue = (v) => v.toLocaleString(),
56
+ formatX = (v) => String(v),
57
+ smoothing,
51
58
  class: className,
52
59
  }: Props = $props();
53
60
 
@@ -63,13 +70,22 @@
63
70
  queueMicrotask(() => {
64
71
  active = d;
65
72
  });
66
- return String(d[xKey]);
73
+ const x = formatX(d[xKey]);
74
+ return mode === "weekly" ? weekOf(x) : x;
67
75
  }
68
76
 
69
77
  // =========================
70
78
  // DERIVED
71
79
  // =========================
72
80
  const uid = `hr-spark-${Math.random().toString(36).slice(2, 8)}`;
81
+ const smoothed = $derived(
82
+ smoothing
83
+ ? smoothSeries(input, xKey, [yKey], smoothing)
84
+ : { points: input, mode: "raw" as const },
85
+ );
86
+ const data = $derived(smoothed.points);
87
+ const mode = $derived(smoothed.mode);
88
+ const valueLabel = $derived(mode === "rolling" ? ROLLING_LABEL : String(yKey));
73
89
  const curve = $derived(smooth ? curveMonotoneX : undefined);
74
90
  const last = $derived(data.length > 0 ? data[data.length - 1] : null);
75
91
  const display = $derived(active ?? last);
@@ -128,7 +144,7 @@
128
144
  {#snippet children({ data: d })}
129
145
  <Tooltip.Header value={trackActive(d as TData)} />
130
146
  <Tooltip.List>
131
- <Tooltip.Item label={String(yKey)} value={formatValue(Number(d[yKey]))} />
147
+ <Tooltip.Item label={valueLabel} value={formatValue(Number(d[yKey]))} />
132
148
  </Tooltip.List>
133
149
  {/snippet}
134
150
  </Tooltip.Root>
@@ -0,0 +1 @@
1
+ export { default as TagChip } from "./tag-chip.svelte";
@@ -0,0 +1,63 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { X } from "phosphor-svelte";
5
+ import { cn } from "../../lib/utils.js";
6
+
7
+ interface Props extends Omit<HTMLAttributes<HTMLSpanElement>, "class" | "children"> {
8
+ /** A leading mark: a tag icon, a colour dot, an avatar. */
9
+ icon?: Snippet;
10
+ /** Shows a remove button after the label. */
11
+ onremove?: () => void;
12
+ /** The remove button's accessible name. */
13
+ removeLabel?: string;
14
+ /**
15
+ * False keeps the remove button out of the tab order and stops it taking focus on press,
16
+ * for a chip inside a field whose input owns the keyboard (Backspace removes).
17
+ */
18
+ removeFocusable?: boolean;
19
+ children?: Snippet;
20
+ class?: string;
21
+ }
22
+
23
+ let {
24
+ icon,
25
+ onremove,
26
+ removeLabel = "Remove",
27
+ removeFocusable = true,
28
+ children,
29
+ class: className,
30
+ ...restProps
31
+ }: Props = $props();
32
+ </script>
33
+
34
+ <!-- A tag is a label the user wrote, not a state, so it's a bordered grey rectangle at the chip
35
+ radius and never a pill (DESIGN.md rules 2 and 15). -->
36
+ <span
37
+ data-slot="tag-chip"
38
+ class={cn(
39
+ "inline-flex h-5 max-w-full shrink-0 items-center gap-1 overflow-hidden rounded-sm border border-border bg-muted/60 px-1.5 text-xs text-foreground",
40
+ "[&>svg]:size-3 [&>svg]:shrink-0 [&>svg]:text-muted-foreground",
41
+ onremove && "pr-0.5",
42
+ className,
43
+ )}
44
+ {...restProps}
45
+ >
46
+ {@render icon?.()}
47
+ <span class="min-w-0 truncate">{@render children?.()}</span>
48
+ {#if onremove}
49
+ <!-- The glyph is 12px; the after: box takes the hit area to 24px without growing the chip. -->
50
+ <button
51
+ type="button"
52
+ class="relative grid size-4 shrink-0 place-items-center rounded-sm text-muted-foreground transition-colors duration-quick ease-smooth-out after:absolute after:-inset-1 hover:bg-foreground/8 hover:text-foreground focus-visible:shadow-[var(--ring-focus)] focus-visible:outline-none motion-reduce:transition-none"
53
+ aria-label={removeLabel}
54
+ tabindex={removeFocusable ? undefined : -1}
55
+ onmousedown={(e) => {
56
+ if (!removeFocusable) e.preventDefault();
57
+ }}
58
+ onclick={onremove}
59
+ >
60
+ <X class="size-3" weight="bold" />
61
+ </button>
62
+ {/if}
63
+ </span>
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import { cn } from "../../lib/utils.js";
3
+
4
+ interface Props {
5
+ count: number;
6
+ class?: string;
7
+ }
8
+
9
+ let { count, class: className }: Props = $props();
10
+ </script>
11
+
12
+ <!-- A count beside a heading: a quiet square, never a pill, so it reads as a number and not a status. -->
13
+ <span
14
+ class={cn(
15
+ "grid h-5 min-w-5 shrink-0 place-items-center rounded-sm bg-foreground/5 px-1 text-xs font-medium text-muted-foreground tabular-nums",
16
+ className,
17
+ )}
18
+ >
19
+ {count.toLocaleString()}
20
+ </span>
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import { Plus } from "phosphor-svelte";
4
+ import { cn } from "../../lib/utils.js";
5
+ import { Button } from "../../shadcn/button/index.js";
6
+ import CountChip from "./count-chip.svelte";
7
+
8
+ interface Props {
9
+ label: string;
10
+ /** The status colour of the dot. Leave out for no dot. */
11
+ hue?: string;
12
+ count: number;
13
+ /** Shows a plus at the end that adds an item to this group. */
14
+ onAdd?: () => void;
15
+ addLabel?: string;
16
+ /** Extra controls before the plus. */
17
+ children?: Snippet;
18
+ class?: string;
19
+ }
20
+
21
+ let { label, hue, count, onAdd, addLabel = "Add", children, class: className }: Props = $props();
22
+ </script>
23
+
24
+ <!-- A group's head, the same in a kanban column and a list group: dot, name, count, plus. -->
25
+ <div class={cn("flex h-8 min-w-0 flex-1 items-center gap-2", className)}>
26
+ {#if hue}
27
+ <span class="size-2 shrink-0 rounded-full" style:background={hue} aria-hidden="true"></span>
28
+ {/if}
29
+ <span class="truncate text-sm font-medium text-foreground">{label}</span>
30
+ <CountChip {count} />
31
+ {#if children || onAdd}
32
+ <span class="ms-auto flex shrink-0 items-center gap-0.5">
33
+ {@render children?.()}
34
+ {#if onAdd}
35
+ <Button
36
+ variant="ghost"
37
+ size="sm"
38
+ icon
39
+ aria-label="{addLabel}: {label}"
40
+ class="text-muted-foreground"
41
+ onclick={onAdd}
42
+ >
43
+ <Plus weight="bold" aria-hidden="true" />
44
+ </Button>
45
+ {/if}
46
+ </span>
47
+ {/if}
48
+ </div>
@@ -0,0 +1,6 @@
1
+ export { default as CountChip } from "./count-chip.svelte";
2
+ export { default as GroupHead } from "./group-head.svelte";
3
+ export { default as PropertyRow } from "./property-row.svelte";
4
+ export { default as WorkCard } from "./work-card.svelte";
5
+ export { default as WorkWell } from "./work-well.svelte";
6
+ export { docLooks, ganttLooks, workLooks } from "./looks.js";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The shapes the work organisms (kanban, task list, gantt) put on wrappers they don't render
3
+ * themselves: a held card's hole, the focus ring around a consumer's card or row. The organism
4
+ * picks the role, this file owns the radius, so a token edit moves every view at once.
5
+ */
6
+ export const workLooks = {
7
+ /** A card-sized hole: a drop slot, the held card's own place, an empty column. */
8
+ hole: "rounded-xl bg-foreground/[0.03] outline-1 outline-dashed outline-border -outline-offset-1",
9
+ /** The wrapper around a consumer's card, so its focus ring follows the card's corners. */
10
+ card: "rounded-xl",
11
+ /** A list row or a rail entry: control radius. */
12
+ row: "rounded-lg",
13
+ /** A one-line row: 32px, 44px on touch (DESIGN.md, Patterns: list rows). The snippet stretches to fill it; a two-line row grows. */
14
+ rowHeight: "grid min-h-8 any-pointer-coarse:min-h-11",
15
+ /** A row-sized hole: an empty list group. */
16
+ rowHole: "rounded-lg outline-1 outline-dashed outline-border -outline-offset-1",
17
+ /** A link wrapped round a chip: the focus ring takes the chip's corners. */
18
+ chipLink: "rounded-sm outline-none focus-visible:shadow-(--ring-focus)",
19
+ /** A quiet grey box inside a card: a command, a quote. Chip radius, no edge. */
20
+ inset: "rounded-sm bg-muted/50",
21
+ /** A surface that holds a whole list: the card step, whose shadow draws its own hairline. */
22
+ panel: "rounded-xl bg-card text-card-foreground shadow-card",
23
+ } as const;
24
+
25
+ /**
26
+ * The gantt's marks. A bar is a control-sized shape, a mark inside a row is chip-sized, and
27
+ * the two round ones are a progress track and today's date number, which DESIGN rule 15
28
+ * allows as a count.
29
+ */
30
+ export const ganttLooks = {
31
+ bar: "rounded-lg",
32
+ mark: "rounded-sm",
33
+ /** The readout that follows a dragged bar. */
34
+ tip: "rounded-sm bg-foreground px-2 py-1 text-[11px] font-medium whitespace-nowrap text-background tabular-nums shadow-raised",
35
+ /** A title that didn't fit its bar, set beside it. */
36
+ outsideLabel: "rounded-sm bg-card/90 px-1 text-xs text-muted-foreground",
37
+ track: "rounded-full",
38
+ day: "rounded-full",
39
+ } as const;
40
+
41
+ /**
42
+ * The doc pages' shapes. Their icon buttons and history button are controls, so they take the
43
+ * Button atom's radius; the edited chip is a chip; the page's icon tile and the demo's frame are
44
+ * card-sized.
45
+ */
46
+ export const docLooks = {
47
+ /** A 28px icon button or a full-width row button: control radius, as `Button`. */
48
+ control: "rounded-lg",
49
+ /** The "Edited" chip that opens the page's history. */
50
+ chip: "rounded-sm",
51
+ /** The icon tile above a page title. */
52
+ tile: "rounded-xl",
53
+ /** The frame a demo draws round a whole doc page. */
54
+ frame: "rounded-xl",
55
+ } as const;
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import type { Component, Snippet } from "svelte";
3
+ import { cn } from "../../lib/utils.js";
4
+
5
+ interface Props {
6
+ label: string;
7
+ /** A Phosphor icon before the label. */
8
+ icon?: Component<{ class?: string; "aria-hidden"?: boolean | "true" | "false" }>;
9
+ /**
10
+ * `responsive`: the label hides in a narrow container (a chip row) and shows as a column
11
+ * from `@3xl` up. `row`: always icon, label, value.
12
+ */
13
+ layout?: "row" | "responsive";
14
+ children: Snippet;
15
+ class?: string;
16
+ }
17
+
18
+ let { label, icon: Icon, layout = "row", children, class: className }: Props = $props();
19
+ </script>
20
+
21
+ <!-- One property: icon and label on the left in muted text, the value on the right. -->
22
+ <div
23
+ role="group"
24
+ aria-label={label}
25
+ class={cn(
26
+ "min-h-8 items-center gap-2",
27
+ layout === "row"
28
+ ? "grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)]"
29
+ : "flex @3xl:grid @3xl:grid-cols-[7rem_minmax(0,1fr)]",
30
+ className,
31
+ )}
32
+ >
33
+ <span
34
+ class={cn(
35
+ "min-w-0 items-center gap-2 text-xs font-medium text-muted-foreground",
36
+ layout === "row" ? "flex" : "hidden @3xl:flex",
37
+ )}
38
+ aria-hidden="true"
39
+ >
40
+ {#if Icon}<Icon class="size-4 shrink-0" aria-hidden="true" />{/if}
41
+ <span class="truncate">{label}</span>
42
+ </span>
43
+ <div class="flex min-w-0 items-center">{@render children()}</div>
44
+ </div>
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { cn } from "../../lib/utils.js";
5
+ import { Card } from "../../shadcn/card/index.js";
6
+
7
+ interface Props extends HTMLAttributes<HTMLElement> {
8
+ /** The bottom row: dates, subtasks, time, avatars. */
9
+ meta?: Snippet;
10
+ children: Snippet;
11
+ class?: string;
12
+ }
13
+
14
+ let { meta, children, class: className, ...rest }: Props = $props();
15
+ </script>
16
+
17
+ <!-- A white card on a grey well: the title, then a quiet meta row. Depth comes from the card
18
+ step and rises one step on hover, so a held card has somewhere to go. -->
19
+ <Card
20
+ {...rest}
21
+ class={cn(
22
+ "gap-2.5 px-3 py-2.5 transition-shadow duration-quick ease-smooth-out hover:shadow-card-hover",
23
+ className,
24
+ )}
25
+ >
26
+ <div class="text-sm leading-snug text-pretty">{@render children()}</div>
27
+ {#if meta}
28
+ <div
29
+ class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs whitespace-nowrap text-muted-foreground tabular-nums"
30
+ >
31
+ {@render meta()}
32
+ </div>
33
+ {/if}
34
+ </Card>
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { cn } from "../../lib/utils.js";
5
+
6
+ interface Props extends HTMLAttributes<HTMLDivElement> {
7
+ /** Any CSS colour. Tints the well only while something is held over it. */
8
+ hue?: string;
9
+ /** Something is held over the well. */
10
+ active?: boolean;
11
+ children: Snippet;
12
+ class?: string;
13
+ }
14
+
15
+ let { hue, active = false, children, class: className, ...rest }: Props = $props();
16
+
17
+ // A well rests grey. Its hue shows only while a card is held over it.
18
+ let style = $derived(
19
+ hue && active
20
+ ? `background: color-mix(in oklch, ${hue} 8%, var(--color-muted)); outline-color: color-mix(in oklch, ${hue} 30%, transparent);`
21
+ : undefined,
22
+ );
23
+ </script>
24
+
25
+ <!-- The grey well white cards sit in. Card radius plus its padding takes the next step up:
26
+ radius-xl cards, 6px in, a radius-2xl well (DESIGN rule 14). -->
27
+ <div
28
+ {...rest}
29
+ class={cn(
30
+ "flex min-h-0 flex-col gap-1.5 overflow-x-hidden overflow-y-auto rounded-2xl bg-muted/70 p-1.5 outline-1 outline-transparent -outline-offset-1 transition-[background-color,outline-color] duration-quick ease-smooth-out dark:bg-muted/60",
31
+ className,
32
+ )}
33
+ {style}
34
+ >
35
+ {@render children()}
36
+ </div>