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

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
@@ -51,9 +51,10 @@ a meaning:
51
51
  colour, not separate hues.
52
52
  - **Colour marks exceptions.** The one stat tile or table row that needs a look
53
53
  is marked by the page: `tone: "negative"` or `"warning"` on a `SectionCards`
54
- item, `data-tone="negative"` or `"warning"` on a `TableRow`. It takes a faint
55
- wash of the colour over the card (7% in light, 12% in dark), and a tile an
56
- outline of it. The page decides, never the sign: a falling cost is good news.
54
+ or `MetricStrip` item, `data-tone="negative"` or `"warning"` on a `TableRow`,
55
+ and `getRowTone` on a `DataTableBody`, returning the tone for the row. It
56
+ takes a faint wash of the colour over the card (7% in light, 12% in dark),
57
+ and a tile or figure an outline of it. The page decides, never the sign: a falling cost is good news.
57
58
  Mark one or two; a page where every row is washed marks nothing.
58
59
 
59
60
  ## Surfaces
@@ -100,6 +101,87 @@ An app page opens on its masthead, `PageHero`: a small mark and eyebrow in the
100
101
  mono face and link colour, the title, one muted line saying what the page is,
101
102
  the actions at the right, and a hairline under it. One per page; no hero card.
102
103
 
104
+ ## Words
105
+
106
+ A page is read by someone who acts on its figures. Its words are plain English,
107
+ in sentence case, and explain themselves: a reader needs no other page to know
108
+ what a figure is, where it came from and what to do next. These rules bind what
109
+ a page's reader sees. The catalogue's own notes (the text that introduces a
110
+ component, its rules and its captions) teach the package and are exempt from
111
+ them, and so are the words in a demo that only name a component's variant or API
112
+ ("Ghost", "moneyCompact") or show a bare state (a field disabled with no reason
113
+ given). Everything else in an example is bound like any app's copy, including
114
+ the error, empty, loading and busy text of a state demo. Each rule's example is
115
+ from the catalogue.
116
+
117
+ - **A title states the finding** when the block makes one point: "Search is
118
+ near its slowest of the month, login near its fastest". In an app, work its
119
+ figures out from the data the block draws, so it cannot go stale. When the
120
+ finding changes with the reader's choice (a toggle, a filter, a pick), the
121
+ title names the topic instead: "Revenue by channel and quarter". No full
122
+ stop, no pun or metaphor.
123
+ - **A description says what the figures are**: the measure, its scope and
124
+ period, how it was counted, whatever the title left out. A single fragment
125
+ takes no full stop: "Each month's revenue against its target". Once there are
126
+ two parts, each ends with one: "Each week's balance against the highest it
127
+ had been so far. At zero the bakery was at a new high." Page copy never names
128
+ a component, a token or a prop.
129
+ - **Explain what the reader cannot guess.** Say how to read a mark that is not
130
+ obvious ("Money in above the line, money out below it"), and define a term
131
+ on first use, in the description, a tooltip or a popover: "Churn counts
132
+ customers who did not renew this month."
133
+ - **Say where the figures come from and how fresh they are**: the system or
134
+ report they are read from, "From sar -r", and when, "Prices as of the last
135
+ market close."
136
+ - **Labels and column heads are short nouns, written in full.** The theme sets
137
+ them in small mono capitals, so write them in sentence case and never
138
+ abbreviate to fit: no "vs", "p95", "Qty". Name what the figure is, then its
139
+ period if the block has several: "Cash flow this year". A note on a plot or
140
+ beside a figure is lowercase: "new limit, 50%", "new this year".
141
+ - **Units.** A figure from the formatters carries its unit (₹2.4L, 14.2%,
142
+ 14:30 IST). Any other unit is named once: after a label in brackets, "Hour of
143
+ day (IST)", or after a title with a comma, "Memory in use, GiB". Keep the
144
+ symbols readers know (%, ₹, ms, s, GiB); anything else is a word. A
145
+ difference between two percentages is in points, to one decimal, as no
146
+ formatter writes them: "Sales is 5.6 points under its budget".
147
+ - **Numbers** go through `useFormat()` and `Delta`. In the default locale that
148
+ is Indian grouping (1,53,110), compact rupees (₹3.1Cr) and amounts under a
149
+ lakh in full; a page under another locale keeps its forms. A figure on its
150
+ own (a cell, a tick, a tooltip) carries its sign, as Colour says; a sign you
151
+ write is `+` or the minus `−`, never a hyphen: "+₹21,300", "−₹18,400". In a
152
+ phrase a word may carry the direction instead: "5.6 points under". A range
153
+ takes an en dash, "0–12 GiB"; a multiple takes ×, "about 1.5× a month". A
154
+ missing figure is —, and a page that leaves items out says how many.
155
+ - **Dates and times** read as the formatters write them, in the default locale
156
+ day before month: "20 Oct 2026, 14:30 IST". Every block says its time zone
157
+ once, on its axis title, its description or each time: "Hour of day (IST)". A
158
+ period is in words, "Last 30 days", "April to March"; a financial year is
159
+ "FY26", with the block saying when it ends: "FY26 ends in March 2026". A
160
+ tooltip's heading says more than the tick under it: "Week ending 1 Nov 2026"
161
+ against "Nov".
162
+ - **A status names a state, not a task**, in a few words that match its tone:
163
+ "Paid", "Out for delivery", "Needs review".
164
+ - **The components write the chart and staleness notes**: FORECAST, TODAY and
165
+ "Stale · 3 Nov". The page passes the point or the date and nothing more, and
166
+ says in its description which stretch is a forecast: "November and December
167
+ are a forecast". It never writes a note of its own for the same thing.
168
+ - **A button or menu item says what happens**, as a verb, in one to three
169
+ words: "Export CSV", "Copy order number". A busy button keeps its label as
170
+ "Saving"; a finished one says so briefly, "Copied". An icon-only button names
171
+ its action in `aria-label`: "Refresh". A toggle names its options in words
172
+ ("Cumulative growth", "Last year"); only a range toggle in a card's header
173
+ shortens them, to 1M, 6M, 1Y and All. A control disabled for a reason the
174
+ page does not show says why beside it, as a chart's Log button does.
175
+ - **Empty, loading and error states say what happened and what to do.** Empty
176
+ names what is missing and why: "No holding matches the search." Loading is
177
+ a `Skeleton` in the block's shape; long work names itself, "Orders imported",
178
+ with its progress, and a waiting line ends in one "…". An error's title says
179
+ what happened, "Import failed"; its line says why and what the reader can
180
+ do: "The CSV could not be read. Export it again and retry." A field's error
181
+ states the rule with an example: "A PIN code is six digits, such as 560001".
182
+ - **Address the reader as you and the business as we**: "What you own, what it
183
+ is worth today", "Our store".
184
+
103
185
  ## Labels and totals
104
186
 
105
187
  - **Labels are small monospaced capitals**: a stat tile's label and footer
@@ -12,7 +12,7 @@ export default function BarListLabelsExample() {
12
12
  <Card>
13
13
  <CardHeader>
14
14
  <CardTitle>Orders by category, ranked</CardTitle>
15
- <CardDescription>Full labels and order counts stay readable in narrow panels</CardDescription>
15
+ <CardDescription>Orders placed this month in each category</CardDescription>
16
16
  </CardHeader>
17
17
  <CardContent>
18
18
  <BarList data={categories} valueFormatter={count} />
@@ -21,7 +21,7 @@ export default function CardExample() {
21
21
  <CardAction>
22
22
  <Button variant="outline" size="sm">
23
23
  <DownloadIcon />
24
- CSV
24
+ Export CSV
25
25
  </Button>
26
26
  </CardAction>
27
27
  </CardHeader>
@@ -29,7 +29,7 @@ export default function CardExample() {
29
29
  <p className="text-3xl font-semibold">{money(4512300)}</p>
30
30
  </CardContent>
31
31
  <CardFooter>
32
- <p className="text-muted-foreground">Updated every night at midnight</p>
32
+ <p className="text-muted-foreground">Updated every night at midnight IST</p>
33
33
  </CardFooter>
34
34
  </Card>
35
35
  );
@@ -4,16 +4,16 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle, ChangeGrid,
4
4
  const months = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"];
5
5
  const growth: Omit<ChangeGridRow, "total">[] = [
6
6
  {
7
- label: "FY24",
7
+ label: "FY25",
8
8
  values: [0.031, 0.012, -0.024, 0.008, 0.017, -0.006, 0.041, 0.052, 0.063, -0.038, 0.004, 0.022],
9
9
  },
10
10
  {
11
- label: "FY25",
11
+ label: "FY26",
12
12
  values: [0.018, -0.011, -0.032, 0.014, 0.009, 0, 0.036, 0.047, 0.071, -0.044, -0.008, 0.019],
13
13
  },
14
14
  // The year is under way: months still to come have no figure.
15
15
  {
16
- label: "FY26",
16
+ label: "FY27",
17
17
  values: [0.026, 0.015, -0.018, 0.021, 0.012, 0.007, 0.044, null, null, null, null, null],
18
18
  },
19
19
  ];
@@ -31,7 +31,8 @@ export default function ChangeGridExample() {
31
31
  <CardHeader>
32
32
  <CardTitle>Sales growth by month</CardTitle>
33
33
  <CardDescription>
34
- Each month against the one before. Deeper tints are larger changes; the year compounds its months.
34
+ Each month against the one before, by financial year; FY27 ends in March 2027. Deeper tints are larger
35
+ changes; the year compounds its months.
35
36
  </CardDescription>
36
37
  </CardHeader>
37
38
  <CardContent>
@@ -17,7 +17,7 @@ export default function ChartBarLossesExample() {
17
17
  return (
18
18
  <BarChartCard
19
19
  title="Operating loss by financial year"
20
- description="A loss every year, so every bar runs down from zero"
20
+ description="FY26 ends in March 2026. A loss every year, so every bar runs down from zero."
21
21
  data={data}
22
22
  xKey="fy"
23
23
  series={series}
@@ -33,7 +33,7 @@ export default function ChartBarTeamsExample() {
33
33
  ];
34
34
  const gap = (row: Row) => {
35
35
  const points = Math.abs(row.actual - row.comparison) * 100;
36
- return row.actual >= row.comparison ? `+${points.toFixed(1)} pts over` : `\u2212${points.toFixed(1)} pts under`;
36
+ return `${row.actual >= row.comparison ? "+" : "\u2212"}${points.toFixed(1)} points`;
37
37
  };
38
38
  return (
39
39
  <BarChartCard
@@ -57,7 +57,7 @@ export default function ChartBarTeamsExample() {
57
57
  horizontal
58
58
  data={rows}
59
59
  xKey="team"
60
- xTickDetail={(row) => `${pct(row.actual)} vs ${pct(row.comparison)} · ${gap(row)}`}
60
+ xTickDetail={(row) => `${pct(row.actual)} against ${pct(row.comparison)}, ${gap(row)}`}
61
61
  series={series}
62
62
  valueFormatter={(v) => pct(v)}
63
63
  // A tick format puts the value axis along the bottom of the sideways bars.
@@ -24,7 +24,9 @@ export default function ChartBarYearsExample() {
24
24
  <BarChartCard
25
25
  title="Profit by financial year"
26
26
  description={
27
- year ? `${year} picked; click it again for every year` : "April to March, before tax. Click a year to pick it."
27
+ year
28
+ ? `${year} picked (April to March, before tax). Click it again for every year.`
29
+ : "April to March, before tax; FY26 ends in March 2026. Click a year to pick it."
28
30
  }
29
31
  data={data}
30
32
  xKey="fy"
@@ -26,8 +26,8 @@ export default function ChartBarExample() {
26
26
  title="Revenue by channel and quarter"
27
27
  description={
28
28
  layout === "grouped"
29
- ? "All five chart colours side by side, in series order"
30
- : "All five chart colours stacked into the quarter's total"
29
+ ? "Each channel's revenue, side by side"
30
+ : "Each channel's revenue, stacked into the quarter's total"
31
31
  }
32
32
  action={
33
33
  <ToggleGroup
@@ -22,7 +22,7 @@ export default function ChartLineEventsExample() {
22
22
  return (
23
23
  <LineChartCard
24
24
  title="Memory pressure of a test group, %"
25
- description="avg10, sampled every 15 s"
25
+ description="Share of time the group stalled waiting for memory, as the kernel's 10 s average (avg10), sampled every 15 s, times in IST"
26
26
  data={data}
27
27
  xKey="second"
28
28
  // A numeric axis: ticks fall on whole minutes, 2 s after the first sample.
@@ -14,7 +14,7 @@ const data = [
14
14
 
15
15
  const series: ChartSeries<(typeof data)[number]> = [
16
16
  { key: "used", label: "Used", color: "var(--chart-1)" },
17
- { key: "anon", label: "Anon", color: "var(--chart-2)" },
17
+ { key: "anon", label: "Program memory", color: "var(--chart-2)" },
18
18
  ];
19
19
 
20
20
  const clock = (minute: number) => `${Math.floor(15 + minute / 60)}:${String(minute % 60).padStart(2, "0")}`;
@@ -23,7 +23,7 @@ export default function ChartLineTimelineExample() {
23
23
  return (
24
24
  <LineChartCard
25
25
  title="Memory in use, GiB"
26
- description="sar -r. Used excludes cache; anon is program memory."
26
+ description="From sar -r, times in IST. Used excludes the page cache; program memory is what sar calls anon."
27
27
  data={data}
28
28
  xKey="minute"
29
29
  // Ticks on both ends of the domain: the first sits at the plot's left edge.
@@ -41,6 +41,12 @@ function Money({ amount }: { amount: number }) {
41
41
  return useFormat().money(amount);
42
42
  }
43
43
 
44
+ // The formatters print a bare hyphen, so a change in plain text writes its own + or −.
45
+ function SignedMoney({ amount }: { amount: number }) {
46
+ const sign = amount > 0 ? "+" : amount < 0 ? "\u2212" : "";
47
+ return `${sign}${useFormat().money(Math.abs(amount))}`;
48
+ }
49
+
44
50
  const header = () => <DataTableSortHeader />;
45
51
 
46
52
  const columns: DataTableColumnDef<Store>[] = [
@@ -60,16 +66,16 @@ const columns: DataTableColumnDef<Store>[] = [
60
66
  accessorKey: "change",
61
67
  header,
62
68
  size: 170,
63
- meta: { label: "vs last month", variant: "number" },
69
+ meta: { label: "Change on last month", variant: "number" },
64
70
  cell: ({ row }) => (
65
71
  <DataBar value={row.original.change} max={largestChange} tone="sign">
66
- <Money amount={row.original.change} />
72
+ <SignedMoney amount={row.original.change} />
67
73
  </DataBar>
68
74
  ),
69
75
  },
70
76
  {
71
77
  id: "daily",
72
- header: "10 days",
78
+ header: "Last 10 days",
73
79
  size: 110,
74
80
  enableSorting: false,
75
81
  cell: ({ row }) => {
@@ -36,7 +36,7 @@ const products: Product[] = [
36
36
 
37
37
  const header = () => <DataTableSortHeader />;
38
38
 
39
- // While the Week column is hidden to fit, the week's change rides under the revenue it belongs to.
39
+ // While the change column is hidden to fit, the week's change rides under the revenue it belongs to.
40
40
  function RevenueCell({ product }: { product: Product }) {
41
41
  const format = useFormat();
42
42
  const { compactColumns } = useDataTable<Product>();
@@ -64,7 +64,7 @@ const columns: DataTableColumnDef<Product>[] = [
64
64
  accessorKey: "change",
65
65
  header,
66
66
  size: 110,
67
- meta: { label: "Week", variant: "number" },
67
+ meta: { label: "Change", variant: "number" },
68
68
  cell: ({ row }) => <Delta value={row.original.change} />,
69
69
  },
70
70
  ];
@@ -73,11 +73,8 @@ export default function DataTableCompactExample() {
73
73
  return (
74
74
  <Card>
75
75
  <CardHeader>
76
- <CardTitle>Products on a phone</CardTitle>
77
- <CardDescription>
78
- Product and revenue are the key columns. When the columns do not fit, the others hide from the right, and the
79
- week's change rides under the revenue once its column is gone; All columns brings back the rest
80
- </CardDescription>
76
+ <CardTitle>Revenue by product</CardTitle>
77
+ <CardDescription>This week's revenue for each product, with the change on last week</CardDescription>
81
78
  </CardHeader>
82
79
  <CardContent>
83
80
  <DataTableRoot data={products} columns={columns}>
@@ -85,7 +82,7 @@ export default function DataTableCompactExample() {
85
82
  <DataTableCompactColumnsToggle />
86
83
  <DataTableRowCount unit="products" />
87
84
  </DataTableToolbarSection>
88
- <DataTable aria-label="Products on a phone">
85
+ <DataTable aria-label="Revenue by product">
89
86
  <DataTableHeader />
90
87
  <DataTableBody />
91
88
  </DataTable>
@@ -77,22 +77,22 @@ export default function DataTableFiltersExample() {
77
77
  return (
78
78
  <Card>
79
79
  <CardHeader>
80
- <CardTitle>Orders with compact filters</CardTitle>
81
- <CardDescription>Search stays visible; secondary filters move into a panel in narrow space.</CardDescription>
80
+ <CardTitle>Open orders</CardTitle>
81
+ <CardDescription>Orders packed or out for delivery, by warehouse</CardDescription>
82
82
  </CardHeader>
83
83
  <CardContent>
84
84
  {/* The closed filter panel is absent at first render, so configure its features explicitly. */}
85
85
  <DataTableRoot data={data} columns={columns} config={{ enableFilters: true, enableSorting: true }}>
86
86
  <DataTableToolbarSection>
87
87
  <DataTableSearchFilter
88
- label="Search compact example"
88
+ label="Search orders"
89
89
  placeholder="Search orders…"
90
90
  className="w-full flex-none sm:w-64"
91
91
  />
92
92
  <Filters />
93
93
  <DataTableRowCount unit="orders" />
94
94
  </DataTableToolbarSection>
95
- <DataTable aria-label="Orders with compact filters">
95
+ <DataTable aria-label="Open orders">
96
96
  <DataTableHeader />
97
97
  <DataTableBody />
98
98
  </DataTable>
@@ -36,6 +36,9 @@ const products: Product[] = [
36
36
  { product: "Desk lamp", category: "Lighting", units: 900, revenue: 255600, change: 0.009 },
37
37
  ];
38
38
 
39
+ // The page's own rule for a product that needs a look; a falling figure alone would not be one.
40
+ const tone = (product: Product): "warning" | undefined => (product.units < 50 ? "warning" : undefined);
41
+
39
42
  // One click sorts largest (or Z) first; the title comes from meta.label.
40
43
  const header = () => <DataTableSortHeader />;
41
44
 
@@ -58,7 +61,7 @@ const columns: DataTableColumnDef<Product>[] = [
58
61
  accessorKey: "change",
59
62
  header,
60
63
  size: 110,
61
- meta: { label: "Week", variant: "number" },
64
+ meta: { label: "Change", variant: "number" },
62
65
  cell: ({ row }) => <Delta value={row.original.change} />,
63
66
  },
64
67
  ];
@@ -68,10 +71,7 @@ export default function DataTableExample() {
68
71
  <Card>
69
72
  <CardHeader>
70
73
  <CardTitle>Products</CardTitle>
71
- <CardDescription>
72
- Search every column, filter by category, sort any column in one click; the product stays put when the table
73
- scrolls in wide panels. In narrow panels every column scrolls, leaving room for the figures
74
- </CardDescription>
74
+ <CardDescription>This week's units and revenue for each product, with the change on last week</CardDescription>
75
75
  </CardHeader>
76
76
  <CardContent>
77
77
  <DataTableRoot
@@ -90,7 +90,7 @@ export default function DataTableExample() {
90
90
  </DataTableToolbarSection>
91
91
  <DataTable aria-label="Products">
92
92
  <DataTableHeader />
93
- <DataTableBody />
93
+ <DataTableBody getRowTone={tone} />
94
94
  </DataTable>
95
95
  </DataTableRoot>
96
96
  </CardContent>
@@ -30,7 +30,7 @@ export default function DistributionBarTugExample() {
30
30
  return (
31
31
  <Card>
32
32
  <CardHeader>
33
- <CardTitle>The tug-of-war</CardTitle>
33
+ <CardTitle>Campaigns earned ₹70,000 more than they lost</CardTitle>
34
34
  <CardDescription>Each finished campaign, losses left of break-even and gains right</CardDescription>
35
35
  </CardHeader>
36
36
  <CardContent>
@@ -30,7 +30,7 @@ export default function HeatmapCalendarExample() {
30
30
  <Card>
31
31
  <CardHeader>
32
32
  <CardTitle>Deploys per day</CardTitle>
33
- <CardDescription>The last 12 months; counts use a single-hue fade of --chart-1</CardDescription>
33
+ <CardDescription>The last 12 months; the stronger the colour, the more deploys</CardDescription>
34
34
  </CardHeader>
35
35
  <CardContent>
36
36
  <CalendarHeatmap data={data} labels={{ heatmapLabel: "Deploys per day over the last 12 months" }}>
@@ -14,7 +14,7 @@ export default function HistogramChartExample() {
14
14
  return (
15
15
  <HistogramChartCard
16
16
  title="Checkout response times"
17
- description="The last 2,000 requests, counted in 100 ms buckets. Lines mark the median and the 95th percentile."
17
+ description="The last 2,000 requests, counted in 100 ms buckets. Lines mark the median and the 95th percentile (95 in 100 requests were faster)."
18
18
  values={times}
19
19
  valueFormatter={(ms) => `${count(ms)} ms`}
20
20
  tickFormatter={count}
@@ -8,7 +8,7 @@ export default function MetricStripExample() {
8
8
  label: "Revenue",
9
9
  value: money(4512300),
10
10
  change: 0.031,
11
- note: "vs Sep",
11
+ note: "against September",
12
12
  trend: [3820000, 3950000, 4110000, 4020000, 4380000, 4512300],
13
13
  },
14
14
  {
@@ -25,6 +25,8 @@ export default function MetricStripExample() {
25
25
  change: -61200,
26
26
  changeFormat: "money",
27
27
  trend: [14200, -3800, 9100, 2600, -18400, -61200],
28
+ // The one figure on the strip that needs a look, marked by the page: a falling margin alone would not be.
29
+ tone: "negative",
28
30
  },
29
31
  { label: "Gift cards", value: moneyCompact(38400), change: 0.18, trend: [4100, 0, 12800, 3300, 9900, 8300] },
30
32
  { label: "Products", value: count(42), note: "3 added in Oct", trend: [38, 39, 39, 40, 39, 42] },
@@ -40,7 +40,7 @@ interface Holding {
40
40
  const holdings: Holding[] = [
41
41
  { name: "Nifty 50 index fund", kind: "Equity", status: "On track", tone: "positive", value: 1840000, change: 0.124 },
42
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 },
43
+ { name: "Gold bonds, 2031", kind: "Gold", status: "Needs review", tone: "warning", value: 410500, change: 0.211 },
44
44
  { name: "Short-term debt fund", kind: "Debt", status: "On track", tone: "positive", value: 655000, change: 0.031 },
45
45
  { name: "Small cap fund", kind: "Equity", status: "Below plan", tone: "negative", value: 318200, change: -0.064 },
46
46
  ];
@@ -76,7 +76,7 @@ function Holdings({ rows }: { rows: Holding[] }) {
76
76
  <TableHead>Holding</TableHead>
77
77
  <TableHead>Status</TableHead>
78
78
  <TableHead data-numeric>Value</TableHead>
79
- <TableHead data-numeric>12 months</TableHead>
79
+ <TableHead data-numeric>Change over 12 months</TableHead>
80
80
  </TableRow>
81
81
  </TableHeader>
82
82
  <TableBody>
@@ -43,8 +43,28 @@ const services: Service[] = [
43
43
  service("login", 175, 161, 203),
44
44
  ];
45
45
 
46
+ function useMilliseconds(): (value: number) => string {
47
+ const { count } = useFormat();
48
+ return (value) => `${count(value)} ms`;
49
+ }
50
+
46
51
  function Milliseconds({ value }: { value: number }) {
47
- return `${useFormat().count(value)} ms`;
52
+ return useMilliseconds()(value);
53
+ }
54
+
55
+ function Range({ row }: { row: Service }) {
56
+ const ms = useMilliseconds();
57
+ return (
58
+ <RangePosition
59
+ variant="compact"
60
+ low={row.low}
61
+ high={row.high}
62
+ value={row.today}
63
+ valueFormatter={ms}
64
+ lowLabel="30-day fastest"
65
+ highLabel="30-day slowest"
66
+ />
67
+ );
48
68
  }
49
69
 
50
70
  const header = () => <DataTableSortHeader />;
@@ -54,8 +74,8 @@ const columns: DataTableColumnDef<Service>[] = [
54
74
  {
55
75
  accessorKey: "today",
56
76
  header,
57
- size: 120,
58
- meta: { label: "p95 today", variant: "number" },
77
+ size: 170,
78
+ meta: { label: "95th percentile today", variant: "number" },
59
79
  cell: ({ row }) => <Milliseconds value={row.original.today} />,
60
80
  },
61
81
  {
@@ -63,16 +83,7 @@ const columns: DataTableColumnDef<Service>[] = [
63
83
  header,
64
84
  size: 200,
65
85
  meta: { label: "30-day range", compact: true },
66
- cell: ({ row }) => (
67
- <RangePosition
68
- variant="compact"
69
- low={row.original.low}
70
- high={row.original.high}
71
- value={row.original.today}
72
- lowLabel="30-day fastest"
73
- highLabel="30-day slowest"
74
- />
75
- ),
86
+ cell: ({ row }) => <Range row={row.original} />,
76
87
  },
77
88
  ];
78
89
 
@@ -82,7 +93,8 @@ export default function RangePositionTableExample() {
82
93
  <CardHeader>
83
94
  <CardTitle>Search is near its slowest of the month, login near its fastest</CardTitle>
84
95
  <CardDescription>
85
- Each service's response time today on a track from its fastest day to its slowest; sort to rank them
96
+ Each service's 95th-percentile response time today (95 in 100 requests were faster) on a track from its
97
+ fastest day to its slowest; sort to rank them
86
98
  </CardDescription>
87
99
  </CardHeader>
88
100
  <CardContent>
@@ -30,14 +30,14 @@ const teams: Team[] = [
30
30
  const NEAR = 0.02;
31
31
  const FAR = 0.05;
32
32
 
33
- const pts = (value: number) => `${(value * 100).toFixed(1)} pts`;
33
+ const points = (value: number) => `${(value * 100).toFixed(1)} points`;
34
34
 
35
35
  function statusOf({ weight, target }: Team): TargetStatus {
36
36
  const gap = weight - target;
37
- const size = `${pts(Math.abs(gap))} ${gap > 0 ? "over" : "under"}`;
37
+ const size = `${points(Math.abs(gap))} ${gap > 0 ? "over" : "under"}`;
38
38
  if (Math.abs(gap) <= NEAR) return { kind: "met", label: "On target" };
39
39
  if (Math.abs(gap) <= FAR) return { kind: "missed", label: size };
40
- return { kind: "warning", label: `${size}, review` };
40
+ return { kind: "warning", label: `${size}, needs review` };
41
41
  }
42
42
 
43
43
  export default function TargetRowsExample() {
@@ -13,6 +13,8 @@ export interface MetricStripItem {
13
13
  note?: React$1.ReactNode;
14
14
  /** Recent values, oldest first, drawn as a sparkline beside the figure. */
15
15
  trend?: number[];
16
+ /** Marks the figure as the exception on the page: a faint wash of the colour and an outline of it. */
17
+ tone?: "negative" | "warning";
16
18
  }
17
19
  export interface MetricStripProps extends Omit<React$1.ComponentProps<typeof Card>, "title"> {
18
20
  title?: React$1.ReactNode;
@@ -39,6 +39,7 @@ function MetricStrip({ title, description, action, items, footer, className, ...
39
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
+ "data-tone": item.tone,
42
43
  className: cn("flex min-w-0 flex-col gap-1.5 border-e border-b px-4 py-4", columns),
43
44
  children: [/* @__PURE__ */ jsx("div", {
44
45
  className: "truncate font-mono text-2xs tracking-wider text-muted-foreground uppercase",
@@ -40,8 +40,15 @@ export interface DataTableBodyProps<TData extends RowData> {
40
40
  * Wrap the callback in `useCallback` so memoized rows don't re-render.
41
41
  */
42
42
  renderRowContextMenu?: (row: TData) => React.ReactNode;
43
+ /**
44
+ * Mark the row that needs a look, such as a holding in deep loss: return
45
+ * `"negative"` or `"warning"` and the row takes a faint wash of that
46
+ * colour, as a `TableRow` with `data-tone` does; `undefined` leaves it
47
+ * plain. The page decides, never the sign. Mark one or two rows.
48
+ */
49
+ getRowTone?: (row: TData) => "negative" | "warning" | undefined;
43
50
  }
44
- export declare function DataTableBody<TData extends RowData>({ children, className, onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, onRowClick, getRowMemoKey, renderRowContextMenu }: DataTableBodyProps<TData>): React.JSX.Element;
51
+ export declare function DataTableBody<TData extends RowData>({ children, className, onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, onRowClick, getRowMemoKey, renderRowContextMenu, getRowTone }: DataTableBodyProps<TData>): React.JSX.Element;
45
52
  export declare namespace DataTableBody {
46
53
  var displayName: string;
47
54
  }
@@ -27,7 +27,7 @@ const DataTableHeader = React.memo(function DataTableHeader({ className, sticky
27
27
  const flexColumnIds = resolveFlexColumnIds(table, !isCompact);
28
28
  const columnSizing = table.state.columnSizing;
29
29
  return /* @__PURE__ */ jsx(TableHeader, {
30
- className: cn(sticky && "sticky top-0 z-30 bg-card", sticky && "after:absolute after:right-0 after:bottom-0 after:left-0 after:h-px after:bg-border", className),
30
+ className: cn(sticky && "sticky top-0 z-30 bg-(--table-fill)", sticky && "after:absolute after:right-0 after:bottom-0 after:left-0 after:h-px after:bg-border", className),
31
31
  children: headerGroups.map((headerGroup) => /* @__PURE__ */ jsx(TableRow, { children: headerGroup.headers.map((header) => {
32
32
  const isFlex = flexColumnIds.has(header.column.id);
33
33
  const headerStyle = {
@@ -62,7 +62,7 @@ const DataTableHeader = React.memo(function DataTableHeader({ className, sticky
62
62
  DataTableHeader.displayName = "DataTableHeader";
63
63
  /** Fade-pulse animation applied to a flashing cell (keyframe in the provider). */
64
64
  const FLASH_ANIMATION = "niko-row-flash 1.2s ease-out";
65
- const BodyRow = React.memo(function BodyRow({ row, displayIndex, expandColumnId, isCompact, isClickable, isExpanded, isSelected, columnWidths, isRowFlashing, flashingCellKeys, renderRowContextMenu }) {
65
+ const BodyRow = React.memo(function BodyRow({ row, displayIndex, expandColumnId, isCompact, isClickable, isExpanded, isSelected, columnWidths, isRowFlashing, flashingCellKeys, renderRowContextMenu, tone }) {
66
66
  const expandCell = isExpanded && expandColumnId ? row.getAllCells().find((c) => c.column.id === expandColumnId) : void 0;
67
67
  const visibleCells = row.getVisibleCells();
68
68
  const rowElement = /* @__PURE__ */ jsx(TableRow, {
@@ -72,8 +72,10 @@ const BodyRow = React.memo(function BodyRow({ row, displayIndex, expandColumnId,
72
72
  "data-parity": displayIndex % 2 === 0 ? "even" : "odd",
73
73
  "data-expanded": isExpanded ? "true" : void 0,
74
74
  "data-state": isSelected ? "selected" : void 0,
75
+ "data-tone": tone,
75
76
  className: cn(isClickable && "cursor-pointer", "group data-[context-menu-open]:bg-muted/50"),
76
77
  children: visibleCells.map((cell) => {
78
+ const pinned = !isCompact && cell.column.getIsPinned();
77
79
  const flashing = isRowFlashing || flashingCellKeys.has(flashCellKey(row.id, cell.column.id));
78
80
  const cellStyle = {
79
81
  width: columnWidths.get(cell.column.id),
@@ -84,8 +86,9 @@ const BodyRow = React.memo(function BodyRow({ row, displayIndex, expandColumnId,
84
86
  "data-flash": flashing ? "true" : void 0,
85
87
  "data-col-id": cell.column.id,
86
88
  "data-numeric": cell.column.columnDef.meta?.variant === "number" || void 0,
89
+ "data-pinned": pinned || void 0,
87
90
  style: cellStyle,
88
- className: cn(!cell.getIsGrouped() && "truncate", !isCompact && cell.column.getIsPinned() && "bg-linear-to-r group-hover:from-muted/50 group-hover:to-muted/50 group-data-[context-menu-open]:from-muted/50 group-data-[context-menu-open]:to-muted/50 group-data-[state=selected]:from-muted group-data-[state=selected]:to-muted"),
91
+ className: cn(!cell.getIsGrouped() && "truncate", pinned && "bg-linear-to-r group-hover:from-muted/50 group-hover:to-muted/50 group-data-[context-menu-open]:from-muted/50 group-data-[context-menu-open]:to-muted/50 group-data-[state=selected]:from-muted group-data-[state=selected]:to-muted"),
89
92
  children: renderCellContent(cell)
90
93
  }, cell.id);
91
94
  })
@@ -102,7 +105,7 @@ const BodyRow = React.memo(function BodyRow({ row, displayIndex, expandColumnId,
102
105
  }) })] });
103
106
  });
104
107
  BodyRow.displayName = "BodyRow";
105
- function DataTableBody({ children, className, onScroll, onScrolledTop, onScrolledBottom, scrollThreshold = 50, onRowClick, getRowMemoKey, renderRowContextMenu }) {
108
+ function DataTableBody({ children, className, onScroll, onScrolledTop, onScrolledBottom, scrollThreshold = 50, onRowClick, getRowMemoKey, renderRowContextMenu, getRowTone }) {
106
109
  const { table, columns, isLoading, flashingRowIds, flashingCellKeys, registerRowScroller, headerMinWidths, isCompact } = useDataTable();
107
110
  const { rows } = table.getRowModel();
108
111
  const containerRef = React.useRef(null);
@@ -245,7 +248,8 @@ function DataTableBody({ children, className, onScroll, onScrolledTop, onScrolle
245
248
  rowMemoKey: getRowMemoKey ? getRowMemoKey(row.original) : "",
246
249
  isRowFlashing: flashingRowIds.has(row.id),
247
250
  flashingCellKeys,
248
- renderRowContextMenu: resolvedRenderRowContextMenu
251
+ renderRowContextMenu: resolvedRenderRowContextMenu,
252
+ tone: getRowTone?.(row.original)
249
253
  }, row.id)) : null, children]
250
254
  });
251
255
  }
@@ -16,7 +16,7 @@ const getCommonPinningStyles = (column, isHeader = false, pinningEnabled = true)
16
16
  maxWidth: columnSize,
17
17
  flexShrink: 0,
18
18
  zIndex: isHeader ? 20 : 10,
19
- backgroundColor: "var(--card)",
19
+ backgroundColor: "var(--table-fill)",
20
20
  boxShadow: isLeft ? "inset -1px 0 0 0 var(--border)" : "inset 1px 0 0 0 var(--border)"
21
21
  };
22
22
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siyahi/ui",
3
- "version": "0.1.9-next.30",
3
+ "version": "0.1.9-next.32",
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/styles/siyahi.css CHANGED
@@ -555,12 +555,13 @@ svg :is(rect, circle, path)[tabindex]:focus-visible,
555
555
  }
556
556
 
557
557
  /*
558
- * An exception the page marks, a stat tile (SectionCard's tone) or a table
559
- * row (<TableRow data-tone="negative">), takes a faint wash of its colour over
560
- * the card, and a tile an outline of it, so the one figure needing attention
558
+ * An exception the page marks, a stat tile (SectionCard's tone), a MetricStrip
559
+ * figure (its item's tone) or a table row (<TableRow data-tone="negative">, a
560
+ * DataTableBody's getRowTone), takes a faint wash of its colour over the card,
561
+ * and a tile or figure an outline of it, so the one figure needing attention
561
562
  * stands out of a run of neutral ones. Unlayered, to outrank a row's hover band.
562
563
  */
563
- :is([data-slot="card"], [data-slot="table-row"]) {
564
+ :is([data-slot="card"], [data-slot="metric-strip-item"], [data-slot="table-row"]) {
564
565
  &[data-tone="negative"] {
565
566
  --exception: var(--negative);
566
567
  }
@@ -568,12 +569,33 @@ svg :is(rect, circle, path)[tabindex]:focus-visible,
568
569
  --exception: var(--warning);
569
570
  }
570
571
  }
571
- [data-slot="card"]:is([data-tone="negative"], [data-tone="warning"]) {
572
+ :is([data-slot="card"], [data-slot="metric-strip-item"]):is([data-tone="negative"], [data-tone="warning"]) {
572
573
  background-color: color-mix(in oklab, var(--exception) var(--wash), var(--card));
574
+ }
575
+ [data-slot="card"]:is([data-tone="negative"], [data-tone="warning"]) {
573
576
  --tw-ring-color: color-mix(in oklab, var(--exception) var(--wash-edge), transparent);
574
577
  }
578
+ /*
579
+ * A figure's outline lies on the four hairlines around it, as a tile's ring does: its neighbours' rules before and
580
+ * above it, and its own end and bottom rules, which sit outside the padding box the inset counts from.
581
+ */
582
+ [data-slot="metric-strip-item"]:is([data-tone="negative"], [data-tone="warning"]) {
583
+ position: relative;
584
+ &::after {
585
+ content: "";
586
+ position: absolute;
587
+ inset: -1px;
588
+ border: 1px solid color-mix(in oklab, var(--exception) var(--wash-edge), transparent);
589
+ pointer-events: none;
590
+ }
591
+ }
592
+ /* A pinned cell paints the card under itself (niko-table's lib/styles), so it lays the row's wash over that. */
575
593
  [data-slot="table-row"]:is([data-tone="negative"], [data-tone="warning"]) {
576
- background-color: color-mix(in oklab, var(--exception) var(--wash), transparent);
594
+ --row-wash: color-mix(in oklab, var(--exception) var(--wash), transparent);
595
+ background-color: var(--row-wash);
596
+ & > [data-pinned] {
597
+ background-image: linear-gradient(var(--row-wash), var(--row-wash));
598
+ }
577
599
  }
578
600
 
579
601
  /*
@@ -615,6 +637,14 @@ svg :is(rect, circle, path)[tabindex]:focus-visible,
615
637
  border-color: transparent;
616
638
  }
617
639
  }
640
+ /*
641
+ * The opaque fill a data table's sticky header and pinned cells paint, so rows
642
+ * scrolling under them stay hidden: the colour under the table, the card it
643
+ * sits in or, on an open surface, whose cards have no fill, the page.
644
+ */
645
+ [data-slot="table-container"] {
646
+ --table-fill: var(--card);
647
+ }
618
648
 
619
649
  /*
620
650
  * A series in a forecast is drawn twice (LineChartCard, AreaChartCard): solid
@@ -804,6 +834,9 @@ div[data-slot="sidebar"]:not([data-side], [data-mobile]) {
804
834
  }
805
835
 
806
836
  @scope ([data-surface="open"]) to ([data-surface="ruled"], [data-surface="card"]) {
837
+ [data-slot="table-container"] {
838
+ --table-fill: var(--background);
839
+ }
807
840
  [data-slot="card"] {
808
841
  background: none;
809
842
  --tw-ring-color: transparent;