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

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/DESIGN.md CHANGED
@@ -28,10 +28,12 @@ a meaning:
28
28
  with a left (or any single-side) coloured border; tint the box or add an
29
29
  icon. 1px neutral dividers between columns are separators, not accents, and
30
30
  are fine.
31
- - **Edges that matter reach 3:1; the rest stay faint.** A field's edge (input,
31
+ - **Edges that matter reach 3:1; the rest are hairlines.** A field's edge (input,
32
32
  textarea, select, input group, the command search) is `--input`, at 3:1 on the
33
33
  canvas and the card; dividers, card rings, gridlines and the outlines of
34
- buttons, toggles and code blocks are the faint `--border`. Focus is an opaque
34
+ buttons, toggles and code blocks are the `--border` hairline (the text colour
35
+ at 18% in light, white at 20% in dark), firm enough to carry the page's
36
+ structure. Focus is an opaque
35
37
  `--ring`, or the opaque destructive red on a destructive control. A selected
36
38
  toggle or pill tab fills with the foreground. A chart bar that a selection
37
39
  dims keeps its full-colour outline and fades only inside.
@@ -41,3 +43,59 @@ a meaning:
41
43
  - Categories (a team, a region, a product line) stay neutral: outline badges,
42
44
  never a hue each. Ordered buckets (largest 5, next 5, rest) use steps of one
43
45
  colour, not separate hues.
46
+ - **Colour marks exceptions.** The one stat tile or table row that needs a look
47
+ is marked by the page: `tone: "negative"` or `"warning"` on a `SectionCards`
48
+ item, `data-tone="negative"` or `"warning"` on a `TableRow`. It takes a faint
49
+ wash of the colour over the card (7% in light, 12% in dark), and a tile an
50
+ outline of it. The page decides, never the sign: a falling cost is good news.
51
+ Mark one or two; a page where every row is washed marks nothing.
52
+
53
+ ## Surfaces
54
+
55
+ `SiyahiProvider`'s `surface` sets how a data block sits on the page. The page
56
+ and card colours are fixed: never tint the canvas.
57
+
58
+ - **`ruled`** (the default, with or without a provider): ruled on white. The
59
+ blocks of a `PanelGrid`, the tiles of a `SectionCards` row and the figures
60
+ of a `MetricStrip` are filled with the card colour and parted by hairlines,
61
+ with a rule above and below the group and none on its left and right edges.
62
+ A card standing alone (a `MetricStrip`, a table's card) takes the same rules
63
+ above and below itself. A short last row ends in a blank cell, ruled off
64
+ like the rest. A stat tile keeps its footer's top rule and faint band. Set
65
+ nothing to get it.
66
+ - **`card`**: tight square cards, for a page of separate blocks, such as an
67
+ index of links. Each block is filled with the card colour inside a hairline
68
+ ring, with no shadow, 8px from the next in a `PanelGrid` or a row of
69
+ `SectionCards`. Ask for it with `surface="card"`.
70
+ - **`open`**: no fill, no lines and no padding, so a block sits level with the
71
+ page's text, for pages read top to bottom.
72
+
73
+ Every `PanelGrid` numbers its blocks 01, 02 before their card title, in the
74
+ mono face and the link colour, so the text can point to one.
75
+
76
+ ## Shape
77
+
78
+ - **Square corners everywhere**: panels, cards, buttons, fields, badges,
79
+ toggles, menus, and bars and tracks (data bars, progress) alike (`--radius`
80
+ is 0). Only a dot, an avatar, a slider, a spinner or a scrollbar is round;
81
+ the stock chart tooltip's colour chips keep shadcn's 2px. Never add a
82
+ `rounded-*` class to a box.
83
+ - **Joined toggles.** The items of a `ToggleGroup` sit flush and share their
84
+ borders, one control; the pressed item fills with the foreground.
85
+ - Badges stay boxed (an outline), the primary button and the pressed toggle
86
+ black (white in dark), titles in the sans face, at normal spacing.
87
+
88
+ ## Page header
89
+
90
+ An app page opens on its masthead, `PageHero`: a small mark and eyebrow in the
91
+ mono face and link colour, the title, one muted line saying what the page is,
92
+ the actions at the right, and a hairline under it. One per page; no hero card.
93
+
94
+ ## Labels and totals
95
+
96
+ - **Labels over figures are small monospaced capitals**: a stat tile's label
97
+ and footer lines, a `MetricStrip` label, a table's column heads. The
98
+ components and the theme set them; never set a label in this style by hand
99
+ elsewhere, and never set a figure or a title in it.
100
+ - **A total row reads as a ledger's**: in `TableFooter`, one rule above it and
101
+ a double rule under it in the text colour, no band.
package/README.md CHANGED
@@ -100,12 +100,23 @@ and support one keyboard entry with arrow navigation. `WeekdayHeatmap` accepts
100
100
 
101
101
  `SiyahiProvider` sets how data blocks sit on the page:
102
102
 
103
- - `surface="card"` (default, even outside a provider): each block is a card
104
- lifted off a faintly tinted canvas.
105
- - `surface="ruled"`: unboxed blocks; `PanelGrid` draws hairlines between them.
106
- - `surface="open"`: no boxes or rules; card padding is removed.
107
-
108
- Nest a provider to change one section. Stack `PageSection` blocks with
103
+ - `surface="ruled"` (default, even outside a provider): ruled on white.
104
+ `PanelGrid`, `SectionCards` and `MetricStrip` fill their blocks with the card
105
+ colour and part them with hairlines, with a rule above and below each group
106
+ and none on the sides; a card standing alone takes the rules above and below
107
+ itself.
108
+ - `surface="card"`: tight square cards, each filled inside a hairline, 8px
109
+ apart, no shadow, for a page of separate blocks.
110
+ - `surface="open"`: no fill, boxes or rules; card padding is removed.
111
+
112
+ `DESIGN.md` has the rest of the look: square corners, mono capital labels,
113
+ ledger totals, the masthead and exception tones.
114
+
115
+ Nest a provider to change one section. A dialog, sheet or popover renders
116
+ outside the page's provider element: `PanelGrid` and `SectionCards` carry
117
+ their surface into it, but a lone `Card` there is ruled unless the popup's
118
+ content sits in its own provider with an explicit surface, such as
119
+ `<SiyahiProvider surface="card">`; a provider without one draws no element. Stack `PageSection` blocks with
109
120
  `flex flex-col gap-6`; use `PanelGrid` for blocks side by side. Categories use
110
121
  neutral outline badges, leaving colour for meaning and chart series.
111
122
 
@@ -1,18 +1,180 @@
1
+ import {
2
+ Badge,
3
+ Button,
4
+ Callout,
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle,
10
+ Delta,
11
+ Input,
12
+ PageHero,
13
+ PanelGrid,
14
+ StatusBadge,
15
+ Table,
16
+ TableBody,
17
+ TableCell,
18
+ TableFooter,
19
+ TableHead,
20
+ TableHeader,
21
+ TableRow,
22
+ ToggleGroup,
23
+ ToggleGroupItem,
24
+ useFormat,
25
+ } from "@siyahi/ui";
26
+ import { DownloadIcon, LightbulbIcon, PlusIcon } from "lucide-react";
27
+ import { useState } from "react";
1
28
  import ChartArea from "./chart-area";
2
- import DataTableExample from "./data-table";
3
- import MetricStrip from "./metric-strip";
29
+ import SectionCardsTrend from "./section-cards-trend";
4
30
 
5
- // What a page built with Siyahi looks like: a metric strip, a chart card and a data table, each the catalogue's own example.
31
+ interface Holding {
32
+ name: string;
33
+ kind: string;
34
+ status: string;
35
+ tone: "positive" | "warning" | "negative";
36
+ value: number;
37
+ change: number;
38
+ }
39
+
40
+ const holdings: Holding[] = [
41
+ { name: "Nifty 50 index fund", kind: "Equity", status: "On track", tone: "positive", value: 1840000, change: 0.124 },
42
+ { name: "Flexi cap fund", kind: "Equity", status: "On track", tone: "positive", value: 962000, change: 0.087 },
43
+ { name: "Gold bonds, 2031", kind: "Gold", status: "Review", tone: "warning", value: 410500, change: 0.211 },
44
+ { name: "Short-term debt fund", kind: "Debt", status: "On track", tone: "positive", value: 655000, change: 0.031 },
45
+ { name: "Small cap fund", kind: "Equity", status: "Below plan", tone: "negative", value: 318200, change: -0.064 },
46
+ ];
47
+
48
+ // The catalogue's own ink drop, painted with currentColor so the masthead tints it.
49
+ function InkDrop() {
50
+ return (
51
+ <svg viewBox="0 0 16 16">
52
+ <path
53
+ fill="currentColor"
54
+ fillRule="evenodd"
55
+ d="M8 .5C8 .5 2.5 6.3 2.5 10a5.5 5.5 0 0 0 11 0C13.5 6.3 8 .5 8 .5ZM4.75 10A3.25 3.25 0 0 0 8 13.25v-1.5A1.75 1.75 0 0 1 6.25 10Z"
56
+ />
57
+ </svg>
58
+ );
59
+ }
60
+
61
+ function Holdings({ rows }: { rows: Holding[] }) {
62
+ const { money, pct } = useFormat();
63
+ const total = rows.reduce((sum, row) => sum + row.value, 0);
64
+ // The change of the whole: today's total against what the same holdings were worth a year ago.
65
+ const yearAgo = rows.reduce((sum, row) => sum + row.value / (1 + row.change), 0);
66
+ return (
67
+ <Card>
68
+ <CardHeader>
69
+ <CardTitle>Holdings</CardTitle>
70
+ <CardDescription>Value today and change over twelve months</CardDescription>
71
+ </CardHeader>
72
+ <CardContent>
73
+ <Table>
74
+ <TableHeader>
75
+ <TableRow>
76
+ <TableHead>Holding</TableHead>
77
+ <TableHead>Status</TableHead>
78
+ <TableHead className="text-right">Value</TableHead>
79
+ <TableHead className="text-right">12 months</TableHead>
80
+ </TableRow>
81
+ </TableHeader>
82
+ <TableBody>
83
+ {rows.length === 0 && (
84
+ <TableRow>
85
+ <TableCell colSpan={4}>
86
+ <span className="text-muted-foreground">No holding matches the search.</span>
87
+ </TableCell>
88
+ </TableRow>
89
+ )}
90
+ {rows.map((row) => (
91
+ // Only the holdings that need a look are marked; a row on track stays plain.
92
+ <TableRow key={row.name} data-tone={row.tone === "positive" ? undefined : row.tone}>
93
+ <TableCell>
94
+ <span className="flex flex-wrap items-center gap-2">
95
+ {row.name}
96
+ <Badge variant="outline">{row.kind}</Badge>
97
+ </span>
98
+ </TableCell>
99
+ <TableCell>
100
+ <StatusBadge tone={row.tone}>{row.status}</StatusBadge>
101
+ </TableCell>
102
+ <TableCell className="text-right">{money(row.value)}</TableCell>
103
+ <TableCell className="text-right">
104
+ <Delta value={row.change} />
105
+ </TableCell>
106
+ </TableRow>
107
+ ))}
108
+ </TableBody>
109
+ <TableFooter>
110
+ <TableRow>
111
+ <TableCell colSpan={2}>Total</TableCell>
112
+ <TableCell className="text-right">{money(total)}</TableCell>
113
+ <TableCell className="text-right">{yearAgo > 0 && pct(total / yearAgo - 1)}</TableCell>
114
+ </TableRow>
115
+ </TableFooter>
116
+ </Table>
117
+ </CardContent>
118
+ </Card>
119
+ );
120
+ }
121
+
122
+ // An app page on the default, ruled surface: masthead, controls, stat tiles, a chart and a table in a PanelGrid, and
123
+ // the takeaway. It sets no surface: ruled on white is what a page gets.
6
124
  export default function OverviewDashboard() {
125
+ const [query, setQuery] = useState("");
126
+ const shown = holdings.filter((row) => row.name.toLowerCase().includes(query.trim().toLowerCase()));
7
127
  return (
8
- <div className="flex flex-col gap-4">
9
- <MetricStrip />
10
- {/* Side by side only where the table's five columns fit beside the chart; at 1440 they would be cut off.
11
- Each card keeps its own height: stretched to the taller one, the chart card ends in blank space. */}
12
- <div className="grid items-start gap-4 2xl:grid-cols-2">
13
- <ChartArea />
14
- <DataTableExample />
128
+ <div className="flex flex-col gap-6">
129
+ <PageHero
130
+ mark={<InkDrop />}
131
+ eyebrow="The Book"
132
+ title="Holdings"
133
+ lead="What you own, what it is worth today, and how each part has moved."
134
+ detail="Prices as of the last market close."
135
+ actions={
136
+ <>
137
+ <Button>
138
+ <PlusIcon />
139
+ Add a holding
140
+ </Button>
141
+ <Button variant="outline">
142
+ <DownloadIcon />
143
+ Export
144
+ </Button>
145
+ </>
146
+ }
147
+ />
148
+ <div className="flex flex-wrap items-center gap-3">
149
+ <ToggleGroup defaultValue={["1y"]} variant="outline" aria-label="Period">
150
+ <ToggleGroupItem value="1m">1 month</ToggleGroupItem>
151
+ <ToggleGroupItem value="1y">1 year</ToggleGroupItem>
152
+ <ToggleGroupItem value="all">All</ToggleGroupItem>
153
+ </ToggleGroup>
154
+ <Input
155
+ className="w-56"
156
+ type="search"
157
+ placeholder="Search holdings"
158
+ aria-label="Search holdings"
159
+ value={query}
160
+ onChange={(event) => setQuery(event.target.value)}
161
+ />
162
+ <p className="text-sm text-muted-foreground">
163
+ Five holdings in three kinds.{" "}
164
+ <a className="text-link underline underline-offset-4" href="#/data">
165
+ How value is worked out
166
+ </a>
167
+ </p>
15
168
  </div>
169
+ <SectionCardsTrend />
170
+ <PanelGrid>
171
+ <ChartArea />
172
+ <Holdings rows={shown} />
173
+ </PanelGrid>
174
+ <Callout tone="accent" icon={<LightbulbIcon />} title="The takeaway">
175
+ Equity is <strong>74%</strong> of the book against a plan of 65%. The small cap fund is the only holding below
176
+ plan.
177
+ </Callout>
16
178
  </div>
17
179
  );
18
180
  }
@@ -1,4 +1,4 @@
1
- import { Delta, SectionCards, SiyahiProvider, useFormat, type SectionCard } from "@siyahi/ui";
1
+ import { Delta, SectionCards, useFormat, type SectionCard } from "@siyahi/ui";
2
2
  import { useState } from "react";
3
3
 
4
4
  // Each sales channel's profit this quarter and its orders; made-up figures.
@@ -20,12 +20,9 @@ export default function SectionCardsSelectExample() {
20
20
  disabled: c.orders === 0,
21
21
  }));
22
22
  return (
23
- // On a ruled surface the tiles part with hairlines, as on a dense operations screen, instead of sitting as cards.
24
- <SiyahiProvider surface="ruled">
25
- <div className="flex flex-col gap-3">
26
- <SectionCards items={items} selected={scope} onSelect={setScope} />
27
- <p className="text-sm text-muted-foreground">Showing {scope}</p>
28
- </div>
29
- </SiyahiProvider>
23
+ <div className="flex flex-col gap-3">
24
+ <SectionCards items={items} selected={scope} onSelect={setScope} />
25
+ <p className="text-sm text-muted-foreground">Showing {scope}</p>
26
+ </div>
30
27
  );
31
28
  }
@@ -17,6 +17,8 @@ export default function SectionCardsTrendExample() {
17
17
  // A run of losses: the meaning colour, not a chart slot.
18
18
  trend: <Sparkline data={[0, -8200, -14100, -30400, -52800, -61200]} color="var(--negative)" />,
19
19
  detail: "Running total since April",
20
+ // The one figure on the page that needs a look, marked by the page: a falling cost would not be.
21
+ tone: "negative",
20
22
  },
21
23
  {
22
24
  label: "Gift cards this year",
@@ -89,7 +89,7 @@ function Dashboard() {
89
89
  }
90
90
 
91
91
  export default function SurfacesExample() {
92
- const [surface, setSurface] = useState<Surface>("card");
92
+ const [surface, setSurface] = useState<Surface>("ruled");
93
93
  return (
94
94
  <div className="flex flex-col gap-4">
95
95
  <ToggleGroup
@@ -103,8 +103,8 @@ export default function SurfacesExample() {
103
103
  aria-label="Surface"
104
104
  >
105
105
  {/* The default first. */}
106
- <ToggleGroupItem value="card">Card</ToggleGroupItem>
107
106
  <ToggleGroupItem value="ruled">Ruled</ToggleGroupItem>
107
+ <ToggleGroupItem value="card">Card</ToggleGroupItem>
108
108
  <ToggleGroupItem value="open">Open</ToggleGroupItem>
109
109
  </ToggleGroup>
110
110
  {/* A nested provider sets the surface for its part of the page. */}
@@ -6,6 +6,7 @@ import {
6
6
  Table,
7
7
  TableBody,
8
8
  TableCell,
9
+ TableFooter,
9
10
  TableHead,
10
11
  TableHeader,
11
12
  TableRow,
@@ -20,6 +21,7 @@ const rows = [
20
21
 
21
22
  export default function TableExample() {
22
23
  const { money } = useFormat();
24
+ const total = (key: "in" | "out") => rows.reduce((sum, row) => sum + row[key], 0);
23
25
  return (
24
26
  <Card>
25
27
  <CardHeader>
@@ -36,13 +38,21 @@ export default function TableExample() {
36
38
  </TableHeader>
37
39
  <TableBody>
38
40
  {rows.map((row) => (
39
- <TableRow key={row.month}>
41
+ // August's spending needs a look: the page marks the row, and the theme washes it amber.
42
+ <TableRow key={row.month} data-tone={row.out > 50000 ? "warning" : undefined}>
40
43
  <TableCell>{row.month}</TableCell>
41
44
  <TableCell className="text-right">{money(row.in)}</TableCell>
42
45
  <TableCell className="text-right">{money(row.out)}</TableCell>
43
46
  </TableRow>
44
47
  ))}
45
48
  </TableBody>
49
+ <TableFooter>
50
+ <TableRow>
51
+ <TableCell>Total</TableCell>
52
+ <TableCell className="text-right">{money(total("in"))}</TableCell>
53
+ <TableCell className="text-right">{money(total("out"))}</TableCell>
54
+ </TableRow>
55
+ </TableFooter>
46
56
  </Table>
47
57
  </CardContent>
48
58
  </Card>
@@ -289,7 +289,7 @@
289
289
  ]
290
290
  },
291
291
  {
292
- "need": "Boxes back, or no lines at all, for a page or a part of one",
292
+ "need": "Separate square cards instead of the default ruled panels, or no lines at all, for a page or a part of one",
293
293
  "guidance": "`SiyahiProvider` with `surface=\"card\"` or `surface=\"open\"`",
294
294
  "components": [
295
295
  "SiyahiProvider"
@@ -936,7 +936,31 @@
936
936
  },
937
937
  {
938
938
  "file": "agents/examples/overview-dashboard.tsx",
939
- "components": []
939
+ "components": [
940
+ "Badge",
941
+ "Button",
942
+ "Callout",
943
+ "Card",
944
+ "CardContent",
945
+ "CardDescription",
946
+ "CardHeader",
947
+ "CardTitle",
948
+ "Delta",
949
+ "Input",
950
+ "PageHero",
951
+ "PanelGrid",
952
+ "StatusBadge",
953
+ "Table",
954
+ "TableBody",
955
+ "TableCell",
956
+ "TableFooter",
957
+ "TableHead",
958
+ "TableHeader",
959
+ "TableRow",
960
+ "ToggleGroup",
961
+ "ToggleGroupItem",
962
+ "useFormat"
963
+ ]
940
964
  },
941
965
  {
942
966
  "file": "agents/examples/page-hero.tsx",
@@ -1066,7 +1090,6 @@
1066
1090
  "components": [
1067
1091
  "Delta",
1068
1092
  "SectionCards",
1069
- "SiyahiProvider",
1070
1093
  "useFormat"
1071
1094
  ]
1072
1095
  },
@@ -1183,6 +1206,7 @@
1183
1206
  "Table",
1184
1207
  "TableBody",
1185
1208
  "TableCell",
1209
+ "TableFooter",
1186
1210
  "TableHead",
1187
1211
  "TableHeader",
1188
1212
  "TableRow",
@@ -14,6 +14,8 @@ export interface SectionCard {
14
14
  detail?: React$1.ReactNode;
15
15
  /** In a picker, a tile that cannot be picked, such as one with nothing in it: faded, and its button disabled. */
16
16
  disabled?: boolean;
17
+ /** Marks the tile as the exception on the page: a faint wash of the colour and an outline of it. */
18
+ tone?: "negative" | "warning";
17
19
  }
18
20
  export interface SectionCardsProps {
19
21
  items: SectionCard[];
@@ -14,15 +14,18 @@ const COLUMNS = {
14
14
  6: "@3xl/main:grid-cols-3"
15
15
  };
16
16
  function SectionCards({ items, selected, onSelect }) {
17
- const ruled = useSurface() === "ruled";
17
+ const surface = useSurface();
18
+ const ruled = surface === "ruled";
18
19
  return /* @__PURE__ */ jsx("div", {
20
+ "data-surface": surface,
19
21
  className: cn("@container/main", ruled && "overflow-hidden border-y"),
20
22
  children: /* @__PURE__ */ jsx("div", {
21
- className: cn("grid grid-cols-1", ruled ? "-mt-px -ml-px" : "gap-4", COLUMNS[items.length] ?? COLUMNS[4]),
23
+ className: cn("grid grid-cols-1", ruled ? "-me-px -mb-px bg-card" : surface === "open" ? "gap-4" : "gap-2", COLUMNS[items.length] ?? COLUMNS[4]),
22
24
  children: items.map((item) => /* @__PURE__ */ jsx("div", {
23
25
  "data-slot": "section-card",
24
- className: cn("min-w-0", ruled && "border-t border-l"),
26
+ className: cn("min-w-0", ruled && "border-e border-b"),
25
27
  children: /* @__PURE__ */ jsxs(Card, {
28
+ "data-tone": item.tone,
26
29
  className: cn("@container/card h-full [--card-spacing:--spacing(3)]", onSelect && "relative", item.label === selected && "ring-2 ring-primary!", item.label === selected && ruled && "ring-inset", onSelect && item.disabled && "opacity-60"),
27
30
  children: [
28
31
  onSelect && /* @__PURE__ */ jsx("button", {
@@ -34,7 +37,10 @@ function SectionCards({ items, selected, onSelect }) {
34
37
  className: "absolute inset-0 z-10 rounded-[inherit] outline-none focus-visible:ring-3 focus-visible:ring-ring focus-visible:ring-inset"
35
38
  }),
36
39
  /* @__PURE__ */ jsxs(CardHeader, { children: [
37
- /* @__PURE__ */ jsx(CardDescription, { children: item.label }),
40
+ /* @__PURE__ */ jsx(CardDescription, {
41
+ className: "font-mono text-2xs tracking-wider uppercase",
42
+ children: item.label
43
+ }),
38
44
  /* @__PURE__ */ jsx(CardTitle, {
39
45
  className: "text-2xl font-semibold tabular-nums",
40
46
  children: item.value
@@ -46,7 +52,7 @@ function SectionCards({ items, selected, onSelect }) {
46
52
  ] }),
47
53
  item.trend && /* @__PURE__ */ jsx(CardContent, { children: item.trend }),
48
54
  (item.headline || item.detail) && /* @__PURE__ */ jsxs(CardFooter, {
49
- className: "flex-col items-start gap-1 text-sm",
55
+ className: "flex-col items-start gap-1 font-mono text-2xs tracking-wider uppercase",
50
56
  children: [item.headline && /* @__PURE__ */ jsx("div", {
51
57
  className: "flex flex-wrap gap-2 font-medium",
52
58
  children: item.headline
@@ -62,7 +62,7 @@ function DistributionBar({ segments, valueFormatter, showLegend = true, showValu
62
62
  /* @__PURE__ */ jsx("div", {
63
63
  role: "img",
64
64
  "aria-label": ariaLabel ?? resolved.map((segment) => `${segment.name} ${figure(segment)}`).join(", "),
65
- className: "bg-muted relative h-2.5 w-full overflow-hidden rounded-full",
65
+ className: "bg-muted relative h-2.5 w-full overflow-hidden",
66
66
  onMouseLeave: onHover ? () => onHover(null) : void 0,
67
67
  children: resolved.map((segment) => segment.width > 0 ? /* @__PURE__ */ jsx("div", {
68
68
  "data-slot": "distribution-segment",
@@ -36,12 +36,12 @@ function MetricStrip({ title, description, action, items, footer, className, ...
36
36
  "data-slot": "metric-strip",
37
37
  className: "@container/strip -mx-4 -my-3 overflow-hidden",
38
38
  children: /* @__PURE__ */ jsx("div", {
39
- className: "-mt-px -ml-px flex flex-wrap",
39
+ className: "-me-px -mb-px flex flex-wrap",
40
40
  children: items.map((item) => /* @__PURE__ */ jsxs("div", {
41
41
  "data-slot": "metric-strip-item",
42
- className: cn("flex min-w-0 flex-col gap-1.5 border-t border-l px-4 py-4", columns),
42
+ className: cn("flex min-w-0 flex-col gap-1.5 border-e border-b px-4 py-4", columns),
43
43
  children: [/* @__PURE__ */ jsx("div", {
44
- className: "truncate text-xs text-muted-foreground",
44
+ className: "truncate font-mono text-2xs tracking-wider text-muted-foreground uppercase",
45
45
  children: item.label
46
46
  }), /* @__PURE__ */ jsxs("div", {
47
47
  className: "flex flex-wrap items-end justify-between gap-x-3 gap-y-1",
@@ -19,10 +19,10 @@ function ChangeBar({ value, max, diverging = false, format, className }) {
19
19
  className: "font-medium"
20
20
  }), /* @__PURE__ */ jsxs("div", {
21
21
  "aria-hidden": "true",
22
- className: "relative h-0.75 w-full rounded-full bg-muted",
22
+ className: "relative h-0.75 w-full bg-muted",
23
23
  children: [/* @__PURE__ */ jsx("div", {
24
24
  "data-slot": "change-bar-fill",
25
- className: "absolute inset-y-0 rounded-full bg-foreground",
25
+ className: "absolute inset-y-0 bg-foreground",
26
26
  style: {
27
27
  [side]: diverging ? "50%" : 0,
28
28
  width: `${share * half}%`
@@ -14,9 +14,9 @@ function DataBar({ value, max, tone = "chart", children, className }) {
14
14
  className: cn("flex items-center gap-2", className),
15
15
  children: [/* @__PURE__ */ jsx("div", {
16
16
  "aria-hidden": "true",
17
- className: "h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-muted",
17
+ className: "h-1.5 w-16 shrink-0 overflow-hidden bg-muted",
18
18
  children: /* @__PURE__ */ jsx("div", {
19
- className: cn("h-full rounded-full", fill),
19
+ className: cn("h-full", fill),
20
20
  style: { width: `${share * 100}%` }
21
21
  })
22
22
  }), /* @__PURE__ */ jsx("span", {
@@ -1,25 +1,23 @@
1
1
  import { ReactNode } from "react";
2
2
  //#region src/components/siyahi/page-hero.d.ts
3
3
  export interface PageHeroProps {
4
- /** A short line above the title in the link colour, such as a package or a section name. */
4
+ /** A short line above the title in the mono face and the link colour, such as the app or a section name. */
5
5
  eyebrow?: ReactNode;
6
- /** The page's h1, set large. */
6
+ /** The page's h1. */
7
7
  title: ReactNode;
8
- /** What the page is, in a sentence or two, in the body colour. */
8
+ /** What the page is, in a sentence, in the muted colour. */
9
9
  lead?: ReactNode;
10
- /** Muted detail under the lead: scope, sources, what is left out. */
10
+ /** Scope, sources or what is left out, run on after the lead in the same muted line. */
11
11
  detail?: ReactNode;
12
- /** The one or two places a reader goes next, usually a primary Button and an outline one. */
12
+ /** The one or two places a reader goes next, usually a primary Button and an outline one, at the right. */
13
13
  actions?: ReactNode;
14
- /**
15
- * A large decorative mark drawn faintly in the accent behind the top right, such as the product's logo as an
16
- * SVG that paints with currentColor. It is hidden from assistive technology.
17
- */
14
+ /** A small mark before the eyebrow, such as the product's logo as an SVG that paints with currentColor. */
18
15
  mark?: ReactNode;
19
16
  }
20
17
  /**
21
- * The introduction at the top of a front page or a long explainer: a large title, what the page is, and where to
22
- * go next, on a card with an optional watermark. One per page, above everything else; a section uses PageSection.
18
+ * The masthead at the top of a page: a small mark and eyebrow, the title, one muted line saying what the page is,
19
+ * the actions at the right, and a hairline under it all. One per page, above everything else; a section uses
20
+ * PageSection.
23
21
  */
24
22
  export declare function PageHero({ eyebrow, title, lead, detail, actions, mark }: PageHeroProps): import("react").JSX.Element;
25
23
  //#endregion
@@ -1,40 +1,42 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  //#region src/components/siyahi/page-hero.tsx
3
3
  /**
4
- * The introduction at the top of a front page or a long explainer: a large title, what the page is, and where to
5
- * go next, on a card with an optional watermark. One per page, above everything else; a section uses PageSection.
4
+ * The masthead at the top of a page: a small mark and eyebrow, the title, one muted line saying what the page is,
5
+ * the actions at the right, and a hairline under it all. One per page, above everything else; a section uses
6
+ * PageSection.
6
7
  */
7
8
  function PageHero({ eyebrow, title, lead, detail, actions, mark }) {
8
9
  return /* @__PURE__ */ jsxs("header", {
9
10
  "data-slot": "page-hero",
10
- className: "relative isolate flex flex-col gap-4 overflow-hidden rounded-2xl border bg-card px-6 py-8 sm:px-10 sm:py-10",
11
- children: [
12
- mark && /* @__PURE__ */ jsx("div", {
13
- "aria-hidden": true,
14
- className: "pointer-events-none absolute -top-8 -right-10 -z-10 size-80 text-chart-1 opacity-10 sm:-top-12 sm:right-4 [&>svg]:size-full",
15
- children: mark
16
- }),
17
- eyebrow && /* @__PURE__ */ jsx("p", {
18
- className: "font-mono text-xs text-link",
19
- children: eyebrow
20
- }),
21
- /* @__PURE__ */ jsx("h1", {
22
- className: "text-5xl font-semibold tracking-tighter text-balance sm:text-6xl",
23
- children: title
24
- }),
25
- lead && /* @__PURE__ */ jsx("div", {
26
- className: "max-w-xl text-lg text-pretty",
27
- children: lead
28
- }),
29
- detail && /* @__PURE__ */ jsx("div", {
30
- className: "max-w-xl text-pretty text-muted-foreground",
31
- children: detail
32
- }),
33
- actions && /* @__PURE__ */ jsx("div", {
34
- className: "mt-2 flex flex-wrap gap-2",
35
- children: actions
36
- })
37
- ]
11
+ className: "flex flex-wrap items-end justify-between gap-x-6 gap-y-3 border-b pb-4",
12
+ children: [/* @__PURE__ */ jsxs("div", {
13
+ className: "flex min-w-0 flex-col gap-1",
14
+ children: [
15
+ (mark || eyebrow) && /* @__PURE__ */ jsxs("p", {
16
+ className: "flex items-center gap-1.5 font-mono text-xs text-link",
17
+ children: [mark && /* @__PURE__ */ jsx("span", {
18
+ "aria-hidden": true,
19
+ className: "size-3.5 shrink-0 [&>svg]:size-full",
20
+ children: mark
21
+ }), eyebrow]
22
+ }),
23
+ /* @__PURE__ */ jsx("h1", {
24
+ className: "text-3xl font-semibold tracking-tight text-balance",
25
+ children: title
26
+ }),
27
+ (lead || detail) && /* @__PURE__ */ jsxs("div", {
28
+ className: "text-sm text-pretty text-muted-foreground",
29
+ children: [
30
+ lead,
31
+ lead && detail && " ",
32
+ detail
33
+ ]
34
+ })
35
+ ]
36
+ }), actions && /* @__PURE__ */ jsx("div", {
37
+ className: "flex flex-wrap gap-2",
38
+ children: actions
39
+ })]
38
40
  });
39
41
  }
40
42
  //#endregion
@@ -13,9 +13,11 @@ export interface PanelGridProps {
13
13
  className?: string;
14
14
  }
15
15
  /**
16
- * Data blocks side by side: chart cards, tables, a MetricStrip. On a ruled surface a hairline parts them across and
17
- * down, with a rule above and below the grid; open leaves whitespace between them, and card a gap between boxes.
18
- * Each block's left and top edges carry its rules, and the grid clips the ones on its own edges.
16
+ * Data blocks side by side: chart cards, tables, a MetricStrip. On a ruled surface, the default, the grid is filled
17
+ * with the card colour and a hairline parts the blocks across and down, with a rule above and below the grid and none
18
+ * on its sides; open leaves whitespace between them, and card an 8px gap between boxes. Each block's bottom and end
19
+ * edges carry its rules and the grid clips the ones on its own edges, so an empty cell at the end of a short last row
20
+ * is a blank stretch of the sheet, ruled off like the rest. The theme numbers each block's card title 01, 02.
19
21
  */
20
22
  export declare function PanelGrid({ columns, children, className }: PanelGridProps): import("react").JSX.Element;
21
23
  //#endregion
@@ -10,21 +10,24 @@ const COLUMNS = {
10
10
  4: "@2xl/panels:grid-cols-2 @5xl/panels:grid-cols-4"
11
11
  };
12
12
  /**
13
- * Data blocks side by side: chart cards, tables, a MetricStrip. On a ruled surface a hairline parts them across and
14
- * down, with a rule above and below the grid; open leaves whitespace between them, and card a gap between boxes.
15
- * Each block's left and top edges carry its rules, and the grid clips the ones on its own edges.
13
+ * Data blocks side by side: chart cards, tables, a MetricStrip. On a ruled surface, the default, the grid is filled
14
+ * with the card colour and a hairline parts the blocks across and down, with a rule above and below the grid and none
15
+ * on its sides; open leaves whitespace between them, and card an 8px gap between boxes. Each block's bottom and end
16
+ * edges carry its rules and the grid clips the ones on its own edges, so an empty cell at the end of a short last row
17
+ * is a blank stretch of the sheet, ruled off like the rest. The theme numbers each block's card title 01, 02.
16
18
  */
17
19
  function PanelGrid({ columns = 2, children, className }) {
18
20
  const surface = useSurface();
19
21
  const ruled = surface === "ruled";
20
22
  return /* @__PURE__ */ jsx("div", {
21
23
  "data-slot": "panel-grid",
24
+ "data-surface": surface,
22
25
  className: cn("@container/panels", ruled && "overflow-clip border-y", className),
23
26
  children: /* @__PURE__ */ jsx("div", {
24
- className: cn("grid grid-cols-1", COLUMNS[columns], ruled ? "-mt-px -ml-px" : surface === "open" ? "gap-x-8 gap-y-6" : "gap-4"),
27
+ className: cn("grid grid-cols-1", COLUMNS[columns], ruled ? "-me-px -mb-px bg-card" : surface === "open" ? "gap-x-8 gap-y-6" : "gap-2"),
25
28
  children: Children.map(children, (child) => /* @__PURE__ */ jsx("div", {
26
29
  "data-slot": "panel",
27
- className: cn("min-w-0", ruled && "border-t border-l"),
30
+ className: cn("min-w-0", ruled && "border-e border-b"),
28
31
  children: child
29
32
  }))
30
33
  })
@@ -19,10 +19,10 @@ function RangePosition({ low, high, value, lowLabel = "Low", highLabel = "High",
19
19
  "aria-valuemax": high,
20
20
  "aria-valuenow": value,
21
21
  "aria-valuetext": `${write(value)}, ${f.pct(share, 0)} of the way from ${write(low)} to ${write(high)}`,
22
- className: "relative h-1.5 min-w-12 flex-1 rounded-full bg-muted",
22
+ className: "relative h-1.5 min-w-12 flex-1 bg-muted",
23
23
  children: [/* @__PURE__ */ jsx("div", {
24
24
  "data-slot": "range-fill",
25
- className: "absolute inset-y-0 left-0 rounded-full bg-foreground",
25
+ className: "absolute inset-y-0 left-0 bg-foreground",
26
26
  style: { width: at }
27
27
  }), /* @__PURE__ */ jsx("div", {
28
28
  "data-slot": "range-thumb",
@@ -2,16 +2,18 @@ import { SiyahiLocale } from "../../lib/locale.js";
2
2
  import { ReactNode } from "react";
3
3
  //#region src/components/siyahi/siyahi-provider.d.ts
4
4
  /**
5
- * How a data block sits on the page. "ruled": no box, hairlines between blocks in a PanelGrid. "open": no box and no
6
- * lines, the block's content level with the page's text. "card": shadcn's own card, a filled box with a 1px ring.
5
+ * How a data block sits on the page. "ruled", the default: blocks filled with the card colour and parted by hairlines,
6
+ * with a rule above and below each group and none on its sides. "card": a filled square card in a hairline ring, 8px
7
+ * from the next, for a page of separate blocks. "open": no fill and no lines, the block's content level with the
8
+ * page's text.
7
9
  */
8
- export type Surface = "ruled" | "open" | "card";
10
+ export type Surface = "ruled" | "card" | "open";
9
11
  export interface SiyahiProviderProps {
10
12
  /** Any setting left out keeps its default: en-IN, INR, Asia/Kolkata. */
11
13
  locale?: Partial<SiyahiLocale>;
12
14
  /**
13
15
  * Set it on a nested provider for one part of a page. Left out, a provider keeps the surface around it, so a
14
- * provider that only changes the locale changes nothing else; the outermost default is "card".
16
+ * provider that only changes the locale changes nothing else; the outermost default is "ruled".
15
17
  */
16
18
  surface?: Surface;
17
19
  children: ReactNode;
@@ -4,7 +4,7 @@ import { jsx } from "react/jsx-runtime";
4
4
  import { createContext, use, useMemo } from "react";
5
5
  //#region src/components/siyahi/siyahi-provider.tsx
6
6
  const LocaleContext = createContext(defaultLocale);
7
- const SurfaceContext = createContext("card");
7
+ const SurfaceContext = createContext("ruled");
8
8
  /** Wrap the app once. Carries the locale, the surface and the tooltip provider shadcn asks for. */
9
9
  function SiyahiProvider({ locale, surface, children }) {
10
10
  const { numberLocale, currency, timeZone } = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siyahi/ui",
3
- "version": "0.1.9-next.27",
3
+ "version": "0.1.9-next.29",
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",
@@ -66,6 +66,7 @@
66
66
  "@types/node": "24.19.2",
67
67
  "@types/react": "~19.3.0",
68
68
  "@types/react-dom": "~19.3.0",
69
+ "happy-dom": "^21.0.3",
69
70
  "react": "19.3.1",
70
71
  "react-dom": "19.3.1",
71
72
  "shadcn": "4.22.1",
package/report.css CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  :root {
7
7
  color-scheme: light dark;
8
- --radius: 0.45rem;
8
+ --radius: 0rem;
9
9
  --background: oklch(0.985 0 0);
10
10
  --foreground: oklch(0.145 0 0);
11
11
  --card: oklch(1 0 0);
@@ -21,7 +21,7 @@
21
21
  --accent: oklch(0.97 0 0);
22
22
  --accent-foreground: oklch(0.205 0 0);
23
23
  --destructive: oklch(0.49 0.19 27.325);
24
- --border: oklch(0.922 0 0);
24
+ --border: oklch(0.145 0 0 / 18%);
25
25
  --input: oklch(0.64 0 0);
26
26
  --ring: oklch(0.62 0.16 275);
27
27
  --positive: oklch(0.527 0.145 150.069);
@@ -35,6 +35,8 @@
35
35
  --heat-3: 36%;
36
36
  --heat-4: 48%;
37
37
  --heat-5: 60%;
38
+ --wash: 7%;
39
+ --wash-edge: 45%;
38
40
  --link: oklch(0.51 0.2 275);
39
41
  --chart-1: oklch(0.4 0.17 275);
40
42
  --chart-2: oklch(0.66 0.13 70);
@@ -47,7 +49,7 @@
47
49
  --sidebar-primary-foreground: oklch(0.985 0 0);
48
50
  --sidebar-accent: oklch(0.97 0 0);
49
51
  --sidebar-accent-foreground: oklch(0.45 0.2 275);
50
- --sidebar-border: oklch(0.922 0 0);
52
+ --sidebar-border: oklch(0.145 0 0 / 18%);
51
53
  --sidebar-ring: oklch(0.62 0.16 275);
52
54
  }
53
55
 
@@ -68,7 +70,7 @@
68
70
  --accent: oklch(0.269 0 0);
69
71
  --accent-foreground: oklch(0.922 0 0);
70
72
  --destructive: oklch(0.8 0.1 22.216);
71
- --border: oklch(1 0 0 / 10%);
73
+ --border: oklch(1 0 0 / 20%);
72
74
  --input: oklch(0.62 0 0);
73
75
  --ring: oklch(0.6 0.14 275);
74
76
  --positive: oklch(0.69 0.16 151.711);
@@ -81,6 +83,8 @@
81
83
  --heat-2: 33.75%;
82
84
  --heat-3: 42.5%;
83
85
  --heat-4: 51.25%;
86
+ --wash: 12%;
87
+ --wash-edge: 55%;
84
88
  --link: oklch(0.75 0.128 275);
85
89
  --chart-1: oklch(0.58 0.2 275);
86
90
  --chart-2: oklch(0.88 0.08 75);
@@ -93,7 +97,7 @@
93
97
  --sidebar-primary-foreground: oklch(0.205 0 0);
94
98
  --sidebar-accent: oklch(0.269 0 0);
95
99
  --sidebar-accent-foreground: oklch(0.8 0.1 275);
96
- --sidebar-border: oklch(1 0 0 / 10%);
100
+ --sidebar-border: oklch(1 0 0 / 20%);
97
101
  --sidebar-ring: oklch(0.6 0.14 275);
98
102
  }
99
103
  }
package/styles/siyahi.css CHANGED
@@ -34,6 +34,12 @@
34
34
  * fallback draws and every prose block rewraps when Geist arrives.
35
35
  */
36
36
  --max-width-prose: 43.1em;
37
+ /*
38
+ * 11px, for labels over figures: a stat tile's label and footer lines and a
39
+ * table's column heads, set as `font-mono text-2xs tracking-wider uppercase`.
40
+ */
41
+ --text-2xs: 0.6875rem;
42
+ --text-2xs--line-height: 1rem;
37
43
  --color-background: var(--background);
38
44
  --color-foreground: var(--foreground);
39
45
  --color-card: var(--card);
@@ -72,6 +78,7 @@
72
78
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
73
79
  --color-sidebar-border: var(--sidebar-border);
74
80
  --color-sidebar-ring: var(--sidebar-ring);
81
+ --radius-xs: calc(var(--radius) * 0.3);
75
82
  --radius-sm: calc(var(--radius) * 0.6);
76
83
  --radius-md: calc(var(--radius) * 0.8);
77
84
  --radius-lg: var(--radius);
@@ -83,8 +90,8 @@
83
90
 
84
91
  :root {
85
92
  color-scheme: light dark;
86
- /* shadcn's "small": crisper than its 0.625rem default, still not square. Cards 10px, buttons 7px. */
87
- --radius: 0.45rem;
93
+ /* Square: panels, buttons, fields, badges and toggles alike. Only a dot, an avatar or a slider thumb stays round. */
94
+ --radius: 0rem;
88
95
  --background: oklch(0.985 0 0);
89
96
  --foreground: oklch(0.145 0 0);
90
97
  --card: oklch(1 0 0);
@@ -102,7 +109,8 @@
102
109
  --accent-foreground: oklch(0.205 0 0);
103
110
  /* Deeper than the loss red, so a destructive button's label reads on its own red tint, hovered too. */
104
111
  --destructive: oklch(0.49 0.19 27.325);
105
- --border: oklch(0.922 0 0);
112
+ /* A step firmer than shadcn's 0.922 grey: with square, filled panels the hairlines carry the page's structure. */
113
+ --border: oklch(0.145 0 0 / 18%);
106
114
  /* The edge of a field (input, textarea, select, input group) at 3:1: --border stays faint for dividers. */
107
115
  --input: oklch(0.64 0 0);
108
116
  --ring: oklch(0.62 0.16 275);
@@ -123,6 +131,12 @@
123
131
  --heat-3: 36%;
124
132
  --heat-4: 48%;
125
133
  --heat-5: 60%;
134
+ /*
135
+ * An exception (a stat tile or a table row the page marks with a tone) takes this share of --negative or
136
+ * --warning over the card, and a tile an outline of the tone at --wash-edge.
137
+ */
138
+ --wash: 7%;
139
+ --wash-edge: 45%;
126
140
  --link: oklch(0.51 0.2 275);
127
141
  /*
128
142
  * Indigo, amber, plum, sky, teal: no green or red, so no series reads as a
@@ -145,7 +159,7 @@
145
159
  --sidebar-primary-foreground: oklch(0.985 0 0);
146
160
  --sidebar-accent: oklch(0.97 0 0);
147
161
  --sidebar-accent-foreground: oklch(0.45 0.2 275);
148
- --sidebar-border: oklch(0.922 0 0);
162
+ --sidebar-border: oklch(0.145 0 0 / 18%);
149
163
  --sidebar-ring: oklch(0.62 0.16 275);
150
164
 
151
165
  @variant dark {
@@ -175,7 +189,7 @@
175
189
  --accent-foreground: oklch(0.922 0 0);
176
190
  /* Paler and calmer than the loss red, so a destructive label reads on its red tint over the card, hovered too. */
177
191
  --destructive: oklch(0.8 0.1 22.216);
178
- --border: oklch(1 0 0 / 10%);
192
+ --border: oklch(1 0 0 / 20%);
179
193
  --input: oklch(0.62 0 0);
180
194
  --ring: oklch(0.6 0.14 275);
181
195
  /*
@@ -195,6 +209,13 @@
195
209
  --heat-3: 42.5%;
196
210
  --heat-4: 51.25%;
197
211
  --heat-5: 60%;
212
+ /*
213
+ * As with heat, a faint share of a dark card reads as the card itself; above
214
+ * 12% the amber wash takes --chart-1, a tile's Sparkline or a row's DataBar,
215
+ * under 3:1.
216
+ */
217
+ --wash: 12%;
218
+ --wash-edge: 55%;
198
219
  --link: oklch(0.75 0.128 275);
199
220
  --chart-1: oklch(0.58 0.2 275);
200
221
  --chart-2: oklch(0.88 0.08 75);
@@ -207,7 +228,7 @@
207
228
  --sidebar-primary-foreground: oklch(0.205 0 0);
208
229
  --sidebar-accent: oklch(0.269 0 0);
209
230
  --sidebar-accent-foreground: oklch(0.8 0.1 275);
210
- --sidebar-border: oklch(1 0 0 / 10%);
231
+ --sidebar-border: oklch(1 0 0 / 20%);
211
232
  --sidebar-ring: oklch(0.6 0.14 275);
212
233
  }
213
234
  }
@@ -249,6 +270,31 @@
249
270
  border-color: var(--foreground);
250
271
  }
251
272
 
273
+ /*
274
+ * A toggle group's items sit flush and share their borders, one control with
275
+ * its parts side by side; stock spaces them 8px apart. A group set to
276
+ * spacing={0} already joins its items by dropping their inner borders. The
277
+ * pressed item rises over its neighbours, so its own edge shows on both sides.
278
+ */
279
+ [data-slot="toggle-group"]:not([data-spacing="0"]) {
280
+ gap: 0;
281
+ > [data-slot="toggle-group-item"] {
282
+ &[aria-pressed="true"],
283
+ &[data-pressed] {
284
+ z-index: 1;
285
+ }
286
+ &[data-variant="outline"] + [data-slot="toggle-group-item"] {
287
+ margin-inline-start: -1px;
288
+ }
289
+ }
290
+ &[data-orientation="vertical"]
291
+ > [data-slot="toggle-group-item"][data-variant="outline"]
292
+ + [data-slot="toggle-group-item"] {
293
+ margin-inline-start: 0;
294
+ margin-block-start: -1px;
295
+ }
296
+ }
297
+
252
298
  /*
253
299
  * --input is the edge of a field, where the reader types or picks. A button,
254
300
  * a toggle and a code block are framed by their label or their content, so
@@ -304,6 +350,11 @@
304
350
  border-color: var(--destructive);
305
351
  }
306
352
 
353
+ /* Progress is a bar, square as every bar is; stock draws it as a pill. */
354
+ [data-slot="progress-track"] {
355
+ border-radius: 0;
356
+ }
357
+
307
358
  /*
308
359
  * A slider's unfilled track is outlined in the field edge, so its extent reads
309
360
  * at 3:1; the muted track alone is 1.05:1 on the canvas.
@@ -326,14 +377,84 @@
326
377
  }
327
378
 
328
379
  /*
329
- * A card on the card surface lifts off the tinted canvas with a faint shadow
330
- * under its 1px ring (Tailwind composes --tw-shadow with the ring), and a data
331
- * table inside it drops its own border, so there is one frame, not two.
380
+ * Column heads are labels, in small monospaced capitals; a sort header's
381
+ * button and a data table's column title take the head's type rather than
382
+ * their own. A total row reads as a
383
+ * ledger's: one rule above it and a double rule under it in the text colour,
384
+ * with no band.
332
385
  */
333
- @scope (:root, [data-surface="card"]) to ([data-surface="ruled"], [data-surface="open"]) {
334
- [data-slot="card"] {
335
- --tw-shadow: 0 1px 2px 0 oklch(0 0 0 / 0.06), 0 1px 3px 0 oklch(0 0 0 / 0.04);
386
+ [data-slot="table-head"] {
387
+ @apply font-mono text-2xs tracking-wider uppercase;
388
+ :is([class~="group/button"], [data-slot="column-title"]) {
389
+ font: inherit;
390
+ letter-spacing: inherit;
391
+ text-transform: inherit;
392
+ }
393
+ }
394
+ [data-slot="table-footer"] {
395
+ background: none;
396
+ border-top: 1px solid var(--foreground);
397
+ border-bottom: 3px double var(--foreground);
398
+ font-weight: 600;
399
+ }
400
+
401
+ /*
402
+ * An exception the page marks, a stat tile (SectionCard's tone) or a table
403
+ * row (<TableRow data-tone="negative">), takes a faint wash of its colour over
404
+ * the card, and a tile an outline of it, so the one figure needing attention
405
+ * stands out of a run of neutral ones. Unlayered, to outrank a row's hover band.
406
+ */
407
+ :is([data-slot="card"], [data-slot="table-row"]) {
408
+ &[data-tone="negative"] {
409
+ --exception: var(--negative);
336
410
  }
411
+ &[data-tone="warning"] {
412
+ --exception: var(--warning);
413
+ }
414
+ }
415
+ [data-slot="card"]:is([data-tone="negative"], [data-tone="warning"]) {
416
+ background-color: color-mix(in oklab, var(--exception) var(--wash), var(--card));
417
+ --tw-ring-color: color-mix(in oklab, var(--exception) var(--wash-edge), transparent);
418
+ }
419
+ [data-slot="table-row"]:is([data-tone="negative"], [data-tone="warning"]) {
420
+ background-color: color-mix(in oklab, var(--exception) var(--wash), transparent);
421
+ }
422
+
423
+ /*
424
+ * Panels in a PanelGrid are numbered 01, 02 before their title, in the mono
425
+ * face and the link colour, so the text can point to one. A panel without a
426
+ * card title, such as a MetricStrip with none, takes no number; a block
427
+ * wrapped in its own SiyahiProvider (which draws no box) still counts. The panel
428
+ * counts, not the title: a card header is a size container, whose style
429
+ * containment would keep each title's count to itself.
430
+ */
431
+ [data-slot="panel-grid"] {
432
+ counter-reset: panel;
433
+ }
434
+ [data-slot="panel"]:has(
435
+ > [data-slot="card"] > [data-slot="card-header"] > [data-slot="card-title"],
436
+ > [data-slot="siyahi-provider"] > [data-slot="card"] > [data-slot="card-header"] > [data-slot="card-title"]
437
+ ) {
438
+ counter-increment: panel;
439
+ }
440
+ :is([data-slot="panel"], [data-slot="panel"] > [data-slot="siyahi-provider"])
441
+ > [data-slot="card"]
442
+ > [data-slot="card-header"]
443
+ > [data-slot="card-title"]::before {
444
+ content: counter(panel, decimal-leading-zero);
445
+ @apply font-mono text-2xs font-medium text-link;
446
+ letter-spacing: 0.04em;
447
+ margin-inline-end: 0.7em;
448
+ position: relative;
449
+ top: -0.12em;
450
+ }
451
+
452
+ /*
453
+ * A data table inside a card drops its own border: the card's ring or the
454
+ * ruled page's hairlines already frame it. On an open page, with no frame,
455
+ * it keeps its border.
456
+ */
457
+ @scope (:root, [data-surface="ruled"], [data-surface="card"]) to ([data-surface="open"]) {
337
458
  [data-slot="card"] [data-slot="table-container"] {
338
459
  border-color: transparent;
339
460
  }
@@ -407,34 +528,52 @@
407
528
  }
408
529
 
409
530
  /*
410
- * Surfaces, from SiyahiProvider's surface prop. Card, the default, is
411
- * shadcn's own card on the tinted canvas. Ruled and open take the box off
412
- * every core Card: no fill, no corner, no ring, and no tinted footer band;
413
- * PanelGrid draws the hairlines between ruled blocks. Open also drops the
414
- * card's padding, so a block's content sits level with the page's text. Each
415
- * scope stops at a provider that says otherwise. Unlayered, like the rule above, to
416
- * outrank the core components' utility classes; a picked SectionCards tile
417
- * keeps its ring, which is important.
531
+ * Surfaces, from SiyahiProvider's surface prop. Ruled, the default, keeps
532
+ * every card's fill and drops its ring: PanelGrid and SectionCards fill their
533
+ * grid with the card colour and draw hairlines between the blocks, with a rule
534
+ * above and below the group and none on its left and right edges, and a card
535
+ * that stands alone, such as a MetricStrip or a table's card, takes those
536
+ * rules above and below itself. Card is a filled square card with a hairline
537
+ * ring and no shadow, 8px from the next, for a page of separate blocks. Open
538
+ * takes the box off every core Card: no fill, no ring, no tinted footer band,
539
+ * and no padding, so a block's content sits level with the page's text. Each
540
+ * scope stops at a provider that says otherwise. Unlayered, like the rules
541
+ * above, to outrank the core components' utility classes; a picked
542
+ * SectionCards tile keeps its ring, which is important, and an exception
543
+ * keeps its outline, which is more specific.
418
544
  */
419
- @scope ([data-surface="ruled"], [data-surface="open"]) to ([data-surface="card"]) {
545
+ [data-slot="card"] {
546
+ --tw-ring-color: var(--border);
547
+ }
548
+
549
+ @scope (:root, [data-surface="ruled"]) to ([data-surface="card"], [data-surface="open"]) {
420
550
  [data-slot="card"] {
421
- background: none;
422
- border-radius: 0;
423
551
  --tw-ring-color: transparent;
424
552
  }
425
- [data-slot="card-footer"] {
426
- background: none;
427
- border-top-width: 0;
428
- /* The band's own top padding stacked on the card's gap, and with no band it read as a hole under the figure. */
429
- padding-top: 0;
553
+ /*
554
+ * A card inside a panel or a tile's cell, or nested in another card, is ruled by what holds it; only a holder
555
+ * inside this scope counts, so a ruled provider inside a card starts its own lone cards afresh.
556
+ */
557
+ [data-slot="card"]:not(
558
+ :scope :is([data-slot="panel"], [data-slot="section-card"], [data-slot="card"]) [data-slot="card"]
559
+ ) {
560
+ border-block: 1px solid var(--border);
430
561
  }
431
562
  }
432
563
 
433
564
  @scope ([data-surface="open"]) to ([data-surface="ruled"], [data-surface="card"]) {
434
565
  [data-slot="card"] {
566
+ background: none;
567
+ --tw-ring-color: transparent;
435
568
  --card-spacing: 0px;
436
569
  gap: calc(var(--spacing) * 4);
437
570
  }
571
+ [data-slot="card-footer"] {
572
+ background: none;
573
+ border-top-width: 0;
574
+ /* The band's own top padding stacked on the card's gap, and with no band it read as a hole under the figure. */
575
+ padding-top: 0;
576
+ }
438
577
  }
439
578
 
440
579
  /*