@siyahi/ui 0.1.9-next.29 → 0.1.9-next.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/DESIGN.md +79 -10
  2. package/agents/examples/avatar.tsx +15 -5
  3. package/agents/examples/chart-line-forecast.tsx +36 -0
  4. package/agents/examples/chart-line-quiet.tsx +114 -0
  5. package/agents/examples/chart-panels.tsx +94 -0
  6. package/agents/examples/code-block.tsx +5 -3
  7. package/agents/examples/field.tsx +108 -0
  8. package/agents/examples/formatters.tsx +14 -14
  9. package/agents/examples/input-states.tsx +12 -22
  10. package/agents/examples/overview-dashboard.tsx +6 -6
  11. package/agents/examples/stale-blocks.tsx +56 -0
  12. package/agents/examples/table.tsx +6 -6
  13. package/agents/manifest.json +104 -3
  14. package/dist/components/registry/charts/area-chart-card.d.ts +7 -3
  15. package/dist/components/registry/charts/area-chart-card.js +52 -25
  16. package/dist/components/registry/charts/bar-chart-card.d.ts +1 -0
  17. package/dist/components/registry/charts/bar-chart-card.js +8 -24
  18. package/dist/components/registry/charts/line-chart-card.d.ts +2 -2
  19. package/dist/components/registry/charts/line-chart-card.js +42 -7
  20. package/dist/components/registry/dashboard-01/section-cards.d.ts +2 -0
  21. package/dist/components/registry/dashboard-01/section-cards.js +3 -0
  22. package/dist/components/registry/heatmap/calendar-heatmap.js +2 -2
  23. package/dist/components/registry/heatmap/status-heatmap.js +2 -2
  24. package/dist/components/registry/heatmap/weekday-heatmap.js +2 -2
  25. package/dist/components/registry/niko-table/filters/table-faceted-filter.js +1 -1
  26. package/dist/components/siyahi/histogram-chart.js +8 -8
  27. package/dist/components/siyahi/scatter-chart.js +1 -1
  28. package/dist/components/siyahi/slope-chart.js +1 -1
  29. package/dist/components/siyahi/stale-note.d.ts +9 -0
  30. package/dist/components/siyahi/stale-note.js +12 -0
  31. package/dist/components/siyahi/treemap.js +1 -1
  32. package/dist/components/siyahi/waterfall-chart.js +4 -4
  33. package/dist/components/ui/field.d.ts +26 -0
  34. package/dist/components/ui/field.js +109 -0
  35. package/dist/components/ui/label.d.ts +5 -0
  36. package/dist/components/ui/label.js +13 -0
  37. package/dist/index.d.ts +5 -2
  38. package/dist/index.js +5 -2
  39. package/dist/lib/chart-frame.d.ts +28 -4
  40. package/dist/lib/chart-frame.js +256 -153
  41. package/dist/lib/chart-marks.d.ts +83 -0
  42. package/dist/lib/chart-marks.js +216 -0
  43. package/dist/lib/chart-series.d.ts +15 -0
  44. package/package.json +1 -1
  45. package/report.css +6 -4
  46. package/styles/siyahi.css +260 -18
@@ -0,0 +1,216 @@
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { usePlotArea, useXAxisScale, useYAxisScale } from "recharts";
3
+ //#region src/lib/chart-marks.tsx
4
+ /** The colour a series takes when it is one of many drawn quietly behind the few a page names in colour. */
5
+ const QUIET = "var(--muted-foreground)";
6
+ /** Quiet series first, so the named ones draw over them; otherwise the page's order. */
7
+ function drawOrder(series) {
8
+ return [...series.filter(({ color }) => color === QUIET), ...series.filter(({ color }) => color !== QUIET)];
9
+ }
10
+ const NAME_START = 24;
11
+ const NAME_FONT = "500 11px \"Geist Variable\", \"Geist Fallback\", ui-sans-serif, sans-serif";
12
+ const NAME_WIDTH_GUESS = 7.5;
13
+ const NAME_GAP = 14;
14
+ let measure;
15
+ /** A name's width as the browser draws it, measured on a canvas; a wide guess where there is none. */
16
+ function nameWidth(name) {
17
+ if (measure === void 0) measure = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d");
18
+ if (!measure) return name.length * NAME_WIDTH_GUESS;
19
+ measure.font = NAME_FONT;
20
+ return measure.measureText(name).width;
21
+ }
22
+ /** Room the names need right of the plot: the widest name as drawn, after its leader and colour square. */
23
+ function endLabelRoom(names) {
24
+ return Math.ceil(NAME_START + Math.max(0, ...names.map(nameWidth)) + 8);
25
+ }
26
+ /**
27
+ * Whether every line ends on the plot's right edge: each series has a value in the last row, there is more than one
28
+ * row, and a numeric x axis runs no further than the data. A line that stops short would need a long leader across
29
+ * the others to reach its name, so the chart keeps its legend then.
30
+ */
31
+ function linesEndAtEdge(data, xKey, series, xDomain) {
32
+ const last = data.at(-1);
33
+ if (!last || data.length < 2) return false;
34
+ if (xDomain && Number(last[xKey]) < xDomain[1]) return false;
35
+ return series.every(({ key }) => Number.isFinite(last[key]));
36
+ }
37
+ const END_LABELS_MIN_WIDTH = 400;
38
+ /**
39
+ * Whether the names fit at the line ends: a card at least 400px wide, names taking at most 30% of it, and a plot
40
+ * tall enough to hold them all 14px apart. Otherwise the chart keeps its legend below.
41
+ */
42
+ function endLabelsFit(names, width, plotHeight) {
43
+ return width >= END_LABELS_MIN_WIDTH && endLabelRoom(names) <= width * .3 && (names.length - 1) * NAME_GAP + 12 <= plotHeight;
44
+ }
45
+ /**
46
+ * Where each name goes: every line's name at its end's level, spread to 14px apart, top to bottom, pushed back up
47
+ * from the plot's foot and never above its top. A point that maps to no number (a scale being replaced) is left out.
48
+ */
49
+ function placeEnds(lines, plot) {
50
+ const gutter = plot.x + plot.width;
51
+ const ends = [];
52
+ for (const end of lines) if (Number.isFinite(end.x) && Number.isFinite(end.y)) ends.push({
53
+ ...end,
54
+ at: end.y,
55
+ gutter,
56
+ leader: false
57
+ });
58
+ ends.sort((a, b) => a.y - b.y);
59
+ let previous = -Infinity;
60
+ for (const end of ends) {
61
+ end.at = Math.max(end.y, previous + NAME_GAP);
62
+ previous = end.at;
63
+ }
64
+ let limit = plot.y + plot.height;
65
+ for (const end of ends.toReversed()) {
66
+ end.at = Math.min(end.at, limit);
67
+ limit = end.at - NAME_GAP;
68
+ }
69
+ for (const end of ends) {
70
+ end.at = Math.max(end.at, plot.y + 4);
71
+ end.leader = Math.abs(end.at - end.y) > 3 || end.x < gutter - 1;
72
+ }
73
+ return ends;
74
+ }
75
+ /**
76
+ * Each series' name in the gutter right of the plot, in the text colour beside a square of the line's colour, in
77
+ * place of a legend the reader has to match colours against. A leader joins each name to its line's end where the
78
+ * name was moved off the end's level or the line ends short of the plot's edge. A quiet (grey) series' name is
79
+ * muted, with no square: its place beside its line's end says which line it is.
80
+ */
81
+ function EndLabels({ data, xKey, series }) {
82
+ const xScale = useXAxisScale();
83
+ const yScale = useYAxisScale();
84
+ const plot = usePlotArea();
85
+ if (!xScale || !yScale || !plot) return null;
86
+ const lines = [];
87
+ for (const { key, label, color } of series) {
88
+ const row = data.findLast((candidate) => Number.isFinite(candidate[key]));
89
+ const x = row && xScale(row[xKey]);
90
+ const y = row && yScale(row[key]);
91
+ if (x !== void 0 && y !== void 0) lines.push({
92
+ id: String(key),
93
+ name: label,
94
+ color,
95
+ x,
96
+ y,
97
+ quiet: color === QUIET
98
+ });
99
+ }
100
+ return /* @__PURE__ */ jsx("g", {
101
+ "data-slot": "end-labels",
102
+ children: placeEnds(lines, plot).map(({ gutter, ...end }) => /* @__PURE__ */ jsxs("g", { children: [
103
+ end.leader && /* @__PURE__ */ jsx("polyline", {
104
+ points: `${end.x + 4},${end.y} ${Math.max(end.x + 4, gutter) + 6},${end.at}`,
105
+ fill: "none",
106
+ stroke: end.color,
107
+ strokeOpacity: .55
108
+ }),
109
+ !end.quiet && /* @__PURE__ */ jsx("rect", {
110
+ x: gutter + 13,
111
+ y: end.at - 3.5,
112
+ width: 7,
113
+ height: 7,
114
+ fill: end.color
115
+ }),
116
+ /* @__PURE__ */ jsx("text", {
117
+ x: gutter + (end.quiet ? 13 : NAME_START),
118
+ y: end.at + 4,
119
+ className: end.quiet ? "fill-muted-foreground text-2xs" : "fill-foreground text-2xs font-medium",
120
+ children: end.name
121
+ })
122
+ ] }, end.id))
123
+ });
124
+ }
125
+ const MARK_LABEL = "stroke-card font-mono text-2xs uppercase";
126
+ const HALO = {
127
+ paintOrder: "stroke",
128
+ strokeWidth: 3,
129
+ strokeLinejoin: "round"
130
+ };
131
+ /**
132
+ * A forecast stretch, from forecastFrom to the last point: a faint hatched band labelled FORECAST behind the lines,
133
+ * and the clip paths that keep each line solid before it and dashed after. And a "today" rule: 1px in the link
134
+ * colour with a small mono capital label.
135
+ */
136
+ function TimeMarks({ data, xKey, forecastFrom, today, clipId, hatch }) {
137
+ const xScale = useXAxisScale();
138
+ const plot = usePlotArea();
139
+ if (!xScale || !plot) return null;
140
+ const top = plot.y;
141
+ const bottom = plot.y + plot.height;
142
+ const cut = forecastFrom === void 0 ? void 0 : xScale(forecastFrom);
143
+ const last = data.at(-1);
144
+ const end = (last && xScale(last[xKey])) ?? plot.x + plot.width;
145
+ const now = today === void 0 ? void 0 : xScale(today);
146
+ return /* @__PURE__ */ jsxs("g", {
147
+ "data-slot": "time-marks",
148
+ children: [cut !== void 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
149
+ /* @__PURE__ */ jsxs("defs", { children: [
150
+ /* @__PURE__ */ jsx("clipPath", {
151
+ id: `${clipId}-actual`,
152
+ children: /* @__PURE__ */ jsx("rect", {
153
+ x: -1e4,
154
+ y: -1e4,
155
+ width: cut + 1e4,
156
+ height: 2e4
157
+ })
158
+ }),
159
+ /* @__PURE__ */ jsx("clipPath", {
160
+ id: `${clipId}-forecast`,
161
+ children: /* @__PURE__ */ jsx("rect", {
162
+ x: cut,
163
+ y: -1e4,
164
+ width: 2e4,
165
+ height: 2e4
166
+ })
167
+ }),
168
+ /* @__PURE__ */ jsx("pattern", {
169
+ id: `${clipId}-hatch`,
170
+ width: 7,
171
+ height: 7,
172
+ patternUnits: "userSpaceOnUse",
173
+ patternTransform: "rotate(45)",
174
+ children: /* @__PURE__ */ jsx("line", {
175
+ x1: 0,
176
+ y1: 0,
177
+ x2: 0,
178
+ y2: 7,
179
+ stroke: "var(--foreground)",
180
+ strokeOpacity: .16
181
+ })
182
+ })
183
+ ] }),
184
+ hatch && /* @__PURE__ */ jsx("rect", {
185
+ x: cut,
186
+ y: top,
187
+ width: Math.max(0, end - cut),
188
+ height: plot.height,
189
+ fill: `url(#${clipId}-hatch)`
190
+ }),
191
+ /* @__PURE__ */ jsx("text", {
192
+ x: end - 4,
193
+ y: top + 12,
194
+ textAnchor: "end",
195
+ className: `${MARK_LABEL} fill-muted-foreground`,
196
+ style: HALO,
197
+ children: "Forecast"
198
+ })
199
+ ] }), now !== void 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("line", {
200
+ x1: now,
201
+ x2: now,
202
+ y1: top,
203
+ y2: bottom,
204
+ stroke: "var(--link)"
205
+ }), /* @__PURE__ */ jsx("text", {
206
+ x: now - 5,
207
+ y: top + 12,
208
+ textAnchor: "end",
209
+ className: `${MARK_LABEL} fill-link`,
210
+ style: HALO,
211
+ children: "Today"
212
+ })] })]
213
+ });
214
+ }
215
+ //#endregion
216
+ export { EndLabels, QUIET, TimeMarks, drawOrder, endLabelRoom, endLabelsFit, linesEndAtEdge, placeEnds };
@@ -84,6 +84,21 @@ export interface SeriesChartProps<T extends object, K extends keyof T> {
84
84
  placeholder?: ReactNode;
85
85
  /** Lines under the chart, such as a caption or a takeaway, in the card's footer. */
86
86
  footer?: ReactNode;
87
+ /**
88
+ * Marks the data as old, with since when, such as "3 Nov": the chart greys out and a small "Stale · 3 Nov" note
89
+ * sits under the title. The page decides; nothing is inferred from dates.
90
+ */
91
+ stale?: ReactNode;
92
+ }
93
+ /** What a chart of lines takes, LineChartCard and AreaChartCard: a forecast stretch and today. */
94
+ export interface LineMarksProps<T extends object, K extends keyof T> {
95
+ /**
96
+ * The x value from which the data is a forecast, such as the last month-end: each line goes dashed from it, a faint
97
+ * hatched band labelled FORECAST covers the stretch, and the end dots are hollow.
98
+ */
99
+ forecastFrom?: NoInfer<T[K]>;
100
+ /** The x value of today: a 1px rule in the link colour with a small TODAY label. */
101
+ today?: NoInfer<T[K]>;
87
102
  }
88
103
  /** A numeric x axis, for samples in time or any measure where a mark can sit between two rows. */
89
104
  export interface NumericXProps {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siyahi/ui",
3
- "version": "0.1.9-next.29",
3
+ "version": "0.1.9-next.30",
4
4
  "description": "Stock shadcn/ui on Base UI and Tailwind v4, a finance theme, a few compositions and formatters.",
5
5
  "keywords": [
6
6
  "base-ui",
package/report.css CHANGED
@@ -23,7 +23,7 @@
23
23
  --destructive: oklch(0.49 0.19 27.325);
24
24
  --border: oklch(0.145 0 0 / 18%);
25
25
  --input: oklch(0.64 0 0);
26
- --ring: oklch(0.62 0.16 275);
26
+ --ring: oklch(0.145 0 0);
27
27
  --positive: oklch(0.527 0.145 150.069);
28
28
  --positive-foreground: oklch(0.985 0 0);
29
29
  --negative: oklch(0.577 0.235 27.325);
@@ -37,6 +37,7 @@
37
37
  --heat-5: 60%;
38
38
  --wash: 7%;
39
39
  --wash-edge: 45%;
40
+ --field-wash: 8%;
40
41
  --link: oklch(0.51 0.2 275);
41
42
  --chart-1: oklch(0.4 0.17 275);
42
43
  --chart-2: oklch(0.66 0.13 70);
@@ -50,7 +51,7 @@
50
51
  --sidebar-accent: oklch(0.97 0 0);
51
52
  --sidebar-accent-foreground: oklch(0.45 0.2 275);
52
53
  --sidebar-border: oklch(0.145 0 0 / 18%);
53
- --sidebar-ring: oklch(0.62 0.16 275);
54
+ --sidebar-ring: oklch(0.145 0 0);
54
55
  }
55
56
 
56
57
  @media (prefers-color-scheme: dark) {
@@ -72,7 +73,7 @@
72
73
  --destructive: oklch(0.8 0.1 22.216);
73
74
  --border: oklch(1 0 0 / 20%);
74
75
  --input: oklch(0.62 0 0);
75
- --ring: oklch(0.6 0.14 275);
76
+ --ring: oklch(0.922 0 0);
76
77
  --positive: oklch(0.69 0.16 151.711);
77
78
  --positive-foreground: oklch(0.145 0 0);
78
79
  --negative: oklch(0.704 0.187 22.216);
@@ -85,6 +86,7 @@
85
86
  --heat-4: 51.25%;
86
87
  --wash: 12%;
87
88
  --wash-edge: 55%;
89
+ --field-wash: 12%;
88
90
  --link: oklch(0.75 0.128 275);
89
91
  --chart-1: oklch(0.58 0.2 275);
90
92
  --chart-2: oklch(0.88 0.08 75);
@@ -98,7 +100,7 @@
98
100
  --sidebar-accent: oklch(0.269 0 0);
99
101
  --sidebar-accent-foreground: oklch(0.8 0.1 275);
100
102
  --sidebar-border: oklch(1 0 0 / 20%);
101
- --sidebar-ring: oklch(0.6 0.14 275);
103
+ --sidebar-ring: oklch(0.922 0 0);
102
104
  }
103
105
  }
104
106
 
package/styles/siyahi.css CHANGED
@@ -1,9 +1,9 @@
1
1
  /*
2
2
  * Siyahi theme: the only file in the package that holds colour values.
3
3
  * shadcn/ui `neutral` greys for both schemes, with ink indigo (hue 275)
4
- * placed, not spread: links, focus rings, the selected sidebar item's text
5
- * and the first chart series. Buttons, badges and toggles stay monochrome,
6
- * where black and white read strongest. Plus three meaning
4
+ * placed, not spread: links, the selected sidebar item's text and the first
5
+ * chart series. Buttons, badges, toggles and focus stay monochrome, where
6
+ * black and white read strongest. Plus three meaning
7
7
  * colours and a five-hue chart palette. `packages/tools/contrast.mjs` checks every value
8
8
  * here in the build; when a check fails, change the value, never the bar.
9
9
  */
@@ -113,7 +113,8 @@
113
113
  --border: oklch(0.145 0 0 / 18%);
114
114
  /* The edge of a field (input, textarea, select, input group) at 3:1: --border stays faint for dividers. */
115
115
  --input: oklch(0.64 0 0);
116
- --ring: oklch(0.62 0.16 275);
116
+ /* Focus is the text colour, as black reads strongest: a field's edge thickens in it, any other control takes a halo. */
117
+ --ring: oklch(0.145 0 0);
117
118
  /* Tailwind green-700, red-600, amber-700, with their chroma lowered to the sRGB edge. */
118
119
  --positive: oklch(0.527 0.145 150.069);
119
120
  --positive-foreground: oklch(0.985 0 0);
@@ -137,6 +138,8 @@
137
138
  */
138
139
  --wash: 7%;
139
140
  --wash-edge: 45%;
141
+ /* An invalid field's wash: this share of --destructive over the card. */
142
+ --field-wash: 8%;
140
143
  --link: oklch(0.51 0.2 275);
141
144
  /*
142
145
  * Indigo, amber, plum, sky, teal: no green or red, so no series reads as a
@@ -160,7 +163,7 @@
160
163
  --sidebar-accent: oklch(0.97 0 0);
161
164
  --sidebar-accent-foreground: oklch(0.45 0.2 275);
162
165
  --sidebar-border: oklch(0.145 0 0 / 18%);
163
- --sidebar-ring: oklch(0.62 0.16 275);
166
+ --sidebar-ring: oklch(0.145 0 0);
164
167
 
165
168
  @variant dark {
166
169
  /*
@@ -191,7 +194,7 @@
191
194
  --destructive: oklch(0.8 0.1 22.216);
192
195
  --border: oklch(1 0 0 / 20%);
193
196
  --input: oklch(0.62 0 0);
194
- --ring: oklch(0.6 0.14 275);
197
+ --ring: oklch(0.922 0 0);
195
198
  /*
196
199
  * Green toned down from Tailwind green-400, below red-400's lightness so a
197
200
  * treemap's deepest green tile keeps its light label at 4.5:1; amber-400.
@@ -216,6 +219,7 @@
216
219
  */
217
220
  --wash: 12%;
218
221
  --wash-edge: 55%;
222
+ --field-wash: 12%;
219
223
  --link: oklch(0.75 0.128 275);
220
224
  --chart-1: oklch(0.58 0.2 275);
221
225
  --chart-2: oklch(0.88 0.08 75);
@@ -229,7 +233,7 @@
229
233
  --sidebar-accent: oklch(0.269 0 0);
230
234
  --sidebar-accent-foreground: oklch(0.8 0.1 275);
231
235
  --sidebar-border: oklch(1 0 0 / 20%);
232
- --sidebar-ring: oklch(0.6 0.14 275);
236
+ --sidebar-ring: oklch(0.922 0 0);
233
237
  }
234
238
  }
235
239
 
@@ -295,6 +299,59 @@
295
299
  }
296
300
  }
297
301
 
302
+ /*
303
+ * A component that lifts a part of itself with a z-index is a stacking context
304
+ * of its own: SectionCards' picker button, an avatar's badge, a calendar's
305
+ * days, a focused toggle, a code block's buttons, a price chart's layers. The
306
+ * lifted part then ranks only against the rest of its component, and can never
307
+ * sit over, or take a tap from, a page's sticky header or section strip (also
308
+ * z-10, where the later element in the page won the tie). In a group, the
309
+ * group is the context, so a badge still sits over the next, overlapping
310
+ * avatar. The data table's scroll container isolates itself.
311
+ */
312
+ :is(
313
+ [data-slot="card"],
314
+ [data-slot="avatar-group"],
315
+ [data-slot="avatar"]:not([data-slot="avatar-group"] > *),
316
+ [data-slot="calendar"],
317
+ [data-slot="toggle-group"],
318
+ [data-slot="code-block"],
319
+ [data-slot="price-chart"]
320
+ ) {
321
+ isolation: isolate;
322
+ }
323
+
324
+ /*
325
+ * A copy button sits in a row above the code, never over it. Core floats a
326
+ * CodeBlockActions written straight in the block over the code's top right
327
+ * (z-10, shown on hover), where it covers the end of a line and painted over
328
+ * the page's sticky section strip; here it takes a row of its own, as in a
329
+ * CodeBlockHeader, always shown, with the header's plain button.
330
+ */
331
+ [data-slot="code-block"] > [data-slot="code-block-actions"] {
332
+ position: static;
333
+ align-self: flex-end;
334
+ margin: 0.5rem 0.5rem 0;
335
+ opacity: 1;
336
+ }
337
+ /*
338
+ * Core's floating fill and faint resting ring go. Its fill outranks the ghost
339
+ * button's own hover fill, so that is set again here, as a header's button
340
+ * shows it; the focus ring is left alone.
341
+ */
342
+ [data-slot="code-block"] > [data-slot="code-block-actions"] > button {
343
+ background-color: transparent;
344
+ &:hover {
345
+ background-color: var(--muted);
346
+ @variant dark {
347
+ background-color: color-mix(in oklab, var(--muted) 50%, transparent);
348
+ }
349
+ }
350
+ &:not(:focus-visible) {
351
+ --tw-ring-shadow: 0 0 #0000;
352
+ }
353
+ }
354
+
298
355
  /*
299
356
  * --input is the edge of a field, where the reader types or picks. A button,
300
357
  * a toggle and a code block are framed by their label or their content, so
@@ -321,17 +378,70 @@
321
378
  }
322
379
 
323
380
  /*
324
- * Focus is opaque wherever the ring is the only sign of it: stock draws the
325
- * ring at half strength (under 2:1 on the canvas) and relies on an opaque
326
- * border these controls do not have. The slider's thumb is a div around a
327
- * hidden range input, so it shows the input's focus.
381
+ * Focus is drawn inside the control, never outside it, so no neighbour and no
382
+ * box that clips can cover it: a 2px ring in the text colour (--ring) on the
383
+ * control's edge, and a 2px ring of the page colour just inside that, so the
384
+ * pair reads on any fill, black, white or hovered, and the outer ring meets
385
+ * the page around the control at the text's contrast. A field shows focus as
386
+ * its own edge, 2px in the text colour; an input group and the command search
387
+ * show it round the whole group. A chart mark is drawn in SVG, where these
388
+ * rings do not paint: it takes a dashed stroke in the text colour, apart from a
389
+ * picked mark's solid one. Menu items and options show focus as their
390
+ * highlight, and popups as themselves, so they take none.
328
391
  */
329
- :is([data-slot="toggle"], [data-slot="toggle-group-item"], [data-slot="scroll-area-viewport"]):focus-visible,
330
- [class~="group/button"][data-day]:focus-visible {
331
- --tw-ring-color: var(--ring);
332
- }
392
+ :focus-visible:not(
393
+ svg,
394
+ svg *,
395
+ [role="menuitem"],
396
+ [role="menuitemcheckbox"],
397
+ [role="menuitemradio"],
398
+ [role="option"],
399
+ [role="dialog"],
400
+ [role="alertdialog"],
401
+ [role="menu"],
402
+ [role="listbox"],
403
+ [data-slot="input"],
404
+ [data-slot="textarea"],
405
+ [data-slot="select-trigger"],
406
+ [data-slot="input-group-control"],
407
+ [data-slot="command-input"]
408
+ ),
333
409
  [data-slot="slider-thumb"]:has(input:focus-visible) {
334
- box-shadow: 0 0 0 3px var(--ring);
410
+ outline: none;
411
+ box-shadow:
412
+ inset 0 0 0 2px var(--ring),
413
+ inset 0 0 0 4px var(--background);
414
+ }
415
+ :is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"]):focus-visible,
416
+ [data-slot="input-group"]:has(:is([data-slot="input-group-control"], [data-slot="command-input"]):focus-visible) {
417
+ outline: none;
418
+ border-color: var(--ring);
419
+ box-shadow: inset 0 0 0 1px var(--ring);
420
+ }
421
+ :is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"])[aria-invalid="true"]:focus-visible,
422
+ [data-slot="input-group"]:has([data-slot="input-group-control"][aria-invalid="true"]:focus-visible) {
423
+ border-color: var(--destructive);
424
+ box-shadow: inset 0 0 0 1px var(--destructive);
425
+ }
426
+ [class~="group/button"][class~="text-destructive"]:focus-visible {
427
+ box-shadow:
428
+ inset 0 0 0 2px var(--destructive),
429
+ inset 0 0 0 4px var(--background);
430
+ }
431
+ /* A code block shows its scrolling content's focus itself; its own frame keeps no halo. */
432
+ [data-slot="code-block"]:has(> [data-slot="code-block-content"]:focus-visible) {
433
+ --tw-ring-shadow: 0 0 #0000;
434
+ }
435
+ svg :is(rect, circle, path)[tabindex]:focus-visible,
436
+ [data-slot="treemap-tile"]:focus-visible > rect:first-child {
437
+ outline: none;
438
+ stroke: var(--ring);
439
+ stroke-width: 2px;
440
+ stroke-dasharray: 3 2;
441
+ }
442
+ /* A treemap tile clips at its own edge, so half its stroke shows: twice the width keeps 2px. */
443
+ [data-slot="treemap-tile"]:focus-visible > rect:first-child {
444
+ stroke-width: 4px;
335
445
  }
336
446
 
337
447
  /*
@@ -349,6 +459,22 @@
349
459
  [data-slot="input-group"]:has([data-slot][aria-invalid="true"]) {
350
460
  border-color: var(--destructive);
351
461
  }
462
+ /* An invalid field also takes a faint wash of the red, so it reads as the one to fix before its message is read. */
463
+ :is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"])[aria-invalid="true"],
464
+ [data-slot="input-group"]:has([data-slot][aria-invalid="true"]) {
465
+ background-color: color-mix(in oklab, var(--destructive) var(--field-wash), var(--card));
466
+ }
467
+
468
+ /*
469
+ * A field's label sits above it in small monospaced capitals, muted, as the
470
+ * labels over figures do: FieldLabel in a Field (directly or in its
471
+ * FieldContent), the home a page gets it from. A label for a checkbox, a
472
+ * switch or a radio reads as a sentence and keeps the sans face, whatever the
473
+ * Field's orientation.
474
+ */
475
+ [data-slot="field"]:not(:has([role="checkbox"], [role="switch"], [role="radio"])) [data-slot="field-label"] {
476
+ @apply font-mono text-2xs tracking-wider text-muted-foreground uppercase;
477
+ }
352
478
 
353
479
  /* Progress is a bar, square as every bar is; stock draws it as a pill. */
354
480
  [data-slot="progress-track"] {
@@ -391,6 +517,36 @@
391
517
  text-transform: inherit;
392
518
  }
393
519
  }
520
+ /*
521
+ * Tables are dense, as a ledger is: compact rows, a rule in the text colour
522
+ * under the column heads, the first column (what each row is) in medium
523
+ * weight, and figures in the mono face, a size down so a column of them takes
524
+ * no more room than the sans did. A figure is a cell marked data-numeric (the
525
+ * data table's number columns, or a plain TableCell the page marks), which
526
+ * also right-aligns it, and any Delta in a cell. Alignment alone says nothing:
527
+ * a right-aligned word stays in the sans. Row hairlines stay; no stripes, no
528
+ * column rules.
529
+ */
530
+ [data-slot="table-head"] {
531
+ height: 1.75rem;
532
+ }
533
+ [data-slot="table-cell"] {
534
+ padding-block: 0.2rem;
535
+ }
536
+ [data-slot="table-header"] [data-slot="table-row"] {
537
+ border-bottom-color: var(--foreground);
538
+ }
539
+ /* A data table's sticky header draws its rule as a line of its own, which stays when the rows scroll under it. */
540
+ [data-slot="table-header"]::after {
541
+ background-color: var(--foreground);
542
+ }
543
+ [data-slot="table-body"] [data-slot="table-cell"]:first-child {
544
+ font-weight: 500;
545
+ }
546
+ :is([data-slot="table-cell"][data-numeric], [data-slot="table-cell"] [data-slot="delta"]) {
547
+ @apply font-mono;
548
+ font-size: 0.8125rem;
549
+ }
394
550
  [data-slot="table-footer"] {
395
551
  background: none;
396
552
  border-top: 1px solid var(--foreground);
@@ -461,13 +617,99 @@
461
617
  }
462
618
 
463
619
  /*
464
- * Chart gridlines take the full border colour: stock shadcn draws them at half
465
- * of it, which all but vanishes on a dark card (5% white).
620
+ * A series in a forecast is drawn twice (LineChartCard, AreaChartCard): solid
621
+ * up to forecastFrom and dashed after it, each copy clipped to its side by the
622
+ * clip paths lib/chart-marks draws. The chart sets the two clip paths' urls.
623
+ */
624
+ [data-slot="chart"] .chart-actual {
625
+ clip-path: var(--chart-actual);
626
+ }
627
+ [data-slot="chart"] .chart-forecast {
628
+ clip-path: var(--chart-forecast);
629
+ }
630
+
631
+ /*
632
+ * Pen-stroke motion: a chart's lines draw in left to right over 1.3s, and a
633
+ * stack's fills are revealed with them (Recharts' own animation, which reduced
634
+ * motion turns off). Then the names at the line ends fade in; a line's end
635
+ * dots appear once it has drawn and fade in then; bars, with no line to wait
636
+ * for, fade in at once rather than grow. The names are Siyahi's own element,
637
+ * kept across data updates, so they fade in once. Reduced motion shows them
638
+ * all at once.
639
+ */
640
+ @keyframes chart-fade-in {
641
+ from {
642
+ opacity: 0;
643
+ }
644
+ }
645
+ [data-slot="chart"] :is(.recharts-bar-rectangles, .recharts-line-dots, .recharts-area-dots) {
646
+ animation: chart-fade-in 0.6s ease-out backwards;
647
+ }
648
+ [data-slot="chart"] [data-slot="end-labels"] {
649
+ animation: chart-fade-in 0.6s ease-out 1.3s backwards;
650
+ }
651
+ @media (prefers-reduced-motion: reduce) {
652
+ [data-slot="chart"]
653
+ :is(.recharts-bar-rectangles, .recharts-line-dots, .recharts-area-dots, [data-slot="end-labels"]) {
654
+ animation: none;
655
+ }
656
+ }
657
+
658
+ /*
659
+ * A block the page marks stale (stale on a chart card or a SectionCards tile)
660
+ * loses its colour: its chart, sparkline and badge turn grey, and a tile's
661
+ * figure and badge turn muted. The chart and sparkline also draw at 65%, so a
662
+ * dark slot's line does not read heavier than a fresh one: a stale block is
663
+ * meant to be harder to read, so its marks are exempt from the 3:1 mark bar.
664
+ * The small "Stale · 3 Nov" note under the title and the figure keep their
665
+ * text contrast.
666
+ */
667
+ [data-slot="card"][data-stale] :is([data-slot="chart"], [data-slot="sparkline"], [data-slot="badge"]) {
668
+ filter: grayscale(1);
669
+ }
670
+ [data-slot="card"][data-stale] :is([data-slot="chart"], [data-slot="sparkline"]) {
671
+ opacity: 0.65;
672
+ }
673
+ [data-slot="section-card"] [data-slot="card"][data-stale] :is([data-slot="card-title"], [data-slot="badge"]) {
674
+ color: var(--muted-foreground);
675
+ }
676
+
677
+ /*
678
+ * Chart gridlines take the full border colour, solid: stock shadcn draws them
679
+ * dashed at half of it, which all but vanishes on a dark card (5% white).
466
680
  */
467
681
  [data-slot="chart"] .recharts-cartesian-grid line {
468
682
  stroke: var(--border);
469
683
  }
470
684
 
685
+ /*
686
+ * The sidebar is parted from the page by a hairline on its page-side edge,
687
+ * its group labels are small monospaced capitals, as every label over content
688
+ * is, and its rows are compact. Fill, icons and the current-page mark stay as
689
+ * shadcn draws them.
690
+ */
691
+ /* shadcn edges a collapsible sidebar on its page side already; a non-collapsible one, set in the page's flow, has none. */
692
+ div[data-slot="sidebar"]:not([data-side], [data-mobile]) {
693
+ border-inline-end: 1px solid var(--sidebar-border);
694
+ }
695
+ [data-slot="sidebar-group-label"] {
696
+ @apply font-mono text-2xs tracking-wider uppercase;
697
+ }
698
+ [data-slot="sidebar-menu"] {
699
+ gap: 0;
700
+ }
701
+ /* A row that opens a group renders as its collapsible trigger, which takes that slot, so rows are found by data-sidebar. */
702
+ [data-sidebar="menu-button"]:not([data-size="lg"]) {
703
+ height: 1.75rem;
704
+ }
705
+ [data-slot="sidebar-menu-sub"] {
706
+ gap: 0;
707
+ padding-block: 0.125rem;
708
+ }
709
+ [data-sidebar="menu-sub-button"] {
710
+ height: 1.5rem;
711
+ }
712
+
471
713
  /*
472
714
  * The selected sub-page marks its stretch of the sidebar's rail in the accent,
473
715
  * so the current place reads at a glance in a long list, as a docs index does.