zz-meridian 0.19.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/payload/CHANGELOG.md +21 -0
  3. package/payload/docs/agents.md +1 -1
  4. package/payload/docs/data-display.md +1 -1
  5. package/payload/package.json +1 -1
  6. package/payload/scripts/interactions.ts +18 -8
  7. package/payload/src/components/base/surface/index.tsx +11 -3
  8. package/payload/src/components/charts/column-chart/README.md +3 -2
  9. package/payload/src/components/charts/column-chart/index.tsx +15 -4
  10. package/payload/src/components/charts/column-chart/preview.tsx +13 -0
  11. package/payload/src/components/charts/scale.ts +11 -6
  12. package/payload/src/components/patterns/activity-feed/README.md +2 -2
  13. package/payload/src/components/patterns/activity-feed/index.tsx +7 -3
  14. package/payload/src/components/patterns/activity-feed/preview.tsx +1 -0
  15. package/payload/src/components/patterns/assistant/README.md +3 -3
  16. package/payload/src/components/patterns/assistant/index.tsx +29 -9
  17. package/payload/src/components/patterns/assistant/preview.tsx +2 -1
  18. package/payload/src/components/patterns/data-table/index.tsx +2 -2
  19. package/payload/src/components/ui/table/README.md +1 -1
  20. package/payload/src/components/ui/table/index.tsx +7 -0
  21. package/payload/src/lib/shared-context.ts +5 -0
  22. package/payload/src/views/home-context.ts +8 -1
  23. package/payload/tests/activity-feed-unknown.test.tsx +22 -0
  24. package/payload/tests/assistant-questions.test.tsx +68 -0
  25. package/payload/tests/chart-scale.test.ts +14 -0
  26. package/payload/tests/column-chart-running-narrow.test.tsx +32 -0
  27. package/payload/tests/column-chart-stacked.test.tsx +7 -0
  28. package/payload/tests/column-chart-whole.test.tsx +19 -0
  29. package/payload/tests/table-hide-below-pad.test.tsx +18 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,27 @@
2
2
 
3
3
  Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
4
4
 
5
+ ## [0.20.0] · 2026-10-20
6
+
7
+ ### New
8
+
9
+ - **The assistant panel offers the page's questions** ([#69](https://github.com/zhixuan312/zz-meridian/issues/69)). `SharedContext.questions` holds the few questions a person would ask first about what a view shows, in their words. While the thread is empty, the console's assistant offers them under its line, as buttons under "Questions to start with", and pressing one sends it as the person's own. A page that shares none keeps the line alone, and the model is never told them. The sample's Home offers questions only about figures the reader may read.
10
+ - **`ColumnChart` takes `whole`** ([#72](https://github.com/zhixuan312/zz-meridian/issues/72)) for a chart of counts: every level is a whole number, so a top of 1 is ticked 0 and 1, with no unlabelled gridline at 0.5. `niceTicks` and `ticksBetween` in `@/components/charts/scale` take the same as a last argument.
11
+ - **`ActivityEvent.unknown`** ([#68](https://github.com/zhixuan312/zz-meridian/issues/68)) says the record does not hold who acted. The feed draws an empty disc in a dashed hairline instead of initials, so nobody unknown reads as a person, and the line still reads the product's words for them (`actor`, such as "Someone not recorded").
12
+
13
+ ### Changed
14
+
15
+ - **A toned stacked chart tells two usual parts apart** ([#67](https://github.com/zhixuan312/zz-meridian/issues/67)). Where a part already is the neutral (`tone: 'neutral'`), a part with neither a tone nor a slot, Other among them, is drawn in the quieter `chart-neutral`, where it was `chart-neutral-strong` like the neutral beside it. Other beside categorical slots is unchanged. To keep two usual parts apart, give the one that is the usual case `tone: 'neutral'` and leave the other untoned.
16
+ - **The column drawn last takes the card's padding when the last column is hidden** ([#70](https://github.com/zhixuan312/zz-meridian/issues/70)). The Table padded the last cell of every row, so where the last column's `hideBelow` dropped it, the column drawn last kept `px-4` and its cells ran past the card's padding edge. Head and body cells with `hideBelow` now carry `data-hide-below`, and the Table pads the cell drawn last at each width. A cell you write yourself with `hideBelow` passes it to `TableCell` or `TableHeader`, as before.
17
+ - **A running period with no data reads "So far" where its column is too narrow** ([#71](https://github.com/zhixuan312/zz-meridian/issues/71)). A Column chart labelled it "No data so far" and cut that to "No data…" in a phone's column, which reads as no data. At rest, in a column too narrow for the words, it reads "So far" above the dashed outline; pointed at, in the readout, the announcement and the table it says them whole.
18
+ - **The assistant panel's Clear and Close show their words, and on a phone the sheet is the whole width** (#69). Clear and Close are small ghost buttons with an icon and the word, still named "Clear conversation" and "Close assistant". Below `sm` the sheet covers the page: the 48px strip of cut page words it left beside it is gone. Escape and Close still close it.
19
+
20
+ ## [0.19.3] · 2026-10-19
21
+
22
+ ### Fixed
23
+
24
+ - **The presses suite finds a control again when its name has gained a live count** ([#66](https://github.com/zhixuan312/zz-meridian/issues/66)). `ShellTools` names its button `Alerts`, then `Alerts, 3 new` once its read answers; on a loaded machine the first load was listed before that read and the fresh load after it, so 0.19.2's find-by-name looked for `Alerts`, waited out the hang bound and reported "was listed but could not be found again". When the exact name is gone, the suite now finds the control by the part of its name before the first comma, and which of the controls that share it it is. Names stay as they are: a count in the accessible name is still how a screen reader hears it.
25
+
5
26
  ## [0.19.2] · 2026-10-19
6
27
 
7
28
  ### Fixed
@@ -24,7 +24,7 @@ Every view's state lives in its address: the period, the filters, the sort, the
24
24
 
25
25
  ### 2. Legible
26
26
 
27
- Every view tells both agents what is on screen in one shared context (decision 0011), and tells them again when that changes: a filter, the period, the day the Meridian points at. "Why did this spike?" then has a referent, and the agent answers about the day the person is looking at, not the one it guessed. The context is the whole state every time: its address and scope, how fresh the data is, what the person points at, each figure with its unit, its change and its definition, what code computed from them (a baseline, a deviation, a share, with the address of its evidence), and what the data cannot say.
27
+ Every view tells both agents what is on screen in one shared context (decision 0011), and tells them again when that changes: a filter, the period, the day the Meridian points at. "Why did this spike?" then has a referent, and the agent answers about the day the person is looking at, not the one it guessed. The context is the whole state every time: its address and scope, how fresh the data is, what the person points at, each figure with its unit, its change and its definition, what code computed from them (a baseline, a deviation, a share, with the address of its evidence), and what the data cannot say. It may also carry `questions`, the few a person would ask first about what the view shows, in their words: the console's assistant offers them while its thread is empty, and they are never in the text a model is told. A view offers only questions about figures the reader may read (Home asks about revenue only for someone who sees it).
28
28
 
29
29
  **In the system:** a view's context is built by a pure function beside it (`src/views/home-context.ts`) and passed to `useShareView(context)`. In an MCP host that sends `ui/update-model-context`, debounced, once a host is connected; on the console the assistant reads the same context with the person's next question. `contextText` writes it as labelled lines for a model; the structured part mirrors them, but the text stands alone, because a host need not give the model the structured part.
30
30
 
@@ -36,7 +36,7 @@ Two measures of different size are two charts on one Meridian, not one chart wit
36
36
  - **Magnitude**: one hue, light to dark, built from the accent and the surface (the heatmap).
37
37
  - **Magnitude by rank**: a Heatmap's step is a cell's share of the largest cell (`max`) unless one outlier would leave every other cell at the lowest step. Then it is ranked (`scale="quantile"`): the cells above zero take five steps of equal count. A step no longer says how much, so the legend's upper end reads the value the top step starts at ("900 or more") in place of "More", and the readout and the hidden table give each cell's exact value.
38
38
  - **State**: positive, warning, critical, always with a word or an icon.
39
- - **Parts of one whole**: one hue. The parts share the accent, mixed into the surface, deepest at the bottom and paler upward (the stacked Trend chart and the stacked Column chart take the same mix), with a 1px hairline of the card between neighbours and at most four parts, the fourth being Other. When the parts are outcomes they carry a tone instead: the usual outcome is neutral (`chart-neutral-strong`), one that needs somebody is warning or critical, the accent never stands beside a tone, and the positive colour is never the usual case. Parts that are identities (teams), or two or more usual outcomes that one neutral would make one, take the categorical slots instead, in order (`series-1` onward, as Identity above), and a part with no slot is neutral. Status colours stay out of such a chart, as the accent stays out of a toned one, since coral and saffron read as a warning beside a real one: a chart with an outcome that needs somebody is a toned chart.
39
+ - **Parts of one whole**: one hue. The parts share the accent, mixed into the surface, deepest at the bottom and paler upward (the stacked Trend chart and the stacked Column chart take the same mix), with a 1px hairline of the card between neighbours and at most four parts, the fourth being Other. When the parts are outcomes they carry a tone instead: the usual outcome is neutral (`chart-neutral-strong`), one that needs somebody is warning or critical, a second usual part beside the neutral one (Other among them) takes no tone and is drawn in the quieter `chart-neutral`, so the two are told apart and Other stays the quietest, the accent never stands beside a tone, and the positive colour is never the usual case. Parts that are identities (teams), or two or more usual outcomes that one neutral would make one, take the categorical slots instead, in order (`series-1` onward, as Identity above), and a part with no slot is neutral. Status colours stay out of such a chart, as the accent stays out of a toned one, since coral and saffron read as a warning beside a real one: a chart with an outcome that needs somebody is a toned chart.
40
40
 
41
41
  Text never wears a series colour; values and labels stay in ink.
42
42
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zz-meridian-template",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
@@ -46,13 +46,19 @@ const HANG_BOUND_MS = 5000;
46
46
  /**
47
47
  * The page's controls and how each is named: its accessible name and the roots it sits in. A control is found again by
48
48
  * that name (and which of the controls with it), never by its place in the walk: a later load can draw the controls in
49
- * another order or fewer of them for a while, a table still loading or rows a concurrent write changed (#65).
49
+ * another order or fewer of them for a while, a table still loading or rows a concurrent write changed (#65). A name that
50
+ * carries a live part after a comma ("Alerts, 3 new") is found again by the part before it once the exact name is gone (#66).
50
51
  */
51
52
  const CONTROLS = `
52
53
  const deepClosest = (el, sel) => { for (let n = el; n; n = deepParent(n)) if (n.matches(sel)) return n; return null; };
53
54
  const vis = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && !deepClosest(el, '[aria-hidden="true"],[inert],.sr-only'); };
54
55
  const hosts = (el) => { const out = []; for (let r = el.getRootNode(); r instanceof ShadowRoot; r = r.host.getRootNode()) out.unshift(r.host.localName); return out; };
55
- const nameOf = (b) => { const h = hosts(b); return (b.getAttribute('aria-label') || deepText(b) || b.title || '').trim().replace(/\\s+/g, ' ').slice(0, 60) + (h.length ? ' (in ' + h.join(' > ') + ')' : ''); };
56
+ const label = (b) => (b.getAttribute('aria-label') || deepText(b) || b.title || '').trim().replace(/\\s+/g, ' ').slice(0, 60);
57
+ const within = (b) => { const h = hosts(b); return h.length ? ' (in ' + h.join(' > ') + ')' : ''; };
58
+ const nameOf = (b) => label(b) + within(b);
59
+ // What the control is without what it says about now: the name before its first comma, so "Alerts, 3 new" is still the
60
+ // "Alerts" listed before the read answered (#66).
61
+ const keyOf = (b) => label(b).split(',')[0].trim() + within(b);
56
62
  // A control already chosen is not pressed: choosing it again changes nothing, which is not a dead control.
57
63
  const chosen = (el) => ['aria-pressed', 'aria-checked', 'aria-selected'].some((a) => el.getAttribute(a) === 'true') || el.getAttribute('aria-current') === 'page' || ['on', 'active', 'checked'].includes(el.getAttribute('data-state'));
58
64
  const pressable = (every) => every.filter((b) => b.matches('button, [role="button"], [role="tab"], [role="switch"], [role="checkbox"], [role="radio"], [role="menuitem"]'))
@@ -62,13 +68,15 @@ const LIST = `(() => {${CONTROLS}
62
68
  const every = deepAll(document.body);
63
69
  return {
64
70
  controls: pressable(every).map(nameOf),
71
+ keys: pressable(every).map(keyOf),
65
72
  links: every.filter((a) => a.matches('a[href]') && vis(a)).map((a) => a.getAttribute('href')),
66
73
  };
67
74
  })()`;
68
75
 
69
76
  /** Scroll the control into view, check nothing covers it, and start counting what changes. */
70
- const ARM = (name: string, nth: number) => `(() => {${CONTROLS}
71
- const el = pressable(deepAll(document.body)).filter((b) => nameOf(b) === ${JSON.stringify(name)})[${nth}]; if (!el) return null;
77
+ const ARM = (name: string, nth: number, key: string, keyNth: number) => `(() => {${CONTROLS}
78
+ const all = pressable(deepAll(document.body));
79
+ const el = all.filter((b) => nameOf(b) === ${JSON.stringify(name)})[${nth}] ?? all.filter((b) => keyOf(b) === ${JSON.stringify(key)})[${keyNth}]; if (!el) return null;
72
80
  el.scrollIntoView({ block: 'center', inline: 'nearest' });
73
81
  const r = el.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
74
82
  const hit = document.elementFromPoint(x, y);
@@ -120,8 +128,8 @@ for (const width of WIDTHS) {
120
128
  const touch = width < 600;
121
129
  for (const route of ROUTES) {
122
130
  // The page's late chrome first (scripts/lib/settle.ts): a control that arrives after the list is taken is never pressed, and the count reads as if it had been.
123
- const fresh = async () => { await page.open(base + route, { width, theme: 'dark', cookie }); await inject(route); await settle(page); return page.eval<{ controls: string[]; links: string[] }>(LIST); };
124
- const { controls, links } = await fresh();
131
+ const fresh = async () => { await page.open(base + route, { width, theme: 'dark', cookie }); await inject(route); await settle(page); return page.eval<{ controls: string[]; keys: string[]; links: string[] }>(LIST); };
132
+ const { controls, keys, links } = await fresh();
125
133
  // A link is followed as a browser follows it: its href as the page writes it, resolved against the page. Skipping every
126
134
  // href that did not start with "/" let a relative link that answered 404 pass (/system/system/contributing).
127
135
  for (const href of new Set(links)) {
@@ -133,11 +141,13 @@ for (const width of WIDTHS) {
133
141
  if (status >= 400) failures.push(`${route}: link ${href}${path === href ? '' : ` (${path})`} answers ${status}`);
134
142
  }
135
143
  for (let i = 0; i < controls.length; i++) {
136
- // Which of the controls named alike this is, in the order the first load listed them.
144
+ // Which of the controls named alike this is, in the order the first load listed them; and, for a name that has
145
+ // changed since (a count that arrived), which of the controls that are the same thing.
137
146
  const nth = controls.slice(0, i).filter((c) => c === controls[i]).length;
147
+ const keyNth = keys.slice(0, i).filter((k) => k === keys[i]).length;
138
148
  type Armed = { x: number; y: number; covered?: string } | null;
139
149
  // A document replaced since the last read has no helper: that is a page to open again, never a crash (#64).
140
- const arm = () => page.eval<Armed>(ARM(controls[i], nth)).catch((): Armed => null);
150
+ const arm = () => page.eval<Armed>(ARM(controls[i], nth, keys[i], keyNth)).catch((): Armed => null);
141
151
  let armed = await arm();
142
152
  // Something left open or a toast in the way: start the page again and try once more. A control not drawn yet on the
143
153
  // new load (a list still loading) is waited for up to the hang bound before it is called gone.
@@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState, type R
4
4
  import { app } from '@/app.config';
5
5
  import { HostBridge, type DisplayMode, type HostContext } from '@/lib/host';
6
6
  import { toast } from '@/components/ui/toast';
7
+ import type { SharedContext } from '@/lib/shared-context';
7
8
 
8
9
  /**
9
10
  * Which surface a view is on, and what it may ask of its host. Components read `useSurface()` and never touch the
@@ -26,10 +27,14 @@ export type Surface = {
26
27
  openLink: (url: string) => void;
27
28
  };
28
29
 
29
- /** The context the console's view on screen last shared, and the path it was shared on (decision 0011). */
30
- let published: { path: string; text: string } | null = null;
30
+ /** The context the console's view on screen last shared, the path it was shared on and its starter questions (decision 0011). */
31
+ let published: { path: string; text: string; questions?: string[] } | null = null;
32
+ const listeners = new Set<() => void>();
31
33
  /** What the console's assistant reads with a question: the shared context of the view on `path`, or `null` when that page shares none. */
32
34
  export const sharedContextOn = (path: string): string | null => (published?.path === path ? published.text : null);
35
+ /** The questions the view on `path` offers to start a thread with, for `useSyncExternalStore`; none when it offers none. */
36
+ export const questionsOn = (path: string): string[] | undefined => (published?.path === path ? published.questions : undefined);
37
+ export const onShared = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
33
38
 
34
39
  const CONSOLE: Surface = {
35
40
  kind: 'console',
@@ -37,7 +42,10 @@ const CONSOLE: Surface = {
37
42
  mode: 'fullscreen',
38
43
  host: {},
39
44
  expand: (p) => { window.location.href = p; },
40
- share: (text) => { published = { path: window.location.pathname, text }; },
45
+ share: (text, structured) => {
46
+ published = { path: window.location.pathname, text, questions: (structured as SharedContext | undefined)?.questions };
47
+ for (const l of listeners) l();
48
+ },
41
49
  openLink: (u) => { window.open(u, '_blank', 'noopener'); },
42
50
  };
43
51
 
@@ -31,7 +31,7 @@ Status: beta
31
31
  | Stacked, identities | `parts` with a categorical `color` each: teams, or several usual outcomes (accepted, delivered and abandoned) that one grey would make one. No tone beside them |
32
32
  | Running | The last column is a period still in progress, in either form |
33
33
 
34
- **The colour of a part.** Parts with no tone are one whole, so they share the accent, mixed into the surface: deepest at the bottom and paler with each part above, between the theme's `chart-stack-hi` and `chart-stack-lo` (dark 92% to 20%, light 78% to 36%). It is the mix a stacked Trend chart takes, from the same function, so the two read as one family. Once any part names a tone the parts are outcomes: the usual outcome is `neutral` and takes `chart-neutral-strong`, and one that needs somebody takes `warning` or `critical`. A part with no tone among parts that have one is neutral too, so the accent never stands beside a status colour, and the positive colour is never used for the usual case. When the parts are identities (teams), or more than one outcome is usual, give each a categorical slot, `color` 1 to 6 in order, as a Trend series takes one: each draws in `series-<n>`, and a part with no slot (Other among them) is neutral. Keep status colours out of such a chart, as the accent is kept out of a toned one: coral (2) and saffron (5) read as a warning beside a real one. A chart with an outcome that needs somebody is a toned chart.
34
+ **The colour of a part.** Parts with no tone are one whole, so they share the accent, mixed into the surface: deepest at the bottom and paler with each part above, between the theme's `chart-stack-hi` and `chart-stack-lo` (dark 92% to 20%, light 78% to 36%). It is the mix a stacked Trend chart takes, from the same function, so the two read as one family. Once any part names a tone the parts are outcomes: the usual outcome is `neutral` and takes `chart-neutral-strong`, and one that needs somebody takes `warning` or `critical`. A part with no tone among parts that have one is neutral too, so the accent never stands beside a status colour, and the positive colour is never used for the usual case. When a part already is the neutral (`tone: 'neutral'`), a second usual part, Other among them, takes no tone and is drawn in the quieter `chart-neutral`, so the two usual parts are told apart and Other stays the quietest; like the paler parts of one hue, it is not held to 3:1, and the swatch's hairline, the legend and the table carry it. When the parts are identities (teams), or more than one outcome is usual, give each a categorical slot, `color` 1 to 6 in order, as a Trend series takes one: each draws in `series-<n>`, and a part with no slot (Other among them) is neutral. Keep status colours out of such a chart, as the accent is kept out of a toned one: coral (2) and saffron (5) read as a warning beside a real one. A chart with an outcome that needs somebody is a toned chart.
35
35
 
36
36
  **At most four parts.** Past four, nobody tells the steps of one hue apart. Given more, the fourth drawn is "Other": the sum of the fourth and every later part, in the columns, the legend, the readout and the table alike. Among outcomes it has no tone of its own and is neutral.
37
37
 
@@ -43,7 +43,7 @@ Status: beta
43
43
 
44
44
  **Periods take dates.** A chart whose columns are periods (days, weeks, months) passes `dates`, one ISO date a column, the period's first day. Its labels then thin as dates do and are never cut; passed as categories, "27 Sept" is cut to its column on a phone and reads "27 S…". Outside a Meridian the dates change nothing else.
45
45
 
46
- **Running.** The last column is a period not over yet, such as today or the month so far. It is not a fact like the columns before it, so its fill is drawn at half strength, and a dashed outline, open at the baseline, stands around it. The fill alone would not do: a neutral column at half strength is nearly lost on the card (1.20:1 dark, 1.17:1 light, where a complete one is 1.50:1 and 1.39:1), so the outline, in `ink`, is what keeps the unfinished column a column, while its faded, hollowed body stays plainly weaker than a complete one. Wherever it is read it says "so far": the plain column's value when pointed at, the readout, the announcement and its row of the table. No other column does.
46
+ **Running.** The last column is a period not over yet, such as today or the month so far. It is not a fact like the columns before it, so its fill is drawn at half strength, and a dashed outline, open at the baseline, stands around it. The fill alone would not do: a neutral column at half strength is nearly lost on the card (1.20:1 dark, 1.17:1 light, where a complete one is 1.50:1 and 1.39:1), so the outline, in `ink`, is what keeps the unfinished column a column, while its faded, hollowed body stays plainly weaker than a complete one. Wherever it is read it says "so far": the plain column's value when pointed at, the readout, the announcement and its row of the table. No other column does. A running period with no data reads "No data so far" (the product's `noData`, then "so far"); at rest, where its column is too narrow for those words, as on a phone, it reads "So far" above the dashed outline rather than a word cut short, and the rest say it whole.
47
47
 
48
48
  ## Sizes
49
49
 
@@ -106,6 +106,7 @@ The view shares the highlighted column and the day pointed at through `useShareV
106
106
 
107
107
  - Category labels are short (drop shared prefixes like `/v1/`); day labels are "27 Sept".
108
108
  - The axis takes an axis formatter, `AXIS_FORMATTERS.count` or `AXIS_FORMATTERS.cost` from `@/lib/format`: one that is true at every tick ("1.5K", never "2K" for 1,500). A value formatter that rounds, such as `formatCompact`, is not one. With no `axisFormat` the axis reads as `format` does, which is right only while `format` is exact.
109
+ - A chart of counts passes `whole`: every level is a whole number, so a top of 1 is ticked 0 and 1, with no unlabelled level at 0.5 that no count stands at.
109
110
  - `format` must tell neighbouring columns apart, because it writes the value above each: an exact count where the values are in the hundreds and low thousands ("902", "1,124", "1,016"), a compact one from the tens of thousands up ("62K", "108K"). Three columns of different heights that all read "1K" say the chart is wrong.
110
111
  - Part labels are short nouns: "Enterprise", "us-east-1", "Failed". The fold is "Other".
111
112
  - A running period is "so far", after its label: "03 Oct · so far" in the readout, "03 Oct, so far" in the table.
@@ -28,6 +28,8 @@ type Shared = {
28
28
  format?: (n: number) => string;
29
29
  /** How a period with no data reads, wherever its value would: "No data" unless the product names it otherwise. */
30
30
  noData?: string;
31
+ /** The values are counts: every tick is a whole number, so a top of 1 draws no level at 0.5 (#72). */
32
+ whole?: boolean;
31
33
  /** How a tick reads: an axis formatter, true at every tick (`AXIS_FORMATTERS` in `@/lib/format`). Without one the axis
32
34
  * reads as `format` does, which is right only while `format` is exact: one that rounds prints a tick it does not stand at. */
33
35
  axisFormat?: (n: number) => string;
@@ -52,6 +54,8 @@ export type ColumnChartProps = Shared & (
52
54
  * narrowest column that keeps its value at rest, and the most parts drawn.
53
55
  */
54
56
  const GAP = 2, THIN = 1, REST = 36, MOST = 4;
57
+ /** About the width of one character of a value at rest (`text-2xs`), in px: enough to tell whether a label fits its column. */
58
+ const CH = 6.5;
55
59
  /**
56
60
  * The plot widths past which `n` columns stand another pixel apart, up to `GAP`: each is the width at which every
57
61
  * column still has `THIN` of its own. A chart with more columns than room closes its gaps before it outgrows its box,
@@ -77,15 +81,18 @@ type Drawn = { key: string; label: string; tone: 'accent' | 'series' | keyof typ
77
81
  * drawn is "Other", the sum of the fourth and every later one. Parts with neither a slot nor a tone share the accent,
78
82
  * each by its place in the stack. Once any part names a slot or a tone the parts are identities or outcomes: a part
79
83
  * with a slot takes it, a part with a tone takes the tone, and one with neither (Other among them) is the neutral, so
80
- * the accent never stands beside a categorical or a status colour.
84
+ * the accent never stands beside a categorical or a status colour. Where a part already is the neutral (`tone:
85
+ * 'neutral'`, a usual outcome beside one that needs somebody), one with neither is the quieter neutral, so the two
86
+ * usual parts are told apart and Other stays the quietest (#67).
81
87
  */
82
88
  function stack(columns: StackedColumn[], parts: ColumnPart[]) {
83
89
  const fold = parts.length > MOST;
84
90
  const listed: ColumnPart[] = fold ? [...parts.slice(0, MOST - 1), { key: 'other', label: 'Other' }] : parts;
85
91
  const named = parts.some((p) => p.tone || p.color);
92
+ const neutralTaken = parts.some((p) => p.tone === 'neutral' && !p.color);
86
93
  const drawn: Drawn[] = listed.map((p, k) => (
87
94
  p.color ? { key: p.key, label: p.label, tone: 'series', color: SERIES_VAR(p.color) }
88
- : named ? { key: p.key, label: p.label, tone: p.tone ?? 'neutral', color: TONES[p.tone ?? 'neutral'] }
95
+ : named ? { key: p.key, label: p.label, tone: p.tone ?? 'neutral', color: p.tone ? TONES[p.tone] : neutralTaken ? 'var(--chart-neutral)' : TONES.neutral }
89
96
  : { key: p.key, label: p.label, tone: 'accent', color: stackPart(k, listed.length) }
90
97
  ));
91
98
  const bars = columns.map((c): Bar => {
@@ -119,6 +126,7 @@ export function ColumnChart({
119
126
  running = false,
120
127
  format = count,
121
128
  noData = 'No data',
129
+ whole = false,
122
130
  axisFormat,
123
131
  height = 200,
124
132
  label,
@@ -142,7 +150,7 @@ export function ColumnChart({
142
150
  const soFar = running ? n - 1 : -1;
143
151
 
144
152
  // A period with no data has no height, so the scale is the periods that have one.
145
- const ticks = niceTicks(Math.max(1, ...bars.filter((b) => !b.none).map((b) => b.total)), 3);
153
+ const ticks = niceTicks(Math.max(1, ...bars.filter((b) => !b.none).map((b) => b.total)), 3, whole);
146
154
  const top = ticks[ticks.length - 1];
147
155
  const af = axisFormat ?? format;
148
156
  const left = Math.max(...ticks.map((t) => af(t).length)) * 6.4 + 10;
@@ -217,10 +225,13 @@ export function ColumnChart({
217
225
  const pointed = active === i;
218
226
  const hot = pointed || (active === null && b.key === highlight);
219
227
  const shown = drawn ? drawn.map((p, k) => ({ p, v: b.values[k] ?? 0 })).filter(({ v }) => v > 0) : [];
220
- const text = totalOf(b, i);
221
228
  const unfinished = i === soFar;
222
229
  // Plain, the pointed value is the chart's readout and takes the tooltip's plane; stacked, the panel is.
223
230
  const tip = pointed && !drawn;
231
+ // At rest a label is held to its column. A running period with no data whose words do not fit says "So far",
232
+ // never "No data…" cut mid-word; the tooltip, the readout, the announcement and the table say it whole (#71).
233
+ const full = totalOf(b, i);
234
+ const text = !tip && b.none && unfinished && full.length * CH > column ? 'So far' : full;
224
235
  return (
225
236
  <div
226
237
  key={b.key}
@@ -35,6 +35,14 @@ const outcomes: ColumnPart[] = [
35
35
  { key: 'server', label: 'Server errors', tone: 'critical' },
36
36
  ];
37
37
  const byOutcome = [sum(STATUS_MIX.slice(0, 2)), STATUS_MIX[2].value, STATUS_MIX[3].value];
38
+ /** A second usual outcome beside a tone: it takes no tone, so it is the quieter neutral beside the one that is the neutral. */
39
+ const usualBesideTone: ColumnPart[] = [
40
+ { key: 'answered', label: 'Answered', tone: 'neutral' },
41
+ { key: 'redirected', label: 'Redirected' },
42
+ { key: 'client', label: 'Client errors', tone: 'warning' },
43
+ { key: 'server', label: 'Server errors', tone: 'critical' },
44
+ ];
45
+ const byUsualBesideTone = STATUS_MIX.map((m) => m.value);
38
46
  /** Two usual outcomes, which one neutral would make one: a categorical slot each, and no status colour beside them. */
39
47
  const several: ColumnPart[] = [
40
48
  { key: 'answered', label: 'Answered', color: 1 },
@@ -95,6 +103,11 @@ export default function ColumnChartPreview() {
95
103
  <ColumnChart label="Requests per day, by how they were answered" format={formatCompact} axisFormat={axis} dates={dates(week)} parts={outcomes} columns={split(week, byOutcome)} />
96
104
  </Plane>
97
105
  </Specimen>
106
+ <Specimen label="Two usual outcomes beside a tone" note="Answered is the neutral (tone: 'neutral'); Redirected, also usual, takes no tone and is the quieter neutral, so the two are told apart in the stack and the legend, and Other, when there is one, stays the quietest. Categorical slots stay out, since coral and saffron would read as a warning beside a real one.">
107
+ <Plane on="surface">
108
+ <ColumnChart label="Requests per day, by status class" format={formatCompact} axisFormat={axis} dates={dates(week)} parts={usualBesideTone} columns={split(week, byUsualBesideTone)} />
109
+ </Plane>
110
+ </Specimen>
98
111
  <Specimen label="Several usual outcomes" note="Answered and redirected are both usual, and one grey would make them one: each takes a categorical slot (color 1, 2), in order. Teams, or any parts that are identities rather than shares of one hue, take the slots the same way, and a part with no slot is the neutral. No status colour stands beside the slots: coral and saffron would read as a warning, so a chart with an outcome that needs somebody is the tone chart above.">
99
112
  <Plane on="surface">
100
113
  <ColumnChart label="Answered requests per day, by how they were answered" format={formatCompact} axisFormat={axis} dates={dates(week)} parts={several} columns={split(week, bySeveral)} />
@@ -1,9 +1,12 @@
1
1
  /** Small, dependency-free scale helpers shared by the charts. */
2
2
 
3
- /** A "nice" upper bound and 3 to 5 ticks from 0 to it. */
4
- export function niceTicks(max: number, count = 4): number[] {
3
+ /**
4
+ * A "nice" upper bound and 3 to 5 ticks from 0 to it. `whole` keeps every tick a whole number, for a scale of counts:
5
+ * a top of 1 is ticked 0 and 1, never with a level at 0.5 that no count stands at (#72).
6
+ */
7
+ export function niceTicks(max: number, count = 4, whole = false): number[] {
5
8
  if (max <= 0) return [0, 1];
6
- return ticksBetween(0, max, count);
9
+ return ticksBetween(0, max, count, whole);
7
10
  }
8
11
 
9
12
  /**
@@ -12,12 +15,14 @@ export function niceTicks(max: number, count = 4): number[] {
12
15
  * the one thing the chart is about the smallest mark on it. Both ends land on a step, so the lowest level is the floor
13
16
  * the plot is drawn to and `ticks[0]` may be read as it. Returns `[0, 1]` like `niceTicks` when there is nothing to show.
14
17
  */
15
- export function ticksBetween(floor: number, max: number, count = 4): number[] {
18
+ export function ticksBetween(floor: number, max: number, count = 4, whole = false): number[] {
16
19
  if (max <= 0) return [0, 1];
17
20
  const raw = (max - floor) / count;
18
- if (!(raw > 0)) return niceTicks(max, count);
21
+ if (!(raw > 0)) return niceTicks(max, count, whole);
19
22
  const mag = 10 ** Math.floor(Math.log10(raw));
20
- const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
23
+ const nice = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
24
+ // A whole scale steps by a whole number: at least 1, and 2.5 becomes 5 as the next nice step (2.5 × 10 stays 25).
25
+ const step = !whole || Number.isInteger(nice) ? nice : nice < 1 ? 1 : [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= nice && Number.isInteger(s)) ?? Math.ceil(nice);
21
26
  // How many steps a level is from zero, rounded before it is floored or ceilinged: in binary, 0.7 / 0.1 is 6.999…,
22
27
  // and a floor of 70% on a 10% step would otherwise come out as 60% — a whole step of the plot spent on nothing.
23
28
  const steps = (n: number) => Math.round(n / step * 1e6) / 1e6;
@@ -6,7 +6,7 @@ Status: beta
6
6
 
7
7
  ## Anatomy
8
8
 
9
- 1. **Actor**: a 24px Avatar for a person; a dot in a soft disc for the system (toned when the event is good or needs attention); an Agent mark for an agent.
9
+ 1. **Actor**: a 24px Avatar for a person; a dot in a soft disc for the system (toned when the event is good or needs attention); an Agent mark for an agent; an empty disc in a dashed `line-strong` hairline, with no initials, for an actor the record does not hold, so nobody unknown reads as a person.
10
10
  2. **Sentence**: the actor (`ink` 500), the verb (`ink-2`) and the object (`ink`), which is a link to its record (`ink` 500, `accent-ink` on hover, a → after it) when it has one; for an agent, "· for Jonas Weber" in `ink-3`.
11
11
  3. **Time**: relative (`text-xs`, `ink-3`, tabular), the exact time in its title.
12
12
  4. **Thread**: a 1px `line` joining the actors down the list.
@@ -17,7 +17,7 @@ Avatar, Agent mark, and the date formatters, inside a Card with a divided header
17
17
 
18
18
  ## Data
19
19
 
20
- `ActivityEvent { id, at, actor, via?, verb, object, href?, tone? }`. `via` names the agent when an assistant acted for `actor`. `href` is the object's record: an event about a record opens it, so the feed is never a dead end. `tone` (`positive`, `warning`, `critical`) colours the system's dot only.
20
+ `ActivityEvent { id, at, actor, system?, unknown?, via?, verb, object, href?, tone? }`. `via` names the agent when an assistant acted for `actor`. `unknown` says the record does not hold who acted: `actor` is then the product's words for that ("Someone not recorded"), read in the line as any actor is, beside the empty disc. `href` is the object's record: an event about a record opens it, so the feed is never a dead end. `tone` (`positive`, `warning`, `critical`) colours the system's dot only.
21
21
 
22
22
  ## States
23
23
 
@@ -5,8 +5,12 @@ import { formatDateTime, formatRelative } from '@/lib/format-date';
5
5
  import { Avatar } from '@/components/ui/avatar';
6
6
  import { AgentMark } from '@/components/ui/agent-mark';
7
7
 
8
- /** One event: who did what to what, and when. `via` names the agent when an assistant acted for `actor`; `system` marks the product acting on its own; `href` is the object's record, when it has one. */
9
- export type ActivityEvent = { id: string; at: string; actor: string; system?: boolean; via?: string; verb: string; object: string; href?: string; tone?: 'positive' | 'warning' | 'critical' };
8
+ /**
9
+ * One event: who did what to what, and when. `via` names the agent when an assistant acted for `actor`; `system` marks the
10
+ * product acting on its own; `unknown` says the record does not hold who acted, and `actor` is then the words the product
11
+ * says for that ("Someone not recorded"); `href` is the object's record, when it has one.
12
+ */
13
+ export type ActivityEvent = { id: string; at: string; actor: string; system?: boolean; unknown?: boolean; via?: string; verb: string; object: string; href?: string; tone?: 'positive' | 'warning' | 'critical' };
10
14
 
11
15
  const DOT = { positive: 'bg-positive', warning: 'bg-warning', critical: 'bg-critical' } as const;
12
16
 
@@ -21,7 +25,7 @@ export function ActivityFeed({ events, now, className }: { events: ActivityEvent
21
25
  <li key={e.id} className="relative flex gap-3 pb-4 last:pb-0">
22
26
  {i < events.length - 1 ? <span aria-hidden className="absolute top-7 bottom-0 left-3 w-px bg-line" /> : null}
23
27
  <span className="relative mt-0.5 shrink-0">
24
- {e.via ? <AgentMark size="md" /> : e.system ? <span className="grid size-6 place-items-center rounded-full bg-fill-track"><span className={cn('size-2 rounded-full', e.tone ? DOT[e.tone] : 'bg-ink-3')} /></span> : <Avatar name={e.actor} size="sm" />}
28
+ {e.via ? <AgentMark size="md" /> : e.system ? <span className="grid size-6 place-items-center rounded-full bg-fill-track"><span className={cn('size-2 rounded-full', e.tone ? DOT[e.tone] : 'bg-ink-3')} /></span> : e.unknown ? <span data-actor-unknown className="block size-6 rounded-full border border-dashed border-line-strong" /> : <Avatar name={e.actor} size="sm" />}
25
29
  </span>
26
30
  <div className="min-w-0 flex-1 pt-0.5">
27
31
  <p className="text-sm text-ink-2">
@@ -23,6 +23,7 @@ export default function ActivityFeedPreview() {
23
23
  { id: 'w', at: new Date(DEMO_NOW.getTime() - 36 * 60_000).toISOString(), actor: app.name, system: true, verb: 'shifted traffic', object: 'from eu-west-1 to eu-central-1', tone: 'warning' },
24
24
  { id: 'a', at: new Date(DEMO_NOW.getTime() - 2.4 * 3600_000).toISOString(), actor: 'Jonas Weber', via: 'Claude', verb: 'raised the rate limit for', object: 'Parallax AI to 2,000 rpm' },
25
25
  { id: 's', at: new Date(DEMO_NOW.getTime() - 5.1 * 3600_000).toISOString(), actor: app.name, system: true, verb: 'deployed', object: 'gateway v4.18.2', href: '/releases', tone: 'positive' },
26
+ { id: 'u', at: new Date(DEMO_NOW.getTime() - 26 * 3600_000).toISOString(), actor: 'Someone not recorded', unknown: true, verb: 'issued a token for', object: 'the staging gateway' },
26
27
  ]}
27
28
  />
28
29
  </Specimen>
@@ -8,7 +8,7 @@ Status: beta
8
8
 
9
9
  1. **Launcher**: a round 36px button in the top-bar tools with the Agent mark, named "Assistant".
10
10
  2. **Panel**: an `aside` named "Assistant", on the `frame` wash with a `line` edge, same as the rail.
11
- 3. **Head**: 56px; the Agent mark, "Assistant" (`t-section`), Clear conversation and Close assistant (ghost icon buttons).
11
+ 3. **Head**: 56px; the Agent mark, "Assistant" (`t-section`), "Clear" and "Close" (small ghost buttons, an icon and the word; named "Clear conversation" and "Close assistant", which begin with the words shown).
12
12
  4. **Thread**: the messages in order, scrolling inside the panel, kept at the newest. Each question carries a quiet "On <page>" caption above its bubble. A change the assistant wants to make is a Proposal card in the message. A failed turn shows a critical Banner with Retry under the thread.
13
13
  5. **Composer**: a Textarea (grows to six rows) and Send (primary, icon only), on a `line` top edge.
14
14
 
@@ -29,7 +29,7 @@ Head 56px, composer padding 12px, thread padding 16px, message gap 16px. Launche
29
29
  | State | Look |
30
30
  |---|---|
31
31
  | Closed | Nothing renders; the launcher is `aria-expanded="false"` |
32
- | Open, empty | One sentence of help, `ink-3`, centred |
32
+ | Open, empty | One sentence of help, `ink-3`, centred; under it, when the page in view shares questions (`SharedContext.questions`), one bordered `surface` button per question, full width, under "Questions to start with". Pressing one sends it as the person's own. A page that shares none keeps the sentence alone |
33
33
  | Open, thread | Messages; the thread follows the newest |
34
34
  | Thread, several pages | Each question is captioned with the page it was asked on ("On Members") |
35
35
  | Error | A critical alert with what happened and Retry; the thread stays |
@@ -60,7 +60,7 @@ The panel enters with no motion of its own yet; the launcher's states ease `colo
60
60
  ## Surfaces
61
61
 
62
62
  - **Console**: from 1024px a third column of the shell at `--assistant-width`; the page narrows by that much.
63
- - **Mobile** (under 1024px): the same panel as a sheet fixed to the right edge over the page, at most the viewport less 48px.
63
+ - **Mobile** (under 1024px): the same panel as a sheet fixed to the right edge over the page, at most the viewport less 48px; below `sm` (40rem) the whole width, since a strip of cut page words beside it reads as nothing.
64
64
  - **Embed**: not applicable; the host is the assistant.
65
65
 
66
66
  Both are decided by CSS breakpoints, never by measuring the window.
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
3
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent } from 'react';
4
4
  import { usePathname, useRouter } from 'next/navigation';
5
5
  import { useChat } from '@ai-sdk/react';
6
6
  import { APICallError, DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses, type UIMessage } from 'ai';
@@ -10,12 +10,11 @@ import { app } from '@/app.config';
10
10
  import { AgentMark } from '@/components/ui/agent-mark';
11
11
  import { Banner } from '@/components/ui/banner';
12
12
  import { Button } from '@/components/ui/button';
13
- import { IconButton } from '@/components/ui/icon-button';
14
13
  import { Proposal, type ProposalChange, type ProposalState } from '@/components/patterns/proposal';
15
14
  import { Spinner } from '@/components/ui/spinner';
16
15
  import { Textarea } from '@/components/ui/textarea';
17
16
  import type { PageContext } from '@/lib/assistant/prompt';
18
- import { sharedContextOn } from '@/components/base/surface';
17
+ import { onShared, questionsOn, sharedContextOn } from '@/components/base/surface';
19
18
  import { pageText } from './page-text';
20
19
  import { useThreadScope } from './scope';
21
20
  import { AssistantText } from './text';
@@ -88,8 +87,9 @@ function ProposalOf({ message, part, onDecide }: { message: UIMessage; part: Too
88
87
  }
89
88
 
90
89
  /**
91
- * The panel, drawn from its messages alone: a third column of the shell from 1024px, a sheet over the page below it.
92
- * Both come from CSS; nothing here measures the window.
90
+ * The panel, drawn from its messages alone: a third column of the shell from 1024px, a sheet over the page below it,
91
+ * the whole width of a phone, so no strip of cut page words is left beside it. All of it comes from CSS; nothing here
92
+ * measures the window.
93
93
  */
94
94
  export function AssistantPanel({
95
95
  messages,
@@ -100,6 +100,7 @@ export function AssistantPanel({
100
100
  onClear,
101
101
  error,
102
102
  onRetry,
103
+ questions,
103
104
  inline,
104
105
  className,
105
106
  }: {
@@ -114,6 +115,8 @@ export function AssistantPanel({
114
115
  /** Said in an alert under the thread, with Retry. */
115
116
  error?: string;
116
117
  onRetry?: () => void;
118
+ /** Questions to start the thread with, offered while it is empty: the page in view's (`SharedContext.questions`). */
119
+ questions?: string[];
117
120
  /** Draw in the flow instead of fixed to the viewport edge: for previews. */
118
121
  inline?: boolean;
119
122
  className?: string;
@@ -152,19 +155,33 @@ export function AssistantPanel({
152
155
  'flex h-full min-h-0 flex-col border-l border-line bg-frame backdrop-blur-xl backdrop-saturate-150',
153
156
  inline
154
157
  ? 'w-(--assistant-width) max-w-full'
155
- : 'fixed inset-y-0 right-0 z-(--layer-overlay) w-(--assistant-width) max-w-[calc(100vw-48px)] bg-ground shadow-overlay lg:relative lg:z-auto lg:shrink-0 lg:bg-frame lg:shadow-none',
158
+ : 'fixed inset-y-0 right-0 z-(--layer-overlay) w-full bg-ground shadow-overlay sm:w-(--assistant-width) sm:max-w-[calc(100vw-48px)] lg:relative lg:z-auto lg:shrink-0 lg:bg-frame lg:shadow-none',
156
159
  className,
157
160
  )}
158
161
  >
159
162
  <div className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4">
160
163
  <AgentMark size="md" />
161
164
  <h2 className="t-card min-w-0 flex-1 truncate">Assistant</h2>
162
- <IconButton label="Clear conversation" icon={<Eraser />} tooltip disabled={messages.length === 0} onClick={onClear} />
163
- <IconButton label="Close assistant" icon={<X />} onClick={onClose} className="-mr-2" />
165
+ {/* Named on screen, not only to a screen reader: the accessible names begin with the words shown. */}
166
+ <Button size="sm" variant="ghost" icon={<Eraser />} aria-label="Clear conversation" disabled={messages.length === 0} onClick={onClear}>Clear</Button>
167
+ <Button size="sm" variant="ghost" icon={<X />} aria-label="Close assistant" onClick={onClose} className="-mr-2">Close</Button>
164
168
  </div>
165
169
  <div ref={thread} className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain px-4 py-4">
166
170
  {messages.length === 0 ? (
167
- <p className="t-small m-auto max-w-[32ch] text-center text-ink-3">Ask about this page: what a figure means, or why it moved.</p>
171
+ <div data-assistant-empty className="m-auto flex w-full flex-col items-center gap-4">
172
+ <p className="t-small max-w-[32ch] text-center text-ink-3">Ask about this page: what a figure means, or why it moved.</p>
173
+ {questions?.length ? (
174
+ <ul aria-label="Questions to start with" className="flex w-full flex-col gap-2">
175
+ {questions.map((q) => (
176
+ <li key={q}>
177
+ <button type="button" disabled={busy} onClick={() => onSend(q)} className="press hit t-small w-full rounded-lg border border-line bg-surface px-3 py-2 text-left text-ink hover:border-line-strong disabled:opacity-45">
178
+ {q}
179
+ </button>
180
+ </li>
181
+ ))}
182
+ </ul>
183
+ ) : null}
184
+ </div>
168
185
  ) : (
169
186
  messages.map((m) => (
170
187
  <div key={m.id} data-role={m.role} className={cn('flex min-w-0 flex-col gap-1', m.role === 'user' && 'items-end')}>
@@ -247,6 +264,8 @@ export function AssistantColumn({ open, onClose, ask, onAsked }: { open: boolean
247
264
  [scope],
248
265
  );
249
266
  const router = useRouter();
267
+ // The page in view's starter questions, as its view shares them; a page that offers none keeps the plain empty state.
268
+ const questions = useSyncExternalStore(onShared, () => questionsOn(path), () => undefined);
250
269
  // The error that said the session changed hands since the thread began: the thread is gone, so it offers no Retry.
251
270
  const [changedHands, setChangedHands] = useState<Error | null>(null);
252
271
  const { messages, setMessages, sendMessage, regenerate, addToolApprovalResponse, status, error } = useChat({
@@ -313,6 +332,7 @@ export function AssistantColumn({ open, onClose, ask, onAsked }: { open: boolean
313
332
  setMessages([]);
314
333
  forgetThread(localStorage, scope);
315
334
  }}
335
+ questions={questions}
316
336
  error={error?.message}
317
337
  onRetry={error && error === changedHands ? undefined : () => regenerate()}
318
338
  />
@@ -33,8 +33,9 @@ export default function AssistantPreview() {
33
33
  <State label="Closed"><AssistantLauncher open={false} onClick={noop} /></State>
34
34
  <State label="Open"><AssistantLauncher open onClick={noop} /></State>
35
35
  </Specimen>
36
- <Specimen label="Panel" note="From 1024px a third column at the assistant width; below it the same panel is a sheet over the page." className="items-start">
36
+ <Specimen label="Panel" note="From 1024px a third column at the assistant width; below it the same panel is a sheet over the page, the whole width of a phone. Empty, it offers the questions the page in view shares (SharedContext.questions), and only its line where the page shares none." className="items-start">
37
37
  <State label="Empty"><AssistantPanel inline messages={[]} onSend={noop} onClose={noop} onDecide={noop} onClear={noop} className="h-120 rounded-lg border" /></State>
38
+ <State label="Empty, with the page's questions"><AssistantPanel inline messages={[]} questions={['Why did requests change over the last 30 days?', 'What moved revenue over the last 30 days?', 'Is anything on this page out of the ordinary?']} onSend={noop} onClose={noop} onDecide={noop} onClear={noop} className="h-120 rounded-lg border" /></State>
38
39
  <State label="A short thread"><AssistantPanel inline messages={thread} onSend={noop} onClose={noop} onDecide={noop} onClear={noop} className="h-120 rounded-lg border" /></State>
39
40
  <State label="Across pages"><AssistantPanel inline messages={[...thread, asked('3', 'Which keys are unused?', 'API keys'), say('4', 'assistant', 'Two keys have not been used in 30 days.')]} onSend={noop} onClose={noop} onDecide={noop} onClear={noop} className="h-120 rounded-lg border" /></State>
40
41
  <State label="Error"><AssistantPanel inline messages={thread.slice(0, 1)} error="The assistant could not reach its provider. Try again." onRetry={noop} onSend={noop} onClose={noop} onDecide={noop} onClear={noop} className="h-120 rounded-lg border" /></State>
@@ -314,7 +314,7 @@ export function DataTable<R>({
314
314
  <tr key={i} className="group/row">
315
315
  {selectable ? <td data-mobile="check"><Skeleton className="size-4" /></td> : null}
316
316
  {columns.map((c, j) => (
317
- <td key={c.key} data-mobile={roleOf(c)} data-num={c.numeric ? 'num' : undefined} className={cellClass({ numeric: c.numeric, hideBelow: c.hideBelow })}>
317
+ <td key={c.key} data-mobile={roleOf(c)} data-num={c.numeric ? 'num' : undefined} data-hide-below={c.hideBelow} className={cellClass({ numeric: c.numeric, hideBelow: c.hideBelow })}>
318
318
  <Skeleton className={cn('h-3', c.numeric ? 'ml-auto w-12' : c.grow ? ['w-48', 'w-40', 'w-56'][i % 3] : ['w-20', 'w-16', 'w-24'][(i + j) % 3])} />
319
319
  </td>
320
320
  ))}
@@ -340,7 +340,7 @@ export function DataTable<R>({
340
340
  const value = c.cell(r);
341
341
  const content = c === titleColumn && href ? <Link href={href}>{value}</Link> : value;
342
342
  return (
343
- <td key={c.key} data-mobile={role} data-num={c.numeric ? 'num' : c.align === 'right' ? 'true' : undefined} title={c.title ? c.title(r) : c.truncate && (typeof value === 'string' || typeof value === 'number') ? String(value) : undefined} className={cellClass(c) || undefined}>
343
+ <td key={c.key} data-mobile={role} data-num={c.numeric ? 'num' : c.align === 'right' ? 'true' : undefined} data-hide-below={c.hideBelow} title={c.title ? c.title(r) : c.truncate && (typeof value === 'string' || typeof value === 'number') ? String(value) : undefined} className={cellClass(c) || undefined}>
344
344
  {/* The dot that separates two facts, on a phone only — the table has a column for each of
345
345
  them, where a dot would be noise. It leads the fact it separates, in that fact's own
346
346
  cell: each fact is a flex item, so a line that runs out of room wraps whole ones and a
@@ -24,7 +24,7 @@ The names follow the HTML elements: `TableHead` is the `thead`, `TableHeader` is
24
24
 
25
25
  ## Sizes
26
26
 
27
- Column widths follow content. A column whose cells can be cut asks for all the width there is — `grow` on the head for the record's name, and `flexible` on any other head whose cells `truncate` — and takes what no other column needs; two or more of them share it, which is what a table with three long columns (a verification, its release, the reason it failed) needs. Every column that must show its whole value keeps its content width. A cut cell carries the full text in its `title`, or opens the record. Other text columns stay on one line. A flexible column is at least 160px, 256px once the table is 672px wide. `hideBelow` drops a column when the table itself is narrower than `sm` 512, `md` 672, `lg` 896 or `xl` 1152px, on its head and its cells alike.
27
+ Column widths follow content. A column whose cells can be cut asks for all the width there is — `grow` on the head for the record's name, and `flexible` on any other head whose cells `truncate` — and takes what no other column needs; two or more of them share it, which is what a table with three long columns (a verification, its release, the reason it failed) needs. Every column that must show its whole value keeps its content width. A cut cell carries the full text in its `title`, or opens the record. Other text columns stay on one line. A flexible column is at least 160px, 256px once the table is 672px wide. `hideBelow` drops a column when the table itself is narrower than `sm` 512, `md` 672, `lg` 896 or `xl` 1152px, on its head and its cells alike, and marks each `data-hide-below`, so the column drawn last at that width, not the dropped one after it, takes the card's padding on its right.
28
28
 
29
29
  ## States
30
30
 
@@ -24,6 +24,11 @@ export function Table({ className, caption, children, ...rest }: HTMLAttributes<
24
24
  className={cn(
25
25
  'w-full border-separate border-spacing-0 text-sm',
26
26
  '[&_tr>*:first-child]:pl-(--card-pad) [&_tr>*:last-child]:pr-(--card-pad)',
27
+ /* The column drawn last takes the card's padding, whichever columns the table's width hides after it (#70). */
28
+ '@max-[1152px]:[&_tr>:nth-last-child(1_of_:not([data-hide-below=xl]))]:pr-(--card-pad)',
29
+ '@max-[896px]:[&_tr>:nth-last-child(1_of_:not([data-hide-below=xl]):not([data-hide-below=lg]))]:pr-(--card-pad)',
30
+ '@max-[672px]:[&_tr>:nth-last-child(1_of_:not([data-hide-below=xl]):not([data-hide-below=lg]):not([data-hide-below=md]))]:pr-(--card-pad)',
31
+ '@max-[512px]:[&_tr>:nth-last-child(1_of_:not([data-hide-below]))]:pr-(--card-pad)',
27
32
  /* A text column after a right-aligned number gets room to breathe, so "234ms" and "Example Co" read as two facts. */
28
33
  '[&_[data-num]+:not([data-num])]:pl-8',
29
34
  className,
@@ -88,6 +93,7 @@ export function TableHeader({
88
93
  <th
89
94
  scope="col"
90
95
  data-num={align === 'right' || undefined}
96
+ data-hide-below={hideBelow}
91
97
  aria-sort={sortable ? (sort === 'asc' ? 'ascending' : sort === 'desc' ? 'descending' : 'none') : undefined}
92
98
  className={cn(
93
99
  'h-9 border-y border-line bg-surface-sunk px-4 text-xs font-medium whitespace-nowrap text-ink-3 first:border-l-0',
@@ -144,6 +150,7 @@ export function TableCell({
144
150
  return (
145
151
  <td
146
152
  data-num={numeric || align === 'right' || undefined}
153
+ data-hide-below={hideBelow}
147
154
  // A cut value is cut everywhere on the page, and this README has always said the cell carries the whole one in its
148
155
  // title. The component does it now for a cell that is a value, rather than each page remembering; a cell that is a
149
156
  // node is its author's, and a `title` passed in wins over this one because the spread comes after.
@@ -48,6 +48,11 @@ export type SharedContext = {
48
48
  insights: Insight[];
49
49
  /** What the data cannot say, stated so nobody fills the gap with a guess. */
50
50
  unknowns: string[];
51
+ /**
52
+ * Questions worth asking first about what the view shows, in the person's words: the console's assistant offers them
53
+ * while its thread is empty. They are for the person, so they are never in the text a model is told.
54
+ */
55
+ questions?: string[];
51
56
  };
52
57
 
53
58
  /** At most `max` items of a list, joined, and how many more there are: a context never grows with the data. */
@@ -273,6 +273,13 @@ export function homeContext(data: HomeData, index: number | null = null): Shared
273
273
  focus = `${formatDate(d.date)}, a ${wd}: ${count(d.requests)} requests${usual ? ` (${times(d.requests / usual)} the median ${wd})` : ''}; error rate ${formatPercent(errorRate[index], 2)}${ratioAgainst(errorRate[index], medianError, "the period's median")}; latency p95 ${formatDuration(d.p95)}${ratioAgainst(d.p95, medianP95, 'the median')}; revenue ${formatCost(d.revenue)}.`;
274
274
  }
275
275
 
276
- return { view: 'home', title: 'Home', address, scope, freshness: freshnessOf(data.updatedAt, data.now), focus, facts, insights, unknowns };
276
+ // What a person asks first on Home, only about the figures they may read.
277
+ const over = period === 'all' ? 'over all time' : `over the ${label}`;
278
+ const questions = [
279
+ ...(data.requests ? [`Why did requests change ${over}?`] : []),
280
+ ...(data.revenue ? [`What moved revenue ${over}?`] : []),
281
+ 'Is anything on this page out of the ordinary?',
282
+ ];
283
+ return { view: 'home', title: 'Home', address, scope, freshness: freshnessOf(data.updatedAt, data.now), focus, facts, insights, unknowns, questions };
277
284
  }
278
285
 
@@ -0,0 +1,22 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ActivityFeed } from '@/components/patterns/activity-feed';
4
+
5
+ const now = new Date('2026-10-19T12:00:00Z');
6
+ const at = '2026-10-19T11:00:00Z';
7
+
8
+ describe('an activity feed event whose actor the record does not hold (#68)', () => {
9
+ it('draws an empty mark with no initials, and the line reads the product\'s words', () => {
10
+ const { container } = render(<ActivityFeed events={[{ id: '1', at, actor: 'Someone not recorded', unknown: true, verb: 'issued a token for', object: 'x' }]} now={now} />);
11
+ const mark = container.querySelector('[data-actor-unknown]');
12
+ expect(mark).not.toBeNull();
13
+ expect(mark).toHaveTextContent(/^$/);
14
+ expect(container.querySelector('li')).not.toHaveTextContent(/\bSN\b/);
15
+ expect(container.querySelector('p')).toHaveTextContent(/^Someone not recorded issued a token for x$/);
16
+ });
17
+ it('still draws a known person\'s initials', () => {
18
+ const { container } = render(<ActivityFeed events={[{ id: '1', at, actor: 'Ada Lovelace', verb: 'resolved', object: 'x' }]} now={now} />);
19
+ expect(container.querySelector('[data-actor-unknown]')).toBeNull();
20
+ expect(container.querySelector('li')).toHaveTextContent(/^AL/);
21
+ });
22
+ });
@@ -0,0 +1,68 @@
1
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
2
+ import { describe, expect, test, vi } from 'vitest';
3
+ import { Tooltip } from 'radix-ui';
4
+ import type { UIMessage } from 'ai';
5
+
6
+ vi.mock('next/navigation', () => ({ usePathname: () => '/', useRouter: () => ({ refresh() {}, push() {}, replace() {} }) }));
7
+ import { AppShell, PageFrame } from '@/components/base/shell';
8
+ import { AssistantPanel } from '@/components/patterns/assistant';
9
+ import { useShareView } from '@/components/base/use-share-view';
10
+ import { contextText, type SharedContext } from '@/lib/shared-context';
11
+
12
+ vi.stubGlobal('IntersectionObserver', class { observe() {} unobserve() {} disconnect() {} });
13
+
14
+ const QUESTIONS = ['Why did requests change over the last 30 days?', 'Is anything on this page out of the ordinary?'];
15
+ const context: SharedContext = { view: 'home', title: 'Home', address: '/', scope: 'last 30 days', facts: [], insights: [], unknowns: [], questions: QUESTIONS };
16
+ const noop = () => {};
17
+ const panel = (props: Partial<Parameters<typeof AssistantPanel>[0]>) =>
18
+ render(<Tooltip.Provider><AssistantPanel inline messages={[]} onSend={noop} onClose={noop} onDecide={noop} onClear={noop} {...props} /></Tooltip.Provider>);
19
+
20
+ describe('the assistant panel\'s starter questions (#69)', () => {
21
+ test('offers the page\'s questions while the thread is empty, and sends one as the person\'s own when pressed', () => {
22
+ const onSend = vi.fn();
23
+ panel({ questions: QUESTIONS, onSend });
24
+ const list = screen.getByRole('list', { name: 'Questions to start with' });
25
+ expect(within(list).getAllByRole('button').map((b) => b.textContent)).toEqual(QUESTIONS);
26
+ fireEvent.click(within(list).getByRole('button', { name: QUESTIONS[1] }));
27
+ expect(onSend).toHaveBeenCalledWith(QUESTIONS[1]);
28
+ });
29
+
30
+ test('keeps the plain empty state where the page offers none, and offers none once there is a thread', () => {
31
+ const empty = panel({});
32
+ expect(screen.getByText(/^Ask about this page: what a figure means, or why it moved\.$/)).toBeInTheDocument();
33
+ expect(screen.queryByRole('list', { name: 'Questions to start with' })).toBeNull();
34
+ empty.unmount();
35
+ const said: UIMessage = { id: '1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] };
36
+ panel({ questions: QUESTIONS, messages: [said] });
37
+ expect(screen.queryByRole('list', { name: 'Questions to start with' })).toBeNull();
38
+ });
39
+
40
+ test('names Clear and Close on screen, in names that begin with the words shown', () => {
41
+ panel({ messages: [{ id: '1', role: 'user', parts: [{ type: 'text', text: 'Hello' }] }] });
42
+ expect(screen.getByRole('button', { name: 'Clear conversation' })).toHaveTextContent(/^Clear$/);
43
+ expect(screen.getByRole('button', { name: 'Close assistant' })).toHaveTextContent(/^Close$/);
44
+ });
45
+
46
+ test('never tells the model the questions', () => {
47
+ expect(contextText(context)).not.toMatch(/out of the ordinary|Why did requests/);
48
+ });
49
+
50
+ test('in the shell, the panel offers the questions the view on screen shares', async () => {
51
+ function View() {
52
+ useShareView(context);
53
+ return <p>Traffic, reliability and spend.</p>;
54
+ }
55
+ await act(async () => {
56
+ render(
57
+ <Tooltip.Provider>
58
+ <AppShell rail={null} assistant={Promise.resolve(true)}>
59
+ <PageFrame title="Home"><View /></PageFrame>
60
+ </AppShell>
61
+ </Tooltip.Provider>,
62
+ );
63
+ });
64
+ fireEvent.click(await screen.findByRole('button', { name: 'Assistant' }, { timeout: 10_000 }));
65
+ const list = await screen.findByRole('list', { name: 'Questions to start with' }, { timeout: 10_000 });
66
+ await waitFor(() => expect(within(list).getAllByRole('button')).toHaveLength(2));
67
+ });
68
+ });
@@ -39,3 +39,17 @@ describe('ticksBetween: the levels a chart is drawn on', () => {
39
39
  expect(ticksBetween(0.7, 0, 4)).toEqual([0, 1]);
40
40
  });
41
41
  });
42
+
43
+ describe('niceTicks for counts (#72)', () => {
44
+ it('steps by whole numbers only, so a top of 1 is ticked 0 and 1 and nothing between', () => {
45
+ expect(niceTicks(1, 3)).toEqual([0, 0.5, 1]);
46
+ expect(niceTicks(1, 3, true)).toEqual([0, 1]);
47
+ expect(niceTicks(2, 3, true)).toEqual([0, 1, 2]);
48
+ expect(niceTicks(7, 3, true)).toEqual([0, 5, 10]);
49
+ expect(niceTicks(9, 4, true)).toEqual([0, 5, 10]);
50
+ expect(niceTicks(0, 3, true)).toEqual([0, 1]);
51
+ });
52
+ it('leaves a scale of counts that steps by whole numbers already as it was', () => {
53
+ for (const max of [12, 40, 75, 640, 3_300_438]) expect(niceTicks(max, 3, true)).toEqual(niceTicks(max, 3));
54
+ });
55
+ });
@@ -0,0 +1,32 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ // A phone's card: six periods in about 300px leave each column some 45px, room for a value but not for "No data so far".
5
+ vi.mock('@/components/charts/use-size', async () => {
6
+ const { useRef } = await import('react');
7
+ return { useSize: () => [useRef(null), { width: 300, height: 0 }] as const };
8
+ });
9
+
10
+ import { ColumnChart, type Column } from '@/components/charts/column-chart';
11
+
12
+ const weeks: Column[] = [
13
+ { key: 'w1', label: '31 Aug', value: 12 },
14
+ { key: 'w2', label: '7 Sept', value: 9 },
15
+ { key: 'w3', label: '14 Sept', value: 14 },
16
+ { key: 'w4', label: '21 Sept', value: 11 },
17
+ { key: 'w5', label: '28 Sept', value: 10 },
18
+ { key: 'w6', label: '5 Oct', value: null },
19
+ ];
20
+
21
+ describe('a running period with no data in a narrow column (#71)', () => {
22
+ it('reads So far at rest, never the words cut short, and says them whole in the table and when pointed at', () => {
23
+ const { container } = render(<ColumnChart label="Failed calls per 1,000" running columns={weeks} />);
24
+ const last = container.querySelector<HTMLElement>('[data-column="w6"]')!;
25
+ expect(last.querySelector('[data-value]')).toHaveTextContent(/^So far$/);
26
+ const rows = container.querySelectorAll('table tbody tr');
27
+ expect(rows[5].querySelector('td')).toHaveTextContent(/^No data so far$/);
28
+ fireEvent.keyDown(screen.getByRole('group', { name: /^Failed calls per 1,000/ }), { key: 'End' });
29
+ expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(/^5 Oct: No data so far$/);
30
+ expect(last.querySelector('[data-value]')).toHaveTextContent(/^No data so far$/);
31
+ });
32
+ });
@@ -171,4 +171,11 @@ describe('a stacked column chart whose parts are identities (#61)', () => {
171
171
  const { container } = render(<ColumnChart label="Calls per week, by team" parts={teams} columns={[{ key: 'w1', label: '14 Sept', values: [5, 4, 2] }]} />);
172
172
  expect(fill(container, 'o')).toBe('var(--chart-neutral-strong)');
173
173
  });
174
+ it('draws a part with neither beside a part that is the neutral as the quieter neutral, so two usual parts are told apart (#67)', () => {
175
+ const refusals: ColumnPart[] = [{ key: 'platform', label: 'Platform failed', tone: 'critical' }, { key: 'malformed', label: 'Malformed call', tone: 'warning' }, { key: 'held', label: 'A guardrail held', tone: 'neutral' }, { key: 'other', label: 'Other' }];
176
+ const { container } = render(<ColumnChart label="Refusals per week, by cause" parts={refusals} columns={[{ key: 'w1', label: '14 Sept', values: [1, 2, 6, 3] }]} />);
177
+ expect(fill(container, 'held')).toBe('var(--chart-neutral-strong)');
178
+ expect(fill(container, 'other')).toBe('var(--chart-neutral)');
179
+ expect(column(container, 'w1').querySelector('[data-part="other"]')).toHaveAttribute('data-tone', 'neutral');
180
+ });
174
181
  });
@@ -0,0 +1,19 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ vi.mock('@/components/charts/use-size', async () => {
5
+ const { useRef } = await import('react');
6
+ return { useSize: () => [useRef(null), { width: 720, height: 0 }] as const };
7
+ });
8
+
9
+ import { ColumnChart } from '@/components/charts/column-chart';
10
+
11
+ const ticks = (c: HTMLElement) => [...c.querySelectorAll('[data-tick]')].map((t) => t.textContent);
12
+
13
+ describe('a column chart of counts (#72)', () => {
14
+ it('draws whole levels only: a top of 1 is 0 and 1, with no level at 0.5', () => {
15
+ const columns = [{ key: 'a', label: 'Mon', value: 0 }, { key: 'b', label: 'Tue', value: 1 }];
16
+ expect(ticks(render(<ColumnChart label="Incidents opened" whole columns={columns} />).container)).toEqual(['0', '1']);
17
+ expect(ticks(render(<ColumnChart label="Incidents opened, as a rate" columns={columns} />).container)).toEqual(['0', '0.5', '1']);
18
+ });
19
+ });
@@ -0,0 +1,18 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
4
+
5
+ // jsdom runs no container query, so this holds the half the browser cannot: each dropped cell says when it is dropped,
6
+ // which the Table's padding rule reads to find the column drawn last (#70). The padding itself was measured in a browser.
7
+ describe('a table whose last column drops below a width (#70)', () => {
8
+ it('marks the dropped column\'s head and cells with the width they drop below', () => {
9
+ const { container } = render(
10
+ <Table>
11
+ <TableHead><TableRow><TableHeader>Name</TableHeader><TableHeader hideBelow="lg">Started</TableHeader></TableRow></TableHead>
12
+ <TableBody><TableRow><TableCell>Login</TableCell><TableCell hideBelow="lg">4 h ago</TableCell></TableRow></TableBody>
13
+ </Table>,
14
+ );
15
+ expect([...container.querySelectorAll('[data-hide-below]')].map((c) => c.getAttribute('data-hide-below'))).toEqual(['lg', 'lg']);
16
+ expect(container.querySelector('table')!.className).toContain('@max-[896px]:[&_tr>:nth-last-child(1_of_:not([data-hide-below=xl]):not([data-hide-below=lg]))]:pr-(--card-pad)');
17
+ });
18
+ });