@wallarm-org/mcp 3.3.0 → 3.4.0-rc-feat-ds-templates.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @wallarm-org/mcp
2
2
 
3
- MCP (Model Context Protocol) server for Wallarm Design System. Provides component metadata, props, variants, design tokens, design guidelines (foundations and patterns), and search capabilities to AI assistants via stdio transport.
3
+ MCP (Model Context Protocol) server for Wallarm Design System. Provides component metadata, props, variants, design tokens, design guidelines (foundations, patterns and templates), and search capabilities to AI assistants via stdio transport.
4
4
 
5
5
  Full guide: [wallarm.github.io/design-system/ai/mcp](https://wallarm.github.io/design-system/ai/mcp).
6
6
 
@@ -58,7 +58,7 @@ For local development or custom setups:
58
58
  | `get_example` | `{ component: string, example: string, page?: number }` | One example's code. Keys are story names, `<Story file>/<name>` when a name repeats; fuzzy match (4+ characters, flagged as "Closest match"), lists names on a miss |
59
59
  | `search_token` | `{ query: string }` | Search design tokens by name, Tailwind class, value, or category; results include the utility classes. Top 20 by relevance |
60
60
  | `get_token_category` | `{ name: string, page?: number }` | All tokens in a category (case-insensitive) with Tailwind classes, e.g. `spacing`, `colors-primary` |
61
- | `list_guidelines` | `{ kind?: 'foundation' \| 'pattern' }` | Guidelines with slug, title, description and the components they use |
61
+ | `list_guidelines` | `{ kind?: 'foundation' \| 'pattern' \| 'template' }` | Guidelines with slug, title, description and the components they use |
62
62
  | `get_guideline` | `{ slug: string, section?: string, page?: number }` | A guideline as markdown, or one section (`rules`, `example`, …). A pattern's examples carry its stories' code, and the overview ends with their names; `section: 'stories'` returns all story code. Long pages and sections are paginated. Also accepts a guideline's own relative links (`../foundations/spacing.md#rules`, the anchor picks the section). Unknown slugs get the nearest slugs |
63
63
 
64
64
  `get_component` also lists the guidelines that use the component.
package/dist/index.js CHANGED
@@ -5308,7 +5308,7 @@ function refine(fn, _params = {}) {
5308
5308
  function superRefine(fn, params) {
5309
5309
  return _superRefine(fn, params);
5310
5310
  }
5311
- const rulesraw_namespaceObject = "Every numeric utility is in px, not 4px steps: p-16 is 16px, p-4 is 4px. Translate Tailwind habits (p-4 → p-16, gap-6 → gap-24, w-64 → w-256).\n\nBefore composing a screen, call get_guideline for the pattern (app shell, list page, table, drawer, form, dialog, filters, empty state, settings, auth). Wrong → right:\n\n- p-5, gap-3, p-10 → stay on the scale: 1, 2, 4, 6, 8, 12, 16, 20, 24, 28, 32, 40, 48, 64…\n- space-y-4, margins per child → <VStack gap={16}>, <HStack gap={8}>\n- Padded page wrapper → Page > PageHeader / PageToolbar / PageContent own the 24px gutter\n- p-* on Card, DrawerBody, DialogBody, PageToolbar, Table → none; they own their padding\n- className on Text, Heading, Field, Drawer/Dialog Header/Body/Footer → wrap in your own element\n- Raw <h1>, <p>, <label> → <Heading as=\"h2\" size=\"lg\">, <Text size=\"sm\">, Field\n- <Text> default (16px) for body → size=\"sm\" (md is only an alias of sm)\n- leading-none (0px), leading-5 (5px) → leading-sm or leading-20\n- weight=\"semibold\" → weight is light|regular|medium|bold (medium = 450)\n- bg-primary, text-muted, bg-card, bg-white, text-gray-500 → bg-bg-primary, text-text-secondary, bg-bg-surface-1\n- text-error, bg-red-500 → danger tokens; categorical color → Badge color\n- dark: variant, setting data-theme → semantic tokens, useTheme().setTheme()\n- rounded-lg, rounded, rounded-[15px] → rounded-8 (controls), rounded-12 (panels)\n- shadow-* on DS components, z-* on overlays → never; they own both\n- focus:ring → focus-visible:ring-3 ring-focus-primary outline-none\n- size, DIALOG_SIZES, DRAWER_SIZES → width: Dialog 400 confirm, 560 default, 960 picker; Drawer 640 default, 700 Timeline form\n- Dialog, useToast from the root → @wallarm-org/design-system/Dialog, /Toast\n- Field invalid → error on the control + FieldError\n- Hand-built side menu → RemoteShell config={NavConfig}\n- ConfirmDialog, FilterBar, Combobox → compose Dialog parts, PageToolbar + FilterDropdown, Select + useSelectSearch\n- data-slot in tests → data-testid\n";
5311
+ const rulesraw_namespaceObject = "Every numeric utility is in px, not 4px steps: p-16 is 16px, p-4 is 4px. Translate Tailwind habits (p-4 → p-16, gap-6 → gap-24, w-64 → w-256).\n\nBefore composing a screen, call get_guideline for the pattern (app shell, list page, table, drawer, form, dialog, filters, empty state, settings, auth). Wrong → right:\n\n- p-5, gap-3, p-10 → stay on the scale: 1, 2, 4, 6, 8, 12, 16, 20, 24, 28, 32, 40, 48, 64…\n- space-y-4, margins per child → <VStack gap={16}>, <HStack gap={8}>\n- Padded page wrapper → PageHeader / PageToolbar / PageContent own the 24px gutter\n- p-* on Card, DrawerBody, DialogBody, PageToolbar, Table → none; they own their padding\n- className on Text, Heading, Field, Drawer/Dialog Header/Body/Footer → wrap in your own element\n- Raw <h1>, <p>, <label> → <Heading as=\"h2\" size=\"lg\">, <Text size=\"sm\">, Field\n- <Text> default (16px) for body → size=\"sm\"\n- leading-none (0px), leading-5 (5px) → leading-sm, leading-20\n- weight=\"semibold\" → weight is light|regular|medium|bold (medium = 450)\n- bg-primary, text-muted, bg-card, bg-white → bg-bg-primary, text-text-secondary, bg-bg-surface-1\n- text-error, bg-red-500 → danger tokens; categorical color → Badge color\n- dark: variant, setting data-theme → semantic tokens, useTheme().setTheme()\n- rounded-lg, rounded, rounded-[15px] → rounded-8 (controls), rounded-12 (panels)\n- shadow-* on DS components, z-* on overlays → never; they own both\n- focus:ring → focus-visible:ring-3 ring-focus-primary outline-none\n- size, DIALOG/DRAWER_SIZES → width: Dialog 400 confirm, 560 default, 960 picker; Drawer 640 default, 700 Timeline form\n- Dialog, useToast from the root → @wallarm-org/design-system/Dialog, /Toast\n- Field invalid → error on the control + FieldError\n- Table actions column → meta.renderMenuAction on the master column\n- Hand-built side menu → RemoteShell config={NavConfig}\n- ConfirmDialog, FilterBar, Combobox → compose Dialog parts, PageToolbar + FilterDropdown, Select + useSelectSearch\n- data-slot in tests → data-testid\n";
5312
5312
  const propMetadataSchema = schemas_object({
5313
5313
  name: schemas_string(),
5314
5314
  type: schemas_string(),
@@ -5375,7 +5375,8 @@ const guidelineStorySchema = schemas_object({
5375
5375
  });
5376
5376
  const guidelineKindSchema = schemas_enum([
5377
5377
  'foundation',
5378
- 'pattern'
5378
+ 'pattern',
5379
+ 'template'
5379
5380
  ]);
5380
5381
  const guidelineSchema = schemas_object({
5381
5382
  slug: schemas_string(),
@@ -5768,7 +5769,7 @@ function formatComponent(c, options = {}) {
5768
5769
  const footer = s ? '' : `\n\n---\nMore: \`get_component\` with section ${componentSections(c).slice(0, 4).map((x)=>`\`'${x}'\``).join(', ')}${c.subComponents.length > 0 ? ' or a sub-component name' : ''}.`;
5769
5770
  return `${text}${footer}${source ? `\n\n_${source}_` : ''}`;
5770
5771
  }
5771
- const ROUTING = "Tools: `search_component` / `get_component` for components — `get_component` returns an overview, pass `section` for full props, sub-components or examples, and `get_example` for one example's code; `search_token` / `get_token_category` for design tokens; `list_guidelines` / `get_guideline` for foundations (spacing, color, typography, …) and patterns (screens and flows). Read the matching pattern with `get_guideline` before composing a screen.";
5772
+ const ROUTING = "Tools: `search_component` / `get_component` for components — `get_component` returns an overview, pass `section` for full props, sub-components or examples, and `get_example` for one example's code; `search_token` / `get_token_category` for design tokens; `list_guidelines` / `get_guideline` for foundations (spacing, color, typography, …), patterns (screens and flows) and templates (complete example screens to start from). Read the matching pattern with `get_guideline` before composing a screen; for a whole page, start from the closest template.";
5772
5773
  function buildInstructions(rules, source) {
5773
5774
  return `${rules.trim()}\n\n${ROUTING}${source ? `\n\n${source}.` : ''}`;
5774
5775
  }
@@ -6454,12 +6455,13 @@ async function main() {
6454
6455
  };
6455
6456
  });
6456
6457
  server.registerTool('list_guidelines', {
6457
- description: "Use when starting a screen or flow, or unsure about spacing, colour or typography rules — lists foundations and patterns with slug, title, description and components.",
6458
+ description: "Use when starting a screen or flow, or unsure about spacing, colour or typography rules — lists foundations, patterns and templates (complete example screens) with slug, title, description and components.",
6458
6459
  inputSchema: {
6459
6460
  kind: schemas_enum([
6460
6461
  'foundation',
6461
- 'pattern'
6462
- ]).optional().describe('Only list foundations or only patterns; omit for both')
6462
+ 'pattern',
6463
+ 'template'
6464
+ ]).optional().describe('Only list one kind; omit for all')
6463
6465
  }
6464
6466
  }, async ({ kind })=>({
6465
6467
  content: [
@@ -6470,9 +6472,9 @@ async function main() {
6470
6472
  ]
6471
6473
  }));
6472
6474
  server.registerTool('get_guideline', {
6473
- description: "Use when composing a screen (read the matching pattern first) or applying a foundation — returns the guideline as markdown. Pass `section` to get one section only; `section: 'stories'` returns a pattern's full live-story code (paginated — pass `page`).",
6475
+ description: "Use when composing a screen (read the matching pattern first) or applying a foundation — returns the guideline as markdown. Pass `section` to get one section only; `section: 'stories'` returns a pattern's or template's full live-story code (paginated — pass `page`).",
6474
6476
  inputSchema: {
6475
- slug: schemas_string().describe('Guideline slug from list_guidelines, e.g. "patterns/entity-table" or "foundations/spacing"'),
6477
+ slug: schemas_string().describe('Guideline slug from list_guidelines, e.g. "patterns/entity-table", "templates/security-dashboard" or "foundations/spacing"'),
6476
6478
  section: schemas_string().optional().describe('Section id or heading, e.g. "rules", "examples", "states", "stories"'),
6477
6479
  page: schemas_number().int().min(1).optional().describe('Page of a paginated response, from 1')
6478
6480
  }
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "4.7.0",
3
- "generatedAt": "2026-11-05T18:24:15.138Z",
2
+ "version": "4.8.0",
3
+ "generatedAt": "2026-11-06T15:12:27.014Z",
4
4
  "components": [
5
5
  {
6
6
  "name": "Accordion",
@@ -22895,7 +22895,7 @@
22895
22895
  },
22896
22896
  {
22897
22897
  "name": "WithIcons",
22898
- "code": "import { List, ListIcon, ListItem } from '@wallarm-org/design-system/List';\n\nexport const WithIcons = () => (\n <List>\n <ListItem>\n <ListIcon className='text-text-success'>&#10003;</ListIcon>\n Completed task\n </ListItem>\n <ListItem>\n <ListIcon className='text-text-warning'>&#9888;</ListIcon>\n Pending review\n </ListItem>\n <ListItem>\n <ListIcon className='text-text-error'>&#10007;</ListIcon>\n Failed check\n </ListItem>\n </List>\n);",
22898
+ "code": "import { List, ListIcon, ListItem } from '@wallarm-org/design-system/List';\n\nexport const WithIcons = () => (\n <List>\n <ListItem>\n <ListIcon className='text-text-success'>&#10003;</ListIcon>\n Completed task\n </ListItem>\n <ListItem>\n <ListIcon className='text-text-warning'>&#9888;</ListIcon>\n Pending review\n </ListItem>\n <ListItem>\n <ListIcon className='text-text-danger'>&#10007;</ListIcon>\n Failed check\n </ListItem>\n </List>\n);",
22899
22899
  "storyFile": "List/List.stories.tsx",
22900
22900
  "storyTitle": "Data Display/List",
22901
22901
  "description": "`ListIcon` puts a meaningful glyph where the marker would be, coloured by what the row\nsays; `ListItem` is a flex row, so the icon and its text share a baseline."
@@ -36081,31 +36081,31 @@
36081
36081
  "docs": [
36082
36082
  {
36083
36083
  "name": "BarList",
36084
- "markdown": "# BarList\n\n## Overview\n\n`BarList` is a composable, JSX-driven horizontal bar list. Callers render one `BarListItem` per data point and decide which slots to compose inside each row (bar, label, value, percent). The parent supplies `max` — the single number that defines 100% for the list. There is no recharts dependency and no internal aggregation: bar width is always `clamp(value / max, 0, 1) × 100%`.\n\nUse it for compact \"top N\" breakdowns inside a `Chart` card — endpoints, status codes, tags, regions — whenever the chart must be small, read fast, and optionally become a filter control.\n\n## Figma\n\n- Root (states: default / hovered / filtered): https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7490-121720&m=dev\n- Row/bar anatomy: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7527-31781&m=dev\n- Loading skeleton: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7519-2600&m=dev\n\n## API\n\nCompound component:\n\n- `BarList` — root. Requires `max`. Provides context for children.\n- `BarListItem` — row (`h-32`). Accepts `value` and `selected`. Becomes interactive when `onClick` is passed.\n- `BarListBar` — the coloured fill. Absolute-positioned under label/value. Accepts `color` from `ChartColor` (each variant renders a 16% alpha overlay of the palette's `500` hue — matching the default slate bar which is `bg-states-primary-pressed` = `slate-500 @ 16%`). Translucent fills let the row's hover / focus tint layer through the bar so interactive state stays visible. For any colour outside the palette, pass a Tailwind `bg-*` utility via `className` (e.g. `<BarListBar className='bg-sky-500/16' />`); `tailwind-merge` resolves the conflict in favour of the explicit class.\n- `BarListLabel` — left-side label slot.\n- `BarListValue` — right-side value slot; place `BarListPercent` inside to get the auto-formatted share.\n- `BarListPercent` — reads `value / max` from context and renders `NN%`. Accepts `digits` and `variant` (`split` | `muted` | `inherit`; default `split`). The value and the `%` symbol are rendered as separate spans (`data-slot='bar-list-percent'` on the root, `data-slot='bar-list-percent-symbol'` on the `%`) so the `split` variant — which matches Figma — can color them independently. `children` override the auto-formatted content when a caller needs full control.\n- `BarListSkeleton` — standalone loading view; use it instead of `BarList` while data is loading.\n\n## Data model\n\n- **`max`** (required, number) — the value at which a bar is full.\n - Percent inputs → `max={100}`.\n - Fractions → `max={1}`.\n - Raw counts → `Math.max(...values)` or the total, depending on what \"100%\" should mean.\n - Invalid (`NaN`, `Infinity`, `-Infinity`, `0`, negative, non-number) → every row renders empty with a `0%` label. A single `console.warn` fires in development.\n- **`value`** (per item, number) — raw magnitude.\n - Non-finite → treated as `0`.\n - Ratio is clamped to `[0, 1]`; values above `max` cap the bar at 100% but the percent label also caps at `100%`.\n- **`selected`** (per item, boolean) — purely visual: applies the active background (`bg-states-primary-active`) and `aria-current=\"true\"`. The bar width and the percent label are driven entirely by `value / max`; selection never changes the fill.\n\n`BarList` does not sort, slice, or aggregate its input — callers already have `data`, they pass it in whatever order they want to display it.\n\n## States\n\n| State | Trigger | Behaviour |\n| ----------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- |\n| Default | — | Grey bar (`bg-states-primary-pressed`) at `value / max` width. |\n| Hover | Pointer over a row | Row background shifts to `bg-states-primary-hover`. Pure CSS; no JS. |\n| Focus | Keyboard focus on a clickable row | Same hover background + `ring-focus-primary` inset ring via `focus-visible`. |\n| Selected | `<BarListItem selected />` | Row gets the active background (`bg-states-primary-active`) + `aria-current=\"true\"`. Bar and percent label stay at `value / max`. |\n| Loading | Render `<BarListSkeleton rows={n} />` instead of `BarList` | Five (or `rows`) pulse bars using the design system's `Skeleton`. |\n| Empty | Render `<ChartEmpty />` instead of `BarList` | Shared chart empty-state body with a centered \"No data\" message. Pass children to override. |\n\n## Interactions\n\n- **Clickable row** — attaching `onClick` to `BarListItem` turns the row into a button: `role=\"button\"`, `tabIndex=0`, Enter/Space dispatch a click. Without `onClick`, the row is a plain `div` with no keyboard affordance.\n- **Filter pattern** — the caller owns the filter state. Typical flow: `onClick` on each row toggles external state; the caller re-renders `BarList` with a single child and sets `selected` on it. `BarListPercent` still reads the real underlying ratio.\n- **Tooltip** — not baked in. Wrap `BarListItem` with the design system's `Tooltip` when a row needs a hint (\"Click to filter\", full endpoint path, …).\n\n## Edge cases & unclear states\n\n- **Ratio clamping** — `value > max` renders the bar at 100% but the percent label also caps at `100%`. There is no special rendering for \"overflow\".\n- **Selection and fill are orthogonal** — `selected` is purely a visual state (background + `aria-current`). The bar width and percent label are always `value / max`. To show a row \"filling to 100%\" when filtered, recompute `max` from the filtered set (e.g. `max={Math.max(...visibleRows.map(r => r.value))}`) instead of relying on `selected`.\n- **Invalid `max`** — `NaN`, `Infinity`, `-Infinity`, `0`, negative, or non-number: every row renders empty with a `0%` label, and a single `console.warn` fires in development. Production stays silent.\n- **Label overlap** — the bar is absolutely positioned; the label sits in the row's flex flow on top of it (`flex-1 min-w-0`) and truncates with an ellipsis before the value.\n- **Zero values** — `value = 0` renders an empty bar and `0%`. No special \"no data\" treatment.\n- **Mount animation** — bars transition their width (`duration-300 ease-out`). On mount they grow from zero via `starting:w-0` (`@starting-style`); the same transition runs for subsequent width changes (e.g. filtering).\n- **`BarListPercent` without a parent `BarListItem`** — renders `0%`. There is no runtime error.\n- **`BarListPercent` color variants** — `split` (default, matches Figma) colors the numeric value with `text-text-primary` and the `%` symbol with `text-text-secondary`, giving the row a \"value > unit\" hierarchy. `muted` pulls both tokens down to `text-text-secondary` when the percent should read as a single de-emphasized label. `inherit` drops both colors so the percent follows whatever color `BarListValue` (or any ancestor) sets — useful when a row needs to read as a single colored token.\n\n## Accessibility notes\n\n- Clickable rows are exposed as `role=\"button\"` with the label text as the accessible name (via visible text content). Pass `aria-label` if the visible text is not descriptive enough.\n- `selected` sets `aria-current=\"true\"`.\n- Focus ring uses `focus-visible` so keyboard users see a ring but pointer clicks do not.\n- The bar element is `aria-hidden` — it is a pure visual and should not be announced.\n- `BarListSkeleton` sets `aria-busy=\"true\"` + `aria-live=\"polite\"` so assistive tech announces the loading transition.\n\n## Open questions / known limitations\n\n- **Convenience `data` prop.** The composable API is the only shape shipped; a `data` shorthand (`<BarList data={…}>`) may be added later if two or three call-sites need it.\n- **Auto-inference of `max`.** Not shipped — the current contract requires `max`. Revisit once a real use case surfaces where the caller cannot compute it.\n- **Empty-state styling.** Use `ChartEmpty` as a sibling of `ChartHeader` when the row list is empty — `BarList` itself does not render a placeholder, and `ChartEmpty` is the shared chart empty-state body (`<ChartEmpty />` for the default \"No data\" label; pass children for filter-specific copy).\n- **Tooltip wiring.** No `tooltip` prop. Consumers compose `<Tooltip>` externally around `BarListItem`."
36084
+ "markdown": "# BarList\n\n## Overview\n\n`BarList` is a composable, JSX-driven horizontal bar list. Callers render one `BarListItem` per data point and decide which slots to compose inside each row (bar, label, value, percent). The parent supplies `max` — the single number that defines 100% for the list. There is no recharts dependency and no internal aggregation: bar width is always `clamp(value / max, 0, 1) × 100%`.\n\nUse it for compact \"top N\" breakdowns inside a `Chart` card — endpoints, status codes, tags, regions — whenever the chart must be small, read fast, and optionally become a filter control.\n\n## API\n\nCompound component:\n\n- `BarList` — root. Requires `max`. Provides context for children.\n- `BarListItem` — row (`h-32`). Accepts `value` and `selected`. Becomes interactive when `onClick` is passed.\n- `BarListBar` — the coloured fill. Absolute-positioned under label/value. Accepts `color` from `ChartColor` (each variant renders a 16% alpha overlay of the palette's `500` hue — matching the default slate bar which is `bg-states-primary-pressed` = `slate-500 @ 16%`). Translucent fills let the row's hover / focus tint layer through the bar so interactive state stays visible. For any colour outside the palette, pass a Tailwind `bg-*` utility via `className` (e.g. `<BarListBar className='bg-sky-500/16' />`); `tailwind-merge` resolves the conflict in favour of the explicit class.\n- `BarListLabel` — left-side label slot.\n- `BarListValue` — right-side value slot; place `BarListPercent` inside to get the auto-formatted share.\n- `BarListPercent` — reads `value / max` from context and renders `NN%`. Accepts `digits` and `variant` (`split` | `muted` | `inherit`; default `split`). The value and the `%` symbol are rendered as separate spans (`data-slot='bar-list-percent'` on the root, `data-slot='bar-list-percent-symbol'` on the `%`) so the `split` variant — which matches Figma — can color them independently. `children` override the auto-formatted content when a caller needs full control.\n- `BarListSkeleton` — standalone loading view; use it instead of `BarList` while data is loading.\n\n## Data model\n\n- **`max`** (required, number) — the value at which a bar is full.\n - Percent inputs → `max={100}`.\n - Fractions → `max={1}`.\n - Raw counts → `Math.max(...values)` or the total, depending on what \"100%\" should mean.\n - Invalid (`NaN`, `Infinity`, `-Infinity`, `0`, negative, non-number) → every row renders empty with a `0%` label. A single `console.warn` fires in development.\n- **`value`** (per item, number) — raw magnitude.\n - Non-finite → treated as `0`.\n - Ratio is clamped to `[0, 1]`; values above `max` cap the bar at 100% but the percent label also caps at `100%`.\n- **`selected`** (per item, boolean) — purely visual: applies the active background (`bg-states-primary-active`) and `aria-current=\"true\"`. The bar width and the percent label are driven entirely by `value / max`; selection never changes the fill.\n\n`BarList` does not sort, slice, or aggregate its input — callers already have `data`, they pass it in whatever order they want to display it.\n\n## States\n\n| State | Trigger | Behaviour |\n| ----------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- |\n| Default | — | Grey bar (`bg-states-primary-pressed`) at `value / max` width. |\n| Hover | Pointer over a row | Row background shifts to `bg-states-primary-hover`. Pure CSS; no JS. |\n| Focus | Keyboard focus on a clickable row | Same hover background + `ring-focus-primary` inset ring via `focus-visible`. |\n| Selected | `<BarListItem selected />` | Row gets the active background (`bg-states-primary-active`) + `aria-current=\"true\"`. Bar and percent label stay at `value / max`. |\n| Loading | Render `<BarListSkeleton rows={n} />` instead of `BarList` | Five (or `rows`) pulse bars using the design system's `Skeleton`. |\n| Empty | Render `<ChartEmpty />` instead of `BarList` | Shared chart empty-state body with a centered \"No data\" message. Pass children to override. |\n\n## Interactions\n\n- **Clickable row** — attaching `onClick` to `BarListItem` turns the row into a button: `role=\"button\"`, `tabIndex=0`, Enter/Space dispatch a click. Without `onClick`, the row is a plain `div` with no keyboard affordance.\n- **Filter pattern** — the caller owns the filter state. Typical flow: `onClick` on each row toggles external state; the caller re-renders `BarList` with a single child and sets `selected` on it. `BarListPercent` still reads the real underlying ratio.\n- **Tooltip** — not baked in. Wrap `BarListItem` with the design system's `Tooltip` when a row needs a hint (\"Click to filter\", full endpoint path, …).\n\n## Edge cases & unclear states\n\n- **Ratio clamping** — `value > max` renders the bar at 100% but the percent label also caps at `100%`. There is no special rendering for \"overflow\".\n- **Selection and fill are orthogonal** — `selected` is purely a visual state (background + `aria-current`). The bar width and percent label are always `value / max`. To show a row \"filling to 100%\" when filtered, recompute `max` from the filtered set (e.g. `max={Math.max(...visibleRows.map(r => r.value))}`) instead of relying on `selected`.\n- **Invalid `max`** — `NaN`, `Infinity`, `-Infinity`, `0`, negative, or non-number: every row renders empty with a `0%` label, and a single `console.warn` fires in development. Production stays silent.\n- **Label overlap** — the bar is absolutely positioned; the label sits in the row's flex flow on top of it (`flex-1 min-w-0`) and truncates with an ellipsis before the value.\n- **Zero values** — `value = 0` renders an empty bar and `0%`. No special \"no data\" treatment.\n- **Mount animation** — bars transition their width (`duration-300 ease-out`). On mount they grow from zero via `starting:w-0` (`@starting-style`); the same transition runs for subsequent width changes (e.g. filtering).\n- **`BarListPercent` without a parent `BarListItem`** — renders `0%`. There is no runtime error.\n- **`BarListPercent` color variants** — `split` (default, matches Figma) colors the numeric value with `text-text-primary` and the `%` symbol with `text-text-secondary`, giving the row a \"value > unit\" hierarchy. `muted` pulls both tokens down to `text-text-secondary` when the percent should read as a single de-emphasized label. `inherit` drops both colors so the percent follows whatever color `BarListValue` (or any ancestor) sets — useful when a row needs to read as a single colored token.\n\n## Accessibility notes\n\n- Clickable rows are exposed as `role=\"button\"` with the label text as the accessible name (via visible text content). Pass `aria-label` if the visible text is not descriptive enough.\n- `selected` sets `aria-current=\"true\"`.\n- Focus ring uses `focus-visible` so keyboard users see a ring but pointer clicks do not.\n- The bar element is `aria-hidden` — it is a pure visual and should not be announced.\n- `BarListSkeleton` sets `aria-busy=\"true\"` + `aria-live=\"polite\"` so assistive tech announces the loading transition.\n\n## Open questions / known limitations\n\n- **Convenience `data` prop.** The composable API is the only shape shipped; a `data` shorthand (`<BarList data={…}>`) may be added later if two or three call-sites need it.\n- **Auto-inference of `max`.** Not shipped — the current contract requires `max`. Revisit once a real use case surfaces where the caller cannot compute it.\n- **Empty-state styling.** Use `ChartEmpty` as a sibling of `ChartHeader` when the row list is empty — `BarList` itself does not render a placeholder, and `ChartEmpty` is the shared chart empty-state body (`<ChartEmpty />` for the default \"No data\" label; pass children for filter-specific copy).\n- **Tooltip wiring.** No `tooltip` prop. Consumers compose `<Tooltip>` externally around `BarListItem`."
36085
36085
  },
36086
36086
  {
36087
36087
  "name": "Chart",
36088
- "markdown": "# Chart\n\n## Overview\n\n`Chart` is the generic card container used by every SimpleCharts visualization. It owns only the card's surface, border, default sizing and the hover-reveal behaviour of its action buttons — the actual chart (bars, pie, line, …) is composed inside.\n\nUse it whenever a chart needs the standard Wallarm \"simple chart\" frame: a rounded surface with a 32px title bar and an optional actions cluster in the top-right that appears on hover.\n\n## Figma\n\n- Root (container + annotations): https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7490-118206&m=dev\n- Header: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7527-31783&m=dev\n- Actions block: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7527-31786&m=dev\n\n## API\n\nCompound component:\n\n- `Chart` — root surface.\n- `ChartHeader` — 32px bar, renders title + actions side by side.\n- `ChartTitle` — text slot; single line, truncates with ellipsis.\n- `ChartActions` — right-aligned button cluster; revealed on card hover/focus by default, and takes no layout space while hidden so the title has the full header width.\n- `ChartEmpty` — centered placeholder for the body when there is no data. Defaults to \"No data\"; pass children to override.\n\n### Sizing\n\n`Chart` is a **block-level** element by default:\n\n- width: `w-full` (fills the parent's content area).\n- min-height: `196px` (Figma spec); `128px` when the card holds a `ColumnChart` (WDS-194).\n\nConsumers customise size through `className`, not dedicated props. This keeps the API small and lets Tailwind utilities compose naturally (`w-[640px]`, `h-320`, `max-w-lg`, `aspect-[16/9]`, …). If you need something rigid, prefer a wrapper:\n\n```tsx\n<div className='w-400'>\n <Chart>…</Chart>\n</div>\n```\n\n### `ChartEmpty`\n\n`ChartEmpty` is the shared empty-state body. It fills the remaining card height with a centered secondary-text message. Use it whenever a chart has no data to display — every simple chart family member opts into the same visual:\n\n```tsx\n<Chart>\n <ChartHeader>\n <ChartTitle>Top 5 Endpoints</ChartTitle>\n </ChartHeader>\n {rows.length === 0 ? <ChartEmpty /> : <BarList max={max}>…</BarList>}\n</Chart>\n```\n\nChildren override the default \"No data\" label for filter-specific messaging (e.g. `<ChartEmpty>No endpoints match the current filter</ChartEmpty>`).\n\n### `ChartActions.alwaysVisible`\n\n`ChartActions` is collapsed to `w-0` with `opacity-0 pointer-events-none` by default and expands to `w-auto opacity-100` on `group-hover/chart` and `group-focus-within/chart`. Because the cluster takes no layout space while idle, `ChartTitle` occupies the whole header width and only truncates (shifts left) when the actions actually appear. The buttons stay in the tab order the whole time — pressing `Tab` into the card expands the cluster via `focus-within` so keyboard users can reach them without hovering. Pass `alwaysVisible` to keep the cluster in the layout permanently — required when the action represents active chart state (e.g. an active \"Remove filter\" button) that should not be hidden.\n\n## Data model\n\nNone — `Chart` is a presentational container. Chart content lives in its children.\n\n## States\n\n| State | Trigger | Behaviour |\n| ------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Default | — | Surface + border + min-height only. `ChartActions` is collapsed (`w-0 opacity-0 pointer-events-none`), so `ChartTitle` has the full header width. Buttons remain focusable via Tab. |\n| Hover (card-hover) | Pointer enters any part of the card | `ChartActions` expands to `w-auto opacity-100` and the title shifts left / truncates to make room. |\n| Focus-within | Keyboard focus lands on any child | Same as hover — actions become visible so keyboard users can reach them. |\n| Actions always-on | `<ChartActions alwaysVisible />` | Actions stay in the flow at full width regardless of hover/focus. Use this when the action itself represents an active state (filtered, selected, pinned). |\n\n## Interactions\n\nNo intrinsic interactions. The container only coordinates hover-reveal of `ChartActions` via CSS group selectors (`group/chart`) on the root.\n\nChildren (buttons, tooltips, dropdowns) own their own interactions and can use the `ChartActions` slot as a neutral layout parent.\n\n## Edge cases & unclear states\n\n- **Long title.** `ChartTitle` has `truncate` + `flex-1 min-w-0` applied, so it claims the full header width until `ChartActions` appears. At that point the title shrinks and is cut with an ellipsis. Wrap the title in a tooltip if the full string needs to be discoverable.\n- **No actions cluster.** `ChartHeader` uses `justify-between`, so a title alone flows left and the right side stays empty; `ChartActions` can be omitted entirely.\n- **Nested `group/*` utilities.** The root uses the namespaced group `group/chart` so it does not clash with unrelated `group-hover:*` rules from parent or child components.\n- **Hover reveal vs. touch devices.** On touch, `:hover` is simulated on tap, so actions become reachable but not discoverable. Consider `alwaysVisible` on touch-first screens.\n\n## Accessibility notes\n\n- The root is a plain `<div>` — this is a layout primitive, not an interactive region. Semantics come from the content placed inside (buttons, links, chart SVGs with ARIA).\n- Actions are revealed on `focus-within`, so keyboard users can always reach them via Tab even without a pointer.\n- Use an accessible name on each action button (`aria-label` for icon-only buttons).\n\n## Open questions / known limitations\n\n- The container draws no states of its own. Empty is the optional `ChartEmpty` body (plus `LineChartEmpty` for line/area plots); each chart ships its own skeleton (`BarListSkeleton`, `PieChartSkeleton`, …); there is no shared error state.\n- The hover-reveal relies on `group/chart` on the root. If a caller wraps `ChartActions` in another `group/chart`, the nearest group wins — document this once a real collision shows up."
36088
+ "markdown": "# Chart\n\n## Overview\n\n`Chart` is the generic card container used by every SimpleCharts visualization. It owns only the card's surface, border, default sizing and the hover-reveal behaviour of its action buttons — the actual chart (bars, pie, line, …) is composed inside.\n\nUse it whenever a chart needs the standard Wallarm \"simple chart\" frame: a rounded surface with a 32px title bar and an optional actions cluster in the top-right that appears on hover.\n\n## API\n\nCompound component:\n\n- `Chart` — root surface.\n- `ChartHeader` — 32px bar, renders title + actions side by side.\n- `ChartTitle` — text slot; single line, truncates with ellipsis.\n- `ChartActions` — right-aligned button cluster; revealed on card hover/focus by default, and takes no layout space while hidden so the title has the full header width.\n- `ChartEmpty` — centered placeholder for the body when there is no data. Defaults to \"No data\"; pass children to override.\n\n### Sizing\n\n`Chart` is a **block-level** element by default:\n\n- width: `w-full` (fills the parent's content area).\n- height: content-sized, no minimum — a metric card can be shorter than a chart card. Give a fixed height through `className` (`h-196`) when cards in a row must match; a CSS grid row already stretches them. In a stretched card `Metric` and `HorizontalBarStack` sit at the bottom (`mt-auto`); `ColumnChart` grows to fill it.\n\nConsumers customise size through `className`, not dedicated props. This keeps the API small and lets Tailwind utilities compose naturally (`w-[640px]`, `h-320`, `max-w-lg`, `aspect-[16/9]`, …). If you need something rigid, prefer a wrapper:\n\n```tsx\n<div className='w-400'>\n <Chart>…</Chart>\n</div>\n```\n\n### `ChartEmpty`\n\n`ChartEmpty` is the shared empty-state body. It fills the remaining card height with a centered secondary-text message. Use it whenever a chart has no data to display — every simple chart family member opts into the same visual:\n\n```tsx\n<Chart>\n <ChartHeader>\n <ChartTitle>Top 5 Endpoints</ChartTitle>\n </ChartHeader>\n {rows.length === 0 ? <ChartEmpty /> : <BarList max={max}>…</BarList>}\n</Chart>\n```\n\nChildren override the default \"No data\" label for filter-specific messaging (e.g. `<ChartEmpty>No endpoints match the current filter</ChartEmpty>`).\n\n### `ChartActions.alwaysVisible`\n\n`ChartActions` is collapsed to `w-0` with `opacity-0 pointer-events-none` by default and expands to `w-auto opacity-100` on `group-hover/chart` and `group-focus-within/chart`. Because the cluster takes no layout space while idle, `ChartTitle` occupies the whole header width and only truncates (shifts left) when the actions actually appear. The buttons stay in the tab order the whole time — pressing `Tab` into the card expands the cluster via `focus-within` so keyboard users can reach them without hovering. Pass `alwaysVisible` to keep the cluster in the layout permanently — required when the action represents active chart state (e.g. an active \"Remove filter\" button) that should not be hidden.\n\n## Data model\n\nNone — `Chart` is a presentational container. Chart content lives in its children.\n\n## States\n\n| State | Trigger | Behaviour |\n| ------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Default | — | Surface + border only. `ChartActions` is collapsed (`w-0 opacity-0 pointer-events-none`), so `ChartTitle` has the full header width. Buttons remain focusable via Tab. |\n| Hover (card-hover) | Pointer enters any part of the card | `ChartActions` expands to `w-auto opacity-100` and the title shifts left / truncates to make room. |\n| Focus-within | Keyboard focus lands on any child | Same as hover — actions become visible so keyboard users can reach them. |\n| Actions always-on | `<ChartActions alwaysVisible />` | Actions stay in the flow at full width regardless of hover/focus. Use this when the action itself represents an active state (filtered, selected, pinned). |\n\n## Interactions\n\nNo intrinsic interactions. The container only coordinates hover-reveal of `ChartActions` via CSS group selectors (`group/chart`) on the root.\n\nChildren (buttons, tooltips, dropdowns) own their own interactions and can use the `ChartActions` slot as a neutral layout parent.\n\n## Edge cases & unclear states\n\n- **Long title.** `ChartTitle` has `truncate` + `flex-1 min-w-0` applied, so it claims the full header width until `ChartActions` appears. At that point the title shrinks and is cut with an ellipsis. Wrap the title in a tooltip if the full string needs to be discoverable.\n- **No actions cluster.** `ChartHeader` uses `justify-between`, so a title alone flows left and the right side stays empty; `ChartActions` can be omitted entirely.\n- **Nested `group/*` utilities.** The root uses the namespaced group `group/chart` so it does not clash with unrelated `group-hover:*` rules from parent or child components.\n- **Hover reveal vs. touch devices.** On touch, `:hover` is simulated on tap, so actions become reachable but not discoverable. Consider `alwaysVisible` on touch-first screens.\n\n## Accessibility notes\n\n- The root is a plain `<div>` — this is a layout primitive, not an interactive region. Semantics come from the content placed inside (buttons, links, chart SVGs with ARIA).\n- Actions are revealed on `focus-within`, so keyboard users can always reach them via Tab even without a pointer.\n- Use an accessible name on each action button (`aria-label` for icon-only buttons).\n\n## Open questions / known limitations\n\n- The container draws no states of its own. Empty is the optional `ChartEmpty` body (plus `LineChartEmpty` for line/area plots); each chart ships its own skeleton (`BarListSkeleton`, `PieChartSkeleton`, …); there is no shared error state.\n- The hover-reveal relies on `group/chart` on the root. If a caller wraps `ChartActions` in another `group/chart`, the nearest group wins — document this once a real collision shows up."
36089
36089
  },
36090
36090
  {
36091
36091
  "name": "ColumnChart",
36092
- "markdown": "# ColumnChart\n\n## Overview\n\n`ColumnChart` — *Vertical stacked columns chart* in Figma and Storybook (`Data Display/SimpleCharts/Vertical Stacked Columns Chart`) — draws vertical stacked columns: a column per step (a day, an hour), each split by category — the Agentic Overview's *Findings by verdict* and *Remediations by status*. It answers \"how much, and of what kind, per step\". Pick `HorizontalBarStack` for one bar of shares (no time axis), `AreaChart` when the steps are dense enough to read as a trend, `BarList` for a top-N.\n\nIt is built on the `LineChart` contexts — the same `data` / `series` / `xKey` model — so the legend, grid, Y axis and hover popover are the `LineChart` slots, unchanged. It adds only what columns need: its own root, body, the stack, and an X axis that centres every label under its column.\n\n## Figma\n\n- Component: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=12612-31838\n- Documentation (states, hover, fade, widths): https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=12610-10640\n- Code Connect: the `Type=Vertical stacked columns` branch of `Chart/Chart.figma.ts`.\n\n## API\n\n```tsx\n<Chart>\n <ChartHeader>\n <ChartTitle>Findings by verdict</ChartTitle>\n </ChartHeader>\n <ColumnChart data={rows} series={verdicts} xKey='timestamp'>\n <LineChartLegend>\n {verdicts.map(s => (\n <LineChartLegendItem key={s.key} seriesKey={s.key}>\n <LineChartHoverPopoverDot color={s.color} />\n <span className='text-xs font-mono text-text-primary'>{s.label}</span>\n <span className='text-xs font-mono font-medium text-text-primary'>{totals[s.key]}</span>\n </LineChartLegendItem>\n ))}\n </LineChartLegend>\n <ColumnChartBody>\n <LineChartGrid />\n <ColumnChartXAxis tickFormatter={formatDate} />\n <LineChartYAxis />\n <ColumnChartStack />\n <LineChartTooltip xTickFormatter={formatDateWithTimezone} />\n </ColumnChartBody>\n </ColumnChart>\n</Chart>\n```\n\n- `ColumnChart<T>` — root. `data`, `series`, `xKey`, generic over the datum like `LineChart<T>`. Provides the `LineChart` data + active-series contexts; renders `<div data-slot='column-chart'>`, a vertical flex — JSX order places the legend.\n- `ColumnChartBody` — recharts' `<ResponsiveContainer>` + `<ComposedChart>`. Without `height` the plot starts at 164px (Figma card 220 = header 32 + legend 24 + plot 164) and follows the card: give `<Chart>` a height and the plot stretches or shrinks with it. A fixed `height` opts out. `aria-hidden`. Resizes re-render the plot at most every `LINE_RESIZE_DEBOUNCE_MS` (50ms, recharts' `debounce`); in between the body clips the previous width (`overflow-x-clip`) instead of letting it spill past the card.\n- `ColumnChartStack` — every `series` entry as one stacked column per datum. No props; mount once.\n- `ColumnChartXAxis` — `tickFormatter`, `interval` (default `'preserveStartEnd'`), `minTickGap`. Labels centred under their columns.\n- Reused unchanged: `LineChartLegend` / `LineChartLegendItem` (legend + hover fade), `LineChartGrid`, `LineChartYAxis`, `LineChartTooltip` (ruler + popover), `LineChartHoverPopoverDot`, `LineChartEmpty` (empty / loading frame).\n\n**The legend total is the consumer's** — sum the series over `data` (or take it from the API) and render it as the item's last child. The chart does not compute it, so a total can also cover a longer period than the columns show.\n\n## Data model\n\n- **`data`** — one datum per column, sorted ascending by `xKey` (a dev warning fires on the first inversion, as in `LineChart`). Each series value is a non-negative count; `null` / `undefined` / `0` draws no part.\n- **`series`** — the categories in legend order. **The first series stacks on top**, the last sits on the axis. `color` drives the part, the legend dot and the popover dot alike (`ChartColor` token or any CSS colour); default `slate`.\n- **`xKey`** — the step value: a timestamp (`number`) or a label (`string`).\n\nNo sorting, aggregation or bucketing happens inside — one datum is one column.\n\n## States\n\n- **Default** — columns over the dashed grid, legend above.\n- **Hover a column** — a dashed ruler at the column's horizontal centre and the popover: the formatted `xKey` value, then one row per series in legend order (`—` for a missing value). The popover's Y is locked to the plot's middle, as in `LineChart`.\n- **Hover / focus a legend item** — the other categories fade to 30% (`LINE_INACTIVE_OPACITY`); the item gets the hover background. The hovered parts carry `data-active='true'`.\n- **Clickable legend** — pass `onClick` to `LineChartLegendItem`: it becomes `role='button'`, tabbable, Enter / Space click. What a click does is the consumer's (the Agentic Overview opens the verdict's list); the chart has no filter state.\n- **Empty / Loading** — no chart-specific frame: compose `LineChartEmpty` (with a message, or bare under skeleton legend items) in place of `ColumnChart`, as the stories do.\n\n- **Card height** — the card is 220px by default and goes down to 128px (Figma, WDS-194): `<Chart>` lowers its 196px floor to 128px when it holds a `column-chart`. At 128px the plot keeps 70px (grid 54 + labels 16) with a one-line legend; a legend that wraps takes its second line from the plot.\n\n## Interactions\n\n- Column hover comes from recharts' tooltip, driven by the pointer anywhere over the step's band — not only over the drawn parts.\n- Legend hover and focus both set the active series; leaving clears it. Moving between legend items does not flicker (`isHoverSyncTarget`).\n\n## Edge cases & unclear states\n\n- **Filling the card** — the body is a `flex-1` item with a 164px basis and the recharts container is absolutely positioned over it. A `100%` container height does not resolve inside the content-sized flex column, and a fixed one cannot follow the card.\n- **Column width** — `resolveColumnWidth(band)`, `band = plot width / data.length`: the band minus a fixed 22px gap, clamped to 16–32px, rounded to whole pixels, and always at least 1px under the step — recharts ignores a `barSize` that fills the band and draws 90% of it. The 22px is measured from Figma (7 steps on a 54px band → 32px, 9 steps on 42px → \"20px adjusted width\"); it is not a token. The width is computed from `usePlotArea()`, so the first frame — before recharts measures — uses recharts' default width.\n- **1px gap between parts** — cut from the *bottom* of every part that sits on another, so the column top and the baseline stay on their true values. A part under ~1px tall disappears into the gap.\n- **Top radius** — 4px on the whole column via recharts' `BarStack` clip-path, so it lands on whichever part is topmost for that column, including when the upper categories are 0.\n- **Why `ComposedChart`** — recharts picks the tooltip cursor by chart type: a band-wide rectangle for `BarChart`, a line for everything else. The ruler is a line, and `ComposedChart` keeps the bars on a band scale, so the `LineChartTooltip` cursor sits on the column centre.\n- **X labels** — a 16px label band (Figma), not recharts' default 30px, so the plot keeps its 148px. `tickSize={0}`: recharts offsets a label by the tick line's length even with `tickLine={false}`, and those 6px pushed the label onto the card border; without them the label keeps Figma's 4px below. Every label is centred under its column (unlike `LineChartXAxis`, which anchors the edge labels to the plot edges). With many steps recharts drops colliding middle labels; `interval={0}` forces all of them.\n\n## Accessibility notes\n\n- The plot is `aria-hidden`; the legend is the accessible representation. Clickable legend items are buttons with keyboard activation.\n- The popover is `role='tooltip'` + `aria-live='polite'` (`ChartHoverCard` via `LineChartHoverPopover`).\n- Colour is not the only carrier: the legend and popover label every category.\n\n## Open questions / known limitations\n\n- **Under 16px per step** (Figma: \"to define\") — the column keeps 16px while the step is wider than 16px, then shrinks to leave about 1px between columns. No grouping or scrolling yet.\n- No legend click-to-filter, controlled `activeKey`, `syncId` or zoom — the `LineChart` root has them; add them here when a screen needs them.\n- Negative values are not supported (the gap logic assumes parts stack upward from 0)."
36092
+ "markdown": "# ColumnChart\n\n## Overview\n\n`ColumnChart` — *Vertical stacked columns chart* in Figma and Storybook (`Data Display/SimpleCharts/Vertical Stacked Columns Chart`) — draws vertical stacked columns: a column per step (a day, an hour), each split by category — the Agentic Overview's *Findings by verdict* and *Remediations by status*. It answers \"how much, and of what kind, per step\". Pick `HorizontalBarStack` for one bar of shares (no time axis), `AreaChart` when the steps are dense enough to read as a trend, `BarList` for a top-N.\n\nIt is built on the `LineChart` contexts — the same `data` / `series` / `xKey` model — so the legend, grid, Y axis and hover popover are the `LineChart` slots, unchanged. It adds only what columns need: its own root, body, the stack, and an X axis that centres every label under its column.\n\n## Code Connect\n\nThe `Type=Vertical stacked columns` branch of `Chart/Chart.figma.ts`.\n\n## API\n\n```tsx\n<Chart>\n <ChartHeader>\n <ChartTitle>Findings by verdict</ChartTitle>\n </ChartHeader>\n <ColumnChart data={rows} series={verdicts} xKey='timestamp'>\n <LineChartLegend>\n {verdicts.map(s => (\n <LineChartLegendItem key={s.key} seriesKey={s.key}>\n <LineChartHoverPopoverDot color={s.color} />\n <span className='text-xs font-mono text-text-primary'>{s.label}</span>\n <span className='text-xs font-mono font-medium text-text-primary'>{totals[s.key]}</span>\n </LineChartLegendItem>\n ))}\n </LineChartLegend>\n <ColumnChartBody>\n <LineChartGrid />\n <ColumnChartXAxis tickFormatter={formatDate} />\n <LineChartYAxis />\n <ColumnChartStack />\n <LineChartTooltip xTickFormatter={formatDateWithTimezone} />\n </ColumnChartBody>\n </ColumnChart>\n</Chart>\n```\n\n- `ColumnChart<T>` — root. `data`, `series`, `xKey`, generic over the datum like `LineChart<T>`. Provides the `LineChart` data + active-series contexts; renders `<div data-slot='column-chart'>`, a vertical flex — JSX order places the legend.\n- `ColumnChartBody` — recharts' `<ResponsiveContainer>` + `<ComposedChart>`. Without `height` the plot starts at 164px (Figma card 220 = header 32 + legend 24 + plot 164) and follows the card: give `<Chart>` a height and the plot stretches or shrinks with it. A fixed `height` opts out. `aria-hidden`. Resizes re-render the plot at most every `LINE_RESIZE_DEBOUNCE_MS` (50ms, recharts' `debounce`); in between the body clips the previous width (`overflow-x-clip`) instead of letting it spill past the card.\n- `ColumnChartStack` — every `series` entry as one stacked column per datum. No props; mount once.\n- `ColumnChartXAxis` — `tickFormatter`, `interval` (default `'preserveStartEnd'`), `minTickGap`. Labels centred under their columns.\n- Reused unchanged: `LineChartLegend` / `LineChartLegendItem` (legend + hover fade), `LineChartGrid`, `LineChartYAxis`, `LineChartTooltip` (ruler + popover), `LineChartHoverPopoverDot`, `LineChartEmpty` (empty / loading frame).\n\n**The legend total is the consumer's** — sum the series over `data` (or take it from the API) and render it as the item's last child. The chart does not compute it, so a total can also cover a longer period than the columns show.\n\n## Data model\n\n- **`data`** — one datum per column, sorted ascending by `xKey` (a dev warning fires on the first inversion, as in `LineChart`). Each series value is a non-negative count; `null` / `undefined` / `0` draws no part.\n- **`series`** — the categories in legend order. **The first series stacks on top**, the last sits on the axis. `color` drives the part, the legend dot and the popover dot alike (`ChartColor` token or any CSS colour); default `slate`.\n- **`xKey`** — the step value: a timestamp (`number`) or a label (`string`).\n\nNo sorting, aggregation or bucketing happens inside — one datum is one column.\n\n## States\n\n- **Default** — columns over the dashed grid, legend above.\n- **Hover a column** — a dashed ruler at the column's horizontal centre and the popover: the formatted `xKey` value, then one row per series in legend order (`—` for a missing value). The popover's Y is locked to the plot's middle, as in `LineChart`.\n- **Hover / focus a legend item** — the other categories fade to 30% (`LINE_INACTIVE_OPACITY`); the item gets the hover background. The hovered parts carry `data-active='true'`.\n- **Clickable legend** — pass `onClick` to `LineChartLegendItem`: it becomes `role='button'`, tabbable, Enter / Space click. What a click does is the consumer's (the Agentic Overview opens the verdict's list); the chart has no filter state.\n- **Empty / Loading** — no chart-specific frame: compose `LineChartEmpty` (with a message, or bare under skeleton legend items) in place of `ColumnChart`, as the stories do.\n\n- **Card height** — the card is 220px by default and goes down to 128px (Figma, WDS-194): `<Chart>` has no minimum height of its own. At 128px the plot keeps 70px (grid 54 + labels 16) with a one-line legend; a legend that wraps takes its second line from the plot.\n\n## Interactions\n\n- Column hover comes from recharts' tooltip, driven by the pointer anywhere over the step's band — not only over the drawn parts.\n- Legend hover and focus both set the active series; leaving clears it. Moving between legend items does not flicker (`isHoverSyncTarget`).\n\n## Edge cases & unclear states\n\n- **Filling the card** — the body is a `flex-1` item with a 164px basis and the recharts container is absolutely positioned over it. A `100%` container height does not resolve inside the content-sized flex column, and a fixed one cannot follow the card.\n- **Column width** — `resolveColumnWidth(band)`, `band = plot width / data.length`: the band minus a fixed 22px gap, clamped to 16–32px, rounded to whole pixels, and always at least 1px under the step — recharts ignores a `barSize` that fills the band and draws 90% of it. The 22px is measured from Figma (7 steps on a 54px band → 32px, 9 steps on 42px → \"20px adjusted width\"); it is not a token. The width is computed from `usePlotArea()`, so the first frame — before recharts measures — uses recharts' default width.\n- **1px gap between parts** — cut from the *bottom* of every part that sits on another, so the column top and the baseline stay on their true values. A part under ~1px tall disappears into the gap.\n- **Top radius** — 4px on the whole column via recharts' `BarStack` clip-path, so it lands on whichever part is topmost for that column, including when the upper categories are 0.\n- **Why `ComposedChart`** — recharts picks the tooltip cursor by chart type: a band-wide rectangle for `BarChart`, a line for everything else. The ruler is a line, and `ComposedChart` keeps the bars on a band scale, so the `LineChartTooltip` cursor sits on the column centre.\n- **X labels** — a 16px label band (Figma), not recharts' default 30px, so the plot keeps its 148px. `tickSize={0}`: recharts offsets a label by the tick line's length even with `tickLine={false}`, and those 6px pushed the label onto the card border; without them the label keeps Figma's 4px below. Every label is centred under its column (unlike `LineChartXAxis`, which anchors the edge labels to the plot edges). With many steps recharts drops colliding middle labels; `interval={0}` forces all of them.\n\n## Accessibility notes\n\n- The plot is `aria-hidden`; the legend is the accessible representation. Clickable legend items are buttons with keyboard activation.\n- The popover is `role='tooltip'` + `aria-live='polite'` (`ChartHoverCard` via `LineChartHoverPopover`).\n- Colour is not the only carrier: the legend and popover label every category.\n\n## Open questions / known limitations\n\n- **Under 16px per step** (Figma: \"to define\") — the column keeps 16px while the step is wider than 16px, then shrinks to leave about 1px between columns. No grouping or scrolling yet.\n- No legend click-to-filter, controlled `activeKey`, `syncId` or zoom — the `LineChart` root has them; add them here when a screen needs them.\n- Negative values are not supported (the gap logic assumes parts stack upward from 0)."
36093
36093
  },
36094
36094
  {
36095
36095
  "name": "HorizontalBarStack",
36096
- "markdown": "# HorizontalBarStack\n\n## Overview\n\n`HorizontalBarStack` is a compact distribution stat: an optional composed header (the shared `Metric` bricks — `MetricValue` + `MetricDelta`, passed as `children`), a single proportional segmented bar (colored segments + an optional grey remainder tail), and a horizontal legend. It renders the body only — the card frame and \"Title\" header are composed with the existing `Chart` / `ChartHeader` / `ChartTitle`, like every other member of the family. The legend is interactive: hovering an item or segment fades the rest (hover-sync), and passing `onSelect` turns items into click-to-filter controls. Use it when one total reads naturally as a few named parts shown as a single band; when each part needs its own row, use `BarList`; when there is no breakdown (just the number), use `Metric`.\n\n## Figma\n\n- Root: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=9667-10883\n- States (hover / filter / disabled): https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=11520-4054\n- Loading states board: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=11580-9211\n\n## Data model\n\n- `data` (required, `HorizontalBarStackDatum[]`) — drives both the bar segments and the legend, so their colors always match. Order is preserved (the caller sorts). Non-finite / negative values are coerced to `0`. No aggregation or \"Other\" bucketing.\n- `children` (optional, `ReactNode`) — the **header slot**. Compose the headline from the shared `Metric` bricks: `<MetricHeader>` wrapping `<MetricValue>` and/or `<MetricDelta>` (and optionally `<MetricTotal>` / `<MetricCaption>`), exactly as `Metric` and `PieChart`'s centre do. Rendered above the bar under this component's own `TestIdProvider`, so the bricks' slot testids resolve to `{base}--header`, `{base}--value`, `{base}--delta`. Omitted → no header row. `MetricValue` locale-formats a numeric child (`toLocaleString('en-US')`); `MetricDelta` is the shared `ChartDelta` — `sentiment` sets the colour (positive → green, negative → red, neutral → slate; default neutral) and `trend` sets the arrow (else the sign of `value`), on independent axes. This replaces the former `value` / `delta` config props — **one way to build a delta across the whole chart family**.\n- `total` (optional, `number`) — the bar denominator. When `total > sum(data.value)`, a grey remainder tail fills `(total − sum)`. Defaults to the sum (no tail). `total <= sum` or non-finite is ignored.\n- `legend` (optional, `boolean`, default `true`) — show/hide the legend.\n- `activeName` / `onActiveNameChange` (optional, `string | null`) — controlled hover target, joined by `name`. Omitted → managed internally. Use the controlled pair to sync hover across charts. Mirrors `PieChart`.\n- `selectedNames` (optional, `string[]`) — names to emphasise. When non-empty, non-selected segments (opacity `0.3`) and legend items (opacity `0.6`) fade. Hover wins — while a series is hovered, only it stays bright. Names not in `data` are ignored. Pass a stable reference.\n- `onSelect` (optional, `(name: string) => void`) — click-to-filter. When provided, legend items become interactive (`role='button'`, `tabIndex=0`, Enter/Space) and fire with the clicked datum's `name`. The caller owns the filter (drive `selectedNames`, or filter `data`). Single-select is just the caller mapping one name.\n\n`HorizontalBarStackDatum.color` uses the family `ChartColor` palette; omit it to auto-assign by index (`HORIZONTAL_BAR_STACK_PALETTE`). `HorizontalBarStackDatum.className` (a Tailwind `bg-*`) overrides the resolved color on both the segment and the legend dot.\n\n## States\n\n| State | Trigger | Behaviour |\n| --- | --- | --- |\n| Default | `data` with ≥1 segment | Segments sized by value and colored via `resolveChartColor`; legend mirrors them. |\n| Hover | pointer/focus on a legend item or segment | `activeName` set → that series stays bright, the rest fade (segments `0.3`); the active legend item gets `bg-states-primary-hover`. Bidirectional (legend ⇄ segment). |\n| Selected / filtered | `selectedNames` includes the row | Selected legend item gets `bg-states-primary-active` + `aria-current`; non-selected segments fade to `0.3` and non-selected legend items to `0.6` (the \"disabled\" look). Hover still wins. |\n| Header | header bricks composed as `children` | The composed `MetricValue` / `MetricDelta` render in a padded slot above the bar; slot testids resolve under HBS's own base. |\n| No header | no `children` | The header slot is not rendered — no padding reserved above the bar. |\n| Remainder | `total > sum(data.value)` | Grey tail (`--color-bg-strong-primary`) fills the gap; not shown in the legend. |\n| Legend off | `legend={false}` | Bar + header only; the bar becomes a named image (`role='img'` + `aria-label` summary). |\n| Empty / no data | `data.length === 0` | Header-only: the composed header renders, but the bar wrapper and legend are not rendered at all (no empty track). Holds even when `total` is set. If no header `children` are composed either, nothing renders — prefer `<ChartEmpty />` for an explicit empty body, or `Metric` for a bare stat. |\n| Loading | consumer swaps in `HorizontalBarStackSkeleton` | Value-sized block + 8px bar track + legend pills (`legendItems`, default 3) on the DS `Skeleton` shimmer, mirroring the loaded layout so the card doesn't jump; announces `aria-busy` / `aria-live`. |\n\n## Interactions\n\nSame contract as `PieChart`, reused verbatim (the shared `makeIsHoverSyncTarget` guard + `activeName` / `selectedNames` semantics), but driven by root props rather than composed children because the legend is derived from `data`.\n\n- **Hover-sync** — hovering (or focusing) a legend item or a segment sets `activeName`; the matching series stays bright and the rest fade. Both `mouseleave`/`blur` handlers inspect `relatedTarget` and skip the null-clear when moving to another hover-syncing sibling (segment or legend item), so there is no intermediate `activeName = null` flicker.\n- **Click-to-filter** — attach `onSelect`; legend items become buttons (`role='button'` + `tabIndex=0`, Enter/Space) and show a `Click to filter` tooltip on hover/focus — `Remove filter` when the item is the active/selected one. The caller filters (e.g. sets `selectedNames`, or filters `data`). Clicking the active item again to clear is a caller convention.\n- **Dimming** — `selectedNames` fades the non-selected series so emphasis lands on the chosen one(s). Hover (`activeName`) overrides selection while a series is hovered.\n- **Controlled hover** — set `activeName` + `onActiveNameChange` to drive/share hover from a parent (cross-chart sync). Stale names (not in current `data`) collapse the highlight to `null`.\n- **Segment tooltip** — each series segment is a DS `Tooltip` trigger; hovering shows the shared white `ChartHoverCard` (dot · name · value) — the same hover-detail surface the LineChart popover uses, so the family reads consistently (the DS `TooltipContent` surface is neutralised so only the card shows). The value is not shown elsewhere on the bar; the remainder tail has none. Baked in (not composed) because the segments are internal.\n\n## Edge cases & unclear states\n\n- **Negative / non-finite values** are coerced to `0` in `resolveSegments` so `flexGrow` geometry stays valid.\n- **No data** (`data.length === 0`): the bar wrapper is not rendered — only the header (value/delta) shows. A `total` with empty `data` still draws no bar (no labels → no bar).\n- **Zero total with data present** (all values 0, no positive `total`): the bar renders an empty track; no console warnings.\n- **`className` on a datum** wins over the palette color on both the segment and its legend dot (the inline `backgroundColor` is skipped).\n- **Remainder is never in the legend** — the legend renders only `data` items, not the synthetic tail. It carries no `data-name` and is never hoverable/selectable; it dims with the rest when a series is active.\n- **Hover wins over selection** — while `activeName` is non-null, only that series is bright regardless of `selectedNames`.\n- **Duplicate `name`s** are used as the React key / `data-name` / join key for hover & selection; keep them unique. A dev-only console warning (matching `PieChart`'s pattern) fires once when duplicates are detected; it is a no-op in production builds.\n- **Slot testids** — the bar-family slots (`--bar`, `--segment`, `--legend`, `--legend-item`, `--legend-dot`) are computed **locally** from the component's own `data-testid`, *not* via `useTestId`, because they render inline in the root and the context hook would resolve a wrapping provider's base (e.g. `Chart`'s) instead of this component's. The header slot is the exception: its composed `Metric` bricks (`--header`, `--value`, `--delta`, …) resolve through `useTestId` because HBS wraps `children` in its own `TestIdProvider` (seeded with its `data-testid`), so a parent `Chart`'s base never leaks in. No `data-testid` → no slot testids (clean DOM).\n\n## Accessibility notes\n\n- The bar is `aria-hidden` when the legend is shown (the legend is the readable representation). When `legend={false}`, the bar instead becomes `role='img'` with an `aria-label` summarizing the segments (`\"Critical 42, High 31, …\"`) — the explicit role is required because `aria-label` is ignored on generic (role-less) elements.\n- When `onSelect` is set, legend items are keyboard-operable buttons (`role='button'`, `tabIndex=0`, Enter/Space), and the selected item exposes `aria-current='true'`. The focus-visible ring uses `ring-focus-primary`.\n- The delta badge is a named image (`role='img'` + `aria-label` like `\"up 10\"`) — `Badge` renders a generic `<div>`, so without the role the label would not be announced and the trend direction (carried only by the `aria-hidden` arrow icon) would be lost. All numbers in labels use the same `toLocaleString('en-US')` formatting as the visible text.\n\n## Open questions / known limitations\n\n- **Per-item analytics** — because the legend is data-derived (not composed sub-components), consumer `data-analytics-*` can't land on individual legend items; the `onSelect(name)` callback carries the identity instead. Revisit (compound the legend) if per-item DOM analytics become a requirement.\n- **Header composition** — the header is a **slot**: consumers compose the shared `MetricHeader` / `MetricValue` / `MetricDelta` bricks from `Metric` as `children` (there is no `value` / `delta` config prop — one way to build a delta across the family, matching `Metric` / `PieChart` / `BarList`). HBS still owns the header's padding via a `horizontal-bar-stack-header` slot wrapper, so composed headers line up without the caller knowing the spacing, and the header can't drift from `Metric` on typography or delta colour.\n- **Legend is data-driven, not composed** — unlike `PieChart` (which exposes `PieChartLegendItem` as composition), HBS derives its legend 1:1 from `data` and wires the hover-sync internally, because the legend and bar must stay colour-synced from a single array. This is intentional; per-item composition would reopen the per-item analytics gap noted above. Revisit only if consumers need to inject custom legend rows.\n- No segment animation; no tooltips on long legend labels (wrap in `OverflowTooltip` if needed)."
36096
+ "markdown": "# HorizontalBarStack\n\n## Overview\n\n`HorizontalBarStack` is a compact distribution stat: an optional composed header (the shared `Metric` bricks — `MetricValue` + `MetricDelta`, passed as `children`), a single proportional segmented bar (colored segments + an optional grey remainder tail), and a horizontal legend. It renders the body only — the card frame and \"Title\" header are composed with the existing `Chart` / `ChartHeader` / `ChartTitle`, like every other member of the family. The legend is interactive: hovering an item or segment fades the rest (hover-sync), and passing `onSelect` turns items into click-to-filter controls. Use it when one total reads naturally as a few named parts shown as a single band; when each part needs its own row, use `BarList`; when there is no breakdown (just the number), use `Metric`.\n\n## Data model\n\n- `data` (required, `HorizontalBarStackDatum[]`) — drives both the bar segments and the legend, so their colors always match. Order is preserved (the caller sorts). Non-finite / negative values are coerced to `0`. No aggregation or \"Other\" bucketing.\n- `children` (optional, `ReactNode`) — the **header slot**. Compose the headline from the shared `Metric` bricks: `<MetricHeader>` wrapping `<MetricValue>` and/or `<MetricDelta>` (and optionally `<MetricTotal>` / `<MetricCaption>`), exactly as `Metric` and `PieChart`'s centre do. Rendered above the bar under this component's own `TestIdProvider`, so the bricks' slot testids resolve to `{base}--header`, `{base}--value`, `{base}--delta`. Omitted → no header row. `MetricValue` locale-formats a numeric child (`toLocaleString('en-US')`); `MetricDelta` is the shared `ChartDelta` — `sentiment` sets the colour (positive → green, negative → red, neutral → slate; default neutral) and `trend` sets the arrow (else the sign of `value`), on independent axes. This replaces the former `value` / `delta` config props — **one way to build a delta across the whole chart family**.\n- `total` (optional, `number`) — the bar denominator. When `total > sum(data.value)`, a grey remainder tail fills `(total − sum)`. Defaults to the sum (no tail). `total <= sum` or non-finite is ignored.\n- `legend` (optional, `boolean`, default `true`) — show/hide the legend.\n- `activeName` / `onActiveNameChange` (optional, `string | null`) — controlled hover target, joined by `name`. Omitted → managed internally. Use the controlled pair to sync hover across charts. Mirrors `PieChart`.\n- `selectedNames` (optional, `string[]`) — names to emphasise. When non-empty, non-selected segments (opacity `0.3`) and legend items (opacity `0.6`) fade. Hover wins — while a series is hovered, only it stays bright. Names not in `data` are ignored. Pass a stable reference.\n- `onSelect` (optional, `(name: string) => void`) — click-to-filter. When provided, legend items become interactive (`role='button'`, `tabIndex=0`, Enter/Space) and fire with the clicked datum's `name`. The caller owns the filter (drive `selectedNames`, or filter `data`). Single-select is just the caller mapping one name.\n\n`HorizontalBarStackDatum.color` uses the family `ChartColor` palette; omit it to auto-assign by index (`HORIZONTAL_BAR_STACK_PALETTE`). `HorizontalBarStackDatum.className` (a Tailwind `bg-*`) overrides the resolved color on both the segment and the legend dot.\n\n## States\n\n| State | Trigger | Behaviour |\n| --- | --- | --- |\n| Default | `data` with ≥1 segment | Segments sized by value and colored via `resolveChartColor`; legend mirrors them. |\n| Hover | pointer/focus on a legend item or segment | `activeName` set → that series stays bright, the rest fade (segments `0.3`); the active legend item gets `bg-states-primary-hover`. Bidirectional (legend ⇄ segment). |\n| Selected / filtered | `selectedNames` includes the row | Selected legend item gets `bg-states-primary-active` + `aria-current`; non-selected segments fade to `0.3` and non-selected legend items to `0.6` (the \"disabled\" look). Hover still wins. |\n| Header | header bricks composed as `children` | The composed `MetricValue` / `MetricDelta` render in a padded slot above the bar; slot testids resolve under HBS's own base. |\n| No header | no `children` | The header slot is not rendered — no padding reserved above the bar. |\n| Remainder | `total > sum(data.value)` | Grey tail (`--color-bg-strong-primary`) fills the gap; not shown in the legend. |\n| Legend off | `legend={false}` | Bar + header only; the bar becomes a named image (`role='img'` + `aria-label` summary). |\n| Empty / no data | `data.length === 0` | Header-only: the composed header renders, but the bar wrapper and legend are not rendered at all (no empty track). Holds even when `total` is set. If no header `children` are composed either, nothing renders — prefer `<ChartEmpty />` for an explicit empty body, or `Metric` for a bare stat. |\n| Loading | consumer swaps in `HorizontalBarStackSkeleton` | Value-sized block + 8px bar track + legend pills (`legendItems`, default 3) on the DS `Skeleton` shimmer, mirroring the loaded layout so the card doesn't jump; announces `aria-busy` / `aria-live`. |\n\n## Interactions\n\nSame contract as `PieChart`, reused verbatim (the shared `makeIsHoverSyncTarget` guard + `activeName` / `selectedNames` semantics), but driven by root props rather than composed children because the legend is derived from `data`.\n\n- **Hover-sync** — hovering (or focusing) a legend item or a segment sets `activeName`; the matching series stays bright and the rest fade. Both `mouseleave`/`blur` handlers inspect `relatedTarget` and skip the null-clear when moving to another hover-syncing sibling (segment or legend item), so there is no intermediate `activeName = null` flicker.\n- **Click-to-filter** — attach `onSelect`; legend items become buttons (`role='button'` + `tabIndex=0`, Enter/Space) and show a `Click to filter` tooltip on hover/focus — `Remove filter` when the item is the active/selected one. The caller filters (e.g. sets `selectedNames`, or filters `data`). Clicking the active item again to clear is a caller convention.\n- **Dimming** — `selectedNames` fades the non-selected series so emphasis lands on the chosen one(s). Hover (`activeName`) overrides selection while a series is hovered.\n- **Controlled hover** — set `activeName` + `onActiveNameChange` to drive/share hover from a parent (cross-chart sync). Stale names (not in current `data`) collapse the highlight to `null`.\n- **Segment tooltip** — each series segment is a DS `Tooltip` trigger; hovering shows the shared white `ChartHoverCard` (dot · name · value) — the same hover-detail surface the LineChart popover uses, so the family reads consistently (the DS `TooltipContent` surface is neutralised so only the card shows). The value is not shown elsewhere on the bar; the remainder tail has none. Baked in (not composed) because the segments are internal.\n\n## Edge cases & unclear states\n\n- **Negative / non-finite values** are coerced to `0` in `resolveSegments` so `flexGrow` geometry stays valid.\n- **No data** (`data.length === 0`): the bar wrapper is not rendered — only the header (value/delta) shows. A `total` with empty `data` still draws no bar (no labels → no bar).\n- **Zero total with data present** (all values 0, no positive `total`): the bar renders an empty track; no console warnings.\n- **`className` on a datum** wins over the palette color on both the segment and its legend dot (the inline `backgroundColor` is skipped).\n- **Remainder is never in the legend** — the legend renders only `data` items, not the synthetic tail. It carries no `data-name` and is never hoverable/selectable; it dims with the rest when a series is active.\n- **Hover wins over selection** — while `activeName` is non-null, only that series is bright regardless of `selectedNames`.\n- **Duplicate `name`s** are used as the React key / `data-name` / join key for hover & selection; keep them unique. A dev-only console warning (matching `PieChart`'s pattern) fires once when duplicates are detected; it is a no-op in production builds.\n- **Slot testids** — the bar-family slots (`--bar`, `--segment`, `--legend`, `--legend-item`, `--legend-dot`) are computed **locally** from the component's own `data-testid`, *not* via `useTestId`, because they render inline in the root and the context hook would resolve a wrapping provider's base (e.g. `Chart`'s) instead of this component's. The header slot is the exception: its composed `Metric` bricks (`--header`, `--value`, `--delta`, …) resolve through `useTestId` because HBS wraps `children` in its own `TestIdProvider` (seeded with its `data-testid`), so a parent `Chart`'s base never leaks in. No `data-testid` → no slot testids (clean DOM).\n\n## Accessibility notes\n\n- The bar is `aria-hidden` when the legend is shown (the legend is the readable representation). When `legend={false}`, the bar instead becomes `role='img'` with an `aria-label` summarizing the segments (`\"Critical 42, High 31, …\"`) — the explicit role is required because `aria-label` is ignored on generic (role-less) elements.\n- When `onSelect` is set, legend items are keyboard-operable buttons (`role='button'`, `tabIndex=0`, Enter/Space), and the selected item exposes `aria-current='true'`. The focus-visible ring uses `ring-focus-primary`.\n- The delta badge is a named image (`role='img'` + `aria-label` like `\"up 10\"`) — `Badge` renders a generic `<div>`, so without the role the label would not be announced and the trend direction (carried only by the `aria-hidden` arrow icon) would be lost. All numbers in labels use the same `toLocaleString('en-US')` formatting as the visible text.\n\n## Open questions / known limitations\n\n- **Per-item analytics** — because the legend is data-derived (not composed sub-components), consumer `data-analytics-*` can't land on individual legend items; the `onSelect(name)` callback carries the identity instead. Revisit (compound the legend) if per-item DOM analytics become a requirement.\n- **Header composition** — the header is a **slot**: consumers compose the shared `MetricHeader` / `MetricValue` / `MetricDelta` bricks from `Metric` as `children` (there is no `value` / `delta` config prop — one way to build a delta across the family, matching `Metric` / `PieChart` / `BarList`). HBS still owns the header's padding via a `horizontal-bar-stack-header` slot wrapper, so composed headers line up without the caller knowing the spacing, and the header can't drift from `Metric` on typography or delta colour.\n- **Legend is data-driven, not composed** — unlike `PieChart` (which exposes `PieChartLegendItem` as composition), HBS derives its legend 1:1 from `data` and wires the hover-sync internally, because the legend and bar must stay colour-synced from a single array. This is intentional; per-item composition would reopen the per-item analytics gap noted above. Revisit only if consumers need to inject custom legend rows.\n- No segment animation; no tooltips on long legend labels (wrap in `OverflowTooltip` if needed)."
36097
36097
  },
36098
36098
  {
36099
36099
  "name": "LineChart",
36100
- "markdown": "# LineChart\n\n## Overview\n\n`LineChart` is a composable time-series chart for `SimpleCharts` cards — request volume, error rate, latency, anything where the X axis reads left-to-right and the Y axis is a continuous number. The SVG is drawn by [`recharts`](https://recharts.org/); we own the visual tokens, the popover surfaces, the legend layout, the empty/loading frames, and the cross-component sync (hover ↔ legend ↔ lines ↔ tooltip ↔ zoom).\n\nThe root takes `data` and `series` as two flat arrays — `data` is one row per X position with denormalised series values, `series` is the schema. Axes, grid, lines, tooltip, legend, and zoom brush are all opt-in slots, so a 200×196 sparkline is the same component as a fully-instrumented dashboard panel — only the children differ.\n\nUse it whenever a chart needs the same card frame as `BarList` / `PieChart` but the data is a trend (or any ordered X axis). Pick `BarList` for top-N comparisons and `PieChart` for share-of-total.\n\nPer the anatomy, a chart may also carry a **metric + legend** row between the card header and the grid: the shared `Metric` bricks (`MetricHeader` + `MetricValue` + `MetricDelta`) on the left, the legend on the right, in one `justify-between` row. This is pure consumer composition (the `WithMetric` story) — no chart props involved; the card grows by the row's height.\n\n## Figma\n\n- Root anatomy: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7533-3334&m=dev\n- Single-line + multi-line: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7533-3335&m=dev\n- Anatomy incl. the metric + legend row: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=11626-37312\n- Default states: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7490-123142&m=dev\n- Hover + zoom popovers: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7527-32438&m=dev\n- Filtered (legend toggling): https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7509-2354&m=dev\n- Loading skeleton: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7519-2591&m=dev\n- Empty state: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=8670-2594&m=dev\n\n## API\n\nCompound component. There is no single \"render everything\" prop — callers pick the slots they need.\n\n### Compositional groups (at a glance)\n\nNineteen named exports collapse into five compositional groups. Each group is independent — pick the slots from the groups you need; ignore the rest.\n\n| Group | Members | Purpose |\n| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |\n| **Root + body** | `LineChart`, `LineChartBody` | Provides the chart context + renders the recharts container. |\n| **Plot** | `LineChartGrid`, `LineChartXAxis`, `LineChartYAxis`, `LineChartLine` | Direct children of `<LineChartBody>` — the SVG geometry. |\n| **HoverPopover.*** | `LineChartTooltip`, `LineChartHoverPopover`, `LineChartHoverPopoverTimestamp`, `LineChartHoverPopoverRow`, `LineChartHoverPopoverDot` | Cursor-driven popover that reads `{ rows, xValue }` from the tooltip render-prop. |\n| **ZoomPopover.*** | `LineChartZoomBrush`, `LineChartZoomPopover`, `LineChartZoomPopoverRange`, `LineChartZoomPopoverConfirm` | Drag-to-zoom brush + the two-phase confirm popover. |\n| **Legend** | `LineChartLegend`, `LineChartLegendItem` | Accessible series legend; participates in hover sync. |\n| **Frame states** | `LineChartEmpty` | Static dashed-grid frame for empty-data and loading states. |\n\nThe flat-named exports stay the canonical form — the table above is just for quick discovery.\n\n### Root + body\n\n- `LineChart<T extends LineChartDatum = LineChartDatum>` — root. Owns `data`, `series`, `xKey`, the controlled series highlight (`activeKey`), the controlled filter (`filteredKeys`), the zoom event (`onZoomChange`), and cross-chart sync (`syncId`, `syncMethod`). **Generic over the datum shape**: pass a concrete `T` to turn `xKey` and `series[].key` into compile-time-checked `keyof T` unions (`<LineChart<RequestRow> data={rows} xKey='timestamp' />`). Leaving the default `LineChartDatum` keeps the loose untyped form. Provides four contexts to its children (data, active key, selection, zoom). The root renders a vertical-flex `<div data-slot='line-chart'>`; **legend placement is structural, not configured** — see the *Layout* section below.\n- `LineChartBody` — wraps recharts' `<ResponsiveContainer>` + `<LineChart>` and passes children through so they can self-register as recharts primitives. Default height is `LINE_CARD_HEIGHT - LINE_HEADER_HEIGHT` (164px) — override with `height`. `aria-hidden='true'` (the legend is the canonical accessible representation). Resizes re-render the plot at most every `LINE_RESIZE_DEBOUNCE_MS` (50ms, recharts' `debounce`); in between the body clips the previous width (`overflow-x-clip`) instead of letting it spill past the card.\n\n### Plot primitives (children of `LineChartBody`)\n\n- `LineChartLine` — one per series. Required `seriesKey` joins to `LineChartSeries.key`. Per-line `curve` (`'monotone'` default, `'linear'`), `disableAnimation`, `connectNulls`, `strokeWidth`, and `onClick`/`onMouseEnter`/`onMouseLeave`. Returns the bare recharts `<Line>` (no wrapper element) so recharts can hoist it into its layout.\n- `LineChartGrid` — wraps `<CartesianGrid>` with the dashed horizontal grid (`stroke='var(--color-border-primary-light)'`, `strokeDasharray='4 4'`). Drops the topmost grid line when it would visually crowd the chart's top edge (Figma \"no top x-line\") and unconditionally drops the bottommost line (the X-axis line owns the bottom rail). Escape hatches: `keepTopLine`, `keepBottomLine`. Opt into vertical grid via `vertical`.\n- `LineChartXAxis` — wraps `<XAxis>`. Reads `xKey` from context. Defaults: `tickLine={false}`, solid `axisLine` (the bottom rail of the plot), 10px sans secondary tick labels. First/last visible tick labels are anchored to their inside edges (`text-anchor: start` on the first, `end` on the last) so they cannot overflow the plot area; middle ticks stay center-anchored. Curated tick-spacing preset via `density: 'sparse' | 'normal' | 'dense'` (maps to `minTickGap` 64/32/16). Advanced escape-hatch props (`tickFormatter`, `interval`, `tickCount`, `ticks`, `minTickGap`, `domain`, `padding`, `type`) forward to recharts when no preset fits. `hideTicks` keeps the layout reservation but skips labels. `axisLine={false}` for sparkline-style plots — pair with `<LineChartGrid keepBottomLine />` so the bottom rail does not vanish.\n- `LineChartYAxis` — wraps `<YAxis>`. Default `width={LINE_Y_LABEL_WIDTH}` (38px, the Figma-mandated gutter). Defaults: `tickLine={false}`, `axisLine={false}`, the topmost tick label is skipped so no value sits flush against the chart's top edge. `interval={0}` disables recharts' built-in collision filter — combined with the top-tick skip, it gives a deterministic four-tick axis.\n- `LineChartTooltip` — wraps `<Tooltip>`. Renders the default `<LineChartHoverPopover>` body or accepts a render-prop child receiving `{ datum, rows, xValue }`. `rows` is the visible series pre-joined with each row's value at the active index — `rows.map(({ series, value }) => …)` is the canonical iteration pattern. **The popover Y is locked to the plot's vertical centre** (via recharts' `usePlotArea` + a `-translate-y-1/2` wrapper) so the popover does not jump between peaks and valleys; the dashed cursor guideline still tracks the cursor X. Cursor stroke matches Figma `7527-32438`: `var(--color-border-strong-primary)` (slate-500), 1px stroke, `4 2` dash pattern. `xTickFormatter` formats the default timestamp slot.\n- `LineChartZoomBrush` — enables drag-to-zoom on the plot. Mount once anywhere inside `<LineChartBody>`. `disabled` removes the interaction; `formatRange(range)` overrides the popover range text; `confirmLabel` overrides the button copy; `container` overrides the portal target (defaults to `document.body` — pass a dialog node when the chart lives inside a focus-trapped modal). See the *Interactions* section for the full two-phase lifecycle.\n\n### Hover popover slots\n\nCompose these inside the `LineChartTooltip` render-prop, or use the default body.\n\n- `LineChartHoverPopover` — the popover surface. `role='tooltip'`, `aria-live='polite'`, `bg-bg-surface-2`, `rounded-12`, `shadow-md`, `min-w-160`.\n- `LineChartHoverPopoverTimestamp` — sans heading (`text-xs font-medium`, primary text). The top row of the default body.\n- `LineChartHoverPopoverRow` — `<dot> <label … value>` row. Required `series` (drives colour and label), optional `value`, optional `formatValue`. Default formatter `toLocaleString()` for numbers, `String()` otherwise, `—` for `null`/`undefined`. Pass `children` to fully override the inner layout.\n- `LineChartHoverPopoverDot` — 8×8 `rounded-2` colour swatch reused by the legend and the popover rows. `color` accepts a palette token (`'brand'`, `'red'`, …) **or** any CSS colour string (`'var(--color-violet-500)'`, `'#8b5cf6'`, `'oklch(…)'`). Consumer-supplied `style.backgroundColor` wins.\n\n### Zoom popover slots\n\n- `LineChartZoomPopover` — popover surface. `role='dialog'`, `aria-modal='false'` (non-modal — focus stays on the chart, the popover is a transient affordance).\n- `LineChartZoomPopoverRange` — text-xs primary range string. Default content comes from `lib/formatRange.ts` (e.g. `26 Dec 00:00 → 3 Jan 23:59`); override via `formatRange` on `<LineChartZoomBrush>`.\n- `LineChartZoomPopoverConfirm` — brand-tinted \"Zoom in\" button. `aria-keyshortcuts='Enter'` so screen readers announce the shortcut. Wires to the context's `confirmZoom`.\n\n### Legend\n\n- `LineChartLegend` — row container. `orientation: 'horizontal' | 'vertical'` (default `'horizontal'`) — the only knob the legend exposes. `align: 'start' | 'center' | 'end'`. **No `position` prop** — placement is JSX-structural.\n- `LineChartLegendItem` — row. Required `seriesKey` joins to a `LineChartSeries.key`. `selected` is explicit; when omitted it falls back to `!hiddenSet.has(seriesKey)`. Becomes interactive when `onClick` is passed (`role='button'`, `tabIndex=0`, Enter/Space dispatch). Hovering / focusing pushes `activeKey` into the context — this is what dims sibling lines.\n\n### Empty state\n\n- `LineChartEmpty` — Figma \"No data\" frame. Three dashed grid lines + one solid bottom axis. Pass `children` for the empty copy (e.g. `<LineChartEmpty>No data</LineChartEmpty>`); omit them to render only the grid frame (the idiom the `Loading` and `Refreshing` stories use). There is **no default copy**.\n\n### `LineChartDatum`\n\n```ts\ntype LineChartDatum = Record<string, number | string | null | undefined>;\n```\n\nOne row per X position. The X value lives under the `xKey` (required — no default); every other series reads from `datum[series.key]`. `null` / `undefined` values create a gap in the line (unless the line opts into `connectNulls`).\n\nPass a concrete interface to `LineChart<T>` to gain compile-time safety on `xKey` and `series[].key`. The generic defaults to the loose `LineChartDatum` form so existing call sites stay typed against `Record<string, …>`.\n\n```ts\ninterface RequestRow { timestamp: number; requests: number; errors: number }\n\n<LineChart<RequestRow> data={rows} xKey='timestamp' /> // ✅\n<LineChart<RequestRow> data={rows} xKey='timetamp' /> // ❌ Type error: 'timetamp' is not keyof RequestRow\n```\n\n### `LineChartSeries`\n\n```ts\ninterface LineChartSeries<K extends string = string> {\n key: K; // unique join key (keyof T when LineChart<T> is used)\n label: string; // visible label\n color?: ChartColor | (string & {}); // palette token or any CSS colour string\n variant?: 'solid' | 'dashed'; // default 'solid'\n}\n```\n\n`color` is shared between the line stroke and the legend/tooltip dot — pass one value, both surfaces stay in sync. Palette tokens (`'brand'`, `'red'`, …) resolve via `LINE_STROKE_FILL`; any other string passes through verbatim, so `'var(--color-violet-500)'`, `'#8b5cf6'`, and `'oklch(…)'` all work. `'slate'` is the implicit fallback when `color` is omitted.\n\n### `LineChartZoomRange`\n\n```ts\ninterface LineChartZoomRange {\n fromIndex: number; // inclusive index into `data`\n toIndex: number; // inclusive index into `data`\n from: number | string; // data[fromIndex][xKey]\n to: number | string; // data[toIndex][xKey]\n}\n```\n\nEmitted via `onZoomChange` exactly once per confirmed brush selection.\n\n## Data model\n\n- **`data`** (required, `LineChartDatum[]`) — single source of truth for both the lines and the tooltip rows. **The caller sorts ascending by `xKey`.** Recharts assumes an ordered domain; unsorted data draws the line backwards. A `console.warn` fires in development on the first inversion.\n- **`series`** (required, `LineChartSeries[]`) — the schema. `key` must be unique and must appear on at least one datum (a development warning fires for series whose key never resolves to a value). Series declared but never composed as a `<LineChartLine>` are valid — you can render legend rows without lines.\n- **`xKey`** (required, `Extract<keyof T, string>`) — the field on each datum used as the X axis. Required because no default matched any real call site (every story passes `'timestamp'`). When `LineChart<T>` is parameterised with a concrete datum type, `xKey` must be one of `T`'s keys; a typo is a compile error. X values must be `number` or `string` — pass `Date.getTime()` timestamps rather than `Date` instances. `Date` is intentionally not in the type union because recharts handles it inconsistently (sorting works, but `ReferenceArea` lookup in the zoom brush silently falls through).\n- **`activeKey`** / **`onActiveKeyChange`** (optional, `string | null`) — controlled hover series. When omitted, the chart manages it internally with `useState`. Use the controlled mode to share the *series highlight* across sibling charts (line dimming + legend hover). The root resolves `activeKey` against the current `seriesByKey` map — if the previously hovered series disappears (filter, refresh, or a controlled value pointing at a missing key), the highlight collapses to `null` instead of dimming every line. In **controlled** mode the chart calls `onActiveKeyChange(null)` from an effect so sibling charts stop highlighting a stale key — wire your external state accordingly so the snap-to-null write does not surprise you. In uncontrolled mode the internal state is left as-is and the next hover overwrites it.\n- **`syncId`** (optional, `string | number`) — pair two or more charts with the same `syncId` and recharts' built-in middleware syncs their *cursor X* (tooltip cursor + brush) — hovering one chart highlights the matching X on every sibling. This is independent of `activeKey`: `syncId` handles the tooltip cursor, `activeKey` handles the series highlight. Use both together for full cross-chart sync, or either one alone.\n- **`syncMethod`** (optional, `'index' | 'value' | (ticks, data) => number`) — how sibling `syncId` charts match X positions. Defaults to `'index'` (recharts' default — match by position in `data`). Use `'value'` when the synced charts have different dataset lengths but share categorical X values.\n- **`filteredKeys`** (optional, `string[]`) — keys to **hide** from the plot. The matching `<LineChartLine>` skips rendering, the tooltip row disappears, and the legend row paints at `opacity-40` with `data-selected='false'`. Stable references only — an inline array literal recreates on every parent render and invalidates the internal `Set` memo. The per-row `selected` prop still wins when set explicitly.\n- **`onZoomChange`** (optional, `(range: LineChartZoomRange | null) => void`) — fires when the user confirms a brush selection. The chart never persists the zoom window — `data` is always the truth. The consumer decides whether to narrow `data` upstream, push to a router, refetch, etc.\n\n`LineChart` does not sort, aggregate, or downsample. Callers prepare the data array.\n\n### Filter mental model (vs. `PieChart`)\n\n`PieChart` uses `selectedNames` (the keys to **highlight**; the rest dim). `LineChart` uses `filteredKeys` (the keys to **hide**). The divergence is intentional — the dominant interaction on the line chart is \"click a legend row to toggle the line off\", not \"click to emphasise one slice\". Consumers of both charts should not expect the two props to behave the same.\n\n## Layout\n\nThe chart is layout-agnostic. The root is a vertical flex container; **JSX child order picks placement**:\n\n```tsx\n// Legend on top:\n<LineChart …>\n <LineChartLegend>…</LineChartLegend>\n <LineChartBody>…</LineChartBody>\n</LineChart>\n\n// Legend on bottom:\n<LineChart …>\n <LineChartBody>…</LineChartBody>\n <LineChartLegend>…</LineChartLegend>\n</LineChart>\n\n// Legend on the side — wrap body+legend in any row flex container\n// (`<HStack>` from the design system is the canonical choice). Pair\n// with `<LineChartLegend orientation='vertical'>` for the legend's own\n// row→column layout.\n<LineChart …>\n <HStack flexGrow gap={12}>\n <LineChartLegend orientation='vertical'>…</LineChartLegend>\n <LineChartBody>…</LineChartBody>\n </HStack>\n</LineChart>\n```\n\nThere is no `position` prop on the legend or on the root. The legend's only knob is its own `orientation` (`'horizontal'` | `'vertical'`); placement is structural and lives in user-land. The `LegendPlacements` story demonstrates all four placements side by side.\n\n## States\n\n| State | Trigger | Behaviour |\n| --------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |\n| Default | — | Lines use `LINE_STROKE_FILL[color]` at full opacity. Mount animation: `400 ms ease-out`, honours `prefers-reduced-motion`. |\n| Hover (line) | Pointer over a `<LineChartLine>` path | No dimming — the plot stays at full opacity. The consumer's `onMouseEnter` / `onMouseLeave` still fire. The dashed cursor guideline and hover popover track the pointer via recharts' tooltip state. |\n| Hover (legend) | Pointer / focus over a `<LineChartLegendItem>` | `activeKey` snaps to that series; sibling lines fade to `LINE_INACTIVE_OPACITY` (0.3). The row paints `bg-states-primary-hover`. |\n| Hover (point) | Pointer over the plot body | Recharts' tooltip state snaps to the nearest X. The Y-locked popover renders one row per visible series at that index. The dashed vertical cursor guideline tracks the cursor X — this is the only on-plot hover affordance; no per-point dots render (`dot={false}` and `activeDot={false}`). Sibling charts that share a `syncId` mirror the highlight automatically. |\n| Focus | Keyboard focus on an interactive legend row | `bg-states-primary-hover` + `ring-focus-primary` inset ring (`focus-visible`). |\n| Filtered | `series.key` ∈ `filteredKeys` | `<LineChartLine>` does not render. Legend row paints at `opacity-40` with `aria-current` removed. |\n| Zoom-dragging | Pointer down → drag on the plot while `<LineChartZoomBrush>` is mounted | Borderless gray `<ReferenceArea>` (`fill: var(--color-states-primary-hover)`, no stroke) follows the cursor; a floating popover tracks the cursor with the live range text. Hover popover and dashed cursor guideline are suppressed — the zoom popover owns the cursor. |\n| Zoom-pending | Mouse release after a non-zero drag | Selection rectangle stays; popover becomes interactive with the \"Zoom in\" button. Hover popover stays suppressed until the pending state resolves. Click outside / Escape dismisses; Enter or button click confirms. |\n| Loading | Render `<LineChartEmpty>{null}</LineChartEmpty>` (or a `Skeleton` strip) | Dashed grid only — no labels, no line trace. Mirrors Figma `7519-2591`. The `Refreshing` story is the canonical pattern. |\n| Empty | `data.length === 0` | Compose `<LineChartEmpty>No data</LineChartEmpty>` in the body slot — the chart itself renders nothing without it. |\n| SinglePoint | `data.length === 1` | Nothing geometric renders — `dot={false}` + `activeDot={false}` mean no marker, and a one-point line has zero length. Hover still surfaces the value via the dashed cursor + popover. |\n| Gapped | `null` / `undefined` value on a datum for a given series | The line splits at the gap (recharts default `connectNulls={false}`). Set `<LineChartLine connectNulls>` to bridge. |\n\n## Interactions\n\n- **Hover sync (legend ↔ tooltip)** — pointer / focus on a `<LineChartLegendItem>` pushes `activeKey` into the active context; lines whose key does not match fade to 30% and the matching line gets `data-active='true'`. Hovering a line itself does **not** drive `activeKey` — the plot stays at full opacity so the cursor sweeping across multiple series does not flash sibling lines on and off. `<LineChartLegendItem>` uses `isHoverSyncTarget(event.relatedTarget)` in its `mouseLeave` / `blur` handlers to skip the `null`-clear when focus / pointer is moving directly between legend rows; without it React would render an intermediate `activeKey=null` frame on every crossing and the dimmed lines would flicker.\n- **Hover popover Y-lock** — `<LineChartTooltip>` reads recharts' inner plot rectangle via `usePlotArea()` and anchors the popover at `plotArea.y + plotArea.height / 2`. A wrapping `<div className='-translate-y-1/2'>` turns that coordinate into the popover's visual centre. `isAnimationActive={false}` is non-negotiable — without it recharts interpolates the popover Y between frames and defeats the lock. The first paint falls back to `HOVER_POPOVER_TOP` (8px) until recharts measures the plot.\n- **Click-to-filter** — caller-driven. Attach `onClick` to `<LineChartLegendItem>`; toggle the matching key in your own `filteredKeys` state. Enter/Space dispatch a click on the row.\n- **Cross-chart hover sync** — two layers compose:\n 1. **Cursor X (tooltip + brush)** — pass the same `syncId` to every chart that should mirror the cursor. Recharts' own redux middleware handles the wiring; no controlled state needed. `syncMethod='value'` lets datasets of different lengths sync by shared category instead of by position.\n 2. **Series highlight (line dimming + legend)** — wire each chart's `activeKey` / `onActiveKeyChange` to a shared `useState<string | null>`. The internal `lastActiveKeyRef` dedupe prevents the per-pixel mousemove flood from spamming the callback. See the `CrossChartHoverSync` story for the canonical recipe pairing both layers.\n- **Zoom (drag → pending → confirm)** — `<LineChartZoomBrush>` enables a crosshair drag on the plot. Two-phase:\n 1. **Drag** — `mousedown` on the plot starts a drag at the active tooltip index. A borderless gray `<ReferenceArea>` (`fill: var(--color-states-primary-hover)`, no stroke) follows the cursor; a portalled floating popover tracks the cursor position and renders the live range string. The hover popover and cursor guideline are hidden while dragging. Drag is followed at the *window* level so the popover keeps tracking when the cursor leaves the SVG and the release still commits even outside the chart. Escape mid-drag cancels outright (no pending popover).\n 2. **Pending** — `mouseup` after a non-zero drag locks the selection and turns the popover interactive (`pointer-events: auto`). Hover suppression continues — the chart reads as non-interactive until the user confirms or dismisses. The \"Zoom in\" button confirms — Enter does the same. Escape, click outside the popover, or starting a new drag dismisses without emitting. The popover stops a bubbling `mousedown` on its surface so clicking the confirm button does not start a fresh drag.\n 3. **Confirm** — fires `onZoomChange({ from, to, fromIndex, toIndex })` exactly once. The consumer narrows `data`; the brush state resets automatically.\n- **Zoom keyboard contract** — the keydown listener is gated to events whose target is inside the chart container, inside the popover, or ambient (focus on `<body>` / `<html>`). It does **not** hijack Enter for forms / dialogs / focused inputs elsewhere on the page.\n- **Single-index click (no zoom)** — clicking the plot without dragging produces a zero-width range and is dropped silently so users cannot accidentally surface a confirm popover on a plain click.\n- **Data refresh while zoom-pending** — the root resets `zoomDrag` and `zoomPending` whenever `data` or `xKey` changes. Emitting a stale range against a no-longer-current dataset would point at nothing meaningful.\n\n## Edge cases & unclear states\n\n- **Per-series hover and per-index hover are different things.** Pointing at a legend row sets the **series** active (dims sibling lines, keeps the popover off). Pointing at the plot body sets the **index** active (lights up the popover for every visible series at that X). They live in separate contexts on purpose so the lines don't re-render on every cursor frame. Hovering a line in the plot does **not** dim siblings — only the legend drives the series dim.\n- **The popover Y is locked, not the X.** The dashed cursor guideline still tracks the cursor's X; the popover surface stays put on Y so it does not jump between peaks and valleys. Disabling animation (`isAnimationActive={false}`) is required — without it recharts interpolates Y between frames.\n- **Filtering down to zero series.** The plot renders an empty frame (grid + axes still visible). Recommend swapping the body for `<LineChartEmpty>No data</LineChartEmpty>` when the consumer's filter UI produces this state.\n- **`null` / `undefined` datum values.** Recharts splits the line at the gap (default `connectNulls={false}`). The tooltip row formats `null` / `undefined` as `—`. Pass `<LineChartLine connectNulls>` to bridge.\n- **`NaN` values.** Recharts coerces `NaN` to `null` (gap behaviour). Pre-filter upstream if you need different handling.\n- **Single point (`data.length === 1`).** Recharts renders nothing geometric — `dot={false}` and `activeDot={false}` mean there is no marker, and a one-point line has zero length. Hover still works (the dashed cursor + popover surface the value). Consider falling back to a different chart type for single-point data.\n- **`xKey` typing.** The public union is `number | string` (no `Date`). Pass `Date.getTime()` timestamps and format with `tickFormatter`; the chart's own `<ReferenceArea>` lookups (zoom brush) rely on this contract — a stray `Date` instance breaks them silently.\n- **Mount animation.** `400 ms ease-out`, `begin=0`. `isAnimationActive='auto'` (the default) honours `prefers-reduced-motion`. Force off with `<LineChartLine disableAnimation />` — useful for E2E screenshot stability.\n- **`className` on a `LineChartLine`.** Recharts paints the line as `<path>` — only `stroke-*` Tailwind utilities take effect, **not** `bg-*` or `fill-*`. The same trap `PieChart` documents for slices applies here in mirror.\n- **Stale `activeKey` after a series disappears.** The root resolves `activeKey` against `seriesByKey`. In **controlled** mode the chart calls `onActiveKeyChange(null)` from an effect — make `onActiveKeyChange` stable so the reset lands in the same render cycle as the data change. In **uncontrolled** mode the internal state is left as-is; the next hover overwrites it. Either way the visible state never gets stuck at \"every line dimmed\".\n- **Stale tooltip cursor after `data` shrinks.** Recharts manages tooltip state internally and resets the cursor on every `data` update. There is no out-of-range slot to clamp — the chart simply re-renders the cursor against the new dataset on the next pointer event.\n- **Brush popover positioning.** The popover is `position: fixed` portalled to `document.body`. **X tracks the cursor** (`clientX` from the latest mousemove, offset right of the pointer); **Y is locked to the plot-area centre** via recharts' `usePlotArea()` + the recharts surface's viewport rect, then offset up by half its own height (`translateY(-50%)`) so the popover's vertical centre matches `<LineChartTooltip>`. Before recharts has measured the plot, Y falls back to the current pointer `clientY` for the one or two frames until `plotArea` lands. While the popover is open the chart subscribes to `window` `scroll` and `resize` (capture phase) and bumps a render tick, so the memoised viewport rect re-reads — without that subscription the portal's `position: fixed` would pin the popover to the original release point as the page scrolls and the chart would slide away beneath it. During **pending** the captured `clientX` is offset by the chart's horizontal scroll delta (the chart's viewport-`left` at release vs. now) so the popover stays anchored to the plot column, not the viewport.\n- **Two charts on one page, both with zoom.** The pending-zoom keydown handler is scoped to the owning chart via `LineChartZoomContext.rootRef`. Enter and Escape only commit / dismiss the chart whose root currently contains the event target (plus the portalled popover and the ambient body/html case). Sibling charts on the same page do not see each other's keystrokes.\n- **Long datasets.** The chart still works at 1000+ points (the `LongTimeRange` story stresses this) but recharts' tick generator and interpolation cost climb past ~5000 points. Downsample upstream.\n- **Edge tick text anchor uses the original `payload.coordinate`, not `props.x`/`props.y`.** Recharts pre-shifts the first/last tick's `tickCoord` inward so a *middle-anchored* label cannot overflow the plot. Our edge ticks override the anchor to `start` / `end`, so that shift would float the text edge ~10px off its tick (e.g. `23:00` landing visually near the `22:00` band). `renderAxisTick` re-anchors to `payload.coordinate` whenever the caller overrides the anchor, so the text's leading/trailing edge lands directly under the tick.\n- **`LineChartXAxis` defaults `interval` to `'preserveStartEnd'`.** Recharts' own default (`preserveEnd`) drops the first data tick whenever it collides with a neighbour, leaving the leftmost label visibly indented from the plot's left edge. We force both edges to render so `index === 0` and `index === visibleTicksCount - 1` are always the actual data edges — which is what the `renderTick` anchor override assumes when it pins the first label flush-left and the last flush-right.\n- **`LineChartEmpty` with no `children`.** Renders only the dashed grid frame — the idiom the `Loading` and `Refreshing` stories use, paired with a `<Skeleton>` strip in place of the legend rows. `null`/`undefined` children are both treated the same (no message overlay).\n- **Recharts requires axes / grid / lines / tooltip / brush to be direct children of `<RechartsLineChart>`.** Our wrappers (`LineChartXAxis`, `LineChartYAxis`, `LineChartGrid`, `LineChartLine`, `LineChartTooltip`, `LineChartZoomBrush`) all return bare recharts elements (or `null`); in recharts v3 each primitive self-registers via Redux dispatch, so mounting them anywhere inside the `<RechartsLineChart>` subtree wires up correctly.\n- **Inline event handlers on controlled mode.** Wrap `onActiveKeyChange` / `onZoomChange` in `useCallback` (or hoist them) — an inline arrow handler recreates every render and propagates through the data context, invalidating memoised legend rows. Not a correctness bug, but kills `React.memo` opportunities for custom row children.\n\n## Accessibility notes\n\n- The chart body (`<LineChartBody>`) is `aria-hidden='true'` — the SVG is decorative. The legend is the canonical screen-reader representation.\n- Recharts' default keyboard a11y layer is disabled (`accessibilityLayer={false}`) — it puts `tabindex=0` + `role='application'` on the SVG and would conflict with `aria-hidden` on our wrapper. The legend rows are the accessible alternative.\n- Interactive `<LineChartLegendItem>` rows expose `role='button'`, `tabIndex=0`; Enter/Space dispatch a click. `aria-current='true'` when the row is selected. Focus ring uses `focus-visible` so keyboard users see the ring and pointer clicks don't.\n- The hover popover (`<LineChartHoverPopover>`) is `role='tooltip'` + `aria-live='polite'`. `<LineChartHoverPopoverDot>` is `aria-hidden` so screen readers don't pronounce the colour swatch.\n- The zoom popover (`<LineChartZoomPopover>`) is `role='dialog'`, `aria-modal='false'` (non-modal — focus stays on the chart). The confirm button advertises `aria-keyshortcuts='Enter'`.\n- `<LineChartEmpty>` is a static frame — pass meaningful `children` (`'No data'`, `'Loading'`, etc.) for the screen-reader announcement.\n\n## Open questions / known limitations\n\n- **Stacked / area / scatter / step variants.** Out of scope for this PR — each is its own compound.\n- **Reference / threshold lines** (`<LineChartReferenceLine>`). Out of scope; will be added when a product use case lands.\n- **Multi-axis** (separate left + right Y). Out of scope.\n- **Built-in zoom-out / reset chip.** Out of scope — consumers render their own affordance in `<ChartActions>` and call `setRange(null)` (the `Zoom` story is the canonical pattern).\n- **Tooltip pinning** (\"click to keep open\"). Hover-only for v1.\n- **Cross-chart hover registry.** `syncId` (cursor X) + controlled `activeKey` (series highlight) cover the one-parent / multiple-charts case. A `ChartGroup` provider would live one level above this component to share an `activeKey` `useState` across an arbitrary number of charts.\n- **Internationalised range / tick formatting.** Caller supplies `tickFormatter` and `formatRange`. The chart does not call `Intl.DateTimeFormat` itself."
36100
+ "markdown": "# LineChart\n\n## Overview\n\n`LineChart` is a composable time-series chart for `SimpleCharts` cards — request volume, error rate, latency, anything where the X axis reads left-to-right and the Y axis is a continuous number. The SVG is drawn by [`recharts`](https://recharts.org/); we own the visual tokens, the popover surfaces, the legend layout, the empty/loading frames, and the cross-component sync (hover ↔ legend ↔ lines ↔ tooltip ↔ zoom).\n\nThe root takes `data` and `series` as two flat arrays — `data` is one row per X position with denormalised series values, `series` is the schema. Axes, grid, lines, tooltip, legend, and zoom brush are all opt-in slots, so a 200×196 sparkline is the same component as a fully-instrumented dashboard panel — only the children differ.\n\nUse it whenever a chart needs the same card frame as `BarList` / `PieChart` but the data is a trend (or any ordered X axis). Pick `BarList` for top-N comparisons and `PieChart` for share-of-total.\n\nPer the anatomy, a chart may also carry a **metric + legend** row between the card header and the grid: the shared `Metric` bricks (`MetricHeader` + `MetricValue` + `MetricDelta`) on the left, the legend on the right, in one `justify-between` row. This is pure consumer composition (the `WithMetric` story) — no chart props involved; the card grows by the row's height.\n\n## API\n\nCompound component. There is no single \"render everything\" prop — callers pick the slots they need.\n\n### Compositional groups (at a glance)\n\nNineteen named exports collapse into five compositional groups. Each group is independent — pick the slots from the groups you need; ignore the rest.\n\n| Group | Members | Purpose |\n| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |\n| **Root + body** | `LineChart`, `LineChartBody` | Provides the chart context + renders the recharts container. |\n| **Plot** | `LineChartGrid`, `LineChartXAxis`, `LineChartYAxis`, `LineChartLine` | Direct children of `<LineChartBody>` — the SVG geometry. |\n| **HoverPopover.*** | `LineChartTooltip`, `LineChartHoverPopover`, `LineChartHoverPopoverTimestamp`, `LineChartHoverPopoverRow`, `LineChartHoverPopoverDot` | Cursor-driven popover that reads `{ rows, xValue }` from the tooltip render-prop. |\n| **ZoomPopover.*** | `LineChartZoomBrush`, `LineChartZoomPopover`, `LineChartZoomPopoverRange`, `LineChartZoomPopoverConfirm` | Drag-to-zoom brush + the two-phase confirm popover. |\n| **Legend** | `LineChartLegend`, `LineChartLegendItem` | Accessible series legend; participates in hover sync. |\n| **Frame states** | `LineChartEmpty` | Static dashed-grid frame for empty-data and loading states. |\n\nThe flat-named exports stay the canonical form — the table above is just for quick discovery.\n\n### Root + body\n\n- `LineChart<T extends LineChartDatum = LineChartDatum>` — root. Owns `data`, `series`, `xKey`, the controlled series highlight (`activeKey`), the controlled filter (`filteredKeys`), the zoom event (`onZoomChange`), and cross-chart sync (`syncId`, `syncMethod`). **Generic over the datum shape**: pass a concrete `T` to turn `xKey` and `series[].key` into compile-time-checked `keyof T` unions (`<LineChart<RequestRow> data={rows} xKey='timestamp' />`). Leaving the default `LineChartDatum` keeps the loose untyped form. Provides four contexts to its children (data, active key, selection, zoom). The root renders a vertical-flex `<div data-slot='line-chart'>`; **legend placement is structural, not configured** — see the *Layout* section below.\n- `LineChartBody` — wraps recharts' `<ResponsiveContainer>` + `<LineChart>` and passes children through so they can self-register as recharts primitives. Default height is `LINE_CARD_HEIGHT - LINE_HEADER_HEIGHT` (164px) — override with `height`. `aria-hidden='true'` (the legend is the canonical accessible representation). Resizes re-render the plot at most every `LINE_RESIZE_DEBOUNCE_MS` (50ms, recharts' `debounce`); in between the body clips the previous width (`overflow-x-clip`) instead of letting it spill past the card.\n\n### Plot primitives (children of `LineChartBody`)\n\n- `LineChartLine` — one per series. Required `seriesKey` joins to `LineChartSeries.key`. Per-line `curve` (`'monotone'` default, `'linear'`), `disableAnimation`, `connectNulls`, `strokeWidth`, and `onClick`/`onMouseEnter`/`onMouseLeave`. Returns the bare recharts `<Line>` (no wrapper element) so recharts can hoist it into its layout.\n- `LineChartGrid` — wraps `<CartesianGrid>` with the dashed horizontal grid (`stroke='var(--color-border-primary-light)'`, `strokeDasharray='4 4'`). Drops the topmost grid line when it would visually crowd the chart's top edge (Figma \"no top x-line\") and unconditionally drops the bottommost line (the X-axis line owns the bottom rail). Escape hatches: `keepTopLine`, `keepBottomLine`. Opt into vertical grid via `vertical`.\n- `LineChartXAxis` — wraps `<XAxis>`. Reads `xKey` from context. Defaults: `tickLine={false}`, solid `axisLine` (the bottom rail of the plot), 10px sans secondary tick labels. First/last visible tick labels are anchored to their inside edges (`text-anchor: start` on the first, `end` on the last) so they cannot overflow the plot area; middle ticks stay center-anchored. Curated tick-spacing preset via `density: 'sparse' | 'normal' | 'dense'` (maps to `minTickGap` 64/32/16). Advanced escape-hatch props (`tickFormatter`, `interval`, `tickCount`, `ticks`, `minTickGap`, `domain`, `padding`, `type`) forward to recharts when no preset fits. `hideTicks` keeps the layout reservation but skips labels. `axisLine={false}` for sparkline-style plots — pair with `<LineChartGrid keepBottomLine />` so the bottom rail does not vanish.\n- `LineChartYAxis` — wraps `<YAxis>`. Default `width={LINE_Y_LABEL_WIDTH}` (38px, the Figma-mandated gutter). Defaults: `tickLine={false}`, `axisLine={false}`, the topmost tick label is skipped so no value sits flush against the chart's top edge. `interval={0}` disables recharts' built-in collision filter — combined with the top-tick skip, it gives a deterministic four-tick axis.\n- `LineChartTooltip` — wraps `<Tooltip>`. Renders the default `<LineChartHoverPopover>` body or accepts a render-prop child receiving `{ datum, rows, xValue }`. `rows` is the visible series pre-joined with each row's value at the active index — `rows.map(({ series, value }) => …)` is the canonical iteration pattern. **The popover Y is locked to the plot's vertical centre** (via recharts' `usePlotArea` + a `-translate-y-1/2` wrapper) so the popover does not jump between peaks and valleys; the dashed cursor guideline still tracks the cursor X. Cursor stroke matches Figma `7527-32438`: `var(--color-border-strong-primary)` (slate-500), 1px stroke, `4 2` dash pattern. `xTickFormatter` formats the default timestamp slot.\n- `LineChartZoomBrush` — enables drag-to-zoom on the plot. Mount once anywhere inside `<LineChartBody>`. `disabled` removes the interaction; `formatRange(range)` overrides the popover range text; `confirmLabel` overrides the button copy; `container` overrides the portal target (defaults to `document.body` — pass a dialog node when the chart lives inside a focus-trapped modal). See the *Interactions* section for the full two-phase lifecycle.\n\n### Hover popover slots\n\nCompose these inside the `LineChartTooltip` render-prop, or use the default body.\n\n- `LineChartHoverPopover` — the popover surface. `role='tooltip'`, `aria-live='polite'`, `bg-bg-surface-2`, `rounded-12`, `shadow-md`, `min-w-160`.\n- `LineChartHoverPopoverTimestamp` — sans heading (`text-xs font-medium`, primary text). The top row of the default body.\n- `LineChartHoverPopoverRow` — `<dot> <label … value>` row. Required `series` (drives colour and label), optional `value`, optional `formatValue`. Default formatter `toLocaleString()` for numbers, `String()` otherwise, `—` for `null`/`undefined`. Pass `children` to fully override the inner layout.\n- `LineChartHoverPopoverDot` — 8×8 `rounded-2` colour swatch reused by the legend and the popover rows. `color` accepts a palette token (`'brand'`, `'red'`, …) **or** any CSS colour string (`'var(--color-violet-500)'`, `'#8b5cf6'`, `'oklch(…)'`). Consumer-supplied `style.backgroundColor` wins.\n\n### Zoom popover slots\n\n- `LineChartZoomPopover` — popover surface. `role='dialog'`, `aria-modal='false'` (non-modal — focus stays on the chart, the popover is a transient affordance).\n- `LineChartZoomPopoverRange` — text-xs primary range string. Default content comes from `lib/formatRange.ts` (e.g. `26 Dec 00:00 → 3 Jan 23:59`); override via `formatRange` on `<LineChartZoomBrush>`.\n- `LineChartZoomPopoverConfirm` — brand-tinted \"Zoom in\" button. `aria-keyshortcuts='Enter'` so screen readers announce the shortcut. Wires to the context's `confirmZoom`.\n\n### Legend\n\n- `LineChartLegend` — row container. `orientation: 'horizontal' | 'vertical'` (default `'horizontal'`) — the only knob the legend exposes. `align: 'start' | 'center' | 'end'`. **No `position` prop** — placement is JSX-structural.\n- `LineChartLegendItem` — row. Required `seriesKey` joins to a `LineChartSeries.key`. `selected` is explicit; when omitted it falls back to `!hiddenSet.has(seriesKey)`. Becomes interactive when `onClick` is passed (`role='button'`, `tabIndex=0`, Enter/Space dispatch). Hovering / focusing pushes `activeKey` into the context — this is what dims sibling lines.\n\n### Empty state\n\n- `LineChartEmpty` — Figma \"No data\" frame. Three dashed grid lines + one solid bottom axis. Pass `children` for the empty copy (e.g. `<LineChartEmpty>No data</LineChartEmpty>`); omit them to render only the grid frame (the idiom the `Loading` and `Refreshing` stories use). There is **no default copy**.\n\n### `LineChartDatum`\n\n```ts\ntype LineChartDatum = Record<string, number | string | null | undefined>;\n```\n\nOne row per X position. The X value lives under the `xKey` (required — no default); every other series reads from `datum[series.key]`. `null` / `undefined` values create a gap in the line (unless the line opts into `connectNulls`).\n\nPass a concrete interface to `LineChart<T>` to gain compile-time safety on `xKey` and `series[].key`. The generic defaults to the loose `LineChartDatum` form so existing call sites stay typed against `Record<string, …>`.\n\n```ts\ninterface RequestRow { timestamp: number; requests: number; errors: number }\n\n<LineChart<RequestRow> data={rows} xKey='timestamp' /> // ✅\n<LineChart<RequestRow> data={rows} xKey='timetamp' /> // ❌ Type error: 'timetamp' is not keyof RequestRow\n```\n\n### `LineChartSeries`\n\n```ts\ninterface LineChartSeries<K extends string = string> {\n key: K; // unique join key (keyof T when LineChart<T> is used)\n label: string; // visible label\n color?: ChartColor | (string & {}); // palette token or any CSS colour string\n variant?: 'solid' | 'dashed'; // default 'solid'\n}\n```\n\n`color` is shared between the line stroke and the legend/tooltip dot — pass one value, both surfaces stay in sync. Palette tokens (`'brand'`, `'red'`, …) resolve via `LINE_STROKE_FILL`; any other string passes through verbatim, so `'var(--color-violet-500)'`, `'#8b5cf6'`, and `'oklch(…)'` all work. `'slate'` is the implicit fallback when `color` is omitted.\n\n### `LineChartZoomRange`\n\n```ts\ninterface LineChartZoomRange {\n fromIndex: number; // inclusive index into `data`\n toIndex: number; // inclusive index into `data`\n from: number | string; // data[fromIndex][xKey]\n to: number | string; // data[toIndex][xKey]\n}\n```\n\nEmitted via `onZoomChange` exactly once per confirmed brush selection.\n\n## Data model\n\n- **`data`** (required, `LineChartDatum[]`) — single source of truth for both the lines and the tooltip rows. **The caller sorts ascending by `xKey`.** Recharts assumes an ordered domain; unsorted data draws the line backwards. A `console.warn` fires in development on the first inversion.\n- **`series`** (required, `LineChartSeries[]`) — the schema. `key` must be unique and must appear on at least one datum (a development warning fires for series whose key never resolves to a value). Series declared but never composed as a `<LineChartLine>` are valid — you can render legend rows without lines.\n- **`xKey`** (required, `Extract<keyof T, string>`) — the field on each datum used as the X axis. Required because no default matched any real call site (every story passes `'timestamp'`). When `LineChart<T>` is parameterised with a concrete datum type, `xKey` must be one of `T`'s keys; a typo is a compile error. X values must be `number` or `string` — pass `Date.getTime()` timestamps rather than `Date` instances. `Date` is intentionally not in the type union because recharts handles it inconsistently (sorting works, but `ReferenceArea` lookup in the zoom brush silently falls through).\n- **`activeKey`** / **`onActiveKeyChange`** (optional, `string | null`) — controlled hover series. When omitted, the chart manages it internally with `useState`. Use the controlled mode to share the *series highlight* across sibling charts (line dimming + legend hover). The root resolves `activeKey` against the current `seriesByKey` map — if the previously hovered series disappears (filter, refresh, or a controlled value pointing at a missing key), the highlight collapses to `null` instead of dimming every line. In **controlled** mode the chart calls `onActiveKeyChange(null)` from an effect so sibling charts stop highlighting a stale key — wire your external state accordingly so the snap-to-null write does not surprise you. In uncontrolled mode the internal state is left as-is and the next hover overwrites it.\n- **`syncId`** (optional, `string | number`) — pair two or more charts with the same `syncId` and recharts' built-in middleware syncs their *cursor X* (tooltip cursor + brush) — hovering one chart highlights the matching X on every sibling. This is independent of `activeKey`: `syncId` handles the tooltip cursor, `activeKey` handles the series highlight. Use both together for full cross-chart sync, or either one alone.\n- **`syncMethod`** (optional, `'index' | 'value' | (ticks, data) => number`) — how sibling `syncId` charts match X positions. Defaults to `'index'` (recharts' default — match by position in `data`). Use `'value'` when the synced charts have different dataset lengths but share categorical X values.\n- **`filteredKeys`** (optional, `string[]`) — keys to **hide** from the plot. The matching `<LineChartLine>` skips rendering, the tooltip row disappears, and the legend row paints at `opacity-40` with `data-selected='false'`. Stable references only — an inline array literal recreates on every parent render and invalidates the internal `Set` memo. The per-row `selected` prop still wins when set explicitly.\n- **`onZoomChange`** (optional, `(range: LineChartZoomRange | null) => void`) — fires when the user confirms a brush selection. The chart never persists the zoom window — `data` is always the truth. The consumer decides whether to narrow `data` upstream, push to a router, refetch, etc.\n\n`LineChart` does not sort, aggregate, or downsample. Callers prepare the data array.\n\n### Filter mental model (vs. `PieChart`)\n\n`PieChart` uses `selectedNames` (the keys to **highlight**; the rest dim). `LineChart` uses `filteredKeys` (the keys to **hide**). The divergence is intentional — the dominant interaction on the line chart is \"click a legend row to toggle the line off\", not \"click to emphasise one slice\". Consumers of both charts should not expect the two props to behave the same.\n\n## Layout\n\nThe chart is layout-agnostic. The root is a vertical flex container; **JSX child order picks placement**:\n\n```tsx\n// Legend on top:\n<LineChart …>\n <LineChartLegend>…</LineChartLegend>\n <LineChartBody>…</LineChartBody>\n</LineChart>\n\n// Legend on bottom:\n<LineChart …>\n <LineChartBody>…</LineChartBody>\n <LineChartLegend>…</LineChartLegend>\n</LineChart>\n\n// Legend on the side — wrap body+legend in any row flex container\n// (`<HStack>` from the design system is the canonical choice). Pair\n// with `<LineChartLegend orientation='vertical'>` for the legend's own\n// row→column layout.\n<LineChart …>\n <HStack flexGrow gap={12}>\n <LineChartLegend orientation='vertical'>…</LineChartLegend>\n <LineChartBody>…</LineChartBody>\n </HStack>\n</LineChart>\n```\n\nThere is no `position` prop on the legend or on the root. The legend's only knob is its own `orientation` (`'horizontal'` | `'vertical'`); placement is structural and lives in user-land. The `LegendPlacements` story demonstrates all four placements side by side.\n\n## States\n\n| State | Trigger | Behaviour |\n| --------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |\n| Default | — | Lines use `LINE_STROKE_FILL[color]` at full opacity. Mount animation: `400 ms ease-out`, honours `prefers-reduced-motion`. |\n| Hover (line) | Pointer over a `<LineChartLine>` path | No dimming — the plot stays at full opacity. The consumer's `onMouseEnter` / `onMouseLeave` still fire. The dashed cursor guideline and hover popover track the pointer via recharts' tooltip state. |\n| Hover (legend) | Pointer / focus over a `<LineChartLegendItem>` | `activeKey` snaps to that series; sibling lines fade to `LINE_INACTIVE_OPACITY` (0.3). The row paints `bg-states-primary-hover`. |\n| Hover (point) | Pointer over the plot body | Recharts' tooltip state snaps to the nearest X. The Y-locked popover renders one row per visible series at that index. The dashed vertical cursor guideline tracks the cursor X — this is the only on-plot hover affordance; no per-point dots render (`dot={false}` and `activeDot={false}`). Sibling charts that share a `syncId` mirror the highlight automatically. |\n| Focus | Keyboard focus on an interactive legend row | `bg-states-primary-hover` + `ring-focus-primary` inset ring (`focus-visible`). |\n| Filtered | `series.key` ∈ `filteredKeys` | `<LineChartLine>` does not render. Legend row paints at `opacity-40` with `aria-current` removed. |\n| Zoom-dragging | Pointer down → drag on the plot while `<LineChartZoomBrush>` is mounted | Borderless gray `<ReferenceArea>` (`fill: var(--color-states-primary-hover)`, no stroke) follows the cursor; a floating popover tracks the cursor with the live range text. Hover popover and dashed cursor guideline are suppressed — the zoom popover owns the cursor. |\n| Zoom-pending | Mouse release after a non-zero drag | Selection rectangle stays; popover becomes interactive with the \"Zoom in\" button. Hover popover stays suppressed until the pending state resolves. Click outside / Escape dismisses; Enter or button click confirms. |\n| Loading | Render `<LineChartEmpty>{null}</LineChartEmpty>` (or a `Skeleton` strip) | Dashed grid only — no labels, no line trace. Mirrors Figma `7519-2591`. The `Refreshing` story is the canonical pattern. |\n| Empty | `data.length === 0` | Compose `<LineChartEmpty>No data</LineChartEmpty>` in the body slot — the chart itself renders nothing without it. |\n| SinglePoint | `data.length === 1` | Nothing geometric renders — `dot={false}` + `activeDot={false}` mean no marker, and a one-point line has zero length. Hover still surfaces the value via the dashed cursor + popover. |\n| Gapped | `null` / `undefined` value on a datum for a given series | The line splits at the gap (recharts default `connectNulls={false}`). Set `<LineChartLine connectNulls>` to bridge. |\n\n## Interactions\n\n- **Hover sync (legend ↔ tooltip)** — pointer / focus on a `<LineChartLegendItem>` pushes `activeKey` into the active context; lines whose key does not match fade to 30% and the matching line gets `data-active='true'`. Hovering a line itself does **not** drive `activeKey` — the plot stays at full opacity so the cursor sweeping across multiple series does not flash sibling lines on and off. `<LineChartLegendItem>` uses `isHoverSyncTarget(event.relatedTarget)` in its `mouseLeave` / `blur` handlers to skip the `null`-clear when focus / pointer is moving directly between legend rows; without it React would render an intermediate `activeKey=null` frame on every crossing and the dimmed lines would flicker.\n- **Hover popover Y-lock** — `<LineChartTooltip>` reads recharts' inner plot rectangle via `usePlotArea()` and anchors the popover at `plotArea.y + plotArea.height / 2`. A wrapping `<div className='-translate-y-1/2'>` turns that coordinate into the popover's visual centre. `isAnimationActive={false}` is non-negotiable — without it recharts interpolates the popover Y between frames and defeats the lock. The first paint falls back to `HOVER_POPOVER_TOP` (8px) until recharts measures the plot.\n- **Click-to-filter** — caller-driven. Attach `onClick` to `<LineChartLegendItem>`; toggle the matching key in your own `filteredKeys` state. Enter/Space dispatch a click on the row.\n- **Cross-chart hover sync** — two layers compose:\n 1. **Cursor X (tooltip + brush)** — pass the same `syncId` to every chart that should mirror the cursor. Recharts' own redux middleware handles the wiring; no controlled state needed. `syncMethod='value'` lets datasets of different lengths sync by shared category instead of by position.\n 2. **Series highlight (line dimming + legend)** — wire each chart's `activeKey` / `onActiveKeyChange` to a shared `useState<string | null>`. The internal `lastActiveKeyRef` dedupe prevents the per-pixel mousemove flood from spamming the callback. See the `CrossChartHoverSync` story for the canonical recipe pairing both layers.\n- **Zoom (drag → pending → confirm)** — `<LineChartZoomBrush>` enables a crosshair drag on the plot. Two-phase:\n 1. **Drag** — `mousedown` on the plot starts a drag at the active tooltip index. A borderless gray `<ReferenceArea>` (`fill: var(--color-states-primary-hover)`, no stroke) follows the cursor; a portalled floating popover tracks the cursor position and renders the live range string. The hover popover and cursor guideline are hidden while dragging. Drag is followed at the *window* level so the popover keeps tracking when the cursor leaves the SVG and the release still commits even outside the chart. Escape mid-drag cancels outright (no pending popover).\n 2. **Pending** — `mouseup` after a non-zero drag locks the selection and turns the popover interactive (`pointer-events: auto`). Hover suppression continues — the chart reads as non-interactive until the user confirms or dismisses. The \"Zoom in\" button confirms — Enter does the same. Escape, click outside the popover, or starting a new drag dismisses without emitting. The popover stops a bubbling `mousedown` on its surface so clicking the confirm button does not start a fresh drag.\n 3. **Confirm** — fires `onZoomChange({ from, to, fromIndex, toIndex })` exactly once. The consumer narrows `data`; the brush state resets automatically.\n- **Zoom keyboard contract** — the keydown listener is gated to events whose target is inside the chart container, inside the popover, or ambient (focus on `<body>` / `<html>`). It does **not** hijack Enter for forms / dialogs / focused inputs elsewhere on the page.\n- **Single-index click (no zoom)** — clicking the plot without dragging produces a zero-width range and is dropped silently so users cannot accidentally surface a confirm popover on a plain click.\n- **Data refresh while zoom-pending** — the root resets `zoomDrag` and `zoomPending` whenever `data` or `xKey` changes. Emitting a stale range against a no-longer-current dataset would point at nothing meaningful.\n\n## Edge cases & unclear states\n\n- **Per-series hover and per-index hover are different things.** Pointing at a legend row sets the **series** active (dims sibling lines, keeps the popover off). Pointing at the plot body sets the **index** active (lights up the popover for every visible series at that X). They live in separate contexts on purpose so the lines don't re-render on every cursor frame. Hovering a line in the plot does **not** dim siblings — only the legend drives the series dim.\n- **The popover Y is locked, not the X.** The dashed cursor guideline still tracks the cursor's X; the popover surface stays put on Y so it does not jump between peaks and valleys. Disabling animation (`isAnimationActive={false}`) is required — without it recharts interpolates Y between frames.\n- **Filtering down to zero series.** The plot renders an empty frame (grid + axes still visible). Recommend swapping the body for `<LineChartEmpty>No data</LineChartEmpty>` when the consumer's filter UI produces this state.\n- **`null` / `undefined` datum values.** Recharts splits the line at the gap (default `connectNulls={false}`). The tooltip row formats `null` / `undefined` as `—`. Pass `<LineChartLine connectNulls>` to bridge.\n- **`NaN` values.** Recharts coerces `NaN` to `null` (gap behaviour). Pre-filter upstream if you need different handling.\n- **Single point (`data.length === 1`).** Recharts renders nothing geometric — `dot={false}` and `activeDot={false}` mean there is no marker, and a one-point line has zero length. Hover still works (the dashed cursor + popover surface the value). Consider falling back to a different chart type for single-point data.\n- **`xKey` typing.** The public union is `number | string` (no `Date`). Pass `Date.getTime()` timestamps and format with `tickFormatter`; the chart's own `<ReferenceArea>` lookups (zoom brush) rely on this contract — a stray `Date` instance breaks them silently.\n- **Mount animation.** `400 ms ease-out`, `begin=0`. `isAnimationActive='auto'` (the default) honours `prefers-reduced-motion`. Force off with `<LineChartLine disableAnimation />` — useful for E2E screenshot stability.\n- **`className` on a `LineChartLine`.** Recharts paints the line as `<path>` — only `stroke-*` Tailwind utilities take effect, **not** `bg-*` or `fill-*`. The same trap `PieChart` documents for slices applies here in mirror.\n- **Stale `activeKey` after a series disappears.** The root resolves `activeKey` against `seriesByKey`. In **controlled** mode the chart calls `onActiveKeyChange(null)` from an effect — make `onActiveKeyChange` stable so the reset lands in the same render cycle as the data change. In **uncontrolled** mode the internal state is left as-is; the next hover overwrites it. Either way the visible state never gets stuck at \"every line dimmed\".\n- **Stale tooltip cursor after `data` shrinks.** Recharts manages tooltip state internally and resets the cursor on every `data` update. There is no out-of-range slot to clamp — the chart simply re-renders the cursor against the new dataset on the next pointer event.\n- **Brush popover positioning.** The popover is `position: fixed` portalled to `document.body`. **X tracks the cursor** (`clientX` from the latest mousemove, offset right of the pointer); **Y is locked to the plot-area centre** via recharts' `usePlotArea()` + the recharts surface's viewport rect, then offset up by half its own height (`translateY(-50%)`) so the popover's vertical centre matches `<LineChartTooltip>`. Before recharts has measured the plot, Y falls back to the current pointer `clientY` for the one or two frames until `plotArea` lands. While the popover is open the chart subscribes to `window` `scroll` and `resize` (capture phase) and bumps a render tick, so the memoised viewport rect re-reads — without that subscription the portal's `position: fixed` would pin the popover to the original release point as the page scrolls and the chart would slide away beneath it. During **pending** the captured `clientX` is offset by the chart's horizontal scroll delta (the chart's viewport-`left` at release vs. now) so the popover stays anchored to the plot column, not the viewport.\n- **Two charts on one page, both with zoom.** The pending-zoom keydown handler is scoped to the owning chart via `LineChartZoomContext.rootRef`. Enter and Escape only commit / dismiss the chart whose root currently contains the event target (plus the portalled popover and the ambient body/html case). Sibling charts on the same page do not see each other's keystrokes.\n- **Long datasets.** The chart still works at 1000+ points (the `LongTimeRange` story stresses this) but recharts' tick generator and interpolation cost climb past ~5000 points. Downsample upstream.\n- **Edge tick text anchor uses the original `payload.coordinate`, not `props.x`/`props.y`.** Recharts pre-shifts the first/last tick's `tickCoord` inward so a *middle-anchored* label cannot overflow the plot. Our edge ticks override the anchor to `start` / `end`, so that shift would float the text edge ~10px off its tick (e.g. `23:00` landing visually near the `22:00` band). `renderAxisTick` re-anchors to `payload.coordinate` whenever the caller overrides the anchor, so the text's leading/trailing edge lands directly under the tick.\n- **`LineChartXAxis` defaults `interval` to `'preserveStartEnd'`.** Recharts' own default (`preserveEnd`) drops the first data tick whenever it collides with a neighbour, leaving the leftmost label visibly indented from the plot's left edge. We force both edges to render so `index === 0` and `index === visibleTicksCount - 1` are always the actual data edges — which is what the `renderTick` anchor override assumes when it pins the first label flush-left and the last flush-right.\n- **`LineChartEmpty` with no `children`.** Renders only the dashed grid frame — the idiom the `Loading` and `Refreshing` stories use, paired with a `<Skeleton>` strip in place of the legend rows. `null`/`undefined` children are both treated the same (no message overlay).\n- **Recharts requires axes / grid / lines / tooltip / brush to be direct children of `<RechartsLineChart>`.** Our wrappers (`LineChartXAxis`, `LineChartYAxis`, `LineChartGrid`, `LineChartLine`, `LineChartTooltip`, `LineChartZoomBrush`) all return bare recharts elements (or `null`); in recharts v3 each primitive self-registers via Redux dispatch, so mounting them anywhere inside the `<RechartsLineChart>` subtree wires up correctly.\n- **Inline event handlers on controlled mode.** Wrap `onActiveKeyChange` / `onZoomChange` in `useCallback` (or hoist them) — an inline arrow handler recreates every render and propagates through the data context, invalidating memoised legend rows. Not a correctness bug, but kills `React.memo` opportunities for custom row children.\n\n## Accessibility notes\n\n- The chart body (`<LineChartBody>`) is `aria-hidden='true'` — the SVG is decorative. The legend is the canonical screen-reader representation.\n- Recharts' default keyboard a11y layer is disabled (`accessibilityLayer={false}`) — it puts `tabindex=0` + `role='application'` on the SVG and would conflict with `aria-hidden` on our wrapper. The legend rows are the accessible alternative.\n- Interactive `<LineChartLegendItem>` rows expose `role='button'`, `tabIndex=0`; Enter/Space dispatch a click. `aria-current='true'` when the row is selected. Focus ring uses `focus-visible` so keyboard users see the ring and pointer clicks don't.\n- The hover popover (`<LineChartHoverPopover>`) is `role='tooltip'` + `aria-live='polite'`. `<LineChartHoverPopoverDot>` is `aria-hidden` so screen readers don't pronounce the colour swatch.\n- The zoom popover (`<LineChartZoomPopover>`) is `role='dialog'`, `aria-modal='false'` (non-modal — focus stays on the chart). The confirm button advertises `aria-keyshortcuts='Enter'`.\n- `<LineChartEmpty>` is a static frame — pass meaningful `children` (`'No data'`, `'Loading'`, etc.) for the screen-reader announcement.\n\n## Open questions / known limitations\n\n- **Stacked / area / scatter / step variants.** Out of scope for this PR — each is its own compound.\n- **Reference / threshold lines** (`<LineChartReferenceLine>`). Out of scope; will be added when a product use case lands.\n- **Multi-axis** (separate left + right Y). Out of scope.\n- **Built-in zoom-out / reset chip.** Out of scope — consumers render their own affordance in `<ChartActions>` and call `setRange(null)` (the `Zoom` story is the canonical pattern).\n- **Tooltip pinning** (\"click to keep open\"). Hover-only for v1.\n- **Cross-chart hover registry.** `syncId` (cursor X) + controlled `activeKey` (series highlight) cover the one-parent / multiple-charts case. A `ChartGroup` provider would live one level above this component to share an `activeKey` `useState` across an arbitrary number of charts.\n- **Internationalised range / tick formatting.** Caller supplies `tickFormatter` and `formatRange`. The chart does not call `Intl.DateTimeFormat` itself."
36101
36101
  },
36102
36102
  {
36103
36103
  "name": "Metric",
36104
- "markdown": "# Metric\n\n## Overview\n\n`Metric` is the family’s compact stat, built as a compound \"brick set\". It renders the body\nonly — the card frame and \"Title\" header are composed with `Chart` / `ChartHeader` /\n`ChartTitle`, like every member of the family. It is the standalone sibling of\n`HorizontalBarStack`: the same value/total/delta header (Figma `_metric-item-common`), without\nthe segmented bar or legend.\n\nAdd `MetricSparkline` after it to show how that number moved over the period (Figma\n`Type=Metric and sparkline`) — a 48px line + area along the card's bottom edge.\n\nUse it when one number is the whole story. When that number breaks into a few named parts shown\nas one band, use `HorizontalBarStack`; when each part needs its own row, use `BarList`.\n\n## Figma\n\n- Root: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=11437-11939\n- Options (brick set): https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=11520-4054\n- Loading states board: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=11580-9211\n- Sparkline — docs: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=12610-10640\n- Sparkline — colour variants: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=12610-10644\n- Sparkline — component (`Type=Metric and sparkline`): https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=12610-10548\n\n## Composition\n\n```tsx\n<Chart>\n <ChartHeader><ChartTitle>Findings</ChartTitle></ChartHeader>\n <Metric>\n <MetricHeader>\n <MetricValue>91</MetricValue>\n <MetricTotal connector=\"of\">120</MetricTotal>\n <MetricDelta value={10} trend=\"up\" sentiment=\"negative\" />\n </MetricHeader>\n <MetricCaption>blocked today</MetricCaption>\n </Metric>\n {/* optional — the trend over the period */}\n <MetricSparkline data={points} color=\"negative\" label=\"Findings\" />\n</Chart>\n```\n\n| Brick | Role |\n| --- | --- |\n| `Metric` | Root: column layout + `TestIdProvider`. `data-slot=\"metric\"`. |\n| `MetricHeader` | Baseline row holding value + total + delta. Exported so `HorizontalBarStack` reuses the exact same header. |\n| `MetricValue` | The headline number. A numeric child is formatted with `toLocaleString('en-US')`; any other node renders as-is. Display font, 30px. |\n| `MetricTotal` | The denominator. `connector` = `slash` (`/120`, default) · `of` (`of 120`) · `total` (`120 total`). Numeric child locale-formatted; number in primary text, connector word muted. |\n| `MetricDelta` | Trend chip — a thin wrapper over the shared `ChartDelta`. `sentiment` sets the colour (positive → green, **negative → red**, neutral → slate; default neutral) independently of the `trend` arrow. |\n| `MetricCaption` | Muted secondary subline under the header. |\n| `MetricSparkline` | The trend under the metric. A **sibling after `Metric`** inside `Chart`, not a child: the metric's bottom `py-8` is the Figma 8px gap, `mt-auto` pins the sparkline to the card's bottom edge, `px-px` is the 1px inset from the border. |\n\nAll bricks are optional; only `MetricValue` is expected in practice.\n\n## Data model\n\n- `MetricValue` / `MetricTotal` children: `number` (locale-formatted) or any `ReactNode`.\n- `MetricTotal.connector`: `'slash' | 'of' | 'total'`, default `'slash'`.\n- `MetricDelta`: `{ value: number; trend?: 'up' | 'down'; sentiment?: 'positive' | 'negative' | 'neutral' }` — the shared `ChartDelta`.\n- `MetricSparkline`:\n - `data: { x: Date | number | string; value: number }[]` — oldest first, evenly spaced (points are\n placed by index, not by time).\n - `color`: `'brand'` (default) · `'positive'` · `'negative'` · `'neutral'` — line tokens\n `bg/fill/brand` · `bg/fill/success` · `bg/fill/danger` · `border/strong/primary`. The area is the\n same token as a vertical gradient 20% → 0%; neutral's area uses `bg/strong/primary`.\n - `label` — series name on the popover row; `formatX(x)` / `formatValue(value: number)` override\n the popover text.\n\n## States\n\n`Metric` itself is display-only. The visual variety comes from which bricks are composed\n(value only, + delta, + total in each connector form, + caption). The `Gallery` story covers the\ncombinations.\n\n**Loading** — swap `Metric` for `MetricSkeleton` inside the `Chart` card (the `Loading` story).\nIt renders a value-sized block (72×36) plus a thin caption line on the DS `Skeleton` shimmer,\nsized to the loaded layout so the card doesn't jump; announces `aria-busy` / `aria-live`.\n\n**Sparkline** — `Sparkline` / `SparklineColors` stories. Empty `data` keeps the 48px box with\nnothing drawn, so the card doesn't jump while data loads.\n\n## Interactions\n\n`Metric` has none. `MetricSparkline` on pointer move snaps to the **nearest point by index** and\nshows a dashed ruler (the LineChart cursor: `border/strong/primary`, `4 2`) plus the LineChart hover\npopover — date and the value row. Pointer leave hides both. No click, no selection, no keyboard.\n\n## Edge cases & unclear states\n\n- **Delta sentiment vs direction** — colour (`sentiment`) and arrow (`trend`) are independent, so an\n increase can read as negative (red ↑) and a decrease as positive (green ↓). Shared with\n `HorizontalBarStack` via the internal `ChartDelta`.\n- **`total` means different things across the family** — here it is the *text* denominator\n (`MetricTotal`). On `HorizontalBarStack`, `total` is the *bar* denominator (the grey remainder\n tail) and is never shown as text.\n- **`data-testid`** — slot ids derive from the root (`metric--header`, `--value`, `--total`,\n `--delta`, `--caption`); no root `data-testid` → clean DOM. `MetricSparkline` sits outside\n `Metric`, so it derives `--sparkline` from the surrounding provider (`Chart`) or takes its own\n `data-testid`; its popover is `{sparkline id}--popover`.\n- **Card height is `Chart`'s, on purpose** — the Figma `Metric and sparkline` card is 140px, ours\n keeps `Chart`'s `min-h-196` like the other cards (only the column chart card goes lower, to 128px); the sparkline stays pinned to the bottom\n and the extra height sits above it.\n- **Sparkline colour vs delta sentiment** — independent props, like `sentiment` vs `trend`: set both.\n- **Sparkline Y domain is the data's own min..max**, mapped to 4px…40px of the 48px plot (top\n headroom for the stroke, bottom room so the area reads under the lowest point). A constant series\n or a single point draws a flat midline. It shows shape, not magnitude — don't read it against zero.\n- **Plain SVG, no recharts** — `viewBox` is one unit per step × 48px with\n `preserveAspectRatio='none'`, and the line uses `vector-effect: non-scaling-stroke`, so it stretches\n to any card width at a constant 1.5px without measuring the container.\n- **The popover is portalled to `document.body`** (`position: fixed`, `--tooltip-z-index`): the\n 48px plot is shorter than the popover and `Chart` clips its overflow. It opens 8px right of the\n ruler and 14px below the plot top, flipping to the left on the right half of the plot. Any scroll or\n resize ends the hover — a fixed popover can't follow the card. If `data` shrinks under a held\n pointer, a point that no longer exists is dropped (no ruler, no popover) until the next move.\n- **Dates** — the default popover date is `formatChartDate(x, { withYear: true })` (`1 Jan, 2026`,\n order from `DateFormatProvider`) for a `Date`, a timestamp, or an ISO string; any other string is\n shown as written. The check is a `YYYY-MM-DD` prefix, not `Date.parse`, which V8 lets read labels\n like `\"Week 1\"` as 1 Jan 2001. A date-only ISO string (`'2026-01-05'`) is that local day — the\n shared `toDate` reads it as local midnight, not UTC (west of UTC it used to show the day before).\n- **A non-finite value** (`NaN`, `Infinity`) leaves a gap in the line and area; its popover value\n reads `—`. The Y domain ignores it.\n\n## Accessibility notes\n\n- The delta badge is a named image (`role='img'` + `aria-label` like `\"up 10\"`) — `Badge` renders a\n generic `<div>`, so without the role the label would not be announced and the trend direction\n (carried only by the `aria-hidden` arrow icon) would be lost. Numbers use `toLocaleString('en-US')`.\n- Non-interactive: no focusable elements.\n- The sparkline plot is `aria-hidden` (like `LineChartBody`): the trend is a visual summary of the\n number already announced by `MetricValue` / `MetricDelta`. The hover popover is `role='tooltip'`\n with `aria-live='polite'`. Pass `aria-label` / `role='img'` on `MetricSparkline` to name the trend\n when it carries information the metric doesn't.\n\n## Open questions / known limitations\n\n- Sparkline points are spaced by index; uneven time gaps are not drawn to scale.\n- Caption is the subline below the header. An inline-right caption placement (if the design ever\n needs one) is not implemented."
36104
+ "markdown": "# Metric\n\n## Overview\n\n`Metric` is the family’s compact stat, built as a compound \"brick set\". It renders the body\nonly — the card frame and \"Title\" header are composed with `Chart` / `ChartHeader` /\n`ChartTitle`, like every member of the family. It is the standalone sibling of\n`HorizontalBarStack`: the same value/total/delta header (Figma `_metric-item-common`), without\nthe segmented bar or legend.\n\nAdd `MetricSparkline` after it to show how that number moved over the period (Figma\n`Type=Metric and sparkline`) — a 48px line + area along the card's bottom edge.\n\nUse it when one number is the whole story. When that number breaks into a few named parts shown\nas one band, use `HorizontalBarStack`; when each part needs its own row, use `BarList`.\n\n## Composition\n\n```tsx\n<Chart>\n <ChartHeader><ChartTitle>Findings</ChartTitle></ChartHeader>\n <Metric>\n <MetricHeader>\n <MetricValue>91</MetricValue>\n <MetricTotal connector=\"of\">120</MetricTotal>\n <MetricDelta value={10} trend=\"up\" sentiment=\"negative\" />\n </MetricHeader>\n <MetricCaption>blocked today</MetricCaption>\n </Metric>\n {/* optional — the trend over the period */}\n <MetricSparkline data={points} color=\"negative\" label=\"Findings\" />\n</Chart>\n```\n\n| Brick | Role |\n| --- | --- |\n| `Metric` | Root: column layout + `TestIdProvider`. `data-slot=\"metric\"`. |\n| `MetricHeader` | Baseline row holding value + total + delta. Exported so `HorizontalBarStack` reuses the exact same header. |\n| `MetricValue` | The headline number. A numeric child is formatted with `toLocaleString('en-US')`; any other node renders as-is. Display font, 30px. |\n| `MetricTotal` | The denominator. `connector` = `slash` (`/120`, default) · `of` (`of 120`) · `total` (`120 total`). Numeric child locale-formatted; number in primary text, connector word muted. |\n| `MetricDelta` | Trend chip — a thin wrapper over the shared `ChartDelta`. `sentiment` sets the colour (positive → green, **negative → red**, neutral → slate; default neutral) independently of the `trend` arrow. |\n| `MetricCaption` | Muted secondary subline under the header. |\n| `MetricSparkline` | The trend under the metric. A **sibling after `Metric`** inside `Chart`, not a child: the metric's bottom `py-8` is the Figma 8px gap, the metric's `mt-auto` pins both to the card's bottom edge, `px-px` is the 1px inset from the border. |\n\nAll bricks are optional; only `MetricValue` is expected in practice.\n\n## Data model\n\n- `MetricValue` / `MetricTotal` children: `number` (locale-formatted) or any `ReactNode`.\n- `MetricTotal.connector`: `'slash' | 'of' | 'total'`, default `'slash'`.\n- `MetricDelta`: `{ value: number; trend?: 'up' | 'down'; sentiment?: 'positive' | 'negative' | 'neutral' }` — the shared `ChartDelta`.\n- `MetricSparkline`:\n - `data: { x: Date | number | string; value: number }[]` — oldest first, evenly spaced (points are\n placed by index, not by time).\n - `color`: `'brand'` (default) · `'positive'` · `'negative'` · `'neutral'` — line tokens\n `bg/fill/brand` · `bg/fill/success` · `bg/fill/danger` · `border/strong/primary`. The area is the\n same token as a vertical gradient 20% → 0%; neutral's area uses `bg/strong/primary`.\n - `label` — series name on the popover row; `formatX(x)` / `formatValue(value: number)` override\n the popover text.\n\n## States\n\n`Metric` itself is display-only. The visual variety comes from which bricks are composed\n(value only, + delta, + total in each connector form, + caption). The `Gallery` story covers the\ncombinations.\n\n**Loading** — swap `Metric` for `MetricSkeleton` inside the `Chart` card (the `Loading` story).\nIt renders a value-sized block (72×36) plus a thin caption line on the DS `Skeleton` shimmer,\nsized to the loaded layout so the card doesn't jump; announces `aria-busy` / `aria-live`.\n\n**Sparkline** — `Sparkline` / `SparklineColors` stories. Empty `data` keeps the 48px box with\nnothing drawn, so the card doesn't jump while data loads.\n\n## Interactions\n\n`Metric` has none. `MetricSparkline` on pointer move snaps to the **nearest point by index** and\nshows a dashed ruler (the LineChart cursor: `border/strong/primary`, `4 2`) plus the LineChart hover\npopover — date and the value row. Pointer leave hides both. No click, no selection, no keyboard.\n\n## Edge cases & unclear states\n\n- **Delta sentiment vs direction** — colour (`sentiment`) and arrow (`trend`) are independent, so an\n increase can read as negative (red ↑) and a decrease as positive (green ↓). Shared with\n `HorizontalBarStack` via the internal `ChartDelta`.\n- **`total` means different things across the family** — here it is the *text* denominator\n (`MetricTotal`). On `HorizontalBarStack`, `total` is the *bar* denominator (the grey remainder\n tail) and is never shown as text.\n- **`data-testid`** — slot ids derive from the root (`metric--header`, `--value`, `--total`,\n `--delta`, `--caption`); no root `data-testid` → clean DOM. `MetricSparkline` sits outside\n `Metric`, so it derives `--sparkline` from the surrounding provider (`Chart`) or takes its own\n `data-testid`; its popover is `{sparkline id}--popover`.\n- **Card height is `Chart`'s, on purpose** — the Figma `Metric and sparkline` card is 140px; ours\n is content-sized like the other cards (`Chart` sets no minimum); the sparkline stays\n pinned to the bottom and any extra height sits above it.\n- **Sparkline colour vs delta sentiment** — independent props, like `sentiment` vs `trend`: set both.\n- **Sparkline Y domain is the data's own min..max**, mapped to 4px…40px of the 48px plot (top\n headroom for the stroke, bottom room so the area reads under the lowest point). A constant series\n or a single point draws a flat midline. It shows shape, not magnitude — don't read it against zero.\n- **Plain SVG, no recharts** — `viewBox` is one unit per step × 48px with\n `preserveAspectRatio='none'`, and the line uses `vector-effect: non-scaling-stroke`, so it stretches\n to any card width at a constant 1.5px without measuring the container.\n- **The popover is portalled to `document.body`** (`position: fixed`, `--tooltip-z-index`): the\n 48px plot is shorter than the popover and `Chart` clips its overflow. It opens 8px right of the\n ruler and 14px below the plot top, flipping to the left on the right half of the plot. Any scroll or\n resize ends the hover — a fixed popover can't follow the card. If `data` shrinks under a held\n pointer, a point that no longer exists is dropped (no ruler, no popover) until the next move.\n- **Dates** — the default popover date is `formatChartDate(x, { withYear: true })` (`1 Jan, 2026`,\n order from `DateFormatProvider`) for a `Date`, a timestamp, or an ISO string; any other string is\n shown as written. The check is a `YYYY-MM-DD` prefix, not `Date.parse`, which V8 lets read labels\n like `\"Week 1\"` as 1 Jan 2001. A date-only ISO string (`'2026-01-05'`) is that local day — the\n shared `toDate` reads it as local midnight, not UTC (west of UTC it used to show the day before).\n- **A non-finite value** (`NaN`, `Infinity`) leaves a gap in the line and area; its popover value\n reads `—`. The Y domain ignores it.\n\n## Accessibility notes\n\n- The delta badge is a named image (`role='img'` + `aria-label` like `\"up 10\"`) — `Badge` renders a\n generic `<div>`, so without the role the label would not be announced and the trend direction\n (carried only by the `aria-hidden` arrow icon) would be lost. Numbers use `toLocaleString('en-US')`.\n- Non-interactive: no focusable elements.\n- The sparkline plot is `aria-hidden` (like `LineChartBody`): the trend is a visual summary of the\n number already announced by `MetricValue` / `MetricDelta`. The hover popover is `role='tooltip'`\n with `aria-live='polite'`. Pass `aria-label` / `role='img'` on `MetricSparkline` to name the trend\n when it carries information the metric doesn't.\n\n## Open questions / known limitations\n\n- Sparkline points are spaced by index; uneven time gaps are not drawn to scale.\n- Caption is the subline below the header. An inline-right caption placement (if the design ever\n needs one) is not implemented."
36105
36105
  },
36106
36106
  {
36107
36107
  "name": "PieChart",
36108
- "markdown": "# PieChart\n\n## Overview\n\n`PieChart` is a composable donut chart paired with a vertical legend, designed for compact \"share-of-total\" breakdowns inside a `Chart` card — response codes, traffic origins, error categories. The donut is drawn by [`recharts`](https://recharts.org/), the legend is hand-rolled JSX, and the two are synced through React context: hovering a slice highlights its legend row and vice versa.\n\nThe root accepts `data` as the single source of truth. The donut renders the slices; the legend is composed by the caller (`PieChartLegendItem` rows are referenced by `name`). This keeps the badge/label/value layout fully flexible while preserving the hover and percent calculations.\n\nUse it whenever a chart needs the same \"small chart card\" frame as `BarList` but the data reads naturally as parts of a whole.\n\n## Figma\n\n- Root: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7490-122167&m=dev\n- Hover state (legend ↔ slice sync): https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7490-122445&m=dev\n- Filtered (single slice): https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7490-122941&m=dev\n- Loading skeleton: https://www.figma.com/design/VKb5gW46uSGw0rqrhZsbXT/WADS-Components?node-id=7519-2618&m=dev\n\n## API\n\nCompound component:\n\n- `PieChart` — root. Accepts `data: PieChartDatum[]`, optional `total`, optional controlled `activeName` / `onActiveNameChange`. Provides context to children.\n- `PieChartDonut` — wraps the recharts donut. Reads `data` + `activeName` from context. Renders a fixed `168×136` block with a `120×120` SVG inside; mouse events on slices fire context's `setActive`. Pass `disableAnimation` when running in environments that need deterministic frames. Children of `PieChartDonut` render absolutely-positioned over the SVG (use `PieChartCenter`).\n- `PieChartCenter` — absolutely-positioned centre slot, intentionally `pointer-events-none` and `aria-hidden` so it never blocks slice hover.\n- `PieChartCenterValue` — primary number (`text-base`, monospace). On by default, swaps `children` for the hovered datum's value while a slice or legend row is hovered. Customise the swap via `formatHoveredValue: (datum) => ReactNode` (defaults to `datum.value.toLocaleString()`); opt out entirely with `hoverSync={false}`. Sets `data-hovered='true'` on the swapped element so callers and E2E selectors can detect the hover state.\n- `PieChartCenterLabel` — secondary unit (`text-xs`, monospace, secondary text token). Optional `pluralize` prop accepts CLDR plural rules (`{ zero?, one?, two?, few?, many?, other }` plus an optional `locale` defaulting to `'en'`); when provided, the label is selected from those rules using `Intl.PluralRules` based on the currently visible count (the hovered datum's value when `hoverSync` is on, otherwise the chart total). When `pluralize` is omitted, `children` renders unchanged.\n- `PieChartLegend` — flex column that takes the remaining horizontal space.\n- `PieChartLegendItem` — row (`h-32`, `rounded-8`). Required `name` (joins to `PieChartDatum.name`). Becomes interactive when `onClick` is passed (role=button + Enter/Space). Hovering the row sets the active context entry, dimming inactive donut slices to 30% opacity.\n- `PieChartLegendValue` — right-aligned value slot. Auto-applies the Figma \"value · percent\" gap.\n- `PieChartLegendPercent` — reads `value / total` from item context. Same `digits` and `variant` props as `BarListPercent`.\n- `LegendDot` — `·` bullet separator (rendered with `aria-hidden`). Compose between `PieChartLegendValue`'s children when you want the Figma `value · percent` pairing; omit when the row shows only one of value/percent.\n- `PieChartSkeleton` — standalone loading view. Mirrors the donut + legend layout with shimmering placeholders.\n\n### `PieChartDatum`\n\n```ts\ninterface PieChartDatum {\n name: string; // unique join key\n value: number;\n color?: ChartColor; // built-in palette → `var(--color-{color}-500)` slice fill\n className?: string; // Tailwind `fill-*` class; wins over `color`\n}\n```\n\n## Data model\n\n- **`data`** (required, `PieChartDatum[]`) — drives both the donut and the legend percent calculations. Rows with non-finite or negative `value` are coerced to `0` so slice geometry stays valid (recharts rejects negatives).\n- **`total`** (optional, number) — defaults to `sum(data.value)`. Override when the centre label should reflect a different denominator (e.g. the **unfiltered** total while the chart shows a filtered view).\n- **`activeName`** / **`onActiveNameChange`** (optional, `string | null`) — controlled hover target. When omitted, the chart manages it internally with `useState`. Use the controlled mode when two charts must share a hover (cross-filter pattern).\n- **`selectedNames`** (optional, `string[]`) — multi-selection set. When non-empty, non-selected donut slices fade to `inactiveOpacity` (the same dim as hover) and non-selected legend rows fade to `opacity-60` (lighter so the row text stays readable). Hover wins — while `activeName` is non-null, only that slice stays bright regardless of selection. Names not in `data` are ignored. Pass a stable reference (`useMemo`/state) — an inline array literal recreates on every parent render and invalidates the internal Set memo. The per-`PieChartLegendItem` `selected` prop still works and fully overrides the derived state when set explicitly (useful when the legend includes a row intentionally outside the chart's multi-selection).\n- **`PieChartLegendItem.name`** (required, string) — must match a `PieChartDatum.name` for the percent to compute and for hover sync to work. Items whose `name` does not match any datum render at `0%`.\n- **`PieChartLegendItem.value`** (optional, number override) — useful for legend rows that are not part of the donut (e.g. a synthetic \"Other\" line).\n\n`PieChart` does not sort, slice, or aggregate. The caller orders `data` and the legend items match.\n\n## States\n\n| State | Trigger | Behaviour |\n| --------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |\n| Default | — | Slices use `var(--color-{color}-500)` (palette-strong). Legend rows show their full content. |\n| Hover (slice) | Pointer over a donut slice | Slice keeps `opacity 1`; sibling slices fade to `0.3`. Matching legend row gets `bg-states-primary-hover`. |\n| Hover (legend) | Pointer over a legend row | Same as slice hover — bidirectional via `activeName` context. |\n| Focus | Keyboard focus on a clickable legend row | `bg-states-primary-hover` + `ring-focus-primary` inset ring (focus-visible). |\n| Selected | `<PieChartLegendItem selected />` or root `selectedNames` includes the row | `bg-states-primary-active` + `aria-current=\"true\"`. Selected donut slice stays bright; non-selected slices and legend rows fade to `inactiveOpacity` (multi-selection emphasis). |\n| Filtered (one) | Caller filters `data` to a single entry | Donut renders a single full ring of that colour; the legend shows only that row at `100%`. |\n| Loading | Render `<PieChartSkeleton rows={n} />` instead of the chart | Circle skeleton on the left + `n` row skeletons on the right; `aria-busy='true'` + `aria-live='polite'`. |\n| Empty | `data.length === 0` (or render `<ChartEmpty />` instead) | Donut renders a flat ring (`var(--color-border-primary-light)`) with no slices; the legend is empty. Prefer `<ChartEmpty />` for an explicit empty body. |\n| Zero total | All values are `0` | Same flat ring — no slices, every legend row reads `0%`. No console warnings. |\n\n## Interactions\n\n- **Hover sync** — `PieChartLegendItem` calls `setActive(name)` on `mouseEnter` / `focus` and `setActive(null)` on `mouseLeave` / `blur`. The donut listens to recharts' `onMouseEnter` / `onMouseLeave` on `<Pie>` and pushes the same name. Context glue makes them mirror. Both leave/blur handlers inspect `event.relatedTarget` and **skip the null-clear when the cursor or focus is moving to another hover-syncing sibling** (legend row or donut slice) — the destination's enter handler sets the new active. Without this guard, React would render an intermediate `activeName=null` frame on every row/slice crossing and the centre value would flicker back to the total each time.\n- **Centre value hover swap** — `PieChartCenterValue` reads the same `activeName` and replaces its `children` with the hovered datum's formatted value while a slice or legend row is hovered. Default formatter is `datum.value.toLocaleString()`; supply `formatHoveredValue` to match the format of `children` (e.g. compact notation, units). Disable with `hoverSync={false}` when the centre should always reflect the unfiltered total. The DOM gains `data-hovered='true'` while swapped — useful for snapshot tests.\n- **Pluralization** — `PieChartCenterLabel` swaps the label form to match the currently visible count when `pluralize` is provided. Combined with the value's hover swap, hovering a single-request slice flips the label from \"requests\" to \"request\" automatically. The label uses `Intl.PluralRules`; locale defaults to `'en'` and can be overridden via `pluralize.locale`.\n- **Click-to-filter** — caller-driven, like `BarList`. Attach `onClick` to `PieChartLegendItem`; the row exposes `role=\"button\"` + `tabIndex=0` and Enter/Space dispatch a click. Toggling the filter usually means filtering `data` and re-rendering with one entry.\n- **Tooltip** — not baked in. Wrap `PieChartLegendItem` (or its label slot) with `Tooltip` / `OverflowTooltip` when needed; the legend item exposes the standard mouse events through the props spread.\n- **`activeName` controlled mode** — set `activeName` + `onActiveNameChange` to drive hover state from a parent (e.g. a sibling `BarList` should highlight the same datum). When controlled, internal hover state is ignored. Wrap `onActiveNameChange` in `useCallback` (or hoist it) — an inline arrow handler will recreate every render and propagate through the data context, recreating every legend row's mouse/focus callbacks. Not a correctness bug, but kills `React.memo` opportunities for custom row children.\n\n## Edge cases & unclear states\n\n- **Recharts ignores negative values.** We coerce `< 0` and non-finite values to `0` before passing data into recharts, otherwise the chart silently drops them (or, in some recharts builds, throws).\n- **Total of zero.** Recharts cannot draw a meaningful pie when every value is `0`. We render a flat outline ring (`var(--color-border-primary-light)`) so the chart shape is visible. Every legend row reads `0%` — the percent calculator returns `0` when the context's `isValidTotal` flag is false. Slice `onMouseEnter` is short-circuited in this state — without the guard, recharts would resolve the placeholder ring's hover index back into the original `data` array and spuriously activate the first legend row.\n- **Single slice.** Recharts draws a full circle (start `90°` → end `-270°`); the slice is visually a closed ring. Hover-dim still works (there is nothing to dim, opacity stays `1`).\n- **`PieChartLegendItem.name` does not match any datum.** The row renders with a `0%` ratio and no slice highlight. There is no runtime warning — the legend can intentionally include rows that are not on the donut (e.g. a hidden \"Other\" row).\n- **Mount animation.** Sectors sweep from origin in **400 ms** with `ease-out` and `begin=0`, sized for dashboards that re-render on every tick — fast enough to feel snappy, slow enough to read as a deliberate reveal. The donut uses recharts' `isAnimationActive='auto'`, which honours `prefers-reduced-motion` and disables animation under SSR. Pass `<PieChartDonut disableAnimation />` to force it off (useful for E2E screenshot stability). Screenshot tests wait `~500 ms` past navigation.\n- **`pointer-events-none` on the centre slot.** Without it, hovering the centre text would steal events and break the slice hover-dim. Keep this if you replace `PieChartCenter` with custom markup.\n- **`className` on a datum.** Apply Tailwind `fill-*` utilities to the SVG path — `bg-*` does **not** work because the slice is a `<path>`, not a `<div>`. The donut applies `className` directly via recharts' `<Cell>`.\n- **Active state when nothing is hovered.** `activeName === null` means every slice renders at full opacity. Switching to a controlled `activeName` and forcing it to `null` is how a parent \"clears\" the highlight.\n- **Stale `activeName` after data changes.** The root resolves `activeName` against the current `byName` map. If the previously hovered slice disappears (filter, refresh, or a controlled value pointing at a name that is no longer in `data`), the highlight collapses to `null` instead of dimming every remaining slice. In **uncontrolled** mode the internal hover state is left as-is — the next hover overwrites it. In **controlled** mode the chart calls `onActiveNameChange(null)` from an effect so sibling charts sharing the same state don't keep highlighting a stale name. Make `onActiveNameChange` stable (or expect this normalization fire to land in the same render cycle as the data change).\n- **Duplicate `name` values.** Names are the join key for percent lookup, hover sync, and React reconciliation. The root warns once in development when `data` contains duplicates; behaviour is best-effort (later duplicates win in `byName`) but should be treated as undefined — fix the data instead.\n\n## Accessibility notes\n\n- The donut SVG and the centre slot are `aria-hidden`; the legend is the screen-reader representation of the same data.\n- Interactive legend rows expose `role=\"button\"`, `tabIndex=0`, and Enter/Space dispatch a click. Focus ring uses `focus-visible` — keyboard users see the ring, pointer clicks don't.\n- `selected` ⇒ `aria-current=\"true\"`.\n- `PieChartSkeleton` sets `aria-busy=\"true\"` + `aria-live=\"polite\"` so AT announces the loading transition.\n- `LegendDot` is `aria-hidden` so screen readers don't pronounce the `·`. The numeric percent is read normally.\n\n## Open questions / known limitations\n\n- **Slice tooltip.** Not baked in — the donut SVG is `aria-hidden` and we direct hover signals through the legend row instead. Add a tooltip later if a use case calls for it; consider the `Tooltip` wrapper around the legend row first.\n- **Smooth transition between filter states.** Recharts re-mounts the slice paths when `data.length` changes, so going from 5 slices to 1 is a hard cut + remount animation. A morphing animation would need a custom shape renderer or a different lib.\n- **Inner-ring labels.** The centre slot is two-line (value + label). Three-line designs would need an explicit gap token / wrapper — revisit when product asks for it.\n- **Built-in empty state.** Currently the donut renders an outline ring when `total === 0` and the legend is empty. Most products will prefer to swap in `<ChartEmpty />`; if a \"0% donut + still-rendered legend\" pattern becomes common, expose it as a prop.\n- **Cross-chart sync.** The controlled `activeName`/`onActiveNameChange` pair is enough for one parent to drive multiple charts. If we ever need a registry of charts that share hover, it should live one level above (a `ChartGroup` provider), not on `PieChart` itself."
36108
+ "markdown": "# PieChart\n\n## Overview\n\n`PieChart` is a composable donut chart paired with a vertical legend, designed for compact \"share-of-total\" breakdowns inside a `Chart` card — response codes, traffic origins, error categories. The donut is drawn by [`recharts`](https://recharts.org/), the legend is hand-rolled JSX, and the two are synced through React context: hovering a slice highlights its legend row and vice versa.\n\nThe root accepts `data` as the single source of truth. The donut renders the slices; the legend is composed by the caller (`PieChartLegendItem` rows are referenced by `name`). This keeps the badge/label/value layout fully flexible while preserving the hover and percent calculations.\n\nUse it whenever a chart needs the same \"small chart card\" frame as `BarList` but the data reads naturally as parts of a whole.\n\n## API\n\nCompound component:\n\n- `PieChart` — root. Accepts `data: PieChartDatum[]`, optional `total`, optional controlled `activeName` / `onActiveNameChange`. Provides context to children.\n- `PieChartDonut` — wraps the recharts donut. Reads `data` + `activeName` from context. Renders a fixed `168×136` block with a `120×120` SVG inside; mouse events on slices fire context's `setActive`. Pass `disableAnimation` when running in environments that need deterministic frames. Children of `PieChartDonut` render absolutely-positioned over the SVG (use `PieChartCenter`).\n- `PieChartCenter` — absolutely-positioned centre slot, intentionally `pointer-events-none` and `aria-hidden` so it never blocks slice hover.\n- `PieChartCenterValue` — primary number (`text-base`, monospace). On by default, swaps `children` for the hovered datum's value while a slice or legend row is hovered. Customise the swap via `formatHoveredValue: (datum) => ReactNode` (defaults to `datum.value.toLocaleString()`); opt out entirely with `hoverSync={false}`. Sets `data-hovered='true'` on the swapped element so callers and E2E selectors can detect the hover state.\n- `PieChartCenterLabel` — secondary unit (`text-xs`, monospace, secondary text token). Optional `pluralize` prop accepts CLDR plural rules (`{ zero?, one?, two?, few?, many?, other }` plus an optional `locale` defaulting to `'en'`); when provided, the label is selected from those rules using `Intl.PluralRules` based on the currently visible count (the hovered datum's value when `hoverSync` is on, otherwise the chart total). When `pluralize` is omitted, `children` renders unchanged.\n- `PieChartLegend` — flex column that takes the remaining horizontal space.\n- `PieChartLegendItem` — row (`h-32`, `rounded-8`). Required `name` (joins to `PieChartDatum.name`). Becomes interactive when `onClick` is passed (role=button + Enter/Space). Hovering the row sets the active context entry, dimming inactive donut slices to 30% opacity.\n- `PieChartLegendValue` — right-aligned value slot. Auto-applies the Figma \"value · percent\" gap.\n- `PieChartLegendPercent` — reads `value / total` from item context. Same `digits` and `variant` props as `BarListPercent`.\n- `LegendDot` — `·` bullet separator (rendered with `aria-hidden`). Compose between `PieChartLegendValue`'s children when you want the Figma `value · percent` pairing; omit when the row shows only one of value/percent.\n- `PieChartSkeleton` — standalone loading view. Mirrors the donut + legend layout with shimmering placeholders.\n\n### `PieChartDatum`\n\n```ts\ninterface PieChartDatum {\n name: string; // unique join key\n value: number;\n color?: ChartColor; // built-in palette → `var(--color-{color}-500)` slice fill\n className?: string; // Tailwind `fill-*` class; wins over `color`\n}\n```\n\n## Data model\n\n- **`data`** (required, `PieChartDatum[]`) — drives both the donut and the legend percent calculations. Rows with non-finite or negative `value` are coerced to `0` so slice geometry stays valid (recharts rejects negatives).\n- **`total`** (optional, number) — defaults to `sum(data.value)`. Override when the centre label should reflect a different denominator (e.g. the **unfiltered** total while the chart shows a filtered view).\n- **`activeName`** / **`onActiveNameChange`** (optional, `string | null`) — controlled hover target. When omitted, the chart manages it internally with `useState`. Use the controlled mode when two charts must share a hover (cross-filter pattern).\n- **`selectedNames`** (optional, `string[]`) — multi-selection set. When non-empty, non-selected donut slices fade to `inactiveOpacity` (the same dim as hover) and non-selected legend rows fade to `opacity-60` (lighter so the row text stays readable). Hover wins — while `activeName` is non-null, only that slice stays bright regardless of selection. Names not in `data` are ignored. Pass a stable reference (`useMemo`/state) — an inline array literal recreates on every parent render and invalidates the internal Set memo. The per-`PieChartLegendItem` `selected` prop still works and fully overrides the derived state when set explicitly (useful when the legend includes a row intentionally outside the chart's multi-selection).\n- **`PieChartLegendItem.name`** (required, string) — must match a `PieChartDatum.name` for the percent to compute and for hover sync to work. Items whose `name` does not match any datum render at `0%`.\n- **`PieChartLegendItem.value`** (optional, number override) — useful for legend rows that are not part of the donut (e.g. a synthetic \"Other\" line).\n\n`PieChart` does not sort, slice, or aggregate. The caller orders `data` and the legend items match.\n\n## States\n\n| State | Trigger | Behaviour |\n| --------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |\n| Default | — | Slices use `var(--color-{color}-500)` (palette-strong). Legend rows show their full content. |\n| Hover (slice) | Pointer over a donut slice | Slice keeps `opacity 1`; sibling slices fade to `0.3`. Matching legend row gets `bg-states-primary-hover`. |\n| Hover (legend) | Pointer over a legend row | Same as slice hover — bidirectional via `activeName` context. |\n| Focus | Keyboard focus on a clickable legend row | `bg-states-primary-hover` + `ring-focus-primary` inset ring (focus-visible). |\n| Selected | `<PieChartLegendItem selected />` or root `selectedNames` includes the row | `bg-states-primary-active` + `aria-current=\"true\"`. Selected donut slice stays bright; non-selected slices and legend rows fade to `inactiveOpacity` (multi-selection emphasis). |\n| Filtered (one) | Caller filters `data` to a single entry | Donut renders a single full ring of that colour; the legend shows only that row at `100%`. |\n| Loading | Render `<PieChartSkeleton rows={n} />` instead of the chart | Circle skeleton on the left + `n` row skeletons on the right; `aria-busy='true'` + `aria-live='polite'`. |\n| Empty | `data.length === 0` (or render `<ChartEmpty />` instead) | Donut renders a flat ring (`var(--color-border-primary-light)`) with no slices; the legend is empty. Prefer `<ChartEmpty />` for an explicit empty body. |\n| Zero total | All values are `0` | Same flat ring — no slices, every legend row reads `0%`. No console warnings. |\n\n## Interactions\n\n- **Hover sync** — `PieChartLegendItem` calls `setActive(name)` on `mouseEnter` / `focus` and `setActive(null)` on `mouseLeave` / `blur`. The donut listens to recharts' `onMouseEnter` / `onMouseLeave` on `<Pie>` and pushes the same name. Context glue makes them mirror. Both leave/blur handlers inspect `event.relatedTarget` and **skip the null-clear when the cursor or focus is moving to another hover-syncing sibling** (legend row or donut slice) — the destination's enter handler sets the new active. Without this guard, React would render an intermediate `activeName=null` frame on every row/slice crossing and the centre value would flicker back to the total each time.\n- **Centre value hover swap** — `PieChartCenterValue` reads the same `activeName` and replaces its `children` with the hovered datum's formatted value while a slice or legend row is hovered. Default formatter is `datum.value.toLocaleString()`; supply `formatHoveredValue` to match the format of `children` (e.g. compact notation, units). Disable with `hoverSync={false}` when the centre should always reflect the unfiltered total. The DOM gains `data-hovered='true'` while swapped — useful for snapshot tests.\n- **Pluralization** — `PieChartCenterLabel` swaps the label form to match the currently visible count when `pluralize` is provided. Combined with the value's hover swap, hovering a single-request slice flips the label from \"requests\" to \"request\" automatically. The label uses `Intl.PluralRules`; locale defaults to `'en'` and can be overridden via `pluralize.locale`.\n- **Click-to-filter** — caller-driven, like `BarList`. Attach `onClick` to `PieChartLegendItem`; the row exposes `role=\"button\"` + `tabIndex=0` and Enter/Space dispatch a click. Toggling the filter usually means filtering `data` and re-rendering with one entry.\n- **Tooltip** — not baked in. Wrap `PieChartLegendItem` (or its label slot) with `Tooltip` / `OverflowTooltip` when needed; the legend item exposes the standard mouse events through the props spread.\n- **`activeName` controlled mode** — set `activeName` + `onActiveNameChange` to drive hover state from a parent (e.g. a sibling `BarList` should highlight the same datum). When controlled, internal hover state is ignored. Wrap `onActiveNameChange` in `useCallback` (or hoist it) — an inline arrow handler will recreate every render and propagate through the data context, recreating every legend row's mouse/focus callbacks. Not a correctness bug, but kills `React.memo` opportunities for custom row children.\n\n## Edge cases & unclear states\n\n- **Recharts ignores negative values.** We coerce `< 0` and non-finite values to `0` before passing data into recharts, otherwise the chart silently drops them (or, in some recharts builds, throws).\n- **Total of zero.** Recharts cannot draw a meaningful pie when every value is `0`. We render a flat outline ring (`var(--color-border-primary-light)`) so the chart shape is visible. Every legend row reads `0%` — the percent calculator returns `0` when the context's `isValidTotal` flag is false. Slice `onMouseEnter` is short-circuited in this state — without the guard, recharts would resolve the placeholder ring's hover index back into the original `data` array and spuriously activate the first legend row.\n- **Single slice.** Recharts draws a full circle (start `90°` → end `-270°`); the slice is visually a closed ring. Hover-dim still works (there is nothing to dim, opacity stays `1`).\n- **`PieChartLegendItem.name` does not match any datum.** The row renders with a `0%` ratio and no slice highlight. There is no runtime warning — the legend can intentionally include rows that are not on the donut (e.g. a hidden \"Other\" row).\n- **Mount animation.** Sectors sweep from origin in **400 ms** with `ease-out` and `begin=0`, sized for dashboards that re-render on every tick — fast enough to feel snappy, slow enough to read as a deliberate reveal. The donut uses recharts' `isAnimationActive='auto'`, which honours `prefers-reduced-motion` and disables animation under SSR. Pass `<PieChartDonut disableAnimation />` to force it off (useful for E2E screenshot stability). Screenshot tests wait `~500 ms` past navigation.\n- **`pointer-events-none` on the centre slot.** Without it, hovering the centre text would steal events and break the slice hover-dim. Keep this if you replace `PieChartCenter` with custom markup.\n- **`className` on a datum.** Apply Tailwind `fill-*` utilities to the SVG path — `bg-*` does **not** work because the slice is a `<path>`, not a `<div>`. The donut applies `className` directly via recharts' `<Cell>`.\n- **Active state when nothing is hovered.** `activeName === null` means every slice renders at full opacity. Switching to a controlled `activeName` and forcing it to `null` is how a parent \"clears\" the highlight.\n- **Stale `activeName` after data changes.** The root resolves `activeName` against the current `byName` map. If the previously hovered slice disappears (filter, refresh, or a controlled value pointing at a name that is no longer in `data`), the highlight collapses to `null` instead of dimming every remaining slice. In **uncontrolled** mode the internal hover state is left as-is — the next hover overwrites it. In **controlled** mode the chart calls `onActiveNameChange(null)` from an effect so sibling charts sharing the same state don't keep highlighting a stale name. Make `onActiveNameChange` stable (or expect this normalization fire to land in the same render cycle as the data change).\n- **Duplicate `name` values.** Names are the join key for percent lookup, hover sync, and React reconciliation. The root warns once in development when `data` contains duplicates; behaviour is best-effort (later duplicates win in `byName`) but should be treated as undefined — fix the data instead.\n\n## Accessibility notes\n\n- The donut SVG and the centre slot are `aria-hidden`; the legend is the screen-reader representation of the same data.\n- Interactive legend rows expose `role=\"button\"`, `tabIndex=0`, and Enter/Space dispatch a click. Focus ring uses `focus-visible` — keyboard users see the ring, pointer clicks don't.\n- `selected` ⇒ `aria-current=\"true\"`.\n- `PieChartSkeleton` sets `aria-busy=\"true\"` + `aria-live=\"polite\"` so AT announces the loading transition.\n- `LegendDot` is `aria-hidden` so screen readers don't pronounce the `·`. The numeric percent is read normally.\n\n## Open questions / known limitations\n\n- **Slice tooltip.** Not baked in — the donut SVG is `aria-hidden` and we direct hover signals through the legend row instead. Add a tooltip later if a use case calls for it; consider the `Tooltip` wrapper around the legend row first.\n- **Smooth transition between filter states.** Recharts re-mounts the slice paths when `data.length` changes, so going from 5 slices to 1 is a hard cut + remount animation. A morphing animation would need a custom shape renderer or a different lib.\n- **Inner-ring labels.** The centre slot is two-line (value + label). Three-line designs would need an explicit gap token / wrapper — revisit when product asks for it.\n- **Built-in empty state.** Currently the donut renders an outline ring when `total === 0` and the legend is empty. Most products will prefer to swap in `<ChartEmpty />`; if a \"0% donut + still-rendered legend\" pattern becomes common, expose it as a prop.\n- **Cross-chart sync.** The controlled `activeName`/`onActiveNameChange` pair is enough for one parent to drive multiple charts. If we ever need a registry of charts that share hover, it should live one level above (a `ChartGroup` provider), not on `PieChart` itself."
36109
36109
  }
36110
36110
  ]
36111
36111
  },