@ggui-ai/ui-gen 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +2 -2
  2. package/dist/{adapter-Cg_rkpGq.d.ts → adapter-h4_V69LI.d.ts} +49 -1
  3. package/dist/adapters/base.d.ts +6 -1
  4. package/dist/adapters/base.js.map +1 -1
  5. package/dist/adapters/generation-dispatch.d.ts +8 -5
  6. package/dist/adapters/generation-dispatch.js +1174 -198
  7. package/dist/adapters/generation-dispatch.js.map +1 -1
  8. package/dist/adapters/index.js +59 -16
  9. package/dist/adapters/index.js.map +1 -1
  10. package/dist/advanced/index.d.ts +10 -9
  11. package/dist/advanced/index.js +1215 -216
  12. package/dist/advanced/index.js.map +1 -1
  13. package/dist/blueprint-validator.js +416 -92
  14. package/dist/blueprint-validator.js.map +1 -1
  15. package/dist/boilerplate.d.ts +2 -2
  16. package/dist/boilerplate.js +10 -5
  17. package/dist/boilerplate.js.map +1 -1
  18. package/dist/check/index.d.ts +3 -8
  19. package/dist/check/index.js +55 -2
  20. package/dist/check/index.js.map +1 -1
  21. package/dist/{create-ui-generator-DFnnArrv.d.ts → create-ui-generator-BrZHF0cf.d.ts} +3 -4
  22. package/dist/{design-mode-DLufhrNp.d.ts → design-mode-DGWN_Gkm.d.ts} +1 -1
  23. package/dist/{dispatch-CStl9eAw.d.ts → dispatch-B07TI30d.d.ts} +2 -2
  24. package/dist/evaluation/axis-checks/index.d.ts +5 -4
  25. package/dist/evaluation/axis-checks/index.js.map +1 -1
  26. package/dist/evaluation/axis-checks/registry.d.ts +4 -3
  27. package/dist/evaluation/index.d.ts +124 -546
  28. package/dist/evaluation/index.js +588 -65
  29. package/dist/evaluation/index.js.map +1 -1
  30. package/dist/evaluation/types.d.ts +17 -0
  31. package/dist/evaluation/types.js.map +1 -1
  32. package/dist/{generate-9IeNT4ZY.d.ts → generate-tSO_shQ0.d.ts} +1 -1
  33. package/dist/harness/check/runtime-render/index.d.ts +5 -5
  34. package/dist/harness/check/runtime-render/index.js +416 -92
  35. package/dist/harness/check/runtime-render/index.js.map +1 -1
  36. package/dist/harness/check/runtime-render/render-check-worker.js +393 -88
  37. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  38. package/dist/harness/index.d.ts +4 -5
  39. package/dist/harness/index.js +36 -7
  40. package/dist/harness/index.js.map +1 -1
  41. package/dist/harness/prompts.d.ts +2 -2
  42. package/dist/harness/prompts.js +7 -1
  43. package/dist/harness/prompts.js.map +1 -1
  44. package/dist/harness/result-types.d.ts +17 -3
  45. package/dist/harness/result-types.js.map +1 -1
  46. package/dist/harness/runtime.d.ts +14 -6
  47. package/dist/harness/runtime.js +13 -7
  48. package/dist/harness/runtime.js.map +1 -1
  49. package/dist/harness/types-public.d.ts +4 -2
  50. package/dist/harness/types-public.js +10 -5
  51. package/dist/harness/types-public.js.map +1 -1
  52. package/dist/harness/validator-trace-sink.d.ts +4 -3
  53. package/dist/index.d.ts +46 -11
  54. package/dist/index.js +1217 -214
  55. package/dist/index.js.map +1 -1
  56. package/dist/patch.d.ts +3 -3
  57. package/dist/{prompts-YU9piOgs.d.ts → prompts-BhNUuRoZ.d.ts} +1 -1
  58. package/dist/tools/render-check-worker.js +8 -1
  59. package/dist/tools/render-check-worker.js.map +1 -1
  60. package/dist/{types-public-BJTLpzWn.d.ts → types-public-h8IMnYuh.d.ts} +388 -3
  61. package/dist/validation/index.d.ts +1 -1
  62. package/dist/validation/index.js +1 -1
  63. package/dist/validation/index.js.map +1 -1
  64. package/dist/visual-evaluator-C2strxqD.d.ts +639 -0
  65. package/dist/workflows.d.ts +3 -3
  66. package/package.json +12 -12
  67. package/src/adapters/base.ts +6 -1
  68. package/src/adapters/claude/message-parser.ts +5 -6
  69. package/src/adapters/claude/sdk-tool-pins.ts +44 -0
  70. package/src/adapters/claude/sdk.ts +3 -3
  71. package/src/adapters/claude/source-capture.ts +20 -0
  72. package/src/adapters/generation-dispatch.ts +2 -0
  73. package/src/boilerplate/free-design-prompt.ts +3 -3
  74. package/src/boilerplate/hard-sections.ts +1 -1
  75. package/src/boilerplate/system-prompt.ts +3 -2
  76. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +3 -1
  77. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +3 -1
  78. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +3 -1
  79. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +3 -1
  80. package/src/check/run-tier0.ts +67 -3
  81. package/src/contract-context.ts +1 -1
  82. package/src/design-system-docs.ts +2 -1
  83. package/src/evaluation/cost-tracker.ts +54 -6
  84. package/src/evaluation/criteria/edge-probe.ts +286 -0
  85. package/src/evaluation/criteria/edge.ts +202 -0
  86. package/src/evaluation/criteria/resolve.ts +74 -8
  87. package/src/evaluation/index.ts +5 -1
  88. package/src/evaluation/judge-spend.ts +48 -0
  89. package/src/evaluation/loop.ts +25 -4
  90. package/src/evaluation/message-parsing.ts +11 -12
  91. package/src/evaluation/types-public.ts +95 -0
  92. package/src/evaluation/types.ts +10 -0
  93. package/src/evaluation/visual-evaluator.ts +236 -40
  94. package/src/harness/check/runtime-render/adapter.ts +18 -0
  95. package/src/harness/check/runtime-render/probe-pending.ts +111 -0
  96. package/src/harness/check/runtime-render/render-check-host.ts +10 -6
  97. package/src/harness/check/runtime-render/render-check-worker.ts +6 -3
  98. package/src/harness/check/runtime-render/render-check.ts +417 -133
  99. package/src/harness/check/runtime-render/walk.ts +200 -0
  100. package/src/harness/coding/assemble-result.ts +2 -0
  101. package/src/harness/coding/generate-task-runner.ts +27 -0
  102. package/src/harness/coding/init-session.ts +5 -3
  103. package/src/harness/coding/run-coding-turn.ts +5 -1
  104. package/src/harness/coding/run-eval-round.ts +63 -3
  105. package/src/harness/in-loop-evaluation.ts +39 -0
  106. package/src/harness/llm-router.ts +58 -1
  107. package/src/harness/result-types.ts +9 -0
  108. package/src/harness/runtime.ts +9 -0
  109. package/src/harness/types-public.ts +10 -1
  110. package/src/index.ts +5 -0
  111. package/src/internal/verdict-line.ts +33 -0
  112. package/src/taught-tokens.ts +31 -0
  113. package/src/tools/render-check-worker.ts +2 -1
  114. package/src/tools/render-check.ts +8 -7
  115. package/src/validation/primitives.ts +1 -1
  116. package/dist/llm-router-nfO7wPec.d.ts +0 -267
  117. package/dist/{bank-CT4eEkta.d.ts → bank-Ctdp3FUA.d.ts} +3 -3
@@ -1,4 +1,5 @@
1
1
  import { listContractGadgets, HOOK_NAME_RE, classifyUi, MODEL_REGISTRY, DANGEROUS_PATTERNS as DANGEROUS_PATTERNS$1, STDLIB_GADGETS_PACKAGE, resolveAppGadgets, APP_GENERATION_PROFILE_BOUNDS, anthropicRejectsForcedToolChoice, normalizeAnthropicModelId, anthropicRejectsSamplingParams, isRecord } from '@ggui-ai/protocol';
2
+ import { consumedTokenManifest } from '@ggui-ai/design/themes';
2
3
  import Anthropic from '@anthropic-ai/sdk';
3
4
  import { z } from 'zod';
4
5
  import { EXPANDED_FRAME, getCssTokens, expandedFrameScrimDecls, fillFitRule, expandedFramePanelRule, FORBIDDEN_IMPORT_SPECIFIERS } from '@ggui-ai/design/rendering';
@@ -13,7 +14,6 @@ import { runSandboxed } from '@ggui-ai/sandbox';
13
14
  import ts6 from 'typescript';
14
15
  import path, { dirname, resolve, relative } from 'path';
15
16
  import { Linter } from 'eslint';
16
- import { consumedTokenManifest } from '@ggui-ai/design/themes';
17
17
  import { createHash } from 'crypto';
18
18
  import { inflateSync } from 'zlib';
19
19
  import { LUCIDE_ICON_NAMES, maxWidth } from '@ggui-ai/design';
@@ -529,6 +529,15 @@ var init_json_schema_ts = __esm({
529
529
  "src/boilerplate/json-schema-ts.ts"() {
530
530
  }
531
531
  });
532
+ var NOT_YET_TAUGHT_TOKENS, taughtTokenManifest;
533
+ var init_taught_tokens = __esm({
534
+ "src/taught-tokens.ts"() {
535
+ NOT_YET_TAUGHT_TOKENS = /* @__PURE__ */ new Set([]);
536
+ taughtTokenManifest = consumedTokenManifest.filter(
537
+ (token) => !NOT_YET_TAUGHT_TOKENS.has(token)
538
+ );
539
+ }
540
+ });
532
541
 
533
542
  // src/patch.ts
534
543
  var patch_exports = {};
@@ -1190,6 +1199,26 @@ var init_client2 = __esm({
1190
1199
  };
1191
1200
  }
1192
1201
  });
1202
+ function servedModelOf(name) {
1203
+ return typeof name === "string" && name.length > 0 ? { servedModel: name } : {};
1204
+ }
1205
+ function anthropicFinishReason(stopReason) {
1206
+ if (stopReason === null || stopReason === void 0) return void 0;
1207
+ if (stopReason === "end_turn" || stopReason === "stop_sequence") return "stop";
1208
+ if (stopReason === "max_tokens") return "length";
1209
+ if (stopReason === "refusal") return "content-filter";
1210
+ return "other";
1211
+ }
1212
+ function googleFinishReason(finishReason) {
1213
+ if (finishReason === null || finishReason === void 0) return void 0;
1214
+ if (finishReason === "STOP") return "stop";
1215
+ if (finishReason === "MAX_TOKENS") return "length";
1216
+ if (["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY"].includes(finishReason)) return "content-filter";
1217
+ return "other";
1218
+ }
1219
+ function visionFinish(finishReason) {
1220
+ return finishReason !== void 0 ? { finishReason } : {};
1221
+ }
1193
1222
  function addStrictSchemaConstraints(schema) {
1194
1223
  const result = { ...schema };
1195
1224
  if (result.type === "object" && result.properties) {
@@ -1617,7 +1646,9 @@ var init_llm_router = __esm({
1617
1646
  return {
1618
1647
  text,
1619
1648
  inputTokens: response.usage.input_tokens,
1620
- outputTokens: response.usage.output_tokens
1649
+ outputTokens: response.usage.output_tokens,
1650
+ ...visionFinish(anthropicFinishReason(response.stop_reason)),
1651
+ ...servedModelOf(response.model)
1621
1652
  };
1622
1653
  } catch (e) {
1623
1654
  const endedAt = Date.now();
@@ -2263,7 +2294,9 @@ ${LOGIN_REQUIRED_TOOL_INSTRUCTION}` : userPrompt;
2263
2294
  text,
2264
2295
  // ggui#1186 (third site): `promptTokenCount` includes `cachedContentTokenCount` — report the non-cached input.
2265
2296
  inputTokens: splitCacheInclusiveUsage(usage?.promptTokenCount ?? 0, usage?.candidatesTokenCount ?? 0, usage?.cachedContentTokenCount ?? 0).tokens.input,
2266
- outputTokens: usage?.candidatesTokenCount ?? 0
2297
+ outputTokens: usage?.candidatesTokenCount ?? 0,
2298
+ ...visionFinish(googleFinishReason(response.candidates?.[0]?.finishReason)),
2299
+ ...servedModelOf(response.modelVersion)
2267
2300
  };
2268
2301
  const endedAt = Date.now();
2269
2302
  emitLlmTraceEvent({
@@ -2835,7 +2868,7 @@ var init_policy2 = __esm({
2835
2868
  var PRIMITIVES_DOCUMENTATION, VALID_PRIMITIVES;
2836
2869
  var init_primitives = __esm({
2837
2870
  "src/validation/primitives.ts"() {
2838
- PRIMITIVES_DOCUMENTATION = "# ggui Primitives & Design System Reference\n\n> You are a world-class UI engineer working with ggui's component library for the first time.\n> This reference documents every available component, prop, and convention.\n> Components handle theming automatically via built-in variants \u2014 pick the right variant and the theme does the rest.\n> For custom styling beyond variants, use CSS variables: var(--ggui-*, fallback).\n\n## Primitives\n\nImport: `import { Component } from '@ggui-ai/design'`\n\n### Container\n\nContainer -- Width-constrained wrapper that centers content horizontally.\n\nRenders a `<div>` with `width: 100%` and a `maxWidth` constraint.\nWhen `center` is true (the default), applies `margin: 0 auto`.\nNo background, border, or shadow -- use Card for visual containment.\n\nCSS variables used: none (pure layout primitive).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| maxWidth | `'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| '3xl' \\| 'full' \\| string` | `'lg'` | Maximum width constraint. Accepts a preset token or any CSS width string. - `'xs'` -- 320px - `'sm'` -- 480px - `'md'` -- 640px - `'lg'` -- 768px - `'xl'` -- 1024px - `'2xl'` -- 1280px - `'3xl'` -- 1536px - `'full'` -- 100% Custom strings (e.g., `'900px'`, `'60ch'`) are passed through as-is. |\n| center | `boolean` | `true` | Whether to center the container horizontally via `margin: 0 auto`. |\n| padding | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `undefined (no padding)` | Padding applied to all sides. Prefer a spacing-scale name (`'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl'`) \u2014 each resolves to the matching `--ggui-spacing-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. |\n\n**Example:**\n```tsx\n<Container maxWidth=\"xl\" padding=\"var(--ggui-spacing-6)\">\n <Stack gap=\"var(--ggui-spacing-4)\">\n <Heading level={1}>Dashboard</Heading>\n <Card shadow=\"md\" padding=\"var(--ggui-spacing-5)\">\n <Text>Welcome back!</Text>\n </Card>\n </Stack>\n</Container>\n```\n\n### Card\n\nCard -- Container with background, shadow, and optional border.\n\nRenders a `<div>` with:\n- Background: `var(--ggui-color-container)`\n- Border (when enabled): `1px solid var(--ggui-color-outlineVariant)`\n- Shadow and radius controlled by design tokens via CSS variables.\n- No built-in transitions.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| padding | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `'lg'` | Padding applied to all sides. Prefer a spacing-scale name (`'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl'`) \u2014 each resolves to the matching `--ggui-spacing-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. |\n| shadow | `'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl'` | `'sm'` | Shadow elevation level. Maps to design tokens: - `'none'` -- no shadow - `'sm'` -- var(--ggui-shape-shadow-sm) -- subtle, default - `'md'` -- var(--ggui-shape-shadow-md) -- dialogs, emphasized sections - `'lg'` -- var(--ggui-shape-shadow-lg) -- floating panels - `'xl'` -- var(--ggui-shape-shadow-xl) -- popovers, modals |\n| border | `boolean` | `true` | Whether to render a 1px border using `var(--ggui-color-outlineVariant)`. |\n| radius | `'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| number \\| string` | `'lg'` | Corner radius. Prefer a radius-scale name (`'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl'`) \u2014 each resolves to the matching `--ggui-shape-radius-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. |\n| surface | `'default' \\| 'elevated' \\| 'sunken' \\| 'accent' \\| 'inverted' \\| 'hero' \\| 'transparent'` | `'default'` | Semantic surface slot. Same vocabulary as ; see that prop's docs for the full slot table. Default Card surface is `'default'` (the active theme's `--ggui-color-container`); pair with `shadow=\"md\"\\|\"lg\"` for elevated cards, `'hero'` for a greeting or hero card (the theme's hero ground, right on a light or a dark host), or `'inverted'` for a dark testimonial-style card on a light theme. |\n| bleed | `boolean` | `false` | Span the expanded frame's inset edge to edge (ggui#1083). In a host's fullscreen canvas the frame gives the root a 16 px content inset; a `bleed` element (a full-width row the design means to touch the panel's edges) takes that inset back and meets the panel edge. A `surface=\"hero\"` band that opens the card already does, without it. Ignored everywhere else, so it is safe on any card. |\n\n**Example:**\n```tsx\n<Card shadow=\"md\" padding=\"lg\" radius=\"lg\">\n <Stack gap=\"md\">\n <Text size=\"sm\" weight=\"medium\">Settings</Text>\n <Input label=\"Name\" value={name} onChange={setName} />\n <Button variant=\"primary\">Save</Button>\n </Stack>\n</Card>\n```\n\n### Stack\n\nStack -- Flexbox layout primitive for arranging children along a single axis.\n\nRenders a `<div>` with `display: flex`. Default layout is vertical (column).\nAll flex shorthand values (`align`, `justify`, `wrap`) are abstracted into\nsemantic prop names.\n\nCSS variables used: none (pure layout primitive).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| direction | `'vertical' \\| 'horizontal'` | `'vertical'` | Main axis direction. - `'vertical'` -- `flex-direction: column` - `'horizontal'` -- `flex-direction: row` |\n| gap | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `'sm'` | Gap between children. Prefer a spacing-scale name (`'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl'`) \u2014 each resolves to the matching `--ggui-spacing-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. |\n| align | `'start' \\| 'center' \\| 'end' \\| 'stretch'` | `'stretch'` | Cross-axis alignment (maps to `align-items`). - `'start'` -- flex-start - `'center'` -- center - `'end'` -- flex-end - `'stretch'` -- stretch (children fill cross-axis) |\n| justify | `'start' \\| 'center' \\| 'end' \\| 'between' \\| 'around' \\| 'evenly'` | `'start'` | Main-axis content distribution (maps to `justify-content`). - `'start'` -- flex-start - `'center'` -- center - `'end'` -- flex-end - `'between'` -- space-between - `'around'` -- space-around - `'evenly'` -- space-evenly |\n| wrap | `boolean` | `false` | Whether children wrap to the next line when they overflow. Maps to `flex-wrap: wrap` when true. |\n\n**Example:**\n```tsx\n<Stack gap=\"lg\" align=\"center\">\n <Heading level={2}>Profile</Heading>\n <Text>Edit your account details below.</Text>\n <Stack direction=\"horizontal\" gap=\"sm\" justify=\"end\">\n <Button variant=\"ghost\">Cancel</Button>\n <Button variant=\"primary\">Save</Button>\n </Stack>\n</Stack>\n```\n\n### Grid\n\nGrid -- 2-D layout primitive. Arranges children into rows AND\ncolumns; reach for it when Stack/Row's single-axis flow isn't\nenough (card galleries, dashboards, stat grids).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| columns | `number \\| ResponsiveColumns` | `2` | Column count. Three forms: - a number \u2014 that many equal columns at every width (`columns={3}`); - a map \u2014 explicit counts per breakpoint (`columns={{ base: 1, md: 3 }}` = 1 column on mobile, 3 from `md`). Use this when the request names exact per-breakpoint counts (\"3 per row on desktop, 1 on mobile\"). Ignored entirely when `minColumnWidth` is set. |\n| gap | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `'md'` | Gap between cells. Prefer a spacing-scale name (`'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl'`); a number is pixels. |\n| minColumnWidth | `number \\| string` | `undefined (use `columns`)` | When set, the grid becomes responsive \u2014 it fits as many equal columns as possible, each at least this wide, and `columns` is ignored. A number is treated as pixels. |\n\n**Example:**\n```tsx\n<Grid columns={3} gap=\"md\">\n {items.map((it) => <Card key={it.id}>{it.name}</Card>)}\n</Grid>\n```\n\n### Skeleton\n\nSkeleton -- a pulsing placeholder for content that has not loaded\nyet. ggui UIs are agent-driven (props arrive late, streams start\nempty), so a loading frame is the rule \u2014 render `Skeleton` instead\nof a blank screen or a hand-rolled pulsing `<div>`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| variant | `'rect' \\| 'text' \\| 'circle'` | `'rect'` | Shape preset. - `'rect'` -- a block (default); pair with `width` / `height`. - `'text'` -- a single text line (height ~1em). - `'circle'` -- equal width/height, fully rounded (avatar slot). |\n| width | `number \\| string` | - | Width. A number is pixels. Defaults to `100%` (`2.5rem` for circle). |\n| height | `number \\| string` | - | Height. A number is pixels. Defaults by variant when unset. |\n| radius | `'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| number \\| string` | `'sm'` | Corner radius. Prefer a radius-scale name. Ignored for `variant=\"circle\"` (always fully round). |\n\n**Example:**\n```tsx\n{user === undefined\n ? <Skeleton variant=\"text\" width=\"40%\" />\n : <Text>{user.name}</Text>}\n```\n\n### Box\n\nBox -- Generic container with padding, margin, background, and border-radius.\n\nRenders a plain `<div>`. Unlike Card, Box has no default background, shadow,\nor border -- it is a blank canvas for custom styling. Use it for layout\nspacing, colored sections, or wrapping arbitrary content.\n\nWhen both `paddingX`/`paddingY` and `padding` are provided, the axis-specific\nprops take precedence and `padding` is ignored.\n\nCSS variables used: none (all values are passed through directly).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| padding | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `undefined (no padding)` | Padding applied to all four sides. Prefer a spacing-scale name (`'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl'`) \u2014 each resolves to the matching `--ggui-spacing-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. Ignored when `paddingX` or `paddingY` is set. |\n| paddingX | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `undefined` | Horizontal (left + right) padding. Accepts a spacing-scale name, a pixel number, or a raw CSS string \u2014 see . When set alongside `paddingY`, they combine into a shorthand `padding: {Y} {X}`. When set without `paddingY`, vertical padding defaults to 0. |\n| paddingY | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `undefined` | Vertical (top + bottom) padding. Accepts a spacing-scale name, a pixel number, or a raw CSS string \u2014 see . When set alongside `paddingX`, they combine into a shorthand `padding: {Y} {X}`. When set without `paddingX`, horizontal padding defaults to 0. |\n| margin | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `undefined (no margin)` | Margin applied to all four sides. Accepts a spacing-scale name, a pixel number, or a raw CSS string \u2014 see . |\n| surface | `'default' \\| 'elevated' \\| 'sunken' \\| 'accent' \\| 'inverted' \\| 'hero' \\| 'transparent'` | `undefined (transparent)` | Semantic surface slot. Picks the right `var(--ggui-color-*)` background token from the active theme. The ONLY way to set a theme-tracking background fill on Box. Available slots: - `'default'` \u2014 base container surface (most common) - `'elevated'` \u2014 same fill, intended to be paired with shadow (use Card.shadow for actual elevation) - `'sunken'` \u2014 recessed / inset region (the `sunken` role; `surfaceVariant` was retired by ggui#987) - `'accent'` \u2014 highlighted / branded fill (`primary-50` token) - `'inverted'` \u2014 dark surface in light mode, light in dark (testimonials, code-snippet cards, hero panels). The surface owns ALL its inks: body text, `muted` / `subtle` labels, chips and outlines inside it re-map to inverse-derived values by cascade \u2014 no `tone=\"inverse\"` needed, and no hand-picked greys. - `'hero'` \u2014 the hero panel's ground: brand-tinted on a light host, the ink pair on a dark one (`heroGround` / `onHeroGround`, derived per mode) \u2014 reach for it for a greeting or hero card instead of guessing the host's darkness with `'inverted'`; it owns its subtree's inks likewise. - `'transparent'` \u2014 explicit \"no fill\" For non-theme-mapped brand colors (e.g. a partner's exact brand hex like Stripe purple) use the escape \u2014 every other hex / rgba on Box is rejected by tier-0 self-check. |\n| bleed | `boolean` | `false` | Span the expanded frame's inset edge to edge (ggui#1083). In a host's fullscreen canvas the frame gives the root a 16 px content inset; a `bleed` element (a full-width row the design means to touch the panel's edges) takes that inset back and meets the panel edge. A `surface=\"hero\"` band that opens the card already does, without it. Ignored everywhere else, so it is safe on any card. |\n| assetColor | `string` | `undefined` | Asset color escape \u2014 the typed valve for legitimate non-theme color values (a partner's exact brand hex, a fixed product surface, etc.). Renders as the Box background. **MUST be paired with .** The semantic name is human-readable documentation of why this color bypasses the theme \u2014 e.g. `\"stripe-brand-purple\"`, `\"slack-aubergine\"`. Tier-0 self-check allows hex / rgba inside `assetColor` ONLY when `assetSemantic` is a non-empty string; one without the other fails the check. Reach for `surface` first. This escape exists for the small set of cases where the operator's theme MUST NOT override the value (brand identity rendering). |\n| assetSemantic | `string` | `undefined` | Human-readable semantic label that documents why bypasses the theme. Required when `assetColor` is set; tier-0 self-check rejects empty strings or a missing `assetSemantic` next to a hex `assetColor`. Examples: `\"stripe-brand-purple\"`, `\"slack-aubergine\"`, `\"partner-logo-orange\"`. Pure documentation \u2014 no rendering effect. |\n| radius | `'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| number \\| string` | `undefined (no rounding)` | Corner radius. Prefer a radius-scale name (`'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl'`) \u2014 each resolves to the matching `--ggui-shape-radius-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. |\n\n**Example:**\n```tsx\n<Box paddingX=\"xl\" paddingY=\"lg\" surface=\"accent\" radius=\"lg\">\n <Text size=\"sm\" tone=\"emphasized\">\n Tip: You can customize your theme in Settings.\n </Text>\n</Box>\n```\n\n### Divider\n\nDivider -- A 1px line to visually separate content sections.\n\nRenders an `<hr>` (horizontal) or `<div>` (vertical) with `role=\"separator\"`.\n- Horizontal: 1px tall, full width, with vertical margin.\n- Vertical: 1px wide, stretches to parent height via `align-self: stretch`,\n with horizontal margin. Works best inside a horizontal Stack or Row.\n\nDefault color: `var(--ggui-color-outlineVariant)`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| orientation | `'horizontal' \\| 'vertical'` | `'horizontal'` | Line direction. - `'horizontal'` -- renders `<hr>`, full width, 1px height, margin top/bottom - `'vertical'` -- renders `<div>`, 1px width, `align-self: stretch`, margin left/right |\n| margin | `number \\| string` | `16` | Spacing around the divider. Numbers are treated as pixels. Applied as vertical margin for horizontal dividers, horizontal margin for vertical. |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `undefined (uses `var(--ggui-color-outlineVariant)`)` | Semantic color slot. Same vocabulary as ; the theme decides what each tone LOOKS like. Defaults to a quiet outline-variant tint when unset (independent of the tone slots). |\n\n**Example:**\n```tsx\n<Stack gap={0}>\n <Text>Section A</Text>\n <Divider margin=\"var(--ggui-spacing-3)\" />\n <Text>Section B</Text>\n</Stack>\n```\n\n### Spacer\n\nSpacer -- Invisible spacing element, either fixed-size or flexible.\n\nRenders an empty `<div>`.\n- Fixed mode (number): sets both `width` and `height` to the given pixel\n value with `flex-shrink: 0`, creating rigid spacing in any direction.\n- Flex mode (`'flex'`): sets `flex: 1`, expanding to fill remaining space\n in a flex container. Useful for pushing siblings apart.\n\nCSS variables used: none.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| size | `number \\| 'flex'` | `16` | Spacing amount. - Number: fixed square spacer (width and height in pixels, `flex-shrink: 0`). - `'flex'`: expands to fill available space (`flex: 1`). |\n\n**Example:**\n```tsx\n<Stack direction=\"horizontal\" align=\"center\">\n <Heading level={3}>Logo</Heading>\n <Spacer size=\"flex\" />\n <Button variant=\"ghost\">Login</Button>\n</Stack>\n```\n\n### Text\n\nText -- Versatile typography primitive for body copy, captions, and labels.\n\nRenders as `<p>` by default (configurable via `is`). Typography is\ncomposed from three orthogonal axes: `size` (font size, with a\nmatching line-height built in), `weight`, and `tone` (semantic\ncolor). `caps` adds the uppercase, letter-spaced treatment for tiny\nsection labels (the classic overline/eyebrow look) \u2014 the label's text\ncomes from a prop, never from the component: an invented eyebrow is copy\nnothing can change.\n\nDefault text color: `var(--ggui-color-onContainer)`.\nAll text renders with `margin: 0` (no default paragraph spacing).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| size | `'xs' \\| 'sm' \\| 'base' \\| 'lg' \\| 'xl' \\| '2xl' \\| '3xl' \\| '4xl'` | `'base'` | Font size. Maps to CSS variables with pixel fallbacks; each size carries a matching line-height (1.5 for `xs`--`base`, 1.625 for `lg`, then tightening on display sizes: 1.375 at `xl`, 1.25 from `2xl` up): - `'xs'` -- var(--ggui-font-size-xs) (12px) - `'sm'` -- var(--ggui-font-size-sm) (14px) - `'base'` -- var(--ggui-font-size-base) (16px) - `'lg'` -- var(--ggui-font-size-lg) (18px) - `'xl'` -- var(--ggui-font-size-xl) (20px) - `'2xl'` -- var(--ggui-font-size-2xl) (24px) - `'3xl'` -- var(--ggui-font-size-3xl) (30px) - `'4xl'` -- var(--ggui-font-size-4xl) (36px) |\n| weight | `'normal' \\| 'medium' \\| 'semibold' \\| 'bold'` | `'normal'` | Font weight. Maps to CSS variables with numeric fallbacks: - `'normal'` -- var(--ggui-font-weight-normal) - `'medium'` -- var(--ggui-font-weight-medium) - `'semibold'` -- var(--ggui-font-weight-semibold) - `'bold'` -- var(--ggui-font-weight-bold) |\n| caps | `boolean` | `false` | When true, uppercases the text and adds 0.05em letter-spacing -- the eyebrow/overline treatment for tiny section labels. Pair with `size=\"xs\" weight=\"semibold\" tone=\"muted\"`. |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `'default' (var(--ggui-color-onContainer))` | Semantic color slot. Picks the right `var(--ggui-color-*)` token from the active theme. The theme decides what each tone LOOKS like \u2014 `'muted'` is a quiet warm grey on Claudic, a cool slate on Indigo, dim cyan on Neon-Noir. Components that use `tone` track the operator's theme switch automatically. Available slots: `'default'` (primary body text), `'muted'` (secondary / metadata), `'subtle'` (very-low-emphasis hint), `'emphasized'` (branded accent), `'loud'` (strongest accent \u2014 the theme's readable accent ink), `'success'` / `'warning'` / `'error'` / `'info'` (status text), `'inverse'` (text on dark surface), `'inherit'` (parent's color). `tone` is the ONLY way to set a Text color. The legacy `color?: string` escape was retired \u2014 raw color strings bypass theming and silently override the operator's preset. |\n| align | `'left' \\| 'center' \\| 'right'` | `undefined (inherits from parent)` | Horizontal text alignment. Maps directly to `text-align`. |\n| truncate | `boolean` | `false` | When true, clips overflowing text with an ellipsis. Applies `overflow: hidden`, `text-overflow: ellipsis`, and `white-space: nowrap`. |\n| is | `'p' \\| 'span' \\| 'div' \\| 'label'` | `'p'` | HTML element to render. Choose based on semantic context: - `'p'` -- paragraph (default, block-level) - `'span'` -- inline text within a sentence - `'div'` -- generic block container - `'label'` -- form label (pair with `htmlFor`) |\n| id | `string` | - | `id` for the rendered element \u2014 anchor an in-page link, or pair with a form control's `aria-labelledby`. |\n| htmlFor | `string` | - | Associates an `is=\"label\"` element with a form control by the control's `id`. Only meaningful when `is=\"label\"`. |\n\n**Example:**\n```tsx\n<Stack gap=\"var(--ggui-spacing-1)\">\n <Text size=\"xs\" weight=\"semibold\" caps tone=\"muted\">{props.section}</Text>\n <Text size=\"lg\">Welcome back, Jane.</Text>\n <Text size=\"xs\" tone=\"muted\">\n Last login: 2 hours ago\n </Text>\n</Stack>\n```\n\n### Heading\n\nHeading -- Semantic heading element (h1-h6) with preset typography styles.\n\nRenders the corresponding `<h1>`-`<h6>` HTML element based on `level`.\nEach level has a preset font size, weight, line height, and letter spacing\nfrom the heading typography tokens:\n- Level 1: 36px / bold / 1.25 line-height / -0.025em tracking\n- Level 2: 30px / bold / 1.25 line-height / -0.025em tracking\n- Level 3: 24px / semibold / 1.375 line-height / 0em tracking\n- Level 4: 20px / semibold / 1.375 line-height / 0em tracking\n- Level 5: 18px / semibold / 1.5 line-height / 0em tracking\n- Level 6: 16px / semibold / 1.5 line-height / 0em tracking\n\nDefault text color: `var(--ggui-color-onContainer)`.\nAll headings render with `margin: 0` (no default heading spacing).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| level | `1 \\| 2 \\| 3 \\| 4 \\| 5 \\| 6` | `2` | Semantic heading level. Determines both the HTML element (`<h1>`-`<h6>`) and the preset typography style. |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `'default' (var(--ggui-color-onContainer))` | Semantic color slot. Same vocabulary as ; see that prop's docs for the full slot table. `tone` is the ONLY way to set a Heading color \u2014 the legacy `color?: string` escape was retired so the operator's theme always wins. |\n| align | `'left' \\| 'center' \\| 'right'` | `undefined (inherits from parent)` | Horizontal text alignment. Maps directly to `text-align`. |\n\n**Example:**\n```tsx\n<Stack gap=\"var(--ggui-spacing-2)\">\n <Heading level={1}>Page Title</Heading>\n <Heading level={3} tone=\"emphasized\">\n Subsection\n </Heading>\n <Text>Body content goes here.</Text>\n</Stack>\n```\n\n### Button\n\nButton -- A clickable button primitive with multiple visual variants and sizes.\n\nRenders a native `<button>` element styled with inline CSS derived from design-token\nCSS variables. Supports a loading spinner, left/right icon slots, and a cross-platform\n`onPress` alias for `onClick`.\n\nBase styles applied to every variant:\n- `border-radius: var(--ggui-shape-radius-control)` \u2014 the control ROLE (the host's button/field radius; falls to `var(--ggui-shape-radius-md)`)\n- `font-weight: var(--ggui-font-weight-medium)`\n- `box-shadow: var(--ggui-shape-shadow-sm)`\n- `gap: var(--ggui-spacing-2)` between icon and text\n- Transitions: background-color, box-shadow, opacity at 200ms ease-in-out\n\nDisabled or loading: `opacity: 0.5`, `cursor: not-allowed`, click handler suppressed.\n\nAlso extends native `ButtonHTMLAttributes` (except `style`/`className`), so props\nlike `type`, `form`, `aria-*`, and `data-*` are forwarded to the `<button>` element.\nThe `type` prop defaults to `'button'` (not `'submit'`), preventing accidental form\nsubmissions.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| variant | `'primary' \\| 'secondary' \\| 'outline' \\| 'ghost' \\| 'danger'` | `'primary'` | Visual style. Maps to CSS variables: - `'primary'` -- `var(--ggui-color-primary-600)` background, white text, no border - `'secondary'` -- `var(--ggui-color-sunken)` background, `var(--ggui-color-onSunken)` text, no border - `'outline'` -- transparent background, `1px solid var(--ggui-color-primary-600)` border, label in the surface's on-colour (`onContainer`) so it reads on any ground \u2014 the brand shows on the border, never as label ink on a dark theme - `'ghost'` -- transparent background, `var(--ggui-color-onSunken)` text, no border - `'danger'` -- `var(--ggui-color-error-600)` background, white text, no border |\n| size | `'xs' \\| 'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls padding, font size, and minimum height: - `'xs'` -- padding `4px 8px`, font `var(--ggui-font-size-xs)`, min-height 24px - `'sm'` -- padding `6px 12px`, font `var(--ggui-font-size-sm)`, min-height 32px - `'md'` -- padding `10px 16px`, font `var(--ggui-font-size-sm)`, min-height 40px - `'lg'` -- padding `12px 24px`, font `var(--ggui-font-size-base)`, min-height 48px |\n| fullWidth | `boolean` | `false` | When true, sets `width: 100%` so the button fills its container. |\n| loading | `boolean` | `false` | When true, replaces children with a 16px `Spinner` (color: `currentColor`) and disables interaction (same effect as `disabled`). |\n| type | `'button' \\| 'submit' \\| 'reset'` | `'button'` | The HTML button type. Defaults to `'button'`, so a Button inside a `<form>` never submits it on click: the ONE control that submits the form sets `type=\"submit\"`; secondary controls keep the default. |\n| leftIcon | `ReactNode` | - | ReactNode rendered before children, inside the flex layout with `var(--ggui-spacing-2)` gap. |\n| rightIcon | `ReactNode` | - | ReactNode rendered after children, inside the flex layout with `var(--ggui-spacing-2)` gap. |\n| onPress | `() => void` | - | Alias for `onClick` for cross-platform compatibility (React Native convention). If both `onClick` and `onPress` are provided, `onClick` takes precedence. |\n\n**Example:**\n```tsx\n<Button variant=\"primary\" size=\"md\" leftIcon={<Icon name=\"save\" />} onClick={handleSave}>\n Save Changes\n</Button>\n```\n\n### Input\n\nInput -- A single-line text input with label, validation, and helper text.\n\nRenders a `<div>` wrapper containing an optional `<label>`, a native `<input>`,\nand an optional message `<span>` for error or helper text.\n\nStyling:\n- Border: `1px solid var(--ggui-color-outline)` (normal),\n `var(--ggui-color-error-500)` (error)\n- Background: `var(--ggui-color-container)` (normal),\n `var(--ggui-color-container)` (disabled)\n- Text: `var(--ggui-color-onContainer)`\n- Border radius: `var(--ggui-shape-radius-control)` \u2014 the control ROLE (the host's button/field radius; falls to `var(--ggui-shape-radius-md)`)\n- Label: `var(--ggui-font-size-sm)`, `var(--ggui-font-weight-medium)`,\n `var(--ggui-color-onSunken)`\n- Transitions: border-color, box-shadow at 200ms ease-in-out\n\nAccessibility: auto-generated `id` links `<label>` to `<input>` via `htmlFor`.\nWhen `error` is set, `aria-invalid` is true and the message has `role=\"alert\"`.\nWhen `required` is true, a red asterisk is appended to the label.\n\nAlso extends native `InputHTMLAttributes` (except `style`, `className`, `onChange`,\n`size`), so props like `autoFocus`, `name`, `pattern`, `aria-*` are forwarded.\n\n**IMPORTANT:** `onChange` receives the string value directly, NOT a React\n`ChangeEvent`. This differs from native `<input>` behavior.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Label rendered above the input. Linked to the input via auto-generated `htmlFor`/`id`. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| placeholder | `string` | - | Placeholder text shown when the input is empty. |\n| value | `string` | - | Controlled value of the input. |\n| onChange | `(value: string) => void` | - | Change handler. Receives the new string value directly, NOT a React event. |\n| type | `'text' \\| 'email' \\| 'password' \\| 'number' \\| 'tel' \\| 'url' \\| 'search' \\| 'date' \\| 'time' \\| 'datetime-local'` | `'text'` | HTML input type. Determines browser behavior (keyboard on mobile, validation, masking). The date/time types render the browser's native, localized, keyboard-accessible picker; their `value` stays an ISO string (`'2026-07-29'`, `'14:30'`, `'2026-07-29T14:30'`) delivered through the usual value-first `onChange`. |\n| error | `string` | - | Error message displayed below the input in `var(--ggui-color-error-500)`. When set, the border turns red and the message element gets `role=\"alert\"`. Takes precedence over `helperText`. |\n| helperText | `string` | - | Helper text displayed below the input in `var(--ggui-color-onSunken)`. Only shown when `error` is not set. |\n| required | `boolean` | `false` | When true, appends a red asterisk (`*`) to the label and sets the native `required` attribute on the `<input>`. |\n| disabled | `boolean` | `false` | When true, sets the native `disabled` attribute. Background changes to `var(--ggui-color-container)`. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls padding and font size: - `'sm'` -- padding `6px 10px`, font `var(--ggui-font-size-sm)` - `'md'` -- padding `10px 12px`, font `var(--ggui-font-size-sm)` - `'lg'` -- padding `12px 14px`, font `var(--ggui-font-size-base)` |\n\n**Example:**\n```tsx\n<Input label=\"Email\" type=\"email\" value={email} onChange={setEmail} error={emailError} />\n```\n\n### TextArea\n\nTextArea -- A multiline text input with label, validation, character count, and auto-resize.\n\nRenders a `<div>` wrapper containing an optional `<label>`, a native `<textarea>`,\nand a footer row with error/helper text on the left and character count on the right.\n\nStyling:\n- Padding: `10px 12px`, font: `var(--ggui-font-size-sm)`, `font-family: inherit`\n- Border: `1px solid var(--ggui-color-outline)` (normal),\n `var(--ggui-color-error-500)` (error)\n- Background: `var(--ggui-color-container)` (normal),\n `var(--ggui-color-container)` (disabled)\n- Border radius: `var(--ggui-shape-radius-control)` \u2014 the control ROLE (the host's button/field radius; falls to `var(--ggui-shape-radius-md)`)\n- Resize: `vertical` by default, `none` when `autoResize` is true\n- Transitions: border-color, box-shadow at 200ms ease-in-out\n\nAccessibility: same label/error linking pattern as Input (auto-generated ids,\n`aria-invalid`, `role=\"alert\"` on error message).\n\nAlso extends native `TextareaHTMLAttributes` (except `style`, `className`, `onChange`).\n\n**IMPORTANT:** `onChange` receives the string value directly, NOT a React\n`ChangeEvent`. This differs from native `<textarea>` behavior.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Label rendered above the textarea. Linked via auto-generated `htmlFor`/`id`. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| placeholder | `string` | - | Placeholder text shown when the textarea is empty. |\n| value | `string` | - | Controlled value of the textarea. |\n| onChange | `(value: string) => void` | - | Change handler. Receives the new string value directly, NOT a React event. |\n| rows | `number` | `4` | Number of visible text rows (native `rows` attribute on `<textarea>`). |\n| error | `string` | - | Error message displayed below the textarea in `var(--ggui-color-error-500)`. When set, the border turns red and the message element gets `role=\"alert\"`. Takes precedence over `helperText`. |\n| helperText | `string` | - | Helper text displayed below the textarea in `var(--ggui-color-onSunken)`. Only shown when `error` is not set. |\n| required | `boolean` | `false` | When true, appends a red asterisk (`*`) to the label and sets the native `required` attribute on the `<textarea>`. |\n| disabled | `boolean` | `false` | When true, sets the native `disabled` attribute. Background changes to `var(--ggui-color-container)`. |\n| maxLength | `number` | - | Maximum character length (native `maxLength` attribute). Also used as the denominator in the character count display when `showCount` is true. |\n| showCount | `boolean` | `false` | When true AND `maxLength` is set, displays a `{current}/{max}` character counter in the footer row (right-aligned, `var(--ggui-font-size-xs)`). Has no effect without `maxLength`. |\n| autoResize | `boolean` | `false` | When true, sets CSS `resize: none` on the textarea. The flag disables manual resizing to signal that external logic handles sizing. The component does NOT auto-adjust height based on content in the current implementation. |\n\n**Example:**\n```tsx\n<TextArea label=\"Bio\" value={bio} onChange={setBio} rows={6} maxLength={500} showCount />\n```\n\n### Select\n\nSelect -- A native dropdown selection primitive with label and validation.\n\nRenders a `<div>` wrapper containing an optional `<label>`, a native `<select>`\nwith custom styling, and an optional message `<span>`.\n\nThe native `<select>` has `appearance: none` with a custom chevron SVG rendered\nas a `background-image` (right-aligned, 12px, onSurfaceVariant color). Extra right\npadding (36px) accommodates the chevron.\n\nStyling:\n- Border: `1px solid var(--ggui-color-outline)` (normal),\n `var(--ggui-color-error-500)` (error)\n- Background: `var(--ggui-color-container)` (normal),\n `var(--ggui-color-container)` (disabled)\n- Text: `var(--ggui-color-onContainer)` when a value is selected,\n `var(--ggui-color-onSunken)` when showing placeholder\n- Border radius: `var(--ggui-shape-radius-control)` \u2014 the control ROLE (the host's button/field radius; falls to `var(--ggui-shape-radius-md)`)\n- Cursor: `pointer` (normal), `not-allowed` (disabled)\n- Transitions: border-color, box-shadow at 200ms ease-in-out\n\nAccessibility: auto-generated `id` links `<label>` to `<select>`.\nWhen `error` is set, `aria-invalid` is true and the message has `role=\"alert\"`.\n\nAlso extends native `SelectHTMLAttributes` (except `style`, `className`,\n`onChange`, `size`).\n\n**IMPORTANT:** `onChange` receives the selected value string directly, NOT a\nReact `ChangeEvent`. This differs from native `<select>` behavior.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Label rendered above the select. Linked via auto-generated `htmlFor`/`id`. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| value | `string` | - | Controlled value. Should match one of the `options[].value` strings. |\n| onChange | `(value: string) => void` | - | Change handler. Receives the selected option's value string directly, NOT a React event. |\n| options | `SelectOption[]` | - | Array of selectable options. Rendered as native `<option>` elements. Must contain at least one option (or use `placeholder` for an empty-state prompt). |\n| placeholder | `string` | - | Placeholder text rendered as a disabled `<option value=\"\">` at the top of the list. Shown when no value is selected. |\n| error | `string` | - | Error message displayed below the select in `var(--ggui-color-error-500)`. When set, the border turns red and the message has `role=\"alert\"`. Takes precedence over `helperText`. |\n| helperText | `string` | - | Helper text displayed below the select in `var(--ggui-color-onSunken)`. Only shown when `error` is not set. |\n| required | `boolean` | `false` | When true, appends a red asterisk (`*`) to the label and sets the native `required` attribute. |\n| disabled | `boolean` | `false` | When true, sets the native `disabled` attribute. Background changes to `var(--ggui-color-container)` and cursor becomes `not-allowed`. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls padding and font size: - `'sm'` -- padding `6px 10px`, font `var(--ggui-font-size-sm)` - `'md'` -- padding `10px 12px`, font `var(--ggui-font-size-sm)` - `'lg'` -- padding `12px 14px`, font `var(--ggui-font-size-base)` |\n\n**Example:**\n```tsx\n<Select\n label=\"Country\"\n value={country}\n onChange={setCountry}\n options={[\n { value: 'us', label: 'United States' },\n { value: 'uk', label: 'United Kingdom' },\n ]}\n placeholder=\"Select a country\"\n/>\n```\n\n### Checkbox\n\nCheckbox -- A custom-styled checkbox with label and description.\n\nRenders a `<label>` wrapper containing a visually-hidden native `<input type=\"checkbox\">`\noverlaid by a custom 18x18px visual box. Supports checked, unchecked, and indeterminate\nstates, each with a distinct SVG icon (checkmark or horizontal dash).\n\nStyling:\n- Box border: `2px solid var(--ggui-color-primary-600)` (checked/indeterminate),\n `var(--ggui-color-outline)` (unchecked)\n- Box fill: `var(--ggui-color-primary-600)` (checked/indeterminate),\n `var(--ggui-color-container)` (unchecked)\n- Check/dash icon: white SVG, 12x12px\n- Box radius: `var(--ggui-shape-radius-sm)`\n- Transition: all 0.2s\n- Label: `var(--ggui-font-size-sm)`, `var(--ggui-font-weight-medium)`\n- Description: `var(--ggui-font-size-xs)`, `var(--ggui-color-onSunken)`\n- Disabled: `opacity: 0.5`, `cursor: not-allowed`\n- Gap between box and text: `var(--ggui-spacing-2)`\n\n**IMPORTANT:** `onChange` receives the boolean checked state directly, NOT a\nReact `ChangeEvent`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Primary label text rendered beside the checkbox box. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| checked | `boolean` | - | Controlled checked state. |\n| onChange | `(checked: boolean) => void` | - | Change handler. Receives the new boolean checked state directly, NOT a React event. |\n| disabled | `boolean` | `false` | When true, sets `opacity: 0.5` and `cursor: not-allowed`. The native input is also disabled, preventing keyboard and click interaction. |\n| description | `string` | - | Secondary description text rendered below the label in smaller, muted type (`var(--ggui-font-size-xs)`, `var(--ggui-color-onSunken)`). |\n| indeterminate | `boolean` | `false` | When true, displays a horizontal dash instead of a checkmark. Used for \"select all\" states where some (but not all) children are checked. The `indeterminate` property is set via a ref on the native `<input>`. Visually identical to `checked` in terms of border and fill color. |\n\n**Example:**\n```tsx\n<Checkbox\n label=\"Accept terms\"\n description=\"You agree to the Terms of Service and Privacy Policy\"\n checked={accepted}\n onChange={setAccepted}\n/>\n```\n\n### Toggle\n\nToggle -- A switch/toggle input rendered as a pill-shaped track with a sliding knob.\n\nRenders a `<label>` wrapper with a `<div role=\"switch\">` track and an animated\ncircular knob. Does NOT use a native `<input>` -- keyboard interaction is handled\nmanually (Space and Enter keys toggle the state). The element is focusable via\n`tabIndex={0}` and shows a focus ring on focus.\n\nStyling:\n- Track (on): `var(--ggui-color-primary-600)`\n- Track (off): `var(--ggui-color-outline)`\n- Knob: white circle with `var(--ggui-shape-shadow-sm)`\n- Focus ring: `0 0 0 3px var(--ggui-color-primary-200)`\n- Transitions: background-color, box-shadow, knob position at 200ms ease-in-out\n- Disabled: `opacity: 0.5`, `cursor: not-allowed`, `tabIndex: -1`\n- Gap between toggle and label: `var(--ggui-spacing-2)`\n\n**IMPORTANT:** `onChange` receives the new boolean state directly (inverted from\ncurrent), NOT a React event.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Label text rendered to the right of the toggle track. Also used as `aria-label` on the switch element. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| checked | `boolean` | - | Controlled checked (on/off) state. |\n| onChange | `(checked: boolean) => void` | - | Change handler. Receives the new boolean state directly (i.e., `!checked`), NOT a React event. |\n| disabled | `boolean` | `false` | When true, sets `opacity: 0.5`, `cursor: not-allowed`, and removes the element from tab order (`tabIndex: -1`). Click and keyboard handlers are suppressed. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls track and knob dimensions: - `'sm'` -- track 36x20px, knob 16px diameter - `'md'` -- track 44x24px, knob 20px diameter - `'lg'` -- track 52x28px, knob 24px diameter |\n\n**Example:**\n```tsx\n<Toggle label=\"Enable notifications\" checked={enabled} onChange={setEnabled} size=\"md\" />\n```\n\n### RadioGroup\n\nRadioGroup -- A group of mutually exclusive radio options with optional label and error.\n\nRenders a `<div role=\"radiogroup\">` containing a label span, a flex container of\nradio options, and an optional error message. Each option is a `<label>` with a\nvisually-hidden native `<input type=\"radio\">` and a custom 18px circle indicator.\n\nStyling:\n- Selected circle: `2px solid var(--ggui-color-primary-600)` border with\n an 8px `var(--ggui-color-primary-600)` filled inner dot\n- Unselected circle: `2px solid var(--ggui-color-outline)` border,\n `var(--ggui-color-container)` fill\n- Circle radius: `var(--ggui-shape-radius-full)`\n- Transition: all 0.2s\n- Vertical gap: `var(--ggui-spacing-2)`, horizontal gap: `var(--ggui-spacing-4)`\n- Error: `var(--ggui-font-size-xs)`, `var(--ggui-color-error-500)`,\n `role=\"alert\"`\n- Disabled options: `opacity: 0.5`, `cursor: not-allowed`\n\nAccessibility: the group has `role=\"radiogroup\"` with `aria-labelledby` pointing\nto the label and `aria-describedby` pointing to the error message (when present).\nAll radio inputs share a common auto-generated `name` attribute.\n\n**IMPORTANT:** `onChange` receives the selected option's value string directly,\nNOT a React `ChangeEvent`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Group label rendered above the options. Used as `aria-labelledby` target on the `role=\"radiogroup\"` container. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| value | `string` | - | Controlled value. Should match one of `options[].value`. |\n| onChange | `(value: string) => void` | - | Change handler. Receives the newly selected option's value string directly, NOT a React event. |\n| options | `RadioOption[]` | - | Array of radio options. Must contain at least two options for meaningful selection. |\n| direction | `'vertical' \\| 'horizontal'` | `'vertical'` | Layout direction for the options container: - `'vertical'` -- column layout, `var(--ggui-spacing-2)` gap - `'horizontal'` -- row layout with `flex-wrap: wrap`, `var(--ggui-spacing-4)` gap |\n| disabled | `boolean` | `false` | When true, disables ALL options (individual `RadioOption.disabled` is additive). Each option gets `opacity: 0.5` and `cursor: not-allowed`. |\n| error | `string` | - | Error message displayed below all options in `var(--ggui-color-error-500)` with `role=\"alert\"`. Linked to the radiogroup via `aria-describedby`. |\n\n**Example:**\n```tsx\n<RadioGroup\n label=\"Plan\"\n value={plan}\n onChange={setPlan}\n options={[\n { value: 'free', label: 'Free', description: 'Up to 5 projects' },\n { value: 'pro', label: 'Pro', description: 'Unlimited projects' },\n ]}\n/>\n```\n\n### Slider\n\nSlider -- A range input with a custom-styled track, fill, and thumb.\n\nRenders a `<div>` wrapper containing an optional label/value header, and a\ntrack area with three layers: background track, colored fill, and a circular\nthumb. A native `<input type=\"range\">` is overlaid with `opacity: 0` to\nprovide accessible keyboard and pointer interaction.\n\nStyling:\n- Track: 6px tall, `var(--ggui-color-outlineVariant)` background, `border-radius: 3px`\n- Fill: `var(--ggui-color-primary-600)` (normal),\n `var(--ggui-color-outline)` (disabled)\n- Thumb: 20px white circle with `2px solid var(--ggui-color-primary-600)`,\n `var(--ggui-shape-shadow-sm)`; disabled border uses outline\n- Value display (when `showValue`): `var(--ggui-color-primary-600)`,\n `var(--ggui-font-size-sm)`, right-aligned in the header row\n- Fill and thumb transitions: 0.1s for smooth dragging\n\nAccessibility: the native `<input type=\"range\">` carries `aria-valuenow`,\n`aria-valuemin`, `aria-valuemax`, and is linked to the label via `aria-labelledby`.\nFalls back to `aria-label=\"Slider\"` when no label is provided.\n\n**IMPORTANT:** `onChange` receives the numeric value directly, NOT a React\n`ChangeEvent`. The value is coerced via `Number(e.target.value)`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Label rendered above the slider track (left-aligned). Used as `aria-labelledby` target on the native range input. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| value | `number` | `0` | Controlled numeric value. Must be between `min` and `max`. |\n| onChange | `(value: number) => void` | - | Change handler. Receives the new numeric value directly, NOT a React event. |\n| min | `number` | `0` | Minimum allowed value. |\n| max | `number` | `100` | Maximum allowed value. |\n| step | `number` | `1` | Step increment for the slider. Determines the granularity of selectable values. |\n| disabled | `boolean` | `false` | When true, sets `cursor: not-allowed` on the native input. The fill color changes to `var(--ggui-color-outline)` and the thumb border also uses outline. |\n| showValue | `boolean` | `false` | When true, displays the current numeric value right-aligned in the header row (beside the label) in `var(--ggui-color-primary-600)`. |\n\n**Example:**\n```tsx\n<Slider label=\"Volume\" value={volume} onChange={setVolume} min={0} max={100} step={5} showValue />\n```\n\n### Badge\n\nBadge -- Inline label for status indicators, counts, or categories.\n\nRenders a `<span>` with `display: inline-flex`, centered content, and\n`white-space: nowrap`. Semantic variant colors use background/text pairings\nfrom the 100/700 color scale. Pill shape uses `border-radius: 9999px`;\nnon-pill uses `var(--ggui-shape-radius-sm)`.\n\nFont weight: `var(--ggui-font-weight-medium)` across all variants.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| variant | `'default' \\| 'primary' \\| 'secondary' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'tertiary'` | `'default'` | Visual style. Maps to background/text color pairings: - `'default'` -- bg `var(--ggui-color-sunken)`, text `var(--ggui-color-onSunken)` - `'primary'` -- bg `var(--ggui-color-primary-100)`, text `var(--ggui-color-primary-700)` - `'secondary'` -- bg `var(--ggui-color-outlineVariant)`, text `var(--ggui-color-onContainer)` - `'success'` -- bg `var(--ggui-color-success-100)`, text `var(--ggui-color-success-700)` - `'warning'` -- bg `var(--ggui-color-warning-100)`, text `var(--ggui-color-warning-700)` - `'error'` -- bg `var(--ggui-color-error-100)`, text `var(--ggui-color-error-700)` - `'info'` -- bg `var(--ggui-color-info-100)`, text `var(--ggui-color-info-700)` |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls padding and font size: - `'sm'` -- padding `2px 6px`, font `var(--ggui-font-size-xs)` - `'md'` -- padding `2px 8px`, font `var(--ggui-font-size-xs)` - `'lg'` -- padding `4px 10px`, font `var(--ggui-font-size-sm)` |\n| pill | `boolean` | `true` | When true, uses fully rounded corners (`border-radius: 9999px`). When false, uses `var(--ggui-shape-radius-sm)`. |\n\n**Example:**\n```tsx\n<Badge variant=\"success\" size=\"sm\">Active</Badge>\n```\n\n### Spinner\n\nSpinner -- Animated SVG loading indicator.\n\nRenders an `<svg>` with `role=\"status\"` and `aria-label=\"Loading\"`.\nThe SVG contains a full outlineVariant background circle and a quarter-arc\nforeground stroke in the spinner color.\n\nAnimation: `ggui-spin 1s linear infinite` (360-degree rotation).\nThe `@keyframes ggui-spin` definition is injected inline via a `<style>` tag.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| size | `number` | `24` | Width and height of the SVG element in pixels. The internal viewBox is always `0 0 24 24`, so this controls rendered size only. |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `undefined (uses `var(--ggui-color-primary-600)`)` | Semantic color slot for the animated foreground arc. Same vocabulary as ; the theme decides the resolved value. The background circle always uses `var(--ggui-color-outlineVariant)`. Use `'inherit'` when the spinner sits inside a colored container (e.g. inside a Button) \u2014 the stroke picks up `currentColor` from the parent so it tracks the container's foreground. |\n\n**Example:**\n```tsx\n<Spinner size={32} tone=\"success\" />\n```\n\n### Avatar\n\nAvatar -- User or entity representation with image or auto-generated initials.\n\nRenders a `<div role=\"img\">` with `overflow: hidden` and `flex-shrink: 0`.\nWhen `src` is provided and loads successfully, renders an `<img>` with\n`object-fit: cover`. On image error (or when no `src`), falls back to\ninitials derived from `name` (up to 2 characters, uppercase).\n\nInitials background: deterministic color from a 5-color palette based on\nname hash (primary-500, success-500, warning-500, error-500, info-500).\nFalls back to `var(--ggui-color-outline)` when no name is given.\nInitials text: white, `font-weight: var(--ggui-font-weight-semibold)`,\n`font-size: resolvedSize * 0.4`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| src | `string` | - | Image URL. When provided and the image loads, it is rendered with `object-fit: cover`. On load error, falls back to initials. |\n| name | `string` | - | Name used for two purposes: 1. Generating initials (splits on spaces, takes first letter of each word, max 2). 2. Deterministic background color selection via character code hash. Also used as `aria-label` on the container. Falls back to `'Avatar'` if omitted. |\n| size | `number \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl'` | `'md'` | Avatar dimensions. Named sizes map to pixel values: - `'xs'` -- 24px - `'sm'` -- 32px - `'md'` -- 40px - `'lg'` -- 48px - `'xl'` -- 64px Numeric values are used directly as pixel dimensions. |\n| shape | `'circle' \\| 'square'` | `'circle'` | Container shape. - `'circle'` -- `border-radius: 50%` - `'square'` -- `border-radius: var(--ggui-shape-radius-md)` |\n\n**Example:**\n```tsx\n<Avatar src=\"/photos/jane.jpg\" name=\"Jane Doe\" size=\"lg\" shape=\"circle\" />\n```\n\n### Alert\n\nAlert -- Contextual message box for important information with icon and optional dismiss.\n\nRenders a `<div role=\"alert\">` with flex layout (12px gap), variant-specific\nbackground, border, text color, and a leading icon. Each variant provides a\ndefault SVG icon (info circle, checkmark, warning triangle, or X circle) that\ncan be overridden via the `icon` prop.\n\nLayout: icon (flex-shrink: 0) | content column (title + body) | close button.\nBorder radius: `var(--ggui-shape-radius-lg)`.\nPadding: `12px 16px`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| variant | `'info' \\| 'success' \\| 'warning' \\| 'error'` | `'info'` | Visual style. Maps to background/border/text/icon color sets: - `'info'` -- bg `var(--ggui-color-info-50)`, border `var(--ggui-color-info-200)`, text `var(--ggui-color-info-800)`, icon `var(--ggui-color-info-500)` - `'success'` -- bg `var(--ggui-color-success-50)`, border `var(--ggui-color-success-200)`, text `var(--ggui-color-success-800)`, icon `var(--ggui-color-success-500)` - `'warning'` -- bg `var(--ggui-color-warning-50)`, border `var(--ggui-color-warning-200)`, text `var(--ggui-color-warning-800)`, icon `var(--ggui-color-warning-500)` - `'error'` -- bg `var(--ggui-color-error-50)`, border `var(--ggui-color-error-200)`, text `var(--ggui-color-error-800)`, icon `var(--ggui-color-error-500)` |\n| title | `string` | - | Optional title rendered above the body in semibold (`var(--ggui-font-weight-semibold)`), `var(--ggui-font-size-sm)`. Title and body are separated by `var(--ggui-spacing-1)` gap. |\n| closable | `boolean` | `false` | When true, renders a close button (X icon) in the top-right area. The button has `min-width: 28px`, `min-height: 28px`, and `opacity: 0.7`. Requires `onClose` to be functional. |\n| onClose | `() => void` | - | Callback fired when the close button is clicked. Only relevant when `closable` is true. |\n| icon | `ReactNode` | - | Custom icon ReactNode to replace the default variant icon. Rendered at the leading position with the variant's icon color applied via `color` CSS property. |\n\n**Example:**\n```tsx\n<Alert variant=\"warning\" title=\"Rate limit\" closable onClose={() => setShow(false)}>\n You have 3 requests remaining this minute.\n</Alert>\n```\n\n### Progress\n\nProgress -- Horizontal progress bar with determinate and indeterminate modes.\n\nRenders a track `<div role=\"progressbar\">` with a colored fill child.\nThe track background is `var(--ggui-color-outlineVariant)` with\npill-shaped corners (border-radius = height / 2).\n\nDeterminate mode: fill width transitions smoothly (`width 0.3s ease`).\nIndeterminate mode: fill is 30% width, animated with\n`ggui-progress-indeterminate 1.5s ease-in-out infinite`\n(translateX from -100% to 400%). The `@keyframes` are injected inline.\n\nAccessibility: `aria-valuenow` is set in determinate mode, omitted in\nindeterminate. `aria-valuemin` is always 0, `aria-valuemax` matches `max`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| value | `number` | - | Current progress value. Clamped to `[0, max]` and converted to a percentage for the fill width: `Math.min(100, Math.max(0, (value / max) * 100))`. |\n| max | `number` | `100` | Maximum value representing 100% progress. |\n| variant | `'default' \\| 'success' \\| 'warning' \\| 'error'` | `'default'` | Fill bar color. Maps to CSS variables: - `'default'` -- `var(--ggui-color-primary-600)` - `'success'` -- `var(--ggui-color-success-500)` - `'warning'` -- `var(--ggui-color-warning-500)` - `'error'` -- `var(--ggui-color-error-500)` |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls track height in pixels: - `'sm'` -- 4px - `'md'` -- 8px - `'lg'` -- 12px |\n| label | `string` | - | Accessible name describing what this bar measures, e.g. `\"Survey progress\"` or `\"Upload\"`. Becomes the progressbar's `aria-label` and \u2014 when `showLabel` is set \u2014 the visible header text in place of the generic word \"Progress\". Always pass this when the surrounding context does not already make the meaning obvious. |\n| showLabel | `boolean` | `false` | When true, displays a header row above the track with the `label` text (or \"Progress\" if `label` is unset) on the left, and the rounded percentage value on the right. |\n| indeterminate | `boolean` | `false` | When true, ignores `value` for visual width and plays a looping animation instead. The fill bar is 30% width and slides across the track. Animation: `ggui-progress-indeterminate 1.5s ease-in-out infinite`. `aria-valuenow` is omitted from the progressbar element. |\n\n**Example:**\n```tsx\n<Progress value={65} variant=\"success\" size=\"md\" showLabel />\n```\n\n### Image\n\nImage -- An `<img>` element with built-in error handling and fallback support.\n\nRenders a native `<img>` with `display: block`. On load error, either renders\nthe `fallback` ReactNode (if provided) or a default placeholder `<div>` with a\nsurfaceVariant background and a centered image SVG icon in outline.\n\nSize values: numbers are treated as pixels, strings are passed through as-is.\nWhen no `width` is set, defaults to `100%`. When no `height` is set, defaults\nto `auto`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| src | `string` | - | Image source URL. Load failure triggers the fallback state. |\n| alt | `string` | - | Alt text for the image. Used as `aria-label` in the error placeholder too. |\n| width | `number \\| string` | `'100%' (applied at render time, not on the type)` | Image width. Numbers are pixels, strings are CSS values (e.g., `'100%'`, `'50vw'`). |\n| height | `number \\| string` | `'auto' (applied at render time, not on the type)` | Image height. Numbers are pixels, strings are CSS values. |\n| objectFit | `'cover' \\| 'contain' \\| 'fill' \\| 'none' \\| 'scale-down'` | `'cover'` | CSS `object-fit` value controlling how the image fills its box. |\n| radius | `'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| number \\| string` | - | Corner radius applied to both the image and the error placeholder. Prefer a radius-scale name (`'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl'`) \u2014 each resolves to the matching `--ggui-shape-radius-*` token. A number is treated as pixels; any other string is passed through. |\n| fallback | `ReactNode` | - | Custom ReactNode rendered when the image fails to load. When provided, completely replaces the default error placeholder (no wrapper div). When omitted, a surfaceVariant background div with an image icon is shown. |\n\n**Example:**\n```tsx\n<Image src=\"/hero.jpg\" alt=\"Hero banner\" width=\"100%\" height={400} objectFit=\"cover\" radius=\"md\" />\n```\n\n### Icon\n\nIcon -- a curated Lucide subset (~200 names) + emoji passthrough.\n\nThree resolution layers:\n1. **Lucide icon:** pass a name from the curated subset (e.g. `sun`, `cloud-rain`, `heart`, `shopping-cart`, `sparkles`, `arrow-up-right`).\n Accepts kebab-case, camelCase, or PascalCase. Renders as stroke SVG. ONLY the subset renders: an\n unknown name renders an empty box (never a placeholder glyph) \u2014 the `get_available_icons` tool lists the exact names.\n2. **Emoji:** pass emoji/unicode directly (e.g. `\u2600\uFE0F`, `\u{1F327}\uFE0F`). Rendered as text.\n3. **Custom SVG:** pass children (`<svg>` element) for full control.\n\nContainer: `<span>` with `display: inline-flex`, centered content.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| name | `string` | - | Lucide icon name from the curated subset (kebab-case, camelCase, or PascalCase all work); a name outside the subset renders an empty box. Also accepts emoji/unicode characters directly. |\n| size | `number` | `24` | Icon dimensions in pixels (applied to both width and height of the wrapper and the inner SVG element). |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `undefined (icon uses `currentColor`)` | Semantic color slot. Same vocabulary as ; the theme decides what each tone LOOKS like. Resolves to a CSS `color` on the wrapper which the inner SVG inherits via `currentColor`. Use `'inherit'` (the default behavior when unset) for icons that should pick up the parent's foreground color. |\n| children | `ReactNode` | - | Custom SVG children. When provided, `name` is ignored and children are rendered inside a sized `<span>` wrapper. |\n| 'aria-label' | `string` | - | Accessible name for a standalone, meaning-bearing icon. When set, the icon exposes `role=\"img\"` + this label. When omitted (the default) the icon is decorative and hidden from screen readers (`aria-hidden`) \u2014 the right choice for an icon next to a text label. |\n\n**Example:**\n```tsx\n<Icon name=\"search\" size={20} tone=\"muted\" />\n<Icon name=\"cloud-rain\" size={32} />\n<Icon name=\"\u2600\uFE0F\" size={24} />\n```\n\n### Link\n\nLink -- Styled anchor element with external link support.\n\nRenders a native `<a>` element. When `external` is true, sets\n`target=\"_blank\"` and `rel=\"noopener noreferrer\"`, and appends a small\n(12px) external-link SVG icon after the children.\n\nTransition: `color 0.2s`.\nUnderline behavior is controlled via mouseEnter/mouseLeave event handlers\n(for the `'hover'` mode).\n\nAlso extends native `AnchorHTMLAttributes` (except `style`/`className`),\nso props like `aria-*`, `data-*`, `title`, etc. are forwarded to the `<a>`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| href | `string` | - | Destination URL. Passed directly to the `<a href>` attribute. |\n| external | `boolean` | `false` | When true, opens link in a new tab (`target=\"_blank\"`, `rel=\"noopener noreferrer\"`) and appends a 12px external-link icon after children. |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `undefined (uses `var(--ggui-color-primary-600)`)` | Semantic color slot for the link text. Same vocabulary as ; the theme decides what each tone LOOKS like. Defaults to a primary-tinted accent (`'loud'`-ish) when unset. |\n| underline | `'always' \\| 'hover' \\| 'none'` | `'hover'` | Underline behavior: - `'always'` -- `text-decoration: underline` at all times - `'hover'` -- underline appears on mouse enter, removed on mouse leave - `'none'` -- no underline ever |\n\n**Example:**\n```tsx\n<Link href=\"https://docs.ggui.ai\" external>Documentation</Link>\n```\n\n### Tooltip\n\nTooltip -- Hoverable information popup positioned relative to a trigger element.\n\nWraps `children` in a `<div>` trigger (display: inline-block) and renders\na fixed-position tooltip `<div role=\"tooltip\">` when visible.\n\nTooltip appearance:\n- Background: `var(--ggui-color-onContainer)`\n- Text: white, `var(--ggui-font-size-xs)`\n- Padding: `6px 10px`, border-radius: `var(--ggui-shape-radius-md)`\n- Max width: 200px, `white-space: nowrap`, `pointer-events: none`\n- Z-index: `zIndex.tooltip` (1800)\n\nShow/hide: triggered by mouseEnter/mouseLeave AND focus/blur on the\ntrigger element. Uses `position: fixed` with coordinates calculated from\n`getBoundingClientRect()` and an 8px offset from the trigger edge.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | Trigger element. Wrapped in a `<div>` with mouseEnter/mouseLeave and focus/blur handlers. |\n| content | `ReactNode` | - | Tooltip content. Can be text or any ReactNode. |\n| position | `'top' \\| 'bottom' \\| 'left' \\| 'right'` | `'top'` | Tooltip placement relative to the trigger element: - `'top'` -- above, centered horizontally, transformed `translateX(-50%) translateY(-100%)` - `'bottom'` -- below, centered horizontally, transformed `translateX(-50%)` - `'left'` -- to the left, centered vertically, transformed `translateX(-100%) translateY(-50%)` - `'right'` -- to the right, centered vertically, transformed `translateY(-50%)` |\n| delay | `number` | `200` | Delay in milliseconds before the tooltip becomes visible after hover/focus. Hiding is immediate (no delay). |\n\n**Example:**\n```tsx\n<Tooltip content=\"Copy to clipboard\" position=\"top\">\n <Button variant=\"ghost\"><Icon name=\"copy\" /></Button>\n</Tooltip>\n```\n\n### Table\n\nTable -- Data table with sortable columns, striped rows, and hover highlights.\n\nRenders a scrollable wrapper `<div>` containing a native `<table>` with\n`border-collapse: collapse` and `width: 100%`. The wrapper has\n`overflow-x: auto` for horizontal scrolling on narrow viewports.\n\nHeader row: 2px bottom border (`var(--ggui-color-outlineVariant)`).\nData rows: 1px bottom border (`var(--ggui-color-sunken)`).\nHover: `var(--ggui-color-container)` background with 150ms ease transition.\nStriped: alternating rows (odd index) get `var(--ggui-color-container)`.\n\nSort behavior: clicking a sortable column header calls `onSort(key, direction)`.\nIf the same column is clicked again while ascending, it toggles to descending.\nThe component does NOT sort data internally -- the parent must sort `data` and\npass updated `sortKey`/`sortDirection`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| columns | `TableColumn<T>[]` | - | Array of column definitions controlling header labels, data keys, and rendering. |\n| data | `T[]` | - | Array of row data objects. Each object's keys should match the column `key` values. |\n| sortKey | `string` | - | The `key` of the currently sorted column. Used to highlight the active sort indicator and determine toggle direction on next click. |\n| sortDirection | `SortDirection` | `'asc'` | Current sort direction for the column identified by `sortKey`. Controls which triangle indicator is highlighted in the header. |\n| onSort | `(key: string, direction: SortDirection) => void` | - | Sort change handler. Called when a sortable column header is clicked. Receives the column `key` and the new `SortDirection`. The component does NOT sort data internally -- you must sort `data` in your state and pass updated `sortKey`/`sortDirection`. |\n| striped | `boolean` | `false` | When true, alternating rows (odd index) get a `var(--ggui-color-container)` background. |\n| hoverable | `boolean` | `true` | When true, rows highlight with `var(--ggui-color-container)` on mouse enter, with a 150ms ease background-color transition. |\n| compact | `boolean` | `false` | When true, reduces cell padding: - Compact: `var(--ggui-spacing-1) var(--ggui-spacing-2)` - Normal: `var(--ggui-spacing-2) var(--ggui-spacing-4)` |\n| bordered | `boolean` | `false` | When true, adds a 1px border around the table wrapper and between cells. Wrapper border: `1px solid var(--ggui-color-outlineVariant)`. Cell borders: `1px solid var(--ggui-color-sunken)`. Wrapper border-radius: `var(--ggui-shape-radius-lg)`. |\n| caption | `string` | - | Accessible table caption. Rendered as a `<caption>` element with `caption-side: top`, `var(--ggui-font-size-sm)`, `var(--ggui-color-onSunken)`. |\n\n**Example:**\n```tsx\n<Table\n columns={[\n { key: 'name', header: 'Name', sortable: true },\n { key: 'role', header: 'Role' },\n { key: 'status', header: 'Status', render: (v) => <Badge variant={v as string}>{v as string}</Badge> },\n ]}\n data={users}\n sortKey={sortKey}\n sortDirection={sortDir}\n onSort={(key, dir) => { setSortKey(key); setSortDir(dir); }}\n striped\n/>\n```\n\n### Tabs\n\nTabs -- Accessible tab navigation with panels and keyboard support.\n\nRenders a `<div role=\"tablist\">` with `<button role=\"tab\">` elements and a\n`<div role=\"tabpanel\">` for the active tab's content. Supports controlled\n(`activeKey` + `onChange`) and uncontrolled (internal state) modes.\n\nKeyboard navigation: ArrowLeft/ArrowRight (and ArrowUp/ArrowDown) cycle\nthrough enabled tabs. Home/End jump to first/last. Focus follows selection.\nDisabled tabs are skipped during keyboard navigation.\n\n**IMPORTANT:** `onChange` receives the tab's `key` string directly, NOT a\nReact event.\n\nTab panel padding: `var(--ggui-spacing-4) 0` (top/bottom only).\nTransitions: color, background-color, border-color at 200ms ease-in-out.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| items | `TabItem[]` | - | Array of tab definitions. Must contain at least one item. |\n| activeKey | `string` | - | Controlled active tab key. When provided, the component is controlled and will not manage its own state. Must match one of `items[].key`. When omitted, defaults to the first item's key (uncontrolled mode). |\n| onChange | `(key: string) => void` | - | Tab change handler. Receives the selected tab's `key` string directly, NOT a React event. In controlled mode, you must update `activeKey` in response to this callback. |\n| variant | `'line' \\| 'pills' \\| 'enclosed'` | `'line'` | Visual style of the tab bar: - `'line'` -- underline indicator (2px solid primary-600 on active), border-bottom on tab list - `'pills'` -- filled pill buttons (primary-600 bg, white text on active), surfaceVariant container with radius-lg - `'enclosed'` -- bordered tab buttons with open bottom (card-style), border-bottom on tab list |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls tab button padding and font size: - `'sm'` -- padding `var(--ggui-spacing-1) var(--ggui-spacing-2)`, font `var(--ggui-font-size-xs)` - `'md'` -- padding `var(--ggui-spacing-2) var(--ggui-spacing-4)`, font `var(--ggui-font-size-sm)` - `'lg'` -- padding `var(--ggui-spacing-4) var(--ggui-spacing-6)`, font `var(--ggui-font-size-base)` |\n| fullWidth | `boolean` | `false` | When true, tab buttons expand equally to fill the container width (`flex: 1`, `justify-content: center` on each button). |\n\n**Example:**\n```tsx\n<Tabs\n variant=\"pills\"\n items={[\n { key: 'overview', label: 'Overview', content: <Overview /> },\n { key: 'settings', label: 'Settings', content: <Settings /> },\n ]}\n activeKey={tab}\n onChange={setTab}\n/>\n```\n\n### Toast\n\nToast -- Notification banner with auto-dismiss and slide-in animation.\n\nRenders a `<div role=\"alert\" aria-live=\"assertive\">` with a variant-specific\nicon, optional title, message body, and optional close button.\n\nAnimation: `ggui-slideInUp 200ms ease-out both` on mount (from the motion\ntoken system). The keyframes are provided by the MotionKeyframes provider.\n\nAuto-dismiss: when `onClose` is provided and `duration > 0`, a timer calls\n`onClose` after `duration` ms. Setting `duration` to `0` disables auto-dismiss.\nThe timer resets if `visible`, `duration`, or `onClose` changes.\n\nDimensions: `min-width: 280px`, `max-width: 420px`.\nShadow: `var(--ggui-shape-shadow-lg)`.\nBorder radius: `var(--ggui-shape-radius-lg)`.\n\nWhen `visible` is false, renders nothing (returns `null`).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| message | `ReactNode` | - | Message body content. Can be text or any ReactNode. |\n| variant | `'info' \\| 'success' \\| 'warning' \\| 'error'` | `'info'` | Visual style. Maps to background/border/text/icon color sets (same palette as Alert): - `'info'` -- bg `var(--ggui-color-info-50)`, border `var(--ggui-color-info-200)`, text `var(--ggui-color-info-800)` - `'success'` -- bg `var(--ggui-color-success-50)`, border `var(--ggui-color-success-200)`, text `var(--ggui-color-success-800)` - `'warning'` -- bg `var(--ggui-color-warning-50)`, border `var(--ggui-color-warning-200)`, text `var(--ggui-color-warning-800)` - `'error'` -- bg `var(--ggui-color-error-50)`, border `var(--ggui-color-error-200)`, text `var(--ggui-color-error-800)` |\n| title | `string` | - | Optional title rendered above the message in semibold (`var(--ggui-font-weight-semibold)`, `var(--ggui-font-size-sm)`). |\n| duration | `number` | `5000` | Auto-dismiss delay in milliseconds. After this duration, `onClose` is called automatically. Set to `0` to disable auto-dismiss (toast stays until manually closed). The timer is only active when both `visible` is true and `onClose` is provided. |\n| onClose | `() => void` | - | Callback fired on auto-dismiss timeout or when the close button is clicked. When provided, a close button (X icon, 16px) is rendered in the top-right area. When omitted, no close button is shown and auto-dismiss is disabled. |\n| visible | `boolean` | `true` | Controls rendering. When false, the component returns `null`. Toggling from false to true triggers the slide-in animation. |\n| position | `'top-right' \\| 'top-left' \\| 'bottom-right' \\| 'bottom-left' \\| 'top-center' \\| 'bottom-center'` | - | Intended screen position. This prop is defined on the interface but is NOT implemented by the Toast component itself -- positioning must be handled by a parent container or toast manager. |\n\n**Example:**\n```tsx\n<Toast variant=\"success\" title=\"Saved\" message=\"Your changes have been saved.\" onClose={() => setShow(false)} />\n```\n\n### Accordion\n\nAccordion -- Collapsible content sections with chevron rotation animation.\n\nRenders a vertical list of items, each with a `<button>` header (inside `<h3>`)\nand a `<div role=\"region\">` panel. Supports controlled (`expandedKeys` + `onChange`)\nand uncontrolled (internal state) modes.\n\nChevron animation: the trailing chevron icon rotates from 0deg (collapsed) to\n180deg (expanded) with `transition: transform var(--ggui-motion-duration-base) var(--ggui-motion-easing-standard)` (ggui#1106).\n\nHeader button: full-width flex layout (`justify-content: space-between`),\n`var(--ggui-font-size-sm)`, `var(--ggui-font-weight-medium)`,\n`var(--ggui-color-onContainer)`.\nHeader padding: `var(--ggui-spacing-2) var(--ggui-spacing-4)`.\nBackground transition: `background-color var(--ggui-motion-duration-fast) var(--ggui-motion-easing-standard)`.\n\n**IMPORTANT:** `onChange` receives the full array of currently expanded keys,\nNOT a single key or a React event. In single mode (`multiple: false`), this\narray will have at most one element.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| items | `AccordionItem[]` | - | Array of collapsible section definitions. |\n| expandedKeys | `string[]` | - | Controlled expanded state. Array of item `key` values that should be open. When provided, the component is controlled and will not manage its own expansion state. When omitted, defaults to `[]` (all collapsed, uncontrolled mode). |\n| onChange | `(expandedKeys: string[]) => void` | - | Expand/collapse handler. Receives the complete array of expanded keys after a toggle. In controlled mode, you must update `expandedKeys` in response to this callback. |\n| multiple | `boolean` | `false` | When true, multiple items can be open simultaneously. When false, opening one item closes any other open item (single-expand mode). |\n| variant | `'default' \\| 'bordered' \\| 'separated'` | `'default'` | Visual style controlling borders and spacing: - `'default'` -- top border on first item, bottom border on all items (`var(--ggui-color-outlineVariant)`), no gap between items - `'bordered'` -- connected card style with left/right/bottom borders on all items, top border on first, shared rounded corners (radius-lg on first/last) - `'separated'` -- each item is an independent card with full border (`1px solid var(--ggui-color-outlineVariant)`), `var(--ggui-shape-radius-lg)` radius, `var(--ggui-spacing-2)` gap between items |\n\n**Example:**\n```tsx\n<Accordion\n variant=\"separated\"\n items={[\n { key: 'faq1', title: 'How do I get started?', content: 'Sign up and...' },\n { key: 'faq2', title: 'What is the pricing?', content: 'We offer...' },\n ]}\n expandedKeys={expanded}\n onChange={setExpanded}\n multiple\n/>\n```\n\n### Support Types\n\n**ResponsiveColumns:**\n\nExplicit column count per viewport breakpoint, mobile-first. `base`\napplies from 0 up; each named key overrides at and above its\nbreakpoint width (`sm` 640px, `md` 768px, `lg` 1024px, `xl` 1280px).\nOmit `base` to default to a single column on the narrowest screens.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| base | `number` | Columns below the `sm` breakpoint. |\n| sm | `number` | Columns from 640px up. |\n| md | `number` | Columns from 768px up. |\n| lg | `number` | Columns from 1024px up. |\n| xl | `number` | Columns from 1280px up. |\n\n**SelectOption:**\n\nAn individual option within a `Select` dropdown.\n\nRendered as a native `<option>` element. When `disabled` is true, the option\nis visible but not selectable.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| value | `string` | The value submitted when this option is selected. Must be unique within the options array. |\n| label | `string` | The display text shown in the dropdown. |\n| disabled | `boolean` | When true, the option is visible but cannot be selected (grayed out by the browser). |\n\n**RadioOption:**\n\nAn individual option within a `RadioGroup`.\n\nRendered as a `<label>` containing a visually-hidden `<input type=\"radio\">`\nand a custom 18px circle indicator. Supports an optional description line\nbelow the label text.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| value | `string` | The value emitted via `RadioGroupProps.onChange` when this option is selected. Must be unique. |\n| label | `string` | Display text for this option. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| description | `string` | Optional secondary description rendered below the label in smaller, muted type (`var(--ggui-font-size-xs)`, `var(--ggui-color-onSunken)`). |\n| disabled | `boolean` | When true, this individual option is grayed out (`opacity: 0.5`) and cannot be selected, regardless of the group-level `disabled` prop. |\n\n**TableColumn:**\n\nColumn definition for the Table component.\n\nEach column maps a `key` in the row data object to a table column with\na header label, optional sorting, alignment, width, and custom rendering.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| key | `string` | Property key in the row data object. Used to extract cell values via `row[key]`. Must be unique across all columns in a Table. |\n| header | `string` | Column header text. Rendered in uppercase, `var(--ggui-font-size-xs)`, `var(--ggui-font-weight-semibold)`, `var(--ggui-color-onSunken)`, with `letter-spacing: 0.05em`. |\n| width | `number \\| string` | Fixed column width. Numbers are pixels, strings are CSS values (e.g., `'200px'`, `'30%'`). When omitted, the column auto-sizes based on content. |\n| align | `'left' \\| 'center' \\| 'right'` | Horizontal text alignment for both the header and data cells. |\n| sortable | `boolean` | When true, the header cell becomes clickable and shows sort direction indicators (ascending/descending triangles). Clicking toggles between `'asc'` and `'desc'`. The header gets `cursor: pointer`, `tabIndex: 0`, and keyboard support (Enter/Space to toggle). |\n| render | `(value: unknown, row: T, index: number) => ReactNode` | Custom cell renderer. When provided, called instead of rendering `row[key]` directly. Receives the cell value, the full row object, and the row index. |\n\n**TabItem:**\n\nDefinition of a single tab within a Tabs component.\n\nEach item provides a unique `key` for identification, a `label` for the\ntab button, and `content` for the associated panel.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| key | `string` | Unique identifier for this tab. Used to match `activeKey` and as the value passed to `onChange`. |\n| label | `ReactNode` | Tab button label. Can be text or any ReactNode. |\n| content | `ReactNode` | Panel content rendered below the tab bar when this tab is active. |\n| disabled | `boolean` | When true, the tab button shows `opacity: 0.5`, `cursor: not-allowed`, and cannot be selected via click or keyboard navigation. |\n| icon | `ReactNode` | Optional icon rendered before the label inside the tab button, with `var(--ggui-spacing-1)` gap between icon and label. |\n\n**AccordionItem:**\n\nDefinition of a single collapsible section within an Accordion.\n\nEach item provides a unique `key`, a clickable `title` for the header button,\nand `content` revealed when expanded.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| key | `string` | Unique identifier for this item. Used in `expandedKeys` and passed to `onChange`. |\n| title | `ReactNode` | Header label rendered inside the toggle button. Can be text or any ReactNode. |\n| content | `ReactNode` | Panel content rendered below the header when expanded. Styled with `var(--ggui-font-size-sm)`, `var(--ggui-color-onSunken)`, `line-height: var(--ggui-font-lineHeight-normal)`. Padding: `0 var(--ggui-spacing-4) var(--ggui-spacing-4)`. |\n| disabled | `boolean` | When true, the header button shows `opacity: 0.5`, `cursor: not-allowed`, and cannot be toggled. |\n\n\n## Components\n\nImport: `import { Component } from '@ggui-ai/design'`\n\n### SearchField\n\nA text input with a leading search icon and optional submit button.\n\nComposes: `Input` (native `<input type=\"search\">`), `Button`, `Spinner`, `Icon`.\n\nSupports controlled and uncontrolled usage. When `value` is `undefined` the\ncomponent tracks its own state internally. Pressing **Enter** triggers\n`onSearch` with the current value. When `loading` is `true` the search icon\nis replaced by a `Spinner` and the input is disabled.\n\nTokens used: `colors.gray[300]` border, `colors.gray[50]` disabled bg,\n`colors.gray[400]` icon color, `colors.gray[900]` text color.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| value | `string` | - | Current search value. When provided, the component is **controlled** and the caller must update this via `onChange`. When omitted, the component manages its own internal state. |\n| onChange | `(value: string) => void` | - | Called on every keystroke with the new input string (value directly, not a React `ChangeEvent`). |\n| onSearch | `(value: string) => void` | - | Called when the user presses **Enter** or clicks the search button (if `showButton` is `true`). Receives the current value directly. |\n| placeholder | `string` | `'Search...'` | Placeholder text shown when the input is empty. |\n| showButton | `boolean` | `false` | When `true`, renders a `Button` primitive to the right of the input. The button's label is set by `buttonText`. |\n| buttonText | `string` | `'Search'` | Label rendered inside the submit button. Only visible when `showButton` is `true`. |\n| loading | `boolean` | - | When `true`, replaces the search icon with a `Spinner`, disables the input, and disables the submit button. |\n| disabled | `boolean` | - | When `true`, the input and button are visually disabled and do not respond to interaction. The input background changes to `colors.gray[50]`. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls input height and font size. - `'sm'` -- 6px vertical padding, 14px font - `'md'` -- 10px vertical padding, 14px font - `'lg'` -- 12px vertical padding, 16px font The Button size maps `sm`->`sm`, `md`->`md`, `lg`->`md`. |\n\n**Example:**\n```tsx\n```tsx\n<SearchField\n value={query}\n onChange={setQuery}\n onSearch={(q) => fetchResults(q)}\n placeholder=\"Search products...\"\n showButton\n buttonText=\"Go\"\n size=\"md\"\n/>\n```\n```\n\n### FormField\n\nA wrapper that adds a label, optional description, error message,\nand helper text around any form input passed as `children`.\n\nComposes: no other primitives -- pure layout with semantic `<label>` and\n`<span>` elements.\n\nVisual hierarchy (top to bottom):\n1. **Label** (required) -- `fontSize.sm`, `fontWeight.medium`, `colors.gray[700]`\n2. **Required indicator** -- red asterisk (`colors.error[500]`) appended to label\n3. **Description** -- `fontSize.xs`, `colors.gray[500]`, 4px bottom margin\n4. **Children** -- the form control itself\n5. **Error / Helper text** -- `fontSize.xs`; error in `colors.error[500]`,\n helper in `colors.gray[500]`. Error takes priority when both are provided.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Text rendered inside the `<label>` element above the input. |\n| children | `ReactNode` | - | The form control (typically an `Input`, `Select`, or `Textarea` primitive) rendered between the label/description and the error/helper row. |\n| error | `string` | - | Error message displayed below `children` in `colors.error[500]`. When present, it takes precedence over `helperText`. |\n| helperText | `string` | - | Neutral guidance text displayed below `children` in `colors.gray[500]`. Hidden when `error` is present. |\n| required | `boolean` | - | When `true`, appends a red asterisk (`*`) after the label text. Does **not** add any HTML validation attributes -- handle that on the child input. |\n| description | `string` | - | Secondary description rendered between the label and the child control in `fontSize.xs` / `colors.gray[500]`. Use for longer guidance that does not belong in `helperText`. |\n\n**Example:**\n```tsx\n```tsx\n<FormField\n label=\"Email address\"\n required\n description=\"We will never share your email.\"\n error={errors.email}\n>\n <Input value={email} onChange={setEmail} placeholder=\"you@example.com\" />\n</FormField>\n```\n```\n\n### MenuItem\n\nA full-width clickable row for menus, sidebars, and action lists.\n\nComposes: none -- renders a native `<button>` element.\n\nBuilt-in transition: `background-color` over `var(--ggui-motion-duration-fast)` / `var(--ggui-motion-easing-standard)` on hover (ggui#1106).\n\nColor logic:\n- **Normal**: text `colors.gray[700]`, hover bg `colors.gray[100]`\n- **Active**: bg `colors.primary[50]`, text `colors.gray[700]`, `fontWeight.medium`\n- **Danger**: text `colors.error[600]`, hover bg `colors.error[50]`, active bg `colors.error[100]`\n- **Disabled**: text `colors.gray[400]`, `cursor: not-allowed`\n\nLayout: flexbox row with `8px` gap, `8px 12px` padding, `radius.md` border-radius.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Primary text content of the menu item. |\n| icon | `ReactNode` | - | Icon or element rendered to the left of the label. Flex-shrink 0. |\n| rightElement | `ReactNode` | - | Element rendered to the right of the label (e.g., a keyboard shortcut badge or a count). Colored `colors.gray[400]`, flex-shrink 0. |\n| onClick | `() => void` | - | Called when the item is clicked. Suppressed when `disabled` is `true`. |\n| disabled | `boolean` | - | When `true`, the item is non-interactive: `colors.gray[400]` text, `cursor: not-allowed`, click handler suppressed. |\n| active | `boolean` | - | Marks this item as the current selection. Applies a tinted background (`colors.primary[50]`, or `colors.error[100]` when `danger` is also set) and `fontWeight.medium`. |\n| danger | `boolean` | - | Switches the item to destructive styling: `colors.error[600]` text, `colors.error[50]` hover background. |\n\n**Example:**\n```tsx\n```tsx\n<MenuItem\n label=\"Delete project\"\n icon={<Icon name=\"trash\" size={16} />}\n danger\n onClick={() => confirmDelete(projectId)}\n/>\n```\n```\n\n### Tag\n\nAn inline label for categories, filters, statuses, or selections.\nOptionally dismissable via a close button.\n\nComposes: none -- pure `<span>` with an optional close `<button>`.\n\nEach `variant` maps to a background / text / border color triple from the\ndesign tokens:\n- `'default'` -- `gray[100]` / `gray[700]` / `gray[200]`\n- `'primary'` -- `primary[50]` / `primary[700]` / `primary[200]`\n- `'success'` -- `success[50]` / `success[700]` / `success[200]`\n- `'warning'` -- `warning[50]` / `warning[700]` / `warning[200]`\n- `'error'` -- `error[50]` / `error[700]` / `error[200]`\n- `'info'` -- `info[50]` / `info[700]` / `info[200]`\n\nThe close button renders an inline SVG \"x\" icon (12x12) with `opacity: 0.7`\nand an `aria-label=\"Remove\"`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | Tag content -- typically a short text string. |\n| variant | `'default' \\| 'primary' \\| 'success' \\| 'warning' \\| 'error' \\| 'info'` | `'default'` | Semantic color variant applied to background, text, and border. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls padding, font size, and internal gap. - `'sm'` -- 2px/6px padding, `fontSize.xs`, 4px gap - `'md'` -- 4px/8px padding, `fontSize.xs`, 6px gap - `'lg'` -- 6px/10px padding, `fontSize.sm`, 6px gap |\n| closable | `boolean` | - | When `true`, renders a small close (\"x\") button after the content. Clicking it fires `onClose`. |\n| onClose | `() => void` | - | Called when the close button is clicked. Only relevant when `closable` is `true`. |\n| icon | `ReactNode` | - | Icon or element rendered before the text content. |\n\n**Example:**\n```tsx\n```tsx\n<Tag variant=\"success\" size=\"md\" closable onClose={() => removeFilter(id)}>\n Active\n</Tag>\n```\n```\n\n### Dropdown\n\nA click-triggered menu anchored to a trigger element. Manages its own\nopen/close state internally.\n\nComposes: `MenuItem` for each option.\n\nBehavior:\n- Clicking the trigger toggles the menu open/closed.\n- Selecting an option calls `onChange(option.value)` and closes the menu.\n- Clicking outside the container or pressing **Escape** closes the menu.\n- The currently selected option (matching `value`) is rendered with\n `MenuItem`'s `active` state.\n\nMenu panel: `colors.white` bg, `colors.gray[200]` border, `radius.lg`\nborder-radius, `shadow.lg`, `zIndex.dropdown`, 160px min-width, 4px padding.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| trigger | `ReactNode` | - | The element the user clicks to open the menu. Receives a wrapping `<div>` with `cursor: pointer` (or `not-allowed` when disabled). |\n| options | `DropdownOption[]` | - | Array of selectable options rendered as `MenuItem` rows. |\n| value | `string` | - | The `value` of the currently selected option. The matching `MenuItem` is rendered with `active` styling. |\n| onChange | `(value: string) => void` | - | Called with the `value` string of the selected option (not the full `DropdownOption` object). The menu closes immediately after. |\n| placement | `'bottom-start' \\| 'bottom-end' \\| 'top-start' \\| 'top-end'` | `'bottom-start'` | Where to anchor the menu panel relative to the trigger. - `'bottom-start'` -- below, aligned to left edge - `'bottom-end'` -- below, aligned to right edge - `'top-start'` -- above, aligned to left edge - `'top-end'` -- above, aligned to right edge |\n| disabled | `boolean` | - | When `true`, the trigger shows `cursor: not-allowed` and clicking it does not open the menu. |\n\n**Example:**\n```tsx\n```tsx\n<Dropdown\n trigger={<Button variant=\"outline\">Sort by</Button>}\n options={[\n { value: 'name', label: 'Name' },\n { value: 'date', label: 'Date created' },\n { value: 'delete', label: 'Delete', danger: true },\n ]}\n value={sortBy}\n onChange={setSortBy}\n placement=\"bottom-end\"\n/>\n```\n```\n\n### Autocomplete\n\nA text input with a filterable suggestion dropdown, keyboard navigation,\nand loading/empty states.\n\nComposes: `Input` primitive (with `label`, `error`, `placeholder` forwarded),\n`Spinner` (loading state).\n\nFiltering: options are filtered client-side by case-insensitive substring\nmatch against both `option.label` and `option.value`.\n\nKeyboard support:\n- **ArrowDown / ArrowUp** -- move highlight through filtered options\n (opens dropdown if closed)\n- **Enter** -- selects the highlighted option\n- **Escape** -- closes the dropdown\n\nOn selection, `onChange` is called with `option.label` (the display text)\nand `onSelect` is called with the full `AutocompleteOption` object.\n\nDropdown panel: `colors.white` bg, `colors.gray[200]` border, `radius.lg`\nborder-radius, `shadow.lg`, `zIndex.dropdown`, max-height 240px with\noverflow scroll.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| value | `string` | `''` | Current text in the input field. On selection, this is set to the selected option's `label`. |\n| onChange | `(value: string) => void` | - | Called on every keystroke with the new input string (value directly, not a React event). Also called on selection with `option.label`. |\n| onSelect | `(option: AutocompleteOption) => void` | - | Called when the user selects an option (click or Enter on highlighted item). Receives the full `AutocompleteOption` object, not just the value string. |\n| options | `AutocompleteOption[]` | - | The full list of available options. Filtering is handled internally via case-insensitive substring match on `label` and `value`. |\n| placeholder | `string` | - | Placeholder text forwarded to the inner `Input` primitive. |\n| label | `string` | - | Label text forwarded to the inner `Input` primitive. |\n| loading | `boolean` | - | When `true`, the dropdown shows a centered `Spinner` instead of the option list. The input remains interactive. |\n| disabled | `boolean` | - | When `true`, the inner `Input` is disabled and the dropdown does not open. |\n| error | `string` | - | Error message forwarded to the inner `Input` primitive. |\n| noResultsText | `string` | `'No results found'` | Text shown in the dropdown when filtering produces zero matches. Rendered centered in `colors.gray[500]` / `fontSize.sm`. |\n\n**Example:**\n```tsx\n```tsx\n<Autocomplete\n label=\"Country\"\n value={country}\n onChange={setCountry}\n onSelect={(opt) => setCountryCode(opt.value)}\n options={countries}\n placeholder=\"Type to search...\"\n noResultsText=\"No countries found\"\n/>\n```\n```\n\n### Breadcrumb\n\nA horizontal navigation trail showing the user's location within a\nhierarchy. Renders a `<nav aria-label=\"Breadcrumb\">`.\n\nComposes: `Link` primitive (for items with `href`).\n\nRendering rules per item:\n- **Last item**: static `<span>` with `colors.gray[900]`, `fontWeight: 500`,\n and `aria-current=\"page\"`.\n- **Non-last with `href`**: `Link` in `colors.gray[500]` with\n `underline=\"hover\"`. If `onItemClick` is provided, `e.preventDefault()`\n is called and the handler fires instead of navigating.\n- **Non-last without `href`**: unstyled `<button>` in `colors.gray[500]`.\n\nLayout: flexbox row, `8px` gap, `fontSize.sm`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| items | `BreadcrumbItem[]` | - | Ordered array of breadcrumb segments from root to current page. |\n| separator | `ReactNode` | `'/'` | Separator rendered between each pair of items. Can be a string (e.g., `\"/\"`, `\">\"`) or a ReactNode (e.g., an `Icon`). Rendered in `colors.gray[400]`. |\n| onItemClick | `(item: BreadcrumbItem, index: number) => void` | - | Called when a non-last item is clicked. Receives the `BreadcrumbItem` and its zero-based `index`. When provided on items that have `href`, the default navigation is prevented via `e.preventDefault()`. |\n\n**Example:**\n```tsx\n```tsx\n<Breadcrumb\n items={[\n { label: 'Home', href: '/' },\n { label: 'Projects', href: '/projects' },\n { label: 'ggui' },\n ]}\n separator=\"/\"\n onItemClick={(item) => router.push(item.href!)}\n/>\n```\n```\n\n### Pagination\n\nPage navigation controls with previous/next arrows, numbered page buttons,\nand optional first/last jumps. Renders a `<nav aria-label=\"Pagination\">`.\n\nComposes: `Button` (ghost variant for prev/next/first/last arrows),\n`Icon` (`chevron-left`, `chevron-right`).\n\nBuilt-in transition: `all` over `var(--ggui-motion-duration-fast)` / `var(--ggui-motion-easing-standard)` on page number buttons (ggui#1106).\n\nPage windowing: when `totalPages > maxVisible`, the component shows the\nfirst page, last page, a window of pages around `currentPage`, and\nellipsis (\"...\") markers for gaps. The window adjusts when near the\nstart or end of the range.\n\nActive page button: `colors.primary[600]` bg, `colors.white` text,\n`fontWeight.medium`. Inactive: transparent bg, `colors.gray[700]` text.\n\nArrow buttons are automatically disabled at boundary pages (first/last).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| currentPage | `number` | - | Current active page. **1-indexed** (first page is `1`). |\n| totalPages | `number` | - | Total number of pages. Determines when last-page / next-page buttons disable. |\n| onPageChange | `(page: number) => void` | - | Called when the user clicks a page number, arrow, or first/last button. Receives the target page number (1-indexed) directly. |\n| showFirstLast | `boolean` | `true` | When `true`, renders double-chevron buttons for jumping to the first and last page. These buttons are disabled when already on the respective boundary. |\n| maxVisible | `number` | `5` | Maximum number of page buttons visible at once (including the first and last page, but excluding ellipsis markers). When `totalPages` exceeds this value, ellipsis gaps appear. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls button dimensions and icon sizes. - `'sm'` -- 28px buttons, 14px icons, Button size `xs` - `'md'` -- 32px buttons, 16px icons, Button size `sm` - `'lg'` -- 40px buttons, 20px icons, Button size `md` |\n| disabled | `boolean` | - | When `true`, all page buttons and arrows are visually dimmed (`opacity: 0.5`) and clicks are suppressed. |\n\n**Example:**\n```tsx\n```tsx\n<Pagination\n currentPage={page}\n totalPages={20}\n onPageChange={setPage}\n maxVisible={7}\n size=\"md\"\n/>\n```\n```\n\n### EmptyState\n\nEmptyState -- placeholder for a region with no data: empty lists,\nzero search results, an error fallback. Render it instead of\nnothing whenever a data array could be empty \u2014 a list that shows\nnothing when empty looks broken.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| icon | `string \\| ReactNode` | - | A Lucide icon name (kebab-case), rendered large and subtle above the title, or a custom node. Omit for a text-only empty state. |\n| title | `string` | - | The headline, e.g. \"No results found\". |\n| description | `string` | - | Optional supporting line below the title. |\n| action | `ReactNode` | - | Optional call-to-action, typically a `<Button>`. |\n\n**Example:**\n```tsx\n{results.length === 0\n ? <EmptyState icon=\"search-x\" title=\"No matches\" description=\"Try a broader query.\" />\n : results.map((r) => <Row key={r.id}>\u2026</Row>)}\n```\n\n### Stat\n\nStat -- a single KPI / metric: a label, a large value, an optional\ntrend-coloured delta and icon. Reach for it whenever the UI is\n\"show a number\" \u2014 dashboards, weather and price cards, analytics\ntiles. Drop several into a `<Grid>` for a stat grid.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | The metric name, e.g. \"Revenue\". Rendered small and uppercase above the value. |\n| value | `string \\| number` | - | The headline value \u2014 the big number. A number, or a pre-formatted string (`\"$48.2k\"`, `\"18\xB0C\"`). |\n| delta | `string` | - | Optional change indicator, pre-formatted, e.g. `\"+12.5%\"` or `\"-3\"`. |\n| trend | `'up' \\| 'down' \\| 'neutral'` | `'neutral'` | Direction of `delta` \u2014 colours it: `'up'` success, `'down'` error, `'neutral'` muted. |\n| icon | `string \\| ReactNode` | - | Optional Lucide icon name (kebab-case) or custom node, shown next to the label. |\n\n**Example:**\n```tsx\n<Stat label=\"Revenue\" value=\"$48.2k\" delta=\"+12.5%\" trend=\"up\" icon=\"trending-up\" />\n```\n\n### Stepper\n\nStepper -- a display-only step indicator for multi-step flows\n(wizards, checkouts, onboarding): numbered markers joined by\nconnector lines, with completed (check), current (filled), and\nupcoming (outlined) states styled distinctly.\n\nStepper renders step state; it never owns it. Declare the step\nlabels as a top-level `const`, track the current index in\n`useState`, and advance it from your own Next/Back buttons (or\n`onStepClick`).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| steps | `string[]` | - | Ordered step labels, one per step. Declare as a top-level `const` array \u2014 never as state. |\n| current | `number` | - | Zero-based index of the active step. Steps before it render as completed (check mark), steps after it as upcoming. |\n| orientation | `'horizontal' \\| 'vertical'` | `'horizontal'` | Flow direction. `'horizontal'` joins the markers with connector lines in a row; `'vertical'` stacks the steps with short connector columns \u2014 better for sidebars or long step labels. |\n| onStepClick | `(index: number) => void` | - | When provided, each step becomes a real button and clicks receive the step's zero-based index. Typical use: jumping back to a completed step; guard forward jumps in the handler if the flow validates per step. |\n\n**Example:**\n```tsx\nconst STEPS = ['Account', 'Profile', 'Review'];\n// In the component: const [step, setStep] = useState(0);\n<Stack gap=\"lg\">\n <Stepper steps={STEPS} current={step} />\n {step === 0 && <AccountFields />}\n <Row gap=\"sm\" justify=\"end\">\n <Button variant=\"ghost\" disabled={step === 0} onClick={() => setStep(step - 1)}>Back</Button>\n <Button onClick={() => setStep(step + 1)}>Next</Button>\n </Row>\n</Stack>\n```\n\n### Markdown\n\nMarkdown -- renders markdown-formatted text as styled content:\nheadings, paragraphs, **bold** / *italic*, `inline code`, fenced\ncode blocks, bulleted and numbered lists, pipe tables, and links.\n\nUse it whenever a prop or context slot carries prose the agent\nauthors in markdown -- article bodies, AI-generated explanations,\ndescriptions, notes, help text. Pass the raw markdown string\nstraight through; never pre-parse it, never split it into per-line\n`Text` elements, and never rebuild formatting with nested\n`<strong>`/`<em>` markup yourself.\n\nSafe by construction: raw HTML in the source displays as literal\ntext (never markup), and links render inert unless their scheme is\nhttp(s)/mailto. Streaming-tolerant: a mid-stream prefix parses\ncleanly and completed constructs never change shape, so binding a\ngrowing string re-renders correctly.\n\nFor a single line of formatted text INSIDE another element (a table\ncell, a list row, a caption), use `MarkdownInline` instead -- same\n`markdown` prop, inline-level constructs only (no headings, lists,\nor code fences), renders inside the parent's typography.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| markdown | `string` | - | Markdown source text, passed through verbatim. Supports the conversational subset: `#`-`###` headings, paragraphs, bold, italic, inline code, fenced code blocks, `-`/`1.` lists, `\\|` pipe tables, and `[label](url)` links. Bind stateful or streamed prose directly. |\n\n**Example:**\n```tsx\n<Stack gap=\"md\">\n <Markdown markdown={recipe.instructions} />\n</Stack>\n```\n\n### MarkdownInline\n\nMarkdownInline -- renders ONE line of inline-level markdown (bold,\nitalic, inline code, links) inside the parent's typography -- a table\ncell, a list row, a caption, a summary line. Same `markdown` prop as\n`Markdown`; block constructs (headings, lists, code fences) are not\nrendered -- reach for `Markdown` for multi-paragraph bodies.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| markdown | `string` | - | Inline markdown source -- bold, italic, inline code, and `[label](url)` links. Block-level syntax renders as plain text. |\n\n**Example:**\n```tsx\n<Row gap=\"sm\">\n <Badge variant=\"success\">stable</Badge>\n <MarkdownInline markdown={release.summary} />\n</Row>\n```\n\n### Support Types\n\n**DropdownOption:**\n\nA single option inside a `Dropdown`. Each option maps to one `MenuItem`\ninternally.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| value | `string` | Unique identifier returned to `onChange` when this option is selected. |\n| label | `string` | Human-readable text shown in the menu row. |\n| icon | `ReactNode` | Optional icon rendered to the left of the label via `MenuItem.icon`. |\n| disabled | `boolean` | When `true`, the option is visible but non-interactive. |\n| danger | `boolean` | When `true`, the option uses destructive (red) styling via `MenuItem.danger`. |\n\n**AutocompleteOption:**\n\nA single option in the `Autocomplete` suggestion list.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| value | `string` | Unique identifier for this option. Also used for case-insensitive filtering against the input value. |\n| label | `string` | Primary display text. Also used for case-insensitive filtering and is written into the input on selection. |\n| description | `string` | Secondary description rendered below the label in `fontSize.xs` / `colors.gray[500]`. |\n| icon | `ReactNode` | Icon rendered to the left of the label/description block. Flex-shrink 0. |\n| disabled | `boolean` | When `true`, the option is visible but non-interactive (`cursor: not-allowed`, `colors.gray[400]` text). |\n\n**BreadcrumbItem:**\n\nA single segment in a `Breadcrumb` trail. Items with `href` render as\n`Link` primitives; items without render as plain `<button>` elements.\nThe last item in the array is always rendered as static text with\n`aria-current=\"page\"`.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| label | `string` | Display text for this breadcrumb segment. |\n| href | `string` | URL for this segment. When provided (and this is not the last item), the segment renders as a `Link` primitive with `underline=\"hover\"`. When omitted, it renders as a `<button>`. |\n| icon | `ReactNode` | Icon rendered immediately before the label. Its color follows the segment's text color: `colors.gray[500]` for navigable items, `colors.gray[900]` for the current (last) item. |\n\n\n## Compositions\n\nImport: `import { Component } from '@ggui-ai/design'`\n\n### Header\n\nProps for the `Header` composition.\n\nA horizontal page header that arranges a logo, navigation, and action slots\nin a flex row (`justify-content: space-between`). Internally renders a `<header>` element;\ndoes not compose other ggui primitives.\n\nWhen `sticky` is true the header gets `position: sticky; top: 0` with `zIndex.sticky`\nand a `shadow.sm` box-shadow.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| logo | `ReactNode` | - | Logo or brand element rendered at the start (flex-shrink: 0). |\n| navigation | `ReactNode` | - | Navigation content rendered in a `<nav>` element with `flex: 1` and a 32 px left margin. |\n| actions | `ReactNode` | - | Right-side action elements (buttons, avatar, etc.) rendered with a 12 px gap. |\n| sticky | `boolean` | `false` | When true, the header becomes `position: sticky` at the top of its scroll container with `zIndex.sticky` and `shadow.sm`. |\n| background | `string` | `colors.white` | Background color of the header. |\n| bordered | `boolean` | `true` | When true, renders a 1 px bottom border in `colors.gray[200]`. |\n\n**Example:**\n```tsx\n```tsx\n<Header\n logo={<img src=\"/logo.svg\" alt=\"Acme\" />}\n navigation={<a href=\"/docs\">Docs</a>}\n actions={<Button size=\"sm\">Sign In</Button>}\n sticky\n bordered\n/>\n```\n```\n\n### Sidebar\n\nProps for the `Sidebar` composition.\n\nA vertical navigation panel that composes the `Icon` primitive for chevron indicators.\nItems are rendered as `<button>` elements inside a scrollable `<nav>`. Nested items\nare indented 16 px per depth level. The sidebar animates width changes with a 200 ms\nCSS transition. Active items are highlighted with `colors.primary[50]` background\nand `colors.primary[700]` text.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| items | `SidebarItem[]` | - | Array of navigation items to render. |\n| activeId | `string` | - | ID of the currently active item. Matched items get a highlighted background and bold text. |\n| onItemClick | `(item: SidebarItem) => void` | - | Called when any item (including parent items with children) is clicked. |\n| collapsed | `boolean` | `false` | When true, hides labels and badges; only icons remain visible, centered in the collapsed width. Nested children are hidden entirely. |\n| header | `ReactNode` | - | Content rendered above the item list, separated by a bottom border. |\n| footer | `ReactNode` | - | Content rendered below the item list, separated by a top border. |\n| width | `number` | `256` | Width in pixels when expanded. |\n| collapsedWidth | `number` | `64` | Width in pixels when collapsed. |\n\n**Example:**\n```tsx\n```tsx\n<Sidebar\n items={[\n { id: 'home', label: 'Home', icon: <Icon name=\"home\" /> },\n { id: 'settings', label: 'Settings', icon: <Icon name=\"settings\" />,\n children: [\n { id: 'profile', label: 'Profile' },\n { id: 'billing', label: 'Billing' },\n ]},\n ]}\n activeId=\"home\"\n onItemClick={(item) => navigate(item.href)}\n collapsed={false}\n width={256}\n/>\n```\n```\n\n### CardGrid\n\nProps for the `CardGrid` composition.\n\nA CSS Grid wrapper that arranges children in equal-width columns. When `columns`\nis a number, it produces `repeat(N, 1fr)`. When it is a responsive object,\nit falls back to `repeat(auto-fit, minmax(280px, 1fr))` for fluid responsive behavior.\n\nDoes not compose any ggui primitives internally \u2014 it is a pure layout container.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | Card elements to arrange in the grid. |\n| columns | `number \\| { sm?: number; md?: number; lg?: number }` | `3` | Number of columns, or a responsive breakpoint map. - `number` \u2014 fixed column count via `repeat(N, 1fr)`. - `{ sm?, md?, lg? }` \u2014 triggers `repeat(auto-fit, minmax(280px, 1fr))` for fluid layout. |\n| gap | `number` | `16` | Gap between grid items in pixels. |\n\n**Example:**\n```tsx\n```tsx\n<CardGrid columns={3} gap={24}>\n <Card>A</Card>\n <Card>B</Card>\n <Card>C</Card>\n</CardGrid>\n```\n```\n\n### CommentThread\n\nProps for the `CommentThread` composition.\n\nA threaded comment section that composes `Avatar`, `Button`, `TextArea`, and `Spinner`\nprimitives. Comments are rendered recursively with 40 px indentation per nesting level.\nEach comment shows author avatar, name, timestamp, content, reactions, and a \"Reply\" toggle.\nWhen `currentUser` is provided, a new-comment input with avatar and submit button is shown\nabove the thread.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| comments | `Comment[]` | - | Array of top-level comments to render. Replies are nested within each comment. |\n| currentUser | `{ /** Display name for the current user's avatar. */ name: string; /** Avatar image URL for the current user. */ avatar?: string; }` | - | Current user info. When provided, a new-comment input area is rendered above the thread. |\n| onAddComment | `(content: string, parentId?: string) => void` | - | Called when the user submits a new top-level comment. |\n| onReply | `(commentId: string, content: string) => void` | - | Called when the user submits a reply to an existing comment. |\n| onReaction | `(commentId: string, emoji: string) => void` | - | Called when the user clicks an emoji reaction on a comment. |\n| loading | `boolean` | - | When true, shows a centered `Spinner` instead of the comment list. |\n\n**Example:**\n```tsx\n```tsx\n<CommentThread\n comments={[\n { id: '1', author: { name: 'Alice' }, content: 'Great work!', timestamp: new Date(),\n replies: [{ id: '2', author: { name: 'Bob' }, content: 'Thanks!', timestamp: new Date() }] }\n ]}\n currentUser={{ name: 'Alice', avatar: '/alice.jpg' }}\n onAddComment={(content) => post(content)}\n onReply={(commentId, content) => reply(commentId, content)}\n onReaction={(commentId, emoji) => react(commentId, emoji)}\n/>\n```\n```\n\n### DataTable\n\nProps for the `DataTable` composition.\n\nA sortable, selectable data table that composes `Checkbox`, `Spinner`, and `Icon`\nprimitives. Renders a `<table>` inside a bordered container with 8 px border-radius.\nThe header row has a `colors.gray[50]` background. Sortable columns show a chevron\nicon on click (toggles asc/desc). Selected rows are highlighted with `colors.primary[50]`.\nRow background transitions use a 150 ms ease. The \"select all\" checkbox supports an\nindeterminate state when a subset of rows is selected.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| columns | `DataTableColumn<T>[]` | - | Column definitions that control header, alignment, sorting, and rendering. |\n| data | `T[]` | - | Row data array. Each entry corresponds to one table row. |\n| rowKey | `string \\| ((row: T) => string)` | `'id'` | Property name or function used to derive a unique key for each row. |\n| loading | `boolean` | - | When true, shows a centered `Spinner` instead of table body rows. |\n| emptyText | `string` | `'No data'` | Text shown when `data` is empty and not loading. |\n| onSort | `(key: string, direction: 'asc' \\| 'desc') => void` | - | Called when a sortable column header is clicked. Toggles direction automatically (asc -> desc) if the same column is clicked again. |\n| sortKey | `string` | - | The column key currently being sorted. |\n| sortDirection | `'asc' \\| 'desc'` | - | The current sort direction. |\n| onRowClick | `(row: T, index: number) => void` | - | Called when a row is clicked. Rows get `cursor: pointer` when this handler is provided. |\n| selectable | `boolean` | - | When true, adds a checkbox column at the start of each row. |\n| selectedKeys | `string[]` | `[]` | Array of currently selected row keys. |\n| onSelectionChange | `(keys: string[]) => void` | - | Called when the set of selected row keys changes (via row checkbox or select-all). |\n\n**Example:**\n```tsx\n```tsx\n<DataTable\n columns={[\n { key: 'name', header: 'Name', sortable: true },\n { key: 'email', header: 'Email' },\n { key: 'role', header: 'Role', render: (v) => <Badge>{v}</Badge> },\n ]}\n data={users}\n rowKey=\"id\"\n selectable\n selectedKeys={selected}\n onSelectionChange={setSelected}\n onSort={(key, dir) => sort(key, dir)}\n sortKey=\"name\"\n sortDirection=\"asc\"\n/>\n```\n```\n\n### ChatWindow\n\nProps for the `ChatWindow` composition.\n\nA messaging interface that composes `Avatar`, `Button`, `Spinner`, and `Icon` primitives.\nLayout is a flex column filling 100% height with a bordered `radius.lg` container.\nMessages from the current user align right with `colors.primary[600]` bubbles and white text.\nOther users' messages align left with `colors.gray[100]` bubbles. The message area\nauto-scrolls to the bottom on new messages via `scrollIntoView({ behavior: 'smooth' })`.\nThe text input sends on Enter (Shift+Enter for newline).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| messages | `ChatMessage[]` | - | Array of chat messages to display in chronological order. |\n| currentUserId | `string` | - | ID of the current user. Messages from this user render right-aligned with a primary color bubble. |\n| onSendMessage | `(content: string) => void` | - | Called when the user submits a message (Enter key or send button). |\n| loading | `boolean` | - | When true, shows a centered `Spinner` instead of the message list. |\n| typing | `{ name: string } \\| null` | - | When non-null, displays a typing indicator below the last message (e.g., \"Alice is typing...\"). |\n| placeholder | `string` | `'Type a message...'` | Placeholder text for the message input field. |\n| header | `ReactNode` | - | Optional header content rendered above the message area, separated by a bottom border. |\n\n**Example:**\n```tsx\n```tsx\n<ChatWindow\n messages={messages}\n currentUserId=\"user-1\"\n onSendMessage={(content) => send(content)}\n typing={{ name: 'Alice' }}\n placeholder=\"Type a message...\"\n header={<h3>Chat with Alice</h3>}\n/>\n```\n```\n\n### NavigationBar\n\nProps for the `NavigationBar` composition.\n\nA horizontal or vertical navigation menu. Does not compose other ggui primitives\n(uses plain `<button>` and `<a>` elements). Active items are styled per variant:\n\n- `'default'` \u2014 active item gets `colors.primary[600]` text and medium font weight.\n- `'pills'` \u2014 active item gets a `radius.full` pill with `colors.primary[100]` background.\n- `'underline'` \u2014 active item gets a 2 px bottom border in `colors.primary[600]`.\n When horizontal, the entire nav also has a 1 px bottom border.\n\nAll items have a 150 ms transition on all properties.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| items | `NavItem[]` | - | Array of navigation items to render. |\n| activeId | `string` | - | ID of the currently active item. Controls visual highlighting per variant style. |\n| onItemClick | `(item: NavItem) => void` | - | Called when a non-disabled item is clicked. For `<a>` elements, `preventDefault` is called first. |\n| orientation | `'horizontal' \\| 'vertical'` | `'horizontal'` | Layout direction. - `'horizontal'` \u2014 flex-row with 4 px gap. - `'vertical'` \u2014 flex-column with 2 px gap. |\n| variant | `'default' \\| 'pills' \\| 'underline'` | `'default'` | Visual style for active/inactive items. - `'default'` \u2014 text color change only. - `'pills'` \u2014 rounded pill background on active items. - `'underline'` \u2014 bottom border on active items. |\n\n**Example:**\n```tsx\n```tsx\n<NavigationBar\n items={[\n { id: 'home', label: 'Home', icon: <Icon name=\"home\" /> },\n { id: 'about', label: 'About' },\n { id: 'contact', label: 'Contact' },\n ]}\n activeId=\"home\"\n onItemClick={(item) => navigate(item.id)}\n orientation=\"horizontal\"\n variant=\"pills\"\n/>\n```\n```\n\n### FileUploader\n\nProps for the `FileUploader` composition.\n\nA drag-and-drop file upload area that composes `Button`, `Progress`, and `Icon` primitives.\nThe drop zone is a dashed-border container that highlights in `colors.primary[400]`/`colors.primary[50]`\non drag-over. Below the drop zone, each file in `files` is listed with its name, size,\noptional progress bar (for uploading status), and a remove button.\n\nFile validation (max size, max count) is applied client-side before calling `onFilesSelected`.\nFiles exceeding `maxSize` are silently filtered out; files exceeding `maxFiles` are truncated.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| files | `UploadedFile[]` | `[]` | Array of files currently in the upload queue, shown below the drop zone. |\n| onFilesSelected | `(files: File[]) => void` | - | Called when the user selects files via click or drag-and-drop. Receives native `File` objects after client-side filtering (maxSize, maxFiles). |\n| onFileRemove | `(fileId: string) => void` | - | Called when the user clicks the remove button on a file entry. |\n| accept | `string` | - | Accepted file types passed to the hidden `<input type=\"file\" accept=\"...\">`. E.g., `'image/*,.pdf'`. |\n| multiple | `boolean` | `true` | When true, allows selecting multiple files at once. |\n| maxSize | `number` | - | Maximum file size in bytes. Files exceeding this are silently excluded from the selection. |\n| maxFiles | `number` | - | Maximum number of files. Excess files beyond `maxFiles - files.length` are truncated. |\n| disabled | `boolean` | - | When true, the drop zone is visually dimmed (opacity 0.5) and non-interactive. |\n| dragDrop | `boolean` | `true` | When true, enables drag-and-drop on the drop zone. When false, the prompt text changes to \"Click to browse files\". |\n\n**Example:**\n```tsx\n```tsx\n<FileUploader\n files={uploadedFiles}\n onFilesSelected={(files) => startUpload(files)}\n onFileRemove={(id) => removeFile(id)}\n accept=\"image/*,.pdf\"\n multiple\n maxSize={5 * 1024 * 1024}\n maxFiles={10}\n dragDrop\n/>\n```\n```\n\n### NotificationCenter\n\nProps for the `NotificationCenter` composition.\n\nA notification list with a header bar that composes `Button`, `Spinner`, and `Icon`\nprimitives. The header shows a \"Notifications\" title, an unread count badge\n(pill in `colors.primary[100]`), and \"Mark all read\" / \"Clear all\" buttons.\nEach notification item shows a colored status dot, title, message, timestamp,\n\"Mark as read\" link, and optional action. The list is scrollable (`overflowY: auto`).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| notifications | `Notification[]` | - | Array of notifications to display, in the order provided. |\n| onMarkAsRead | `(id: string) => void` | - | Called when the user clicks \"Mark as read\" on an individual notification. |\n| onMarkAllAsRead | `() => void` | - | Called when the user clicks the \"Mark all read\" header button. Only shown when there are unread items. |\n| onDismiss | `(id: string) => void` | - | Called when the user clicks the dismiss (X) button on an individual notification. |\n| onClearAll | `() => void` | - | Called when the user clicks the \"Clear all\" header button. Only shown when there are any notifications. |\n| emptyText | `string` | `'No notifications'` | Text shown when `notifications` is empty and not loading. |\n| loading | `boolean` | - | When true, shows a centered `Spinner` instead of the notification list. |\n\n**Example:**\n```tsx\n```tsx\n<NotificationCenter\n notifications={[\n { id: '1', title: 'Deployment complete', type: 'success', timestamp: new Date(), read: false },\n { id: '2', title: 'Build failed', type: 'error', message: 'Lint errors', timestamp: new Date() },\n ]}\n onMarkAsRead={(id) => markRead(id)}\n onMarkAllAsRead={() => markAllRead()}\n onDismiss={(id) => dismiss(id)}\n onClearAll={() => clearAll()}\n/>\n```\n```\n\n### Modal\n\nProps for the `Modal` composition.\n\nA dialog overlay that composes `Button`, `Icon`, and `Heading` primitives.\nThe overlay uses `animation.fadeIn` (`ggui-fadeIn`) and the dialog panel uses\n`animation.scaleIn` (`ggui-scaleIn`) \u2014 both GPU-composited (opacity + transform).\nWhen open, `document.body.style.overflow` is set to `'hidden'` to prevent background\nscrolling, and restored on close. The dialog has `role=\"dialog\"` and `aria-modal=\"true\"`.\n\nSize widths: `sm` = 400 px, `md` = 500 px, `lg` = 640 px, `xl` = 800 px,\n`full` = 100vw (no border-radius, no padding).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| open | `boolean` | - | Controls modal visibility. When false, the component renders nothing. |\n| onClose | `() => void` | - | Called to close the modal (overlay click, escape key, or close button). |\n| title | `string` | - | Optional title rendered in the modal header via the `Heading` primitive (level 4). |\n| children | `ReactNode` | - | Modal body content rendered in a scrollable area (`overflowY: auto`). |\n| footer | `ReactNode` | - | Footer content rendered below the body, right-aligned with an 8 px gap, separated by a top border. |\n| size | `'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| 'full'` | `'md'` | Controls the width of the modal panel. - `'sm'` \u2014 400 px - `'md'` \u2014 500 px - `'lg'` \u2014 640 px - `'xl'` \u2014 800 px - `'full'` \u2014 100vw, no border-radius, stretches to fill viewport |\n| closeOnOverlayClick | `boolean` | `true` | When true, clicking the semi-transparent overlay behind the modal calls `onClose`. |\n| closeOnEscape | `boolean` | `true` | When true, pressing the Escape key calls `onClose`. |\n| showCloseButton | `boolean` | `true` | When true, renders a ghost close button (X icon) in the modal header. |\n\n**Example:**\n```tsx\n```tsx\n<Modal\n open={isOpen}\n onClose={() => setOpen(false)}\n title=\"Confirm Action\"\n size=\"md\"\n footer={\n <>\n <Button variant=\"ghost\" onClick={() => setOpen(false)}>Cancel</Button>\n <Button onClick={handleConfirm}>Confirm</Button>\n </>\n }\n>\n <p>Are you sure you want to proceed?</p>\n</Modal>\n```\n```\n\n### CommandPalette\n\nProps for the `CommandPalette` composition.\n\nA searchable command menu (Cmd+K / Ctrl+K pattern) that composes `Spinner` and `Icon`\nprimitives. Appears as a centered overlay at 15vh from the top. Commands are filtered\nby label and description (case-insensitive substring match). Results are grouped under\nuppercase section headers. Keyboard navigation is fully supported:\nArrow Up/Down to navigate, Enter to select, Escape to close.\n\nWhen `recentIds` are provided and the search query is empty, matching commands appear\nin a \"Recent\" section at the top (deduplicated from their original groups).\n\nThe footer shows navigation hints: \"Up/Down Navigate\", \"Enter Select\", \"Esc Close\".\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| open | `boolean` | - | Controls palette visibility. When false, the component renders nothing. |\n| onClose | `() => void` | - | Called to close the palette (overlay click, Escape key, or after command selection). |\n| commands | `Command[]` | - | Full array of available commands. Filtered client-side by the search query. |\n| onSelect | `(command: Command) => void` | - | Called when a non-disabled command is selected (Enter key or click). The palette auto-closes after selection. |\n| placeholder | `string` | `'Search commands...'` | Placeholder text for the search input. |\n| recentIds | `string[]` | `[]` | IDs of recently used commands. When the query is empty, these appear in a \"Recent\" section at the top of the results. |\n| loading | `boolean` | - | When true, shows a centered `Spinner` instead of the command list. |\n\n**Example:**\n```tsx\n```tsx\n<CommandPalette\n open={isOpen}\n onClose={() => setOpen(false)}\n commands={[\n { id: 'new', label: 'New File', shortcut: 'Ctrl+N', group: 'File' },\n { id: 'save', label: 'Save', shortcut: 'Ctrl+S', group: 'File' },\n { id: 'theme', label: 'Toggle Theme', group: 'Preferences' },\n ]}\n onSelect={(cmd) => executeCommand(cmd.id)}\n recentIds={['save']}\n placeholder=\"Search commands...\"\n/>\n```\n```\n\n### Footer\n\nProps for the `Footer` composition.\n\nA site footer with `role=\"contentinfo\"` that lays out a brand slot, link columns,\nsocial icons, and a bottom bar. Does not compose other ggui primitives (uses plain\nHTML elements). Content is constrained to `max-width: 1280px` with auto margins.\nLink columns use a responsive flex layout (`flex: 0 1 180px`). The bottom bar\nincludes copyright text, social links, and bottom-bar links separated by a top border.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| brand | `ReactNode` | - | Brand element (logo, tagline) rendered in a flexible column (`flex: 1 1 280px`). |\n| columns | `FooterColumn[]` | - | Array of link columns rendered in a flex-wrap layout with 48 px gap. |\n| socialLinks | `FooterSocialLink[]` | - | Social media icon links rendered in the bottom bar. |\n| bottomText | `string` | - | Text displayed at the start of the bottom bar (e.g., copyright notice). |\n| bottomLinks | `FooterLink[]` | - | Links displayed in the bottom bar after social icons (e.g., Privacy, Terms). |\n| background | `string` | `colors.gray[50]` | Background color of the footer. |\n| bordered | `boolean` | `true` | When true, renders a 1 px top border in `colors.gray[200]`. |\n\n**Example:**\n```tsx\n```tsx\n<Footer\n brand={<img src=\"/logo.svg\" alt=\"Acme\" />}\n columns={[\n { title: 'Product', links: [{ label: 'Features', href: '/features' }] },\n { title: 'Company', links: [{ label: 'About', href: '/about' }] },\n ]}\n socialLinks={[\n { label: 'Twitter', href: 'https://twitter.com/acme', icon: <TwitterIcon /> },\n ]}\n bottomText=\"&copy; 2026 Acme Inc.\"\n bottomLinks={[{ label: 'Privacy', href: '/privacy' }]}\n bordered\n/>\n```\n```\n\n### Hero\n\nProps for the `Hero` composition.\n\nA prominent landing-page hero section that renders heading, description, CTA buttons,\nand an optional media slot. Does not compose other ggui primitives (uses plain HTML\nelements styled with design tokens). Content is constrained to `max-width: 1280px`.\n\nLayout modes:\n- `align='center'` \u2014 single-column centered layout with `max-width: 800px` text area.\n- `align='left'` \u2014 two-column side-by-side layout (50/50 split with media slot).\n\nSize controls vertical padding and font sizes:\n- `'sm'` \u2014 48 px vertical padding, 3xl heading, lg description.\n- `'md'` \u2014 80 px vertical padding, 4xl heading, xl description.\n- `'lg'` \u2014 120 px vertical padding, 5xl heading, xl description.\n\nThe primary action button uses `colors.primary[600]` fill; the secondary action uses\nan outlined style. When `overlay` is true with a `backgroundImage`, text switches to white\nand borders become semi-transparent.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| heading | `string` | - | Main heading text rendered as an `<h1>` with bold weight and tight line-height. |\n| description | `string` | - | Description paragraph rendered below the heading with relaxed line-height. |\n| primaryAction | `HeroAction` | - | Primary CTA button rendered with `colors.primary[600]` background and white text. |\n| secondaryAction | `HeroAction` | - | Secondary CTA button rendered with a transparent background and a 1 px border. |\n| media | `ReactNode` | - | Media element (image, video, illustration) rendered beside or below the text content. |\n| align | `'center' \\| 'left'` | `'center'` | Text and layout alignment. - `'center'` \u2014 centered single-column layout. - `'left'` \u2014 left-aligned text with media in a right column (50/50 split). |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls vertical padding and heading/description font sizes. - `'sm'` \u2014 compact (48 px padding, 3xl/lg fonts). - `'md'` \u2014 standard (80 px padding, 4xl/xl fonts). - `'lg'` \u2014 spacious (120 px padding, 5xl/xl fonts). |\n| background | `string` | `colors.white (when no backgroundImage is set)` | Background color of the hero section. |\n| backgroundImage | `string` | - | Background image URL applied as `background-size: cover; background-position: center`. |\n| overlay | `boolean` | `false` | When true and `backgroundImage` is set, renders a semi-transparent black overlay (`rgba(0,0,0,0.5)`) and switches text to white/semi-transparent white for contrast. |\n\n**Example:**\n```tsx\n```tsx\n<Hero\n heading=\"Build Better UIs, Faster\"\n description=\"The universal interface layer between AI agents and humans.\"\n primaryAction={{ label: 'Get Started', onClick: () => navigate('/signup') }}\n secondaryAction={{ label: 'Learn More', href: '/docs' }}\n media={<img src=\"/hero.png\" alt=\"Hero\" />}\n align=\"left\"\n size=\"lg\"\n/>\n```\n```\n\n### Support Types\n\n**SidebarItem:**\n\nA single navigation entry in a `Sidebar`. Supports nested children for\ncollapsible sub-menus and an optional badge slot.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier used for active-state matching and React keys. |\n| label | `string` | Display label for the item. Hidden when the sidebar is collapsed. |\n| icon | `ReactNode` | Leading icon rendered before the label. Remains visible when collapsed. |\n| href | `string` | Optional URL associated with this item (not rendered as a link by default). |\n| badge | `ReactNode` | Trailing badge element (e.g., unread count). Hidden when collapsed. |\n| children | `SidebarItem[]` | Nested child items. When present, the item acts as a collapsible section (chevron indicator shown). |\n| disabled | `boolean` | When true, the item is visually dimmed and non-interactive (`cursor: not-allowed`). |\n\n**Comment:**\n\nA single comment entry in a `CommentThread`. Supports nested replies\nand emoji reactions with counts.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier for this comment. |\n| author | `{ /** Display name of the author. */ name: string; /** URL for the author's avatar image. Passed to the `Avatar` primitive. */ avatar?: string; }` | Comment author metadata. |\n| content | `string` | The comment body text. |\n| timestamp | `string \\| Date` | Timestamp of the comment. Rendered via `toLocaleString()` when a `Date` object. |\n| replies | `Comment[]` | Nested reply comments. Each reply is rendered indented 40 px deeper. |\n| reactions | `{ emoji: string; count: number }[]` | Emoji reactions with their aggregated counts (e.g., `{ emoji: \"\u{1F44D}\", count: 3 }`). |\n\n**DataTableColumn:**\n\nColumn definition for a `DataTable`. Controls header text, width, alignment,\nsorting, and custom cell rendering.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| key | `string` | Property key on the row object used to extract cell values. Also serves as the sort key. |\n| header | `string` | Column header text displayed in the `<thead>`. |\n| width | `number \\| string` | Column width as a CSS value (number for pixels, string for any CSS unit). |\n| sortable | `boolean` | When true, the column header is clickable and triggers `onSort`. An arrow icon indicates direction. |\n| render | `(value: unknown, row: T, index: number) => ReactNode` | Custom cell renderer. When omitted, the raw value is stringified via `String()`. |\n| align | `'left' \\| 'center' \\| 'right'` | Text alignment for both header and body cells. |\n\n**ChatMessage:**\n\nA single message in a `ChatWindow`. Includes sender metadata, delivery status,\nand a timestamp.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier for this message. |\n| content | `string` | The message body text. |\n| sender | `{ /** Unique ID of the sender. Compared to `currentUserId` to determine alignment. */ id: string; /** Display name of the sender. */ name: string; /** Avatar image URL. Only shown for non-current-user messages (xs size). */ avatar?: string; }` | Sender metadata used for avatar rendering and alignment. |\n| timestamp | `string \\| Date` | Message timestamp. Rendered as `HH:MM` via `toLocaleTimeString` when a `Date` object. |\n| status | `'sending' \\| 'sent' \\| 'delivered' \\| 'read' \\| 'error'` | Delivery status indicator shown on the current user's messages. - `'sending'` \u2014 shows a dot bullet - `'sent'` \u2014 shows a single checkmark - `'delivered'` \u2014 shows double checkmarks - `'read'` \u2014 shows double checkmarks (same visual as delivered) - `'error'` \u2014 shows an exclamation mark |\n\n**NavItem:**\n\nA single navigation entry in a `NavigationBar`. Supports nested children\nfor sub-menus (rendered by the parent via dropdown, not built-in).\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier used for active-state matching and React keys. |\n| label | `string` | Display label for the navigation link. |\n| href | `string` | URL for the item. When provided, renders an `<a>` element instead of `<button>`. |\n| icon | `ReactNode` | Optional icon rendered before the label. |\n| children | `NavItem[]` | Nested child items (for sub-menu structures; rendering is consumer-defined). |\n| disabled | `boolean` | When true, the item is visually dimmed (opacity 0.5) and non-interactive. |\n\n**UploadedFile:**\n\nA file entry in the `FileUploader` composition. Tracks upload progress,\nstatus, and optional error messages.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier for this file entry. Used as a key and for removal callbacks. |\n| name | `string` | Original file name. Rendered with text-overflow ellipsis when too long. |\n| size | `number` | File size in bytes. Formatted as B/KB/MB/GB for display. |\n| type | `string` | MIME type of the file (e.g., `'image/png'`). |\n| progress | `number` | Upload progress as a percentage (0-100). Shown via the `Progress` primitive when status is `'uploading'`. |\n| status | `'pending' \\| 'uploading' \\| 'success' \\| 'error'` | Current upload lifecycle status. - `'pending'` \u2014 file selected but upload not started. - `'uploading'` \u2014 upload in progress; `progress` bar is shown. - `'success'` \u2014 upload completed. - `'error'` \u2014 upload failed; `error` message is shown in `colors.error[500]`. |\n| error | `string` | Error message displayed when status is `'error'`. |\n| url | `string` | The remote URL of the uploaded file after successful upload. |\n\n**Notification:**\n\nA single notification entry in the `NotificationCenter`. Supports semantic type coloring,\nread state, and an optional inline action button.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier for this notification. |\n| title | `string` | Notification title rendered in medium weight. |\n| message | `string` | Optional body text rendered below the title in a smaller font. |\n| timestamp | `string \\| Date` | Timestamp of the notification. Rendered via `toLocaleString()` when a `Date` object. |\n| read | `boolean` | Read state. Unread notifications get a `colors.primary[50]` background and a colored status dot matching the notification type. |\n| type | `'info' \\| 'success' \\| 'warning' \\| 'error'` | Semantic type that controls the status dot color on unread notifications. - `'info'` \u2014 `colors.info[500]` (blue) - `'success'` \u2014 `colors.success[500]` (green) - `'warning'` \u2014 `colors.warning[500]` (amber) - `'error'` \u2014 `colors.error[500]` (red) |\n| icon | `ReactNode` | Optional icon rendered alongside the notification (not used by the default implementation). |\n| action | `{ /** Button label text. */ label: string; /** Click handler for the action. */ onClick: () => void; }` | Optional inline action button rendered next to the timestamp. |\n\n**Command:**\n\nA single command entry in a `CommandPalette`. Commands can be grouped,\nhave keyboard shortcuts, and support a disabled state.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier for this command. |\n| label | `string` | Display label for the command. Searchable by the palette's query filter. |\n| description | `string` | Optional description text shown below the label. Also searchable. |\n| icon | `ReactNode` | Icon rendered at the start of the command row. |\n| shortcut | `string` | Keyboard shortcut hint displayed at the end of the row in a `<kbd>` element. |\n| group | `string` | Group name for visual sectioning. Commands with the same group are rendered under a shared header. Defaults to `'Commands'` if omitted. |\n| disabled | `boolean` | When true, the command is visually dimmed and cannot be selected. |\n\n**FooterLink:**\n\nA single link entry in a footer column or the bottom bar. Supports both\n`href` navigation and `onClick` handlers (onClick takes precedence via `preventDefault`).\n\n| Property | Type | Description |\n|----------|------|-------------|\n| label | `string` | Display text for the link. |\n| href | `string` | URL for the link. |\n| onClick | `() => void` | Click handler. When provided, `preventDefault` is called on the anchor click. |\n\n**FooterColumn:**\n\nA named column of links in the `Footer` layout. Each column has an optional title\nand a list of links rendered as a vertical stack with 10 px gap.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| title | `string` | Column heading rendered as an `<h4>` with semibold weight. |\n| links | `FooterLink[]` | Links displayed in this column. |\n\n**FooterSocialLink:**\n\nA social media link in the `Footer` bottom bar. Rendered as an icon-only anchor\nwith `aria-label` for accessibility.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| label | `string` | Accessible label for the social link (used as `aria-label`). |\n| href | `string` | URL for the social media profile or page. |\n| icon | `ReactNode` | Icon element rendered inside the anchor (typically an SVG or `Icon` primitive). |\n\n**HeroAction:**\n\nA call-to-action button definition for the `Hero` composition.\nUsed for both the primary (filled) and secondary (outlined) action buttons.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| label | `string` | Button label text. |\n| onClick | `() => void` | Click handler for the button. |\n| href | `string` | Optional URL (not used by the default implementation; available for consumer routing). |\n\n\n## System Conventions\n\n### onChange Behavior (CRITICAL)\n\nAll form control onChange handlers receive the VALUE DIRECTLY, not a React event object.\n\n```tsx\n// CORRECT \u2014 onChange receives value directly\n<Input value={name} onChange={setName} />\n<Input value={email} onChange={(value) => setEmail(value)} />\n<Select value={country} onChange={setCountry} options={countries} />\n<Checkbox checked={agreed} onChange={setAgreed} />\n\n// WRONG \u2014 DO NOT use e.target.value!\n<Input value={name} onChange={(e) => setName(e.target.value)} /> // WILL BREAK\n```\n\nApplies to: Input, TextArea, Select, Checkbox, RadioGroup, Slider, Tabs, Accordion.\n\n### Available Motion & Animation\n\nRender `<MotionKeyframes />` once (anywhere in tree) to enable all keyframes.\n\n**Entrance/exit:** fadeIn, fadeOut, slideInUp, slideInDown, scaleIn, scaleOut\n**State feedback:** flash (background-color highlight), pulse (opacity breathing), bounce (scale overshoot)\n**Easing:** linear, easeIn, easeOut, easeInOut, spring (bouncy)\n**Durations:** instant(0ms), fast(100ms), normal(200ms), slow(300ms), slower(500ms)\n\n```tsx\n// Entrance animation on mount\n<div style={{ animation: 'ggui-fadeIn 200ms ease-out' }}>Content</div>\n\n// Stagger list items\n{items.map((item, i) => (\n <div key={item.id} style={{ animation: \\`ggui-slideInUp 300ms ease-out \\${i * 50}ms both\\` }}>\n {item.name}\n </div>\n))}\n\n// Flash highlight when data changes (e.g., stock price update)\n// useAnimationKey returns a key that increments when dep changes \u2192 remounts element \u2192 replays animation\nconst priceKey = useAnimationKey(stock.price);\n<div key={priceKey} style={{\n animation: animation.flash,\n '--ggui-flash-color': stock.change > 0 ? 'var(--ggui-color-success-100)' : 'var(--ggui-color-error-100)',\n} as React.CSSProperties}>\n {stock.price}\n</div>\n\n// Respect reduced-motion preference\nconst { motionEnabled } = useMotion();\n<div style={motionEnabled ? { animation: 'ggui-scaleIn 200ms ease-out' } : undefined}>\n Content\n</div>\n```\n\n### Elevation System\n\n6 levels mapping shadow intensity to z-index for layering:\n- Level 0: flat (no shadow, z: auto) \u2014 inline content\n- Level 1: sm shadow (z: auto) \u2014 cards, sections\n- Level 2: md shadow (z: 1000) \u2014 dropdowns, popovers\n- Level 3: lg shadow (z: 1200) \u2014 sticky banners\n- Level 4: xl shadow (z: 1400) \u2014 modals, dialogs\n- Level 5: 2xl shadow (z: 1800) \u2014 tooltips, toasts\n\n### Import Constraints\n\nOnly these imports are allowed:\n- `react`\n- `@ggui-ai/design` \u2014 the whole design system (primitives, components, compositions, and the `Clickable` / `Hoverable` / `Pressable` traits) is one import; there are no subpaths\n- `@ggui-ai/wire` (wire hooks)\n\nNo external libraries (lodash, date-fns, etc.). No fetch(). No eval().";
2871
+ PRIMITIVES_DOCUMENTATION = "# ggui Primitives & Design System Reference\n\n> You are a world-class UI engineer working with ggui's component library for the first time.\n> This reference documents every available component, prop, and convention.\n> Components handle theming automatically via built-in variants \u2014 pick the right variant and the theme does the rest.\n> For custom styling beyond variants, use CSS variables: var(--ggui-*, fallback).\n\n## Primitives\n\nImport: `import { Component } from '@ggui-ai/design'`\n\n### Container\n\nContainer -- Width-constrained wrapper that centers content horizontally.\n\nRenders a `<div>` with `width: 100%` and a `maxWidth` constraint.\nWhen `center` is true (the default), applies `margin: 0 auto`.\nNo background, border, or shadow -- use Card for visual containment.\n\nCSS variables used: none (pure layout primitive).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| maxWidth | `'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| '3xl' \\| 'full' \\| string` | `'lg'` | Maximum width constraint. Accepts a preset token or any CSS width string. - `'xs'` -- 320px - `'sm'` -- 480px - `'md'` -- 640px - `'lg'` -- 768px - `'xl'` -- 1024px - `'2xl'` -- 1280px - `'3xl'` -- 1536px - `'full'` -- 100% Custom strings (e.g., `'900px'`, `'60ch'`) are passed through as-is. |\n| center | `boolean` | `true` | Whether to center the container horizontally via `margin: 0 auto`. |\n| padding | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `undefined (no padding)` | Padding applied to all sides. Prefer a spacing-scale name (`'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl'`) \u2014 each resolves to the matching `--ggui-spacing-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. |\n\n**Example:**\n```tsx\n<Container maxWidth=\"xl\" padding=\"var(--ggui-spacing-6)\">\n <Stack gap=\"var(--ggui-spacing-4)\">\n <Heading level={1}>Dashboard</Heading>\n <Card shadow=\"md\" padding=\"var(--ggui-spacing-5)\">\n <Text>Welcome back!</Text>\n </Card>\n </Stack>\n</Container>\n```\n\n### Card\n\nCard -- Container with background, shadow, and optional border.\n\nRenders a `<div>` with:\n- Background: `var(--ggui-color-container)`\n- Border (when enabled): `1px solid var(--ggui-color-outlineVariant)`\n- Shadow and radius controlled by design tokens via CSS variables.\n- No built-in transitions.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| padding | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `'lg'` | Padding applied to all sides. Prefer a spacing-scale name (`'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl'`) \u2014 each resolves to the matching `--ggui-spacing-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. |\n| shadow | `'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl'` | `'sm'` | Shadow elevation level. Maps to design tokens: - `'none'` -- no shadow - `'sm'` -- var(--ggui-shape-shadow-sm) -- subtle, default - `'md'` -- var(--ggui-shape-shadow-md) -- dialogs, emphasized sections - `'lg'` -- var(--ggui-shape-shadow-lg) -- floating panels - `'xl'` -- var(--ggui-shape-shadow-xl) -- popovers, modals |\n| border | `boolean` | `true` | Whether to render a 1px border using `var(--ggui-color-outlineVariant)`. |\n| radius | `'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| number \\| string` | `'lg'` | Corner radius. Prefer a radius-scale name (`'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl'`) \u2014 each resolves to the matching `--ggui-shape-radius-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. |\n| surface | `'default' \\| 'elevated' \\| 'sunken' \\| 'accent' \\| 'inverted' \\| 'hero' \\| 'transparent'` | `'default'` | Semantic surface slot. Same vocabulary as ; see that prop's docs for the full slot table. Default Card surface is `'default'` (the active theme's `--ggui-color-container`); pair with `shadow=\"md\"\\|\"lg\"` for elevated cards, `'hero'` for a greeting or hero card (the theme's hero ground, right on a light or a dark host), or `'inverted'` for a dark testimonial-style card on a light theme. |\n| bleed | `boolean` | `false` | Span the expanded frame's inset edge to edge (ggui#1083). In a host's fullscreen canvas the frame gives the root a 16 px content inset; a `bleed` element (a full-width row the design means to touch the panel's edges) takes that inset back and meets the panel edge. A `surface=\"hero\"` band that opens the card already does, without it. Ignored everywhere else, so it is safe on any card. |\n\n**Example:**\n```tsx\n<Card shadow=\"md\" padding=\"lg\" radius=\"lg\">\n <Stack gap=\"md\">\n <Text size=\"sm\" weight=\"medium\">Settings</Text>\n <Input label=\"Name\" value={name} onChange={setName} />\n <Button variant=\"primary\">Save</Button>\n </Stack>\n</Card>\n```\n\n### Stack\n\nStack -- Flexbox layout primitive for arranging children along a single axis.\n\nRenders a `<div>` with `display: flex`. Default layout is vertical (column).\nAll flex shorthand values (`align`, `justify`, `wrap`) are abstracted into\nsemantic prop names.\n\nCSS variables used: none (pure layout primitive).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| direction | `'vertical' \\| 'horizontal'` | `'vertical'` | Main axis direction. - `'vertical'` -- `flex-direction: column` - `'horizontal'` -- `flex-direction: row` |\n| gap | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `'sm'` | Gap between children. Prefer a spacing-scale name (`'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl'`) \u2014 each resolves to the matching `--ggui-spacing-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. |\n| align | `'start' \\| 'center' \\| 'end' \\| 'stretch'` | `'stretch'` | Cross-axis alignment (maps to `align-items`). - `'start'` -- flex-start - `'center'` -- center - `'end'` -- flex-end - `'stretch'` -- stretch (children fill cross-axis) |\n| justify | `'start' \\| 'center' \\| 'end' \\| 'between' \\| 'around' \\| 'evenly'` | `'start'` | Main-axis content distribution (maps to `justify-content`). - `'start'` -- flex-start - `'center'` -- center - `'end'` -- flex-end - `'between'` -- space-between - `'around'` -- space-around - `'evenly'` -- space-evenly |\n| wrap | `boolean` | `false` | Whether children wrap to the next line when they overflow. Maps to `flex-wrap: wrap` when true. |\n\n**Example:**\n```tsx\n<Stack gap=\"lg\" align=\"center\">\n <Heading level={2}>Profile</Heading>\n <Text>Edit your account details below.</Text>\n <Stack direction=\"horizontal\" gap=\"sm\" justify=\"end\">\n <Button variant=\"ghost\">Cancel</Button>\n <Button variant=\"primary\">Save</Button>\n </Stack>\n</Stack>\n```\n\n### Grid\n\nGrid -- 2-D layout primitive. Arranges children into rows AND\ncolumns; reach for it when Stack/Row's single-axis flow isn't\nenough (card galleries, dashboards, stat grids).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| columns | `number \\| ResponsiveColumns` | `2` | Column count. Three forms: - a number \u2014 that many equal columns at every width (`columns={3}`); - a map \u2014 explicit counts per breakpoint (`columns={{ base: 1, md: 3 }}` = 1 column on mobile, 3 from `md`). Use this when the request names exact per-breakpoint counts (\"3 per row on desktop, 1 on mobile\"). Ignored entirely when `minColumnWidth` is set. |\n| gap | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `'md'` | Gap between cells. Prefer a spacing-scale name (`'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl'`); a number is pixels. |\n| minColumnWidth | `number \\| string` | `undefined (use `columns`)` | When set, the grid becomes responsive \u2014 it fits as many equal columns as possible, each at least this wide, and `columns` is ignored. A number is treated as pixels. |\n\n**Example:**\n```tsx\n<Grid columns={3} gap=\"md\">\n {items.map((it) => <Card key={it.id}>{it.name}</Card>)}\n</Grid>\n```\n\n### Skeleton\n\nSkeleton -- a pulsing placeholder for content that has not loaded\nyet. ggui UIs are agent-driven (props arrive late, streams start\nempty), so a loading frame is the rule \u2014 render `Skeleton` instead\nof a blank screen or a hand-rolled pulsing `<div>`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| variant | `'rect' \\| 'text' \\| 'circle'` | `'rect'` | Shape preset. - `'rect'` -- a block (default); pair with `width` / `height`. - `'text'` -- a single text line (height ~1em). - `'circle'` -- equal width/height, fully rounded (avatar slot). |\n| width | `number \\| string` | - | Width. A number is pixels. Defaults to `100%` (`2.5rem` for circle). |\n| height | `number \\| string` | - | Height. A number is pixels. Defaults by variant when unset. |\n| radius | `'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| number \\| string` | `'sm'` | Corner radius. Prefer a radius-scale name. Ignored for `variant=\"circle\"` (always fully round). |\n\n**Example:**\n```tsx\n{user === undefined\n ? <Skeleton variant=\"text\" width=\"40%\" />\n : <Text>{user.name}</Text>}\n```\n\n### Box\n\nBox -- Generic container with padding, margin, background, and border-radius.\n\nRenders a plain `<div>`. Unlike Card, Box has no default background, shadow,\nor border -- it is a blank canvas for custom styling. Use it for layout\nspacing, colored sections, or wrapping arbitrary content.\n\nWhen both `paddingX`/`paddingY` and `padding` are provided, the axis-specific\nprops take precedence and `padding` is ignored.\n\nCSS variables used: none (all values are passed through directly).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| padding | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `undefined (no padding)` | Padding applied to all four sides. Prefer a spacing-scale name (`'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl'`) \u2014 each resolves to the matching `--ggui-spacing-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. Ignored when `paddingX` or `paddingY` is set. |\n| paddingX | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `undefined` | Horizontal (left + right) padding. Accepts a spacing-scale name, a pixel number, or a raw CSS string \u2014 see . When set alongside `paddingY`, they combine into a shorthand `padding: {Y} {X}`. When set without `paddingY`, vertical padding defaults to 0. |\n| paddingY | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `undefined` | Vertical (top + bottom) padding. Accepts a spacing-scale name, a pixel number, or a raw CSS string \u2014 see . When set alongside `paddingX`, they combine into a shorthand `padding: {Y} {X}`. When set without `paddingX`, horizontal padding defaults to 0. |\n| margin | `'none' \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| '2xl' \\| number \\| string` | `undefined (no margin)` | Margin applied to all four sides. Accepts a spacing-scale name, a pixel number, or a raw CSS string \u2014 see . |\n| surface | `'default' \\| 'elevated' \\| 'sunken' \\| 'accent' \\| 'inverted' \\| 'hero' \\| 'transparent'` | `undefined (transparent)` | Semantic surface slot. Picks the right `var(--ggui-color-*)` background token from the active theme. The ONLY way to set a theme-tracking background fill on Box. Available slots: - `'default'` \u2014 base container surface (most common) - `'elevated'` \u2014 same fill, intended to be paired with shadow (use Card.shadow for actual elevation) - `'sunken'` \u2014 recessed / inset region (the `sunken` role; `surfaceVariant` was retired by ggui#987) - `'accent'` \u2014 highlighted / branded fill (`primary-50` token) - `'inverted'` \u2014 dark surface in light mode, light in dark (testimonials, code-snippet cards, hero panels). The surface owns ALL its inks: body text, `muted` / `subtle` labels, chips and outlines inside it re-map to inverse-derived values by cascade \u2014 no `tone=\"inverse\"` needed, and no hand-picked greys. - `'hero'` \u2014 the hero panel's ground: brand-tinted on a light host, the ink pair on a dark one (`heroGround` / `onHeroGround`, derived per mode) \u2014 reach for it for a greeting or hero card instead of guessing the host's darkness with `'inverted'`; it owns its subtree's inks likewise. - `'transparent'` \u2014 explicit \"no fill\" For non-theme-mapped brand colors (e.g. a partner's exact brand hex like Stripe purple) use the escape \u2014 every other hex / rgba on Box is rejected by tier-0 self-check. |\n| bleed | `boolean` | `false` | Span the expanded frame's inset edge to edge (ggui#1083). In a host's fullscreen canvas the frame gives the root a 16 px content inset; a `bleed` element (a full-width row the design means to touch the panel's edges) takes that inset back and meets the panel edge. A `surface=\"hero\"` band that opens the card already does, without it. Ignored everywhere else, so it is safe on any card. |\n| assetColor | `string` | `undefined` | Asset color escape \u2014 the typed valve for legitimate non-theme color values (a partner's exact brand hex, a fixed product surface, etc.). Renders as the Box background. **MUST be paired with .** The semantic name is human-readable documentation of why this color bypasses the theme \u2014 e.g. `\"stripe-brand-purple\"`, `\"slack-aubergine\"`. Tier-0 self-check allows hex / rgba inside `assetColor` ONLY when `assetSemantic` is a non-empty string; one without the other fails the check. Reach for `surface` first. This escape exists for the small set of cases where the operator's theme MUST NOT override the value (brand identity rendering). |\n| assetSemantic | `string` | `undefined` | Human-readable semantic label that documents why bypasses the theme. Required when `assetColor` is set; tier-0 self-check rejects empty strings or a missing `assetSemantic` next to a hex `assetColor`. Examples: `\"stripe-brand-purple\"`, `\"slack-aubergine\"`, `\"partner-logo-orange\"`. Pure documentation \u2014 no rendering effect. |\n| radius | `'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| number \\| string` | `undefined (no rounding)` | Corner radius. Prefer a radius-scale name (`'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl'`) \u2014 each resolves to the matching `--ggui-shape-radius-*` token. A number is treated as pixels; any other string is passed through as a raw CSS value. |\n\n**Example:**\n```tsx\n<Box paddingX=\"xl\" paddingY=\"lg\" surface=\"accent\" radius=\"lg\">\n <Text size=\"sm\" tone=\"emphasized\">\n Tip: You can customize your theme in Settings.\n </Text>\n</Box>\n```\n\n### Divider\n\nDivider -- A 1px line to visually separate content sections.\n\nRenders an `<hr>` (horizontal) or `<div>` (vertical) with `role=\"separator\"`.\n- Horizontal: 1px tall, full width, with vertical margin.\n- Vertical: 1px wide, stretches to parent height via `align-self: stretch`,\n with horizontal margin. Works best inside a horizontal Stack or Row.\n\nDefault color: `var(--ggui-color-outlineVariant)`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| orientation | `'horizontal' \\| 'vertical'` | `'horizontal'` | Line direction. - `'horizontal'` -- renders `<hr>`, full width, 1px height, margin top/bottom - `'vertical'` -- renders `<div>`, 1px width, `align-self: stretch`, margin left/right |\n| margin | `number \\| string` | `16` | Spacing around the divider. Numbers are treated as pixels. Applied as vertical margin for horizontal dividers, horizontal margin for vertical. |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `undefined (uses `var(--ggui-color-outlineVariant)`)` | Semantic color slot. Same vocabulary as ; the theme decides what each tone LOOKS like. Defaults to a quiet outline-variant tint when unset (independent of the tone slots). |\n\n**Example:**\n```tsx\n<Stack gap={0}>\n <Text>Section A</Text>\n <Divider margin=\"var(--ggui-spacing-3)\" />\n <Text>Section B</Text>\n</Stack>\n```\n\n### Spacer\n\nSpacer -- Invisible spacing element, either fixed-size or flexible.\n\nRenders an empty `<div>`.\n- Fixed mode (number): sets both `width` and `height` to the given pixel\n value with `flex-shrink: 0`, creating rigid spacing in any direction.\n- Flex mode (`'flex'`): sets `flex: 1`, expanding to fill remaining space\n in a flex container. Useful for pushing siblings apart.\n\nCSS variables used: none.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| size | `number \\| 'flex'` | `16` | Spacing amount. - Number: fixed square spacer (width and height in pixels, `flex-shrink: 0`). - `'flex'`: expands to fill available space (`flex: 1`). |\n\n**Example:**\n```tsx\n<Stack direction=\"horizontal\" align=\"center\">\n <Heading level={3}>Logo</Heading>\n <Spacer size=\"flex\" />\n <Button variant=\"ghost\">Login</Button>\n</Stack>\n```\n\n### Text\n\nText -- Versatile typography primitive for body copy, captions, and labels.\n\nRenders as `<p>` by default (configurable via `is`). Typography is\ncomposed from three orthogonal axes: `size` (font size, with a\nmatching line-height built in), `weight`, and `tone` (semantic\ncolor). `caps` adds the uppercase, letter-spaced treatment for tiny\nsection labels (the classic overline/eyebrow look) \u2014 the label's text\ncomes from a prop, never from the component: an invented eyebrow is copy\nnothing can change.\n\nDefault text color: `var(--ggui-color-onContainer)`.\nAll text renders with `margin: 0` (no default paragraph spacing).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| size | `'xs' \\| 'sm' \\| 'base' \\| 'lg' \\| 'xl' \\| '2xl' \\| '3xl' \\| '4xl'` | `'base'` | Font size. Maps to CSS variables with pixel fallbacks; each size carries a matching line-height (1.5 for `xs`--`base`, 1.625 for `lg`, then tightening on display sizes: 1.375 at `xl`, 1.25 from `2xl` up): - `'xs'` -- var(--ggui-font-size-xs) (12px) - `'sm'` -- var(--ggui-font-size-sm) (14px) - `'base'` -- var(--ggui-font-size-base) (16px) - `'lg'` -- var(--ggui-font-size-lg) (18px) - `'xl'` -- var(--ggui-font-size-xl) (20px) - `'2xl'` -- var(--ggui-font-size-2xl) (24px) - `'3xl'` -- var(--ggui-font-size-3xl) (30px) - `'4xl'` -- var(--ggui-font-size-4xl) (36px) |\n| weight | `'normal' \\| 'medium' \\| 'semibold' \\| 'bold'` | `'normal'` | Font weight. Maps to CSS variables with numeric fallbacks: - `'normal'` -- var(--ggui-font-weight-normal) - `'medium'` -- var(--ggui-font-weight-medium) - `'semibold'` -- var(--ggui-font-weight-semibold) - `'bold'` -- var(--ggui-font-weight-bold) |\n| caps | `boolean` | `false` | When true, uppercases the text and adds 0.05em letter-spacing -- the eyebrow/overline treatment for tiny section labels. Pair with `size=\"xs\" weight=\"semibold\" tone=\"muted\"`. |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `'default' (var(--ggui-color-onContainer))` | Semantic color slot. Picks the right `var(--ggui-color-*)` token from the active theme. The theme decides what each tone LOOKS like \u2014 `'muted'` is a quiet warm grey on Claudic, a cool slate on Indigo, dim cyan on Neon-Noir. Components that use `tone` track the operator's theme switch automatically. Available slots: `'default'` (primary body text), `'muted'` (secondary / metadata), `'subtle'` (very-low-emphasis hint), `'emphasized'` (branded accent), `'loud'` (strongest accent \u2014 the theme's readable accent ink), `'success'` / `'warning'` / `'error'` / `'info'` (status text), `'inverse'` (text on dark surface), `'inherit'` (parent's color). `tone` is the ONLY way to set a Text color. The legacy `color?: string` escape was retired \u2014 raw color strings bypass theming and silently override the operator's preset. |\n| align | `'left' \\| 'center' \\| 'right'` | `undefined (inherits from parent)` | Horizontal text alignment. Maps directly to `text-align`. |\n| truncate | `boolean` | `false` | When true, clips overflowing text with an ellipsis. Applies `overflow: hidden`, `text-overflow: ellipsis`, and `white-space: nowrap`. |\n| is | `'p' \\| 'span' \\| 'div' \\| 'label'` | `'p'` | HTML element to render. Choose based on semantic context: - `'p'` -- paragraph (default, block-level) - `'span'` -- inline text within a sentence - `'div'` -- generic block container - `'label'` -- form label (pair with `htmlFor`) |\n| id | `string` | - | `id` for the rendered element \u2014 anchor an in-page link, or pair with a form control's `aria-labelledby`. |\n| htmlFor | `string` | - | Associates an `is=\"label\"` element with a form control by the control's `id`. Only meaningful when `is=\"label\"`. |\n\n**Example:**\n```tsx\n<Stack gap=\"var(--ggui-spacing-1)\">\n <Text size=\"xs\" weight=\"semibold\" caps tone=\"muted\">{props.section}</Text>\n <Text size=\"lg\">Welcome back, Jane.</Text>\n <Text size=\"xs\" tone=\"muted\">\n Last login: 2 hours ago\n </Text>\n</Stack>\n```\n\n### Heading\n\nHeading -- Semantic heading element (h1-h6) with preset typography styles.\n\nRenders the corresponding `<h1>`-`<h6>` HTML element based on `level`.\nEach level has a preset font size, weight, line height, and letter spacing\nfrom the heading typography tokens:\n- Level 1: 36px / bold / 1.25 line-height / -0.025em tracking\n- Level 2: 30px / bold / 1.25 line-height / -0.025em tracking\n- Level 3: 24px / semibold / 1.375 line-height / 0em tracking\n- Level 4: 20px / semibold / 1.375 line-height / 0em tracking\n- Level 5: 18px / semibold / 1.5 line-height / 0em tracking\n- Level 6: 16px / semibold / 1.5 line-height / 0em tracking\n\nDefault text color: `var(--ggui-color-onContainer)`.\nAll headings render with `margin: 0` (no default heading spacing).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| level | `1 \\| 2 \\| 3 \\| 4 \\| 5 \\| 6` | `2` | Semantic heading level. Determines both the HTML element (`<h1>`-`<h6>`) and the preset typography style. |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `'default' (var(--ggui-color-onContainer))` | Semantic color slot. Same vocabulary as ; see that prop's docs for the full slot table. `tone` is the ONLY way to set a Heading color \u2014 the legacy `color?: string` escape was retired so the operator's theme always wins. |\n| align | `'left' \\| 'center' \\| 'right'` | `undefined (inherits from parent)` | Horizontal text alignment. Maps directly to `text-align`. |\n\n**Example:**\n```tsx\n<Stack gap=\"var(--ggui-spacing-2)\">\n <Heading level={1}>Page Title</Heading>\n <Heading level={3} tone=\"emphasized\">\n Subsection\n </Heading>\n <Text>Body content goes here.</Text>\n</Stack>\n```\n\n### Button\n\nButton -- A clickable button primitive with multiple visual variants and sizes.\n\nRenders a native `<button>` element styled with inline CSS derived from design-token\nCSS variables. Supports a loading spinner, left/right icon slots, and a cross-platform\n`onPress` alias for `onClick`.\n\nBase styles applied to every variant:\n- `border-radius: var(--ggui-shape-radius-control)` \u2014 the control ROLE (the host's button/field radius; falls to `var(--ggui-shape-radius-md)`)\n- `font-weight: var(--ggui-font-weight-medium)`\n- `box-shadow: var(--ggui-shape-shadow-sm)`\n- `gap: var(--ggui-spacing-2)` between icon and text\n- Transitions: background-color, box-shadow, opacity at 200ms ease-in-out\n\nDisabled or loading: `opacity: 0.5`, `cursor: not-allowed`, click handler suppressed.\n\nAlso extends native `ButtonHTMLAttributes` (except `style`/`className`), so props\nlike `type`, `form`, `aria-*`, and `data-*` are forwarded to the `<button>` element.\nThe `type` prop defaults to `'button'` (not `'submit'`), preventing accidental form\nsubmissions.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| variant | `'primary' \\| 'secondary' \\| 'outline' \\| 'ghost' \\| 'danger'` | `'primary'` | Visual style. Maps to CSS variables: - `'primary'` -- `var(--ggui-color-primary-600)` background, white text, no border - `'secondary'` -- `var(--ggui-color-sunken)` background, `var(--ggui-color-onContainer)` text, no border - `'outline'` -- transparent background, `1px solid var(--ggui-color-primary-600)` border, label in the surface's on-colour (`onContainer`) so it reads on any ground \u2014 the brand shows on the border, never as label ink on a dark theme - `'ghost'` -- transparent background, `var(--ggui-color-onContainer)` text, no border (its rank is the missing chrome, not a dimmer label, which would read as disabled) - `'danger'` -- `var(--ggui-color-error-600)` background, white text, no border |\n| size | `'xs' \\| 'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls padding, font size, and minimum height: - `'xs'` -- padding `4px 8px`, font `var(--ggui-font-size-xs)`, min-height 24px - `'sm'` -- padding `6px 12px`, font `var(--ggui-font-size-sm)`, min-height 32px - `'md'` -- padding `10px 16px`, font `var(--ggui-font-size-sm)`, min-height 40px - `'lg'` -- padding `12px 24px`, font `var(--ggui-font-size-base)`, min-height 48px |\n| fullWidth | `boolean` | `false` | When true, sets `width: 100%` so the button fills its container. |\n| loading | `boolean` | `false` | When true, replaces children with a 16px `Spinner` (color: `currentColor`) and disables interaction (same effect as `disabled`). |\n| type | `'button' \\| 'submit' \\| 'reset'` | `'button'` | The HTML button type. Defaults to `'button'`, so a Button inside a `<form>` never submits it on click: the ONE control that submits the form sets `type=\"submit\"`; secondary controls keep the default. |\n| leftIcon | `ReactNode` | - | ReactNode rendered before children, inside the flex layout with `var(--ggui-spacing-2)` gap. |\n| rightIcon | `ReactNode` | - | ReactNode rendered after children, inside the flex layout with `var(--ggui-spacing-2)` gap. |\n| onPress | `() => void` | - | Alias for `onClick` for cross-platform compatibility (React Native convention). If both `onClick` and `onPress` are provided, `onClick` takes precedence. |\n\n**Example:**\n```tsx\n<Button variant=\"primary\" size=\"md\" leftIcon={<Icon name=\"save\" />} onClick={handleSave}>\n Save Changes\n</Button>\n```\n\n### Input\n\nInput -- A single-line text input with label, validation, and helper text.\n\nRenders a `<div>` wrapper containing an optional `<label>`, a native `<input>`,\nand an optional message `<span>` for error or helper text.\n\nStyling:\n- Border: `1px solid var(--ggui-color-outline)` (normal),\n `var(--ggui-color-error-500)` (error)\n- Background: `var(--ggui-color-container)` (normal),\n `var(--ggui-color-container)` (disabled)\n- Text: `var(--ggui-color-onContainer)`\n- Border radius: `var(--ggui-shape-radius-control)` \u2014 the control ROLE (the host's button/field radius; falls to `var(--ggui-shape-radius-md)`)\n- Label: `var(--ggui-font-size-sm)`, `var(--ggui-font-weight-medium)`,\n `var(--ggui-color-onSunken)`\n- Transitions: border-color, box-shadow at 200ms ease-in-out\n\nAccessibility: auto-generated `id` links `<label>` to `<input>` via `htmlFor`.\nWhen `error` is set, `aria-invalid` is true and the message has `role=\"alert\"`.\nWhen `required` is true, a red asterisk is appended to the label.\n\nAlso extends native `InputHTMLAttributes` (except `style`, `className`, `onChange`,\n`size`), so props like `autoFocus`, `name`, `pattern`, `aria-*` are forwarded.\n\n**IMPORTANT:** `onChange` receives the string value directly, NOT a React\n`ChangeEvent`. This differs from native `<input>` behavior.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Label rendered above the input. Linked to the input via auto-generated `htmlFor`/`id`. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| placeholder | `string` | - | Placeholder text shown when the input is empty. |\n| value | `string` | - | Controlled value of the input. |\n| onChange | `(value: string) => void` | - | Change handler. Receives the new string value directly, NOT a React event. |\n| type | `'text' \\| 'email' \\| 'password' \\| 'number' \\| 'tel' \\| 'url' \\| 'search' \\| 'date' \\| 'time' \\| 'datetime-local'` | `'text'` | HTML input type. Determines browser behavior (keyboard on mobile, validation, masking). The date/time types render the browser's native, localized, keyboard-accessible picker; their `value` stays an ISO string (`'2026-07-29'`, `'14:30'`, `'2026-07-29T14:30'`) delivered through the usual value-first `onChange`. |\n| error | `string` | - | Error message displayed below the input in `var(--ggui-color-error-500)`. When set, the border turns red and the message element gets `role=\"alert\"`. Takes precedence over `helperText`. |\n| helperText | `string` | - | Helper text displayed below the input in `var(--ggui-color-onSunken)`. Only shown when `error` is not set. |\n| required | `boolean` | `false` | When true, appends a red asterisk (`*`) to the label and sets the native `required` attribute on the `<input>`. |\n| disabled | `boolean` | `false` | When true, sets the native `disabled` attribute. Background changes to `var(--ggui-color-container)`. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls padding and font size: - `'sm'` -- padding `6px 10px`, font `var(--ggui-font-size-sm)` - `'md'` -- padding `10px 12px`, font `var(--ggui-font-size-sm)` - `'lg'` -- padding `12px 14px`, font `var(--ggui-font-size-base)` |\n\n**Example:**\n```tsx\n<Input label=\"Email\" type=\"email\" value={email} onChange={setEmail} error={emailError} />\n```\n\n### TextArea\n\nTextArea -- A multiline text input with label, validation, character count, and auto-resize.\n\nRenders a `<div>` wrapper containing an optional `<label>`, a native `<textarea>`,\nand a footer row with error/helper text on the left and character count on the right.\n\nStyling:\n- Padding: `10px 12px`, font: `var(--ggui-font-size-sm)`, `font-family: inherit`\n- Border: `1px solid var(--ggui-color-outline)` (normal),\n `var(--ggui-color-error-500)` (error)\n- Background: `var(--ggui-color-container)` (normal),\n `var(--ggui-color-container)` (disabled)\n- Border radius: `var(--ggui-shape-radius-control)` \u2014 the control ROLE (the host's button/field radius; falls to `var(--ggui-shape-radius-md)`)\n- Resize: `vertical` by default, `none` when `autoResize` is true\n- Transitions: border-color, box-shadow at 200ms ease-in-out\n\nAccessibility: same label/error linking pattern as Input (auto-generated ids,\n`aria-invalid`, `role=\"alert\"` on error message).\n\nAlso extends native `TextareaHTMLAttributes` (except `style`, `className`, `onChange`).\n\n**IMPORTANT:** `onChange` receives the string value directly, NOT a React\n`ChangeEvent`. This differs from native `<textarea>` behavior.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Label rendered above the textarea. Linked via auto-generated `htmlFor`/`id`. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| placeholder | `string` | - | Placeholder text shown when the textarea is empty. |\n| value | `string` | - | Controlled value of the textarea. |\n| onChange | `(value: string) => void` | - | Change handler. Receives the new string value directly, NOT a React event. |\n| rows | `number` | `4` | Number of visible text rows (native `rows` attribute on `<textarea>`). |\n| error | `string` | - | Error message displayed below the textarea in `var(--ggui-color-error-500)`. When set, the border turns red and the message element gets `role=\"alert\"`. Takes precedence over `helperText`. |\n| helperText | `string` | - | Helper text displayed below the textarea in `var(--ggui-color-onSunken)`. Only shown when `error` is not set. |\n| required | `boolean` | `false` | When true, appends a red asterisk (`*`) to the label and sets the native `required` attribute on the `<textarea>`. |\n| disabled | `boolean` | `false` | When true, sets the native `disabled` attribute. Background changes to `var(--ggui-color-container)`. |\n| maxLength | `number` | - | Maximum character length (native `maxLength` attribute). Also used as the denominator in the character count display when `showCount` is true. |\n| showCount | `boolean` | `false` | When true AND `maxLength` is set, displays a `{current}/{max}` character counter in the footer row (right-aligned, `var(--ggui-font-size-xs)`). Has no effect without `maxLength`. |\n| autoResize | `boolean` | `false` | When true, sets CSS `resize: none` on the textarea. The flag disables manual resizing to signal that external logic handles sizing. The component does NOT auto-adjust height based on content in the current implementation. |\n\n**Example:**\n```tsx\n<TextArea label=\"Bio\" value={bio} onChange={setBio} rows={6} maxLength={500} showCount />\n```\n\n### Select\n\nSelect -- A native dropdown selection primitive with label and validation.\n\nRenders a `<div>` wrapper containing an optional `<label>`, a native `<select>`\nwith custom styling, and an optional message `<span>`.\n\nThe native `<select>` has `appearance: none` with a custom chevron SVG rendered\nas a `background-image` (right-aligned, 12px, onSurfaceVariant color). Extra right\npadding (36px) accommodates the chevron.\n\nStyling:\n- Border: `1px solid var(--ggui-color-outline)` (normal),\n `var(--ggui-color-error-500)` (error)\n- Background: `var(--ggui-color-container)` (normal),\n `var(--ggui-color-container)` (disabled)\n- Text: `var(--ggui-color-onContainer)` for the displayed option,\n `var(--ggui-color-onSunken)` only while the placeholder shows (a\n `placeholder` with no `value` or `defaultValue`, until the user picks;\n or with `value=\"\"` / `defaultValue=\"\"`)\n- Border radius: `var(--ggui-shape-radius-control)` \u2014 the control ROLE (the host's button/field radius; falls to `var(--ggui-shape-radius-md)`)\n- Cursor: `pointer` (normal), `not-allowed` (disabled)\n- Transitions: border-color, box-shadow at 200ms ease-in-out\n\nAccessibility: auto-generated `id` links `<label>` to `<select>`.\nWhen `error` is set, `aria-invalid` is true and the message has `role=\"alert\"`.\n\nAlso extends native `SelectHTMLAttributes` (except `style`, `className`,\n`onChange`, `size`).\n\n**IMPORTANT:** `onChange` receives the selected value string directly, NOT a\nReact `ChangeEvent`. This differs from native `<select>` behavior.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Label rendered above the select. Linked via auto-generated `htmlFor`/`id`. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| value | `string` | - | Controlled value. Should match one of the `options[].value` strings. |\n| onChange | `(value: string) => void` | - | Change handler. Receives the selected option's value string directly, NOT a React event. |\n| options | `SelectOption[]` | - | Array of selectable options. Rendered as native `<option>` elements. Must contain at least one option (or use `placeholder` for an empty-state prompt). |\n| placeholder | `string` | - | Placeholder text rendered as a disabled `<option value=\"\">` at the top of the list. With no `value` and no `defaultValue` the select starts on it, so it shows until the user picks; it cannot be picked again after that. |\n| error | `string` | - | Error message displayed below the select in `var(--ggui-color-error-500)`. When set, the border turns red and the message has `role=\"alert\"`. Takes precedence over `helperText`. |\n| helperText | `string` | - | Helper text displayed below the select in `var(--ggui-color-onSunken)`. Only shown when `error` is not set. |\n| required | `boolean` | `false` | When true, appends a red asterisk (`*`) to the label and sets the native `required` attribute. |\n| disabled | `boolean` | `false` | When true, sets the native `disabled` attribute. Background changes to `var(--ggui-color-container)` and cursor becomes `not-allowed`. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls padding and font size: - `'sm'` -- padding `6px 10px`, font `var(--ggui-font-size-sm)` - `'md'` -- padding `10px 12px`, font `var(--ggui-font-size-sm)` - `'lg'` -- padding `12px 14px`, font `var(--ggui-font-size-base)` |\n\n**Example:**\n```tsx\n<Select\n label=\"Country\"\n value={country}\n onChange={setCountry}\n options={[\n { value: 'us', label: 'United States' },\n { value: 'uk', label: 'United Kingdom' },\n ]}\n placeholder=\"Select a country\"\n/>\n```\n\n### Checkbox\n\nCheckbox -- A custom-styled checkbox with label and description.\n\nRenders a `<label>` wrapper containing a visually-hidden native `<input type=\"checkbox\">`\noverlaid by a custom 18x18px visual box. Supports checked, unchecked, and indeterminate\nstates, each with a distinct SVG icon (checkmark or horizontal dash).\n\nStyling:\n- Box border: `2px solid var(--ggui-color-primary-600)` (checked/indeterminate),\n `var(--ggui-color-outline)` (unchecked)\n- Box fill: `var(--ggui-color-primary-600)` (checked/indeterminate),\n `var(--ggui-color-container)` (unchecked)\n- Check/dash icon: white SVG, 12x12px\n- Box radius: `var(--ggui-shape-radius-sm)`\n- Transition: all 0.2s\n- Label: `var(--ggui-font-size-sm)`, `var(--ggui-font-weight-medium)`, `var(--ggui-color-onContainer)`\n- Description: `var(--ggui-font-size-xs)`, `var(--ggui-color-onSunken)`\n- Disabled: `opacity: 0.5`, `cursor: not-allowed`\n- Gap between box and text: `var(--ggui-spacing-2)`\n\n**IMPORTANT:** `onChange` receives the boolean checked state directly, NOT a\nReact `ChangeEvent`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Primary label text rendered beside the checkbox box. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onContainer)`. |\n| checked | `boolean` | - | Controlled checked state. |\n| onChange | `(checked: boolean) => void` | - | Change handler. Receives the new boolean checked state directly, NOT a React event. |\n| disabled | `boolean` | `false` | When true, sets `opacity: 0.5` and `cursor: not-allowed`. The native input is also disabled, preventing keyboard and click interaction. |\n| description | `string` | - | Secondary description text rendered below the label in smaller, muted type (`var(--ggui-font-size-xs)`, `var(--ggui-color-onSunken)`). |\n| indeterminate | `boolean` | `false` | When true, displays a horizontal dash instead of a checkmark. Used for \"select all\" states where some (but not all) children are checked. The `indeterminate` property is set via a ref on the native `<input>`. Visually identical to `checked` in terms of border and fill color. |\n\n**Example:**\n```tsx\n<Checkbox\n label=\"Accept terms\"\n description=\"You agree to the Terms of Service and Privacy Policy\"\n checked={accepted}\n onChange={setAccepted}\n/>\n```\n\n### Toggle\n\nToggle -- A switch/toggle input rendered as a pill-shaped track with a sliding knob.\n\nRenders a `<label>` wrapper with a `<div role=\"switch\">` track and an animated\ncircular knob. Does NOT use a native `<input>` -- keyboard interaction is handled\nmanually (Space and Enter keys toggle the state). The element is focusable via\n`tabIndex={0}` and shows a focus ring on focus.\n\nStyling:\n- Track (on): `var(--ggui-color-primary-600)`\n- Track (off): `var(--ggui-color-outline)`\n- Knob: white circle with `var(--ggui-shape-shadow-sm)`\n- Focus ring: `0 0 0 3px var(--ggui-color-primary-200)`\n- Transitions: background-color, box-shadow, knob position at 200ms ease-in-out\n- Disabled: `opacity: 0.5`, `cursor: not-allowed`, `tabIndex: -1`\n- Gap between toggle and label: `var(--ggui-spacing-2)`\n\n**IMPORTANT:** `onChange` receives the new boolean state directly (inverted from\ncurrent), NOT a React event.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Label text rendered to the right of the toggle track. Also used as `aria-label` on the switch element. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onContainer)`. |\n| checked | `boolean` | - | Controlled checked (on/off) state. |\n| onChange | `(checked: boolean) => void` | - | Change handler. Receives the new boolean state directly (i.e., `!checked`), NOT a React event. |\n| disabled | `boolean` | `false` | When true, sets `opacity: 0.5`, `cursor: not-allowed`, and removes the element from tab order (`tabIndex: -1`). Click and keyboard handlers are suppressed. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls track and knob dimensions: - `'sm'` -- track 36x20px, knob 16px diameter - `'md'` -- track 44x24px, knob 20px diameter - `'lg'` -- track 52x28px, knob 24px diameter |\n\n**Example:**\n```tsx\n<Toggle label=\"Enable notifications\" checked={enabled} onChange={setEnabled} size=\"md\" />\n```\n\n### RadioGroup\n\nRadioGroup -- A group of mutually exclusive radio options with optional label and error.\n\nRenders a `<div role=\"radiogroup\">` containing a label span, a flex container of\nradio options, and an optional error message. Each option is a `<label>` with a\nvisually-hidden native `<input type=\"radio\">` and a custom 18px circle indicator.\n\nStyling:\n- Selected circle: `2px solid var(--ggui-color-primary-600)` border with\n an 8px `var(--ggui-color-primary-600)` filled inner dot\n- Unselected circle: `2px solid var(--ggui-color-outline)` border,\n `var(--ggui-color-container)` fill\n- Circle radius: `var(--ggui-shape-radius-full)`\n- Transition: all 0.2s\n- Vertical gap: `var(--ggui-spacing-2)`, horizontal gap: `var(--ggui-spacing-4)`\n- Error: `var(--ggui-font-size-xs)`, `var(--ggui-color-error-500)`,\n `role=\"alert\"`\n- Disabled options: `opacity: 0.5`, `cursor: not-allowed`\n\nAccessibility: the group has `role=\"radiogroup\"` with `aria-labelledby` pointing\nto the label and `aria-describedby` pointing to the error message (when present).\nAll radio inputs share a common auto-generated `name` attribute.\n\n**IMPORTANT:** `onChange` receives the selected option's value string directly,\nNOT a React `ChangeEvent`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Group label rendered above the options. Used as `aria-labelledby` target on the `role=\"radiogroup\"` container. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| value | `string` | - | Controlled value. Should match one of `options[].value`. |\n| onChange | `(value: string) => void` | - | Change handler. Receives the newly selected option's value string directly, NOT a React event. |\n| options | `RadioOption[]` | - | Array of radio options. Must contain at least two options for meaningful selection. |\n| direction | `'vertical' \\| 'horizontal'` | `'vertical'` | Layout direction for the options container: - `'vertical'` -- column layout, `var(--ggui-spacing-2)` gap - `'horizontal'` -- row layout with `flex-wrap: wrap`, `var(--ggui-spacing-4)` gap |\n| disabled | `boolean` | `false` | When true, disables ALL options (individual `RadioOption.disabled` is additive). Each option gets `opacity: 0.5` and `cursor: not-allowed`. |\n| error | `string` | - | Error message displayed below all options in `var(--ggui-color-error-500)` with `role=\"alert\"`. Linked to the radiogroup via `aria-describedby`. |\n\n**Example:**\n```tsx\n<RadioGroup\n label=\"Plan\"\n value={plan}\n onChange={setPlan}\n options={[\n { value: 'free', label: 'Free', description: 'Up to 5 projects' },\n { value: 'pro', label: 'Pro', description: 'Unlimited projects' },\n ]}\n/>\n```\n\n### Slider\n\nSlider -- A range input with a custom-styled track, fill, and thumb.\n\nRenders a `<div>` wrapper containing an optional label/value header, and a\ntrack area with three layers: background track, colored fill, and a circular\nthumb. A native `<input type=\"range\">` is overlaid with `opacity: 0` to\nprovide accessible keyboard and pointer interaction.\n\nStyling:\n- Track: 6px tall, `var(--ggui-color-outlineVariant)` background, `border-radius: 3px`\n- Fill: `var(--ggui-color-primary-600)` (normal),\n `var(--ggui-color-outline)` (disabled)\n- Thumb: 20px white circle with `2px solid var(--ggui-color-primary-600)`,\n `var(--ggui-shape-shadow-sm)`; disabled border uses outline\n- Value display (when `showValue`): `var(--ggui-color-primary-600)`,\n `var(--ggui-font-size-sm)`, right-aligned in the header row\n- Fill and thumb transitions: 0.1s for smooth dragging\n\nAccessibility: the native `<input type=\"range\">` carries `aria-valuenow`,\n`aria-valuemin`, `aria-valuemax`, and is linked to the label via `aria-labelledby`.\nFalls back to `aria-label=\"Slider\"` when no label is provided.\n\n**IMPORTANT:** `onChange` receives the numeric value directly, NOT a React\n`ChangeEvent`. The value is coerced via `Number(e.target.value)`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Label rendered above the slider track (left-aligned). Used as `aria-labelledby` target on the native range input. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onSunken)`. |\n| value | `number` | `0` | Controlled numeric value. Must be between `min` and `max`. |\n| onChange | `(value: number) => void` | - | Change handler. Receives the new numeric value directly, NOT a React event. |\n| min | `number` | `0` | Minimum allowed value. |\n| max | `number` | `100` | Maximum allowed value. |\n| step | `number` | `1` | Step increment for the slider. Determines the granularity of selectable values. |\n| disabled | `boolean` | `false` | When true, sets `cursor: not-allowed` on the native input. The fill color changes to `var(--ggui-color-outline)` and the thumb border also uses outline. |\n| showValue | `boolean` | `false` | When true, displays the current numeric value right-aligned in the header row (beside the label) in `var(--ggui-color-primary-600)`. |\n\n**Example:**\n```tsx\n<Slider label=\"Volume\" value={volume} onChange={setVolume} min={0} max={100} step={5} showValue />\n```\n\n### Badge\n\nBadge -- Inline label for status indicators, counts, or categories.\n\nRenders a `<span>` with `display: inline-flex`, centered content, and\n`white-space: nowrap`. Semantic variant colors use background/text pairings\nfrom the 100/700 color scale. Pill shape uses `border-radius: 9999px`;\nnon-pill uses `var(--ggui-shape-radius-sm)`.\n\nFont weight: `var(--ggui-font-weight-medium)` across all variants.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| variant | `'default' \\| 'primary' \\| 'secondary' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'tertiary'` | `'default'` | Visual style. Maps to background/text color pairings: - `'default'` -- bg `var(--ggui-color-sunken)`, text `var(--ggui-color-onSunken)` - `'primary'` -- bg `var(--ggui-color-primary-100)`, text `var(--ggui-color-primary-700)` - `'secondary'` -- bg `var(--ggui-color-outlineVariant)`, text `var(--ggui-color-onContainer)` - `'success'` -- bg `var(--ggui-color-success-100)`, text `var(--ggui-color-success-700)` - `'warning'` -- bg `var(--ggui-color-warning-100)`, text `var(--ggui-color-warning-700)` - `'error'` -- bg `var(--ggui-color-error-100)`, text `var(--ggui-color-error-700)` - `'info'` -- bg `var(--ggui-color-info-100)`, text `var(--ggui-color-info-700)` |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls padding and font size: - `'sm'` -- padding `2px 6px`, font `var(--ggui-font-size-xs)` - `'md'` -- padding `2px 8px`, font `var(--ggui-font-size-xs)` - `'lg'` -- padding `4px 10px`, font `var(--ggui-font-size-sm)` |\n| pill | `boolean` | `true` | When true, uses fully rounded corners (`border-radius: 9999px`). When false, uses `var(--ggui-shape-radius-sm)`. |\n\n**Example:**\n```tsx\n<Badge variant=\"success\" size=\"sm\">Active</Badge>\n```\n\n### Spinner\n\nSpinner -- Animated SVG loading indicator.\n\nRenders an `<svg>` with `role=\"status\"` and `aria-label=\"Loading\"`.\nThe SVG contains a full outlineVariant background circle and a quarter-arc\nforeground stroke in the spinner color.\n\nAnimation: `ggui-spin 1s linear infinite` (360-degree rotation).\nThe `@keyframes ggui-spin` definition is injected inline via a `<style>` tag.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| size | `number` | `24` | Width and height of the SVG element in pixels. The internal viewBox is always `0 0 24 24`, so this controls rendered size only. |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `undefined (uses `var(--ggui-color-primary-600)`)` | Semantic color slot for the animated foreground arc. Same vocabulary as ; the theme decides the resolved value. The background circle always uses `var(--ggui-color-outlineVariant)`. Use `'inherit'` when the spinner sits inside a colored container (e.g. inside a Button) \u2014 the stroke picks up `currentColor` from the parent so it tracks the container's foreground. |\n\n**Example:**\n```tsx\n<Spinner size={32} tone=\"success\" />\n```\n\n### Avatar\n\nAvatar -- User or entity representation with image or auto-generated initials.\n\nRenders a `<div role=\"img\">` with `overflow: hidden` and `flex-shrink: 0`.\nWhen `src` is provided and loads successfully, renders an `<img>` with\n`object-fit: cover`. On image error (or when no `src`), falls back to\ninitials derived from `name` (up to 2 characters, uppercase).\n\nInitials background: deterministic color from a 5-color palette based on\nname hash (primary-500, success-500, warning-500, error-500, info-500).\nFalls back to `var(--ggui-color-outline)` when no name is given.\nInitials text: white, `font-weight: var(--ggui-font-weight-semibold)`,\n`font-size: resolvedSize * 0.4`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| src | `string` | - | Image URL. When provided and the image loads, it is rendered with `object-fit: cover`. On load error, falls back to initials. |\n| name | `string` | - | Name used for two purposes: 1. Generating initials (splits on spaces, takes first letter of each word, max 2). 2. Deterministic background color selection via character code hash. Also used as `aria-label` on the container. Falls back to `'Avatar'` if omitted. |\n| size | `number \\| 'xs' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl'` | `'md'` | Avatar dimensions. Named sizes map to pixel values: - `'xs'` -- 24px - `'sm'` -- 32px - `'md'` -- 40px - `'lg'` -- 48px - `'xl'` -- 64px Numeric values are used directly as pixel dimensions. |\n| shape | `'circle' \\| 'square'` | `'circle'` | Container shape. - `'circle'` -- `border-radius: 50%` - `'square'` -- `border-radius: var(--ggui-shape-radius-md)` |\n\n**Example:**\n```tsx\n<Avatar src=\"/photos/jane.jpg\" name=\"Jane Doe\" size=\"lg\" shape=\"circle\" />\n```\n\n### Alert\n\nAlert -- Contextual message box for important information with icon and optional dismiss.\n\nRenders a `<div role=\"alert\">` with flex layout (12px gap), variant-specific\nbackground, border, text color, and a leading icon. Each variant provides a\ndefault SVG icon (info circle, checkmark, warning triangle, or X circle) that\ncan be overridden via the `icon` prop.\n\nLayout: icon (flex-shrink: 0) | content column (title + body) | close button.\nBorder radius: `var(--ggui-shape-radius-lg)`.\nPadding: `12px 16px`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| variant | `'info' \\| 'success' \\| 'warning' \\| 'error'` | `'info'` | Visual style. Maps to background/border/text/icon color sets: - `'info'` -- bg `var(--ggui-color-info-50)`, border `var(--ggui-color-info-200)`, text `var(--ggui-color-info-800)`, icon `var(--ggui-color-info-500)` - `'success'` -- bg `var(--ggui-color-success-50)`, border `var(--ggui-color-success-200)`, text `var(--ggui-color-success-800)`, icon `var(--ggui-color-success-500)` - `'warning'` -- bg `var(--ggui-color-warning-50)`, border `var(--ggui-color-warning-200)`, text `var(--ggui-color-warning-800)`, icon `var(--ggui-color-warning-500)` - `'error'` -- bg `var(--ggui-color-error-50)`, border `var(--ggui-color-error-200)`, text `var(--ggui-color-error-800)`, icon `var(--ggui-color-error-500)` |\n| title | `string` | - | Optional title rendered above the body in semibold (`var(--ggui-font-weight-semibold)`), `var(--ggui-font-size-sm)`. Title and body are separated by `var(--ggui-spacing-1)` gap. |\n| closable | `boolean` | `false` | When true, renders a close button (X icon) in the top-right area. The button has `min-width: 28px`, `min-height: 28px`, and `opacity: 0.7`. Requires `onClose` to be functional. |\n| onClose | `() => void` | - | Callback fired when the close button is clicked. Only relevant when `closable` is true. |\n| icon | `ReactNode` | - | Custom icon ReactNode to replace the default variant icon. Rendered at the leading position with the variant's icon color applied via `color` CSS property. |\n\n**Example:**\n```tsx\n<Alert variant=\"warning\" title=\"Rate limit\" closable onClose={() => setShow(false)}>\n You have 3 requests remaining this minute.\n</Alert>\n```\n\n### Progress\n\nProgress -- Horizontal progress bar with determinate and indeterminate modes.\n\nRenders a track `<div role=\"progressbar\">` with a colored fill child.\nThe track background is `var(--ggui-color-outlineVariant)` with\npill-shaped corners (border-radius = height / 2).\n\nDeterminate mode: fill width transitions smoothly (`width 0.3s ease`).\nIndeterminate mode: fill is 30% width, animated with\n`ggui-progress-indeterminate 1.5s ease-in-out infinite`\n(translateX from -100% to 400%). The `@keyframes` are injected inline.\n\nAccessibility: `aria-valuenow` is set in determinate mode, omitted in\nindeterminate. `aria-valuemin` is always 0, `aria-valuemax` matches `max`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| value | `number` | - | Current progress value. Clamped to `[0, max]` and converted to a percentage for the fill width: `Math.min(100, Math.max(0, (value / max) * 100))`. |\n| max | `number` | `100` | Maximum value representing 100% progress. |\n| variant | `'default' \\| 'success' \\| 'warning' \\| 'error'` | `'default'` | Fill bar color. Maps to CSS variables: - `'default'` -- `var(--ggui-color-primary-600)` - `'success'` -- `var(--ggui-color-success-500)` - `'warning'` -- `var(--ggui-color-warning-500)` - `'error'` -- `var(--ggui-color-error-500)` |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls track height in pixels: - `'sm'` -- 4px - `'md'` -- 8px - `'lg'` -- 12px |\n| label | `string` | - | Accessible name describing what this bar measures, e.g. `\"Survey progress\"` or `\"Upload\"`. Becomes the progressbar's `aria-label` and \u2014 when `showLabel` is set \u2014 the visible header text in place of the generic word \"Progress\". Always pass this when the surrounding context does not already make the meaning obvious. |\n| showLabel | `boolean` | `false` | When true, displays a header row above the track with the `label` text (or \"Progress\" if `label` is unset) on the left, and the rounded percentage value on the right. |\n| indeterminate | `boolean` | `false` | When true, ignores `value` for visual width and plays a looping animation instead. The fill bar is 30% width and slides across the track. Animation: `ggui-progress-indeterminate 1.5s ease-in-out infinite`. `aria-valuenow` is omitted from the progressbar element. |\n\n**Example:**\n```tsx\n<Progress value={65} variant=\"success\" size=\"md\" showLabel />\n```\n\n### Image\n\nImage -- An `<img>` element with built-in error handling and fallback support.\n\nRenders a native `<img>` with `display: block`. On load error, either renders\nthe `fallback` ReactNode (if provided) or a default placeholder `<div>` with a\nsurfaceVariant background and a centered image SVG icon in outline.\n\nSize values: numbers are treated as pixels, strings are passed through as-is.\nWhen no `width` is set, defaults to `100%`. When no `height` is set, defaults\nto `auto`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| src | `string` | - | Image source URL. Load failure triggers the fallback state. |\n| alt | `string` | - | Alt text for the image. Used as `aria-label` in the error placeholder too. |\n| width | `number \\| string` | `'100%' (applied at render time, not on the type)` | Image width. Numbers are pixels, strings are CSS values (e.g., `'100%'`, `'50vw'`). |\n| height | `number \\| string` | `'auto' (applied at render time, not on the type)` | Image height. Numbers are pixels, strings are CSS values. |\n| objectFit | `'cover' \\| 'contain' \\| 'fill' \\| 'none' \\| 'scale-down'` | `'cover'` | CSS `object-fit` value controlling how the image fills its box. |\n| radius | `'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| number \\| string` | - | Corner radius applied to both the image and the error placeholder. Prefer a radius-scale name (`'none' \\| 'sm' \\| 'md' \\| 'lg' \\| 'xl'`) \u2014 each resolves to the matching `--ggui-shape-radius-*` token. A number is treated as pixels; any other string is passed through. |\n| fallback | `ReactNode` | - | Custom ReactNode rendered when the image fails to load. When provided, completely replaces the default error placeholder (no wrapper div). When omitted, a surfaceVariant background div with an image icon is shown. |\n\n**Example:**\n```tsx\n<Image src=\"/hero.jpg\" alt=\"Hero banner\" width=\"100%\" height={400} objectFit=\"cover\" radius=\"md\" />\n```\n\n### Icon\n\nIcon -- a curated Lucide subset (~200 names) + emoji passthrough.\n\nThree resolution layers:\n1. **Lucide icon:** pass a name from the curated subset (e.g. `sun`, `cloud-rain`, `heart`, `shopping-cart`, `sparkles`, `arrow-up-right`).\n Accepts kebab-case, camelCase, or PascalCase. Renders as stroke SVG. ONLY the subset renders: an\n unknown name renders an empty box (never a placeholder glyph) \u2014 the `get_available_icons` tool lists the exact names.\n2. **Emoji:** pass emoji/unicode directly (e.g. `\u2600\uFE0F`, `\u{1F327}\uFE0F`). Rendered as text.\n3. **Custom SVG:** pass children (`<svg>` element) for full control.\n\nContainer: `<span>` with `display: inline-flex`, centered content.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| name | `string` | - | Lucide icon name from the curated subset (kebab-case, camelCase, or PascalCase all work); a name outside the subset renders an empty box. Also accepts emoji/unicode characters directly. |\n| size | `number` | `24` | Icon dimensions in pixels (applied to both width and height of the wrapper and the inner SVG element). |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `undefined (icon uses `currentColor`)` | Semantic color slot. Same vocabulary as ; the theme decides what each tone LOOKS like. Resolves to a CSS `color` on the wrapper which the inner SVG inherits via `currentColor`. Use `'inherit'` (the default behavior when unset) for icons that should pick up the parent's foreground color. |\n| children | `ReactNode` | - | Custom SVG children. When provided, `name` is ignored and children are rendered inside a sized `<span>` wrapper. |\n| 'aria-label' | `string` | - | Accessible name for a standalone, meaning-bearing icon. When set, the icon exposes `role=\"img\"` + this label. When omitted (the default) the icon is decorative and hidden from screen readers (`aria-hidden`) \u2014 the right choice for an icon next to a text label. |\n\n**Example:**\n```tsx\n<Icon name=\"search\" size={20} tone=\"muted\" />\n<Icon name=\"cloud-rain\" size={32} />\n<Icon name=\"\u2600\uFE0F\" size={24} />\n```\n\n### Link\n\nLink -- Styled anchor element with external link support.\n\nRenders a native `<a>` element. When `external` is true, sets\n`target=\"_blank\"` and `rel=\"noopener noreferrer\"`, and appends a small\n(12px) external-link SVG icon after the children.\n\nTransition: `color 0.2s`.\nUnderline behavior is controlled via mouseEnter/mouseLeave event handlers\n(for the `'hover'` mode).\n\nAlso extends native `AnchorHTMLAttributes` (except `style`/`className`),\nso props like `aria-*`, `data-*`, `title`, etc. are forwarded to the `<a>`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | children |\n| href | `string` | - | Destination URL. Passed directly to the `<a href>` attribute. |\n| external | `boolean` | `false` | When true, opens link in a new tab (`target=\"_blank\"`, `rel=\"noopener noreferrer\"`) and appends a 12px external-link icon after children. |\n| tone | `\\| 'default' \\| 'muted' \\| 'subtle' \\| 'emphasized' \\| 'loud' \\| 'success' \\| 'warning' \\| 'error' \\| 'info' \\| 'inverse' \\| 'inherit'` | `undefined (uses `var(--ggui-color-primary-600)`)` | Semantic color slot for the link text. Same vocabulary as ; the theme decides what each tone LOOKS like. Defaults to a primary-tinted accent (`'loud'`-ish) when unset. |\n| underline | `'always' \\| 'hover' \\| 'none'` | `'hover'` | Underline behavior: - `'always'` -- `text-decoration: underline` at all times - `'hover'` -- underline appears on mouse enter, removed on mouse leave - `'none'` -- no underline ever |\n\n**Example:**\n```tsx\n<Link href=\"https://docs.ggui.ai\" external>Documentation</Link>\n```\n\n### Tooltip\n\nTooltip -- Hoverable information popup positioned relative to a trigger element.\n\nWraps `children` in a `<div>` trigger (display: inline-block) and renders\na fixed-position tooltip `<div role=\"tooltip\">` when visible.\n\nTooltip appearance:\n- Background: `var(--ggui-color-onContainer)`\n- Text: white, `var(--ggui-font-size-xs)`\n- Padding: `6px 10px`, border-radius: `var(--ggui-shape-radius-md)`\n- Max width: 200px, `white-space: nowrap`, `pointer-events: none`\n- Z-index: `zIndex.tooltip` (1800)\n\nShow/hide: triggered by mouseEnter/mouseLeave AND focus/blur on the\ntrigger element. Uses `position: fixed` with coordinates calculated from\n`getBoundingClientRect()` and an 8px offset from the trigger edge.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | Trigger element. Wrapped in a `<div>` with mouseEnter/mouseLeave and focus/blur handlers. |\n| content | `ReactNode` | - | Tooltip content. Can be text or any ReactNode. |\n| position | `'top' \\| 'bottom' \\| 'left' \\| 'right'` | `'top'` | Tooltip placement relative to the trigger element: - `'top'` -- above, centered horizontally, transformed `translateX(-50%) translateY(-100%)` - `'bottom'` -- below, centered horizontally, transformed `translateX(-50%)` - `'left'` -- to the left, centered vertically, transformed `translateX(-100%) translateY(-50%)` - `'right'` -- to the right, centered vertically, transformed `translateY(-50%)` |\n| delay | `number` | `200` | Delay in milliseconds before the tooltip becomes visible after hover/focus. Hiding is immediate (no delay). |\n\n**Example:**\n```tsx\n<Tooltip content=\"Copy to clipboard\" position=\"top\">\n <Button variant=\"ghost\"><Icon name=\"copy\" /></Button>\n</Tooltip>\n```\n\n### Table\n\nTable -- Data table with sortable columns, striped rows, and hover highlights.\n\nRenders a scrollable wrapper `<div>` containing a native `<table>` with\n`border-collapse: collapse` and `width: 100%`. The wrapper has\n`overflow-x: auto` for horizontal scrolling on narrow viewports.\n\nHeader row: 2px bottom border (`var(--ggui-color-outlineVariant)`).\nData rows: 1px bottom border (`var(--ggui-color-sunken)`).\nHover: `var(--ggui-color-container)` background with 150ms ease transition.\nStriped: alternating rows (odd index) get `var(--ggui-color-container)`.\n\nSort behavior: clicking a sortable column header calls `onSort(key, direction)`.\nIf the same column is clicked again while ascending, it toggles to descending.\nThe component does NOT sort data internally -- the parent must sort `data` and\npass updated `sortKey`/`sortDirection`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| columns | `TableColumn<T>[]` | - | Array of column definitions controlling header labels, data keys, and rendering. |\n| data | `T[]` | - | Array of row data objects. Each object's keys should match the column `key` values. |\n| sortKey | `string` | - | The `key` of the currently sorted column. Used to highlight the active sort indicator and determine toggle direction on next click. |\n| sortDirection | `SortDirection` | `'asc'` | Current sort direction for the column identified by `sortKey`. Controls which triangle indicator is highlighted in the header. |\n| onSort | `(key: string, direction: SortDirection) => void` | - | Sort change handler. Called when a sortable column header is clicked. Receives the column `key` and the new `SortDirection`. The component does NOT sort data internally -- you must sort `data` in your state and pass updated `sortKey`/`sortDirection`. |\n| striped | `boolean` | `false` | When true, alternating rows (odd index) get a `var(--ggui-color-container)` background. |\n| hoverable | `boolean` | `true` | When true, rows highlight with `var(--ggui-color-container)` on mouse enter, with a 150ms ease background-color transition. |\n| compact | `boolean` | `false` | When true, reduces cell padding: - Compact: `var(--ggui-spacing-1) var(--ggui-spacing-2)` - Normal: `var(--ggui-spacing-2) var(--ggui-spacing-4)` |\n| bordered | `boolean` | `false` | When true, adds a 1px border around the table wrapper and between cells. Wrapper border: `1px solid var(--ggui-color-outlineVariant)`. Cell borders: `1px solid var(--ggui-color-sunken)`. Wrapper border-radius: `var(--ggui-shape-radius-lg)`. |\n| caption | `string` | - | Accessible table caption. Rendered as a `<caption>` element with `caption-side: top`, `var(--ggui-font-size-sm)`, `var(--ggui-color-onSunken)`. |\n\n**Example:**\n```tsx\n<Table\n columns={[\n { key: 'name', header: 'Name', sortable: true },\n { key: 'role', header: 'Role' },\n { key: 'status', header: 'Status', render: (v) => <Badge variant={v as string}>{v as string}</Badge> },\n ]}\n data={users}\n sortKey={sortKey}\n sortDirection={sortDir}\n onSort={(key, dir) => { setSortKey(key); setSortDir(dir); }}\n striped\n/>\n```\n\n### Tabs\n\nTabs -- Accessible tab navigation with panels and keyboard support.\n\nRenders a `<div role=\"tablist\">` with `<button role=\"tab\">` elements and a\n`<div role=\"tabpanel\">` for the active tab's content. Supports controlled\n(`activeKey` + `onChange`) and uncontrolled (internal state) modes.\n\nKeyboard navigation: ArrowLeft/ArrowRight (and ArrowUp/ArrowDown) cycle\nthrough enabled tabs. Home/End jump to first/last. Focus follows selection.\nDisabled tabs are skipped during keyboard navigation.\n\n**IMPORTANT:** `onChange` receives the tab's `key` string directly, NOT a\nReact event.\n\nTab panel padding: `var(--ggui-spacing-4) 0` (top/bottom only).\nTransitions: color, background-color, border-color at 200ms ease-in-out.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| items | `TabItem[]` | - | Array of tab definitions. Must contain at least one item. |\n| activeKey | `string` | - | Controlled active tab key. When provided, the component is controlled and will not manage its own state. Must match one of `items[].key`. When omitted, defaults to the first item's key (uncontrolled mode). |\n| onChange | `(key: string) => void` | - | Tab change handler. Receives the selected tab's `key` string directly, NOT a React event. In controlled mode, you must update `activeKey` in response to this callback. |\n| variant | `'line' \\| 'pills' \\| 'enclosed'` | `'line'` | Visual style of the tab bar: - `'line'` -- underline indicator (2px solid primary-600 on active), border-bottom on tab list - `'pills'` -- filled pill buttons (primary-600 bg, white text on active), surfaceVariant container with radius-lg - `'enclosed'` -- bordered tab buttons with open bottom (card-style), border-bottom on tab list |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls tab button padding and font size: - `'sm'` -- padding `var(--ggui-spacing-1) var(--ggui-spacing-2)`, font `var(--ggui-font-size-xs)` - `'md'` -- padding `var(--ggui-spacing-2) var(--ggui-spacing-4)`, font `var(--ggui-font-size-sm)` - `'lg'` -- padding `var(--ggui-spacing-4) var(--ggui-spacing-6)`, font `var(--ggui-font-size-base)` |\n| fullWidth | `boolean` | `false` | When true, tab buttons expand equally to fill the container width (`flex: 1`, `justify-content: center` on each button). |\n\n**Example:**\n```tsx\n<Tabs\n variant=\"pills\"\n items={[\n { key: 'overview', label: 'Overview', content: <Overview /> },\n { key: 'settings', label: 'Settings', content: <Settings /> },\n ]}\n activeKey={tab}\n onChange={setTab}\n/>\n```\n\n### Toast\n\nToast -- Notification banner with auto-dismiss and slide-in animation.\n\nRenders a `<div role=\"alert\" aria-live=\"assertive\">` with a variant-specific\nicon, optional title, message body, and optional close button.\n\nAnimation: `ggui-slideInUp 200ms ease-out both` on mount (from the motion\ntoken system). The keyframes are provided by the MotionKeyframes provider.\n\nAuto-dismiss: when `onClose` is provided and `duration > 0`, a timer calls\n`onClose` after `duration` ms. Setting `duration` to `0` disables auto-dismiss.\nThe timer resets if `visible`, `duration`, or `onClose` changes.\n\nDimensions: `min-width: 280px`, `max-width: 420px`.\nShadow: `var(--ggui-shape-shadow-lg)`.\nBorder radius: `var(--ggui-shape-radius-lg)`.\n\nWhen `visible` is false, renders nothing (returns `null`).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| message | `ReactNode` | - | Message body content. Can be text or any ReactNode. |\n| variant | `'info' \\| 'success' \\| 'warning' \\| 'error'` | `'info'` | Visual style. Maps to background/border/text/icon color sets (same palette as Alert): - `'info'` -- bg `var(--ggui-color-info-50)`, border `var(--ggui-color-info-200)`, text `var(--ggui-color-info-800)` - `'success'` -- bg `var(--ggui-color-success-50)`, border `var(--ggui-color-success-200)`, text `var(--ggui-color-success-800)` - `'warning'` -- bg `var(--ggui-color-warning-50)`, border `var(--ggui-color-warning-200)`, text `var(--ggui-color-warning-800)` - `'error'` -- bg `var(--ggui-color-error-50)`, border `var(--ggui-color-error-200)`, text `var(--ggui-color-error-800)` |\n| title | `string` | - | Optional title rendered above the message in semibold (`var(--ggui-font-weight-semibold)`, `var(--ggui-font-size-sm)`). |\n| duration | `number` | `5000` | Auto-dismiss delay in milliseconds. After this duration, `onClose` is called automatically. Set to `0` to disable auto-dismiss (toast stays until manually closed). The timer is only active when both `visible` is true and `onClose` is provided. |\n| onClose | `() => void` | - | Callback fired on auto-dismiss timeout or when the close button is clicked. When provided, a close button (X icon, 16px) is rendered in the top-right area. When omitted, no close button is shown and auto-dismiss is disabled. |\n| visible | `boolean` | `true` | Controls rendering. When false, the component returns `null`. Toggling from false to true triggers the slide-in animation. |\n| position | `'top-right' \\| 'top-left' \\| 'bottom-right' \\| 'bottom-left' \\| 'top-center' \\| 'bottom-center'` | - | Intended screen position. This prop is defined on the interface but is NOT implemented by the Toast component itself -- positioning must be handled by a parent container or toast manager. |\n\n**Example:**\n```tsx\n<Toast variant=\"success\" title=\"Saved\" message=\"Your changes have been saved.\" onClose={() => setShow(false)} />\n```\n\n### Accordion\n\nAccordion -- Collapsible content sections with chevron rotation animation.\n\nRenders a vertical list of items, each with a `<button>` header (inside `<h3>`)\nand a `<div role=\"region\">` panel. Supports controlled (`expandedKeys` + `onChange`)\nand uncontrolled (internal state) modes.\n\nChevron animation: the trailing chevron icon rotates from 0deg (collapsed) to\n180deg (expanded) with `transition: transform var(--ggui-motion-duration-base) var(--ggui-motion-easing-standard)` (ggui#1106).\n\nHeader button: full-width flex layout (`justify-content: space-between`),\n`var(--ggui-font-size-sm)`, `var(--ggui-font-weight-medium)`,\n`var(--ggui-color-onContainer)`.\nHeader padding: `var(--ggui-spacing-2) var(--ggui-spacing-4)`.\nBackground transition: `background-color var(--ggui-motion-duration-fast) var(--ggui-motion-easing-standard)`.\n\n**IMPORTANT:** `onChange` receives the full array of currently expanded keys,\nNOT a single key or a React event. In single mode (`multiple: false`), this\narray will have at most one element.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| items | `AccordionItem[]` | - | Array of collapsible section definitions. |\n| expandedKeys | `string[]` | - | Controlled expanded state. Array of item `key` values that should be open. When provided, the component is controlled and will not manage its own expansion state. When omitted, defaults to `[]` (all collapsed, uncontrolled mode). |\n| onChange | `(expandedKeys: string[]) => void` | - | Expand/collapse handler. Receives the complete array of expanded keys after a toggle. In controlled mode, you must update `expandedKeys` in response to this callback. |\n| multiple | `boolean` | `false` | When true, multiple items can be open simultaneously. When false, opening one item closes any other open item (single-expand mode). |\n| variant | `'default' \\| 'bordered' \\| 'separated'` | `'default'` | Visual style controlling borders and spacing: - `'default'` -- top border on first item, bottom border on all items (`var(--ggui-color-outlineVariant)`), no gap between items - `'bordered'` -- connected card style with left/right/bottom borders on all items, top border on first, shared rounded corners (radius-lg on first/last) - `'separated'` -- each item is an independent card with full border (`1px solid var(--ggui-color-outlineVariant)`), `var(--ggui-shape-radius-lg)` radius, `var(--ggui-spacing-2)` gap between items |\n\n**Example:**\n```tsx\n<Accordion\n variant=\"separated\"\n items={[\n { key: 'faq1', title: 'How do I get started?', content: 'Sign up and...' },\n { key: 'faq2', title: 'What is the pricing?', content: 'We offer...' },\n ]}\n expandedKeys={expanded}\n onChange={setExpanded}\n multiple\n/>\n```\n\n### Support Types\n\n**ResponsiveColumns:**\n\nExplicit column count per viewport breakpoint, mobile-first. `base`\napplies from 0 up; each named key overrides at and above its\nbreakpoint width (`sm` 640px, `md` 768px, `lg` 1024px, `xl` 1280px).\nOmit `base` to default to a single column on the narrowest screens.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| base | `number` | Columns below the `sm` breakpoint. |\n| sm | `number` | Columns from 640px up. |\n| md | `number` | Columns from 768px up. |\n| lg | `number` | Columns from 1024px up. |\n| xl | `number` | Columns from 1280px up. |\n\n**SelectOption:**\n\nAn individual option within a `Select` dropdown.\n\nRendered as a native `<option>` element. When `disabled` is true, the option\nis visible but not selectable.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| value | `string` | The value submitted when this option is selected. Must be unique within the options array. |\n| label | `string` | The display text shown in the dropdown. |\n| disabled | `boolean` | When true, the option is visible but cannot be selected (grayed out by the browser). |\n\n**RadioOption:**\n\nAn individual option within a `RadioGroup`.\n\nRendered as a `<label>` containing a visually-hidden `<input type=\"radio\">`\nand a custom 18px circle indicator. Supports an optional description line\nbelow the label text.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| value | `string` | The value emitted via `RadioGroupProps.onChange` when this option is selected. Must be unique. |\n| label | `string` | Display text for this option. Styled with `var(--ggui-font-size-sm)` and `var(--ggui-color-onContainer)`. |\n| description | `string` | Optional secondary description rendered below the label in smaller, muted type (`var(--ggui-font-size-xs)`, `var(--ggui-color-onSunken)`). |\n| disabled | `boolean` | When true, this individual option is grayed out (`opacity: 0.5`) and cannot be selected, regardless of the group-level `disabled` prop. |\n\n**TableColumn:**\n\nColumn definition for the Table component.\n\nEach column maps a `key` in the row data object to a table column with\na header label, optional sorting, alignment, width, and custom rendering.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| key | `string` | Property key in the row data object. Used to extract cell values via `row[key]`. Must be unique across all columns in a Table. |\n| header | `string` | Column header text. Rendered in uppercase, `var(--ggui-font-size-xs)`, `var(--ggui-font-weight-semibold)`, `var(--ggui-color-onSunken)`, with `letter-spacing: 0.05em`. |\n| width | `number \\| string` | Fixed column width. Numbers are pixels, strings are CSS values (e.g., `'200px'`, `'30%'`). When omitted, the column auto-sizes based on content. |\n| align | `'left' \\| 'center' \\| 'right'` | Horizontal text alignment for both the header and data cells. |\n| sortable | `boolean` | When true, the header cell becomes clickable and shows sort direction indicators (ascending/descending triangles). Clicking toggles between `'asc'` and `'desc'`. The header gets `cursor: pointer`, `tabIndex: 0`, and keyboard support (Enter/Space to toggle). |\n| render | `(value: unknown, row: T, index: number) => ReactNode` | Custom cell renderer. When provided, called instead of rendering `row[key]` directly. Receives the cell value, the full row object, and the row index. |\n\n**TabItem:**\n\nDefinition of a single tab within a Tabs component.\n\nEach item provides a unique `key` for identification, a `label` for the\ntab button, and `content` for the associated panel.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| key | `string` | Unique identifier for this tab. Used to match `activeKey` and as the value passed to `onChange`. |\n| label | `ReactNode` | Tab button label. Can be text or any ReactNode. |\n| content | `ReactNode` | Panel content rendered below the tab bar when this tab is active. |\n| disabled | `boolean` | When true, the tab button shows `opacity: 0.5`, `cursor: not-allowed`, and cannot be selected via click or keyboard navigation. |\n| icon | `ReactNode` | Optional icon rendered before the label inside the tab button, with `var(--ggui-spacing-1)` gap between icon and label. |\n\n**AccordionItem:**\n\nDefinition of a single collapsible section within an Accordion.\n\nEach item provides a unique `key`, a clickable `title` for the header button,\nand `content` revealed when expanded.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| key | `string` | Unique identifier for this item. Used in `expandedKeys` and passed to `onChange`. |\n| title | `ReactNode` | Header label rendered inside the toggle button. Can be text or any ReactNode. |\n| content | `ReactNode` | Panel content rendered below the header when expanded. Styled with `var(--ggui-font-size-sm)`, `var(--ggui-color-onContainer)`, `line-height: var(--ggui-font-lineHeight-normal)`. Padding: `0 var(--ggui-spacing-4) var(--ggui-spacing-4)`. |\n| disabled | `boolean` | When true, the header button shows `opacity: 0.5`, `cursor: not-allowed`, and cannot be toggled. |\n\n\n## Components\n\nImport: `import { Component } from '@ggui-ai/design'`\n\n### SearchField\n\nA text input with a leading search icon and optional submit button.\n\nComposes: `Input` (native `<input type=\"search\">`), `Button`, `Spinner`, `Icon`.\n\nSupports controlled and uncontrolled usage. When `value` is `undefined` the\ncomponent tracks its own state internally. Pressing **Enter** triggers\n`onSearch` with the current value. When `loading` is `true` the search icon\nis replaced by a `Spinner` and the input is disabled.\n\nTokens used: `colors.gray[300]` border, `colors.gray[50]` disabled bg,\n`colors.gray[400]` icon color, `colors.gray[900]` text color.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| value | `string` | - | Current search value. When provided, the component is **controlled** and the caller must update this via `onChange`. When omitted, the component manages its own internal state. |\n| onChange | `(value: string) => void` | - | Called on every keystroke with the new input string (value directly, not a React `ChangeEvent`). |\n| onSearch | `(value: string) => void` | - | Called when the user presses **Enter** or clicks the search button (if `showButton` is `true`). Receives the current value directly. |\n| placeholder | `string` | `'Search...'` | Placeholder text shown when the input is empty. |\n| showButton | `boolean` | `false` | When `true`, renders a `Button` primitive to the right of the input. The button's label is set by `buttonText`. |\n| buttonText | `string` | `'Search'` | Label rendered inside the submit button. Only visible when `showButton` is `true`. |\n| loading | `boolean` | - | When `true`, replaces the search icon with a `Spinner`, disables the input, and disables the submit button. |\n| disabled | `boolean` | - | When `true`, the input and button are visually disabled and do not respond to interaction. The input background changes to `colors.gray[50]`. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls input height and font size. - `'sm'` -- 6px vertical padding, 14px font - `'md'` -- 10px vertical padding, 14px font - `'lg'` -- 12px vertical padding, 16px font The Button size maps `sm`->`sm`, `md`->`md`, `lg`->`md`. |\n\n**Example:**\n```tsx\n```tsx\n<SearchField\n value={query}\n onChange={setQuery}\n onSearch={(q) => fetchResults(q)}\n placeholder=\"Search products...\"\n showButton\n buttonText=\"Go\"\n size=\"md\"\n/>\n```\n```\n\n### FormField\n\nA wrapper that adds a label, optional description, error message,\nand helper text around any form input passed as `children`.\n\nComposes: no other primitives -- pure layout with semantic `<label>` and\n`<span>` elements.\n\nVisual hierarchy (top to bottom):\n1. **Label** (required) -- `fontSize.sm`, `fontWeight.medium`, `colors.gray[700]`\n2. **Required indicator** -- red asterisk (`colors.error[500]`) appended to label\n3. **Description** -- `fontSize.xs`, `colors.gray[500]`, 4px bottom margin\n4. **Children** -- the form control itself\n5. **Error / Helper text** -- `fontSize.xs`; error in `colors.error[500]`,\n helper in `colors.gray[500]`. Error takes priority when both are provided.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Text rendered inside the `<label>` element above the input. |\n| children | `ReactNode` | - | The form control (typically an `Input`, `Select`, or `Textarea` primitive) rendered between the label/description and the error/helper row. |\n| error | `string` | - | Error message displayed below `children` in `colors.error[500]`. When present, it takes precedence over `helperText`. |\n| helperText | `string` | - | Neutral guidance text displayed below `children` in `colors.gray[500]`. Hidden when `error` is present. |\n| required | `boolean` | - | When `true`, appends a red asterisk (`*`) after the label text. Does **not** add any HTML validation attributes -- handle that on the child input. |\n| description | `string` | - | Secondary description rendered between the label and the child control in `fontSize.xs` / `colors.gray[500]`. Use for longer guidance that does not belong in `helperText`. |\n\n**Example:**\n```tsx\n```tsx\n<FormField\n label=\"Email address\"\n required\n description=\"We will never share your email.\"\n error={errors.email}\n>\n <Input value={email} onChange={setEmail} placeholder=\"you@example.com\" />\n</FormField>\n```\n```\n\n### MenuItem\n\nA full-width clickable row for menus, sidebars, and action lists.\n\nComposes: none -- renders a native `<button>` element.\n\nBuilt-in transition: `background-color` over `var(--ggui-motion-duration-fast)` / `var(--ggui-motion-easing-standard)` on hover (ggui#1106).\n\nColor logic:\n- **Normal**: text `colors.gray[700]`, hover bg `colors.gray[100]`\n- **Active**: bg `colors.primary[50]`, text `colors.gray[700]`, `fontWeight.medium`\n- **Danger**: text `colors.error[600]`, hover bg `colors.error[50]`, active bg `colors.error[100]`\n- **Disabled**: text `colors.gray[400]`, `cursor: not-allowed`\n\nLayout: flexbox row with `8px` gap, `8px 12px` padding, `radius.md` border-radius.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | Primary text content of the menu item. |\n| icon | `ReactNode` | - | Icon or element rendered to the left of the label. Flex-shrink 0. |\n| rightElement | `ReactNode` | - | Element rendered to the right of the label (e.g., a keyboard shortcut badge or a count). Colored `colors.gray[400]`, flex-shrink 0. |\n| onClick | `() => void` | - | Called when the item is clicked. Suppressed when `disabled` is `true`. |\n| disabled | `boolean` | - | When `true`, the item is non-interactive: `colors.gray[400]` text, `cursor: not-allowed`, click handler suppressed. |\n| active | `boolean` | - | Marks this item as the current selection. Applies a tinted background (`colors.primary[50]`, or `colors.error[100]` when `danger` is also set) and `fontWeight.medium`. |\n| danger | `boolean` | - | Switches the item to destructive styling: `colors.error[600]` text, `colors.error[50]` hover background. |\n\n**Example:**\n```tsx\n```tsx\n<MenuItem\n label=\"Delete project\"\n icon={<Icon name=\"trash\" size={16} />}\n danger\n onClick={() => confirmDelete(projectId)}\n/>\n```\n```\n\n### Tag\n\nAn inline label for categories, filters, statuses, or selections.\nOptionally dismissable via a close button.\n\nComposes: none -- pure `<span>` with an optional close `<button>`.\n\nEach `variant` maps to a background / text / border color triple from the\ndesign tokens:\n- `'default'` -- `gray[100]` / `gray[700]` / `gray[200]`\n- `'primary'` -- `primary[50]` / `primary[700]` / `primary[200]`\n- `'success'` -- `success[50]` / `success[700]` / `success[200]`\n- `'warning'` -- `warning[50]` / `warning[700]` / `warning[200]`\n- `'error'` -- `error[50]` / `error[700]` / `error[200]`\n- `'info'` -- `info[50]` / `info[700]` / `info[200]`\n\nThe close button renders an inline SVG \"x\" icon (12x12) with `opacity: 0.7`\nand an `aria-label=\"Remove\"`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | Tag content -- typically a short text string. |\n| variant | `'default' \\| 'primary' \\| 'success' \\| 'warning' \\| 'error' \\| 'info'` | `'default'` | Semantic color variant applied to background, text, and border. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls padding, font size, and internal gap. - `'sm'` -- 2px/6px padding, `fontSize.xs`, 4px gap - `'md'` -- 4px/8px padding, `fontSize.xs`, 6px gap - `'lg'` -- 6px/10px padding, `fontSize.sm`, 6px gap |\n| closable | `boolean` | - | When `true`, renders a small close (\"x\") button after the content. Clicking it fires `onClose`. |\n| onClose | `() => void` | - | Called when the close button is clicked. Only relevant when `closable` is `true`. |\n| icon | `ReactNode` | - | Icon or element rendered before the text content. |\n\n**Example:**\n```tsx\n```tsx\n<Tag variant=\"success\" size=\"md\" closable onClose={() => removeFilter(id)}>\n Active\n</Tag>\n```\n```\n\n### Dropdown\n\nA click-triggered menu anchored to a trigger element. Manages its own\nopen/close state internally.\n\nComposes: `MenuItem` for each option.\n\nBehavior:\n- Clicking the trigger toggles the menu open/closed.\n- Selecting an option calls `onChange(option.value)` and closes the menu.\n- Clicking outside the container or pressing **Escape** closes the menu.\n- The currently selected option (matching `value`) is rendered with\n `MenuItem`'s `active` state.\n\nMenu panel: `colors.white` bg, `colors.gray[200]` border, `radius.lg`\nborder-radius, `shadow.lg`, `zIndex.dropdown`, 160px min-width, 4px padding.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| trigger | `ReactNode` | - | The element the user clicks to open the menu. Receives a wrapping `<div>` with `cursor: pointer` (or `not-allowed` when disabled). |\n| options | `DropdownOption[]` | - | Array of selectable options rendered as `MenuItem` rows. |\n| value | `string` | - | The `value` of the currently selected option. The matching `MenuItem` is rendered with `active` styling. |\n| onChange | `(value: string) => void` | - | Called with the `value` string of the selected option (not the full `DropdownOption` object). The menu closes immediately after. |\n| placement | `'bottom-start' \\| 'bottom-end' \\| 'top-start' \\| 'top-end'` | `'bottom-start'` | Where to anchor the menu panel relative to the trigger. - `'bottom-start'` -- below, aligned to left edge - `'bottom-end'` -- below, aligned to right edge - `'top-start'` -- above, aligned to left edge - `'top-end'` -- above, aligned to right edge |\n| disabled | `boolean` | - | When `true`, the trigger shows `cursor: not-allowed` and clicking it does not open the menu. |\n\n**Example:**\n```tsx\n```tsx\n<Dropdown\n trigger={<Button variant=\"outline\">Sort by</Button>}\n options={[\n { value: 'name', label: 'Name' },\n { value: 'date', label: 'Date created' },\n { value: 'delete', label: 'Delete', danger: true },\n ]}\n value={sortBy}\n onChange={setSortBy}\n placement=\"bottom-end\"\n/>\n```\n```\n\n### Autocomplete\n\nA text input with a filterable suggestion dropdown, keyboard navigation,\nand loading/empty states.\n\nComposes: `Input` primitive (with `label`, `error`, `placeholder` forwarded),\n`Spinner` (loading state).\n\nFiltering: options are filtered client-side by case-insensitive substring\nmatch against both `option.label` and `option.value`.\n\nKeyboard support:\n- **ArrowDown / ArrowUp** -- move highlight through filtered options\n (opens dropdown if closed)\n- **Enter** -- selects the highlighted option\n- **Escape** -- closes the dropdown\n\nOn selection, `onChange` is called with `option.label` (the display text)\nand `onSelect` is called with the full `AutocompleteOption` object.\n\nDropdown panel: `colors.white` bg, `colors.gray[200]` border, `radius.lg`\nborder-radius, `shadow.lg`, `zIndex.dropdown`, max-height 240px with\noverflow scroll.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| value | `string` | `''` | Current text in the input field. On selection, this is set to the selected option's `label`. |\n| onChange | `(value: string) => void` | - | Called on every keystroke with the new input string (value directly, not a React event). Also called on selection with `option.label`. |\n| onSelect | `(option: AutocompleteOption) => void` | - | Called when the user selects an option (click or Enter on highlighted item). Receives the full `AutocompleteOption` object, not just the value string. |\n| options | `AutocompleteOption[]` | - | The full list of available options. Filtering is handled internally via case-insensitive substring match on `label` and `value`. |\n| placeholder | `string` | - | Placeholder text forwarded to the inner `Input` primitive. |\n| label | `string` | - | Label text forwarded to the inner `Input` primitive. |\n| loading | `boolean` | - | When `true`, the dropdown shows a centered `Spinner` instead of the option list. The input remains interactive. |\n| disabled | `boolean` | - | When `true`, the inner `Input` is disabled and the dropdown does not open. |\n| error | `string` | - | Error message forwarded to the inner `Input` primitive. |\n| noResultsText | `string` | `'No results found'` | Text shown in the dropdown when filtering produces zero matches. Rendered centered in `colors.gray[500]` / `fontSize.sm`. |\n\n**Example:**\n```tsx\n```tsx\n<Autocomplete\n label=\"Country\"\n value={country}\n onChange={setCountry}\n onSelect={(opt) => setCountryCode(opt.value)}\n options={countries}\n placeholder=\"Type to search...\"\n noResultsText=\"No countries found\"\n/>\n```\n```\n\n### Breadcrumb\n\nA horizontal navigation trail showing the user's location within a\nhierarchy. Renders a `<nav aria-label=\"Breadcrumb\">`.\n\nComposes: `Link` primitive (for items with `href`).\n\nRendering rules per item:\n- **Last item**: static `<span>` with `colors.gray[900]`, `fontWeight: 500`,\n and `aria-current=\"page\"`.\n- **Non-last with `href`**: `Link` in `colors.gray[500]` with\n `underline=\"hover\"`. If `onItemClick` is provided, `e.preventDefault()`\n is called and the handler fires instead of navigating.\n- **Non-last without `href`**: unstyled `<button>` in `colors.gray[500]`.\n\nLayout: flexbox row, `8px` gap, `fontSize.sm`.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| items | `BreadcrumbItem[]` | - | Ordered array of breadcrumb segments from root to current page. |\n| separator | `ReactNode` | `'/'` | Separator rendered between each pair of items. Can be a string (e.g., `\"/\"`, `\">\"`) or a ReactNode (e.g., an `Icon`). Rendered in `colors.gray[400]`. |\n| onItemClick | `(item: BreadcrumbItem, index: number) => void` | - | Called when a non-last item is clicked. Receives the `BreadcrumbItem` and its zero-based `index`. When provided on items that have `href`, the default navigation is prevented via `e.preventDefault()`. |\n\n**Example:**\n```tsx\n```tsx\n<Breadcrumb\n items={[\n { label: 'Home', href: '/' },\n { label: 'Projects', href: '/projects' },\n { label: 'ggui' },\n ]}\n separator=\"/\"\n onItemClick={(item) => router.push(item.href!)}\n/>\n```\n```\n\n### Pagination\n\nPage navigation controls with previous/next arrows, numbered page buttons,\nand optional first/last jumps. Renders a `<nav aria-label=\"Pagination\">`.\n\nComposes: `Button` (ghost variant for prev/next/first/last arrows),\n`Icon` (`chevron-left`, `chevron-right`).\n\nBuilt-in transition: `all` over `var(--ggui-motion-duration-fast)` / `var(--ggui-motion-easing-standard)` on page number buttons (ggui#1106).\n\nPage windowing: when `totalPages > maxVisible`, the component shows the\nfirst page, last page, a window of pages around `currentPage`, and\nellipsis (\"...\") markers for gaps. The window adjusts when near the\nstart or end of the range.\n\nActive page button: `colors.primary[600]` bg, `colors.white` text,\n`fontWeight.medium`. Inactive: transparent bg, `colors.gray[700]` text.\n\nArrow buttons are automatically disabled at boundary pages (first/last).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| currentPage | `number` | - | Current active page. **1-indexed** (first page is `1`). |\n| totalPages | `number` | - | Total number of pages. Determines when last-page / next-page buttons disable. |\n| onPageChange | `(page: number) => void` | - | Called when the user clicks a page number, arrow, or first/last button. Receives the target page number (1-indexed) directly. |\n| showFirstLast | `boolean` | `true` | When `true`, renders double-chevron buttons for jumping to the first and last page. These buttons are disabled when already on the respective boundary. |\n| maxVisible | `number` | `5` | Maximum number of page buttons visible at once (including the first and last page, but excluding ellipsis markers). When `totalPages` exceeds this value, ellipsis gaps appear. |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls button dimensions and icon sizes. - `'sm'` -- 28px buttons, 14px icons, Button size `xs` - `'md'` -- 32px buttons, 16px icons, Button size `sm` - `'lg'` -- 40px buttons, 20px icons, Button size `md` |\n| disabled | `boolean` | - | When `true`, all page buttons and arrows are visually dimmed (`opacity: 0.5`) and clicks are suppressed. |\n\n**Example:**\n```tsx\n```tsx\n<Pagination\n currentPage={page}\n totalPages={20}\n onPageChange={setPage}\n maxVisible={7}\n size=\"md\"\n/>\n```\n```\n\n### EmptyState\n\nEmptyState -- placeholder for a region with no data: empty lists,\nzero search results, an error fallback. Render it instead of\nnothing whenever a data array could be empty \u2014 a list that shows\nnothing when empty looks broken.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| icon | `string \\| ReactNode` | - | A Lucide icon name (kebab-case), rendered large and subtle above the title, or a custom node. Omit for a text-only empty state. |\n| title | `string` | - | The headline, e.g. \"No results found\". |\n| description | `string` | - | Optional supporting line below the title. |\n| action | `ReactNode` | - | Optional call-to-action, typically a `<Button>`. |\n\n**Example:**\n```tsx\n{results.length === 0\n ? <EmptyState icon=\"search-x\" title=\"No matches\" description=\"Try a broader query.\" />\n : results.map((r) => <Row key={r.id}>\u2026</Row>)}\n```\n\n### Stat\n\nStat -- a single KPI / metric: a label, a large value, an optional\ntrend-coloured delta and icon. Reach for it whenever the UI is\n\"show a number\" \u2014 dashboards, weather and price cards, analytics\ntiles. Drop several into a `<Grid>` for a stat grid.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| label | `string` | - | The metric name, e.g. \"Revenue\". Rendered small and uppercase above the value. |\n| value | `string \\| number` | - | The headline value \u2014 the big number. A number, or a pre-formatted string (`\"$48.2k\"`, `\"18\xB0C\"`). |\n| delta | `string` | - | Optional change indicator, pre-formatted, e.g. `\"+12.5%\"` or `\"-3\"`. |\n| trend | `'up' \\| 'down' \\| 'neutral'` | `'neutral'` | Direction of `delta` \u2014 colours it: `'up'` success, `'down'` error, `'neutral'` muted. |\n| icon | `string \\| ReactNode` | - | Optional Lucide icon name (kebab-case) or custom node, shown next to the label. |\n\n**Example:**\n```tsx\n<Stat label=\"Revenue\" value=\"$48.2k\" delta=\"+12.5%\" trend=\"up\" icon=\"trending-up\" />\n```\n\n### Stepper\n\nStepper -- a display-only step indicator for multi-step flows\n(wizards, checkouts, onboarding): numbered markers joined by\nconnector lines, with completed (check), current (filled), and\nupcoming (outlined) states styled distinctly.\n\nStepper renders step state; it never owns it. Declare the step\nlabels as a top-level `const`, track the current index in\n`useState`, and advance it from your own Next/Back buttons (or\n`onStepClick`).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| steps | `string[]` | - | Ordered step labels, one per step. Declare as a top-level `const` array \u2014 never as state. |\n| current | `number` | - | Zero-based index of the active step. Steps before it render as completed (check mark), steps after it as upcoming. |\n| orientation | `'horizontal' \\| 'vertical'` | `'horizontal'` | Flow direction. `'horizontal'` joins the markers with connector lines in a row; `'vertical'` stacks the steps with short connector columns \u2014 better for sidebars or long step labels. |\n| onStepClick | `(index: number) => void` | - | When provided, each step becomes a real button and clicks receive the step's zero-based index. Typical use: jumping back to a completed step; guard forward jumps in the handler if the flow validates per step. |\n\n**Example:**\n```tsx\nconst STEPS = ['Account', 'Profile', 'Review'];\n// In the component: const [step, setStep] = useState(0);\n<Stack gap=\"lg\">\n <Stepper steps={STEPS} current={step} />\n {step === 0 && <AccountFields />}\n <Row gap=\"sm\" justify=\"end\">\n <Button variant=\"ghost\" disabled={step === 0} onClick={() => setStep(step - 1)}>Back</Button>\n <Button onClick={() => setStep(step + 1)}>Next</Button>\n </Row>\n</Stack>\n```\n\n### Markdown\n\nMarkdown -- renders markdown-formatted text as styled content:\nheadings, paragraphs, **bold** / *italic*, `inline code`, fenced\ncode blocks, bulleted and numbered lists, pipe tables, and links.\n\nUse it whenever a prop or context slot carries prose the agent\nauthors in markdown -- article bodies, AI-generated explanations,\ndescriptions, notes, help text. Pass the raw markdown string\nstraight through; never pre-parse it, never split it into per-line\n`Text` elements, and never rebuild formatting with nested\n`<strong>`/`<em>` markup yourself.\n\nSafe by construction: raw HTML in the source displays as literal\ntext (never markup), and links render inert unless their scheme is\nhttp(s)/mailto. Streaming-tolerant: a mid-stream prefix parses\ncleanly and completed constructs never change shape, so binding a\ngrowing string re-renders correctly.\n\nFor a single line of formatted text INSIDE another element (a table\ncell, a list row, a caption), use `MarkdownInline` instead -- same\n`markdown` prop, inline-level constructs only (no headings, lists,\nor code fences), renders inside the parent's typography.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| markdown | `string` | - | Markdown source text, passed through verbatim. Supports the conversational subset: `#`-`###` headings, paragraphs, bold, italic, inline code, fenced code blocks, `-`/`1.` lists, `\\|` pipe tables, and `[label](url)` links. Bind stateful or streamed prose directly. |\n\n**Example:**\n```tsx\n<Stack gap=\"md\">\n <Markdown markdown={recipe.instructions} />\n</Stack>\n```\n\n### MarkdownInline\n\nMarkdownInline -- renders ONE line of inline-level markdown (bold,\nitalic, inline code, links) inside the parent's typography -- a table\ncell, a list row, a caption, a summary line. Same `markdown` prop as\n`Markdown`; block constructs (headings, lists, code fences) are not\nrendered -- reach for `Markdown` for multi-paragraph bodies.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| markdown | `string` | - | Inline markdown source -- bold, italic, inline code, and `[label](url)` links. Block-level syntax renders as plain text. |\n\n**Example:**\n```tsx\n<Row gap=\"sm\">\n <Badge variant=\"success\">stable</Badge>\n <MarkdownInline markdown={release.summary} />\n</Row>\n```\n\n### Support Types\n\n**DropdownOption:**\n\nA single option inside a `Dropdown`. Each option maps to one `MenuItem`\ninternally.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| value | `string` | Unique identifier returned to `onChange` when this option is selected. |\n| label | `string` | Human-readable text shown in the menu row. |\n| icon | `ReactNode` | Optional icon rendered to the left of the label via `MenuItem.icon`. |\n| disabled | `boolean` | When `true`, the option is visible but non-interactive. |\n| danger | `boolean` | When `true`, the option uses destructive (red) styling via `MenuItem.danger`. |\n\n**AutocompleteOption:**\n\nA single option in the `Autocomplete` suggestion list.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| value | `string` | Unique identifier for this option. Also used for case-insensitive filtering against the input value. |\n| label | `string` | Primary display text. Also used for case-insensitive filtering and is written into the input on selection. |\n| description | `string` | Secondary description rendered below the label in `fontSize.xs` / `colors.gray[500]`. |\n| icon | `ReactNode` | Icon rendered to the left of the label/description block. Flex-shrink 0. |\n| disabled | `boolean` | When `true`, the option is visible but non-interactive (`cursor: not-allowed`, `colors.gray[400]` text). |\n\n**BreadcrumbItem:**\n\nA single segment in a `Breadcrumb` trail. Items with `href` render as\n`Link` primitives; items without render as plain `<button>` elements.\nThe last item in the array is always rendered as static text with\n`aria-current=\"page\"`.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| label | `string` | Display text for this breadcrumb segment. |\n| href | `string` | URL for this segment. When provided (and this is not the last item), the segment renders as a `Link` primitive with `underline=\"hover\"`. When omitted, it renders as a `<button>`. |\n| icon | `ReactNode` | Icon rendered immediately before the label. Its color follows the segment's text color: `colors.gray[500]` for navigable items, `colors.gray[900]` for the current (last) item. |\n\n\n## Compositions\n\nImport: `import { Component } from '@ggui-ai/design'`\n\n### Header\n\nProps for the `Header` composition.\n\nA horizontal page header that arranges a logo, navigation, and action slots\nin a flex row (`justify-content: space-between`). Internally renders a `<header>` element;\ndoes not compose other ggui primitives.\n\nWhen `sticky` is true the header gets `position: sticky; top: 0` with `zIndex.sticky`\nand a `shadow.sm` box-shadow.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| logo | `ReactNode` | - | Logo or brand element rendered at the start (flex-shrink: 0). |\n| navigation | `ReactNode` | - | Navigation content rendered in a `<nav>` element with `flex: 1` and a 32 px left margin. |\n| actions | `ReactNode` | - | Right-side action elements (buttons, avatar, etc.) rendered with a 12 px gap. |\n| sticky | `boolean` | `false` | When true, the header becomes `position: sticky` at the top of its scroll container with `zIndex.sticky` and `shadow.sm`. |\n| background | `string` | `colors.white` | Background color of the header. |\n| bordered | `boolean` | `true` | When true, renders a 1 px bottom border in `colors.gray[200]`. |\n\n**Example:**\n```tsx\n```tsx\n<Header\n logo={<img src=\"/logo.svg\" alt=\"Acme\" />}\n navigation={<a href=\"/docs\">Docs</a>}\n actions={<Button size=\"sm\">Sign In</Button>}\n sticky\n bordered\n/>\n```\n```\n\n### Sidebar\n\nProps for the `Sidebar` composition.\n\nA vertical navigation panel that composes the `Icon` primitive for chevron indicators.\nItems are rendered as `<button>` elements inside a scrollable `<nav>`. Nested items\nare indented 16 px per depth level. The sidebar animates width changes with a 200 ms\nCSS transition. Active items are highlighted with `colors.primary[50]` background\nand `colors.primary[700]` text.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| items | `SidebarItem[]` | - | Array of navigation items to render. |\n| activeId | `string` | - | ID of the currently active item. Matched items get a highlighted background and bold text. |\n| onItemClick | `(item: SidebarItem) => void` | - | Called when any item (including parent items with children) is clicked. |\n| collapsed | `boolean` | `false` | When true, hides labels and badges; only icons remain visible, centered in the collapsed width. Nested children are hidden entirely. |\n| header | `ReactNode` | - | Content rendered above the item list, separated by a bottom border. |\n| footer | `ReactNode` | - | Content rendered below the item list, separated by a top border. |\n| width | `number` | `256` | Width in pixels when expanded. |\n| collapsedWidth | `number` | `64` | Width in pixels when collapsed. |\n\n**Example:**\n```tsx\n```tsx\n<Sidebar\n items={[\n { id: 'home', label: 'Home', icon: <Icon name=\"home\" /> },\n { id: 'settings', label: 'Settings', icon: <Icon name=\"settings\" />,\n children: [\n { id: 'profile', label: 'Profile' },\n { id: 'billing', label: 'Billing' },\n ]},\n ]}\n activeId=\"home\"\n onItemClick={(item) => navigate(item.href)}\n collapsed={false}\n width={256}\n/>\n```\n```\n\n### CardGrid\n\nProps for the `CardGrid` composition.\n\nA CSS Grid wrapper that arranges children in equal-width columns. When `columns`\nis a number, it produces `repeat(N, 1fr)`. When it is a responsive object,\nit falls back to `repeat(auto-fit, minmax(280px, 1fr))` for fluid responsive behavior.\n\nDoes not compose any ggui primitives internally \u2014 it is a pure layout container.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| children | `ReactNode` | - | Card elements to arrange in the grid. |\n| columns | `number \\| { sm?: number; md?: number; lg?: number }` | `3` | Number of columns, or a responsive breakpoint map. - `number` \u2014 fixed column count via `repeat(N, 1fr)`. - `{ sm?, md?, lg? }` \u2014 triggers `repeat(auto-fit, minmax(280px, 1fr))` for fluid layout. |\n| gap | `number` | `16` | Gap between grid items in pixels. |\n\n**Example:**\n```tsx\n```tsx\n<CardGrid columns={3} gap={24}>\n <Card>A</Card>\n <Card>B</Card>\n <Card>C</Card>\n</CardGrid>\n```\n```\n\n### CommentThread\n\nProps for the `CommentThread` composition.\n\nA threaded comment section that composes `Avatar`, `Button`, `TextArea`, and `Spinner`\nprimitives. Comments are rendered recursively with 40 px indentation per nesting level.\nEach comment shows author avatar, name, timestamp, content, reactions, and a \"Reply\" toggle.\nWhen `currentUser` is provided, a new-comment input with avatar and submit button is shown\nabove the thread.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| comments | `Comment[]` | - | Array of top-level comments to render. Replies are nested within each comment. |\n| currentUser | `{ /** Display name for the current user's avatar. */ name: string; /** Avatar image URL for the current user. */ avatar?: string; }` | - | Current user info. When provided, a new-comment input area is rendered above the thread. |\n| onAddComment | `(content: string, parentId?: string) => void` | - | Called when the user submits a new top-level comment. |\n| onReply | `(commentId: string, content: string) => void` | - | Called when the user submits a reply to an existing comment. |\n| onReaction | `(commentId: string, emoji: string) => void` | - | Called when the user clicks an emoji reaction on a comment. |\n| loading | `boolean` | - | When true, shows a centered `Spinner` instead of the comment list. |\n\n**Example:**\n```tsx\n```tsx\n<CommentThread\n comments={[\n { id: '1', author: { name: 'Alice' }, content: 'Great work!', timestamp: new Date(),\n replies: [{ id: '2', author: { name: 'Bob' }, content: 'Thanks!', timestamp: new Date() }] }\n ]}\n currentUser={{ name: 'Alice', avatar: '/alice.jpg' }}\n onAddComment={(content) => post(content)}\n onReply={(commentId, content) => reply(commentId, content)}\n onReaction={(commentId, emoji) => react(commentId, emoji)}\n/>\n```\n```\n\n### DataTable\n\nProps for the `DataTable` composition.\n\nA sortable, selectable data table that composes `Checkbox`, `Spinner`, and `Icon`\nprimitives. Renders a `<table>` inside a bordered container with 8 px border-radius.\nThe header row has a `colors.gray[50]` background. Sortable columns show a chevron\nicon on click (toggles asc/desc). Selected rows are highlighted with `colors.primary[50]`.\nRow background transitions use a 150 ms ease. The \"select all\" checkbox supports an\nindeterminate state when a subset of rows is selected.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| columns | `DataTableColumn<T>[]` | - | Column definitions that control header, alignment, sorting, and rendering. |\n| data | `T[]` | - | Row data array. Each entry corresponds to one table row. |\n| rowKey | `string \\| ((row: T) => string)` | `'id'` | Property name or function used to derive a unique key for each row. |\n| loading | `boolean` | - | When true, shows a centered `Spinner` instead of table body rows. |\n| emptyText | `string` | `'No data'` | Text shown when `data` is empty and not loading. |\n| onSort | `(key: string, direction: 'asc' \\| 'desc') => void` | - | Called when a sortable column header is clicked. Toggles direction automatically (asc -> desc) if the same column is clicked again. |\n| sortKey | `string` | - | The column key currently being sorted. |\n| sortDirection | `'asc' \\| 'desc'` | - | The current sort direction. |\n| onRowClick | `(row: T, index: number) => void` | - | Called when a row is clicked. Rows get `cursor: pointer` when this handler is provided. |\n| selectable | `boolean` | - | When true, adds a checkbox column at the start of each row. |\n| selectedKeys | `string[]` | `[]` | Array of currently selected row keys. |\n| onSelectionChange | `(keys: string[]) => void` | - | Called when the set of selected row keys changes (via row checkbox or select-all). |\n\n**Example:**\n```tsx\n```tsx\n<DataTable\n columns={[\n { key: 'name', header: 'Name', sortable: true },\n { key: 'email', header: 'Email' },\n { key: 'role', header: 'Role', render: (v) => <Badge>{v}</Badge> },\n ]}\n data={users}\n rowKey=\"id\"\n selectable\n selectedKeys={selected}\n onSelectionChange={setSelected}\n onSort={(key, dir) => sort(key, dir)}\n sortKey=\"name\"\n sortDirection=\"asc\"\n/>\n```\n```\n\n### ChatWindow\n\nProps for the `ChatWindow` composition.\n\nA messaging interface that composes `Avatar`, `Button`, `Spinner`, and `Icon` primitives.\nLayout is a flex column filling 100% height with a bordered `radius.lg` container.\nMessages from the current user align right with `colors.primary[600]` bubbles and white text.\nOther users' messages align left with `colors.gray[100]` bubbles. The message area\nauto-scrolls to the bottom on new messages via `scrollIntoView({ behavior: 'smooth' })`.\nThe text input sends on Enter (Shift+Enter for newline).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| messages | `ChatMessage[]` | - | Array of chat messages to display in chronological order. |\n| currentUserId | `string` | - | ID of the current user. Messages from this user render right-aligned with a primary color bubble. |\n| onSendMessage | `(content: string) => void` | - | Called when the user submits a message (Enter key or send button). |\n| loading | `boolean` | - | When true, shows a centered `Spinner` instead of the message list. |\n| typing | `{ name: string } \\| null` | - | When non-null, displays a typing indicator below the last message (e.g., \"Alice is typing...\"). |\n| placeholder | `string` | `'Type a message...'` | Placeholder text for the message input field. |\n| header | `ReactNode` | - | Optional header content rendered above the message area, separated by a bottom border. |\n\n**Example:**\n```tsx\n```tsx\n<ChatWindow\n messages={messages}\n currentUserId=\"user-1\"\n onSendMessage={(content) => send(content)}\n typing={{ name: 'Alice' }}\n placeholder=\"Type a message...\"\n header={<h3>Chat with Alice</h3>}\n/>\n```\n```\n\n### NavigationBar\n\nProps for the `NavigationBar` composition.\n\nA horizontal or vertical navigation menu. Does not compose other ggui primitives\n(uses plain `<button>` and `<a>` elements). Active items are styled per variant:\n\n- `'default'` \u2014 active item gets `colors.primary[600]` text and medium font weight.\n- `'pills'` \u2014 active item gets a `radius.full` pill with `colors.primary[100]` background.\n- `'underline'` \u2014 active item gets a 2 px bottom border in `colors.primary[600]`.\n When horizontal, the entire nav also has a 1 px bottom border.\n\nAll items have a 150 ms transition on all properties.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| items | `NavItem[]` | - | Array of navigation items to render. |\n| activeId | `string` | - | ID of the currently active item. Controls visual highlighting per variant style. |\n| onItemClick | `(item: NavItem) => void` | - | Called when a non-disabled item is clicked. For `<a>` elements, `preventDefault` is called first. |\n| orientation | `'horizontal' \\| 'vertical'` | `'horizontal'` | Layout direction. - `'horizontal'` \u2014 flex-row with 4 px gap. - `'vertical'` \u2014 flex-column with 2 px gap. |\n| variant | `'default' \\| 'pills' \\| 'underline'` | `'default'` | Visual style for active/inactive items. - `'default'` \u2014 text color change only. - `'pills'` \u2014 rounded pill background on active items. - `'underline'` \u2014 bottom border on active items. |\n\n**Example:**\n```tsx\n```tsx\n<NavigationBar\n items={[\n { id: 'home', label: 'Home', icon: <Icon name=\"home\" /> },\n { id: 'about', label: 'About' },\n { id: 'contact', label: 'Contact' },\n ]}\n activeId=\"home\"\n onItemClick={(item) => navigate(item.id)}\n orientation=\"horizontal\"\n variant=\"pills\"\n/>\n```\n```\n\n### FileUploader\n\nProps for the `FileUploader` composition.\n\nA drag-and-drop file upload area that composes `Button`, `Progress`, and `Icon` primitives.\nThe drop zone is a dashed-border container that highlights in `colors.primary[400]`/`colors.primary[50]`\non drag-over. Below the drop zone, each file in `files` is listed with its name, size,\noptional progress bar (for uploading status), and a remove button.\n\nFile validation (max size, max count) is applied client-side before calling `onFilesSelected`.\nFiles exceeding `maxSize` are silently filtered out; files exceeding `maxFiles` are truncated.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| files | `UploadedFile[]` | `[]` | Array of files currently in the upload queue, shown below the drop zone. |\n| onFilesSelected | `(files: File[]) => void` | - | Called when the user selects files via click or drag-and-drop. Receives native `File` objects after client-side filtering (maxSize, maxFiles). |\n| onFileRemove | `(fileId: string) => void` | - | Called when the user clicks the remove button on a file entry. |\n| accept | `string` | - | Accepted file types passed to the hidden `<input type=\"file\" accept=\"...\">`. E.g., `'image/*,.pdf'`. |\n| multiple | `boolean` | `true` | When true, allows selecting multiple files at once. |\n| maxSize | `number` | - | Maximum file size in bytes. Files exceeding this are silently excluded from the selection. |\n| maxFiles | `number` | - | Maximum number of files. Excess files beyond `maxFiles - files.length` are truncated. |\n| disabled | `boolean` | - | When true, the drop zone is visually dimmed (opacity 0.5) and non-interactive. |\n| dragDrop | `boolean` | `true` | When true, enables drag-and-drop on the drop zone. When false, the prompt text changes to \"Click to browse files\". |\n\n**Example:**\n```tsx\n```tsx\n<FileUploader\n files={uploadedFiles}\n onFilesSelected={(files) => startUpload(files)}\n onFileRemove={(id) => removeFile(id)}\n accept=\"image/*,.pdf\"\n multiple\n maxSize={5 * 1024 * 1024}\n maxFiles={10}\n dragDrop\n/>\n```\n```\n\n### NotificationCenter\n\nProps for the `NotificationCenter` composition.\n\nA notification list with a header bar that composes `Button`, `Spinner`, and `Icon`\nprimitives. The header shows a \"Notifications\" title, an unread count badge\n(pill in `colors.primary[100]`), and \"Mark all read\" / \"Clear all\" buttons.\nEach notification item shows a colored status dot, title, message, timestamp,\n\"Mark as read\" link, and optional action. The list is scrollable (`overflowY: auto`).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| notifications | `Notification[]` | - | Array of notifications to display, in the order provided. |\n| onMarkAsRead | `(id: string) => void` | - | Called when the user clicks \"Mark as read\" on an individual notification. |\n| onMarkAllAsRead | `() => void` | - | Called when the user clicks the \"Mark all read\" header button. Only shown when there are unread items. |\n| onDismiss | `(id: string) => void` | - | Called when the user clicks the dismiss (X) button on an individual notification. |\n| onClearAll | `() => void` | - | Called when the user clicks the \"Clear all\" header button. Only shown when there are any notifications. |\n| emptyText | `string` | `'No notifications'` | Text shown when `notifications` is empty and not loading. |\n| loading | `boolean` | - | When true, shows a centered `Spinner` instead of the notification list. |\n\n**Example:**\n```tsx\n```tsx\n<NotificationCenter\n notifications={[\n { id: '1', title: 'Deployment complete', type: 'success', timestamp: new Date(), read: false },\n { id: '2', title: 'Build failed', type: 'error', message: 'Lint errors', timestamp: new Date() },\n ]}\n onMarkAsRead={(id) => markRead(id)}\n onMarkAllAsRead={() => markAllRead()}\n onDismiss={(id) => dismiss(id)}\n onClearAll={() => clearAll()}\n/>\n```\n```\n\n### Modal\n\nProps for the `Modal` composition.\n\nA dialog overlay that composes `Button`, `Icon`, and `Heading` primitives.\nThe overlay uses `animation.fadeIn` (`ggui-fadeIn`) and the dialog panel uses\n`animation.scaleIn` (`ggui-scaleIn`) \u2014 both GPU-composited (opacity + transform).\nWhen open, `document.body.style.overflow` is set to `'hidden'` to prevent background\nscrolling, and restored on close. The dialog has `role=\"dialog\"` and `aria-modal=\"true\"`.\n\nSize widths: `sm` = 400 px, `md` = 500 px, `lg` = 640 px, `xl` = 800 px,\n`full` = 100vw (no border-radius, no padding).\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| open | `boolean` | - | Controls modal visibility. When false, the component renders nothing. |\n| onClose | `() => void` | - | Called to close the modal (overlay click, escape key, or close button). |\n| title | `string` | - | Optional title rendered in the modal header via the `Heading` primitive (level 4). |\n| children | `ReactNode` | - | Modal body content rendered in a scrollable area (`overflowY: auto`). |\n| footer | `ReactNode` | - | Footer content rendered below the body, right-aligned with an 8 px gap, separated by a top border. |\n| size | `'sm' \\| 'md' \\| 'lg' \\| 'xl' \\| 'full'` | `'md'` | Controls the width of the modal panel. - `'sm'` \u2014 400 px - `'md'` \u2014 500 px - `'lg'` \u2014 640 px - `'xl'` \u2014 800 px - `'full'` \u2014 100vw, no border-radius, stretches to fill viewport |\n| closeOnOverlayClick | `boolean` | `true` | When true, clicking the semi-transparent overlay behind the modal calls `onClose`. |\n| closeOnEscape | `boolean` | `true` | When true, pressing the Escape key calls `onClose`. |\n| showCloseButton | `boolean` | `true` | When true, renders a ghost close button (X icon) in the modal header. |\n\n**Example:**\n```tsx\n```tsx\n<Modal\n open={isOpen}\n onClose={() => setOpen(false)}\n title=\"Confirm Action\"\n size=\"md\"\n footer={\n <>\n <Button variant=\"ghost\" onClick={() => setOpen(false)}>Cancel</Button>\n <Button onClick={handleConfirm}>Confirm</Button>\n </>\n }\n>\n <p>Are you sure you want to proceed?</p>\n</Modal>\n```\n```\n\n### CommandPalette\n\nProps for the `CommandPalette` composition.\n\nA searchable command menu (Cmd+K / Ctrl+K pattern) that composes `Spinner` and `Icon`\nprimitives. Appears as a centered overlay at 15vh from the top. Commands are filtered\nby label and description (case-insensitive substring match). Results are grouped under\nuppercase section headers. Keyboard navigation is fully supported:\nArrow Up/Down to navigate, Enter to select, Escape to close.\n\nWhen `recentIds` are provided and the search query is empty, matching commands appear\nin a \"Recent\" section at the top (deduplicated from their original groups).\n\nThe footer shows navigation hints: \"Up/Down Navigate\", \"Enter Select\", \"Esc Close\".\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| open | `boolean` | - | Controls palette visibility. When false, the component renders nothing. |\n| onClose | `() => void` | - | Called to close the palette (overlay click, Escape key, or after command selection). |\n| commands | `Command[]` | - | Full array of available commands. Filtered client-side by the search query. |\n| onSelect | `(command: Command) => void` | - | Called when a non-disabled command is selected (Enter key or click). The palette auto-closes after selection. |\n| placeholder | `string` | `'Search commands...'` | Placeholder text for the search input. |\n| recentIds | `string[]` | `[]` | IDs of recently used commands. When the query is empty, these appear in a \"Recent\" section at the top of the results. |\n| loading | `boolean` | - | When true, shows a centered `Spinner` instead of the command list. |\n\n**Example:**\n```tsx\n```tsx\n<CommandPalette\n open={isOpen}\n onClose={() => setOpen(false)}\n commands={[\n { id: 'new', label: 'New File', shortcut: 'Ctrl+N', group: 'File' },\n { id: 'save', label: 'Save', shortcut: 'Ctrl+S', group: 'File' },\n { id: 'theme', label: 'Toggle Theme', group: 'Preferences' },\n ]}\n onSelect={(cmd) => executeCommand(cmd.id)}\n recentIds={['save']}\n placeholder=\"Search commands...\"\n/>\n```\n```\n\n### Footer\n\nProps for the `Footer` composition.\n\nA site footer with `role=\"contentinfo\"` that lays out a brand slot, link columns,\nsocial icons, and a bottom bar. Does not compose other ggui primitives (uses plain\nHTML elements). Content is constrained to `max-width: 1280px` with auto margins.\nLink columns use a responsive flex layout (`flex: 0 1 180px`). The bottom bar\nincludes copyright text, social links, and bottom-bar links separated by a top border.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| brand | `ReactNode` | - | Brand element (logo, tagline) rendered in a flexible column (`flex: 1 1 280px`). |\n| columns | `FooterColumn[]` | - | Array of link columns rendered in a flex-wrap layout with 48 px gap. |\n| socialLinks | `FooterSocialLink[]` | - | Social media icon links rendered in the bottom bar. |\n| bottomText | `string` | - | Text displayed at the start of the bottom bar (e.g., copyright notice). |\n| bottomLinks | `FooterLink[]` | - | Links displayed in the bottom bar after social icons (e.g., Privacy, Terms). |\n| background | `string` | `colors.gray[50]` | Background color of the footer. |\n| bordered | `boolean` | `true` | When true, renders a 1 px top border in `colors.gray[200]`. |\n\n**Example:**\n```tsx\n```tsx\n<Footer\n brand={<img src=\"/logo.svg\" alt=\"Acme\" />}\n columns={[\n { title: 'Product', links: [{ label: 'Features', href: '/features' }] },\n { title: 'Company', links: [{ label: 'About', href: '/about' }] },\n ]}\n socialLinks={[\n { label: 'Twitter', href: 'https://twitter.com/acme', icon: <TwitterIcon /> },\n ]}\n bottomText=\"&copy; 2026 Acme Inc.\"\n bottomLinks={[{ label: 'Privacy', href: '/privacy' }]}\n bordered\n/>\n```\n```\n\n### Hero\n\nProps for the `Hero` composition.\n\nA prominent landing-page hero section that renders heading, description, CTA buttons,\nand an optional media slot. Does not compose other ggui primitives (uses plain HTML\nelements styled with design tokens). Content is constrained to `max-width: 1280px`.\n\nLayout modes:\n- `align='center'` \u2014 single-column centered layout with `max-width: 800px` text area.\n- `align='left'` \u2014 two-column side-by-side layout (50/50 split with media slot).\n\nSize controls vertical padding and font sizes:\n- `'sm'` \u2014 48 px vertical padding, 3xl heading, lg description.\n- `'md'` \u2014 80 px vertical padding, 4xl heading, xl description.\n- `'lg'` \u2014 120 px vertical padding, 5xl heading, xl description.\n\nThe primary action button uses `colors.primary[600]` fill; the secondary action uses\nan outlined style. When `overlay` is true with a `backgroundImage`, text switches to white\nand borders become semi-transparent.\n\n**Props:**\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| heading | `string` | - | Main heading text rendered as an `<h1>` with bold weight and tight line-height. |\n| description | `string` | - | Description paragraph rendered below the heading with relaxed line-height. |\n| primaryAction | `HeroAction` | - | Primary CTA button rendered with `colors.primary[600]` background and white text. |\n| secondaryAction | `HeroAction` | - | Secondary CTA button rendered with a transparent background and a 1 px border. |\n| media | `ReactNode` | - | Media element (image, video, illustration) rendered beside or below the text content. |\n| align | `'center' \\| 'left'` | `'center'` | Text and layout alignment. - `'center'` \u2014 centered single-column layout. - `'left'` \u2014 left-aligned text with media in a right column (50/50 split). |\n| size | `'sm' \\| 'md' \\| 'lg'` | `'md'` | Controls vertical padding and heading/description font sizes. - `'sm'` \u2014 compact (48 px padding, 3xl/lg fonts). - `'md'` \u2014 standard (80 px padding, 4xl/xl fonts). - `'lg'` \u2014 spacious (120 px padding, 5xl/xl fonts). |\n| background | `string` | `colors.white (when no backgroundImage is set)` | Background color of the hero section. |\n| backgroundImage | `string` | - | Background image URL applied as `background-size: cover; background-position: center`. |\n| overlay | `boolean` | `false` | When true and `backgroundImage` is set, renders a semi-transparent black overlay (`rgba(0,0,0,0.5)`) and switches text to white/semi-transparent white for contrast. |\n\n**Example:**\n```tsx\n```tsx\n<Hero\n heading=\"Build Better UIs, Faster\"\n description=\"The universal interface layer between AI agents and humans.\"\n primaryAction={{ label: 'Get Started', onClick: () => navigate('/signup') }}\n secondaryAction={{ label: 'Learn More', href: '/docs' }}\n media={<img src=\"/hero.png\" alt=\"Hero\" />}\n align=\"left\"\n size=\"lg\"\n/>\n```\n```\n\n### Support Types\n\n**SidebarItem:**\n\nA single navigation entry in a `Sidebar`. Supports nested children for\ncollapsible sub-menus and an optional badge slot.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier used for active-state matching and React keys. |\n| label | `string` | Display label for the item. Hidden when the sidebar is collapsed. |\n| icon | `ReactNode` | Leading icon rendered before the label. Remains visible when collapsed. |\n| href | `string` | Optional URL associated with this item (not rendered as a link by default). |\n| badge | `ReactNode` | Trailing badge element (e.g., unread count). Hidden when collapsed. |\n| children | `SidebarItem[]` | Nested child items. When present, the item acts as a collapsible section (chevron indicator shown). |\n| disabled | `boolean` | When true, the item is visually dimmed and non-interactive (`cursor: not-allowed`). |\n\n**Comment:**\n\nA single comment entry in a `CommentThread`. Supports nested replies\nand emoji reactions with counts.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier for this comment. |\n| author | `{ /** Display name of the author. */ name: string; /** URL for the author's avatar image. Passed to the `Avatar` primitive. */ avatar?: string; }` | Comment author metadata. |\n| content | `string` | The comment body text. |\n| timestamp | `string \\| Date` | Timestamp of the comment. Rendered via `toLocaleString()` when a `Date` object. |\n| replies | `Comment[]` | Nested reply comments. Each reply is rendered indented 40 px deeper. |\n| reactions | `{ emoji: string; count: number }[]` | Emoji reactions with their aggregated counts (e.g., `{ emoji: \"\u{1F44D}\", count: 3 }`). |\n\n**DataTableColumn:**\n\nColumn definition for a `DataTable`. Controls header text, width, alignment,\nsorting, and custom cell rendering.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| key | `string` | Property key on the row object used to extract cell values. Also serves as the sort key. |\n| header | `string` | Column header text displayed in the `<thead>`. |\n| width | `number \\| string` | Column width as a CSS value (number for pixels, string for any CSS unit). |\n| sortable | `boolean` | When true, the column header is clickable and triggers `onSort`. An arrow icon indicates direction. |\n| render | `(value: unknown, row: T, index: number) => ReactNode` | Custom cell renderer. When omitted, the raw value is stringified via `String()`. |\n| align | `'left' \\| 'center' \\| 'right'` | Text alignment for both header and body cells. |\n\n**ChatMessage:**\n\nA single message in a `ChatWindow`. Includes sender metadata, delivery status,\nand a timestamp.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier for this message. |\n| content | `string` | The message body text. |\n| sender | `{ /** Unique ID of the sender. Compared to `currentUserId` to determine alignment. */ id: string; /** Display name of the sender. */ name: string; /** Avatar image URL. Only shown for non-current-user messages (xs size). */ avatar?: string; }` | Sender metadata used for avatar rendering and alignment. |\n| timestamp | `string \\| Date` | Message timestamp. Rendered as `HH:MM` via `toLocaleTimeString` when a `Date` object. |\n| status | `'sending' \\| 'sent' \\| 'delivered' \\| 'read' \\| 'error'` | Delivery status indicator shown on the current user's messages. - `'sending'` \u2014 shows a dot bullet - `'sent'` \u2014 shows a single checkmark - `'delivered'` \u2014 shows double checkmarks - `'read'` \u2014 shows double checkmarks (same visual as delivered) - `'error'` \u2014 shows an exclamation mark |\n\n**NavItem:**\n\nA single navigation entry in a `NavigationBar`. Supports nested children\nfor sub-menus (rendered by the parent via dropdown, not built-in).\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier used for active-state matching and React keys. |\n| label | `string` | Display label for the navigation link. |\n| href | `string` | URL for the item. When provided, renders an `<a>` element instead of `<button>`. |\n| icon | `ReactNode` | Optional icon rendered before the label. |\n| children | `NavItem[]` | Nested child items (for sub-menu structures; rendering is consumer-defined). |\n| disabled | `boolean` | When true, the item is visually dimmed (opacity 0.5) and non-interactive. |\n\n**UploadedFile:**\n\nA file entry in the `FileUploader` composition. Tracks upload progress,\nstatus, and optional error messages.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier for this file entry. Used as a key and for removal callbacks. |\n| name | `string` | Original file name. Rendered with text-overflow ellipsis when too long. |\n| size | `number` | File size in bytes. Formatted as B/KB/MB/GB for display. |\n| type | `string` | MIME type of the file (e.g., `'image/png'`). |\n| progress | `number` | Upload progress as a percentage (0-100). Shown via the `Progress` primitive when status is `'uploading'`. |\n| status | `'pending' \\| 'uploading' \\| 'success' \\| 'error'` | Current upload lifecycle status. - `'pending'` \u2014 file selected but upload not started. - `'uploading'` \u2014 upload in progress; `progress` bar is shown. - `'success'` \u2014 upload completed. - `'error'` \u2014 upload failed; `error` message is shown in `colors.error[500]`. |\n| error | `string` | Error message displayed when status is `'error'`. |\n| url | `string` | The remote URL of the uploaded file after successful upload. |\n\n**Notification:**\n\nA single notification entry in the `NotificationCenter`. Supports semantic type coloring,\nread state, and an optional inline action button.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier for this notification. |\n| title | `string` | Notification title rendered in medium weight. |\n| message | `string` | Optional body text rendered below the title in a smaller font. |\n| timestamp | `string \\| Date` | Timestamp of the notification. Rendered via `toLocaleString()` when a `Date` object. |\n| read | `boolean` | Read state. Unread notifications get a `colors.primary[50]` background and a colored status dot matching the notification type. |\n| type | `'info' \\| 'success' \\| 'warning' \\| 'error'` | Semantic type that controls the status dot color on unread notifications. - `'info'` \u2014 `colors.info[500]` (blue) - `'success'` \u2014 `colors.success[500]` (green) - `'warning'` \u2014 `colors.warning[500]` (amber) - `'error'` \u2014 `colors.error[500]` (red) |\n| icon | `ReactNode` | Optional icon rendered alongside the notification (not used by the default implementation). |\n| action | `{ /** Button label text. */ label: string; /** Click handler for the action. */ onClick: () => void; }` | Optional inline action button rendered next to the timestamp. |\n\n**Command:**\n\nA single command entry in a `CommandPalette`. Commands can be grouped,\nhave keyboard shortcuts, and support a disabled state.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| id | `string` | Unique identifier for this command. |\n| label | `string` | Display label for the command. Searchable by the palette's query filter. |\n| description | `string` | Optional description text shown below the label. Also searchable. |\n| icon | `ReactNode` | Icon rendered at the start of the command row. |\n| shortcut | `string` | Keyboard shortcut hint displayed at the end of the row in a `<kbd>` element. |\n| group | `string` | Group name for visual sectioning. Commands with the same group are rendered under a shared header. Defaults to `'Commands'` if omitted. |\n| disabled | `boolean` | When true, the command is visually dimmed and cannot be selected. |\n\n**FooterLink:**\n\nA single link entry in a footer column or the bottom bar. Supports both\n`href` navigation and `onClick` handlers (onClick takes precedence via `preventDefault`).\n\n| Property | Type | Description |\n|----------|------|-------------|\n| label | `string` | Display text for the link. |\n| href | `string` | URL for the link. |\n| onClick | `() => void` | Click handler. When provided, `preventDefault` is called on the anchor click. |\n\n**FooterColumn:**\n\nA named column of links in the `Footer` layout. Each column has an optional title\nand a list of links rendered as a vertical stack with 10 px gap.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| title | `string` | Column heading rendered as an `<h4>` with semibold weight. |\n| links | `FooterLink[]` | Links displayed in this column. |\n\n**FooterSocialLink:**\n\nA social media link in the `Footer` bottom bar. Rendered as an icon-only anchor\nwith `aria-label` for accessibility.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| label | `string` | Accessible label for the social link (used as `aria-label`). |\n| href | `string` | URL for the social media profile or page. |\n| icon | `ReactNode` | Icon element rendered inside the anchor (typically an SVG or `Icon` primitive). |\n\n**HeroAction:**\n\nA call-to-action button definition for the `Hero` composition.\nUsed for both the primary (filled) and secondary (outlined) action buttons.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| label | `string` | Button label text. |\n| onClick | `() => void` | Click handler for the button. |\n| href | `string` | Optional URL (not used by the default implementation; available for consumer routing). |\n\n\n## System Conventions\n\n### onChange Behavior (CRITICAL)\n\nAll form control onChange handlers receive the VALUE DIRECTLY, not a React event object.\n\n```tsx\n// CORRECT \u2014 onChange receives value directly\n<Input value={name} onChange={setName} />\n<Input value={email} onChange={(value) => setEmail(value)} />\n<Select value={country} onChange={setCountry} options={countries} />\n<Checkbox checked={agreed} onChange={setAgreed} />\n\n// WRONG \u2014 DO NOT use e.target.value!\n<Input value={name} onChange={(e) => setName(e.target.value)} /> // WILL BREAK\n```\n\nApplies to: Input, TextArea, Select, Checkbox, RadioGroup, Slider, Tabs, Accordion.\n\n### Available Motion & Animation\n\nRender `<MotionKeyframes />` once (anywhere in tree) to enable all keyframes.\n\n**Entrance/exit:** fadeIn, fadeOut, slideInUp, slideInDown, scaleIn, scaleOut\n**State feedback:** flash (background-color highlight), pulse (opacity breathing), bounce (scale overshoot)\n**Easing:** linear, easeIn, easeOut, easeInOut, spring (bouncy)\n**Durations:** instant(0ms), fast(100ms), normal(200ms), slow(300ms), slower(500ms)\n\n```tsx\n// Entrance animation on mount\n<div style={{ animation: 'ggui-fadeIn 200ms ease-out' }}>Content</div>\n\n// Stagger list items\n{items.map((item, i) => (\n <div key={item.id} style={{ animation: \\`ggui-slideInUp 300ms ease-out \\${i * 50}ms both\\` }}>\n {item.name}\n </div>\n))}\n\n// Flash highlight when data changes (e.g., stock price update)\n// useAnimationKey returns a key that increments when dep changes \u2192 remounts element \u2192 replays animation\nconst priceKey = useAnimationKey(stock.price);\n<div key={priceKey} style={{\n animation: animation.flash,\n '--ggui-flash-color': stock.change > 0 ? 'var(--ggui-color-success-100)' : 'var(--ggui-color-error-100)',\n} as React.CSSProperties}>\n {stock.price}\n</div>\n\n// Respect reduced-motion preference\nconst { motionEnabled } = useMotion();\n<div style={motionEnabled ? { animation: 'ggui-scaleIn 200ms ease-out' } : undefined}>\n Content\n</div>\n```\n\n### Elevation System\n\n6 levels mapping shadow intensity to z-index for layering:\n- Level 0: flat (no shadow, z: auto) \u2014 inline content\n- Level 1: sm shadow (z: auto) \u2014 cards, sections\n- Level 2: md shadow (z: 1000) \u2014 dropdowns, popovers\n- Level 3: lg shadow (z: 1200) \u2014 sticky banners\n- Level 4: xl shadow (z: 1400) \u2014 modals, dialogs\n- Level 5: 2xl shadow (z: 1800) \u2014 tooltips, toasts\n\n### Import Constraints\n\nOnly these imports are allowed:\n- `react`\n- `@ggui-ai/design` \u2014 the whole design system (primitives, components, compositions, and the `Clickable` / `Hoverable` / `Pressable` traits) is one import; there are no subpaths\n- `@ggui-ai/wire` (wire hooks)\n\nNo external libraries (lodash, date-fns, etc.). No fetch(). No eval().";
2839
2872
  VALID_PRIMITIVES = [
2840
2873
  "Container",
2841
2874
  "Card",
@@ -3366,6 +3399,22 @@ var init_validation = __esm({
3366
3399
  }
3367
3400
  });
3368
3401
 
3402
+ // src/internal/verdict-line.ts
3403
+ function parseVerdictLine(stdout) {
3404
+ const lines = stdout.split("\n");
3405
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
3406
+ const line = lines[i];
3407
+ if (line.startsWith(`${VERDICT_PREFIX} `)) return JSON.parse(line.slice(VERDICT_PREFIX.length + 1));
3408
+ }
3409
+ return void 0;
3410
+ }
3411
+ var VERDICT_PREFIX;
3412
+ var init_verdict_line = __esm({
3413
+ "src/internal/verdict-line.ts"() {
3414
+ VERDICT_PREFIX = "@@ggui-render-check-verdict@@";
3415
+ }
3416
+ });
3417
+
3369
3418
  // src/harness/check/runtime-render/render-check-host.ts
3370
3419
  var render_check_host_exports = {};
3371
3420
  __export(render_check_host_exports, {
@@ -3473,18 +3522,19 @@ function mapSandboxResultToCheckResult(result, t0, boundMs = DEFAULT_RENDER_CHEC
3473
3522
  t0
3474
3523
  );
3475
3524
  }
3476
- const stdout = result.stdout.trim();
3477
- if (stdout.length === 0) {
3478
- return unverified("render check worker exited without producing a verdict.", t0);
3479
- }
3525
+ let verdict;
3480
3526
  try {
3481
- return JSON.parse(stdout);
3527
+ verdict = parseVerdictLine(result.stdout);
3482
3528
  } catch (err) {
3483
3529
  return unverified(
3484
3530
  `render check worker verdict was not valid JSON \u2014 ${err instanceof Error ? err.message : String(err)}`,
3485
3531
  t0
3486
3532
  );
3487
3533
  }
3534
+ if (verdict === void 0) {
3535
+ return unverified("render check worker exited without producing a verdict.", t0);
3536
+ }
3537
+ return verdict;
3488
3538
  }
3489
3539
  async function runRenderCheckViaWorker(input, options = {}) {
3490
3540
  const t0 = Date.now();
@@ -3503,7 +3553,9 @@ async function runRenderCheckViaWorker(input, options = {}) {
3503
3553
  gracePeriodMs: bounds.gracePeriodMs,
3504
3554
  maxStdoutBytes: CHECK_STDOUT_CAP,
3505
3555
  nodeHeapMb: bounds.heapMb,
3506
- stdin: JSON.stringify(input),
3556
+ // ggui#1398: the check learns its own deadline, so its report-only
3557
+ // pending phase can step aside instead of pushing it past the bound.
3558
+ stdin: JSON.stringify({ ...input, deadlineAt: t0 + bounds.timeoutMs }),
3507
3559
  // NODE_ENV steers React's production vs development build — keep
3508
3560
  // parity with the caller; everything else stays on the sandbox's
3509
3561
  // default allowlist.
@@ -3520,6 +3572,7 @@ async function runRenderCheckViaWorker(input, options = {}) {
3520
3572
  var DEFAULT_RENDER_CHECK_HOST_BOUNDS, CHECK_STDOUT_CAP, WORKER_BASENAME;
3521
3573
  var init_render_check_host = __esm({
3522
3574
  "src/harness/check/runtime-render/render-check-host.ts"() {
3575
+ init_verdict_line();
3523
3576
  DEFAULT_RENDER_CHECK_HOST_BOUNDS = {
3524
3577
  timeoutMs: 3e4,
3525
3578
  heapMb: 512,
@@ -4466,14 +4519,14 @@ function suggestManifestTokens(name) {
4466
4519
  const own = name.endsWith("-") ? segments.length : segments.length - 1;
4467
4520
  for (let n = own; n >= 2; n--) {
4468
4521
  const prefix = `--${segments.slice(0, n).join("-")}-`;
4469
- const family = consumedTokenManifest.filter((token) => token.startsWith(prefix));
4522
+ const family = taughtTokenManifest.filter((token) => token.startsWith(prefix));
4470
4523
  if (family.length > 0) return wordHitsFirst(name, rolesFirst(family));
4471
4524
  }
4472
4525
  const words = new Set(
4473
4526
  segments.slice(1).filter((segment) => segment.length >= 4).map((segment) => segment.toLowerCase())
4474
4527
  );
4475
4528
  return rolesFirst(
4476
- consumedTokenManifest.filter(
4529
+ taughtTokenManifest.filter(
4477
4530
  (token) => token.split("-").some((segment) => words.has(segment.toLowerCase()))
4478
4531
  )
4479
4532
  );
@@ -4485,8 +4538,8 @@ function wordHitsFirst(name, family) {
4485
4538
  return [...family.filter(hit), ...family.filter((token) => !hit(token))];
4486
4539
  }
4487
4540
  function rolesFirst(tokens) {
4488
- const isRole = (token) => !/^\d+$/.test(token.slice(token.lastIndexOf("-") + 1));
4489
- return [...tokens.filter(isRole), ...tokens.filter((token) => !isRole(token))];
4541
+ const isRole2 = (token) => !/^\d+$/.test(token.slice(token.lastIndexOf("-") + 1));
4542
+ return [...tokens.filter(isRole2), ...tokens.filter((token) => !isRole2(token))];
4490
4543
  }
4491
4544
  function offManifestHint(name) {
4492
4545
  const successor = RETIRED_TOKEN_SUCCESSORS.get(name);
@@ -4570,6 +4623,40 @@ function detectComponentNeverReturns(sourceCode) {
4570
4623
  for (const st of body.statements) if (!isFunctionLike(st)) visit(st);
4571
4624
  return returnsValue ? null : { jsxStatementLine };
4572
4625
  }
4626
+ function kitControlTags(source) {
4627
+ const out = [];
4628
+ const open = new RegExp(`<(${KIT_CONTROLS.join("|")})\\b`, "g");
4629
+ for (const m of source.matchAll(open)) {
4630
+ const start = (m.index ?? 0) + m[0].length;
4631
+ let depth = 0;
4632
+ let quote = null;
4633
+ let i = start;
4634
+ for (; i < source.length; i += 1) {
4635
+ const c = source[i];
4636
+ if (quote !== null) {
4637
+ if (c === quote && source[i - 1] !== "\\") quote = null;
4638
+ } else if (c === '"' || c === "'" || c === "`") quote = c;
4639
+ else if (c === "{") depth += 1;
4640
+ else if (c === "}") depth -= 1;
4641
+ else if (c === ">" && depth === 0) break;
4642
+ }
4643
+ out.push({ name: m[1] ?? "", attrs: source.slice(start, i), line: source.slice(0, m.index ?? 0).split("\n").length });
4644
+ }
4645
+ return out;
4646
+ }
4647
+ function inlineBorderKey(attrs, source) {
4648
+ const BORDER = /\b(border(?:Top|Right|Bottom|Left)?(?:Color|Width|Style)?)\s*:/;
4649
+ const style = attrs.match(/\bstyle=\{\{([\s\S]*)\}\}/);
4650
+ if (style !== null) {
4651
+ const hit2 = (style[1] ?? "").match(BORDER);
4652
+ if (hit2 !== null) return hit2[1] ?? null;
4653
+ }
4654
+ const named = attrs.match(/\bstyle=\{\s*([A-Za-z_$][\w$]*)\s*\}/);
4655
+ if (named === null) return null;
4656
+ const decl = source.match(new RegExp(`\\b(?:const|let)\\s+${named[1]}\\b[^=]*=\\s*\\{([^{}]*)\\}`));
4657
+ const hit = decl !== null ? (decl[1] ?? "").match(BORDER) : null;
4658
+ return hit !== null ? hit[1] ?? null : null;
4659
+ }
4573
4660
  async function runTier0Checks(sourceCode, contract, buildErrors, gadgetTypes, designMode = DEFAULT_DESIGN_MODE) {
4574
4661
  const issues = [];
4575
4662
  const lines = sourceCode.split("\n");
@@ -4967,6 +5054,20 @@ ${errorDetail}`,
4967
5054
  });
4968
5055
  }
4969
5056
  }
5057
+ for (const tag of enforceDesignVocabulary ? kitControlTags(sourceCode) : []) {
5058
+ const key = inlineBorderKey(tag.attrs, sourceCode);
5059
+ if (key === null) continue;
5060
+ issues.push({
5061
+ tier: 0,
5062
+ result: "fail",
5063
+ category: "tokens",
5064
+ subcategory: "control-inline-border",
5065
+ severity: "critical",
5066
+ description: `control-inline-border: <${tag.name}> sets an inline \`${key}\` \u2014 a kit control draws its own boundary, and an inline border paints over it`,
5067
+ fix: `Remove the border from <${tag.name}>'s style (keep borderRadius if you need it). An outline chip is <Button variant="outline"> exactly as it comes; a control-like edge on your own element uses var(--ggui-color-controlOutline).`,
5068
+ line: tag.line
5069
+ });
5070
+ }
4970
5071
  if (!sourceCode.includes("interface Props") && !sourceCode.includes("type Props")) {
4971
5072
  issues.push({
4972
5073
  tier: 0,
@@ -5099,7 +5200,7 @@ ${errorDetail}`,
5099
5200
  const extras = checkWirePreservation(sourceCode, contractsForExtraCheck).extra;
5100
5201
  for (const site of extras) {
5101
5202
  const hook = HOOK_NAME_FOR[site.kind];
5102
- const field = CONTRACT_FIELD_FOR[site.kind];
5203
+ const field2 = CONTRACT_FIELD_FOR[site.kind];
5103
5204
  issues.push({
5104
5205
  tier: 0,
5105
5206
  result: "fail",
@@ -5107,7 +5208,7 @@ ${errorDetail}`,
5107
5208
  subcategory: `wire_undeclared:${site.kind}:${site.name}`,
5108
5209
  severity: "critical",
5109
5210
  description: `Component calls ${hook}('${site.name}') but the contract does not declare ${site.kind} '${site.name}'. Every wire reference in the generated code MUST correspond to a declared entry on the agent-authored contract \u2014 the runtime mounts/registers wire surfaces from the contract, not from the code. ` + (contract === void 0 ? "No contract authored at all \u2014 describing the contract in the prompt text is NOT enough; the agent MUST pass a structured `contract` field on the ggui_render call." : ""),
5110
- fix: `Either (a) declare \`${field}.${site.name}\` on the \`contract\` input so the runtime registers the surface for this UI, or (b) remove the ${hook}('${site.name}') call from the component if it isn't part of the intended wire surface.`
5211
+ fix: `Either (a) declare \`${field2}.${site.name}\` on the \`contract\` input so the runtime registers the surface for this UI, or (b) remove the ${hook}('${site.name}') call from the component if it isn't part of the intended wire surface.`
5111
5212
  });
5112
5213
  }
5113
5214
  } catch {
@@ -5238,10 +5339,11 @@ async function runTier0(sourceCode, contract, designMode = DEFAULT_DESIGN_MODE)
5238
5339
  }
5239
5340
  return { issues, pass };
5240
5341
  }
5241
- var MANIFEST_TOKENS, RETIRED_TOKEN_SUCCESSORS, TOKEN_REF_RE, HOOK_NAME_FOR, CONTRACT_FIELD_FOR;
5342
+ var MANIFEST_TOKENS, RETIRED_TOKEN_SUCCESSORS, TOKEN_REF_RE, HOOK_NAME_FOR, CONTRACT_FIELD_FOR, KIT_CONTROLS;
5242
5343
  var init_run_tier0 = __esm({
5243
5344
  "src/check/run-tier0.ts"() {
5244
5345
  init_design_mode();
5346
+ init_taught_tokens();
5245
5347
  init_contract_validation();
5246
5348
  init_type_checker();
5247
5349
  init_react_linter();
@@ -5266,6 +5368,7 @@ var init_run_tier0 = __esm({
5266
5368
  stream: "streamSpec",
5267
5369
  context: "contextSpec"
5268
5370
  };
5371
+ KIT_CONTROLS = ["Button", "Input", "Select", "TextArea", "Checkbox", "RadioGroup", "Toggle", "SearchField"];
5269
5372
  }
5270
5373
  });
5271
5374
 
@@ -6315,6 +6418,7 @@ var init_llm_evaluator = __esm({
6315
6418
  // src/evaluation/cost-tracker.ts
6316
6419
  var cost_tracker_exports = {};
6317
6420
  __export(cost_tracker_exports, {
6421
+ CACHE_WRITE_PREMIUM_BOUND: () => CACHE_WRITE_PREMIUM_BOUND,
6318
6422
  CostTracker: () => CostTracker,
6319
6423
  pricePer1k: () => pricePer1k
6320
6424
  });
@@ -6330,21 +6434,42 @@ function pricePer1k(model) {
6330
6434
  const id = resolveRegistryId(model);
6331
6435
  if (id === null) return FALLBACK_PER_1K;
6332
6436
  const { costs } = MODEL_REGISTRY[id];
6333
- return { input: costs.inputPer1M / 1e3, output: costs.outputPer1M / 1e3 };
6437
+ return {
6438
+ input: costs.inputPer1M / 1e3,
6439
+ output: costs.outputPer1M / 1e3,
6440
+ cacheRead: (costs.cacheReadPer1M ?? costs.inputPer1M) / 1e3,
6441
+ cacheWrite: (costs.cacheWritePer1M ?? costs.inputPer1M * CACHE_WRITE_PREMIUM_BOUND) / 1e3
6442
+ };
6334
6443
  }
6335
- var FALLBACK_PER_1K, CostTracker;
6444
+ var CACHE_WRITE_PREMIUM_BOUND, FALLBACK_PER_1K, CostTracker;
6336
6445
  var init_cost_tracker = __esm({
6337
6446
  "src/evaluation/cost-tracker.ts"() {
6338
- FALLBACK_PER_1K = { input: 3e-3, output: 0.015 };
6447
+ CACHE_WRITE_PREMIUM_BOUND = Math.max(
6448
+ 1,
6449
+ ...Object.values(MODEL_REGISTRY).flatMap(
6450
+ ({ costs }) => costs.cacheWritePer1M !== void 0 && costs.inputPer1M > 0 ? [costs.cacheWritePer1M / costs.inputPer1M] : []
6451
+ )
6452
+ );
6453
+ FALLBACK_PER_1K = {
6454
+ input: 3e-3,
6455
+ output: 0.015,
6456
+ cacheRead: 3e-3,
6457
+ cacheWrite: 3e-3 * CACHE_WRITE_PREMIUM_BOUND
6458
+ };
6339
6459
  CostTracker = class {
6340
6460
  constructor(maxBudget) {
6341
6461
  this.maxBudget = maxBudget;
6342
6462
  }
6343
6463
  maxBudget;
6344
6464
  totalCost = 0;
6345
- record(model, inputTokens, outputTokens) {
6465
+ /**
6466
+ * Record one call. `inputTokens` is the provider's UNCACHED input; a call that read or wrote the prompt cache
6467
+ * passes those counts in `cache` (ggui#1524), priced at the cache rates — until then a turn with a long cached
6468
+ * prefix cost the cap almost nothing while the provider billed its reads and writes.
6469
+ */
6470
+ record(model, inputTokens, outputTokens, cache = {}) {
6346
6471
  const prices = pricePer1k(model);
6347
- const cost = inputTokens / 1e3 * prices.input + outputTokens / 1e3 * prices.output;
6472
+ const cost = inputTokens / 1e3 * prices.input + outputTokens / 1e3 * prices.output + (cache.read ?? 0) / 1e3 * prices.cacheRead + (cache.write ?? 0) / 1e3 * prices.cacheWrite;
6348
6473
  this.totalCost += cost;
6349
6474
  return cost;
6350
6475
  }
@@ -6463,9 +6588,14 @@ var init_select = __esm({
6463
6588
  });
6464
6589
 
6465
6590
  // src/evaluation/criteria/resolve.ts
6466
- function majority(id, answers) {
6591
+ function besideRead(kind, m) {
6592
+ if (m.edge === void 0) return { verdict: "n/a", evidence: "not measured: this capture had no page to read" };
6593
+ if (m.edge === null) return { verdict: "n/a", evidence: "edge probe failed on this capture" };
6594
+ return { verdict: m.edge.verdict, evidence: m.edge.evidence };
6595
+ }
6596
+ function majority(id, answers, unansweredReason) {
6467
6597
  const votes = answers.flatMap((a) => a.filter((x) => x.id === id));
6468
- if (votes.length === 0) return { verdict: "n/a", evidence: "not answered" };
6598
+ if (votes.length === 0) return { verdict: "n/a", evidence: unansweredReason ?? "not answered" };
6469
6599
  const counts = /* @__PURE__ */ new Map();
6470
6600
  for (const v of votes) counts.set(v.verdict, (counts.get(v.verdict) ?? 0) + 1);
6471
6601
  const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
@@ -6482,8 +6612,9 @@ function instrumentRead(kind, m) {
6482
6612
  if (m.inkRatio === null) return { verdict: "n/a", evidence: "capture unreadable" };
6483
6613
  return { verdict: "n/a", evidence: `fill ${m.inkRatio.toFixed(3)} reported; no floor ruled` };
6484
6614
  }
6485
- function readRow(row, answers, m) {
6486
- if (row.evaluation === "judge") return majority(row.id, answers);
6615
+ function readRow(row, answers, m, unansweredReason) {
6616
+ if (row.evaluation === "judge" && REQUIRES_POST_ACTION_CAPTURE.has(row.id) && m.postActionCapture !== true) return { verdict: "n/a", evidence: NO_POST_ACTION };
6617
+ if (row.evaluation === "judge") return majority(row.id, answers, unansweredReason);
6487
6618
  if (row.evaluation === "human") return { verdict: "n/a", evidence: `reader's column (status ${row.status})` };
6488
6619
  if (row.status !== "live") return { verdict: "n/a", evidence: `not implemented (status ${row.status})` };
6489
6620
  const kind = INSTRUMENT_BY_ID[row.id];
@@ -6495,8 +6626,18 @@ function resolveCriteriaBlock(args) {
6495
6626
  for (const s of args.selected.selection) {
6496
6627
  const row = byId.get(s.id);
6497
6628
  if (row === void 0) continue;
6498
- const read = readRow(row, args.answers, args.measurements);
6499
- verdicts.push({ id: row.id, severity: row.severity, method: row.evaluation, status: row.status, source: s.source, verdict: read.verdict, evidence: read.evidence });
6629
+ const read = readRow(row, args.answers, args.measurements, args.unansweredReason);
6630
+ const beside = INSTRUMENT_BESIDE_JUDGE[row.id];
6631
+ verdicts.push({
6632
+ id: row.id,
6633
+ severity: row.severity,
6634
+ method: row.evaluation,
6635
+ status: row.status,
6636
+ source: s.source,
6637
+ verdict: read.verdict,
6638
+ evidence: read.evidence,
6639
+ ...beside !== void 0 && row.evaluation === "judge" ? { instrument: besideRead(beside, args.measurements) } : {}
6640
+ });
6500
6641
  }
6501
6642
  return {
6502
6643
  criteriaSetId: args.selected.criteriaSetId,
@@ -6504,7 +6645,15 @@ function resolveCriteriaBlock(args) {
6504
6645
  selectorVersion: CRITERIA_SELECTOR_VERSION,
6505
6646
  context: args.context,
6506
6647
  selection: args.selected.selection,
6507
- verdicts
6648
+ verdicts,
6649
+ ...args.call !== void 0 ? {
6650
+ call: {
6651
+ answer: args.call.answer,
6652
+ // Kept answers whose id names no row of the bank: the judge answered something nobody asked.
6653
+ unknownIds: args.answers.flat().filter((a) => !byId.has(a.id)).length,
6654
+ ...args.call.finishReason !== void 0 ? { finishReason: args.call.finishReason } : {}
6655
+ }
6656
+ } : {}
6508
6657
  };
6509
6658
  }
6510
6659
  function criteriaFrameLine(frame) {
@@ -6523,14 +6672,14 @@ ${text}
6523
6672
  }
6524
6673
  function buildCriteriaJudgeBlock(bank, selected, inputs = {}) {
6525
6674
  const byId = new Map(bankRows(bank).map((r) => [r.id, r]));
6526
- const asked = selected.selection.map((s) => byId.get(s.id)).filter((r) => r !== void 0 && r.evaluation === "judge");
6675
+ const asked = selected.selection.map((s) => byId.get(s.id)).filter((r) => r !== void 0 && r.evaluation === "judge").filter((r) => inputs.postActionCapture === true || !REQUIRES_POST_ACTION_CAPTURE.has(r.id));
6527
6676
  if (asked.length === 0) return "";
6528
6677
  const lines = asked.map((r) => `- ${r.id} (${r.severity}): ${r.text} Evidence must name: ${r.evidence}`);
6529
6678
  return "## Criteria \u2014 answer each one from the FRAME only\nRead these off the screenshot. Never infer them from the request, the styling profile or any direction text above.\n" + (inputs.frame !== void 0 ? `${criteriaFrameLine(inputs.frame)}
6530
6679
  ` : "") + (inputs.propsJson !== void 0 ? criteriaPropsSection(inputs.propsJson) : "") + `${lines.join("\n")}
6531
6680
  Add to your JSON a top-level "criteria" array with exactly these ids: [{ "id": "<id>", "verdict": "pass" | "fail" | "n/a", "evidence": "<one sentence naming the region or element you read it from>" }]. Every row here was selected as applicable to this card, so "n/a" never means "does not apply": use it ONLY when this frame cannot show the criterion \u2014 it is then recorded as not evaluated.`;
6532
6681
  }
6533
- var INSTRUMENT_BY_ID, CRITERIA_PROPS_MAX_CHARS;
6682
+ var INSTRUMENT_BY_ID, INSTRUMENT_BESIDE_JUDGE, REQUIRES_POST_ACTION_CAPTURE, NO_POST_ACTION, CRITERIA_PROPS_MAX_CHARS;
6534
6683
  var init_resolve = __esm({
6535
6684
  "src/evaluation/criteria/resolve.ts"() {
6536
6685
  init_bank();
@@ -6540,6 +6689,11 @@ var init_resolve = __esm({
6540
6689
  "floor.fit": "fit",
6541
6690
  "finish.canvas.inhabited": "fill"
6542
6691
  };
6692
+ INSTRUMENT_BESIDE_JUDGE = {
6693
+ "space.edge": "edge"
6694
+ };
6695
+ REQUIRES_POST_ACTION_CAPTURE = /* @__PURE__ */ new Set(["state.feedback"]);
6696
+ NO_POST_ACTION = "not evaluated: no post-action capture";
6543
6697
  CRITERIA_PROPS_MAX_CHARS = 2e3;
6544
6698
  }
6545
6699
  });
@@ -6729,6 +6883,364 @@ var init_design_identity = __esm({
6729
6883
  }
6730
6884
  });
6731
6885
 
6886
+ // src/evaluation/criteria/edge.ts
6887
+ function contentEdge(surface, blocks) {
6888
+ const texts = blocks.filter((b) => b.within === surface.id && b.kind !== "surface" && b.role !== "centred");
6889
+ if (texts.length === 0) return void 0;
6890
+ const first = texts.reduce((a, b) => b.top < a.top ? b : a);
6891
+ return first.textEdge ?? first.edge;
6892
+ }
6893
+ function columns(direct) {
6894
+ const all = direct.filter((b) => b.kind !== "surface");
6895
+ const bridges = (b) => {
6896
+ const under = all.filter((x) => x !== b && overlaps(x, b));
6897
+ return under.some((x, i) => under.slice(i + 1).some((y) => !overlaps(x, y)));
6898
+ };
6899
+ const flow = all.filter((b) => !bridges(b));
6900
+ const parentPool = all.filter((b) => bridges(b));
6901
+ const parent = flow.map((_, i) => i);
6902
+ const find = (i) => parent[i] === i ? i : parent[i] = find(parent[i]);
6903
+ for (let i = 0; i < flow.length; i += 1) for (let j = i + 1; j < flow.length; j += 1) if (overlaps(flow[i], flow[j])) parent[find(i)] = find(j);
6904
+ const groups = /* @__PURE__ */ new Map();
6905
+ flow.forEach((b, i) => groups.set(find(i), [...groups.get(find(i)) ?? [], b]));
6906
+ const cols = [...groups.values()];
6907
+ for (const s of direct.filter((b) => b.kind === "surface")) {
6908
+ const hit = cols.filter((c) => c.some((b) => b.kind !== "surface" && overlaps(b, s)));
6909
+ if (hit.length === 1) hit[0].push(s);
6910
+ else parentPool.push(s);
6911
+ }
6912
+ const lp = parentPool.map((_, i) => i);
6913
+ const lfind = (i) => lp[i] === i ? i : lp[i] = lfind(lp[i]);
6914
+ for (let i = 0; i < parentPool.length; i += 1) for (let j = i + 1; j < parentPool.length; j += 1) if (overlaps(parentPool[i], parentPool[j])) lp[lfind(i)] = lfind(j);
6915
+ const lgroups = /* @__PURE__ */ new Map();
6916
+ parentPool.forEach((b, i) => lgroups.set(lfind(i), [...lgroups.get(lfind(i)) ?? [], b]));
6917
+ cols.push(...lgroups.values());
6918
+ return cols;
6919
+ }
6920
+ function checkColumn(container, col, blocks) {
6921
+ const surfaces = col.filter((b) => b.kind === "surface");
6922
+ const grounds = surfaces.flatMap((s) => {
6923
+ const c = s.role === "table" ? void 0 : contentEdge(s, blocks);
6924
+ return c === void 0 ? [] : [c];
6925
+ });
6926
+ const flow = col.filter((b) => b.kind !== "surface" && b.role !== "centred" && b.role !== "list-item");
6927
+ const listItems = col.filter((b) => b.role === "list-item");
6928
+ const candidates = [...flow.flatMap((b) => [b.edge, ...b.textEdge !== void 0 ? [b.textEdge] : []]), ...surfaces.flatMap((s) => [s.edge]), ...grounds];
6929
+ const blockEdges = (b) => [b.edge, ...b.textEdge !== void 0 ? [b.textEdge] : []];
6930
+ const endEdgesBesides = (b) => [...surfaces.map((s) => s.right), ...flow.filter((x) => x !== b && x.kind === "controls").map((x) => x.right)];
6931
+ let best = candidates.length === 0 ? [] : null;
6932
+ for (const E of candidates) {
6933
+ const allowed = [E, ...grounds];
6934
+ const off = [];
6935
+ for (const b of flow) {
6936
+ if (blockEdges(b).some((x) => allowed.some((a) => same(x, a)))) continue;
6937
+ if (b.kind === "controls" && endEdgesBesides(b).some((r) => same(b.right, r))) continue;
6938
+ const nearest = allowed.reduce((n, a) => Math.abs(a - b.edge) < Math.abs(n - b.edge) ? a : n);
6939
+ off.push({ container, block: b.id, at: b.edge, nearest, offPx: Math.abs(b.edge - nearest) });
6940
+ }
6941
+ for (const s of surfaces) {
6942
+ const c = s.role === "table" ? void 0 : contentEdge(s, blocks);
6943
+ if (same(s.edge, E) || c !== void 0 && same(c, E)) continue;
6944
+ const nearest = c !== void 0 && Math.abs(c - E) < Math.abs(s.edge - E) ? c : s.edge;
6945
+ off.push({ container, block: s.id, at: nearest, nearest: E, offPx: Math.abs(nearest - E) });
6946
+ }
6947
+ if (best === null || off.length < best.length || off.length === best.length && sum(off) < sum(best)) best = off;
6948
+ if (best.length === 0) break;
6949
+ }
6950
+ const offsets = best ?? [];
6951
+ if (listItems.length > 1) {
6952
+ const e0 = listItems[0].edge;
6953
+ for (const li of listItems.slice(1)) if (!same(li.edge, e0)) offsets.push({ container, block: li.id, at: li.edge, nearest: e0, offPx: Math.abs(li.edge - e0) });
6954
+ }
6955
+ const centred = col.filter((b) => b.role === "centred");
6956
+ const edgeAligned = col.filter((b) => b.role !== "centred");
6957
+ const groupTop = centred.length > 0 ? Math.min(...centred.map((c) => c.top)) : 0;
6958
+ const groupEndsColumn = centred.length > 1 && edgeAligned.every((b) => b.top < groupTop);
6959
+ if (centred.length > 0 && edgeAligned.some((b) => b.kind !== "surface") && !groupEndsColumn) {
6960
+ const E = flow[0]?.edge ?? edgeAligned[0].edge;
6961
+ for (const c of centred) offsets.push({ container, block: c.id, at: c.edge, nearest: E, offPx: Math.abs(c.edge - E), reason: "a centred block in an edge-aligned column" });
6962
+ } else if (centred.length > 1) {
6963
+ const a0 = axis(centred[0]);
6964
+ for (const c of centred.slice(1)) if (!same(axis(c), a0)) offsets.push({ container, block: c.id, at: axis(c), nearest: a0, offPx: Math.abs(axis(c) - a0) });
6965
+ }
6966
+ return offsets;
6967
+ }
6968
+ function judgeEdges(measured, opts = {}) {
6969
+ if (opts.placeholdersOnly === true) return { verdict: "n/a", offsets: [], evidence: "only loading placeholders are shown; not evaluated" };
6970
+ let blocks = measured;
6971
+ const kept = blocks.filter((b) => b.role !== "placeholder");
6972
+ const content = kept.filter((b) => b.kind !== "surface").length;
6973
+ if (content < 2) return { verdict: "n/a", offsets: [], evidence: kept.length === 0 ? "no blocks were measured" : "fewer than two blocks once placeholders are set aside; not evaluated" };
6974
+ blocks = kept;
6975
+ const offsets = [];
6976
+ const containers = [CARD, ...blocks.filter((b) => b.kind === "surface" && b.role !== "table").map((b) => b.id)];
6977
+ for (const container of containers) {
6978
+ const direct = blocks.filter((b) => (b.within ?? CARD) === container);
6979
+ for (const col of columns(direct)) offsets.push(...checkColumn(container, col, blocks));
6980
+ }
6981
+ if (offsets.length === 0) return { verdict: "pass", offsets, evidence: "every column shares its edge, or sits on a stacked surface\u2019s content edge, within 2 px" };
6982
+ const worst = offsets.reduce((a, b) => b.offPx > a.offPx ? b : a);
6983
+ const lines = offsets.map((o) => `${o.block} at x${round(o.at)} is ${round(o.offPx)} px from x${round(o.nearest)} (in ${o.container}${o.reason !== void 0 ? `; ${o.reason}` : ""})`);
6984
+ return { verdict: "fail", offsets, evidence: `${offsets.length} block(s) off the allowed edges, largest ${round(worst.offPx)} px: ${lines.join("; ")}` };
6985
+ }
6986
+ var EDGE_TOLERANCE_PX, CARD, same, overlaps, axis, sum, round;
6987
+ var init_edge = __esm({
6988
+ "src/evaluation/criteria/edge.ts"() {
6989
+ EDGE_TOLERANCE_PX = 2;
6990
+ CARD = "card";
6991
+ same = (a, b) => Math.abs(a - b) <= EDGE_TOLERANCE_PX;
6992
+ overlaps = (a, b) => a.left < b.right && b.left < a.right;
6993
+ axis = (b) => (b.left + b.right) / 2;
6994
+ sum = (o) => o.reduce((s, x) => s + x.offPx, 0);
6995
+ round = (n) => (Math.round(n * 10) / 10).toString();
6996
+ }
6997
+ });
6998
+
6999
+ // src/evaluation/criteria/edge-probe.ts
7000
+ function parseEdgeProbe(value) {
7001
+ if (typeof value !== "object" || value === null) return null;
7002
+ const raw = field(value, "blocks");
7003
+ const placeholdersOnly = field(value, "placeholdersOnly");
7004
+ if (!Array.isArray(raw) || typeof placeholdersOnly !== "boolean") return null;
7005
+ const skippedRaw = field(value, "skipped");
7006
+ if (typeof skippedRaw !== "object" || skippedRaw === null) return null;
7007
+ const rowItems = field(skippedRaw, "rowItems");
7008
+ const ornaments = field(skippedRaw, "ornaments");
7009
+ if (!num(rowItems) || !num(ornaments)) return null;
7010
+ const blocks = [];
7011
+ for (const entry of raw) {
7012
+ if (typeof entry !== "object" || entry === null) return null;
7013
+ const [id, kind, edge, left, right, top] = ["id", "kind", "edge", "left", "right", "top"].map((k) => field(entry, k));
7014
+ const [within, textEdge, role] = ["within", "textEdge", "role"].map((k) => field(entry, k));
7015
+ if (typeof id !== "string" || !isKind(kind) || !num(edge) || !num(left) || !num(right) || !num(top)) return null;
7016
+ if (within !== void 0 && typeof within !== "string") return null;
7017
+ if (textEdge !== void 0 && !num(textEdge)) return null;
7018
+ if (role !== void 0 && !isRole(role)) return null;
7019
+ blocks.push({
7020
+ id,
7021
+ kind,
7022
+ edge,
7023
+ left,
7024
+ right,
7025
+ top,
7026
+ ...within !== void 0 ? { within } : {},
7027
+ ...textEdge !== void 0 ? { textEdge } : {},
7028
+ ...role !== void 0 ? { role } : {}
7029
+ });
7030
+ }
7031
+ return { blocks, placeholdersOnly, skipped: { rowItems, ornaments } };
7032
+ }
7033
+ var EDGE_BLOCKS_EXPRESSION, KINDS, ROLES, isKind, isRole, num, field;
7034
+ var init_edge_probe = __esm({
7035
+ "src/evaluation/criteria/edge-probe.ts"() {
7036
+ EDGE_BLOCKS_EXPRESSION = String.raw`(() => {
7037
+ const root = document.getElementById('root');
7038
+ if (!root) return null;
7039
+ const rootBox = root.getBoundingClientRect();
7040
+ const x0 = rootBox.left;
7041
+ const px = (v) => Math.round((v - x0) * 2) / 2;
7042
+ const CONTROL = 'button,input,select,textarea,[role="button"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],[role="option"],[role="link"],a[href]';
7043
+ const ALNUM = /[\p{L}\p{N}]/u;
7044
+ const css = (el) => getComputedStyle(el);
7045
+ const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0(\.0+)?\)$/.test(c);
7046
+ // Visible through every ancestor (an ancestor at opacity 0 or visibility hidden hides it), and not degenerate.
7047
+ const shown = (el) => {
7048
+ const s = css(el);
7049
+ if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) === 0) return false;
7050
+ if (typeof el.checkVisibility === 'function' && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
7051
+ const r = el.getBoundingClientRect();
7052
+ return r.width > 1 && r.height > 1;
7053
+ };
7054
+ const inline = (s) => /^inline/.test(s.display) && s.display !== 'inline-block' && s.display !== 'inline-flex' && s.display !== 'inline-grid';
7055
+ const groundOf = (el) => {
7056
+ for (let p = el.parentElement; p; p = p.parentElement) { const b = css(p).backgroundColor; if (!clear(b)) return b; }
7057
+ return 'rgb(255, 255, 255)';
7058
+ };
7059
+ const sides = (s) => ['Top', 'Right', 'Bottom', 'Left'].filter((k) => parseFloat(s['border' + k + 'Width']) > 0 && s['border' + k + 'Style'] !== 'none' && !clear(s['border' + k + 'Color'])).length;
7060
+ const bounded = (el) => { const s = css(el); return sides(s) >= 1 || (!clear(s.backgroundColor) && s.backgroundColor !== groundOf(el)); };
7061
+ // A control: a native or ARIA one, or a bounded box the visitor clicks (a pointer cursor or a tab stop) that holds no
7062
+ // control of its own and is not the whole card. Its contents are not blocks of the column (decided 2026-10-02).
7063
+ const isControl = (el) => {
7064
+ const s = css(el);
7065
+ if (inline(s)) return false;
7066
+ if (el.matches(CONTROL)) return true;
7067
+ if (el === root) return false;
7068
+ const clickable = s.cursor === 'pointer' || (el.hasAttribute('tabindex') && el.tabIndex >= 0);
7069
+ if (!clickable || !bounded(el) || el.querySelector(CONTROL)) return false;
7070
+ const r = el.getBoundingClientRect();
7071
+ return r.width * r.height < 0.6 * rootBox.width * rootBox.height;
7072
+ };
7073
+ const isTable = (el) => el.matches('table,[role="table"],[role="grid"]');
7074
+ const isSurface = (el) => {
7075
+ if (el === root || isControl(el)) return false;
7076
+ const s = css(el);
7077
+ if (/^inline/.test(s.display) || s.display === 'contents') return false;
7078
+ if (isTable(el)) return true;
7079
+ return sides(s) >= 3 || (!clear(s.backgroundColor) && s.backgroundColor !== groundOf(el)) || (s.boxShadow && s.boxShadow !== 'none');
7080
+ };
7081
+ const path = (el) => {
7082
+ const parts = [];
7083
+ for (let e = el; e && e !== root && parts.length < 4; e = e.parentElement) {
7084
+ const i = e.parentElement ? Array.prototype.indexOf.call(e.parentElement.children, e) + 1 : 1;
7085
+ parts.unshift(e.tagName.toLowerCase() + ':' + i);
7086
+ }
7087
+ return parts.join('>');
7088
+ };
7089
+ const label = (el) => { const t = (el.innerText || '').replace(/\s+/g, ' ').trim(); return path(el) + (t ? ' "' + t.slice(0, 24) + '"' : ''); };
7090
+ const surfaces = [];
7091
+ const surfaceOf = (el, self) => { for (let p = self ? el : el.parentElement; p && p !== root; p = p.parentElement) if (surfaces.indexOf(p) >= 0) return p; return null; };
7092
+ const inTable = (el) => { for (let p = el.parentElement; p && p !== root; p = p.parentElement) if (isTable(p)) return true; return false; };
7093
+ const inControl = (el) => { for (let p = el; p && p !== root; p = p.parentElement) if (isControl(p)) return p; return null; };
7094
+ const all = Array.prototype.slice.call(root.querySelectorAll('*')).filter(shown);
7095
+ for (const el of all) if (isSurface(el) && !inTable(el) && !inControl(el)) surfaces.push(el);
7096
+ const ids = new Map();
7097
+ const idOf = (el) => { if (!ids.has(el)) ids.set(el, label(el)); return ids.get(el); };
7098
+ const blocks = [];
7099
+ // Per block: the element it stands for and its first line's box, for rows.
7100
+ const owners = [];
7101
+ const lines = [];
7102
+ // Per block: its raw line boxes and left, so a row reads the line two items share, not a wrapped item's widest line.
7103
+ const rects = [];
7104
+ const rawLeft = [];
7105
+ const words = [];
7106
+ const within = (el, self) => { const s = surfaceOf(el, self); return s ? { within: idOf(s) } : {}; };
7107
+ for (const s of surfaces) {
7108
+ const r = s.getBoundingClientRect();
7109
+ owners.push(s); lines.push({ top: r.top, bottom: r.bottom }); rects.push([r]); rawLeft.push(r.left); words.push('');
7110
+ // A placeholder: a box with no text, no control and no image or icon inside it (a loading bar). Not a block.
7111
+ const holds = Array.prototype.some.call(s.querySelectorAll('*'), (el) => shown(el) && (el.matches(CONTROL) || el.tagName === 'IMG' || el.tagName.toLowerCase() === 'svg')) || ALNUM.test(s.innerText || '');
7112
+ blocks.push(Object.assign({ id: idOf(s), kind: 'surface', edge: px(r.left), left: px(r.left), right: px(r.right), top: Math.round(r.top) }, within(s, false), isTable(s) ? { role: 'table' } : holds ? {} : { role: 'placeholder' }));
7113
+ }
7114
+ const blockOf = (node) => { for (let p = node.parentElement; p && p !== root; p = p.parentElement) if (!inline(css(p))) return p; return root; };
7115
+ const groups = new Map();
7116
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
7117
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
7118
+ if (!/\S/.test(n.data) || !n.parentElement || n.parentElement.closest('style,script,noscript,template') || !shown(n.parentElement) || inControl(n.parentElement) || inTable(n.parentElement)) continue;
7119
+ const b = blockOf(n);
7120
+ if (!groups.has(b)) groups.set(b, []);
7121
+ groups.get(b).push(n);
7122
+ }
7123
+ // Ink: text whose own colour is not transparent. The edge is the first visible character in ink (decided 2026-10-02):
7124
+ // leading invisible text, private-use icon-font glyphs and formatting characters are not characters of the block,
7125
+ // while a currency sign or a quotation mark is.
7126
+ const inked = (n) => !clear(css(n.parentElement).color) && ALNUM.test(n.data);
7127
+ const textRects = (nodes) => {
7128
+ const rs = [];
7129
+ for (const n of nodes) { if (!inked(n)) continue; const range = document.createRange(); range.selectNodeContents(n); for (const r of range.getClientRects()) if (r.width > 0 && r.height > 0) rs.push(r); }
7130
+ return rs;
7131
+ };
7132
+ const glyphAt = (n, i) => { const range = document.createRange(); range.setStart(n, i); range.setEnd(n, i + 1); const r = range.getClientRects()[0]; return r || null; };
7133
+ const firstGlyph = (nodes) => {
7134
+ for (const n of nodes) { if (!inked(n)) continue; const i = n.data.search(/[^\s\p{Co}\p{Cf}]/u); if (i < 0) continue; const r = glyphAt(n, i); if (r) return r; }
7135
+ return null;
7136
+ };
7137
+ // A marker paints: a loaded image, an SVG with a filled or stroked shape, or an element with its own fill, border
7138
+ // or visible text. An element that paints nothing is not a marker.
7139
+ const painted = (m) => {
7140
+ if (!shown(m)) return false;
7141
+ if (m.tagName === 'IMG') return m.complete && m.naturalWidth > 0;
7142
+ if (m.tagName.toLowerCase() === 'svg' || m.closest('svg')) {
7143
+ const shapes = m.querySelectorAll('path,circle,rect,line,polyline,polygon,ellipse,use,text');
7144
+ return Array.prototype.some.call(shapes, (sh) => { const st = css(sh); return (st.fill !== 'none' && !clear(st.fill)) || (st.stroke !== 'none' && !clear(st.stroke)); });
7145
+ }
7146
+ const s = css(m);
7147
+ if (!clear(s.backgroundColor) || sides(s) >= 1 || ((m.textContent || '').trim().length > 0 && !clear(s.color))) return true;
7148
+ // A wrapper that paints nothing itself is a marker when an image or icon inside it paints.
7149
+ return Array.prototype.some.call(m.querySelectorAll('svg,img'), (k) => painted(k));
7150
+ };
7151
+ const markerBefore = (b, textLeft, lineTop, lineBottom) => {
7152
+ const cands = [];
7153
+ const consider = (m) => { if (!painted(m)) return; const r = m.getBoundingClientRect(); if (r.left < textLeft - 2 && r.bottom > lineTop && r.top < lineBottom) cands.push(r.left); };
7154
+ for (const m of b.querySelectorAll('svg,img,[aria-hidden="true"],i')) if ((m.textContent || '').trim().length <= 2) consider(m);
7155
+ const p = b.parentElement;
7156
+ if (p && p !== root && /flex/.test(css(p).display) && !/column/.test(css(p).flexDirection)) for (const sib of p.children) { if (sib === b) break; if (sib.matches('svg,img,i,[aria-hidden="true"]') || !(sib.textContent || '').trim()) consider(sib); }
7157
+ return cands.length ? Math.min.apply(null, cands) : null;
7158
+ };
7159
+ let texts = 0;
7160
+ let ornaments = 0;
7161
+ for (const [b, nodes] of groups) {
7162
+ if (!nodes.some((n) => ALNUM.test(n.data))) { ornaments += 1; continue; }
7163
+ const rs = textRects(nodes);
7164
+ const g = firstGlyph(nodes);
7165
+ if (!rs.length || g === null) continue;
7166
+ texts += 1;
7167
+ const first = g.left;
7168
+ const left = Math.min.apply(null, rs.map((r) => r.left));
7169
+ const right = Math.max.apply(null, rs.map((r) => r.right));
7170
+ const top = Math.min.apply(null, rs.map((r) => r.top));
7171
+ const marker = markerBefore(b, first, g.top, g.bottom);
7172
+ const s = css(b);
7173
+ const box = b.getBoundingClientRect();
7174
+ const parent = b.parentElement ? css(b.parentElement) : null;
7175
+ const centredText = s.textAlign === 'center' && right - left < box.width * 0.9;
7176
+ const centredBox = parent && /flex/.test(parent.display) && /column/.test(parent.flexDirection) && parent.alignItems === 'center';
7177
+ const list = b.matches('li') || s.display === 'list-item' ? b.closest('ul,ol') : null;
7178
+ const inProse = list && list.previousElementSibling && list.previousElementSibling.matches('p');
7179
+ const block = { id: idOf(b), kind: 'text', edge: px(marker !== null ? marker : first), left: px(Math.min(left, marker !== null ? marker : left)), right: px(right), top: Math.round(top) };
7180
+ if (marker !== null) block.textEdge = px(first);
7181
+ if (centredText || centredBox) block.role = 'centred';
7182
+ else if (inProse) block.role = 'list-item';
7183
+ owners.push(b); lines.push({ top: g.top, bottom: g.bottom }); rects.push(rs); rawLeft.push(Math.min(left, marker !== null ? marker : left)); words.push(nodes.map((n) => n.data).join('').trim());
7184
+ blocks.push(Object.assign(block, within(b, true)));
7185
+ }
7186
+ const ctlGroups = new Map();
7187
+ for (const el of all) { if (!isControl(el) || inTable(el) || (el.parentElement && inControl(el.parentElement))) continue; const p = el.parentElement || root; if (!ctlGroups.has(p)) ctlGroups.set(p, []); ctlGroups.get(p).push(el); }
7188
+ let controls = 0;
7189
+ for (const [p, els] of ctlGroups) {
7190
+ const rs = els.map((e) => e.getBoundingClientRect());
7191
+ const topRow = Math.min.apply(null, rs.map((r) => r.top));
7192
+ let firstEl = null; let firstR = null;
7193
+ els.forEach((e, i) => { const r = rs[i]; if (r.top - topRow < r.height / 2 && (!firstR || r.left < firstR.left)) { firstEl = e; firstR = r; } });
7194
+ let edge = firstR.left;
7195
+ if (!bounded(firstEl)) { const tw = document.createTreeWalker(firstEl, NodeFilter.SHOW_TEXT); const ns = []; for (let n = tw.nextNode(); n; n = tw.nextNode()) ns.push(n); const g = firstGlyph(ns); if (g) edge = g.left; }
7196
+ controls += 1;
7197
+ const ps = css(p);
7198
+ const centred = ps.justifyContent === 'center' || (ps.textAlign === 'center' && !/flex|grid/.test(ps.display));
7199
+ const block = { id: idOf(p) + ' [controls]', kind: 'controls', edge: px(edge), left: px(Math.min.apply(null, rs.map((r) => r.left))), right: px(Math.max.apply(null, rs.map((r) => r.right))), top: Math.round(topRow) };
7200
+ if (centred) block.role = 'centred';
7201
+ owners.push(firstEl); lines.push({ top: firstR.top, bottom: firstR.bottom }); rects.push(rs); rawLeft.push(Math.min.apply(null, rs.map((r) => r.left))); words.push('');
7202
+ blocks.push(Object.assign(block, within(firstEl, true)));
7203
+ }
7204
+ // Rows (decided 2026-10-01, read by geometry 2026-10-02): items set on one line, however the CSS sets them. An item
7205
+ // after another measured item on its line that holds one block (a text, a control, or a box holding at most one
7206
+ // text) belongs to the row and is not measured; an item with a stack inside it is a column.
7207
+ const dropped = new Set();
7208
+ const sameLine = (a, b) => Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > Math.min(a.bottom - a.top, b.bottom - b.top) / 2;
7209
+ const contains = (o, el) => o === el || o.contains(el);
7210
+ const innerTexts = (i) => owners.map((el, k) => k).filter((k) => k !== i && blocks[k].kind !== 'surface' && contains(owners[i], owners[k]));
7211
+ const isPill = (i) => blocks[i].kind === 'surface' && innerTexts(i).length <= 1 && !owners.some((el, k) => k !== i && blocks[k].kind === 'surface' && contains(owners[i], el));
7212
+ const itemOf = (el, other) => { let e = el; while (e.parentElement && e.parentElement !== root && !e.parentElement.contains(other)) e = e.parentElement; return e; };
7213
+ for (let i = 0; i < blocks.length; i += 1) {
7214
+ if (blocks[i].kind === 'surface' && !isPill(i)) continue;
7215
+ const before = blocks.findIndex((bj, j) => j !== i && bj.within === blocks[i].within && !(bj.kind === 'surface' && !isPill(j)) && !contains(owners[j], owners[i]) && !contains(owners[i], owners[j]) && rects[j].some((r) => sameLine(r, lines[i]) && r.right <= rawLeft[i] + 1));
7216
+ if (before < 0) continue;
7217
+ // A number or bullet in front of a text on its line is a hanging marker, not a row: the pair is one block with two
7218
+ // edges, the marker's and the text's.
7219
+ if (blocks[i].kind === 'text' && blocks[before].kind === 'text' && /^[\p{N}\p{P}\p{S}]{1,3}$/u.test(words[before])) {
7220
+ blocks[i] = Object.assign({}, blocks[i], { edge: blocks[before].edge, left: Math.min(blocks[i].left, blocks[before].left), textEdge: blocks[i].edge });
7221
+ delete blocks[i].role;
7222
+ dropped.add(before);
7223
+ continue;
7224
+ }
7225
+ const item = itemOf(owners[i], owners[before]);
7226
+ const inside = owners.map((el, k) => k).filter((k) => contains(item, owners[k]) && blocks[k].kind !== 'surface');
7227
+ const boxes = owners.map((el, k) => k).filter((k) => contains(item, owners[k]) && blocks[k].kind === 'surface' && k !== i);
7228
+ if (blocks[i].kind === 'surface') { if (boxes.length === 0) { dropped.add(i); for (const k of innerTexts(i)) dropped.add(k); } continue; }
7229
+ if (inside.length === 1 && boxes.length === 0) dropped.add(i);
7230
+ }
7231
+ const kept = blocks.filter((_, i) => !dropped.has(i));
7232
+ // Placeholders: a frame with no text in ink shows nothing to evaluate, whatever controls or boxes it draws.
7233
+ return { blocks: kept, placeholdersOnly: texts === 0, skipped: { rowItems: dropped.size, ornaments } };
7234
+ })()`;
7235
+ KINDS = ["text", "controls", "surface"];
7236
+ ROLES = ["list-item", "table", "centred", "placeholder"];
7237
+ isKind = (v) => KINDS.some((k) => k === v);
7238
+ isRole = (v) => ROLES.some((r) => r === v);
7239
+ num = (v) => typeof v === "number" && Number.isFinite(v);
7240
+ field = (o, key) => Reflect.get(o, key);
7241
+ }
7242
+ });
7243
+
6732
7244
  // src/evaluation/visual-evaluator.ts
6733
7245
  var visual_evaluator_exports = {};
6734
7246
  __export(visual_evaluator_exports, {
@@ -6847,6 +7359,7 @@ function canvasOverflowIssue(canvas, viewport, contentHeight, verdict) {
6847
7359
  const box = isDeclaredViewport(canvas, viewport) ? `declared ${viewport.width}\xD7${viewport.height}; class box ${classBox.width}\xD7${classBox.height}` : `${viewport.width}\xD7${viewport.height}`;
6848
7360
  return {
6849
7361
  dimension: "canvas-overflow",
7362
+ origin: "instrument",
6850
7363
  severity: verdict === "fail" ? "critical" : "major",
6851
7364
  description: `Rendered content is ${contentHeight}px tall on the ${canvas} canvas (${box}) \u2014 ${hidden}px ${verdict === "fail" ? "is cut off: the inline card does not scroll" : "sits below the first screen"}.`,
6852
7365
  fix: "Fit the composition to the canvas: fewer and shorter sections, one compact row of chips, no hero taller than a third of the box, no fixed min-heights or tall paddings \u2014 measure against the canvas, not the page."
@@ -6855,6 +7368,7 @@ function canvasOverflowIssue(canvas, viewport, contentHeight, verdict) {
6855
7368
  function canvasOverflowXIssue(canvas, viewport, hiddenPx, verdict) {
6856
7369
  return {
6857
7370
  dimension: "canvas-overflow-x",
7371
+ origin: "instrument",
6858
7372
  severity: verdict === "fail" ? "critical" : "major",
6859
7373
  description: `Content runs ${hiddenPx}px past the ${canvas} card's ${viewport.width}px width \u2014 the card's frame clips it, so a visitor sees a cut-off row or a sideways scroll.`,
6860
7374
  fix: "Keep every row within the card: wrap long text (overflow-wrap: anywhere), let a wide table or code block scroll inside its own container, and give no child a fixed width wider than the card."
@@ -6865,6 +7379,7 @@ function canvasBlankIssue(canvas, viewport) {
6865
7379
  const box = isDeclaredViewport(canvas, viewport) ? `declared ${viewport.width}\xD7${viewport.height}; class box ${classBox.width}\xD7${classBox.height}` : `${viewport.width}\xD7${viewport.height}`;
6866
7380
  return {
6867
7381
  dimension: "canvas-blank",
7382
+ origin: "instrument",
6868
7383
  severity: "critical",
6869
7384
  description: `Nothing painted on the ${canvas} canvas (${box}): the capture is one flat colour \u2014 the component mounted and rendered no pixels.`,
6870
7385
  fix: "Return and paint the component tree: a root that renders nothing is a blank, not an empty state. Check that the default export returns its JSX, that no early return yields null on the sample props, and that the first paint shows visible content."
@@ -7080,6 +7595,19 @@ async function measureContentHeight(page, expression = CONTENT_HEIGHT_EXPRESSION
7080
7595
  return null;
7081
7596
  }
7082
7597
  }
7598
+ async function measureEdges(page) {
7599
+ try {
7600
+ const probe = parseEdgeProbe(await page.evaluate(EDGE_BLOCKS_EXPRESSION));
7601
+ if (probe === null) {
7602
+ console.warn("[visual-eval] edge probe unavailable: the page returned no readable blocks");
7603
+ return null;
7604
+ }
7605
+ return judgeEdges(probe.blocks, { placeholdersOnly: probe.placeholdersOnly });
7606
+ } catch (e) {
7607
+ console.warn(`[visual-eval] edge probe unavailable: ${e instanceof Error ? e.message : String(e)}`);
7608
+ return null;
7609
+ }
7610
+ }
7083
7611
  async function captureScreenshotDetailed(html, viewport = DEFAULT_SCREENSHOT_VIEWPORT, deps = {}, capture = "full-page", natural = { padPx: JUDGE_INLINE_PAD_PX }) {
7084
7612
  try {
7085
7613
  const launchOptions = await resolveLaunchOptions(viewport, deps);
@@ -7101,12 +7629,14 @@ async function captureScreenshotDetailed(html, viewport = DEFAULT_SCREENSHOT_VIE
7101
7629
  }
7102
7630
  const framed = cardHeightPx === null ? viewport.height - 2 * natural.padPx : floorPx !== void 0 ? Math.max(cardHeightPx, floorPx) : cardHeightPx;
7103
7631
  const clip = naturalClip(viewport, framed, natural.padPx);
7632
+ const edge2 = await measureEdges(page);
7104
7633
  const screenshot2 = await page.screenshot({ type: "png", fullPage: false, clip });
7105
- return { png: Buffer.from(screenshot2), contentHeight: cardHeightPx, overflowX };
7634
+ return { png: Buffer.from(screenshot2), contentHeight: cardHeightPx, overflowX, edge: edge2 };
7106
7635
  }
7107
7636
  const contentHeight = await measureContentHeight(page);
7637
+ const edge = await measureEdges(page);
7108
7638
  const screenshot = await page.screenshot({ type: "png", fullPage: capture === "full-page" });
7109
- return { png: Buffer.from(screenshot), contentHeight };
7639
+ return { png: Buffer.from(screenshot), contentHeight, edge };
7110
7640
  } finally {
7111
7641
  await browser.close();
7112
7642
  }
@@ -7192,6 +7722,42 @@ function overflowXVerdict(canvas, frame) {
7192
7722
  if (px === void 0 || px === null || px <= OVERFLOW_X_ROUNDING_PX || frame.policy.overflow === "none") return null;
7193
7723
  return canvasOverflowXIssue(canvas, frame.viewport, px, frame.policy.overflow);
7194
7724
  }
7725
+ function judgeSalvageOf(error, text, finishReason) {
7726
+ const cause = finishReason === void 0 ? "unknown" : finishReason === "length" ? "output-cap" : finishReason === "stop" ? "malformed" : "stopped";
7727
+ const message = error instanceof Error ? error.message : String(error);
7728
+ const position = /position (\d+)/.exec(message);
7729
+ const start = text.indexOf("{");
7730
+ const offset = position !== null ? (start > 0 ? start : 0) + Number(position[1]) : void 0;
7731
+ const parseError = message.startsWith("Visual evaluator did not return valid JSON") ? "no complete JSON object in the answer" : message.replace(/"(?:[^"\\]|\\.)*"/g, '"\u2026"').slice(0, 160);
7732
+ return { cause, ...finishReason !== void 0 ? { finishReason } : {}, parseError, ...offset !== void 0 ? { offset } : {}, chars: text.length };
7733
+ }
7734
+ function judgeRecordOf(k, parsed, samples, criteria, requestedModel) {
7735
+ const salvaged = parsed.flatMap((a) => a.salvage !== void 0 ? [a.salvage.cause] : []);
7736
+ const answered = [...parsed, ...criteria?.kind === "ok" ? [criteria] : []];
7737
+ const served = [...new Set(answered.flatMap((a) => a.servedModel !== void 0 ? [a.servedModel] : []))];
7738
+ const criteriaSalvaged = criteria?.kind === "ok" ? criteria.salvage?.cause : void 0;
7739
+ return {
7740
+ k,
7741
+ rule: "median",
7742
+ samples,
7743
+ sigma: populationSigma(samples),
7744
+ notes: parsed.map((a) => a.result.critique ?? ""),
7745
+ ...salvaged.length > 0 ? { salvaged } : {},
7746
+ ...criteriaSalvaged !== void 0 ? { criteriaSalvaged } : {},
7747
+ models: { requested: requestedModel, served }
7748
+ };
7749
+ }
7750
+ function callFacts(response) {
7751
+ return {
7752
+ ...response.finishReason !== void 0 ? { finishReason: response.finishReason } : {},
7753
+ ...response.servedModel !== void 0 ? { servedModel: response.servedModel } : {}
7754
+ };
7755
+ }
7756
+ function criteriaCallOf(criteria) {
7757
+ if (criteria?.kind !== "ok") return {};
7758
+ const answer = criteria.result.criteriaCounts ?? NO_CRITERIA_ARRAY;
7759
+ return { call: { answer, ...criteria.finishReason !== void 0 ? { finishReason: criteria.finishReason } : {} } };
7760
+ }
7195
7761
  async function judgeFrame(judge, config, model, png, originalPrompt, profileBlock, canvas, k, criteriaBlock) {
7196
7762
  const [scoring, criteria] = await Promise.all([
7197
7763
  Promise.all(Array.from({ length: k }, () => judgeAndParse(judge, config, model, png, originalPrompt, profileBlock, canvas))),
@@ -7203,7 +7769,10 @@ function criteriaCallTokens(criteria) {
7203
7769
  return criteria?.kind === "ok" ? { inputTokens: criteria.inputTokens, outputTokens: criteria.outputTokens } : void 0;
7204
7770
  }
7205
7771
  function criteriaReads(criteria) {
7206
- return criteria?.kind === "ok" ? [criteria.result.criteriaAnswers ?? []] : [];
7772
+ return criteria?.kind === "ok" && criteria.salvage === void 0 ? [criteria.result.criteriaAnswers ?? []] : [];
7773
+ }
7774
+ function criteriaUnansweredReason(criteria) {
7775
+ return criteria?.kind === "ok" && criteria.salvage !== void 0 ? { unansweredReason: `not answered: the criteria answer was salvaged (${criteria.salvage.cause})` } : {};
7207
7776
  }
7208
7777
  function judgeCountFor(config, canvas) {
7209
7778
  const k = Math.max(1, Math.floor(config.judgeK ?? 1));
@@ -7226,12 +7795,16 @@ async function judgeAndParse(judge, config, model, screenshot, originalPrompt, p
7226
7795
  const response = await judge(config, model, VISUAL_EVAL_PROMPT, screenshot, originalPrompt, profileBlock, criteriaBlock);
7227
7796
  try {
7228
7797
  const result = parseVisualResponse(response.text, config.passThreshold);
7229
- return { kind: "ok", result, inputTokens: response.inputTokens, outputTokens: response.outputTokens };
7798
+ return { kind: "ok", result, inputTokens: response.inputTokens, outputTokens: response.outputTokens, ...callFacts(response) };
7230
7799
  } catch (e) {
7231
7800
  const salvaged = salvageTruncatedVisualAnswer(response.text, config.passThreshold);
7232
7801
  if (salvaged !== null) {
7233
- console.warn(`[visual-eval] visual_judge_truncated${canvas ? ` canvas=${canvas}` : ""}: answer cut after ${response.text.length} chars \u2014 score recovered from the closed prefix, issues list partial`);
7234
- return { kind: "ok", result: salvaged, inputTokens: response.inputTokens, outputTokens: response.outputTokens };
7802
+ const salvage = judgeSalvageOf(e, response.text, response.finishReason);
7803
+ const token = criteriaBlock.length > 0 ? "visual_criteria_salvaged" : "visual_judge_salvaged";
7804
+ console.warn(
7805
+ `[visual-eval] ${token}${canvas ? ` canvas=${canvas}` : ""} cause=${salvage.cause} finish=${salvage.finishReason ?? "unreported"} at=${salvage.offset ?? "?"}/${salvage.chars} (${salvage.parseError}): score recovered from the closed prefix, issues list partial`
7806
+ );
7807
+ return { kind: "ok", result: salvaged, inputTokens: response.inputTokens, outputTokens: response.outputTokens, salvage, ...callFacts(response) };
7235
7808
  }
7236
7809
  const reason = `judge answer unparsable: ${e instanceof Error ? e.message : String(e)}`;
7237
7810
  if (attempt === 1) {
@@ -7310,17 +7883,11 @@ async function runVisualEvaluationDetailed(context, config, deps = {}) {
7310
7883
  const result2 = representative.result;
7311
7884
  result2.finalScore = median;
7312
7885
  result2.passed = median >= config.passThreshold;
7313
- result2.inputTokens = parsed.reduce((sum, a) => sum + a.inputTokens, 0);
7314
- result2.outputTokens = parsed.reduce((sum, a) => sum + a.outputTokens, 0);
7886
+ result2.inputTokens = parsed.reduce((sum2, a) => sum2 + a.inputTokens, 0);
7887
+ result2.outputTokens = parsed.reduce((sum2, a) => sum2 + a.outputTokens, 0);
7315
7888
  const response2 = { inputTokens: result2.inputTokens, outputTokens: result2.outputTokens };
7316
7889
  const criteriaTokens = criteriaCallTokens(criteriaAnswer);
7317
- const judgeRecord = {
7318
- k,
7319
- rule: "median",
7320
- samples,
7321
- sigma: populationSigma(samples),
7322
- notes: parsed.map((a) => a.result.critique ?? "")
7323
- };
7890
+ const judgeRecord = judgeRecordOf(k, parsed, samples, criteriaAnswer, model);
7324
7891
  const contentHeight = attempt2.contentHeight;
7325
7892
  const overflow = contentHeight !== null && contentHeight > viewport.height;
7326
7893
  const fitIssue = fitVerdict(canvas, frame);
@@ -7344,7 +7911,9 @@ async function runVisualEvaluationDetailed(context, config, deps = {}) {
7344
7911
  context: criteriaContext,
7345
7912
  selected: criteriaSelected,
7346
7913
  answers: criteriaReads(criteriaAnswer),
7347
- measurements: { overflow, contentHeight, viewportHeight: viewport.height, inkRatio }
7914
+ measurements: { overflow, contentHeight, viewportHeight: viewport.height, inkRatio, edge: attempt2.edge ?? null },
7915
+ ...criteriaUnansweredReason(criteriaAnswer),
7916
+ ...criteriaCallOf(criteriaAnswer)
7348
7917
  }) : void 0;
7349
7918
  perCanvasResults.push(result2);
7350
7919
  perCanvas.push({
@@ -7406,7 +7975,7 @@ function canvasScopedIssue(canvas, issue) {
7406
7975
  }
7407
7976
  function aggregateCanvasResults(perCanvas, results) {
7408
7977
  const n = results.length;
7409
- const mean = (pick) => Math.round(results.reduce((sum, r) => sum + pick(r), 0) / n);
7978
+ const mean = (pick) => Math.round(results.reduce((sum2, r) => sum2 + pick(r), 0) / n);
7410
7979
  const dimensions = {
7411
7980
  completeness: mean((r) => r.dimensions.completeness),
7412
7981
  visualPolish: mean((r) => r.dimensions.visualPolish),
@@ -7426,7 +7995,18 @@ function aggregateCanvasResults(perCanvas, results) {
7426
7995
  outputTokens += r.outputTokens ?? 0;
7427
7996
  });
7428
7997
  const criteriaSpent = perCanvas.flatMap((c) => c.criteriaTokens !== void 0 ? [c.criteriaTokens] : []);
7998
+ const judgeSalvage = {
7999
+ scoring: {
8000
+ salvaged: perCanvas.reduce((sum2, c) => sum2 + (c.judge.salvaged?.length ?? 0), 0),
8001
+ answers: perCanvas.reduce((sum2, c) => sum2 + c.judge.samples.length, 0)
8002
+ },
8003
+ criteria: {
8004
+ salvaged: perCanvas.filter((c) => c.judge.criteriaSalvaged !== void 0).length,
8005
+ answered: perCanvas.filter((c) => c.criteriaTokens !== void 0).length
8006
+ }
8007
+ };
7429
8008
  return {
8009
+ judgeSalvage,
7430
8010
  passed: perCanvas.every((c) => c.passed),
7431
8011
  finalScore: mean((r) => r.finalScore),
7432
8012
  dimensions,
@@ -7436,8 +8016,8 @@ function aggregateCanvasResults(perCanvas, results) {
7436
8016
  outputTokens,
7437
8017
  ...criteriaSpent.length > 0 ? {
7438
8018
  criteriaTokens: {
7439
- inputTokens: criteriaSpent.reduce((sum, t) => sum + t.inputTokens, 0),
7440
- outputTokens: criteriaSpent.reduce((sum, t) => sum + t.outputTokens, 0)
8019
+ inputTokens: criteriaSpent.reduce((sum2, t) => sum2 + t.inputTokens, 0),
8020
+ outputTokens: criteriaSpent.reduce((sum2, t) => sum2 + t.outputTokens, 0)
7441
8021
  }
7442
8022
  } : {},
7443
8023
  canvases: [...perCanvas]
@@ -7523,7 +8103,9 @@ async function judgeStoredCapture(input, config, deps = {}) {
7523
8103
  context: criteriaContext,
7524
8104
  selected: criteriaSelected,
7525
8105
  answers: criteriaReads(criteriaAnswer),
7526
- measurements: { overflow, contentHeight, viewportHeight: viewport.height, inkRatio }
8106
+ measurements: { overflow, contentHeight, viewportHeight: viewport.height, inkRatio },
8107
+ ...criteriaUnansweredReason(criteriaAnswer),
8108
+ ...criteriaCallOf(criteriaAnswer)
7527
8109
  }) : void 0;
7528
8110
  const criteriaTokens = criteriaCallTokens(criteriaAnswer);
7529
8111
  return {
@@ -7534,13 +8116,13 @@ async function judgeStoredCapture(input, config, deps = {}) {
7534
8116
  score: median,
7535
8117
  passed,
7536
8118
  issues,
7537
- judge: { k, rule: "median", samples, sigma: populationSigma(samples), notes: parsed.map((a) => a.result.critique ?? "") },
8119
+ judge: judgeRecordOf(k, parsed, samples, criteriaAnswer, model),
7538
8120
  contentHeight,
7539
8121
  overflow,
7540
8122
  inkRatio,
7541
8123
  ...criteria !== void 0 ? { criteria } : {},
7542
- inputTokens: parsed.reduce((sum, a) => sum + a.inputTokens, 0),
7543
- outputTokens: parsed.reduce((sum, a) => sum + a.outputTokens, 0),
8124
+ inputTokens: parsed.reduce((sum2, a) => sum2 + a.inputTokens, 0),
8125
+ outputTokens: parsed.reduce((sum2, a) => sum2 + a.outputTokens, 0),
7544
8126
  ...criteriaTokens !== void 0 ? { criteriaTokens } : {}
7545
8127
  }
7546
8128
  };
@@ -7579,7 +8161,7 @@ function salvageTruncatedVisualAnswer(text, passThreshold) {
7579
8161
  hierarchy,
7580
8162
  aesthetics,
7581
8163
  issues,
7582
- critique: `[truncated judge answer \u2014 score recovered from the closed prefix; ${issues.length} issue(s) kept, the rest and the critique were cut]`
8164
+ critique: `[salvaged judge answer \u2014 it did not parse; score recovered from the closed prefix; ${issues.length} issue(s) kept, the rest and the critique lost]`
7583
8165
  });
7584
8166
  return parseVisualResponse(rebuilt, passThreshold);
7585
8167
  }
@@ -7608,31 +8190,43 @@ function parseVisualResponse(text, passThreshold) {
7608
8190
  };
7609
8191
  const issues = (raw.issues || []).map((i) => ({
7610
8192
  dimension: i.dimension || "visual",
8193
+ origin: "judge",
7611
8194
  severity: i.severity || "minor",
7612
8195
  description: i.description || "",
7613
8196
  fix: i.fix || ""
7614
8197
  }));
7615
- const criteriaAnswers = parseCriteriaAnswers(raw.criteria);
8198
+ const criteria = parseCriteriaAnswers(raw.criteria);
7616
8199
  return {
7617
8200
  passed: finalScore >= passThreshold,
7618
8201
  finalScore,
7619
8202
  dimensions,
7620
8203
  issues,
7621
8204
  critique: raw.critique,
7622
- ...criteriaAnswers !== void 0 ? { criteriaAnswers } : {}
8205
+ ...criteria.answers !== void 0 ? { criteriaAnswers: criteria.answers } : {},
8206
+ criteriaCounts: criteria.counts
7623
8207
  };
7624
8208
  }
7625
8209
  function parseCriteriaAnswers(raw) {
7626
- if (!Array.isArray(raw)) return void 0;
7627
- const out = [];
8210
+ if (!Array.isArray(raw)) return { counts: NO_CRITERIA_ARRAY };
8211
+ const answers = [];
8212
+ const dropped = { notObject: 0, badId: 0, badVerdict: 0 };
7628
8213
  for (const entry of raw) {
7629
- if (typeof entry !== "object" || entry === null) continue;
8214
+ if (typeof entry !== "object" || entry === null) {
8215
+ dropped.notObject += 1;
8216
+ continue;
8217
+ }
7630
8218
  const o = entry;
7631
- if (typeof o.id !== "string") continue;
7632
- if (o.verdict !== "pass" && o.verdict !== "fail" && o.verdict !== "n/a") continue;
7633
- out.push({ id: o.id, verdict: o.verdict, evidence: typeof o.evidence === "string" ? o.evidence : "" });
8219
+ if (typeof o.id !== "string") {
8220
+ dropped.badId += 1;
8221
+ continue;
8222
+ }
8223
+ if (o.verdict !== "pass" && o.verdict !== "fail" && o.verdict !== "n/a") {
8224
+ dropped.badVerdict += 1;
8225
+ continue;
8226
+ }
8227
+ answers.push({ id: o.id, verdict: o.verdict, evidence: typeof o.evidence === "string" ? o.evidence : "" });
7634
8228
  }
7635
- return out;
8229
+ return { answers, counts: { array: true, received: raw.length, kept: answers.length, dropped } };
7636
8230
  }
7637
8231
  function getDefaultVisualModel(provider) {
7638
8232
  switch (provider) {
@@ -7652,7 +8246,12 @@ async function runVisualEval(context, config, deps = {}) {
7652
8246
  const issues = (result.issues || []).map(toEvalIssue2);
7653
8247
  const summary = summarizeVisualResult(result);
7654
8248
  const coverage = { status: "ran" };
7655
- return summary === void 0 ? { issues, coverage } : { issues, summary, coverage };
8249
+ const tokens = result.inputTokens !== void 0 && result.outputTokens !== void 0 ? {
8250
+ inputTokens: result.inputTokens,
8251
+ outputTokens: result.outputTokens,
8252
+ ...result.criteriaTokens !== void 0 ? { criteria: result.criteriaTokens } : {}
8253
+ } : void 0;
8254
+ return { issues, ...summary !== void 0 ? { summary } : {}, coverage, ...tokens !== void 0 ? { tokens } : {} };
7656
8255
  }
7657
8256
  function toEvalIssue2(issue) {
7658
8257
  return {
@@ -7662,7 +8261,8 @@ function toEvalIssue2(issue) {
7662
8261
  subcategory: issue.dimension,
7663
8262
  severity: issue.severity === "critical" ? "critical" : "major",
7664
8263
  description: issue.description,
7665
- fix: issue.fix || ""
8264
+ fix: issue.fix || "",
8265
+ ...issue.origin !== void 0 ? { origin: issue.origin } : {}
7666
8266
  };
7667
8267
  }
7668
8268
  async function runVisualFit(context, config, deps = {}) {
@@ -7708,7 +8308,7 @@ async function runVisualFit(context, config, deps = {}) {
7708
8308
  }
7709
8309
  return { status: "measured", issues, readings };
7710
8310
  }
7711
- var JUDGE_SCOPE_CLASS, JUDGE_PANEL_CLASS, JUDGE_INK_INSET_PX, JUDGE_GROUND_MARGIN_PX, JUDGE_GROUND_CLASS, JUDGE_INLINE_FRAME_CLASS, JUDGE_INLINE_FRAME_BORDER_PX, JUDGE_INLINE_FRAME_RADIUS_PX, JUDGE_INLINE_PAD_PX, JUDGE_INLINE_INK_INSET_PX, HOST_COLOUR_PATTERN, HOST_FRAME_BOUNDS, EXECUTABLE_PATH_LAUNCH_ARGS, DEFAULT_SCREENSHOT_VIEWPORT, CONTENT_HEIGHT_EXPRESSION, CARD_HEIGHT_EXPRESSION, OVERFLOW_X_ROUNDING_PX, CARD_OVERFLOW_X_EXPRESSION, VISUAL_EVAL_PROMPT, VISUAL_JUDGE_MAX_OUTPUT_TOKENS, VISUAL_JUDGE_PROMPT_VERSION, VISUAL_JUDGE_PROMPT_DIGEST;
8311
+ var JUDGE_SCOPE_CLASS, JUDGE_PANEL_CLASS, JUDGE_INK_INSET_PX, JUDGE_GROUND_MARGIN_PX, JUDGE_GROUND_CLASS, JUDGE_INLINE_FRAME_CLASS, JUDGE_INLINE_FRAME_BORDER_PX, JUDGE_INLINE_FRAME_RADIUS_PX, JUDGE_INLINE_PAD_PX, JUDGE_INLINE_INK_INSET_PX, HOST_COLOUR_PATTERN, HOST_FRAME_BOUNDS, EXECUTABLE_PATH_LAUNCH_ARGS, DEFAULT_SCREENSHOT_VIEWPORT, CONTENT_HEIGHT_EXPRESSION, CARD_HEIGHT_EXPRESSION, OVERFLOW_X_ROUNDING_PX, CARD_OVERFLOW_X_EXPRESSION, VISUAL_EVAL_PROMPT, VISUAL_JUDGE_MAX_OUTPUT_TOKENS, VISUAL_JUDGE_PROMPT_VERSION, VISUAL_JUDGE_PROMPT_DIGEST, NO_CRITERIA_ARRAY;
7712
8312
  var init_visual_evaluator = __esm({
7713
8313
  "src/evaluation/visual-evaluator.ts"() {
7714
8314
  init_styling_profile();
@@ -7718,6 +8318,8 @@ var init_visual_evaluator = __esm({
7718
8318
  init_design_identity();
7719
8319
  init_llm_router();
7720
8320
  init_design_mode();
8321
+ init_edge();
8322
+ init_edge_probe();
7721
8323
  JUDGE_SCOPE_CLASS = "ggui-judge-scope";
7722
8324
  JUDGE_PANEL_CLASS = "ggui-judge-panel";
7723
8325
  JUDGE_INK_INSET_PX = EXPANDED_FRAME.insetPx + 24 + 1;
@@ -7784,6 +8386,7 @@ Respond with ONLY a JSON object (no markdown, no explanation):
7784
8386
  VISUAL_JUDGE_MAX_OUTPUT_TOKENS = 4096;
7785
8387
  VISUAL_JUDGE_PROMPT_VERSION = "v2-bounded-issues";
7786
8388
  VISUAL_JUDGE_PROMPT_DIGEST = createHash("sha256").update(VISUAL_EVAL_PROMPT, "utf8").digest("hex");
8389
+ NO_CRITERIA_ARRAY = { array: false, received: 0, kept: 0, dropped: { notObject: 0, badId: 0, badVerdict: 0 } };
7787
8390
  }
7788
8391
  });
7789
8392
 
@@ -7810,7 +8413,7 @@ __export(primitive_slice_exports, {
7810
8413
  function computePrimitiveAllowlist(classification) {
7811
8414
  const set = new Set(CORE_PRIMITIVES);
7812
8415
  const v = classification.vector;
7813
- for (const [axis, value] of [
8416
+ for (const [axis2, value] of [
7814
8417
  ["render", v.render],
7815
8418
  ["state", v.state],
7816
8419
  ["writes", v.writes],
@@ -7820,7 +8423,7 @@ function computePrimitiveAllowlist(classification) {
7820
8423
  ["layout", v.layout],
7821
8424
  ["tooling", v.tooling]
7822
8425
  ]) {
7823
- const additions = AXIS_PRIMITIVES[`${axis}:${value}`] ?? [];
8426
+ const additions = AXIS_PRIMITIVES[`${axis2}:${value}`] ?? [];
7824
8427
  for (const p of additions) set.add(p);
7825
8428
  }
7826
8429
  return [...set].sort();
@@ -11092,8 +11695,8 @@ var FRAGMENT_REGISTRY = {
11092
11695
  layout: layoutFragments,
11093
11696
  tooling: toolingFragments
11094
11697
  };
11095
- function lookupFragment(axis, value) {
11096
- return FRAGMENT_REGISTRY[axis]?.[value];
11698
+ function lookupFragment(axis2, value) {
11699
+ return FRAGMENT_REGISTRY[axis2]?.[value];
11097
11700
  }
11098
11701
 
11099
11702
  // src/compose.ts
@@ -11110,10 +11713,10 @@ var AXIS_ORDER = [
11110
11713
  function compose(classification) {
11111
11714
  const v = classification.vector;
11112
11715
  const matched = [];
11113
- for (const axis of AXIS_ORDER) {
11114
- const value = v[axis];
11716
+ for (const axis2 of AXIS_ORDER) {
11717
+ const value = v[axis2];
11115
11718
  if (!value) continue;
11116
- const frag = lookupFragment(axis, value);
11719
+ const frag = lookupFragment(axis2, value);
11117
11720
  if (frag) matched.push(frag);
11118
11721
  }
11119
11722
  const promptParts = matched.filter((f) => f.promptText && f.promptText.trim().length > 0).map((f) => f.promptText.trim());
@@ -11505,6 +12108,7 @@ init_types_public();
11505
12108
  init_design_mode();
11506
12109
 
11507
12110
  // src/boilerplate/free-design-prompt.ts
12111
+ init_taught_tokens();
11508
12112
  init_design_mode();
11509
12113
 
11510
12114
  // src/boilerplate/hard-sections.ts
@@ -11715,7 +12319,7 @@ The width works the same way, and the mistake is the mirror image: **your outerm
11715
12319
 
11716
12320
  - Never put \`maxWidth\` (or a \`max-w-*\` class) on the element you return at the top. On a wide canvas that turns your card into a narrow strip with the frame's ground on either side of it, and on a narrow one it does nothing \u2014 the same composition reading differently at two sizes.
11717
12321
  - DO cap the reading measure inside: a column of prose is easiest to read at 60\u201370 characters, so wrap the TEXT in a \`<Container maxWidth="sm">\` (or your own \`max-width\`) and leave the root filling. A hero band, a toolbar, a table or an image then still uses the whole width, which is what makes a wide card look composed rather than padded.
11718
- - Centre that inner column with the container's own centring, not by shrinking the root.
12322
+ - Centre that inner column with the container's own centring, not by shrinking the root \u2014 and give the card ONE column: every block of the reading flow (a heading, its text, and the chips or buttons that answer it) sits in a \`<Container>\` of the same \`maxWidth\`, so their left edges line up even where a full-width band runs between them.
11719
12323
  - **They are two different elements.** Even when the whole card is one column of text, the root still fills and the column sits INSIDE it \u2014 \`<Box padding="lg"><Container maxWidth="sm">\u2026</Container></Box>\`, never a root that is itself the container. A centred card is centring inside a filled frame, not a shrunken frame.`;
11720
12324
  var COMPONENT_STRUCTURE = `## Component Structure
11721
12325
 
@@ -11792,7 +12396,7 @@ function colorVocabularyFromManifest(manifest) {
11792
12396
  function renderRamp(r) {
11793
12397
  return `\`${r.family}-${r.steps.join("/")}\`${r.bare ? ` (plus bare \`${r.family}\`)` : ""}`;
11794
12398
  }
11795
- function renderFreeColorRule(manifest = consumedTokenManifest) {
12399
+ function renderFreeColorRule(manifest = taughtTokenManifest) {
11796
12400
  const { ramps, roles } = colorVocabularyFromManifest(manifest);
11797
12401
  const brand = ramps.filter((r) => r.family === "primary" || r.family === "neutral");
11798
12402
  const state = ramps.filter((r) => r.family !== "primary" && r.family !== "neutral");
@@ -11882,7 +12486,7 @@ var TOKEN_GROUPS = [
11882
12486
  { label: "Radius (optional)", prefixes: ["--ggui-shape-radius-"], note: "" },
11883
12487
  { label: "Shadow (optional)", prefixes: ["--ggui-shape-shadow-"], note: "" }
11884
12488
  ];
11885
- function renderTokenVocabulary(manifest = consumedTokenManifest) {
12489
+ function renderTokenVocabulary(manifest = taughtTokenManifest) {
11886
12490
  const sorted = [...manifest].sort();
11887
12491
  const claimed = /* @__PURE__ */ new Set();
11888
12492
  const lines = [
@@ -12414,13 +13018,14 @@ MANDATORY:
12414
13018
  2. NEVER use CSS gradients with custom colors. If you need a gradient: \`linear-gradient(to bottom, var(--ggui-color-primary-500), var(--ggui-color-primary-700))\`.
12415
13019
  3. NEVER invent your own palette. The system provides primary, neutral, success, warning, error, and info \u2014 use ONLY these.
12416
13020
  4. NEVER include literal fallback values in ANY token reference \u2014 colors, spacing, typography, radius, shadows alike. Write \`var(--ggui-spacing-4)\` bare, never \`var(--ggui-spacing-4, 16px)\`; \`var(--ggui-color-primary-600)\` bare, never \`var(--ggui-color-primary-600, #0284c7)\`. The runtime injects every token; a literal fallback paints the wrong value exactly when the operator's theme matters most.
13021
+ 5. NEVER set an inline \`border\`, \`borderColor\`, \`borderWidth\` or \`borderStyle\` on a kit control \u2014 \`Button\`, \`Input\`, \`Select\`, \`TextArea\`, \`Checkbox\`, \`RadioGroup\`, \`Toggle\`, \`SearchField\`. The control draws its own boundary, and it clears 3:1 on its surface in every theme: an outline chip is \`<Button variant="outline">\` exactly as it comes (its edge is \`controlAccentOutline\`). Where you draw a control-like edge on your own element, use \`var(--ggui-color-controlOutline)\`. A ramp step such as \`primary-200\` as a control's border is nearly invisible (about 1.1:1).
12417
13022
 
12418
13023
  Token categories:
12419
13024
  - Brand fills: \`var(--ggui-color-primary-600)\`, \`var(--ggui-color-primary-50)\`
12420
13025
  - Accent text: \`var(--ggui-color-link)\` \u2014 eyebrows, taglines, links, labels; readable per mode (never a bare \`primary-<stop>\` as a text color)
12421
13026
  - Text: \`var(--ggui-color-onContainer)\`, \`var(--ggui-color-onSunken)\`
12422
13027
  - Backgrounds: \`var(--ggui-color-container)\`, \`var(--ggui-color-sunken)\`
12423
- - Borders: \`var(--ggui-color-outline)\`
13028
+ - Borders: \`var(--ggui-color-outline)\` \u2014 dividers and the edges of non-interactive boxes; a control's edge is the kit's (rule 5)
12424
13029
  - Spacing: \`var(--ggui-spacing-4)\`, \`var(--ggui-spacing-6)\`
12425
13030
  - Typography: \`var(--ggui-font-size-sm)\`, \`var(--ggui-font-weight-semibold)\`
12426
13031
  - Shadows: \`var(--ggui-shape-shadow-sm)\`, \`var(--ggui-shape-shadow-md)\`, \`var(--ggui-shape-shadow-lg)\`
@@ -12435,7 +13040,7 @@ Use the FULL primary palette throughout the component \u2014 NOT only on submit
12435
13040
  | Element | Token | Purpose |
12436
13041
  |---------|-------|---------|
12437
13042
  | Section headers, hero areas, highlight strips | \`primary-50\` / \`primary-100\` | Subtle branded backgrounds |
12438
- | Borders, dividers, focus rings, input focus | \`primary-200\` / \`primary-300\` | Branded structure |
13043
+ | Dividers, section rules, edges of non-interactive boxes | \`primary-200\` / \`primary-300\` | Branded structure \u2014 never a control's border (rule 5) |
12439
13044
  | Accent TEXT \u2014 eyebrows, taglines, links, labels, inline arrows | \`link\` (\`var(--ggui-color-link)\`) or \`<Text tone="emphasized">\` | The brand's READABLE ink, derived per mode to clear 4.5:1 \u2014 a bare \`primary-300\u2026600\` as a text color fails tier-0 |
12440
13045
  | Icon fills, badges, active indicators | \`primary-500\` / \`primary-600\` | Core accent fill |
12441
13046
  | Buttons, CTAs, filled interactive elements | \`primary-600\` / \`primary-700\` | Primary actions |
@@ -13108,6 +13713,7 @@ These tokens adapt automatically to any theme (light, dark, branded). **ALWAYS u
13108
13713
  | link | var(--ggui-color-link) | Accent TEXT \u2014 eyebrows, taglines, links, labels, inline arrows: the brand's readable ink, derived per mode to clear 4.5:1 on the container and the ground |
13109
13714
  | outline | var(--ggui-color-outline) | Borders, dividers |
13110
13715
  | outlineVariant | var(--ggui-color-outlineVariant) | Subtle borders |
13716
+ | controlOutline | var(--ggui-color-controlOutline) | A control's edge \u2014 clears 3:1 on its surface; kit controls draw it themselves |
13111
13717
 
13112
13718
  **Usage pattern:**
13113
13719
  - Page/section background \u2192 \`var(--ggui-color-ground)\`
@@ -13232,7 +13838,7 @@ Use \`var(--ggui-spacing-N)\` for all padding, gap, and margin values. **Never u
13232
13838
  ### Color usage guide
13233
13839
  - **Layering roles** (ground, container, elevated, sunken + their on* inks; outline): one kind of AREA each \u2014 use them for every background/text/border decision; they adapt to any theme
13234
13840
  - **primary-50/100**: Section backgrounds, highlight strips, card headers
13235
- - **primary-200/300**: Borders, dividers, focus rings, input outlines
13841
+ - **primary-200/300**: Dividers and section rules \u2014 never a control's border: kit controls draw their own edge (\`controlOutline\` / \`controlAccentOutline\`), so set no inline border on them
13236
13842
  - **primary-500/600**: Filled buttons, CTAs, badges, icon fills, active indicators \u2014 fills and borders, NOT a text color
13237
13843
  - **link**: Accent TEXT \u2014 eyebrows, taglines, links, labels, inline arrows; the brand's readable ink per mode (a bare \`primary-300\u2026600\` as a text color fails tier-0: \`universal.accent_text_ink\`)
13238
13844
  - **primary-700/800/900**: Headings and text on light primary backgrounds
@@ -14035,6 +14641,165 @@ function editableSurface(el) {
14035
14641
  function parentLink(node) {
14036
14642
  return node;
14037
14643
  }
14644
+
14645
+ // src/harness/check/runtime-render/probe-pending.ts
14646
+ function createProbePendingSource() {
14647
+ const pending = /* @__PURE__ */ new Set();
14648
+ const listeners = /* @__PURE__ */ new Set();
14649
+ const notify = () => {
14650
+ for (const listener of [...listeners]) listener();
14651
+ };
14652
+ return {
14653
+ isPending: (actionName) => pending.has(actionName),
14654
+ subscribe: (listener) => {
14655
+ listeners.add(listener);
14656
+ return () => {
14657
+ listeners.delete(listener);
14658
+ };
14659
+ },
14660
+ mark: (actionName) => {
14661
+ pending.add(actionName);
14662
+ notify();
14663
+ },
14664
+ clear: () => {
14665
+ if (pending.size === 0) return;
14666
+ pending.clear();
14667
+ notify();
14668
+ }
14669
+ };
14670
+ }
14671
+ function lookOf(el) {
14672
+ return {
14673
+ connected: el.isConnected !== false,
14674
+ // React renders `disabled={true}` as `disabled=""`, so presence is the test.
14675
+ disabled: el.getAttribute("disabled") !== null || el.getAttribute("aria-disabled") === "true",
14676
+ ariaBusy: el.getAttribute("aria-busy") === "true",
14677
+ text: el.tagName?.toLowerCase() === "input" ? String(typeof el.value === "string" ? el.value : el.getAttribute("value") ?? "") : el.textContent ?? ""
14678
+ };
14679
+ }
14680
+ async function settlesWithin(ms, work) {
14681
+ let timer;
14682
+ const expired = new Promise((resolve4) => {
14683
+ timer = setTimeout(() => resolve4(false), Math.max(0, ms));
14684
+ });
14685
+ try {
14686
+ return await Promise.race([work.then(() => true), expired]);
14687
+ } finally {
14688
+ if (timer !== void 0) clearTimeout(timer);
14689
+ }
14690
+ }
14691
+ function lookChanged(before, after) {
14692
+ if (before.connected && !after.connected) return true;
14693
+ if (!before.disabled && after.disabled) return true;
14694
+ if (!before.ariaBusy && after.ariaBusy) return true;
14695
+ return before.text.trim() !== after.text.trim();
14696
+ }
14697
+
14698
+ // src/harness/check/runtime-render/walk.ts
14699
+ var LAST_PRESSED_KEPT = 4;
14700
+ function isDisabled(el) {
14701
+ return el.getAttribute("disabled") !== null || el.getAttribute("aria-disabled") === "true";
14702
+ }
14703
+ function labelOf(el) {
14704
+ const named = el.getAttribute("aria-label") ?? el.getAttribute("placeholder");
14705
+ return (named ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
14706
+ }
14707
+ function screenFingerprint(container) {
14708
+ const parts = [];
14709
+ for (const el of container.querySelectorAll(
14710
+ 'button, [role="button"], a[href], input, select, textarea, form'
14711
+ )) {
14712
+ parts.push(
14713
+ [el.tagName.toLowerCase(), el.getAttribute("type") ?? "", labelOf(el), isDisabled(el) ? "disabled" : ""].join("|")
14714
+ );
14715
+ }
14716
+ return parts.join("\n");
14717
+ }
14718
+ function keyed(controls, describe) {
14719
+ const seen = /* @__PURE__ */ new Map();
14720
+ const out = /* @__PURE__ */ new Map();
14721
+ for (const c of controls) {
14722
+ const d = describe(c.element);
14723
+ const n = seen.get(d) ?? 0;
14724
+ seen.set(d, n + 1);
14725
+ out.set(`${d}#${n}`, c);
14726
+ }
14727
+ return out;
14728
+ }
14729
+ function firstStepToUnpressed(screens, from) {
14730
+ const firstStep = /* @__PURE__ */ new Map([[from, void 0]]);
14731
+ const queue = [from];
14732
+ while (queue.length > 0) {
14733
+ const at = queue.shift();
14734
+ const screen = screens.get(at);
14735
+ if (screen === void 0) continue;
14736
+ if (at !== from && screen.order.some((k) => !screen.led.has(k))) return firstStep.get(at);
14737
+ for (const [key, to] of screen.led) {
14738
+ if (to === at || firstStep.has(to)) continue;
14739
+ firstStep.set(to, firstStep.get(at) ?? key);
14740
+ queue.push(to);
14741
+ }
14742
+ }
14743
+ return void 0;
14744
+ }
14745
+ async function walkToAction(input) {
14746
+ const lastPressed = [];
14747
+ const screenTexts = [];
14748
+ const screens = /* @__PURE__ */ new Map();
14749
+ let presses = 0;
14750
+ let namedControlSeen = false;
14751
+ const outcome = (stoppedBy, extra = {}) => ({
14752
+ fired: false,
14753
+ presses,
14754
+ screens: screens.size,
14755
+ stoppedBy,
14756
+ namedControlSeen,
14757
+ lastPressed: [...lastPressed],
14758
+ screenTexts: [...screenTexts],
14759
+ ...extra
14760
+ });
14761
+ const mounted = await input.mount();
14762
+ if (mounted === void 0) return outcome("no-mount");
14763
+ const { container } = mounted;
14764
+ const arrive = () => {
14765
+ mounted.prime();
14766
+ const controls = keyed(input.controls(container), input.describe);
14767
+ if ([...controls.values()].some((c) => c.namesAction)) namedControlSeen = true;
14768
+ const id = screenFingerprint(container);
14769
+ if (!screens.has(id)) {
14770
+ screens.set(id, { order: [...controls.keys()], led: /* @__PURE__ */ new Map() });
14771
+ screenTexts.push(container.textContent ?? "");
14772
+ }
14773
+ return { id, controls };
14774
+ };
14775
+ let here = arrive();
14776
+ const first = here.id;
14777
+ for (; ; ) {
14778
+ const screen = screens.get(here.id);
14779
+ const onOpenFirstScreen = here.id === first && screen.order.some((k) => !screen.led.has(k));
14780
+ if (!onOpenFirstScreen) {
14781
+ if (presses >= input.maxPresses) return outcome("press-cap");
14782
+ if (Date.now() >= input.stopAt) return outcome("deadline");
14783
+ }
14784
+ const key = screen.order.find((k) => !screen.led.has(k)) ?? firstStepToUnpressed(screens, here.id);
14785
+ if (key === void 0) return outcome("explored");
14786
+ const control = here.controls.get(key);
14787
+ if (control === void 0) {
14788
+ screen.led.set(key, here.id);
14789
+ continue;
14790
+ }
14791
+ presses += 1;
14792
+ lastPressed.push(input.describe(control.element));
14793
+ if (lastPressed.length > LAST_PRESSED_KEPT) lastPressed.shift();
14794
+ if (await input.press(control)) {
14795
+ return outcome("dispatched", { fired: true, element: control.element, container });
14796
+ }
14797
+ here = arrive();
14798
+ if (!screen.led.has(key)) screen.led.set(key, here.id);
14799
+ }
14800
+ }
14801
+
14802
+ // src/harness/check/runtime-render/render-check.ts
14038
14803
  var RENDER_CHECK_KINDS = [
14039
14804
  "render-no-throw",
14040
14805
  "prop-sensitivity",
@@ -14044,6 +14809,10 @@ var RENDER_CHECK_KINDS = [
14044
14809
  "optional-props-omitted",
14045
14810
  "stream-rerender"
14046
14811
  ];
14812
+ var PENDING_PHASE_RESERVE_MS = 5e3;
14813
+ var WALK_MAX_PRESSES = 60;
14814
+ var WALK_ACTION_CAP_MS = 3e3;
14815
+ var WALK_DEADLINE_MARGIN_MS = 2e3;
14047
14816
  async function runRenderCheck(input, options = {}) {
14048
14817
  const g = hostGlobals();
14049
14818
  if ("window" in g && "document" in g) {
@@ -14109,7 +14878,9 @@ async function runRenderCheckInProcess(input) {
14109
14878
  const wireConfig = createProbeWireConfig(probe);
14110
14879
  uninstallSpy = probe.installPostMessageSpy();
14111
14880
  installProductionActShim();
14112
- const { render, cleanup } = await import('@testing-library/react');
14881
+ const { render, cleanup, act } = await import('@testing-library/react');
14882
+ const { ActionPendingContext } = await import('@ggui-ai/wire/internal');
14883
+ const pendingSource = createProbePendingSource();
14113
14884
  const userEventModule = await import('@testing-library/user-event');
14114
14885
  const userEvent = userEventModule.default ?? userEventModule;
14115
14886
  const originalConsoleError = console.error;
@@ -14146,7 +14917,8 @@ async function runRenderCheckInProcess(input) {
14146
14917
  };
14147
14918
  const boundaryRef = {
14148
14919
  stack: null,
14149
- error: null
14920
+ error: null,
14921
+ catches: 0
14150
14922
  };
14151
14923
  class ProbeErrorBoundary extends React.Component {
14152
14924
  state = { caught: false };
@@ -14154,6 +14926,7 @@ async function runRenderCheckInProcess(input) {
14154
14926
  return { caught: true };
14155
14927
  }
14156
14928
  componentDidCatch(error, errorInfo) {
14929
+ boundaryRef.catches += 1;
14157
14930
  if (errorInfo.componentStack && !boundaryRef.stack) {
14158
14931
  boundaryRef.stack = errorInfo.componentStack;
14159
14932
  }
@@ -14166,19 +14939,19 @@ async function runRenderCheckInProcess(input) {
14166
14939
  return this.props.children;
14167
14940
  }
14168
14941
  }
14942
+ const cardTree = () => React.createElement(GguiWireProvider, {
14943
+ config: wireConfig,
14944
+ children: React.createElement(ProbeErrorBoundary, {
14945
+ children: React.createElement(ActionPendingContext.Provider, {
14946
+ value: pendingSource,
14947
+ children: React.createElement(Component, input.mockupProps)
14948
+ })
14949
+ })
14950
+ });
14169
14951
  let renderResult;
14170
14952
  const RENDER_TIMEOUT_MS = 5e3;
14171
14953
  try {
14172
- const renderPromise = Promise.resolve().then(
14173
- () => render(
14174
- React.createElement(GguiWireProvider, {
14175
- config: wireConfig,
14176
- children: React.createElement(ProbeErrorBoundary, {
14177
- children: React.createElement(Component, input.mockupProps)
14178
- })
14179
- })
14180
- )
14181
- );
14954
+ const renderPromise = Promise.resolve().then(() => render(cardTree()));
14182
14955
  const timeoutPromise = new Promise((_, reject) => {
14183
14956
  setTimeout(
14184
14957
  () => reject(
@@ -14230,6 +15003,9 @@ async function runRenderCheckInProcess(input) {
14230
15003
  const user = buildClickUser(userEvent, hostGlobals().document);
14231
15004
  let actionsChecked = 0;
14232
15005
  let streamsChecked = 0;
15006
+ let pendingTally;
15007
+ const pendingTargets = [];
15008
+ const walkedTexts = [];
14233
15009
  try {
14234
15010
  let inputPriming;
14235
15011
  try {
@@ -14246,15 +15022,42 @@ async function runRenderCheckInProcess(input) {
14246
15022
  hookName: "useAction",
14247
15023
  hookArg: name
14248
15024
  });
15025
+ const walkPriming = {};
15026
+ const mount = async () => {
15027
+ let mounted;
15028
+ try {
15029
+ mounted = render(cardTree());
15030
+ } catch {
15031
+ return void 0;
15032
+ }
15033
+ await flushPromises();
15034
+ const root = mounted.container;
15035
+ return {
15036
+ container: toMinimalElement(root),
15037
+ prime: () => {
15038
+ try {
15039
+ primeInputs(root);
15040
+ } catch (err) {
15041
+ walkPriming.error ??= err instanceof Error ? err.message : String(err);
15042
+ }
15043
+ }
15044
+ };
15045
+ };
14249
15046
  const issue = await checkActionWiring({
14250
- container,
15047
+ mount,
14251
15048
  actionName: name,
14252
15049
  actionLabel: entry.label,
14253
15050
  wiring,
14254
15051
  probe,
14255
- user
15052
+ user,
15053
+ pendingTargets,
15054
+ stopAt: walkStopAt(input.deadlineAt),
15055
+ walkedTexts
14256
15056
  });
14257
- if (issue) issues.push({ ...issue, diagnostics: { ...issue.diagnostics, inputPriming } });
15057
+ if (issue) {
15058
+ const priming = "primed" in inputPriming && walkPriming.error !== void 0 ? { error: `on a screen the action walk reached: ${walkPriming.error}` } : inputPriming;
15059
+ issues.push({ ...issue, diagnostics: { ...issue.diagnostics, inputPriming: priming } });
15060
+ }
14258
15061
  }
14259
15062
  }
14260
15063
  if (input.contract?.actionSpec) {
@@ -14276,6 +15079,7 @@ async function runRenderCheckInProcess(input) {
14276
15079
  if (input.contract?.propsSpec) {
14277
15080
  for (const issue of checkPropCoverage({
14278
15081
  container,
15082
+ walkedTexts,
14279
15083
  propsSpec: input.contract.propsSpec,
14280
15084
  mockupProps: input.mockupProps
14281
15085
  })) {
@@ -14381,10 +15185,21 @@ async function runRenderCheckInProcess(input) {
14381
15185
  }
14382
15186
  }
14383
15187
  }
15188
+ const timeLeft = input.deadlineAt === void 0 ? Number.POSITIVE_INFINITY : input.deadlineAt - Date.now();
15189
+ if (input.contract?.actionSpec && timeLeft >= PENDING_PHASE_RESERVE_MS) {
15190
+ pendingTally = await runPendingPhase({
15191
+ targets: pendingTargets,
15192
+ source: pendingSource,
15193
+ act,
15194
+ catches: () => boundaryRef.catches,
15195
+ actions: Object.entries(input.contract.actionSpec).map(([name, entry]) => ({ name, label: entry.label })),
15196
+ ...input.deadlineAt !== void 0 ? { deadlineAt: input.deadlineAt } : {}
15197
+ });
15198
+ }
14384
15199
  } finally {
14385
15200
  cleanup();
14386
15201
  }
14387
- return finalize(issues, t0, actionsChecked, streamsChecked);
15202
+ return finalize(issues, t0, actionsChecked, streamsChecked, pendingTally);
14388
15203
  } finally {
14389
15204
  try {
14390
15205
  uninstallSpy?.();
@@ -14596,7 +15411,7 @@ async function setupHappyDom() {
14596
15411
  delete g.IS_REACT_ACT_ENVIRONMENT;
14597
15412
  };
14598
15413
  }
14599
- function finalize(issues, t0, actionsChecked, streamsChecked) {
15414
+ function finalize(issues, t0, actionsChecked, streamsChecked, pendingTally) {
14600
15415
  const ok = !issues.some((i) => i.outcome === "failed");
14601
15416
  return {
14602
15417
  ok,
@@ -14604,12 +15419,86 @@ function finalize(issues, t0, actionsChecked, streamsChecked) {
14604
15419
  stats: {
14605
15420
  actionsChecked,
14606
15421
  streamsChecked,
14607
- renderMs: Date.now() - t0
15422
+ renderMs: Date.now() - t0,
15423
+ ...pendingTally !== void 0 ? {
15424
+ pendingAffordance: {
15425
+ dispatched: pendingTally.dispatched,
15426
+ visible: pendingTally.visible,
15427
+ missing: [...pendingTally.missing],
15428
+ gone: [...pendingTally.gone]
15429
+ }
15430
+ } : {}
14608
15431
  }
14609
15432
  };
14610
15433
  }
15434
+ var PENDING_TARGET_CAP_MS = 2e3;
15435
+ var PENDING_PHASE_TAIL_MS = 1e3;
15436
+ async function runPendingPhase(input) {
15437
+ const tally = { dispatched: 0, visible: 0, missing: [], gone: [] };
15438
+ let stopped = false;
15439
+ for (const target of input.targets) {
15440
+ const budget = Math.min(
15441
+ PENDING_TARGET_CAP_MS,
15442
+ input.deadlineAt === void 0 ? Number.POSITIVE_INFINITY : input.deadlineAt - Date.now() - PENDING_PHASE_TAIL_MS
15443
+ );
15444
+ const found = stopped || budget <= 0 ? void 0 : pendingControlOf(target, input.actions);
15445
+ if (found === void 0) {
15446
+ tally.gone.push(target.actionName);
15447
+ if (budget <= 0) stopped = true;
15448
+ continue;
15449
+ }
15450
+ const el = controlToRead(found, target);
15451
+ const before = lookOf(el);
15452
+ const caughtBefore = input.catches();
15453
+ const settled = await settlesWithin(
15454
+ budget,
15455
+ (async () => {
15456
+ await input.act(async () => {
15457
+ input.source.mark(target.actionName);
15458
+ });
15459
+ await flushPromises();
15460
+ })()
15461
+ );
15462
+ const crashed = input.catches() !== caughtBefore;
15463
+ tally.dispatched += 1;
15464
+ if (settled && !crashed && lookChanged(before, lookOf(el))) tally.visible += 1;
15465
+ else tally.missing.push(target.actionName);
15466
+ if (!settled) {
15467
+ stopped = true;
15468
+ continue;
15469
+ }
15470
+ await input.act(async () => {
15471
+ input.source.clear();
15472
+ });
15473
+ await flushPromises();
15474
+ if (crashed) stopped = true;
15475
+ }
15476
+ return tally;
15477
+ }
15478
+ function pendingControlOf(target, actions) {
15479
+ const movedToAnother = !matchesActionIdentity(target.element, target.actionName, target.actionLabel) && actions.some((a) => a.name !== target.actionName && matchesActionIdentity(target.element, a.name, a.label));
15480
+ if (lookOf(target.element).connected && !movedToAnother) return target.element;
15481
+ if (target.wiringKind !== "click") return void 0;
15482
+ return findActionElements(target.container, target.actionName, target.actionLabel, { identityOnly: true })[0];
15483
+ }
15484
+ function controlToRead(el, target) {
15485
+ if (el.tagName.toLowerCase() !== "form") return el;
15486
+ for (const c of el.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"]')) {
15487
+ if (matchesActionIdentity(c, target.actionName, target.actionLabel)) return c;
15488
+ }
15489
+ for (const submitter of el.querySelectorAll(
15490
+ 'button[type="submit"], input[type="submit"], button:not([type])'
15491
+ )) {
15492
+ return submitter;
15493
+ }
15494
+ return el;
15495
+ }
15496
+ function walkStopAt(deadlineAt) {
15497
+ const cap2 = Date.now() + WALK_ACTION_CAP_MS;
15498
+ return deadlineAt === void 0 ? cap2 : Math.min(cap2, deadlineAt - PENDING_PHASE_RESERVE_MS - WALK_DEADLINE_MARGIN_MS);
15499
+ }
14611
15500
  async function checkActionWiring(input) {
14612
- const { container, actionName, actionLabel, wiring, probe, user } = input;
15501
+ const { actionName, actionLabel, wiring, probe, user } = input;
14613
15502
  const baseDiagnostics = {
14614
15503
  observedJsxElements: wiring.observedJsxElements,
14615
15504
  observedNativeProps: wiring.observedNativeProps,
@@ -14633,25 +15522,56 @@ async function checkActionWiring(input) {
14633
15522
  diagnostics: baseDiagnostics
14634
15523
  };
14635
15524
  }
14636
- const fired = await simulateAndCheck({
14637
- container,
14638
- user,
14639
- probe,
14640
- wiringKind: wiring.kind,
14641
- actionName,
14642
- actionLabel,
14643
- eventKind: "action.fired"
15525
+ const kind = wiring.kind;
15526
+ const walk = await walkToAction({
15527
+ mount: input.mount,
15528
+ controls: (container) => actionControls(container, kind, actionName, actionLabel),
15529
+ press: async (control) => {
15530
+ const before = probe.getFireLog().length;
15531
+ try {
15532
+ await dispatchTrigger(control.element, control.how, user);
15533
+ } catch {
15534
+ return false;
15535
+ }
15536
+ await flushPromises();
15537
+ return probe.getFireLog().slice(before).some((e) => e.kind === "action.fired" && e.name === actionName);
15538
+ },
15539
+ describe: describeElement,
15540
+ stopAt: input.stopAt,
15541
+ maxPresses: WALK_MAX_PRESSES
14644
15542
  });
14645
- if (fired.fired) return null;
15543
+ input.walkedTexts.push(...walk.screenTexts);
15544
+ if (walk.fired) {
15545
+ if (input.pendingTargets && walk.element && walk.container && (kind === "click" || kind === "submit")) {
15546
+ input.pendingTargets.push({
15547
+ actionName,
15548
+ actionLabel,
15549
+ wiringKind: kind,
15550
+ element: walk.element,
15551
+ container: walk.container
15552
+ });
15553
+ }
15554
+ return null;
15555
+ }
15556
+ const allFired = probe.getFireLog().filter((e) => e.kind === "action.fired").map((e) => e.name);
15557
+ const otherActionsFired = Array.from(new Set(allFired)).filter((n) => n !== actionName);
15558
+ const stoppedBy = walk.stoppedBy === "dispatched" ? "explored" : walk.stoppedBy;
14646
15559
  return {
14647
15560
  check: "action-wiring",
14648
15561
  outcome: "unverified",
14649
15562
  subject: actionName,
14650
- reason: `Source confirms ${wiring.kind} wiring exists for action '${actionName}', but synthetic ${wiring.kind} did not dispatch it. Likely cause: conditional rendering, required input/form fill, or a multi-step interaction the static probe cannot complete.`,
14651
- elementHint: fired.attemptedHint,
15563
+ reason: `Source confirms ${kind} wiring exists for action '${actionName}', but synthetic ${kind} did not dispatch it. Likely cause: conditional rendering, required input/form fill, or a multi-step interaction the static probe cannot complete.`,
15564
+ ...walk.presses === 0 && stoppedBy === "explored" ? { elementHint: `No ${kind}-eligible element found in DOM` } : {},
14652
15565
  diagnostics: {
14653
15566
  ...baseDiagnostics,
14654
- actionsFiredFromClicks: fired.otherActionsFired
15567
+ actionsFiredFromClicks: otherActionsFired,
15568
+ walk: {
15569
+ presses: walk.presses,
15570
+ screens: walk.screens,
15571
+ stoppedBy,
15572
+ namedControlSeen: walk.namedControlSeen,
15573
+ lastPressed: walk.lastPressed
15574
+ }
14655
15575
  }
14656
15576
  };
14657
15577
  }
@@ -14719,32 +15639,6 @@ async function checkSelectionIdentity(input) {
14719
15639
  }
14720
15640
  return null;
14721
15641
  }
14722
- async function simulateAndCheck(input) {
14723
- const { container, user, probe, wiringKind, actionName, actionLabel, eventKind } = input;
14724
- const candidates = findCandidateElements(container, wiringKind, actionName, actionLabel);
14725
- if (candidates.length === 0) {
14726
- return { fired: false, attemptedHint: `No ${wiringKind}-eligible element found in DOM` };
14727
- }
14728
- for (const el of candidates) {
14729
- const before = probe.getFireLog().length;
14730
- try {
14731
- await dispatchTrigger(el, wiringKind, user);
14732
- } catch {
14733
- continue;
14734
- }
14735
- await flushPromises();
14736
- const newEvents = probe.getFireLog().slice(before);
14737
- const matched = newEvents.some((e) => e.kind === eventKind && e.name === actionName);
14738
- if (matched) return { fired: true };
14739
- }
14740
- const allFired = probe.getFireLog().filter((e) => e.kind === "action.fired").map((e) => e.name);
14741
- const otherActionsFired = Array.from(new Set(allFired)).filter((n) => n !== actionName);
14742
- return {
14743
- fired: false,
14744
- attemptedHint: candidates.length ? describeElement(candidates[0]) : void 0,
14745
- otherActionsFired
14746
- };
14747
- }
14748
15642
  async function dispatchTrigger(el, kind, user) {
14749
15643
  switch (kind) {
14750
15644
  case "click":
@@ -14830,40 +15724,40 @@ function closestForm(el) {
14830
15724
  }
14831
15725
  return null;
14832
15726
  }
14833
- function findCandidateElements(container, kind, name, label) {
14834
- switch (kind) {
14835
- case "click":
14836
- return findActionElements(container, name, label);
14837
- case "submit": {
14838
- const all = container.querySelectorAll(
14839
- 'form, button[type="submit"], input[type="submit"]'
14840
- );
14841
- return Array.from(all);
14842
- }
14843
- case "change": {
14844
- const all = container.querySelectorAll("select, input, textarea");
14845
- return Array.from(all);
14846
- }
14847
- case "keyboard-enter":
14848
- return findActionElements(container, name, label);
15727
+ function actionControls(container, kind, name, label) {
15728
+ const clickables = findActionElements(container, name, label);
15729
+ const named = (el) => matchesActionIdentity(el, name, label);
15730
+ if (kind === "click" || kind === "keyboard-enter") {
15731
+ return clickables.map((element) => ({ element, namesAction: named(element), how: kind }));
14849
15732
  }
15733
+ const eligible = Array.from(
15734
+ container.querySelectorAll(
15735
+ kind === "submit" ? 'form, button[type="submit"], input[type="submit"]' : "select, input, textarea"
15736
+ )
15737
+ );
15738
+ const seen = new Set(eligible);
15739
+ return [
15740
+ ...eligible.map((element) => ({ element, namesAction: named(element), how: kind })),
15741
+ ...clickables.filter((el) => !seen.has(el)).map((element) => ({ element, namesAction: named(element), how: "click" }))
15742
+ ];
14850
15743
  }
14851
15744
  function checkPropCoverage(input) {
14852
- const { container, propsSpec, mockupProps } = input;
15745
+ const { container, walkedTexts, propsSpec, mockupProps } = input;
14853
15746
  const issues = [];
14854
- const text = container.textContent ?? "";
15747
+ const texts = [container.textContent ?? "", ...walkedTexts];
14855
15748
  for (const [propName, entry] of Object.entries(propsSpec.properties)) {
14856
15749
  if (!entry.required) continue;
14857
15750
  const value = mockupProps[propName];
14858
15751
  if (value === void 0 || value === null) continue;
14859
15752
  const marker = pickScalarMarker(value);
14860
15753
  if (marker === null) continue;
14861
- if (!text.includes(marker)) {
15754
+ if (!texts.some((text) => text.includes(marker))) {
15755
+ const where = walkedTexts.length > 0 ? ", on the first screen or any screen the action walk reached" : "";
14862
15756
  issues.push({
14863
15757
  check: "prop-coverage",
14864
15758
  outcome: "unverified",
14865
15759
  subject: propName,
14866
- reason: `Required prop '${propName}' value (${JSON.stringify(marker).slice(0, 60)}) not visible in rendered DOM`
15760
+ reason: `Required prop '${propName}' value (${JSON.stringify(marker).slice(0, 60)}) not visible in rendered DOM${where}`
14867
15761
  });
14868
15762
  }
14869
15763
  }
@@ -14903,26 +15797,28 @@ async function checkStreamRerender(input) {
14903
15797
  }
14904
15798
  return null;
14905
15799
  }
14906
- function findActionElements(container, name, label) {
15800
+ function matchesActionIdentity(el, name, label) {
15801
+ const norm = (s) => s.toLowerCase().replace(/[\s_-]+/g, "");
15802
+ const aria = (el.getAttribute("aria-label") ?? "").toLowerCase();
15803
+ const txt = (el.textContent ?? "").toLowerCase();
15804
+ const dataAction = (el.getAttribute("data-action") ?? "").toLowerCase();
15805
+ return aria.includes(label.toLowerCase()) || txt.includes(label.toLowerCase()) || norm(aria).includes(norm(name)) || norm(txt).includes(norm(name)) || dataAction === name.toLowerCase() || norm(txt).includes(norm(label));
15806
+ }
15807
+ function findActionElements(container, name, label, options = {}) {
14907
15808
  const candidates = [];
14908
15809
  const seen = /* @__PURE__ */ new Set();
14909
- const norm = (s) => s.toLowerCase().replace(/[\s_-]+/g, "");
14910
- const nameKey = norm(name);
14911
- const labelKey = norm(label);
14912
15810
  const allClickable = container.querySelectorAll(
14913
15811
  'button, [role="button"], input[type="submit"], input[type="button"], a[href]'
14914
15812
  );
14915
15813
  for (const el of allClickable) {
14916
- const aria = (el.getAttribute("aria-label") ?? "").toLowerCase();
14917
- const txt = (el.textContent ?? "").toLowerCase();
14918
- const dataAction = (el.getAttribute("data-action") ?? "").toLowerCase();
14919
- if (aria.includes(label.toLowerCase()) || txt.includes(label.toLowerCase()) || norm(aria).includes(nameKey) || norm(txt).includes(nameKey) || dataAction === name.toLowerCase() || norm(txt).includes(labelKey)) {
15814
+ if (matchesActionIdentity(el, name, label)) {
14920
15815
  if (!seen.has(el)) {
14921
15816
  seen.add(el);
14922
15817
  candidates.push(el);
14923
15818
  }
14924
15819
  }
14925
15820
  }
15821
+ if (options.identityOnly) return candidates;
14926
15822
  for (const el of allClickable) {
14927
15823
  if (!seen.has(el)) {
14928
15824
  seen.add(el);
@@ -15195,7 +16091,9 @@ function createRuntimeRenderCheck(config = {}) {
15195
16091
  elapsedMs: Date.now() - t0,
15196
16092
  ...queuedField(queuedMs),
15197
16093
  renderMs: result.stats.renderMs,
15198
- ...load
16094
+ ...load,
16095
+ // ggui#1398: report-only — carried to the probe's meta for the bench, never an issue.
16096
+ ...result.stats.pendingAffordance !== void 0 ? { pendingAffordance: result.stats.pendingAffordance } : {}
15199
16097
  };
15200
16098
  }
15201
16099
  };
@@ -15272,6 +16170,18 @@ function toEvalIssue(issue) {
15272
16170
  "primed" in diag.inputPriming ? `input priming: ${diag.inputPriming.primed} control(s) filled before the click` : `input priming failed: ${diag.inputPriming.error}`
15273
16171
  );
15274
16172
  }
16173
+ if (diag?.walk) {
16174
+ const w = diag.walk;
16175
+ const stopped = {
16176
+ explored: "every reachable control pressed",
16177
+ "press-cap": "stopped at the press cap",
16178
+ deadline: "stopped at the time budget",
16179
+ "no-mount": "the card could not be mounted for the walk"
16180
+ }[w.stoppedBy];
16181
+ const named = w.namedControlSeen ? "a control naming the action rendered, but pressing it did not dispatch the action" : "no screen rendered a control naming the action";
16182
+ const last = w.lastPressed.length ? `; last pressed: ${w.lastPressed.join(", ")}` : "";
16183
+ diagParts.push(`walk: ${w.presses} press(es) across ${w.screens} screen(s), ${stopped}; ${named}${last}`);
16184
+ }
15275
16185
  const diagSuffix = diagParts.length ? ` [observed: ${diagParts.join("; ")}]` : "";
15276
16186
  switch (issue.check) {
15277
16187
  case "render-no-throw":
@@ -15426,6 +16336,8 @@ async function assembleGenerationResult(input) {
15426
16336
  // real cache activity instead of a structural zero.
15427
16337
  ...telemetry.cacheReadTokens !== void 0 ? { cacheReadTokens: telemetry.cacheReadTokens } : {},
15428
16338
  ...telemetry.cacheCreationTokens !== void 0 ? { cacheCreationTokens: telemetry.cacheCreationTokens } : {},
16339
+ // ggui#1522 — the in-loop visual judge's spend, beside `tokens` and never inside it.
16340
+ ...telemetry.inLoopVisualTokens !== void 0 ? { inLoopVisualTokens: telemetry.inLoopVisualTokens } : {},
15429
16341
  generationTimeMs: totalMs,
15430
16342
  turnsUsed: telemetry.turnsUsed,
15431
16343
  passesUsed: 1,
@@ -15462,6 +16374,9 @@ init_check();
15462
16374
  init_check();
15463
16375
  init_check();
15464
16376
 
16377
+ // src/tools/render-check.ts
16378
+ init_verdict_line();
16379
+
15465
16380
  // src/adapters/tools.ts
15466
16381
  init_sample_props();
15467
16382
 
@@ -15497,7 +16412,7 @@ function getComponentDocumentation(name) {
15497
16412
  const section = nextMarker === -1 ? PRIMITIVES_DOCUMENTATION.slice(startIdx) : PRIMITIVES_DOCUMENTATION.slice(startIdx, nextMarker);
15498
16413
  return section.replace(/\\n/g, "\n").replace(/\\"/g, '"').slice(0, 3e3);
15499
16414
  }
15500
- async function autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode = DEFAULT_DESIGN_MODE, axis) {
16415
+ async function autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode = DEFAULT_DESIGN_MODE, axis2) {
15501
16416
  const commitStart = Date.now();
15502
16417
  const raw = workspace.read();
15503
16418
  if (!raw && raw !== "") {
@@ -15537,17 +16452,17 @@ async function autoCommit(workspace, commitMeta, message, contract, contextPolic
15537
16452
  gadgetTypes,
15538
16453
  designMode
15539
16454
  );
15540
- const axisFacts = axis !== void 0 ? {
16455
+ const axisFacts = axis2 !== void 0 ? {
15541
16456
  sourceCode: formatted,
15542
16457
  ...contract !== void 0 ? { contract } : {},
15543
- originalPrompt: axis.originalPrompt,
15544
- classification: axis.classification,
16458
+ originalPrompt: axis2.originalPrompt,
16459
+ classification: axis2.classification,
15545
16460
  designMode,
15546
- ...axis.canvas !== void 0 ? { canvas: axis.canvas } : {},
15547
- ...axis.fixtureProps !== void 0 ? { fixtureProps: axis.fixtureProps } : {}
16461
+ ...axis2.canvas !== void 0 ? { canvas: axis2.canvas } : {},
16462
+ ...axis2.fixtureProps !== void 0 ? { fixtureProps: axis2.fixtureProps } : {}
15548
16463
  } : void 0;
15549
16464
  if (axisFacts !== void 0 && !buildSuccess) traceAxisChecksSkipped(axisFacts, "build failed \u2014 no check ran");
15550
- const axisRun = axis !== void 0 && axisFacts !== void 0 && buildSuccess ? runGatedAxisChecks(axis.checks, { ...axisFacts, compiledCode }) : void 0;
16465
+ const axisRun = axis2 !== void 0 && axisFacts !== void 0 && buildSuccess ? runGatedAxisChecks(axis2.checks, { ...axisFacts, compiledCode }) : void 0;
15551
16466
  const axisIssues = axisRun?.issues ?? [];
15552
16467
  const checkIssues = [...tier0Issues, ...axisIssues];
15553
16468
  const tier0Fails = checkIssues.filter((i) => i.result === "fail");
@@ -15594,7 +16509,7 @@ ${errors.join("\n")}
15594
16509
  Fix the issues.`
15595
16510
  };
15596
16511
  }
15597
- async function executeTool(workspace, tool, input, commitMeta, contract, applyPatch2, contextPolicy, gadgetTypes, designMode = DEFAULT_DESIGN_MODE, axis) {
16512
+ async function executeTool(workspace, tool, input, commitMeta, contract, applyPatch2, contextPolicy, gadgetTypes, designMode = DEFAULT_DESIGN_MODE, axis2) {
15598
16513
  switch (tool) {
15599
16514
  case "write":
15600
16515
  case "rewrite": {
@@ -15613,7 +16528,7 @@ async function executeTool(workspace, tool, input, commitMeta, contract, applyPa
15613
16528
  const lineCount = code.split("\n").length;
15614
16529
  const message = input.commit_message || `write ${lineCount} lines`;
15615
16530
  console.log(`[coding-agent] write: ${lineCount} lines \u2192 auto-commit`);
15616
- return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode, axis);
16531
+ return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode, axis2);
15617
16532
  }
15618
16533
  case "apply_changes": {
15619
16534
  const currentFile = workspace.read();
@@ -15789,7 +16704,7 @@ The file now reflects your latest patch. Submit a follow-up apply_changes target
15789
16704
  await workspace.stage();
15790
16705
  const message = input.commit_message || "apply changes";
15791
16706
  console.log(`[coding-agent] apply_changes: ${changes.length} changes applied \u2192 auto-commit`);
15792
- return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode, axis);
16707
+ return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode, axis2);
15793
16708
  }
15794
16709
  case "get_components_info": {
15795
16710
  const names = input.names ?? [];
@@ -16130,7 +17045,10 @@ ${closingInstruction}`;
16130
17045
  );
16131
17046
  const llmMs = Date.now() - llmStart;
16132
17047
  if (costTracker) {
16133
- costTracker.record(codingModel, response.inputTokens, response.outputTokens);
17048
+ costTracker.record(codingModel, response.inputTokens, response.outputTokens, {
17049
+ ...response.cacheReadTokens !== void 0 ? { read: response.cacheReadTokens } : {},
17050
+ ...response.cacheCreationTokens !== void 0 ? { write: response.cacheCreationTokens } : {}
17051
+ });
16134
17052
  }
16135
17053
  const tokens = { input: response.inputTokens, output: response.outputTokens };
16136
17054
  const cacheTokens = {
@@ -16594,7 +17512,8 @@ function probeMetaOf(outcome, probeIssues) {
16594
17512
  ...outcome.reason !== void 0 ? { reason: outcome.reason } : {},
16595
17513
  ...outcome.renderMs !== void 0 ? { renderMs: outcome.renderMs } : {},
16596
17514
  ...outcome.hostLoad !== void 0 ? { hostLoad: outcome.hostLoad } : {},
16597
- ...outcome.queuedMs !== void 0 ? { queuedMs: outcome.queuedMs } : {}
17515
+ ...outcome.queuedMs !== void 0 ? { queuedMs: outcome.queuedMs } : {},
17516
+ ...outcome.pendingAffordance !== void 0 ? { pendingAffordance: outcome.pendingAffordance } : {}
16598
17517
  };
16599
17518
  const elapsed = outcome.elapsedMs !== void 0 ? { elapsedMs: outcome.elapsedMs } : {};
16600
17519
  if (outcome.status !== "ran") {
@@ -16700,6 +17619,7 @@ async function runEvalRound(ctx, input) {
16700
17619
  const currentSource = workspace.read() ?? "";
16701
17620
  let evalResult;
16702
17621
  let evalTokens = { input: 0, output: 0 };
17622
+ let inLoopVisualTokens = void 0;
16703
17623
  let evalLlmMs = 0;
16704
17624
  try {
16705
17625
  if (probeOnly) {
@@ -16736,6 +17656,7 @@ async function runEvalRound(ctx, input) {
16736
17656
  prevFailFingerprints,
16737
17657
  preWarmedContext,
16738
17658
  evalTokens,
17659
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
16739
17660
  evalLlmMs,
16740
17661
  lastResultText: "",
16741
17662
  isEvalFeedback: false,
@@ -16760,6 +17681,7 @@ async function runEvalRound(ctx, input) {
16760
17681
  prevFailFingerprints,
16761
17682
  preWarmedContext,
16762
17683
  evalTokens,
17684
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
16763
17685
  evalLlmMs,
16764
17686
  lastResultText: lines.join("\n\n"),
16765
17687
  isEvalFeedback: true,
@@ -16778,6 +17700,7 @@ async function runEvalRound(ctx, input) {
16778
17700
  prevFailFingerprints,
16779
17701
  preWarmedContext,
16780
17702
  evalTokens,
17703
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
16781
17704
  evalLlmMs,
16782
17705
  lastResultText: "",
16783
17706
  isEvalFeedback: false,
@@ -16828,6 +17751,7 @@ async function runEvalRound(ctx, input) {
16828
17751
  prevFailFingerprints,
16829
17752
  preWarmedContext,
16830
17753
  evalTokens,
17754
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
16831
17755
  evalLlmMs,
16832
17756
  lastResultText: lines.join("\n\n"),
16833
17757
  isEvalFeedback: true,
@@ -16852,6 +17776,7 @@ async function runEvalRound(ctx, input) {
16852
17776
  prevFailFingerprints,
16853
17777
  preWarmedContext,
16854
17778
  evalTokens,
17779
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
16855
17780
  evalLlmMs,
16856
17781
  lastResultText: "",
16857
17782
  isEvalFeedback: false,
@@ -16950,13 +17875,22 @@ async function runEvalRound(ctx, input) {
16950
17875
  evalLlmMs = Date.now() - evalLlmStart;
16951
17876
  llmResult = llm;
16952
17877
  visualIssues = visual?.issues ?? null;
17878
+ inLoopVisualTokens = visual?.tokens;
17879
+ if (inLoopVisualTokens !== void 0) {
17880
+ costTracker.record(visualEvalAgent.model, inLoopVisualTokens.inputTokens, inLoopVisualTokens.outputTokens);
17881
+ const criteriaSpend = inLoopVisualTokens.criteria;
17882
+ if (criteriaSpend !== void 0) costTracker.record(visualEvalAgent.model, criteriaSpend.inputTokens, criteriaSpend.outputTokens);
17883
+ }
16953
17884
  const visualSummary = visual?.summary;
16954
17885
  const visualCoverage = visual?.coverage ?? { status: "not-applicable", reason: "visual leg not configured" };
16955
17886
  if (visualCoverage.status === "skipped") {
16956
17887
  console.log(`[eval] visual-judge: SKIPPED (${visualCoverage.reason ?? "no reason recorded"})`);
16957
17888
  }
16958
17889
  if (llmResult) {
16959
- costTracker.record(evaluationAgent.model, llmResult.inputTokens, llmResult.outputTokens);
17890
+ costTracker.record(evaluationAgent.model, llmResult.inputTokens, llmResult.outputTokens, {
17891
+ ...llmResult.cacheReadTokens !== void 0 ? { read: llmResult.cacheReadTokens } : {},
17892
+ ...llmResult.cacheCreationTokens !== void 0 ? { write: llmResult.cacheCreationTokens } : {}
17893
+ });
16960
17894
  evalTokens = {
16961
17895
  input: llmResult.inputTokens,
16962
17896
  output: llmResult.outputTokens,
@@ -17007,7 +17941,8 @@ async function runEvalRound(ctx, input) {
17007
17941
  `[simple] eval round ${evalRoundsUsed}: ${fails.length} fails, ${warns.length} warns | cost=$${costTracker.getTotal().toFixed(4)} | tiers: t1+2=${llmResult?.issues.length ?? 0} visual=${visualIssues?.length ?? 0} axis[${classification.riskTier}]=${modeFails}F/${modeWarns}W${modeBreakdown ? ` (${modeBreakdown})` : ""}${crossRound}`
17008
17942
  );
17009
17943
  const blocking = evalResult.issues.filter((i) => i.result === "fail");
17010
- const shouldContinue = qualityMode === "fast" ? blocking.length > 0 : blocking.length > 0 || warns.length > 0;
17944
+ const visualMajorsActed = visualEvaluation?.actOn === "major" ? (visualIssues ?? []).filter((i) => i.origin === "judge" && i.result === "warn" && i.severity === "major") : [];
17945
+ const shouldContinue = qualityMode === "fast" ? blocking.length > 0 || visualMajorsActed.length > 0 : blocking.length > 0 || warns.length > 0;
17011
17946
  const currFailFingerprints = new Set(blocking.map(fingerprintFail));
17012
17947
  if (!shouldContinue) {
17013
17948
  const exitProbe = await runProbeAtExit({
@@ -17040,6 +17975,7 @@ async function runEvalRound(ctx, input) {
17040
17975
  prevFailFingerprints: enrichedFingerprints,
17041
17976
  preWarmedContext,
17042
17977
  evalTokens,
17978
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17043
17979
  evalLlmMs,
17044
17980
  lastResultText: lines.join("\n\n"),
17045
17981
  isEvalFeedback: true,
@@ -17067,6 +18003,7 @@ async function runEvalRound(ctx, input) {
17067
18003
  prevFailFingerprints: /* @__PURE__ */ new Set([...currFailFingerprints, ...contractFails.map(fingerprintFail)]),
17068
18004
  preWarmedContext,
17069
18005
  evalTokens,
18006
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17070
18007
  evalLlmMs,
17071
18008
  lastResultText: lines.join("\n\n"),
17072
18009
  isEvalFeedback: true,
@@ -17090,6 +18027,7 @@ async function runEvalRound(ctx, input) {
17090
18027
  prevFailFingerprints: currFailFingerprints,
17091
18028
  preWarmedContext,
17092
18029
  evalTokens,
18030
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17093
18031
  evalLlmMs,
17094
18032
  lastResultText: "",
17095
18033
  isEvalFeedback: false,
@@ -17128,6 +18066,7 @@ async function runEvalRound(ctx, input) {
17128
18066
  prevFailFingerprints: /* @__PURE__ */ new Set([...currFailFingerprints, ...stuckContractFails.map(fingerprintFail)]),
17129
18067
  preWarmedContext,
17130
18068
  evalTokens,
18069
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17131
18070
  evalLlmMs,
17132
18071
  lastResultText: lines.join("\n\n"),
17133
18072
  isEvalFeedback: true,
@@ -17144,6 +18083,7 @@ async function runEvalRound(ctx, input) {
17144
18083
  prevFailFingerprints: currFailFingerprints,
17145
18084
  preWarmedContext,
17146
18085
  evalTokens,
18086
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17147
18087
  evalLlmMs,
17148
18088
  lastResultText: "",
17149
18089
  isEvalFeedback: false,
@@ -17183,6 +18123,7 @@ async function runEvalRound(ctx, input) {
17183
18123
  prevFailFingerprints: /* @__PURE__ */ new Set([...currFailFingerprints, ...fitFails.map(fingerprintFail)]),
17184
18124
  preWarmedContext,
17185
18125
  evalTokens,
18126
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17186
18127
  evalLlmMs,
17187
18128
  lastResultText: fitLines.join("\n\n"),
17188
18129
  isEvalFeedback: true,
@@ -17209,6 +18150,7 @@ async function runEvalRound(ctx, input) {
17209
18150
  prevFailFingerprints: /* @__PURE__ */ new Set([...currFailFingerprints, ...capContractFails.map(fingerprintFail)]),
17210
18151
  preWarmedContext,
17211
18152
  evalTokens,
18153
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17212
18154
  evalLlmMs,
17213
18155
  lastResultText: contractLines.join("\n\n"),
17214
18156
  isEvalFeedback: true,
@@ -17232,6 +18174,7 @@ async function runEvalRound(ctx, input) {
17232
18174
  prevFailFingerprints: currFailFingerprints,
17233
18175
  preWarmedContext,
17234
18176
  evalTokens,
18177
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17235
18178
  evalLlmMs,
17236
18179
  lastResultText: "",
17237
18180
  isEvalFeedback: false,
@@ -17259,6 +18202,7 @@ async function runEvalRound(ctx, input) {
17259
18202
  ]),
17260
18203
  preWarmedContext,
17261
18204
  evalTokens,
18205
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17262
18206
  evalLlmMs,
17263
18207
  lastResultText: probeLines.join("\n\n"),
17264
18208
  isEvalFeedback: true,
@@ -17278,6 +18222,7 @@ async function runEvalRound(ctx, input) {
17278
18222
  prevFailFingerprints: currFailFingerprints,
17279
18223
  preWarmedContext,
17280
18224
  evalTokens,
18225
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17281
18226
  evalLlmMs,
17282
18227
  lastResultText: "",
17283
18228
  isEvalFeedback: false,
@@ -17293,6 +18238,11 @@ async function runEvalRound(ctx, input) {
17293
18238
  console.log(
17294
18239
  `[simple] eval round ${evalRoundsUsed}: ${allActionable.length} issues (${blocking.length} fail, ${warns.length} warn) \u2192 feeding back to coding loop`
17295
18240
  );
18241
+ if (visualEvaluation?.actOn !== void 0) {
18242
+ console.log(
18243
+ `[simple] eval round ${evalRoundsUsed}: continuing: fails=${blocking.length}, visual majors acted=${visualMajorsActed.length} (actOn=${visualEvaluation.actOn})`
18244
+ );
18245
+ }
17296
18246
  return {
17297
18247
  control: "feedback",
17298
18248
  evalDone: false,
@@ -17311,6 +18261,7 @@ async function runEvalRound(ctx, input) {
17311
18261
  ),
17312
18262
  preWarmedContext,
17313
18263
  evalTokens,
18264
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17314
18265
  evalLlmMs,
17315
18266
  lastResultText: issueLines.join("\n\n"),
17316
18267
  isEvalFeedback: true,
@@ -17334,6 +18285,7 @@ async function runEvalRound(ctx, input) {
17334
18285
  prevFailFingerprints,
17335
18286
  preWarmedContext,
17336
18287
  evalTokens,
18288
+ ...inLoopVisualTokens !== void 0 ? { inLoopVisualTokens } : {},
17337
18289
  evalLlmMs,
17338
18290
  lastResultText: "",
17339
18291
  isEvalFeedback: false,
@@ -17380,6 +18332,20 @@ function absorbTokens(telemetry, usage) {
17380
18332
  telemetry.cacheCreationTokens = (telemetry.cacheCreationTokens ?? 0) + usage.cacheCreation;
17381
18333
  }
17382
18334
  }
18335
+ function absorbInLoopVisualTokens(telemetry, part) {
18336
+ if (part === void 0) return;
18337
+ const prev = telemetry.inLoopVisualTokens;
18338
+ const prevCriteria = prev?.criteria;
18339
+ const criteria = part.criteria === void 0 ? prevCriteria : {
18340
+ inputTokens: (prevCriteria?.inputTokens ?? 0) + part.criteria.inputTokens,
18341
+ outputTokens: (prevCriteria?.outputTokens ?? 0) + part.criteria.outputTokens
18342
+ };
18343
+ telemetry.inLoopVisualTokens = {
18344
+ inputTokens: (prev?.inputTokens ?? 0) + part.inputTokens,
18345
+ outputTokens: (prev?.outputTokens ?? 0) + part.outputTokens,
18346
+ ...criteria !== void 0 ? { criteria } : {}
18347
+ };
18348
+ }
17383
18349
  function absorbTurn(telemetry, turn) {
17384
18350
  absorbTokens(telemetry, {
17385
18351
  input: turn.tokens.input,
@@ -17510,7 +18476,7 @@ function createGenerateTaskRunner(input) {
17510
18476
  }
17511
18477
  if (!evalDone && telemetry.compiledCode && (session.tiersMod && (session.codeEvalEnabled || session.visualEvalEnabled) || session.probeOnlyEnabled)) {
17512
18478
  const evalRoundStart = Date.now();
17513
- const round = await runEvalRound(
18479
+ const round2 = await runEvalRound(
17514
18480
  {
17515
18481
  workspace: session.workspace,
17516
18482
  harness: session.harness,
@@ -17543,23 +18509,24 @@ function createGenerateTaskRunner(input) {
17543
18509
  }
17544
18510
  );
17545
18511
  telemetry.cumulativeEvalWallMs += Date.now() - evalRoundStart;
17546
- telemetry.cumulativeEvalLlmMs += round.evalLlmMs;
17547
- telemetry.evalRoundsUsed = round.evalRoundsUsed;
17548
- prevModeSubcats = round.prevModeSubcats;
17549
- prevFailFingerprints = round.prevFailFingerprints;
17550
- preWarmedContext = round.preWarmedContext;
17551
- if (round.evalResult) telemetry.evalResult = round.evalResult;
17552
- if (round.contractFeedback) telemetry.contractFeedback = round.contractFeedback;
17553
- evalDone = round.evalDone;
17554
- absorbTokens(telemetry, round.evalTokens);
17555
- if (round.control === "break") break;
18512
+ telemetry.cumulativeEvalLlmMs += round2.evalLlmMs;
18513
+ telemetry.evalRoundsUsed = round2.evalRoundsUsed;
18514
+ prevModeSubcats = round2.prevModeSubcats;
18515
+ prevFailFingerprints = round2.prevFailFingerprints;
18516
+ preWarmedContext = round2.preWarmedContext;
18517
+ if (round2.evalResult) telemetry.evalResult = round2.evalResult;
18518
+ if (round2.contractFeedback) telemetry.contractFeedback = round2.contractFeedback;
18519
+ evalDone = round2.evalDone;
18520
+ absorbTokens(telemetry, round2.evalTokens);
18521
+ absorbInLoopVisualTokens(telemetry, round2.inLoopVisualTokens);
18522
+ if (round2.control === "break") break;
17556
18523
  if (session.probeOnlyEnabled) {
17557
18524
  telemetry.probeRepairUsed = true;
17558
- telemetry.probeRepairTrigger = round.evalResult?.runtimeProbe;
18525
+ telemetry.probeRepairTrigger = round2.evalResult?.runtimeProbe;
17559
18526
  }
17560
- lastResultText = round.lastResultText;
17561
- isEvalFeedback = round.isEvalFeedback;
17562
- lastDiffFailed = round.lastDiffFailed;
18527
+ lastResultText = round2.lastResultText;
18528
+ isEvalFeedback = round2.isEvalFeedback;
18529
+ lastDiffFailed = round2.lastDiffFailed;
17563
18530
  } else {
17564
18531
  break;
17565
18532
  }
@@ -17736,6 +18703,16 @@ var AgentWorkspace = class {
17736
18703
  init_llm_router();
17737
18704
  init_policy2();
17738
18705
  init_dupe_break();
18706
+
18707
+ // src/harness/in-loop-evaluation.ts
18708
+ function inLoopEvaluation(options) {
18709
+ return {
18710
+ text: options.evaluation?.enabled === true,
18711
+ visual: options.visualEvaluation?.enabled === true || options.qualityConfig?.visualEval === true
18712
+ };
18713
+ }
18714
+
18715
+ // src/harness/coding/init-session.ts
17739
18716
  function resolveSessionAgents(config) {
17740
18717
  const coding = config.codingAgent;
17741
18718
  const evaluation = config.evaluationAgent ?? coding;
@@ -17764,8 +18741,9 @@ async function initSession(input) {
17764
18741
  [APPLY_CHANGES_TOOL, GET_ICONS_TOOL],
17765
18742
  "required"
17766
18743
  );
18744
+ const { text: codeEvalEnabled, visual: visualEvalEnabled } = inLoopEvaluation(params);
17767
18745
  let preWarmPromise;
17768
- if (params.evaluation?.enabled) {
18746
+ if (codeEvalEnabled) {
17769
18747
  const evalSpec = agents.evaluation;
17770
18748
  preWarmPromise = Promise.resolve().then(() => (init_llm_evaluator(), llm_evaluator_exports)).then(
17771
18749
  (mod) => mod.preWarmEval(
@@ -17791,8 +18769,6 @@ async function initSession(input) {
17791
18769
  const originalProps = propsMatch?.[0];
17792
18770
  const qualityConfig = params.qualityConfig;
17793
18771
  const qualityMode = qualityConfig?.quality ?? "fast";
17794
- const codeEvalEnabled = !!params.evaluation?.enabled;
17795
- const visualEvalEnabled = !!(params.visualEvaluation?.enabled || qualityConfig?.visualEval);
17796
18772
  const probeOnlyEnabled = harness.check.runtimeRender !== void 0 && !codeEvalEnabled && !visualEvalEnabled;
17797
18773
  const maxEvalRounds = qualityMode === "high-quality" ? params.evaluation?.maxRounds ?? 3 : params.evaluation?.maxRounds ?? 2;
17798
18774
  const visualThreshold = params.visualEvaluation?.passThreshold ?? 70;