@siyahi/ui 0.1.9-next.23 → 0.1.9-next.25

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 (47) hide show
  1. package/agents/examples/change-grid-money.tsx +36 -0
  2. package/agents/examples/change-grid.tsx +42 -0
  3. package/agents/examples/delta.tsx +11 -6
  4. package/agents/examples/drawdown-chart.tsx +35 -0
  5. package/agents/examples/heatmap-calendar-signed.tsx +67 -0
  6. package/agents/examples/heatmap-status.tsx +6 -4
  7. package/agents/examples/histogram-chart.tsx +28 -0
  8. package/agents/examples/scatter-chart-quadrants.tsx +63 -0
  9. package/agents/examples/scatter-chart.tsx +75 -0
  10. package/agents/examples/slope-chart-market.tsx +33 -0
  11. package/agents/examples/slope-chart.tsx +26 -0
  12. package/agents/examples/waterfall-chart-stores.tsx +27 -0
  13. package/agents/examples/waterfall-chart.tsx +37 -0
  14. package/agents/manifest.json +160 -0
  15. package/dist/components/registry/heatmap/calendar-heatmap.d.ts +8 -2
  16. package/dist/components/registry/heatmap/calendar-heatmap.js +96 -18
  17. package/dist/components/registry/heatmap/status-heatmap.js +86 -47
  18. package/dist/components/siyahi/change-grid.d.ts +31 -0
  19. package/dist/components/siyahi/change-grid.js +74 -0
  20. package/dist/components/siyahi/delta.js +2 -2
  21. package/dist/components/siyahi/drawdown-chart.d.ts +34 -0
  22. package/dist/components/siyahi/drawdown-chart.js +102 -0
  23. package/dist/components/siyahi/histogram-chart.d.ts +57 -0
  24. package/dist/components/siyahi/histogram-chart.js +134 -0
  25. package/dist/components/siyahi/scatter-chart.d.ts +59 -0
  26. package/dist/components/siyahi/scatter-chart.js +367 -0
  27. package/dist/components/siyahi/slope-chart.d.ts +35 -0
  28. package/dist/components/siyahi/slope-chart.js +159 -0
  29. package/dist/components/siyahi/treemap.js +3 -7
  30. package/dist/components/siyahi/waterfall-chart.d.ts +51 -0
  31. package/dist/components/siyahi/waterfall-chart.js +147 -0
  32. package/dist/index.d.ts +9 -2
  33. package/dist/index.js +7 -1
  34. package/dist/lib/chart-frame.d.ts +7 -2
  35. package/dist/lib/chart-frame.js +44 -10
  36. package/dist/lib/chart-series.d.ts +2 -3
  37. package/dist/lib/fit-text.d.ts +6 -0
  38. package/dist/lib/fit-text.js +11 -0
  39. package/dist/lib/heat.d.ts +7 -0
  40. package/dist/lib/heat.js +14 -0
  41. package/dist/lib/scatter.d.ts +98 -0
  42. package/dist/lib/scatter.js +218 -0
  43. package/dist/lib/treemap.d.ts +0 -5
  44. package/dist/lib/treemap.js +1 -16
  45. package/package.json +1 -1
  46. package/report.css +9 -0
  47. package/styles/siyahi.css +23 -5
@@ -0,0 +1,6 @@
1
+ //#region src/lib/fit-text.d.ts
2
+ /** About how wide a text-xs label draws, in pixels. */
3
+ export declare const textWidth: (text: string) => number;
4
+ /** The label, cut with an ellipsis when it would draw wider than `width` pixels at text-xs. */
5
+ export declare function fit(text: string, width: number): string;
6
+ //#endregion
@@ -0,0 +1,11 @@
1
+ //#region src/lib/fit-text.ts
2
+ const CHAR_PX = 6.6;
3
+ /** About how wide a text-xs label draws, in pixels. */
4
+ const textWidth = (text) => text.length * CHAR_PX;
5
+ /** The label, cut with an ellipsis when it would draw wider than `width` pixels at text-xs. */
6
+ function fit(text, width) {
7
+ const room = Math.floor(width / CHAR_PX);
8
+ return text.length <= room ? text : `${text.slice(0, Math.max(room - 1, 1)).trimEnd()}…`;
9
+ }
10
+ //#endregion
11
+ export { fit, textWidth };
@@ -0,0 +1,7 @@
1
+ //#region src/lib/heat.d.ts
2
+ export declare const HEAT_STEPS = 5;
3
+ /** The range the heat scale uses: one that is not a positive number falls back to ±5%. */
4
+ export declare const heatRange: (range?: number) => number;
5
+ export declare const heatFill: (side: "positive" | "negative", step: number) => string;
6
+ export declare function heat(change: number | null, range?: number): string;
7
+ //#endregion
@@ -0,0 +1,14 @@
1
+ //#region src/lib/heat.ts
2
+ /** The signed heat scale a Treemap tile and a ChangeGrid cell share: five steps of gain or loss mixed into the card. */
3
+ const DEFAULT_RANGE = .05;
4
+ const HEAT_STEPS = 5;
5
+ /** The range the heat scale uses: one that is not a positive number falls back to ±5%. */
6
+ const heatRange = (range = DEFAULT_RANGE) => Number.isFinite(range) && range > 0 ? range : DEFAULT_RANGE;
7
+ const heatFill = (side, step) => `color-mix(in oklab, var(--${side}) var(--heat-${step}), var(--card))`;
8
+ function heat(change, range) {
9
+ if (!change || !Number.isFinite(change)) return "var(--muted)";
10
+ const step = Math.min(5, Math.ceil(Math.abs(change) / heatRange(range) * 5));
11
+ return heatFill(change > 0 ? "positive" : "negative", step);
12
+ }
13
+ //#endregion
14
+ export { HEAT_STEPS, heat, heatFill, heatRange };
@@ -0,0 +1,98 @@
1
+ //#region src/lib/scatter.d.ts
2
+ /**
3
+ * Ticks for a log axis at 1, 2 and 5 times a power of ten, from the largest at or under min to the smallest at or
4
+ * over max, so the axis ends on a tick and every label reads as a round number.
5
+ */
6
+ export declare function logTicks(min: number, max: number): number[];
7
+ /**
8
+ * Ticks for a linear axis that just covers min to max, about `count` of them, a step of 1, 2, 2.5 or 5 times a power
9
+ * of ten: −50% to +75% by 25s, never −50% to +150% because the step had to be 50. Values that differ only by float
10
+ * noise, such as 0.3 and 0.30000000000000004, count as one value and get that value as their only tick.
11
+ */
12
+ export declare function linearTicks(min: number, max: number, count?: number): number[];
13
+ export interface Box {
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ }
19
+ export interface LabelCandidate {
20
+ key: string;
21
+ text: string;
22
+ /** The dot's centre, in pixels. */
23
+ cx: number;
24
+ cy: number;
25
+ /** The dot's radius, its edge included: a sized dot keeps names off all of itself. */
26
+ r: number;
27
+ }
28
+ export interface PlacedLabel {
29
+ key: string;
30
+ text: string;
31
+ /** Where the text starts and its baseline, with textAnchor="start". */
32
+ x: number;
33
+ y: number;
34
+ }
35
+ /** How labels are set: their type size in pixels and a name's width in that type. */
36
+ export interface LabelType {
37
+ size: number;
38
+ measure: (text: string) => number;
39
+ }
40
+ /**
41
+ * Names beside dots, in the candidates' order, up to `limit`: each tries the right of its dot, then the left, above,
42
+ * below and the four corners, and takes the first spot inside the plot that covers no other label, no other dot and
43
+ * nothing `reserved` (such as a quadrant's name). A name that finds no spot is left to the tooltip, so labels never sit
44
+ * on top of each other.
45
+ */
46
+ export declare function placeLabels(candidates: readonly LabelCandidate[], plot: Box, limit: number, { size, measure }: LabelType, reserved?: readonly Box[]): PlacedLabel[];
47
+ /**
48
+ * Splits the plot into four at a value on each axis, such as zero, with an optional name in each corner. On a log
49
+ * axis x must be above zero, or no split is drawn. A name that does not fit its quadrant in two lines is left out.
50
+ */
51
+ export interface ScatterQuadrants {
52
+ x: number;
53
+ y: number;
54
+ labels?: {
55
+ topLeft?: string;
56
+ topRight?: string;
57
+ bottomLeft?: string;
58
+ bottomRight?: string;
59
+ };
60
+ }
61
+ export type QuadrantLabels = ScatterQuadrants["labels"];
62
+ /** A split a log axis cannot place, at or below zero, or one that is not a number, is no split at all. */
63
+ export declare function quadrantSplit(quadrants: ScatterQuadrants | undefined, xScale: "linear" | "log"): ScatterQuadrants | undefined;
64
+ /** The largest sized dot; the smallest stays big enough to see and to tap. */
65
+ export declare const MAX_RADIUS = 20;
66
+ /**
67
+ * Each dot's radius. Area, not radius, follows size, so a holding twice as large has twice the ink. A size that weighs
68
+ * nothing draws the smallest dot, so it cannot shrink or hide the others. Points without sizes all take `plain`.
69
+ */
70
+ export declare function dotSizes(points: readonly {
71
+ size?: number;
72
+ }[], plain: number): {
73
+ radiusOf: (p: {
74
+ size?: number;
75
+ }) => number;
76
+ sized: boolean;
77
+ };
78
+ /**
79
+ * Each corner's name in the plot's corner, in the band the y axis keeps clear above or below the data, wrapped to its
80
+ * own quadrant's width so two names on one edge never meet: where its lines are drawn, and the box they take.
81
+ */
82
+ export declare function corners(plot: Box, names: QuadrantLabels, split: {
83
+ x: number;
84
+ y: number;
85
+ } | null, type: LabelType): {
86
+ key: string;
87
+ lines: string[];
88
+ x: number;
89
+ y: number;
90
+ line: number;
91
+ box: {
92
+ x: number;
93
+ y: number;
94
+ width: number;
95
+ height: number;
96
+ };
97
+ }[];
98
+ //#endregion
@@ -0,0 +1,218 @@
1
+ //#region src/lib/scatter.ts
2
+ /**
3
+ * Ticks for a log axis at 1, 2 and 5 times a power of ten, from the largest at or under min to the smallest at or
4
+ * over max, so the axis ends on a tick and every label reads as a round number.
5
+ */
6
+ function logTicks(min, max) {
7
+ if (!(min > 0) || !Number.isFinite(max) || !(max >= min)) return [];
8
+ const ticks = [];
9
+ for (let decade = Math.floor(Math.log10(min));; decade++) for (const step of [
10
+ 1,
11
+ 2,
12
+ 5
13
+ ]) {
14
+ const tick = Number((step * 10 ** decade).toPrecision(12));
15
+ if (Number(((step === 5 ? 10 : step === 2 ? 5 : 2) * 10 ** decade).toPrecision(12)) <= min) continue;
16
+ ticks.push(tick);
17
+ if (tick >= max) return ticks;
18
+ }
19
+ }
20
+ const clean = (value) => Number(value.toPrecision(12)) || 0;
21
+ /**
22
+ * Ticks for a linear axis that just covers min to max, about `count` of them, a step of 1, 2, 2.5 or 5 times a power
23
+ * of ten: −50% to +75% by 25s, never −50% to +150% because the step had to be 50. Values that differ only by float
24
+ * noise, such as 0.3 and 0.30000000000000004, count as one value and get that value as their only tick.
25
+ */
26
+ function linearTicks(min, max, count = 5) {
27
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max < min) return [];
28
+ if (max - min <= Math.max(Math.abs(min), Math.abs(max)) * 1e-9) return [clean(min)];
29
+ const rough = (max - min) / (count - 1);
30
+ const power = 10 ** Math.floor(Math.log10(rough));
31
+ const step = ([
32
+ 1,
33
+ 2,
34
+ 2.5,
35
+ 5,
36
+ 10
37
+ ].find((m) => m * power >= rough * (1 - 1e-9)) ?? 10) * power;
38
+ const first = Math.floor(min / step + 1e-9);
39
+ const last = Math.ceil(max / step - 1e-9);
40
+ return Array.from({ length: last - first + 1 }, (_, i) => clean((first + i) * step));
41
+ }
42
+ const HALO = 2;
43
+ const GAP = 4;
44
+ const overlaps = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
45
+ /**
46
+ * Names beside dots, in the candidates' order, up to `limit`: each tries the right of its dot, then the left, above,
47
+ * below and the four corners, and takes the first spot inside the plot that covers no other label, no other dot and
48
+ * nothing `reserved` (such as a quadrant's name). A name that finds no spot is left to the tooltip, so labels never sit
49
+ * on top of each other.
50
+ */
51
+ function placeLabels(candidates, plot, limit, { size, measure }, reserved = []) {
52
+ const line = size * 4 / 3;
53
+ const dots = candidates.map(({ key, cx, cy, r }) => ({
54
+ key,
55
+ box: {
56
+ x: cx - r,
57
+ y: cy - r,
58
+ width: r * 2,
59
+ height: r * 2
60
+ }
61
+ }));
62
+ const taken = [...reserved];
63
+ const placed = [];
64
+ for (const { key, text, cx, cy, r: radius } of candidates) {
65
+ if (placed.length >= limit) break;
66
+ const width = measure(text) + 4;
67
+ const spot = [
68
+ {
69
+ x: cx + radius + GAP,
70
+ y: cy - line / 2,
71
+ width,
72
+ height: line
73
+ },
74
+ {
75
+ x: cx - radius - GAP - width,
76
+ y: cy - line / 2,
77
+ width,
78
+ height: line
79
+ },
80
+ {
81
+ x: cx - width / 2,
82
+ y: cy - radius - GAP - line,
83
+ width,
84
+ height: line
85
+ },
86
+ {
87
+ x: cx - width / 2,
88
+ y: cy + radius + GAP,
89
+ width,
90
+ height: line
91
+ },
92
+ {
93
+ x: cx + radius,
94
+ y: cy - radius - line,
95
+ width,
96
+ height: line
97
+ },
98
+ {
99
+ x: cx - radius - width,
100
+ y: cy - radius - line,
101
+ width,
102
+ height: line
103
+ },
104
+ {
105
+ x: cx + radius,
106
+ y: cy + radius,
107
+ width,
108
+ height: line
109
+ },
110
+ {
111
+ x: cx - radius - width,
112
+ y: cy + radius,
113
+ width,
114
+ height: line
115
+ }
116
+ ].find((box) => box.x >= plot.x && box.y >= plot.y && box.x + box.width <= plot.x + plot.width && box.y + box.height <= plot.y + plot.height && !taken.some((other) => overlaps(box, other)) && !dots.some((dot) => dot.key !== key && overlaps(box, dot.box)));
117
+ if (!spot) continue;
118
+ taken.push(spot);
119
+ placed.push({
120
+ key,
121
+ text,
122
+ x: spot.x + HALO,
123
+ y: spot.y + size
124
+ });
125
+ }
126
+ return placed;
127
+ }
128
+ /** A split a log axis cannot place, at or below zero, or one that is not a number, is no split at all. */
129
+ function quadrantSplit(quadrants, xScale) {
130
+ if (!quadrants || !Number.isFinite(quadrants.x) || !Number.isFinite(quadrants.y)) return void 0;
131
+ return xScale === "linear" || quadrants.x > 0 ? quadrants : void 0;
132
+ }
133
+ /** The largest sized dot; the smallest stays big enough to see and to tap. */
134
+ const MAX_RADIUS = 20;
135
+ const MIN_RADIUS = 3;
136
+ /** A size that is not a positive number (NaN, Infinity, a negative) weighs nothing. */
137
+ const weight = (p) => p.size !== void 0 && Number.isFinite(p.size) && p.size > 0 ? p.size : 0;
138
+ /**
139
+ * Each dot's radius. Area, not radius, follows size, so a holding twice as large has twice the ink. A size that weighs
140
+ * nothing draws the smallest dot, so it cannot shrink or hide the others. Points without sizes all take `plain`.
141
+ */
142
+ function dotSizes(points, plain) {
143
+ const largest = points.reduce((most, p) => Math.max(most, weight(p)), 0);
144
+ const sized = points.some((p) => p.size !== void 0);
145
+ const radiusOf = (p) => {
146
+ if (!sized || p.size === void 0) return plain;
147
+ return largest > 0 ? Math.max(MIN_RADIUS, 20 * Math.sqrt(weight(p) / largest)) : MIN_RADIUS;
148
+ };
149
+ return {
150
+ radiusOf,
151
+ sized
152
+ };
153
+ }
154
+ /** Words broken into lines no wider than `width`; a word wider than that keeps a line of its own. */
155
+ function wrap(text, width, measure) {
156
+ const lines = [];
157
+ for (const word of text.split(" ")) {
158
+ const last = lines.at(-1);
159
+ if (last !== void 0 && measure(`${last} ${word}`) <= width) lines[lines.length - 1] = `${last} ${word}`;
160
+ else lines.push(word);
161
+ }
162
+ return lines;
163
+ }
164
+ /**
165
+ * Each corner's name in the plot's corner, in the band the y axis keeps clear above or below the data, wrapped to its
166
+ * own quadrant's width so two names on one edge never meet: where its lines are drawn, and the box they take.
167
+ */
168
+ function corners(plot, names, split, type) {
169
+ if (!names || !split) return [];
170
+ const line = type.size * 4 / 3;
171
+ const inset = 6;
172
+ return [
173
+ [
174
+ names.topLeft,
175
+ "left",
176
+ "top"
177
+ ],
178
+ [
179
+ names.topRight,
180
+ "right",
181
+ "top"
182
+ ],
183
+ [
184
+ names.bottomLeft,
185
+ "left",
186
+ "bottom"
187
+ ],
188
+ [
189
+ names.bottomRight,
190
+ "right",
191
+ "bottom"
192
+ ]
193
+ ].flatMap(([text, side, edge]) => {
194
+ if (!text) return [];
195
+ const room = (side === "left" ? split.x - plot.x : plot.x + plot.width - split.x) - 12;
196
+ const lines = wrap(text, Math.max(room, 0), type.measure);
197
+ const width = Math.max(...lines.map(type.measure));
198
+ if (lines.length > 2 || width > room) return [];
199
+ const height = lines.length * line;
200
+ const x = side === "left" ? plot.x + inset : plot.x + plot.width - inset - width;
201
+ const top = edge === "top" ? plot.y + inset : plot.y + plot.height - inset - height;
202
+ return [{
203
+ key: `${side}-${edge}`,
204
+ lines,
205
+ x,
206
+ y: top + type.size,
207
+ line,
208
+ box: {
209
+ x,
210
+ y: top,
211
+ width,
212
+ height
213
+ }
214
+ }];
215
+ });
216
+ }
217
+ //#endregion
218
+ export { MAX_RADIUS, corners, dotSizes, linearTicks, logTicks, placeLabels, quadrantSplit };
@@ -18,10 +18,5 @@ export declare function layout<T extends {
18
18
  weight: number;
19
19
  group?: string;
20
20
  }>(items: T[], width: number, height: number): Layout<T>;
21
- export declare const HEAT_STEPS = 5;
22
- /** The range the heat scale uses: one that is not a positive number falls back to ±5%. */
23
- export declare const heatRange: (range?: number) => number;
24
- export declare const heatFill: (side: "positive" | "negative", step: number) => string;
25
- export declare function heat(change: number | null, range?: number): string;
26
21
  //#endregion
27
22
  export {};
@@ -31,20 +31,5 @@ function layout(items, width, height) {
31
31
  }])
32
32
  };
33
33
  }
34
- const DEFAULT_RANGE = .05;
35
- const HEAT_STEPS = 5;
36
- /** The range the heat scale uses: one that is not a positive number falls back to ±5%. */
37
- const heatRange = (range = DEFAULT_RANGE) => Number.isFinite(range) && range > 0 ? range : DEFAULT_RANGE;
38
- const DARK_FLOOR = 25;
39
- const heatFill = (side, step) => {
40
- const mix = (share) => `color-mix(in oklab, var(--${side}) ${share}%, var(--card))`;
41
- const dark = DARK_FLOOR + (step - 1) * 35 / 4;
42
- return `light-dark(${mix(step * 12)}, ${mix(dark)})`;
43
- };
44
- function heat(change, range) {
45
- if (!change) return "var(--muted)";
46
- const step = Math.min(5, Math.ceil(Math.abs(change) / heatRange(range) * 5));
47
- return heatFill(change > 0 ? "positive" : "negative", step);
48
- }
49
34
  //#endregion
50
- export { HEAT_STEPS, heat, heatFill, heatRange, layout };
35
+ export { layout };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siyahi/ui",
3
- "version": "0.1.9-next.23",
3
+ "version": "0.1.9-next.25",
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
@@ -30,6 +30,11 @@
30
30
  --negative-foreground: oklch(0.985 0 0);
31
31
  --warning: oklch(0.555 0.149 48.998);
32
32
  --warning-foreground: oklch(0.985 0 0);
33
+ --heat-1: 12%;
34
+ --heat-2: 24%;
35
+ --heat-3: 36%;
36
+ --heat-4: 48%;
37
+ --heat-5: 60%;
33
38
  --link: oklch(0.51 0.2 275);
34
39
  --chart-1: oklch(0.4 0.17 275);
35
40
  --chart-2: oklch(0.66 0.13 70);
@@ -72,6 +77,10 @@
72
77
  --negative-foreground: oklch(0.145 0 0);
73
78
  --warning: oklch(0.828 0.169 84.429);
74
79
  --warning-foreground: oklch(0.145 0 0);
80
+ --heat-1: 25%;
81
+ --heat-2: 33.75%;
82
+ --heat-3: 42.5%;
83
+ --heat-4: 51.25%;
75
84
  --link: oklch(0.75 0.128 275);
76
85
  --chart-1: oklch(0.58 0.2 275);
77
86
  --chart-2: oklch(0.88 0.08 75);
package/styles/siyahi.css CHANGED
@@ -113,6 +113,16 @@
113
113
  --negative-foreground: oklch(0.985 0 0);
114
114
  --warning: oklch(0.555 0.149 48.998);
115
115
  --warning-foreground: oklch(0.985 0 0);
116
+ /*
117
+ * Heat steps: how much of --positive or --negative each of five steps mixes
118
+ * into the card, for Treemap, ChangeGrid and a signed CalendarHeatmap
119
+ * (lib/heat.ts). The contrast check reads them from here.
120
+ */
121
+ --heat-1: 12%;
122
+ --heat-2: 24%;
123
+ --heat-3: 36%;
124
+ --heat-4: 48%;
125
+ --heat-5: 60%;
116
126
  --link: oklch(0.51 0.2 275);
117
127
  /*
118
128
  * Indigo, amber, plum, sky, teal: no green or red, so no series reads as a
@@ -179,6 +189,12 @@
179
189
  --negative-foreground: oklch(0.145 0 0);
180
190
  --warning: oklch(0.828 0.169 84.429);
181
191
  --warning-foreground: oklch(0.145 0 0);
192
+ /* A 12% tint of a dark card reads as the card itself, so dark starts at 25%. */
193
+ --heat-1: 25%;
194
+ --heat-2: 33.75%;
195
+ --heat-3: 42.5%;
196
+ --heat-4: 51.25%;
197
+ --heat-5: 60%;
182
198
  --link: oklch(0.75 0.128 275);
183
199
  --chart-1: oklch(0.58 0.2 275);
184
200
  --chart-2: oklch(0.88 0.08 75);
@@ -237,9 +253,11 @@
237
253
  * --input is the edge of a field, where the reader types or picks. A button,
238
254
  * a toggle and a code block are framed by their label or their content, so
239
255
  * their outline stays the faint divider colour; scoping the variable keeps
240
- * the fill each derives from it faint too.
256
+ * the fill each derives from it faint too. A button is matched by its own
257
+ * group/button class: rendered as a popover or menu trigger, it takes that
258
+ * trigger's data-slot instead of "button".
241
259
  */
242
- :is([data-slot="button"], [data-slot="toggle"], [data-slot="toggle-group-item"], [data-slot="code-block"]) {
260
+ :is([class~="group/button"], [data-slot="toggle"], [data-slot="toggle-group-item"], [data-slot="code-block"]) {
243
261
  --input: var(--border);
244
262
  }
245
263
 
@@ -263,7 +281,7 @@
263
281
  * hidden range input, so it shows the input's focus.
264
282
  */
265
283
  :is([data-slot="toggle"], [data-slot="toggle-group-item"], [data-slot="scroll-area-viewport"]):focus-visible,
266
- [data-slot="button"][data-day]:focus-visible {
284
+ [class~="group/button"][data-day]:focus-visible {
267
285
  --tw-ring-color: var(--ring);
268
286
  }
269
287
  [data-slot="slider-thumb"]:has(input:focus-visible) {
@@ -274,13 +292,13 @@
274
292
  * A destructive control focuses, and an invalid field is edged, in the full
275
293
  * destructive colour; stock fades both to 40-50%, under 3:1.
276
294
  */
277
- [data-slot="button"][class~="text-destructive"]:focus-visible,
295
+ [class~="group/button"][class~="text-destructive"]:focus-visible,
278
296
  [data-slot="code-block"][data-variant="destructive"]:has(> [data-slot="code-block-content"]:focus-visible),
279
297
  :is(
280
298
  [data-slot="input"],
281
299
  [data-slot="textarea"],
282
300
  [data-slot="select-trigger"],
283
- [data-slot="button"]
301
+ [class~="group/button"]
284
302
  )[aria-invalid="true"],
285
303
  [data-slot="input-group"]:has([data-slot][aria-invalid="true"]) {
286
304
  border-color: var(--destructive);