@heyramzi/ui 2.11.2 → 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.
- package/dist/charts/index.js +1 -0
- package/dist/charts/smoothing.js +93 -0
- package/dist/components/chip/index.js +1 -0
- package/dist/components/work-parts/index.js +6 -0
- package/dist/components/work-parts/looks.js +53 -0
- package/package.json +1 -1
- package/src/charts/area-chart.svelte +40 -6
- package/src/charts/index.ts +8 -0
- package/src/charts/number-chart.css +4 -1
- package/src/charts/smoothing.ts +114 -0
- package/src/charts/sparkline.svelte +19 -3
- package/src/components/chip/index.ts +1 -0
- package/src/components/chip/tag-chip.svelte +63 -0
- package/src/components/table-view/table-view.svelte +3 -3
- package/src/components/table-view/table-view.test.ts +7 -4
- package/src/components/work-parts/count-chip.svelte +20 -0
- package/src/components/work-parts/group-head.svelte +48 -0
- package/src/components/work-parts/index.ts +6 -0
- package/src/components/work-parts/looks.ts +55 -0
- package/src/components/work-parts/property-row.svelte +44 -0
- package/src/components/work-parts/work-card.svelte +34 -0
- package/src/components/work-parts/work-well.svelte +36 -0
- package/src/tokens/craft.css +6 -0
package/dist/charts/index.js
CHANGED
|
@@ -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.
|
|
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
|
|
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) => ({
|
|
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={
|
|
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
|
/>
|
package/src/charts/index.ts
CHANGED
|
@@ -53,7 +53,11 @@
|
|
|
53
53
|
align-items: center;
|
|
54
54
|
flex: none;
|
|
55
55
|
}
|
|
56
|
+
/* One label line tall, so a chip on one tile never drops its value below the next tile's. */
|
|
56
57
|
.hr-number-trailing {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
height: 1lh;
|
|
57
61
|
margin-left: auto;
|
|
58
62
|
flex: none;
|
|
59
63
|
font-size: 0.625rem;
|
|
@@ -74,7 +78,6 @@
|
|
|
74
78
|
font-family: var(--font-heading, inherit);
|
|
75
79
|
font-weight: 600;
|
|
76
80
|
font-variant-numeric: tabular-nums;
|
|
77
|
-
font-feature-settings: "tnum";
|
|
78
81
|
line-height: 1.15;
|
|
79
82
|
letter-spacing: -0.015em;
|
|
80
83
|
color: var(--foreground, oklch(0.2 0.01 286));
|
|
@@ -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
|
-
|
|
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={
|
|
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>
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
rowClass?: (row: Row) => string | undefined;
|
|
37
37
|
/** Below this width the table scrolls sideways inside its frame. */
|
|
38
38
|
minWidth?: string;
|
|
39
|
-
/**
|
|
39
|
+
/** Ignored: the panel or card under a table is its frame, so it draws none of its own. */
|
|
40
40
|
bare?: boolean;
|
|
41
41
|
class?: string;
|
|
42
42
|
/** The sorted column and its direction. Bind it; columns with `sortable` set it. */
|
|
@@ -92,7 +92,6 @@
|
|
|
92
92
|
cell,
|
|
93
93
|
rowClass,
|
|
94
94
|
minWidth = "0",
|
|
95
|
-
bare = false,
|
|
96
95
|
class: className,
|
|
97
96
|
sort = $bindable(null),
|
|
98
97
|
rowHref,
|
|
@@ -318,7 +317,8 @@
|
|
|
318
317
|
{/if}
|
|
319
318
|
{/snippet}
|
|
320
319
|
|
|
321
|
-
|
|
320
|
+
<!-- WHY no frame: the surface under a table is its frame, the white panel or a section card. -->
|
|
321
|
+
<div class={cn("min-w-0", className)}>
|
|
322
322
|
<Table.Root
|
|
323
323
|
aria-label={label}
|
|
324
324
|
aria-busy={loading || undefined}
|
|
@@ -109,10 +109,13 @@ describe("TableView extensions", () => {
|
|
|
109
109
|
done();
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
it("
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
it("draws no frame of its own, bare or not", () => {
|
|
113
|
+
for (const bare of [false, true]) {
|
|
114
|
+
const { target, done } = render({ bare });
|
|
115
|
+
const frame = (target.firstElementChild as HTMLElement).className;
|
|
116
|
+
expect(frame).not.toMatch(/\b(border|ring|shadow|rounded)/);
|
|
117
|
+
done();
|
|
118
|
+
}
|
|
116
119
|
});
|
|
117
120
|
});
|
|
118
121
|
|
|
@@ -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>
|
package/src/tokens/craft.css
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
--radius-3xl: 20px; /* hero surface, full-bleed panel */
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/* ── Type, by role ───────────────────────────────────────────
|
|
15
|
+
Tailwind's own `text-xs` to `text-base` cover the rest. */
|
|
16
|
+
@theme {
|
|
17
|
+
--text-2xs: 0.6875rem; /* a count, a kbd hint, a meta line under a tile; the floor */
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
/* ── Elevation ───────────────────────────────────────────────
|
|
15
21
|
The steps carry no ring, since a bordered box would get two edges a pixel
|
|
16
22
|
apart; a borderless one adds `var(--elev-hairline)` in front. */
|