@misoto22/design 0.8.0 → 0.10.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 (167) hide show
  1. package/dist/agent/Accordion.md +1 -1
  2. package/dist/agent/Alert.md +1 -1
  3. package/dist/agent/AppShell.md +1 -1
  4. package/dist/agent/ArchitectureFigure.md +1 -1
  5. package/dist/agent/AreaChart.md +1 -1
  6. package/dist/agent/Article.md +1 -1
  7. package/dist/agent/AspectRatio.md +1 -1
  8. package/dist/agent/Avatar.md +1 -1
  9. package/dist/agent/Badge.md +1 -1
  10. package/dist/agent/BarChart.md +1 -1
  11. package/dist/agent/BarList.md +1 -1
  12. package/dist/agent/BigNumber.md +1 -1
  13. package/dist/agent/BoxPlot.md +1 -1
  14. package/dist/agent/Breadcrumb.md +1 -1
  15. package/dist/agent/BulletChart.md +1 -1
  16. package/dist/agent/Button.md +1 -1
  17. package/dist/agent/Calendar.md +1 -1
  18. package/dist/agent/Card.md +1 -1
  19. package/dist/agent/Checkbox.md +1 -1
  20. package/dist/agent/Code.md +1 -1
  21. package/dist/agent/CodeBlock.md +1 -1
  22. package/dist/agent/Collapsible.md +1 -1
  23. package/dist/agent/ColorPicker.md +75 -0
  24. package/dist/agent/Combobox.md +1 -1
  25. package/dist/agent/Command.md +1 -1
  26. package/dist/agent/ComposedChart.md +1 -1
  27. package/dist/agent/ContextMenu.md +1 -1
  28. package/dist/agent/DataflowFigure.md +1 -1
  29. package/dist/agent/DatePicker.md +1 -1
  30. package/dist/agent/DescriptionList.md +1 -1
  31. package/dist/agent/Diagram.md +1 -1
  32. package/dist/agent/DiagramCanvas.md +1 -1
  33. package/dist/agent/DiagramExportMenu.md +1 -1
  34. package/dist/agent/DiagramInspector.md +1 -1
  35. package/dist/agent/DiagramLegend.md +1 -1
  36. package/dist/agent/DiagramMinimap.md +1 -1
  37. package/dist/agent/DiagramToolbar.md +1 -1
  38. package/dist/agent/Dialog.md +1 -1
  39. package/dist/agent/DropdownMenu.md +1 -1
  40. package/dist/agent/EmptyState.md +1 -1
  41. package/dist/agent/ErrorState.md +1 -1
  42. package/dist/agent/Facet.md +1 -1
  43. package/dist/agent/Field.md +1 -1
  44. package/dist/agent/FigureBand.md +1 -1
  45. package/dist/agent/FloatingIconButton.md +1 -1
  46. package/dist/agent/FunnelChart.md +1 -1
  47. package/dist/agent/Heading.md +1 -1
  48. package/dist/agent/Heatmap.md +1 -1
  49. package/dist/agent/Histogram.md +1 -1
  50. package/dist/agent/Input.md +1 -1
  51. package/dist/agent/Kbd.md +2 -2
  52. package/dist/agent/LifecycleFigure.md +1 -1
  53. package/dist/agent/LineChart.md +1 -1
  54. package/dist/agent/LinkArrow.md +1 -1
  55. package/dist/agent/Markdown.md +1 -1
  56. package/dist/agent/NativeSelect.md +1 -1
  57. package/dist/agent/NavItem.md +1 -1
  58. package/dist/agent/NumberField.md +72 -0
  59. package/dist/agent/Pagination.md +1 -1
  60. package/dist/agent/PieChart.md +1 -1
  61. package/dist/agent/Popover.md +1 -1
  62. package/dist/agent/Progress.md +1 -1
  63. package/dist/agent/RadarChart.md +1 -1
  64. package/dist/agent/RadialChart.md +1 -1
  65. package/dist/agent/RadioGroup.md +1 -1
  66. package/dist/agent/SankeyChart.md +1 -1
  67. package/dist/agent/ScatterChart.md +1 -1
  68. package/dist/agent/ScrollArea.md +1 -1
  69. package/dist/agent/SearchableMenu.md +1 -1
  70. package/dist/agent/Select.md +1 -1
  71. package/dist/agent/Separator.md +1 -1
  72. package/dist/agent/SequenceFigure.md +1 -1
  73. package/dist/agent/Sheet.md +1 -1
  74. package/dist/agent/Sidebar.md +193 -0
  75. package/dist/agent/Skeleton.md +1 -1
  76. package/dist/agent/Slider.md +7 -3
  77. package/dist/agent/Sparkline.md +1 -1
  78. package/dist/agent/Spinner.md +1 -1
  79. package/dist/agent/StatusDot.md +1 -1
  80. package/dist/agent/StatusPill.md +1 -1
  81. package/dist/agent/Steps.md +1 -1
  82. package/dist/agent/Switch.md +1 -1
  83. package/dist/agent/Table.md +3 -1
  84. package/dist/agent/Tabs.md +1 -1
  85. package/dist/agent/Tag.md +1 -1
  86. package/dist/agent/Text.md +1 -1
  87. package/dist/agent/Textarea.md +1 -1
  88. package/dist/agent/Timestamp.md +1 -1
  89. package/dist/agent/Toast.md +1 -1
  90. package/dist/agent/ToggleGroup.md +1 -1
  91. package/dist/agent/Toolbar.md +1 -1
  92. package/dist/agent/Tooltip.md +1 -1
  93. package/dist/agent/TreemapChart.md +1 -1
  94. package/dist/agent/WaterfallChart.md +1 -1
  95. package/dist/agent/WorkflowFigure.md +1 -1
  96. package/dist/agent/catalog.json +362 -2
  97. package/dist/agent/index.md +9 -6
  98. package/dist/charts/BulletChart/BulletChart.js +16 -2
  99. package/dist/charts/BulletChart/BulletChart.js.map +1 -1
  100. package/dist/charts/RadarChart/RadarChart.js +4 -2
  101. package/dist/charts/RadarChart/RadarChart.js.map +1 -1
  102. package/dist/charts/lib/chart.js +16 -1
  103. package/dist/charts/lib/chart.js.map +1 -1
  104. package/dist/components/AppShell/AppShell.js +2 -2
  105. package/dist/components/AppShell/AppShell.js.map +1 -1
  106. package/dist/components/Button/Button.js +3 -3
  107. package/dist/components/Button/Button.js.map +1 -1
  108. package/dist/components/ColorPicker/ColorPicker.d.ts +67 -0
  109. package/dist/components/ColorPicker/ColorPicker.js +334 -0
  110. package/dist/components/ColorPicker/ColorPicker.js.map +1 -0
  111. package/dist/components/Command/Command.js +1 -1
  112. package/dist/components/Command/Command.js.map +1 -1
  113. package/dist/components/ContextMenu/ContextMenu.js +1 -1
  114. package/dist/components/ContextMenu/ContextMenu.js.map +1 -1
  115. package/dist/components/Dialog/Dialog.js +1 -1
  116. package/dist/components/Dialog/Dialog.js.map +1 -1
  117. package/dist/components/DropdownMenu/DropdownMenu.js +1 -1
  118. package/dist/components/DropdownMenu/DropdownMenu.js.map +1 -1
  119. package/dist/components/ErrorState/ErrorState.js +1 -1
  120. package/dist/components/ErrorState/ErrorState.js.map +1 -1
  121. package/dist/components/Kbd/Kbd.d.ts +7 -0
  122. package/dist/components/Kbd/Kbd.js +1 -1
  123. package/dist/components/Kbd/Kbd.js.map +1 -1
  124. package/dist/components/NavItem/NavItem.js +1 -1
  125. package/dist/components/NavItem/NavItem.js.map +1 -1
  126. package/dist/components/NumberField/NumberField.d.ts +69 -0
  127. package/dist/components/NumberField/NumberField.js +162 -0
  128. package/dist/components/NumberField/NumberField.js.map +1 -0
  129. package/dist/components/Popover/Popover.js +1 -1
  130. package/dist/components/Popover/Popover.js.map +1 -1
  131. package/dist/components/Select/Select.js +1 -1
  132. package/dist/components/Select/Select.js.map +1 -1
  133. package/dist/components/Sheet/Sheet.js +1 -1
  134. package/dist/components/Sheet/Sheet.js.map +1 -1
  135. package/dist/components/Sidebar/Sidebar.d.ts +357 -0
  136. package/dist/components/Sidebar/Sidebar.js +455 -0
  137. package/dist/components/Sidebar/Sidebar.js.map +1 -0
  138. package/dist/components/Slider/Slider.d.ts +22 -1
  139. package/dist/components/Slider/Slider.js +72 -8
  140. package/dist/components/Slider/Slider.js.map +1 -1
  141. package/dist/components/Table/Table.d.ts +14 -0
  142. package/dist/components/Table/Table.js.map +1 -1
  143. package/dist/components/Tabs/Tabs.js +7 -1
  144. package/dist/components/Tabs/Tabs.js.map +1 -1
  145. package/dist/diagrams/lib/route.d.ts +22 -1
  146. package/dist/diagrams/lib/route.js +24 -1
  147. package/dist/diagrams/lib/route.js.map +1 -1
  148. package/dist/diagrams/lib/wires.js +9 -5
  149. package/dist/diagrams/lib/wires.js.map +1 -1
  150. package/dist/index.d.ts +3 -0
  151. package/dist/index.js +3 -0
  152. package/dist/index.js.map +1 -1
  153. package/dist/lib/color.d.ts +80 -0
  154. package/dist/lib/color.js +189 -0
  155. package/dist/lib/color.js.map +1 -0
  156. package/dist/lib/numeric.d.ts +35 -0
  157. package/dist/lib/numeric.js +29 -0
  158. package/dist/lib/numeric.js.map +1 -0
  159. package/dist/semantic.css +4 -2
  160. package/dist/styles.css +1 -1
  161. package/dist/themes.css +99 -0
  162. package/dist/tokens.css +133 -4
  163. package/dist/tokens.d.ts +5 -0
  164. package/dist/tokens.js +31 -3
  165. package/dist/tokens.json +31 -3
  166. package/package.json +1 -1
  167. package/skills/misoto22-design/rules/forms.md +3 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/charts/BulletChart/BulletChart.tsx"],"sourcesContent":["import type { ReactNode } from 'react'\nimport { ChartFigure } from '../lib/figure'\nimport { type ChartEmptyProps } from '../lib/empty'\nimport { defaultTick } from '../lib/format'\nimport { clampedFraction } from '../lib/scale'\n\n/**\n * The heaviest and lightest a qualitative band may be, as multiples of\n * `--chart-fill`.\n *\n * Few's rule is no more than five bands in one hue, arranged so the low end of\n * the scale is the heaviest. The measure bar is solid ink on top of them, so\n * the ceiling has to stay well under it: bands that compete with the bar stop\n * being the ground it is read against.\n */\nconst BAND_WEIGHT = { heaviest: 1, lightest: 0.3 } as const\n\n/** How tall the measure bar is, as a share of the track. Few's is a third. */\nconst BAR_SHARE = 0.34\n\n/** One measure, its target, and the bands it is read against. */\nexport interface BulletMeasure {\n /** What is being measured. Doubles as the row's key, so it must be unique. */\n name: string\n /** The number the row is about. */\n value: number\n /**\n * The number the measure is being judged against — last year, the budget,\n * the SLO. Drawn as a rule across the bar, not as a second bar.\n */\n target?: number\n /**\n * The qualitative bands, as ascending UPPER bounds. `[60, 80]` on a 0–100\n * scale draws three bands: 0–60, 60–80, 80–100.\n *\n * Few's limit is five, and it is a real one: past that the shades stop being\n * separable and the bands become a gradient nobody can read a boundary off.\n */\n ranges?: number[]\n /** The scale, as `[min, max]`. Falls back to the chart's, then to the data. */\n domain?: [number, number]\n /** A second line under the name — the unit, the window, the owner. */\n detail?: ReactNode\n}\n\n/** A measure with its scale and bands resolved. */\ninterface ResolvedMeasure extends BulletMeasure {\n domain: [number, number]\n bands: { from: number; to: number; weight: number }[]\n /** Written out for the hidden table, which cannot print an array. */\n rangeLabel: string\n}\n\n/** Where a value sits on the scale, 0 to 1, clamped to it. */\nfunction position(value: number, [low, high]: [number, number]): number {\n return clampedFraction(value, low, high)\n}\n\n/** Whether a number is off the end of the scale it is being drawn against. */\nfunction overflows(value: number | undefined, [low, high]: [number, number]): boolean {\n return value !== undefined && Number.isFinite(value) && (value > high || value < low)\n}\n\n/**\n * A share of the track, as a CSS percentage.\n *\n * Rounded, because binary floating point turns `0.8 - 0.5` into\n * `30.000000000000004%` — a number no layout engine cares about and every\n * reader of the DOM does. Four places is finer than a subpixel on any track\n * this chart will be given.\n */\nfunction pct(share: number): string {\n return `${Number((share * 100).toFixed(4))}%`\n}\n\n/** The bands, from the ascending upper bounds, each with its own weight. */\nfunction bands(\n ranges: number[],\n [low, high]: [number, number],\n formatValue: (value: number) => string,\n): { list: ResolvedMeasure['bands']; label: string } {\n const set = [...new Set(ranges.filter(Number.isFinite))].sort((a, b) => a - b)\n // A bound off the end of the scale has no boundary to draw: it would be a\n // band with no width, or one that starts past the track. It is dropped from\n // the picture and kept in the LABEL, because a caller who pinned `[0, 50]`\n // and passed `[60, 80]` has made a mistake, and one flat band that says\n // nothing is how it stays invisible.\n const bounds = set.filter((bound) => bound > low && bound < high)\n\n const edges = [low, ...bounds, high]\n const count = edges.length - 1\n const { heaviest, lightest } = BAND_WEIGHT\n\n const list = Array.from({ length: count }, (_, index) => ({\n from: edges[index]!,\n to: edges[index + 1]!,\n // Heaviest at the low end, lightening as the scale rises — so the measure\n // bar, which is solid, stands out most where performance is best.\n weight: count === 1 ? lightest : heaviest - (index / (count - 1)) * (heaviest - lightest),\n }))\n\n // The label names the bounds the caller SET, not the band tops: the last\n // band's top is the end of the scale, which is not a threshold anybody chose\n // — and not the drawable subset either, or a bound the scale cannot hold\n // would go missing from the reading as well as from the picture.\n return { list, label: set.map(formatValue).join(', ') }\n}\n\n/** Every measure, with its scale and bands worked out. */\nfunction resolve(\n data: BulletMeasure[],\n domain: [number, number] | undefined,\n ranges: number[] | undefined,\n formatValue: (value: number) => string,\n): ResolvedMeasure[] {\n return data.map((measure) => {\n const own = measure.ranges ?? ranges ?? []\n const high = Math.max(measure.value, measure.target ?? Number.NEGATIVE_INFINITY, ...own)\n // Anchored at zero unless told otherwise: the measure is drawn as a BAR,\n // and a bar read off a scale that does not start at zero overstates every\n // difference on it.\n const scale: [number, number] = measure.domain ?? domain ?? [Math.min(0, measure.value), high || 1]\n const built = bands(own, scale, formatValue)\n\n return { ...measure, domain: scale, bands: built.list, rangeLabel: built.label }\n })\n}\n\nexport interface BulletChartProps {\n /**\n * What the chart shows, in a sentence a reader could act on. Required, and\n * announced to a screen reader even when it is not printed.\n */\n title: string\n /** Prints the title above the rows instead of hiding it from sight. */\n showTitle?: boolean\n /** A line under the title — the unit, the window, the caveat. */\n description?: ReactNode\n /** One entry per measure. Order is the reading order; it is not sorted. */\n data: BulletMeasure[]\n /**\n * The bands every measure falls back to, as ascending upper bounds.\n *\n * Shared bands only mean something when the measures share a scale. Where\n * they do not — a latency beside a conversion rate — put `ranges` and\n * `domain` on each measure instead.\n */\n ranges?: number[]\n /** The scale every measure falls back to, as `[min, max]`. */\n domain?: [number, number]\n /** Formats every number the chart prints. */\n formatValue?: (value: number) => string\n /** Prints the scale's two ends under each track. */\n showScale?: boolean\n /** Merged onto the figure, last, so a call site can size or space it. */\n className?: string\n /** Drops the hidden table view. Only correct when the page prints the data itself. */\n hideDataTable?: boolean\n /** What the chart shows when it has nothing to draw. */\n empty?: ChartEmptyProps\n}\n\n/**\n * A measure, its target, and the bands that say whether the number is any good\n * — one dense row per thing being tracked.\n *\n * Stephen Few designed this to replace the dashboard gauge, and the argument\n * has held: a speedometer spends a whole card saying one number badly, where a\n * bullet graph says the same number, its target, and the qualitative context\n * around it in the height of a line of text. Ten of them stack into a status\n * page a reader can scan in one pass.\n *\n * What it will not do is show change over time. A bullet graph is one instant,\n * and `target` is the only comparison it carries; when the question is\n * \"how did we get here\" it wants a `<LineChart>`, and when several measures\n * have to be compared against each OTHER rather than each against its own\n * target, a `<BarList>` ranks them and this does not.\n *\n * **The bands are the part to be careful with.** They are a judgement — someone\n * decided that 80 is \"good\" — drawn in the same ink as the measurement, and a\n * reader has no way to tell an agreed threshold from a number somebody typed.\n * They also compress: a value near the top of the scale sits in the same band\n * whether it cleared the boundary by a point or by thirty. The band answers\n * \"is this acceptable\", never \"by how much\".\n *\n * No rendering engine, deliberately — this is the one chart in the set with\n * nothing to compute. Each row is a single linear scale with no axis, no ticks\n * and no shared plot area, so it is laid out as HTML: the bands and the bar are\n * inline-axis offsets, which means the whole chart mirrors correctly in a\n * right-to-left document, where an SVG drawn in user space would not.\n *\n * @example\n * <BulletChart\n * title=\"Service levels against target\"\n * data={[{ name: 'Uptime', value: 99.4, target: 99.9, ranges: [98, 99.5] }]}\n * domain={[95, 100]}\n * />\n */\nexport function BulletChart({\n title,\n showTitle,\n description,\n data,\n ranges,\n domain,\n formatValue = defaultTick,\n showScale = false,\n className,\n hideDataTable = false,\n empty,\n}: BulletChartProps) {\n const measures = resolve(data, domain, ranges, formatValue)\n\n return (\n <ChartFigure\n title={title}\n showTitle={showTitle}\n description={description}\n className={className}\n table={\n hideDataTable\n ? false\n : {\n rows: measures as unknown as Record<string, unknown>[],\n rowKey: 'name',\n columns: [\n { key: 'value', label: 'Value' },\n { key: 'target', label: 'Target' },\n { key: 'rangeLabel', label: 'Range bounds' },\n ],\n }\n }\n isEmpty={measures.length === 0}\n empty={empty}\n >\n <div className=\"flex w-full flex-col gap-4\">\n {measures.map((measure) => (\n <BulletRow\n key={measure.name}\n measure={measure}\n formatValue={formatValue}\n showScale={showScale}\n />\n ))}\n </div>\n </ChartFigure>\n )\n}\n\nfunction BulletRow({\n measure,\n formatValue,\n showScale,\n}: {\n measure: ResolvedMeasure\n formatValue: (value: number) => string\n showScale: boolean\n}) {\n const scale = measure.domain\n const barShare = position(measure.value, scale)\n const targetShare = measure.target === undefined ? null : position(measure.target, scale)\n const runsOver = overflows(measure.value, scale) || overflows(measure.target, scale)\n\n return (\n <div className=\"flex w-full flex-col gap-1.5\">\n <div className=\"flex items-baseline justify-between gap-3\">\n <span className=\"truncate text-sm text-(--ink)\">\n {measure.name}\n {measure.detail && (\n <span className=\"ms-2 mono-meta text-(--ink-3-aa)\">{measure.detail}</span>\n )}\n </span>\n <span className=\"shrink-0 font-mono text-xs tabular-nums text-(--ink)\">\n {formatValue(measure.value)}\n {measure.target !== undefined && (\n <span className=\"text-(--ink-3-aa)\"> / {formatValue(measure.target)}</span>\n )}\n </span>\n </div>\n\n {/* The graphic repeats what the line above and the hidden table already\n say, so it is out of the accessibility tree entirely rather than\n announced a third time as a stack of empty boxes. */}\n <div\n aria-hidden\n data-slot=\"bullet-track\"\n className=\"relative h-6 w-full overflow-hidden rounded-(--radius-sm)\"\n >\n {measure.bands.map((band) => (\n <span\n key={`${band.from}-${band.to}`}\n data-slot=\"bullet-band\"\n className=\"absolute inset-y-0 block\"\n style={{\n insetInlineStart: pct(position(band.from, scale)),\n inlineSize: pct(position(band.to, scale) - position(band.from, scale)),\n // The one translucent-mark pair in the token layer, because it is\n // the only one that holds a different number on each ground.\n backgroundColor: `color-mix(in srgb, var(--series-1) calc(var(--chart-fill) * ${band.weight} * 100%), transparent)`,\n }}\n />\n ))}\n\n {/* Solid, and a third of the track's height. The measure is the one\n thing here that is a measurement rather than a judgement, so it is\n the only mark drawn at full weight. */}\n <span\n data-slot=\"bullet-bar\"\n className=\"absolute block bg-(--series-1)\"\n style={{\n insetInlineStart: 0,\n insetBlockStart: pct((1 - BAR_SHARE) / 2),\n blockSize: pct(BAR_SHARE),\n inlineSize: pct(barShare),\n }}\n />\n\n {targetShare !== null && (\n // A rule ACROSS the bar rather than a second bar beside it: the two\n // are different kinds of number, and the reader has to be able to\n // tell at a glance which one was achieved and which was asked for.\n <span\n data-slot=\"bullet-target\"\n className=\"absolute inset-y-1 block w-0.5 bg-(--ink)\"\n style={{ insetInlineStart: pct(targetShare), marginInlineStart: '-1px' }}\n />\n )}\n\n {runsOver && (\n // Clamped and silent, a number past the end of the scale fills the\n // track exactly as a number AT the end of it does — so the one thing\n // the row cannot say is the one thing the reader most needs to know.\n // The notch is what breaks that tie; the printed figure above still\n // carries the real number.\n <span\n data-slot=\"bullet-overflow\"\n className=\"absolute inset-y-0 end-0 block w-1.5 bg-(--chart-surface)\"\n style={{\n clipPath: 'polygon(100% 0, 0 50%, 100% 100%)',\n }}\n />\n )}\n </div>\n\n {showScale && (\n <div className=\"flex justify-between mono-meta text-(--chart-axis)\">\n <span>{formatValue(scale[0])}</span>\n <span>{formatValue(scale[1])}</span>\n </div>\n )}\n </div>\n )\n}\n\nexport default BulletChart\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/charts/BulletChart/BulletChart.tsx"],"sourcesContent":["import type { ReactNode } from 'react'\nimport { ChartFigure } from '../lib/figure'\nimport { type ChartEmptyProps } from '../lib/empty'\nimport { defaultTick } from '../lib/format'\nimport { clampedFraction } from '../lib/scale'\n\n/**\n * The heaviest and lightest a qualitative band may be, as multiples of\n * `--chart-fill`.\n *\n * Few's rule is no more than five bands in one hue, arranged so the low end of\n * the scale is the heaviest. The measure bar is solid ink on top of them, so\n * the ceiling has to stay well under it: bands that compete with the bar stop\n * being the ground it is read against.\n */\nconst BAND_WEIGHT = { heaviest: 1, lightest: 0.3 } as const\n\n/** How tall the measure bar is, as a share of the track. Few's is a third. */\nconst BAR_SHARE = 0.34\n\n/** One measure, its target, and the bands it is read against. */\nexport interface BulletMeasure {\n /** What is being measured. Doubles as the row's key, so it must be unique. */\n name: string\n /** The number the row is about. */\n value: number\n /**\n * The number the measure is being judged against — last year, the budget,\n * the SLO. Drawn as a rule across the bar, not as a second bar.\n */\n target?: number\n /**\n * The qualitative bands, as ascending UPPER bounds. `[60, 80]` on a 0–100\n * scale draws three bands: 0–60, 60–80, 80–100.\n *\n * Few's limit is five, and it is a real one: past that the shades stop being\n * separable and the bands become a gradient nobody can read a boundary off.\n */\n ranges?: number[]\n /** The scale, as `[min, max]`. Falls back to the chart's, then to the data. */\n domain?: [number, number]\n /** A second line under the name — the unit, the window, the owner. */\n detail?: ReactNode\n}\n\n/** A measure with its scale and bands resolved. */\ninterface ResolvedMeasure extends BulletMeasure {\n domain: [number, number]\n bands: { from: number; to: number; weight: number }[]\n /** Written out for the hidden table, which cannot print an array. */\n rangeLabel: string\n}\n\n/** Where a value sits on the scale, 0 to 1, clamped to it. */\nfunction position(value: number, [low, high]: [number, number]): number {\n return clampedFraction(value, low, high)\n}\n\n/** Whether a number is off the end of the scale it is being drawn against. */\nfunction overflows(value: number | undefined, [low, high]: [number, number]): boolean {\n return value !== undefined && Number.isFinite(value) && (value > high || value < low)\n}\n\n/**\n * A share of the track, as a CSS percentage.\n *\n * Rounded, because binary floating point turns `0.8 - 0.5` into\n * `30.000000000000004%` — a number no layout engine cares about and every\n * reader of the DOM does. Four places is finer than a subpixel on any track\n * this chart will be given.\n */\nfunction pct(share: number): string {\n return `${Number((share * 100).toFixed(4))}%`\n}\n\n/** The bands, from the ascending upper bounds, each with its own weight. */\nfunction bands(\n ranges: number[],\n [low, high]: [number, number],\n formatValue: (value: number) => string,\n): { list: ResolvedMeasure['bands']; label: string } {\n const set = [...new Set(ranges.filter(Number.isFinite))].sort((a, b) => a - b)\n // A bound off the end of the scale has no boundary to draw: it would be a\n // band with no width, or one that starts past the track. It is dropped from\n // the picture and kept in the LABEL, because a caller who pinned `[0, 50]`\n // and passed `[60, 80]` has made a mistake, and one flat band that says\n // nothing is how it stays invisible.\n const bounds = set.filter((bound) => bound > low && bound < high)\n\n const edges = [low, ...bounds, high]\n const count = edges.length - 1\n const { heaviest, lightest } = BAND_WEIGHT\n\n const list = Array.from({ length: count }, (_, index) => ({\n from: edges[index]!,\n to: edges[index + 1]!,\n // Heaviest at the low end, lightening as the scale rises — so the measure\n // bar, which is solid, stands out most where performance is best.\n weight: count === 1 ? lightest : heaviest - (index / (count - 1)) * (heaviest - lightest),\n }))\n\n // The label names the bounds the caller SET, not the band tops: the last\n // band's top is the end of the scale, which is not a threshold anybody chose\n // — and not the drawable subset either, or a bound the scale cannot hold\n // would go missing from the reading as well as from the picture.\n return { list, label: set.map(formatValue).join(', ') }\n}\n\n/** Every measure, with its scale and bands worked out. */\nfunction resolve(\n data: BulletMeasure[],\n domain: [number, number] | undefined,\n ranges: number[] | undefined,\n formatValue: (value: number) => string,\n): ResolvedMeasure[] {\n return data.map((measure) => {\n const own = measure.ranges ?? ranges ?? []\n const high = Math.max(measure.value, measure.target ?? Number.NEGATIVE_INFINITY, ...own)\n // Anchored at zero unless told otherwise: the measure is drawn as a BAR,\n // and a bar read off a scale that does not start at zero overstates every\n // difference on it.\n const scale: [number, number] = measure.domain ?? domain ?? [Math.min(0, measure.value), high || 1]\n const built = bands(own, scale, formatValue)\n\n return { ...measure, domain: scale, bands: built.list, rangeLabel: built.label }\n })\n}\n\nexport interface BulletChartProps {\n /**\n * What the chart shows, in a sentence a reader could act on. Required, and\n * announced to a screen reader even when it is not printed.\n */\n title: string\n /** Prints the title above the rows instead of hiding it from sight. */\n showTitle?: boolean\n /** A line under the title — the unit, the window, the caveat. */\n description?: ReactNode\n /** One entry per measure. Order is the reading order; it is not sorted. */\n data: BulletMeasure[]\n /**\n * The bands every measure falls back to, as ascending upper bounds.\n *\n * Shared bands only mean something when the measures share a scale. Where\n * they do not — a latency beside a conversion rate — put `ranges` and\n * `domain` on each measure instead.\n */\n ranges?: number[]\n /** The scale every measure falls back to, as `[min, max]`. */\n domain?: [number, number]\n /** Formats every number the chart prints. */\n formatValue?: (value: number) => string\n /** Prints the scale's two ends under each track. */\n showScale?: boolean\n /** Merged onto the figure, last, so a call site can size or space it. */\n className?: string\n /** Drops the hidden table view. Only correct when the page prints the data itself. */\n hideDataTable?: boolean\n /** What the chart shows when it has nothing to draw. */\n empty?: ChartEmptyProps\n}\n\n/**\n * A measure, its target, and the bands that say whether the number is any good\n * — one dense row per thing being tracked.\n *\n * Stephen Few designed this to replace the dashboard gauge, and the argument\n * has held: a speedometer spends a whole card saying one number badly, where a\n * bullet graph says the same number, its target, and the qualitative context\n * around it in the height of a line of text. Ten of them stack into a status\n * page a reader can scan in one pass.\n *\n * What it will not do is show change over time. A bullet graph is one instant,\n * and `target` is the only comparison it carries; when the question is\n * \"how did we get here\" it wants a `<LineChart>`, and when several measures\n * have to be compared against each OTHER rather than each against its own\n * target, a `<BarList>` ranks them and this does not.\n *\n * **The bands are the part to be careful with.** They are a judgement — someone\n * decided that 80 is \"good\" — drawn in the same ink as the measurement, and a\n * reader has no way to tell an agreed threshold from a number somebody typed.\n * They also compress: a value near the top of the scale sits in the same band\n * whether it cleared the boundary by a point or by thirty. The band answers\n * \"is this acceptable\", never \"by how much\".\n *\n * No rendering engine, deliberately — this is the one chart in the set with\n * nothing to compute. Each row is a single linear scale with no axis, no ticks\n * and no shared plot area, so it is laid out as HTML: the bands and the bar are\n * inline-axis offsets, which means the whole chart mirrors correctly in a\n * right-to-left document, where an SVG drawn in user space would not.\n *\n * @example\n * <BulletChart\n * title=\"Service levels against target\"\n * data={[{ name: 'Uptime', value: 99.4, target: 99.9, ranges: [98, 99.5] }]}\n * domain={[95, 100]}\n * />\n */\nexport function BulletChart({\n title,\n showTitle,\n description,\n data,\n ranges,\n domain,\n formatValue = defaultTick,\n showScale = false,\n className,\n hideDataTable = false,\n empty,\n}: BulletChartProps) {\n const measures = resolve(data, domain, ranges, formatValue)\n\n return (\n <ChartFigure\n title={title}\n showTitle={showTitle}\n description={description}\n className={className}\n table={\n hideDataTable\n ? false\n : {\n rows: measures as unknown as Record<string, unknown>[],\n rowKey: 'name',\n columns: [\n { key: 'value', label: 'Value' },\n { key: 'target', label: 'Target' },\n { key: 'rangeLabel', label: 'Range bounds' },\n ],\n }\n }\n isEmpty={measures.length === 0}\n empty={empty}\n >\n <div className=\"flex w-full flex-col gap-4\">\n {measures.map((measure) => (\n <BulletRow\n key={measure.name}\n measure={measure}\n formatValue={formatValue}\n showScale={showScale}\n />\n ))}\n </div>\n </ChartFigure>\n )\n}\n\nfunction BulletRow({\n measure,\n formatValue,\n showScale,\n}: {\n measure: ResolvedMeasure\n formatValue: (value: number) => string\n showScale: boolean\n}) {\n const scale = measure.domain\n const barShare = position(measure.value, scale)\n const targetShare = measure.target === undefined ? null : position(measure.target, scale)\n const runsOver = overflows(measure.value, scale) || overflows(measure.target, scale)\n\n return (\n <div className=\"flex w-full flex-col gap-1.5\">\n <div className=\"flex items-baseline justify-between gap-3\">\n <span className=\"truncate text-sm text-(--ink)\">\n {measure.name}\n {measure.detail && (\n <span className=\"ms-2 mono-meta text-(--ink-3-aa)\">{measure.detail}</span>\n )}\n </span>\n <span className=\"shrink-0 font-mono text-xs tabular-nums text-(--ink)\">\n {formatValue(measure.value)}\n {measure.target !== undefined && (\n <span className=\"text-(--ink-3-aa)\"> / {formatValue(measure.target)}</span>\n )}\n </span>\n </div>\n\n {/* The graphic repeats what the line above and the hidden table already\n say, so it is out of the accessibility tree entirely rather than\n announced a third time as a stack of empty boxes. */}\n <div\n aria-hidden\n data-slot=\"bullet-track\"\n className=\"relative h-6 w-full overflow-hidden rounded-(--radius-sm)\"\n >\n {measure.bands.map((band) => (\n <span\n key={`${band.from}-${band.to}`}\n data-slot=\"bullet-band\"\n className=\"absolute inset-y-0 block\"\n style={{\n insetInlineStart: pct(position(band.from, scale)),\n inlineSize: pct(position(band.to, scale) - position(band.from, scale)),\n // The one translucent-mark pair in the token layer, because it is\n // the only one that holds a different number on each ground.\n backgroundColor: `color-mix(in srgb, var(--series-1) calc(var(--chart-fill) * ${band.weight} * 100%), transparent)`,\n }}\n />\n ))}\n\n {/* Solid, and a third of the track's height. The measure is the one\n thing here that is a measurement rather than a judgement, so it is\n the only mark drawn at full weight. */}\n <span\n data-slot=\"bullet-bar\"\n className=\"absolute block bg-(--series-1)\"\n style={{\n insetInlineStart: 0,\n insetBlockStart: pct((1 - BAR_SHARE) / 2),\n blockSize: pct(BAR_SHARE),\n inlineSize: pct(barShare),\n }}\n />\n\n {targetShare !== null && (\n // A rule ACROSS the bar rather than a second bar beside it: the two\n // are different kinds of number, and the reader has to be able to\n // tell at a glance which one was achieved and which was asked for.\n //\n // The paper halo is what makes that true where the rule lands ON the\n // bar. Both are drawn in --ink, so a target inside the achieved range\n // — which is every measure that is doing well — was an ink rule on an\n // ink bar and simply not there.\n <span\n data-slot=\"bullet-target\"\n className=\"absolute inset-y-1 block w-0.5 bg-(--ink) shadow-[0_0_0_1px_var(--paper)]\"\n style={{\n insetInlineStart: pct(targetShare),\n // Pulled back by its own width in PROPORTION to where it sits:\n // flush at the start at 0, centred in the middle, flush at the end\n // at 1. A fixed half-width pull put a target of zero half outside\n // the track, where `overflow-hidden` took it — so \"Open incidents,\n // target 0\" drew no target at all, on the one measure whose whole\n // point is the distance from it.\n marginInlineStart: `${-2 * targetShare}px`,\n }}\n />\n )}\n\n {runsOver && (\n // Clamped and silent, a number past the end of the scale fills the\n // track exactly as a number AT the end of it does — so the one thing\n // the row cannot say is the one thing the reader most needs to know.\n // The notch is what breaks that tie; the printed figure above still\n // carries the real number.\n <span\n data-slot=\"bullet-overflow\"\n className=\"absolute inset-y-0 end-0 block w-1.5 bg-(--chart-surface)\"\n style={{\n clipPath: 'polygon(100% 0, 0 50%, 100% 100%)',\n }}\n />\n )}\n </div>\n\n {showScale && (\n <div className=\"flex justify-between mono-meta text-(--chart-axis)\">\n <span>{formatValue(scale[0])}</span>\n <span>{formatValue(scale[1])}</span>\n </div>\n )}\n </div>\n )\n}\n\nexport default BulletChart\n"],"mappings":"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","names":[]}
@@ -111,7 +111,9 @@ function Radar({
111
111
  const opacity = { stroke: dimmed ? 0.2 : 1, fill: dimmed ? 0.1 : 1, dot: dimmed ? 0.2 : 1 };
112
112
  const isFilled = variant === "filled";
113
113
  const { dot, activeDot } = resolveDots(children, id, dataKey, opacity.dot);
114
- const filledStrength = `calc(var(--chart-fill) * ${FILL_STRENGTH})`;
114
+ const dimFill = dimmed ? ` * ${opacity.fill}` : "";
115
+ const filledStrength = `calc(var(--chart-fill) * ${FILL_STRENGTH}${dimFill})`;
116
+ const filledOpacity = fillOpacity === void 0 ? filledStrength : fillOpacity * opacity.fill;
115
117
  const stops = colorStops(config[dataKey]);
116
118
  return /* @__PURE__ */ jsxs(Fragment, { children: [
117
119
  /* @__PURE__ */ jsx(
@@ -122,7 +124,7 @@ function Radar({
122
124
  strokeOpacity: opacity.stroke,
123
125
  strokeWidth: STROKE_WIDTH,
124
126
  fill: isFilled ? `url(#${id}-fill-${dataKey})` : "none",
125
- fillOpacity: isFilled ? (fillOpacity ?? filledStrength) * opacity.fill : 0,
127
+ fillOpacity: isFilled ? filledOpacity : 0,
126
128
  dot,
127
129
  activeDot,
128
130
  filter: glowing ? `url(#${id}-glow-${dataKey})` : void 0,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/charts/RadarChart/RadarChart.tsx"],"sourcesContent":["'use client'\n\nimport {\n Children,\n createContext,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useMemo,\n useState,\n type ComponentProps,\n type FC,\n type ReactElement,\n type ReactNode,\n} from 'react'\nimport {\n PolarAngleAxis as RechartsPolarAngleAxis,\n PolarGrid as RechartsPolarGrid,\n PolarRadiusAxis as RechartsPolarRadiusAxis,\n Radar as RechartsRadar,\n RadarChart as RechartsRadarChart,\n} from 'recharts'\nimport { useReducedMotion } from 'motion/react'\nimport { ChartContainer, colorStops, type ChartConfig } from '../lib/chart'\nimport { ChartFigure } from '../lib/figure'\nimport { type ChartEmptyProps } from '../lib/empty'\nimport { useChartSelection } from '../lib/selection'\nimport { ChartBackground, type ChartBackgroundVariant } from '../lib/background'\nimport { ChartDot, type ChartDotVariant } from '../lib/dot'\nimport { ChartLegend, ChartLegendContent } from '../lib/legend'\nimport { ChartTooltip, ChartTooltipContent } from '../lib/tooltip'\nimport { LoadingIndicator } from '../lib/loading'\nimport { ColorStops, GlowFilter, SeriesGradient } from '../lib/paint'\nimport type { ChartLegendSlotProps, ChartTooltipSlotProps } from '../AreaChart/AreaChart'\n\nconst STROKE_WIDTH = 1\n/**\n * How solid a filled radar is, as a multiplier on `--chart-fill`.\n *\n * Above 1 because a radar's fill is the mark itself rather than a wash under a\n * line — it has to hold its shape where two of them overlap.\n */\nconst FILL_STRENGTH = 2.2\nconst LOADING_POINTS = 6\n\n/** One skeleton morph, in milliseconds. */\nconst LOADING_CYCLE = 1500\n\n/**\n * Whether a radar is filled or left as an outline.\n *\n * `lines` is the honest default above two series: filled polygons overlap, and\n * the reader is then judging areas through two layers of translucency, which is\n * the one thing a radar is worst at.\n */\nexport type RadarVariant = 'filled' | 'lines'\n\ninterface RadarChartContextValue {\n config: ChartConfig\n isLoading: boolean\n selectedDataKey: string | null\n selectDataKey: (dataKey: string | null) => void\n}\n\nconst RadarChartContext = createContext<RadarChartContextValue | null>(null)\n\nfunction useRadarChart(): RadarChartContextValue {\n const context = useContext(RadarChartContext)\n if (!context) throw new Error('A radar chart part must be rendered inside <RadarChart>')\n return context\n}\n\ntype ValidateKeys<TData, TConfig> = {\n [K in keyof TConfig]: K extends keyof TData ? ChartConfig[string] : never\n}\n\nexport interface RadarChartProps<\n TData extends Record<string, unknown>,\n TConfig extends Record<string, ChartConfig[string]>,\n> {\n config: TConfig & ValidateKeys<TData, TConfig>\n /** The rows the chart draws. One entry per point, bar or category. */\n data: TData[]\n /**\n * What the chart shows, in a sentence a reader could act on. Required, and\n * announced to a screen reader even when it is not printed.\n */\n title: string\n /** Prints the title above the plot instead of hiding it from sight. */\n showTitle?: boolean\n /** A line under the title — the unit, the window, the caveat. */\n description?: ReactNode\n /**\n * The composed parts — axes, grid, tooltip, legend, and the marks\n * themselves.\n */\n children: ReactNode\n /** Merged onto the figure, last, so a call site can size or space it. */\n className?: string\n /** Escape hatch onto the raw Recharts chart element. */\n chartProps?: ComponentProps<typeof RechartsRadarChart>\n /** The series lit on first render, when the chart keeps its own selection. */\n defaultSelectedDataKey?: string | null\n /**\n * The selected series, driven from outside.\n *\n * Give this and the chart follows it; leave it undefined and the chart keeps\n * its own, starting from `defaultSelectedDataKey`.\n */\n selectedDataKey?: string | null\n /** Fires when the selection changes, and with null when it is cleared. */\n onSelectionChange?: (selectedDataKey: string | null) => void\n /**\n * Swaps the marks for an animated skeleton, keeping the measured height so\n * the page does not jump when the data lands.\n */\n isLoading?: boolean\n /** How many points the skeleton draws. */\n loadingPoints?: number\n /** The row field naming each spoke. Used by the table view. */\n angleDataKey?: keyof TData & string\n /**\n * Drops the hidden table view. Only correct when the page prints the data\n * itself.\n */\n hideDataTable?: boolean\n /**\n * What the chart shows when it has nothing to draw. `false` keeps the empty\n * plot, for a chart whose emptiness is itself the reading.\n */\n empty?: ChartEmptyProps | false\n}\n\n/**\n * A profile across several named dimensions — the shape for \"what is this thing\n * strong and weak at\".\n *\n * It reads a SHAPE, not a set of values: the area a radar encloses depends on\n * the order the spokes happen to be in, so it is the wrong chart for comparing\n * magnitudes and the right one for recognising a silhouette. Two or three\n * series at most.\n *\n * @example\n * <RadarChart title=\"Team skills\" config={config} data={data} angleDataKey=\"skill\">\n * <RadarChart.PolarGrid />\n * <RadarChart.PolarAngleAxis dataKey=\"skill\" />\n * <RadarChart.Tooltip />\n * <RadarChart.Radar dataKey=\"current\" />\n * </RadarChart>\n */\nexport function RadarChart<\n TData extends Record<string, unknown>,\n TConfig extends Record<string, ChartConfig[string]>,\n>({\n config,\n data,\n title,\n showTitle,\n description,\n children,\n className,\n chartProps,\n defaultSelectedDataKey = null,\n selectedDataKey: controlledDataKey,\n onSelectionChange,\n isLoading = false,\n loadingPoints,\n angleDataKey,\n hideDataTable = false,\n empty,\n}: RadarChartProps<TData, TConfig>) {\n const chartId = useId().replace(/:/g, '')\n const loadingData = useLoadingShape(isLoading, loadingPoints)\n const [selectedDataKey, selectDataKey] = useChartSelection(\n controlledDataKey,\n defaultSelectedDataKey,\n onSelectionChange,\n )\n\n const context = useMemo<RadarChartContextValue>(\n () => ({ config, isLoading, selectedDataKey, selectDataKey }),\n [config, isLoading, selectDataKey, selectedDataKey],\n )\n\n return (\n <RadarChartContext.Provider value={context}>\n <ChartFigure\n title={title}\n showTitle={showTitle}\n description={description}\n className={className}\n table={\n hideDataTable\n ? false\n : {\n rows: data,\n rowKey: angleDataKey,\n columns: Object.entries(config).map(([key, series]) => ({\n key,\n label: series.label,\n })),\n }\n }\n isEmpty={!isLoading && data.length === 0}\n empty={empty}\n >\n <ChartContainer config={config}>\n <RechartsRadarChart id={chartId} data={isLoading ? loadingData : data} {...chartProps}>\n {children}\n {isLoading && <LoadingRadar />}\n </RechartsRadarChart>\n </ChartContainer>\n <LoadingIndicator isLoading={isLoading} />\n </ChartFigure>\n </RadarChartContext.Provider>\n )\n}\n\nexport interface RadarProps {\n dataKey: string\n variant?: RadarVariant\n /**\n * How solid the filled area is, 0–1. Left unset it follows `--chart-fill`,\n * which is what keeps it readable on both grounds.\n */\n fillOpacity?: number\n /** A halo around the outline — for the one series that is the point. */\n glowing?: boolean\n isClickable?: boolean\n children?: ReactNode\n radarProps?: Omit<ComponentProps<typeof RechartsRadar>, 'dataKey'>\n}\n\n/** One radar series, with its stroke, fill and glow scoped to its own id. */\nfunction Radar({\n dataKey,\n variant = 'filled',\n fillOpacity,\n glowing = false,\n isClickable = false,\n children,\n radarProps,\n}: RadarProps) {\n const { config, isLoading, selectedDataKey, selectDataKey } = useRadarChart()\n const id = useId().replace(/:/g, '')\n\n if (isLoading) return null\n\n const isSelected = selectedDataKey === null || selectedDataKey === dataKey\n const dimmed = isClickable && !isSelected\n // The fill recedes twice as far as the stroke. A dimmed outline still traces\n // its shape, which is what a radar is read by; a dimmed fill just muddies the\n // one that was picked.\n const opacity = { stroke: dimmed ? 0.2 : 1, fill: dimmed ? 0.1 : 1, dot: dimmed ? 0.2 : 1 }\n const isFilled = variant === 'filled'\n\n const { dot, activeDot } = resolveDots(children, id, dataKey, opacity.dot)\n // `--chart-fill` is a number, not a length, so it can be read into a\n // calc() the SVG attribute accepts — which is how the default follows the\n // ground without the component knowing which ground it is on.\n const filledStrength = `calc(var(--chart-fill) * ${FILL_STRENGTH})` as unknown as number\n const stops = colorStops(config[dataKey])\n\n return (\n <>\n <RechartsRadar\n dataKey={dataKey}\n stroke={`url(#${id}-stroke-${dataKey})`}\n strokeOpacity={opacity.stroke}\n strokeWidth={STROKE_WIDTH}\n fill={isFilled ? `url(#${id}-fill-${dataKey})` : 'none'}\n fillOpacity={isFilled ? (fillOpacity ?? filledStrength) * opacity.fill : 0}\n dot={dot}\n activeDot={activeDot}\n filter={glowing ? `url(#${id}-glow-${dataKey})` : undefined}\n className=\"transition-opacity duration-(--duration-base)\"\n style={isClickable ? { cursor: 'pointer' } : undefined}\n onClick={() => {\n if (!isClickable) return\n selectDataKey(selectedDataKey === dataKey ? null : dataKey)\n }}\n {...radarProps}\n />\n <defs>\n {/* The dots read this one; the stroke and fill get their own below. */}\n <SeriesGradient id={id} dataKey={dataKey} config={config} />\n <linearGradient id={`${id}-stroke-${dataKey}`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n <ColorStops dataKey={dataKey} stops={stops} />\n </linearGradient>\n {isFilled && (\n // Radial, and fading outward: a flat fill makes the outer ring — the\n // part carrying the shape — the least readable part of the mark.\n <radialGradient id={`${id}-fill-${dataKey}`} cx=\"50%\" cy=\"50%\" r=\"50%\">\n <ColorStops\n dataKey={dataKey}\n stops={stops}\n opacities={\n stops === 1 ? [0.8, 0.3] : Array.from({ length: stops }, (_, i) => (i === 0 ? 0.8 : 0.3))\n }\n />\n </radialGradient>\n )}\n {glowing && <GlowFilter id={id} dataKey={dataKey} spread={4} intensity={0.6} />}\n </defs>\n </>\n )\n}\n\nexport interface DotProps {\n variant?: ChartDotVariant\n}\n\n/** The resting point marker for the radar it is composed inside. A slot. */\nconst Dot: FC<DotProps> = () => null\n\n/** The marker under the pointer. */\nconst ActiveDot: FC<DotProps> = () => null\n\nfunction resolveDots(children: ReactNode, id: string, dataKey: string, dotOpacity: number) {\n let dot: ComponentProps<typeof RechartsRadar>['dot'] = false\n let activeDot: ComponentProps<typeof RechartsRadar>['activeDot'] = false\n\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return\n const { variant } = (child as ReactElement<DotProps>).props\n\n if (child.type === Dot) {\n dot = <ChartDot variant={variant} dataKey={dataKey} chartId={id} fillOpacity={dotOpacity} />\n }\n if (child.type === ActiveDot) {\n activeDot = (\n <ChartDot variant={variant} dataKey={dataKey} chartId={id} fillOpacity={dotOpacity} />\n )\n }\n })\n\n return { dot, activeDot }\n}\n\n/**\n * The web behind the marks. `gridType=\"circle\"` swaps the polygon for rings,\n * which reads as a scale rather than as a shape and suits a single series.\n */\nfunction PolarGrid({\n gridType = 'polygon',\n stroke = 'var(--chart-grid)',\n strokeDasharray = '3 4',\n ...rest\n}: ComponentProps<typeof RechartsPolarGrid>) {\n return (\n <RechartsPolarGrid\n gridType={gridType}\n stroke={stroke}\n strokeDasharray={strokeDasharray}\n {...rest}\n />\n )\n}\n\n/** The spoke labels around the perimeter. */\nfunction PolarAngleAxis({\n tick = { fill: 'var(--chart-axis)', fontSize: 12 },\n tickLine = false,\n ...rest\n}: ComponentProps<typeof RechartsPolarAngleAxis>) {\n const { isLoading } = useRadarChart()\n if (isLoading) return null\n return <RechartsPolarAngleAxis tick={tick} tickLine={tickLine} {...rest} />\n}\n\n/** The scale running out from the centre. */\nfunction PolarRadiusAxis({\n tick = { fill: 'var(--chart-axis)', fontSize: 10 },\n tickLine = false,\n axisLine = false,\n ...rest\n}: ComponentProps<typeof RechartsPolarRadiusAxis>) {\n const { isLoading } = useRadarChart()\n if (isLoading) return null\n return (\n <RechartsPolarRadiusAxis tick={tick} tickLine={tickLine} axisLine={axisLine} {...rest} />\n )\n}\n\n/** The hover panel. No crosshair: there is no straight axis to run one along. */\nfunction Tooltip({ variant, roundness, defaultIndex }: ChartTooltipSlotProps) {\n const { isLoading, selectedDataKey } = useRadarChart()\n if (isLoading) return null\n\n return (\n <ChartTooltip\n defaultIndex={defaultIndex}\n cursor={false}\n content={\n <ChartTooltipContent selected={selectedDataKey} roundness={roundness} variant={variant} />\n }\n />\n )\n}\n\n/** The key. Under the web and centred, since the spokes name dimensions, not series. */\nfunction Legend({\n variant,\n align = 'center',\n verticalAlign = 'bottom',\n isClickable = false,\n}: ChartLegendSlotProps) {\n const { isLoading, selectedDataKey, selectDataKey } = useRadarChart()\n if (isLoading) return null\n\n return (\n <ChartLegend\n verticalAlign={verticalAlign}\n align={align}\n content={\n <ChartLegendContent\n selected={selectedDataKey}\n onSelectChange={selectDataKey}\n isClickable={isClickable}\n variant={variant}\n />\n }\n />\n )\n}\n\n/** The decorative plate behind the web. */\nfunction Background({ variant = 'dots' }: { variant?: ChartBackgroundVariant }) {\n const { isLoading } = useRadarChart()\n if (isLoading) return null\n return <ChartBackground variant={variant} />\n}\n\n/** A deterministic spoke value, so the server and the client agree. */\nfunction shape(index: number, cycle: number): number {\n const x = Math.sin(index * 91.7 + cycle * 47.3) * 21_942.7\n return 30 + (x - Math.floor(x)) * 70\n}\n\n/**\n * The skeleton's spokes, re-rolled once per cycle.\n *\n * An interval rather than the shimmer's exit callback: a radar's skeleton\n * animates by MORPHING between shapes, which is Recharts' own transition, so\n * there is no travelling highlight to synchronise against.\n */\nfunction useLoadingShape(isLoading: boolean, points = LOADING_POINTS) {\n const [cycle, setCycle] = useState(0)\n\n useEffect(() => {\n if (!isLoading) return\n const timer = setInterval(() => setCycle((previous) => previous + 1), LOADING_CYCLE)\n return () => clearInterval(timer)\n }, [isLoading])\n\n return useMemo(\n () =>\n Array.from({ length: points }, (_, index) => ({\n spoke: String.fromCharCode(65 + index),\n value: shape(index, cycle),\n })),\n [cycle, points],\n )\n}\n\n/** The skeleton radar, morphing between shapes while the data is in flight. */\nfunction LoadingRadar() {\n const reduceMotion = useReducedMotion()\n\n return (\n <RechartsRadar\n dataKey=\"value\"\n stroke=\"currentColor\"\n strokeOpacity={0.3}\n strokeWidth={2}\n fill=\"currentColor\"\n fillOpacity={0.1}\n dot={false}\n className=\"text-(--ink)\"\n isAnimationActive={!reduceMotion}\n animationDuration={LOADING_CYCLE}\n animationEasing=\"ease-in-out\"\n />\n )\n}\n\nRadarChart.Radar = Radar\nRadarChart.Dot = Dot\nRadarChart.ActiveDot = ActiveDot\nRadarChart.PolarGrid = PolarGrid\nRadarChart.PolarAngleAxis = PolarAngleAxis\nRadarChart.PolarRadiusAxis = PolarRadiusAxis\nRadarChart.Tooltip = Tooltip\nRadarChart.Legend = Legend\nRadarChart.Background = Background\n\nexport default RadarChart\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/charts/RadarChart/RadarChart.tsx"],"sourcesContent":["'use client'\n\nimport {\n Children,\n createContext,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useMemo,\n useState,\n type ComponentProps,\n type FC,\n type ReactElement,\n type ReactNode,\n} from 'react'\nimport {\n PolarAngleAxis as RechartsPolarAngleAxis,\n PolarGrid as RechartsPolarGrid,\n PolarRadiusAxis as RechartsPolarRadiusAxis,\n Radar as RechartsRadar,\n RadarChart as RechartsRadarChart,\n} from 'recharts'\nimport { useReducedMotion } from 'motion/react'\nimport { ChartContainer, colorStops, type ChartConfig } from '../lib/chart'\nimport { ChartFigure } from '../lib/figure'\nimport { type ChartEmptyProps } from '../lib/empty'\nimport { useChartSelection } from '../lib/selection'\nimport { ChartBackground, type ChartBackgroundVariant } from '../lib/background'\nimport { ChartDot, type ChartDotVariant } from '../lib/dot'\nimport { ChartLegend, ChartLegendContent } from '../lib/legend'\nimport { ChartTooltip, ChartTooltipContent } from '../lib/tooltip'\nimport { LoadingIndicator } from '../lib/loading'\nimport { ColorStops, GlowFilter, SeriesGradient } from '../lib/paint'\nimport type { ChartLegendSlotProps, ChartTooltipSlotProps } from '../AreaChart/AreaChart'\n\nconst STROKE_WIDTH = 1\n/**\n * How solid a filled radar is, as a multiplier on `--chart-fill`.\n *\n * Above 1 because a radar's fill is the mark itself rather than a wash under a\n * line — it has to hold its shape where two of them overlap.\n */\nconst FILL_STRENGTH = 2.2\nconst LOADING_POINTS = 6\n\n/** One skeleton morph, in milliseconds. */\nconst LOADING_CYCLE = 1500\n\n/**\n * Whether a radar is filled or left as an outline.\n *\n * `lines` is the honest default above two series: filled polygons overlap, and\n * the reader is then judging areas through two layers of translucency, which is\n * the one thing a radar is worst at.\n */\nexport type RadarVariant = 'filled' | 'lines'\n\ninterface RadarChartContextValue {\n config: ChartConfig\n isLoading: boolean\n selectedDataKey: string | null\n selectDataKey: (dataKey: string | null) => void\n}\n\nconst RadarChartContext = createContext<RadarChartContextValue | null>(null)\n\nfunction useRadarChart(): RadarChartContextValue {\n const context = useContext(RadarChartContext)\n if (!context) throw new Error('A radar chart part must be rendered inside <RadarChart>')\n return context\n}\n\ntype ValidateKeys<TData, TConfig> = {\n [K in keyof TConfig]: K extends keyof TData ? ChartConfig[string] : never\n}\n\nexport interface RadarChartProps<\n TData extends Record<string, unknown>,\n TConfig extends Record<string, ChartConfig[string]>,\n> {\n config: TConfig & ValidateKeys<TData, TConfig>\n /** The rows the chart draws. One entry per point, bar or category. */\n data: TData[]\n /**\n * What the chart shows, in a sentence a reader could act on. Required, and\n * announced to a screen reader even when it is not printed.\n */\n title: string\n /** Prints the title above the plot instead of hiding it from sight. */\n showTitle?: boolean\n /** A line under the title — the unit, the window, the caveat. */\n description?: ReactNode\n /**\n * The composed parts — axes, grid, tooltip, legend, and the marks\n * themselves.\n */\n children: ReactNode\n /** Merged onto the figure, last, so a call site can size or space it. */\n className?: string\n /** Escape hatch onto the raw Recharts chart element. */\n chartProps?: ComponentProps<typeof RechartsRadarChart>\n /** The series lit on first render, when the chart keeps its own selection. */\n defaultSelectedDataKey?: string | null\n /**\n * The selected series, driven from outside.\n *\n * Give this and the chart follows it; leave it undefined and the chart keeps\n * its own, starting from `defaultSelectedDataKey`.\n */\n selectedDataKey?: string | null\n /** Fires when the selection changes, and with null when it is cleared. */\n onSelectionChange?: (selectedDataKey: string | null) => void\n /**\n * Swaps the marks for an animated skeleton, keeping the measured height so\n * the page does not jump when the data lands.\n */\n isLoading?: boolean\n /** How many points the skeleton draws. */\n loadingPoints?: number\n /** The row field naming each spoke. Used by the table view. */\n angleDataKey?: keyof TData & string\n /**\n * Drops the hidden table view. Only correct when the page prints the data\n * itself.\n */\n hideDataTable?: boolean\n /**\n * What the chart shows when it has nothing to draw. `false` keeps the empty\n * plot, for a chart whose emptiness is itself the reading.\n */\n empty?: ChartEmptyProps | false\n}\n\n/**\n * A profile across several named dimensions — the shape for \"what is this thing\n * strong and weak at\".\n *\n * It reads a SHAPE, not a set of values: the area a radar encloses depends on\n * the order the spokes happen to be in, so it is the wrong chart for comparing\n * magnitudes and the right one for recognising a silhouette. Two or three\n * series at most.\n *\n * @example\n * <RadarChart title=\"Team skills\" config={config} data={data} angleDataKey=\"skill\">\n * <RadarChart.PolarGrid />\n * <RadarChart.PolarAngleAxis dataKey=\"skill\" />\n * <RadarChart.Tooltip />\n * <RadarChart.Radar dataKey=\"current\" />\n * </RadarChart>\n */\nexport function RadarChart<\n TData extends Record<string, unknown>,\n TConfig extends Record<string, ChartConfig[string]>,\n>({\n config,\n data,\n title,\n showTitle,\n description,\n children,\n className,\n chartProps,\n defaultSelectedDataKey = null,\n selectedDataKey: controlledDataKey,\n onSelectionChange,\n isLoading = false,\n loadingPoints,\n angleDataKey,\n hideDataTable = false,\n empty,\n}: RadarChartProps<TData, TConfig>) {\n const chartId = useId().replace(/:/g, '')\n const loadingData = useLoadingShape(isLoading, loadingPoints)\n const [selectedDataKey, selectDataKey] = useChartSelection(\n controlledDataKey,\n defaultSelectedDataKey,\n onSelectionChange,\n )\n\n const context = useMemo<RadarChartContextValue>(\n () => ({ config, isLoading, selectedDataKey, selectDataKey }),\n [config, isLoading, selectDataKey, selectedDataKey],\n )\n\n return (\n <RadarChartContext.Provider value={context}>\n <ChartFigure\n title={title}\n showTitle={showTitle}\n description={description}\n className={className}\n table={\n hideDataTable\n ? false\n : {\n rows: data,\n rowKey: angleDataKey,\n columns: Object.entries(config).map(([key, series]) => ({\n key,\n label: series.label,\n })),\n }\n }\n isEmpty={!isLoading && data.length === 0}\n empty={empty}\n >\n <ChartContainer config={config}>\n <RechartsRadarChart id={chartId} data={isLoading ? loadingData : data} {...chartProps}>\n {children}\n {isLoading && <LoadingRadar />}\n </RechartsRadarChart>\n </ChartContainer>\n <LoadingIndicator isLoading={isLoading} />\n </ChartFigure>\n </RadarChartContext.Provider>\n )\n}\n\nexport interface RadarProps {\n dataKey: string\n variant?: RadarVariant\n /**\n * How solid the filled area is, 0–1. Left unset it follows `--chart-fill`,\n * which is what keeps it readable on both grounds.\n */\n fillOpacity?: number\n /** A halo around the outline — for the one series that is the point. */\n glowing?: boolean\n isClickable?: boolean\n children?: ReactNode\n radarProps?: Omit<ComponentProps<typeof RechartsRadar>, 'dataKey'>\n}\n\n/** One radar series, with its stroke, fill and glow scoped to its own id. */\nfunction Radar({\n dataKey,\n variant = 'filled',\n fillOpacity,\n glowing = false,\n isClickable = false,\n children,\n radarProps,\n}: RadarProps) {\n const { config, isLoading, selectedDataKey, selectDataKey } = useRadarChart()\n const id = useId().replace(/:/g, '')\n\n if (isLoading) return null\n\n const isSelected = selectedDataKey === null || selectedDataKey === dataKey\n const dimmed = isClickable && !isSelected\n // The fill recedes twice as far as the stroke. A dimmed outline still traces\n // its shape, which is what a radar is read by; a dimmed fill just muddies the\n // one that was picked.\n const opacity = { stroke: dimmed ? 0.2 : 1, fill: dimmed ? 0.1 : 1, dot: dimmed ? 0.2 : 1 }\n const isFilled = variant === 'filled'\n\n const { dot, activeDot } = resolveDots(children, id, dataKey, opacity.dot)\n // `--chart-fill` is a number, not a length, so it can be read into a\n // calc() the SVG attribute accepts — which is how the default follows the\n // ground without the component knowing which ground it is on. Dimming has\n // to multiply inside the calc() rather than scale the result: the value is\n // a string, and multiplying it in JS hands the attribute NaN.\n const dimFill = dimmed ? ` * ${opacity.fill}` : ''\n const filledStrength = `calc(var(--chart-fill) * ${FILL_STRENGTH}${dimFill})` as unknown as number\n const filledOpacity = fillOpacity === undefined ? filledStrength : fillOpacity * opacity.fill\n const stops = colorStops(config[dataKey])\n\n return (\n <>\n <RechartsRadar\n dataKey={dataKey}\n stroke={`url(#${id}-stroke-${dataKey})`}\n strokeOpacity={opacity.stroke}\n strokeWidth={STROKE_WIDTH}\n fill={isFilled ? `url(#${id}-fill-${dataKey})` : 'none'}\n fillOpacity={isFilled ? filledOpacity : 0}\n dot={dot}\n activeDot={activeDot}\n filter={glowing ? `url(#${id}-glow-${dataKey})` : undefined}\n className=\"transition-opacity duration-(--duration-base)\"\n style={isClickable ? { cursor: 'pointer' } : undefined}\n onClick={() => {\n if (!isClickable) return\n selectDataKey(selectedDataKey === dataKey ? null : dataKey)\n }}\n {...radarProps}\n />\n <defs>\n {/* The dots read this one; the stroke and fill get their own below. */}\n <SeriesGradient id={id} dataKey={dataKey} config={config} />\n <linearGradient id={`${id}-stroke-${dataKey}`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n <ColorStops dataKey={dataKey} stops={stops} />\n </linearGradient>\n {isFilled && (\n // Radial, and fading outward: a flat fill makes the outer ring — the\n // part carrying the shape — the least readable part of the mark.\n <radialGradient id={`${id}-fill-${dataKey}`} cx=\"50%\" cy=\"50%\" r=\"50%\">\n <ColorStops\n dataKey={dataKey}\n stops={stops}\n opacities={\n stops === 1 ? [0.8, 0.3] : Array.from({ length: stops }, (_, i) => (i === 0 ? 0.8 : 0.3))\n }\n />\n </radialGradient>\n )}\n {glowing && <GlowFilter id={id} dataKey={dataKey} spread={4} intensity={0.6} />}\n </defs>\n </>\n )\n}\n\nexport interface DotProps {\n variant?: ChartDotVariant\n}\n\n/** The resting point marker for the radar it is composed inside. A slot. */\nconst Dot: FC<DotProps> = () => null\n\n/** The marker under the pointer. */\nconst ActiveDot: FC<DotProps> = () => null\n\nfunction resolveDots(children: ReactNode, id: string, dataKey: string, dotOpacity: number) {\n let dot: ComponentProps<typeof RechartsRadar>['dot'] = false\n let activeDot: ComponentProps<typeof RechartsRadar>['activeDot'] = false\n\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return\n const { variant } = (child as ReactElement<DotProps>).props\n\n if (child.type === Dot) {\n dot = <ChartDot variant={variant} dataKey={dataKey} chartId={id} fillOpacity={dotOpacity} />\n }\n if (child.type === ActiveDot) {\n activeDot = (\n <ChartDot variant={variant} dataKey={dataKey} chartId={id} fillOpacity={dotOpacity} />\n )\n }\n })\n\n return { dot, activeDot }\n}\n\n/**\n * The web behind the marks. `gridType=\"circle\"` swaps the polygon for rings,\n * which reads as a scale rather than as a shape and suits a single series.\n */\nfunction PolarGrid({\n gridType = 'polygon',\n stroke = 'var(--chart-grid)',\n strokeDasharray = '3 4',\n ...rest\n}: ComponentProps<typeof RechartsPolarGrid>) {\n return (\n <RechartsPolarGrid\n gridType={gridType}\n stroke={stroke}\n strokeDasharray={strokeDasharray}\n {...rest}\n />\n )\n}\n\n/** The spoke labels around the perimeter. */\nfunction PolarAngleAxis({\n tick = { fill: 'var(--chart-axis)', fontSize: 12 },\n tickLine = false,\n ...rest\n}: ComponentProps<typeof RechartsPolarAngleAxis>) {\n const { isLoading } = useRadarChart()\n if (isLoading) return null\n return <RechartsPolarAngleAxis tick={tick} tickLine={tickLine} {...rest} />\n}\n\n/** The scale running out from the centre. */\nfunction PolarRadiusAxis({\n tick = { fill: 'var(--chart-axis)', fontSize: 10 },\n tickLine = false,\n axisLine = false,\n ...rest\n}: ComponentProps<typeof RechartsPolarRadiusAxis>) {\n const { isLoading } = useRadarChart()\n if (isLoading) return null\n return (\n <RechartsPolarRadiusAxis tick={tick} tickLine={tickLine} axisLine={axisLine} {...rest} />\n )\n}\n\n/** The hover panel. No crosshair: there is no straight axis to run one along. */\nfunction Tooltip({ variant, roundness, defaultIndex }: ChartTooltipSlotProps) {\n const { isLoading, selectedDataKey } = useRadarChart()\n if (isLoading) return null\n\n return (\n <ChartTooltip\n defaultIndex={defaultIndex}\n cursor={false}\n content={\n <ChartTooltipContent selected={selectedDataKey} roundness={roundness} variant={variant} />\n }\n />\n )\n}\n\n/** The key. Under the web and centred, since the spokes name dimensions, not series. */\nfunction Legend({\n variant,\n align = 'center',\n verticalAlign = 'bottom',\n isClickable = false,\n}: ChartLegendSlotProps) {\n const { isLoading, selectedDataKey, selectDataKey } = useRadarChart()\n if (isLoading) return null\n\n return (\n <ChartLegend\n verticalAlign={verticalAlign}\n align={align}\n content={\n <ChartLegendContent\n selected={selectedDataKey}\n onSelectChange={selectDataKey}\n isClickable={isClickable}\n variant={variant}\n />\n }\n />\n )\n}\n\n/** The decorative plate behind the web. */\nfunction Background({ variant = 'dots' }: { variant?: ChartBackgroundVariant }) {\n const { isLoading } = useRadarChart()\n if (isLoading) return null\n return <ChartBackground variant={variant} />\n}\n\n/** A deterministic spoke value, so the server and the client agree. */\nfunction shape(index: number, cycle: number): number {\n const x = Math.sin(index * 91.7 + cycle * 47.3) * 21_942.7\n return 30 + (x - Math.floor(x)) * 70\n}\n\n/**\n * The skeleton's spokes, re-rolled once per cycle.\n *\n * An interval rather than the shimmer's exit callback: a radar's skeleton\n * animates by MORPHING between shapes, which is Recharts' own transition, so\n * there is no travelling highlight to synchronise against.\n */\nfunction useLoadingShape(isLoading: boolean, points = LOADING_POINTS) {\n const [cycle, setCycle] = useState(0)\n\n useEffect(() => {\n if (!isLoading) return\n const timer = setInterval(() => setCycle((previous) => previous + 1), LOADING_CYCLE)\n return () => clearInterval(timer)\n }, [isLoading])\n\n return useMemo(\n () =>\n Array.from({ length: points }, (_, index) => ({\n spoke: String.fromCharCode(65 + index),\n value: shape(index, cycle),\n })),\n [cycle, points],\n )\n}\n\n/** The skeleton radar, morphing between shapes while the data is in flight. */\nfunction LoadingRadar() {\n const reduceMotion = useReducedMotion()\n\n return (\n <RechartsRadar\n dataKey=\"value\"\n stroke=\"currentColor\"\n strokeOpacity={0.3}\n strokeWidth={2}\n fill=\"currentColor\"\n fillOpacity={0.1}\n dot={false}\n className=\"text-(--ink)\"\n isAnimationActive={!reduceMotion}\n animationDuration={LOADING_CYCLE}\n animationEasing=\"ease-in-out\"\n />\n )\n}\n\nRadarChart.Radar = Radar\nRadarChart.Dot = Dot\nRadarChart.ActiveDot = ActiveDot\nRadarChart.PolarGrid = PolarGrid\nRadarChart.PolarAngleAxis = PolarAngleAxis\nRadarChart.PolarRadiusAxis = PolarRadiusAxis\nRadarChart.Tooltip = Tooltip\nRadarChart.Legend = Legend\nRadarChart.Background = Background\n\nexport default RadarChart\n"],"mappings":"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","names":[]}
@@ -85,6 +85,11 @@ function ChartContainer({
85
85
  // 1200px it is a 675px-tall plot with six points in it. The cap is
86
86
  // what stops the aspect ratio running away with a wide container,
87
87
  // and `min-h` keeps it from collapsing in a narrow one.
88
+ //
89
+ // With a footer the shape moves DOWN one element rather than being
90
+ // dropped — see the container below. Sizing this box 16:9 would hand
91
+ // the footer's height to the footer out of the plot's share, which is
92
+ // a plot that shrinks as its brush grows.
88
93
  !footer && "aspect-video max-h-[26rem] min-h-[13rem]",
89
94
  // Recharts' own defaults, re-pointed at the token layer.
90
95
  "[&_.recharts-cartesian-axis-tick_text]:fill-(--chart-axis)",
@@ -104,7 +109,17 @@ function ChartContainer({
104
109
  ...rest,
105
110
  children: [
106
111
  /* @__PURE__ */ jsx(ChartStyle, { id: chartId, config }),
107
- /* @__PURE__ */ jsx(ResponsiveContainer, { className: "min-h-0 w-full flex-1", initialDimension, children }),
112
+ /* @__PURE__ */ jsx(
113
+ ResponsiveContainer,
114
+ {
115
+ className: cn(
116
+ "w-full flex-1",
117
+ footer ? "aspect-video max-h-[26rem] min-h-[13rem]" : "min-h-0"
118
+ ),
119
+ initialDimension,
120
+ children
121
+ }
122
+ ),
108
123
  footer
109
124
  ]
110
125
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/charts/lib/chart.tsx"],"sourcesContent":["'use client'\n\nimport {\n createContext,\n useContext,\n useId,\n useMemo,\n type ComponentProps,\n type ComponentType,\n type ReactNode,\n} from 'react'\nimport { ResponsiveContainer } from 'recharts'\nimport { cn } from '../../lib/cn'\n\n/**\n * How many series the system will paint before it refuses.\n *\n * Eight is not a rendering limit — it is the width of the validated ramp in\n * `tokens.css`. A ninth series has no slot, and generating one would mean\n * inventing a colour nobody checked against the eighth. The catalogued answer\n * to \"I have twelve categories\" is to fold the tail into an \"Other\" series or\n * to facet, and this constant is where that decision is enforced rather than\n * left to whoever is looking at the chart.\n */\nexport const SERIES_SLOTS = 8\n\n/** Theme name → the selector its block is written under. */\nconst THEMES = { light: '', dark: \"[data-mode='dark']\" } as const\n\ntype ThemeKey = keyof typeof THEMES\n\nconst THEME_KEYS = Object.keys(THEMES) as ThemeKey[]\n\n/** Colours for one series, per theme. At least one theme must be given. */\ntype ThemeColors = {\n [K in ThemeKey]: Required<Pick<Record<ThemeKey, string[]>, K>> &\n Partial<Omit<Record<ThemeKey, string[]>, K>>\n}[ThemeKey]\n\nexport interface ChartSeries {\n /**\n * What the legend, the tooltip and the hidden data table call this series.\n *\n * Required, unlike the shape this was ported from. Colour is never the only\n * carrier of identity in this system, and a legend entry with no label is a\n * swatch that means nothing — least of all in the monochrome default, where\n * two series differ by a step of grey and a texture.\n */\n label: ReactNode\n /** Drawn in place of the legend/tooltip swatch. Decorative; label still wins. */\n icon?: ComponentType\n /**\n * Explicit colours for this series, overriding its slot on the series ramp.\n *\n * Reach for this only when the data itself carries a colour the reader\n * already knows — a brand, a traffic light, a map key. For \"I want my chart\n * to have colours\", set `data-chart-palette=\"chroma\"` on any ancestor: that\n * swaps the whole ramp for a palette validated against both grounds, which a\n * hand-picked array is not.\n *\n * More than one colour paints the series as a horizontal gradient across\n * those stops. Values are plain CSS colours; anything that could break out of\n * a declaration is rejected at the boundary rather than injected.\n */\n colors?: ThemeColors\n}\n\n/** Series keys → how each is labelled and painted. Declaration order is slot order. */\nexport type ChartConfig = Record<string, ChartSeries>\n\ninterface ChartContextValue {\n config: ChartConfig\n}\n\nconst ChartContext = createContext<ChartContextValue | null>(null)\n\n/** The chart config of the nearest chart root. Throws outside one. */\nexport function useChart(): ChartContextValue {\n const context = useContext(ChartContext)\n if (!context) throw new Error('A chart part must be rendered inside a chart root')\n return context\n}\n\n/**\n * A CSS colour, or something that would escape the declaration it is written\n * into.\n *\n * `ChartStyle` writes consumer-supplied values into a `<style>` element, which\n * makes this a system boundary in the security sense: without the check, a\n * `colors` array carrying `red; } html { display: none } .x {` is not a colour,\n * it is a stylesheet. The allowed set is deliberately shaped around what a\n * colour is made of — identifiers, digits, `#`, brackets, commas, percent — so\n * `var(--x)`, `oklch(…)` and `color-mix(in srgb, …)` all pass and no `;`, `{`,\n * `}`, `<`, `\\` or comment sequence does.\n */\nconst CSS_COLOR = /^[A-Za-z0-9#(),.%\\-\\s_]{1,120}$/\n\nfunction isSafeColor(value: unknown): value is string {\n return typeof value === 'string' && CSS_COLOR.test(value) && !value.includes('/*')\n}\n\n/**\n * Distributes fewer colours than slots, giving the surplus to the last stops.\n * Two colours over four slots read as `[a, a, b, b]`; three over four as\n * `[a, b, c, c]`.\n */\nfunction distribute(colors: string[], slots: number): string[] {\n if (colors.length >= slots) return colors.slice(0, slots)\n\n const base = Math.floor(slots / colors.length)\n const extra = slots % colors.length\n const out: string[] = []\n colors.forEach((color, index) => {\n const count = base + (index >= colors.length - extra ? 1 : 0)\n for (let n = 0; n < count; n += 1) out.push(color)\n })\n return out\n}\n\n/** How many gradient stops a series paints with — one means a flat fill. */\nexport function colorStops(series: ChartSeries | undefined): number {\n if (!series?.colors) return 1\n return Math.max(1, ...THEME_KEYS.map((theme) => series.colors?.[theme]?.length ?? 0))\n}\n\n/**\n * The custom properties one chart instance paints from.\n *\n * Every series resolves to `--color-<key>-<n>`, and every mark, swatch and\n * gradient stop in the package reads only those names. That indirection is what\n * lets the same chart be repainted by an ancestor attribute — a theme, a\n * palette, dark mode — without a component knowing anything about colour.\n *\n * A series with no `colors` takes its slot on the ramp, in declaration order.\n * That is the \"assign in fixed order, never cycle\" rule expressed as code: the\n * fourth series is `--series-4` whether or not the first three are on screen,\n * so hiding a series never repaints the survivors.\n */\nfunction ChartStyle({ id, config }: { id: string; config: ChartConfig }) {\n const css = useMemo(() => {\n const entries = Object.entries(config)\n\n const rules = (theme: ThemeKey) =>\n entries\n .flatMap(([key, series], slot) => {\n const stops = colorStops(series)\n const slotColor = `var(--series-${(slot % SERIES_SLOTS) + 1})`\n const given = series.colors?.[theme] ?? series.colors?.light ?? series.colors?.dark\n const safe = given?.filter(isSafeColor) ?? []\n // A rejected colour falls back to the ramp rather than to nothing: an\n // unpainted series is invisible, which is a worse failure than an\n // ignored value and a harder one to notice.\n const colors = safe.length > 0 ? safe : [slotColor]\n return distribute(colors, stops).map(\n (color, index) => ` --color-${cssName(key)}-${index}: ${color};`,\n )\n })\n .join('\\n')\n\n return THEME_KEYS.map((theme) => {\n const body = rules(theme)\n if (!body) return ''\n return `${THEMES[theme]} [data-chart='${id}'] {\\n${body}\\n}`\n })\n .filter(Boolean)\n .join('\\n')\n }, [id, config])\n\n if (!css) return null\n\n return <style>{css}</style>\n}\n\n/**\n * A series key, reduced to what may appear in a custom property name. Keys come\n * from consumer data, so the same boundary argument as `isSafeColor` applies —\n * here it is the property NAME rather than its value.\n */\nexport function cssName(key: string): string {\n return key.replace(/[^A-Za-z0-9_-]/g, '-')\n}\n\nexport interface ChartContainerProps\n extends Omit<ComponentProps<'div'>, 'children'>,\n Pick<\n ComponentProps<typeof ResponsiveContainer>,\n 'initialDimension' | 'minHeight' | 'minWidth' | 'maxHeight' | 'height' | 'width' | 'children'\n > {\n config: ChartConfig\n /** Rendered under the plot, inside the same measured box — the brush lives here. */\n footer?: ReactNode\n}\n\n/**\n * The measured box every chart is drawn into, and the only place Recharts'\n * hard-coded greys are re-pointed at system tokens.\n *\n * Recharts paints its axes, grid, cursor and radial track with literal `#ccc`\n * and `#fff`. The selectors below catch each of those and hand it a token, so a\n * chart follows light/dark and a surface theme without a consumer configuring\n * anything — and so nothing in the package has to pass a colour down by prop.\n */\nexport function ChartContainer({\n id,\n config,\n initialDimension = { width: 320, height: 200 },\n className,\n children,\n footer,\n ...rest\n}: ChartContainerProps) {\n const generated = useId().replace(/:/g, '')\n const chartId = `chart-${id ?? generated}`\n\n return (\n <ChartContext.Provider value={{ config }}>\n <div\n data-slot=\"chart\"\n data-chart={chartId}\n className={cn(\n 'relative flex min-h-0 w-full flex-1 flex-col justify-center text-xs',\n // 16:9 is right for a card and wrong for a full-width panel: at\n // 1200px it is a 675px-tall plot with six points in it. The cap is\n // what stops the aspect ratio running away with a wide container,\n // and `min-h` keeps it from collapsing in a narrow one.\n !footer && 'aspect-video max-h-[26rem] min-h-[13rem]',\n // Recharts' own defaults, re-pointed at the token layer.\n \"[&_.recharts-cartesian-axis-tick_text]:fill-(--chart-axis)\",\n \"[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-(--chart-grid)\",\n '[&_.recharts-curve.recharts-tooltip-cursor]:stroke-(--chart-cursor)',\n \"[&_.recharts-polar-grid_[stroke='#ccc']]:stroke-(--chart-grid)\",\n '[&_.recharts-radial-bar-background-sector]:fill-(--chart-track)',\n '[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-(--chart-track)',\n \"[&_.recharts-reference-line_[stroke='#ccc']]:stroke-(--chart-grid)\",\n \"[&_.recharts-dot[stroke='#fff']]:stroke-transparent\",\n \"[&_.recharts-sector[stroke='#fff']]:stroke-transparent\",\n // Recharts draws its own focus ring; the system draws one focus ring,\n // in one place, and this is how the duplicate is removed.\n '[&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden',\n className,\n )}\n {...rest}\n >\n <ChartStyle id={chartId} config={config} />\n <ResponsiveContainer className=\"min-h-0 w-full flex-1\" initialDimension={initialDimension}>\n {children}\n </ResponsiveContainer>\n {footer}\n </div>\n </ChartContext.Provider>\n )\n}\n\n/**\n * Reads the config entry behind a Recharts payload item.\n *\n * Recharts hands the same series different names in different chart types — a\n * pie sector's key is on `payload.payload[nameKey]`, a bar's is `dataKey` — so\n * this is the one place that disagreement is resolved.\n */\nexport function seriesFromPayload(\n config: ChartConfig,\n payload: unknown,\n key: string,\n): ChartSeries | undefined {\n if (typeof payload !== 'object' || payload === null) return undefined\n\n const nested =\n 'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null\n ? (payload.payload as Record<string, unknown>)\n : undefined\n\n let name = key\n const direct = (payload as Record<string, unknown>)[key]\n if (typeof direct === 'string') name = direct\n else if (nested && typeof nested[key] === 'string') name = nested[key] as string\n\n return name in config ? config[name] : config[key]\n}\n\n/** Formats an expanded stack's axis, where 0–1 means 0–100%. */\nexport function percentTick(value: number): string {\n return `${Math.round(value * 100)}%`\n}\n"],"mappings":";AA0KS,cA8CH,YA9CG;AAxKT;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP,SAAS,2BAA2B;AACpC,SAAS,UAAU;AAYZ,MAAM,eAAe;AAG5B,MAAM,SAAS,EAAE,OAAO,IAAI,MAAM,qBAAqB;AAIvD,MAAM,aAAa,OAAO,KAAK,MAAM;AA2CrC,MAAM,eAAe,cAAwC,IAAI;AAG1D,SAAS,WAA8B;AAC5C,QAAM,UAAU,WAAW,YAAY;AACvC,MAAI,CAAC,QAAS,OAAM,IAAI,MAAM,mDAAmD;AACjF,SAAO;AACT;AAcA,MAAM,YAAY;AAElB,SAAS,YAAY,OAAiC;AACpD,SAAO,OAAO,UAAU,YAAY,UAAU,KAAK,KAAK,KAAK,CAAC,MAAM,SAAS,IAAI;AACnF;AAOA,SAAS,WAAW,QAAkB,OAAyB;AAC7D,MAAI,OAAO,UAAU,MAAO,QAAO,OAAO,MAAM,GAAG,KAAK;AAExD,QAAM,OAAO,KAAK,MAAM,QAAQ,OAAO,MAAM;AAC7C,QAAM,QAAQ,QAAQ,OAAO;AAC7B,QAAM,MAAgB,CAAC;AACvB,SAAO,QAAQ,CAAC,OAAO,UAAU;AAC/B,UAAM,QAAQ,QAAQ,SAAS,OAAO,SAAS,QAAQ,IAAI;AAC3D,aAAS,IAAI,GAAG,IAAI,OAAO,KAAK,EAAG,KAAI,KAAK,KAAK;AAAA,EACnD,CAAC;AACD,SAAO;AACT;AAGO,SAAS,WAAW,QAAyC;AAClE,MAAI,CAAC,QAAQ,OAAQ,QAAO;AAC5B,SAAO,KAAK,IAAI,GAAG,GAAG,WAAW,IAAI,CAAC,UAAU,OAAO,SAAS,KAAK,GAAG,UAAU,CAAC,CAAC;AACtF;AAeA,SAAS,WAAW,EAAE,IAAI,OAAO,GAAwC;AACvE,QAAM,MAAM,QAAQ,MAAM;AACxB,UAAM,UAAU,OAAO,QAAQ,MAAM;AAErC,UAAM,QAAQ,CAAC,UACb,QACG,QAAQ,CAAC,CAAC,KAAK,MAAM,GAAG,SAAS;AAChC,YAAM,QAAQ,WAAW,MAAM;AAC/B,YAAM,YAAY,gBAAiB,OAAO,eAAgB,CAAC;AAC3D,YAAM,QAAQ,OAAO,SAAS,KAAK,KAAK,OAAO,QAAQ,SAAS,OAAO,QAAQ;AAC/E,YAAM,OAAO,OAAO,OAAO,WAAW,KAAK,CAAC;AAI5C,YAAM,SAAS,KAAK,SAAS,IAAI,OAAO,CAAC,SAAS;AAClD,aAAO,WAAW,QAAQ,KAAK,EAAE;AAAA,QAC/B,CAAC,OAAO,UAAU,aAAa,QAAQ,GAAG,CAAC,IAAI,KAAK,KAAK,KAAK;AAAA,MAChE;AAAA,IACF,CAAC,EACA,KAAK,IAAI;AAEd,WAAO,WAAW,IAAI,CAAC,UAAU;AAC/B,YAAM,OAAO,MAAM,KAAK;AACxB,UAAI,CAAC,KAAM,QAAO;AAClB,aAAO,GAAG,OAAO,KAAK,CAAC,iBAAiB,EAAE;AAAA,EAAS,IAAI;AAAA;AAAA,IACzD,CAAC,EACE,OAAO,OAAO,EACd,KAAK,IAAI;AAAA,EACd,GAAG,CAAC,IAAI,MAAM,CAAC;AAEf,MAAI,CAAC,IAAK,QAAO;AAEjB,SAAO,oBAAC,WAAO,eAAI;AACrB;AAOO,SAAS,QAAQ,KAAqB;AAC3C,SAAO,IAAI,QAAQ,mBAAmB,GAAG;AAC3C;AAsBO,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA,mBAAmB,EAAE,OAAO,KAAK,QAAQ,IAAI;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAwB;AACtB,QAAM,YAAY,MAAM,EAAE,QAAQ,MAAM,EAAE;AAC1C,QAAM,UAAU,SAAS,MAAM,SAAS;AAExC,SACE,oBAAC,aAAa,UAAb,EAAsB,OAAO,EAAE,OAAO,GACrC;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,cAAY;AAAA,MACZ,WAAW;AAAA,QACT;AAAA;AAAA;AAAA;AAAA;AAAA,QAKA,CAAC,UAAU;AAAA;AAAA,QAEX;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA;AAAA;AAAA,QAGA;AAAA,QACA;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEJ;AAAA,4BAAC,cAAW,IAAI,SAAS,QAAgB;AAAA,QACzC,oBAAC,uBAAoB,WAAU,yBAAwB,kBACpD,UACH;AAAA,QACC;AAAA;AAAA;AAAA,EACH,GACF;AAEJ;AASO,SAAS,kBACd,QACA,SACA,KACyB;AACzB,MAAI,OAAO,YAAY,YAAY,YAAY,KAAM,QAAO;AAE5D,QAAM,SACJ,aAAa,WAAW,OAAO,QAAQ,YAAY,YAAY,QAAQ,YAAY,OAC9E,QAAQ,UACT;AAEN,MAAI,OAAO;AACX,QAAM,SAAU,QAAoC,GAAG;AACvD,MAAI,OAAO,WAAW,SAAU,QAAO;AAAA,WAC9B,UAAU,OAAO,OAAO,GAAG,MAAM,SAAU,QAAO,OAAO,GAAG;AAErE,SAAO,QAAQ,SAAS,OAAO,IAAI,IAAI,OAAO,GAAG;AACnD;AAGO,SAAS,YAAY,OAAuB;AACjD,SAAO,GAAG,KAAK,MAAM,QAAQ,GAAG,CAAC;AACnC;","names":[]}
1
+ {"version":3,"sources":["../../../src/charts/lib/chart.tsx"],"sourcesContent":["'use client'\n\nimport {\n createContext,\n useContext,\n useId,\n useMemo,\n type ComponentProps,\n type ComponentType,\n type ReactNode,\n} from 'react'\nimport { ResponsiveContainer } from 'recharts'\nimport { cn } from '../../lib/cn'\n\n/**\n * How many series the system will paint before it refuses.\n *\n * Eight is not a rendering limit — it is the width of the validated ramp in\n * `tokens.css`. A ninth series has no slot, and generating one would mean\n * inventing a colour nobody checked against the eighth. The catalogued answer\n * to \"I have twelve categories\" is to fold the tail into an \"Other\" series or\n * to facet, and this constant is where that decision is enforced rather than\n * left to whoever is looking at the chart.\n */\nexport const SERIES_SLOTS = 8\n\n/** Theme name → the selector its block is written under. */\nconst THEMES = { light: '', dark: \"[data-mode='dark']\" } as const\n\ntype ThemeKey = keyof typeof THEMES\n\nconst THEME_KEYS = Object.keys(THEMES) as ThemeKey[]\n\n/** Colours for one series, per theme. At least one theme must be given. */\ntype ThemeColors = {\n [K in ThemeKey]: Required<Pick<Record<ThemeKey, string[]>, K>> &\n Partial<Omit<Record<ThemeKey, string[]>, K>>\n}[ThemeKey]\n\nexport interface ChartSeries {\n /**\n * What the legend, the tooltip and the hidden data table call this series.\n *\n * Required, unlike the shape this was ported from. Colour is never the only\n * carrier of identity in this system, and a legend entry with no label is a\n * swatch that means nothing — least of all in the monochrome default, where\n * two series differ by a step of grey and a texture.\n */\n label: ReactNode\n /** Drawn in place of the legend/tooltip swatch. Decorative; label still wins. */\n icon?: ComponentType\n /**\n * Explicit colours for this series, overriding its slot on the series ramp.\n *\n * Reach for this only when the data itself carries a colour the reader\n * already knows — a brand, a traffic light, a map key. For \"I want my chart\n * to have colours\", set `data-chart-palette=\"chroma\"` on any ancestor: that\n * swaps the whole ramp for a palette validated against both grounds, which a\n * hand-picked array is not.\n *\n * More than one colour paints the series as a horizontal gradient across\n * those stops. Values are plain CSS colours; anything that could break out of\n * a declaration is rejected at the boundary rather than injected.\n */\n colors?: ThemeColors\n}\n\n/** Series keys → how each is labelled and painted. Declaration order is slot order. */\nexport type ChartConfig = Record<string, ChartSeries>\n\ninterface ChartContextValue {\n config: ChartConfig\n}\n\nconst ChartContext = createContext<ChartContextValue | null>(null)\n\n/** The chart config of the nearest chart root. Throws outside one. */\nexport function useChart(): ChartContextValue {\n const context = useContext(ChartContext)\n if (!context) throw new Error('A chart part must be rendered inside a chart root')\n return context\n}\n\n/**\n * A CSS colour, or something that would escape the declaration it is written\n * into.\n *\n * `ChartStyle` writes consumer-supplied values into a `<style>` element, which\n * makes this a system boundary in the security sense: without the check, a\n * `colors` array carrying `red; } html { display: none } .x {` is not a colour,\n * it is a stylesheet. The allowed set is deliberately shaped around what a\n * colour is made of — identifiers, digits, `#`, brackets, commas, percent — so\n * `var(--x)`, `oklch(…)` and `color-mix(in srgb, …)` all pass and no `;`, `{`,\n * `}`, `<`, `\\` or comment sequence does.\n */\nconst CSS_COLOR = /^[A-Za-z0-9#(),.%\\-\\s_]{1,120}$/\n\nfunction isSafeColor(value: unknown): value is string {\n return typeof value === 'string' && CSS_COLOR.test(value) && !value.includes('/*')\n}\n\n/**\n * Distributes fewer colours than slots, giving the surplus to the last stops.\n * Two colours over four slots read as `[a, a, b, b]`; three over four as\n * `[a, b, c, c]`.\n */\nfunction distribute(colors: string[], slots: number): string[] {\n if (colors.length >= slots) return colors.slice(0, slots)\n\n const base = Math.floor(slots / colors.length)\n const extra = slots % colors.length\n const out: string[] = []\n colors.forEach((color, index) => {\n const count = base + (index >= colors.length - extra ? 1 : 0)\n for (let n = 0; n < count; n += 1) out.push(color)\n })\n return out\n}\n\n/** How many gradient stops a series paints with — one means a flat fill. */\nexport function colorStops(series: ChartSeries | undefined): number {\n if (!series?.colors) return 1\n return Math.max(1, ...THEME_KEYS.map((theme) => series.colors?.[theme]?.length ?? 0))\n}\n\n/**\n * The custom properties one chart instance paints from.\n *\n * Every series resolves to `--color-<key>-<n>`, and every mark, swatch and\n * gradient stop in the package reads only those names. That indirection is what\n * lets the same chart be repainted by an ancestor attribute — a theme, a\n * palette, dark mode — without a component knowing anything about colour.\n *\n * A series with no `colors` takes its slot on the ramp, in declaration order.\n * That is the \"assign in fixed order, never cycle\" rule expressed as code: the\n * fourth series is `--series-4` whether or not the first three are on screen,\n * so hiding a series never repaints the survivors.\n */\nfunction ChartStyle({ id, config }: { id: string; config: ChartConfig }) {\n const css = useMemo(() => {\n const entries = Object.entries(config)\n\n const rules = (theme: ThemeKey) =>\n entries\n .flatMap(([key, series], slot) => {\n const stops = colorStops(series)\n const slotColor = `var(--series-${(slot % SERIES_SLOTS) + 1})`\n const given = series.colors?.[theme] ?? series.colors?.light ?? series.colors?.dark\n const safe = given?.filter(isSafeColor) ?? []\n // A rejected colour falls back to the ramp rather than to nothing: an\n // unpainted series is invisible, which is a worse failure than an\n // ignored value and a harder one to notice.\n const colors = safe.length > 0 ? safe : [slotColor]\n return distribute(colors, stops).map(\n (color, index) => ` --color-${cssName(key)}-${index}: ${color};`,\n )\n })\n .join('\\n')\n\n return THEME_KEYS.map((theme) => {\n const body = rules(theme)\n if (!body) return ''\n return `${THEMES[theme]} [data-chart='${id}'] {\\n${body}\\n}`\n })\n .filter(Boolean)\n .join('\\n')\n }, [id, config])\n\n if (!css) return null\n\n return <style>{css}</style>\n}\n\n/**\n * A series key, reduced to what may appear in a custom property name. Keys come\n * from consumer data, so the same boundary argument as `isSafeColor` applies —\n * here it is the property NAME rather than its value.\n */\nexport function cssName(key: string): string {\n return key.replace(/[^A-Za-z0-9_-]/g, '-')\n}\n\nexport interface ChartContainerProps\n extends Omit<ComponentProps<'div'>, 'children'>,\n Pick<\n ComponentProps<typeof ResponsiveContainer>,\n 'initialDimension' | 'minHeight' | 'minWidth' | 'maxHeight' | 'height' | 'width' | 'children'\n > {\n config: ChartConfig\n /** Rendered under the plot, inside the same measured box — the brush lives here. */\n footer?: ReactNode\n}\n\n/**\n * The measured box every chart is drawn into, and the only place Recharts'\n * hard-coded greys are re-pointed at system tokens.\n *\n * Recharts paints its axes, grid, cursor and radial track with literal `#ccc`\n * and `#fff`. The selectors below catch each of those and hand it a token, so a\n * chart follows light/dark and a surface theme without a consumer configuring\n * anything — and so nothing in the package has to pass a colour down by prop.\n */\nexport function ChartContainer({\n id,\n config,\n initialDimension = { width: 320, height: 200 },\n className,\n children,\n footer,\n ...rest\n}: ChartContainerProps) {\n const generated = useId().replace(/:/g, '')\n const chartId = `chart-${id ?? generated}`\n\n return (\n <ChartContext.Provider value={{ config }}>\n <div\n data-slot=\"chart\"\n data-chart={chartId}\n className={cn(\n 'relative flex min-h-0 w-full flex-1 flex-col justify-center text-xs',\n // 16:9 is right for a card and wrong for a full-width panel: at\n // 1200px it is a 675px-tall plot with six points in it. The cap is\n // what stops the aspect ratio running away with a wide container,\n // and `min-h` keeps it from collapsing in a narrow one.\n //\n // With a footer the shape moves DOWN one element rather than being\n // dropped — see the container below. Sizing this box 16:9 would hand\n // the footer's height to the footer out of the plot's share, which is\n // a plot that shrinks as its brush grows.\n !footer && 'aspect-video max-h-[26rem] min-h-[13rem]',\n // Recharts' own defaults, re-pointed at the token layer.\n \"[&_.recharts-cartesian-axis-tick_text]:fill-(--chart-axis)\",\n \"[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-(--chart-grid)\",\n '[&_.recharts-curve.recharts-tooltip-cursor]:stroke-(--chart-cursor)',\n \"[&_.recharts-polar-grid_[stroke='#ccc']]:stroke-(--chart-grid)\",\n '[&_.recharts-radial-bar-background-sector]:fill-(--chart-track)',\n '[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-(--chart-track)',\n \"[&_.recharts-reference-line_[stroke='#ccc']]:stroke-(--chart-grid)\",\n \"[&_.recharts-dot[stroke='#fff']]:stroke-transparent\",\n \"[&_.recharts-sector[stroke='#fff']]:stroke-transparent\",\n // Recharts draws its own focus ring; the system draws one focus ring,\n // in one place, and this is how the duplicate is removed.\n '[&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden',\n className,\n )}\n {...rest}\n >\n <ChartStyle id={chartId} config={config} />\n {/* The plot carries the shape when there is a footer under it.\n It used to carry nothing: the outer box dropped `aspect-video` AND\n `min-h` the moment a footer existed, on the reasoning that a fixed\n aspect plus a footer overflows — which is true of the aspect and\n not of the floor. So a chart with a brush had a plot with no\n intrinsic height at all, and in any container that sizes to its\n content the plot measured zero and only the brush's own 56px\n survived. Recharts says so out loud — \"width(-1) and height(-1) of\n chart should be greater than 0\" — into a console nobody reads. */}\n <ResponsiveContainer\n className={cn(\n 'w-full flex-1',\n footer ? 'aspect-video max-h-[26rem] min-h-[13rem]' : 'min-h-0',\n )}\n initialDimension={initialDimension}\n >\n {children}\n </ResponsiveContainer>\n {footer}\n </div>\n </ChartContext.Provider>\n )\n}\n\n/**\n * Reads the config entry behind a Recharts payload item.\n *\n * Recharts hands the same series different names in different chart types — a\n * pie sector's key is on `payload.payload[nameKey]`, a bar's is `dataKey` — so\n * this is the one place that disagreement is resolved.\n */\nexport function seriesFromPayload(\n config: ChartConfig,\n payload: unknown,\n key: string,\n): ChartSeries | undefined {\n if (typeof payload !== 'object' || payload === null) return undefined\n\n const nested =\n 'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null\n ? (payload.payload as Record<string, unknown>)\n : undefined\n\n let name = key\n const direct = (payload as Record<string, unknown>)[key]\n if (typeof direct === 'string') name = direct\n else if (nested && typeof nested[key] === 'string') name = nested[key] as string\n\n return name in config ? config[name] : config[key]\n}\n\n/** Formats an expanded stack's axis, where 0–1 means 0–100%. */\nexport function percentTick(value: number): string {\n return `${Math.round(value * 100)}%`\n}\n"],"mappings":";AA0KS,cA8CH,YA9CG;AAxKT;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP,SAAS,2BAA2B;AACpC,SAAS,UAAU;AAYZ,MAAM,eAAe;AAG5B,MAAM,SAAS,EAAE,OAAO,IAAI,MAAM,qBAAqB;AAIvD,MAAM,aAAa,OAAO,KAAK,MAAM;AA2CrC,MAAM,eAAe,cAAwC,IAAI;AAG1D,SAAS,WAA8B;AAC5C,QAAM,UAAU,WAAW,YAAY;AACvC,MAAI,CAAC,QAAS,OAAM,IAAI,MAAM,mDAAmD;AACjF,SAAO;AACT;AAcA,MAAM,YAAY;AAElB,SAAS,YAAY,OAAiC;AACpD,SAAO,OAAO,UAAU,YAAY,UAAU,KAAK,KAAK,KAAK,CAAC,MAAM,SAAS,IAAI;AACnF;AAOA,SAAS,WAAW,QAAkB,OAAyB;AAC7D,MAAI,OAAO,UAAU,MAAO,QAAO,OAAO,MAAM,GAAG,KAAK;AAExD,QAAM,OAAO,KAAK,MAAM,QAAQ,OAAO,MAAM;AAC7C,QAAM,QAAQ,QAAQ,OAAO;AAC7B,QAAM,MAAgB,CAAC;AACvB,SAAO,QAAQ,CAAC,OAAO,UAAU;AAC/B,UAAM,QAAQ,QAAQ,SAAS,OAAO,SAAS,QAAQ,IAAI;AAC3D,aAAS,IAAI,GAAG,IAAI,OAAO,KAAK,EAAG,KAAI,KAAK,KAAK;AAAA,EACnD,CAAC;AACD,SAAO;AACT;AAGO,SAAS,WAAW,QAAyC;AAClE,MAAI,CAAC,QAAQ,OAAQ,QAAO;AAC5B,SAAO,KAAK,IAAI,GAAG,GAAG,WAAW,IAAI,CAAC,UAAU,OAAO,SAAS,KAAK,GAAG,UAAU,CAAC,CAAC;AACtF;AAeA,SAAS,WAAW,EAAE,IAAI,OAAO,GAAwC;AACvE,QAAM,MAAM,QAAQ,MAAM;AACxB,UAAM,UAAU,OAAO,QAAQ,MAAM;AAErC,UAAM,QAAQ,CAAC,UACb,QACG,QAAQ,CAAC,CAAC,KAAK,MAAM,GAAG,SAAS;AAChC,YAAM,QAAQ,WAAW,MAAM;AAC/B,YAAM,YAAY,gBAAiB,OAAO,eAAgB,CAAC;AAC3D,YAAM,QAAQ,OAAO,SAAS,KAAK,KAAK,OAAO,QAAQ,SAAS,OAAO,QAAQ;AAC/E,YAAM,OAAO,OAAO,OAAO,WAAW,KAAK,CAAC;AAI5C,YAAM,SAAS,KAAK,SAAS,IAAI,OAAO,CAAC,SAAS;AAClD,aAAO,WAAW,QAAQ,KAAK,EAAE;AAAA,QAC/B,CAAC,OAAO,UAAU,aAAa,QAAQ,GAAG,CAAC,IAAI,KAAK,KAAK,KAAK;AAAA,MAChE;AAAA,IACF,CAAC,EACA,KAAK,IAAI;AAEd,WAAO,WAAW,IAAI,CAAC,UAAU;AAC/B,YAAM,OAAO,MAAM,KAAK;AACxB,UAAI,CAAC,KAAM,QAAO;AAClB,aAAO,GAAG,OAAO,KAAK,CAAC,iBAAiB,EAAE;AAAA,EAAS,IAAI;AAAA;AAAA,IACzD,CAAC,EACE,OAAO,OAAO,EACd,KAAK,IAAI;AAAA,EACd,GAAG,CAAC,IAAI,MAAM,CAAC;AAEf,MAAI,CAAC,IAAK,QAAO;AAEjB,SAAO,oBAAC,WAAO,eAAI;AACrB;AAOO,SAAS,QAAQ,KAAqB;AAC3C,SAAO,IAAI,QAAQ,mBAAmB,GAAG;AAC3C;AAsBO,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA,mBAAmB,EAAE,OAAO,KAAK,QAAQ,IAAI;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAwB;AACtB,QAAM,YAAY,MAAM,EAAE,QAAQ,MAAM,EAAE;AAC1C,QAAM,UAAU,SAAS,MAAM,SAAS;AAExC,SACE,oBAAC,aAAa,UAAb,EAAsB,OAAO,EAAE,OAAO,GACrC;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,cAAY;AAAA,MACZ,WAAW;AAAA,QACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAUA,CAAC,UAAU;AAAA;AAAA,QAEX;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA;AAAA;AAAA,QAGA;AAAA,QACA;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEJ;AAAA,4BAAC,cAAW,IAAI,SAAS,QAAgB;AAAA,QAUzC;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,SAAS,6CAA6C;AAAA,YACxD;AAAA,YACA;AAAA,YAEC;AAAA;AAAA,QACH;AAAA,QACC;AAAA;AAAA;AAAA,EACH,GACF;AAEJ;AASO,SAAS,kBACd,QACA,SACA,KACyB;AACzB,MAAI,OAAO,YAAY,YAAY,YAAY,KAAM,QAAO;AAE5D,QAAM,SACJ,aAAa,WAAW,OAAO,QAAQ,YAAY,YAAY,QAAQ,YAAY,OAC9E,QAAQ,UACT;AAEN,MAAI,OAAO;AACX,QAAM,SAAU,QAAoC,GAAG;AACvD,MAAI,OAAO,WAAW,SAAU,QAAO;AAAA,WAC9B,UAAU,OAAO,OAAO,GAAG,MAAM,SAAU,QAAO,OAAO,GAAG;AAErE,SAAO,QAAQ,SAAS,OAAO,IAAI,IAAI,OAAO,GAAG;AACnD;AAGO,SAAS,YAAY,OAAuB;AACjD,SAAO,GAAG,KAAK,MAAM,QAAQ,GAAG,CAAC;AACnC;","names":[]}
@@ -83,7 +83,7 @@ function AppShell({
83
83
  "md:static md:z-auto md:translate-x-0"
84
84
  ),
85
85
  children: [
86
- brand && /* @__PURE__ */ jsx("div", { className: "flex h-14 items-center border-b border-(--rule) px-5", children: brand }),
86
+ brand && /* @__PURE__ */ jsx("div", { className: "flex h-(--bar-h) items-center border-b border-(--rule) px-5", children: brand }),
87
87
  /* @__PURE__ */ jsx(
88
88
  "nav",
89
89
  {
@@ -96,7 +96,7 @@ function AppShell({
96
96
  }
97
97
  ),
98
98
  /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-col", children: [
99
- /* @__PURE__ */ jsxs("header", { className: "sticky top-0 z-(--z-sticky) flex h-14 items-center gap-3 border-b border-(--rule) bg-(--paper)/85 px-(--page-pad) backdrop-blur", children: [
99
+ /* @__PURE__ */ jsxs("header", { className: "sticky top-0 z-(--z-sticky) flex h-(--bar-h) items-center gap-3 border-b border-(--rule) bg-(--paper)/85 px-(--page-pad) backdrop-blur", children: [
100
100
  /* @__PURE__ */ jsx(
101
101
  "button",
102
102
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/AppShell/AppShell.tsx"],"sourcesContent":["'use client'\n\nimport { Menu, X } from 'lucide-react'\nimport { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from 'react'\nimport type { HTMLAttributes, ReactNode } from 'react'\nimport { cn } from '../../lib/cn'\n\n/**\n * The width the sidebar stops being a drawer at — `--breakpoint-md`, the same\n * 768px every `md:` variant below is compiled against. Written out rather than\n * read from the token, because a media query string is not a custom property\n * and `matchMedia` cannot resolve one.\n */\nconst DESKTOP = '(min-width: 768px)'\n\n/** True where nothing can answer the question: a server, or a DOM without it. */\nfunction cannotMeasure(): boolean {\n return typeof window === 'undefined' || typeof window.matchMedia !== 'function'\n}\n\nfunction subscribeToWidth(onChange: () => void): () => void {\n if (cannotMeasure()) return () => {}\n const list = window.matchMedia(DESKTOP)\n list.addEventListener('change', onChange)\n return () => list.removeEventListener('change', onChange)\n}\n\n/**\n * Falls back to \"not a drawer\", which is the safe answer rather than the likely\n * one: an environment that cannot measure must not take an entire application's\n * navigation out of the accessibility tree on a guess.\n */\nfunction readIsDrawer(): boolean {\n if (cannotMeasure()) return false\n return !window.matchMedia(DESKTOP).matches\n}\n\nfunction readIsDrawerOnServer(): boolean {\n return false\n}\n\n/**\n * True while the sidebar is a drawer rather than a column.\n *\n * `open` alone cannot answer this. It is false on a desktop too, where the\n * sidebar is a visible grid column — so anything keyed on `open` by itself\n * would hide the navigation of every desktop app in the package.\n *\n * `useSyncExternalStore` rather than an effect: the server has no viewport, so\n * it renders the desktop answer and React reconciles the real one on hydration\n * instead of the two silently disagreeing.\n */\nfunction useIsDrawer(): boolean {\n return useSyncExternalStore(subscribeToWidth, readIsDrawer, readIsDrawerOnServer)\n}\n\nexport interface AppShellProps extends HTMLAttributes<HTMLDivElement> {\n /** Navigation content for the sidebar (e.g. a stack of `NavItem`s). */\n sidebar: ReactNode\n /** Optional content for the sticky topbar, laid out after the mobile toggle. */\n topbar?: ReactNode\n /** Optional brand lockup pinned to the top of the sidebar. */\n brand?: ReactNode\n /**\n * Which element the content well renders as.\n *\n * `main` is right for the shell of an application, and is the default. A\n * document may contain exactly one `main` landmark, so an AppShell rendered\n * INSIDE another page — a preview on a documentation site, a screenshot\n * harness — must pass `div`, or the page has two and assistive tech can no\n * longer answer \"where is the content\".\n */\n contentAs?: 'main' | 'div'\n /**\n * Names the sidebar landmark.\n *\n * Two `complementary` landmarks with the same name cannot be told apart, and\n * a shell rendered inside another page — a preview, a screenshot harness —\n * makes exactly that pair. It is also the only way a non-English app gets a\n * landmark name its readers can read.\n */\n sidebarLabel?: string\n /** Names the navigation landmark inside the sidebar. */\n navLabel?: string\n /** The drawer toggle, closed and open. */\n openLabel?: string\n closeLabel?: string\n children: ReactNode\n}\n\n/**\n * Two columns on a desktop, one column and a drawer on a phone.\n *\n * The drawer closes on Escape as well as on the scrim, because a drawer that\n * only closes by tapping outside it strands a keyboard user inside a menu they\n * cannot leave. The scrim is a `<button>` for the same reason — a `<div>` with\n * an `onClick` is not reachable by keyboard and not announced as anything.\n *\n * Below `md` the closed drawer is `inert`. Sliding it off-screen is a visual\n * state and nothing more: without that attribute every link in it stays\n * focusable and stays in the accessibility tree, so Tab from the toggle walks\n * into a menu nobody can see. Closing it also returns focus to the toggle,\n * because the element focus was on is the element that just left.\n *\n * @example\n * <AppShell brand={<Wordmark />} sidebar={<NavItem href=\"/\">Home</NavItem>}>\n * <Card>…</Card>\n * </AppShell>\n */\nexport function AppShell({\n sidebar,\n topbar,\n brand,\n contentAs: Content = 'main',\n sidebarLabel = 'Sidebar',\n navLabel = 'Primary',\n openLabel = 'Open navigation',\n closeLabel = 'Close navigation',\n children,\n className,\n ...rest\n}: AppShellProps) {\n const [open, setOpen] = useState(false)\n const sidebarId = useId()\n const toggle = useRef<HTMLButtonElement>(null)\n const isDrawer = useIsDrawer()\n\n /**\n * Both ways out, so both return focus.\n *\n * Escape used to leave the reader standing on a link that had just slid off\n * screen; the scrim is worse, because the scrim itself is the focused element\n * and it unmounts, dropping focus to `<body>`. Either way the drawer is about\n * to become inert, and focus inside an inert subtree is focus the browser\n * throws away.\n */\n const close = useCallback(() => {\n setOpen(false)\n toggle.current?.focus()\n }, [])\n\n useEffect(() => {\n if (!open) return\n const onKey = (event: KeyboardEvent) => {\n if (event.key === 'Escape') close()\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [open, close])\n\n return (\n <div\n className={cn('min-h-svh bg-(--paper) md:grid md:grid-cols-[15rem_1fr]', className)}\n {...rest}\n >\n {open && (\n <button\n type=\"button\"\n aria-label={closeLabel}\n onClick={close}\n className=\"fixed inset-0 z-(--z-scrim) bg-(--scrim) md:hidden\"\n />\n )}\n\n <aside\n id={sidebarId}\n aria-label={sidebarLabel}\n // Only below `md`, and only while it is shut. Above the breakpoint this\n // element is the page's navigation column, and inert would delete it.\n inert={isDrawer && !open}\n // The transition is motion like any other, and the reduced-motion rule\n // in keyframes.css matches on this attribute.\n data-m22-animated\n className={cn(\n 'fixed inset-y-0 start-0 z-(--z-modal) flex w-60 flex-col border-e border-(--rule) bg-(--paper) transition-transform duration-(--duration-slow) ease-(--ease-out-expo)',\n // The drawer slides in from the edge reading STARTS at, so in an\n // right-to-left document it comes from the right.\n open ? 'translate-x-0' : '-translate-x-full rtl:translate-x-full',\n 'md:static md:z-auto md:translate-x-0',\n )}\n >\n {brand && (\n <div className=\"flex h-14 items-center border-b border-(--rule) px-5\">{brand}</div>\n )}\n <nav\n aria-label={navLabel}\n className=\"flex flex-1 flex-col gap-1 overflow-y-auto p-3 scroll-slim\"\n >\n {sidebar}\n </nav>\n </aside>\n\n <div className=\"flex min-w-0 flex-col\">\n <header className=\"sticky top-0 z-(--z-sticky) flex h-14 items-center gap-3 border-b border-(--rule) bg-(--paper)/85 px-(--page-pad) backdrop-blur\">\n <button\n type=\"button\"\n aria-label={open ? closeLabel : openLabel}\n aria-expanded={open}\n aria-controls={sidebarId}\n ref={toggle}\n onClick={() => setOpen((previous) => !previous)}\n className=\"-ms-2.5 grid size-11 place-items-center rounded-(--radius) text-(--ink-2) transition-colors duration-(--duration-fast) hover:text-(--ink) md:hidden\"\n >\n {open ? <X size={20} strokeWidth={1.5} aria-hidden /> : <Menu size={20} strokeWidth={1.5} aria-hidden />}\n </button>\n {topbar}\n </header>\n\n <Content className=\"mx-auto w-full max-w-(--w-page) px-(--page-pad) py-8\">{children}</Content>\n </div>\n </div>\n )\n}\n\nexport default AppShell\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/components/AppShell/AppShell.tsx"],"sourcesContent":["'use client'\n\nimport { Menu, X } from 'lucide-react'\nimport { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from 'react'\nimport type { HTMLAttributes, ReactNode } from 'react'\nimport { cn } from '../../lib/cn'\n\n/**\n * The width the sidebar stops being a drawer at — `--breakpoint-md`, the same\n * 768px every `md:` variant below is compiled against. Written out rather than\n * read from the token, because a media query string is not a custom property\n * and `matchMedia` cannot resolve one.\n */\nconst DESKTOP = '(min-width: 768px)'\n\n/** True where nothing can answer the question: a server, or a DOM without it. */\nfunction cannotMeasure(): boolean {\n return typeof window === 'undefined' || typeof window.matchMedia !== 'function'\n}\n\nfunction subscribeToWidth(onChange: () => void): () => void {\n if (cannotMeasure()) return () => {}\n const list = window.matchMedia(DESKTOP)\n list.addEventListener('change', onChange)\n return () => list.removeEventListener('change', onChange)\n}\n\n/**\n * Falls back to \"not a drawer\", which is the safe answer rather than the likely\n * one: an environment that cannot measure must not take an entire application's\n * navigation out of the accessibility tree on a guess.\n */\nfunction readIsDrawer(): boolean {\n if (cannotMeasure()) return false\n return !window.matchMedia(DESKTOP).matches\n}\n\nfunction readIsDrawerOnServer(): boolean {\n return false\n}\n\n/**\n * True while the sidebar is a drawer rather than a column.\n *\n * `open` alone cannot answer this. It is false on a desktop too, where the\n * sidebar is a visible grid column — so anything keyed on `open` by itself\n * would hide the navigation of every desktop app in the package.\n *\n * `useSyncExternalStore` rather than an effect: the server has no viewport, so\n * it renders the desktop answer and React reconciles the real one on hydration\n * instead of the two silently disagreeing.\n */\nfunction useIsDrawer(): boolean {\n return useSyncExternalStore(subscribeToWidth, readIsDrawer, readIsDrawerOnServer)\n}\n\nexport interface AppShellProps extends HTMLAttributes<HTMLDivElement> {\n /** Navigation content for the sidebar (e.g. a stack of `NavItem`s). */\n sidebar: ReactNode\n /** Optional content for the sticky topbar, laid out after the mobile toggle. */\n topbar?: ReactNode\n /** Optional brand lockup pinned to the top of the sidebar. */\n brand?: ReactNode\n /**\n * Which element the content well renders as.\n *\n * `main` is right for the shell of an application, and is the default. A\n * document may contain exactly one `main` landmark, so an AppShell rendered\n * INSIDE another page — a preview on a documentation site, a screenshot\n * harness — must pass `div`, or the page has two and assistive tech can no\n * longer answer \"where is the content\".\n */\n contentAs?: 'main' | 'div'\n /**\n * Names the sidebar landmark.\n *\n * Two `complementary` landmarks with the same name cannot be told apart, and\n * a shell rendered inside another page — a preview, a screenshot harness —\n * makes exactly that pair. It is also the only way a non-English app gets a\n * landmark name its readers can read.\n */\n sidebarLabel?: string\n /** Names the navigation landmark inside the sidebar. */\n navLabel?: string\n /** The drawer toggle, closed and open. */\n openLabel?: string\n closeLabel?: string\n children: ReactNode\n}\n\n/**\n * Two columns on a desktop, one column and a drawer on a phone.\n *\n * The drawer closes on Escape as well as on the scrim, because a drawer that\n * only closes by tapping outside it strands a keyboard user inside a menu they\n * cannot leave. The scrim is a `<button>` for the same reason — a `<div>` with\n * an `onClick` is not reachable by keyboard and not announced as anything.\n *\n * Below `md` the closed drawer is `inert`. Sliding it off-screen is a visual\n * state and nothing more: without that attribute every link in it stays\n * focusable and stays in the accessibility tree, so Tab from the toggle walks\n * into a menu nobody can see. Closing it also returns focus to the toggle,\n * because the element focus was on is the element that just left.\n *\n * @example\n * <AppShell brand={<Wordmark />} sidebar={<NavItem href=\"/\">Home</NavItem>}>\n * <Card>…</Card>\n * </AppShell>\n */\nexport function AppShell({\n sidebar,\n topbar,\n brand,\n contentAs: Content = 'main',\n sidebarLabel = 'Sidebar',\n navLabel = 'Primary',\n openLabel = 'Open navigation',\n closeLabel = 'Close navigation',\n children,\n className,\n ...rest\n}: AppShellProps) {\n const [open, setOpen] = useState(false)\n const sidebarId = useId()\n const toggle = useRef<HTMLButtonElement>(null)\n const isDrawer = useIsDrawer()\n\n /**\n * Both ways out, so both return focus.\n *\n * Escape used to leave the reader standing on a link that had just slid off\n * screen; the scrim is worse, because the scrim itself is the focused element\n * and it unmounts, dropping focus to `<body>`. Either way the drawer is about\n * to become inert, and focus inside an inert subtree is focus the browser\n * throws away.\n */\n const close = useCallback(() => {\n setOpen(false)\n toggle.current?.focus()\n }, [])\n\n useEffect(() => {\n if (!open) return\n const onKey = (event: KeyboardEvent) => {\n if (event.key === 'Escape') close()\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [open, close])\n\n return (\n <div\n className={cn('min-h-svh bg-(--paper) md:grid md:grid-cols-[15rem_1fr]', className)}\n {...rest}\n >\n {open && (\n <button\n type=\"button\"\n aria-label={closeLabel}\n onClick={close}\n className=\"fixed inset-0 z-(--z-scrim) bg-(--scrim) md:hidden\"\n />\n )}\n\n <aside\n id={sidebarId}\n aria-label={sidebarLabel}\n // Only below `md`, and only while it is shut. Above the breakpoint this\n // element is the page's navigation column, and inert would delete it.\n inert={isDrawer && !open}\n // The transition is motion like any other, and the reduced-motion rule\n // in keyframes.css matches on this attribute.\n data-m22-animated\n className={cn(\n 'fixed inset-y-0 start-0 z-(--z-modal) flex w-60 flex-col border-e border-(--rule) bg-(--paper) transition-transform duration-(--duration-slow) ease-(--ease-out-expo)',\n // The drawer slides in from the edge reading STARTS at, so in an\n // right-to-left document it comes from the right.\n open ? 'translate-x-0' : '-translate-x-full rtl:translate-x-full',\n 'md:static md:z-auto md:translate-x-0',\n )}\n >\n {brand && (\n <div className=\"flex h-(--bar-h) items-center border-b border-(--rule) px-5\">{brand}</div>\n )}\n <nav\n aria-label={navLabel}\n className=\"flex flex-1 flex-col gap-1 overflow-y-auto p-3 scroll-slim\"\n >\n {sidebar}\n </nav>\n </aside>\n\n <div className=\"flex min-w-0 flex-col\">\n <header className=\"sticky top-0 z-(--z-sticky) flex h-(--bar-h) items-center gap-3 border-b border-(--rule) bg-(--paper)/85 px-(--page-pad) backdrop-blur\">\n <button\n type=\"button\"\n aria-label={open ? closeLabel : openLabel}\n aria-expanded={open}\n aria-controls={sidebarId}\n ref={toggle}\n onClick={() => setOpen((previous) => !previous)}\n className=\"-ms-2.5 grid size-11 place-items-center rounded-(--radius) text-(--ink-2) transition-colors duration-(--duration-fast) hover:text-(--ink) md:hidden\"\n >\n {open ? <X size={20} strokeWidth={1.5} aria-hidden /> : <Menu size={20} strokeWidth={1.5} aria-hidden />}\n </button>\n {topbar}\n </header>\n\n <Content className=\"mx-auto w-full max-w-(--w-page) px-(--page-pad) py-8\">{children}</Content>\n </div>\n </div>\n )\n}\n\nexport default AppShell\n"],"mappings":";AA4JQ,cAQF,YARE;AA1JR,SAAS,MAAM,SAAS;AACxB,SAAS,aAAa,WAAW,OAAO,QAAQ,UAAU,4BAA4B;AAEtF,SAAS,UAAU;AAQnB,MAAM,UAAU;AAGhB,SAAS,gBAAyB;AAChC,SAAO,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe;AACvE;AAEA,SAAS,iBAAiB,UAAkC;AAC1D,MAAI,cAAc,EAAG,QAAO,MAAM;AAAA,EAAC;AACnC,QAAM,OAAO,OAAO,WAAW,OAAO;AACtC,OAAK,iBAAiB,UAAU,QAAQ;AACxC,SAAO,MAAM,KAAK,oBAAoB,UAAU,QAAQ;AAC1D;AAOA,SAAS,eAAwB;AAC/B,MAAI,cAAc,EAAG,QAAO;AAC5B,SAAO,CAAC,OAAO,WAAW,OAAO,EAAE;AACrC;AAEA,SAAS,uBAAgC;AACvC,SAAO;AACT;AAaA,SAAS,cAAuB;AAC9B,SAAO,qBAAqB,kBAAkB,cAAc,oBAAoB;AAClF;AAuDO,SAAS,SAAS;AAAA,EACvB;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW,UAAU;AAAA,EACrB,eAAe;AAAA,EACf,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAkB;AAChB,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,YAAY,MAAM;AACxB,QAAM,SAAS,OAA0B,IAAI;AAC7C,QAAM,WAAW,YAAY;AAW7B,QAAM,QAAQ,YAAY,MAAM;AAC9B,YAAQ,KAAK;AACb,WAAO,SAAS,MAAM;AAAA,EACxB,GAAG,CAAC,CAAC;AAEL,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,QAAQ,CAAC,UAAyB;AACtC,UAAI,MAAM,QAAQ,SAAU,OAAM;AAAA,IACpC;AACA,aAAS,iBAAiB,WAAW,KAAK;AAC1C,WAAO,MAAM,SAAS,oBAAoB,WAAW,KAAK;AAAA,EAC5D,GAAG,CAAC,MAAM,KAAK,CAAC;AAEhB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,GAAG,2DAA2D,SAAS;AAAA,MACjF,GAAG;AAAA,MAEH;AAAA,gBACC;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,cAAY;AAAA,YACZ,SAAS;AAAA,YACT,WAAU;AAAA;AAAA,QACZ;AAAA,QAGF;AAAA,UAAC;AAAA;AAAA,YACC,IAAI;AAAA,YACJ,cAAY;AAAA,YAGZ,OAAO,YAAY,CAAC;AAAA,YAGpB,qBAAiB;AAAA,YACjB,WAAW;AAAA,cACT;AAAA;AAAA;AAAA,cAGA,OAAO,kBAAkB;AAAA,cACzB;AAAA,YACF;AAAA,YAEC;AAAA,uBACC,oBAAC,SAAI,WAAU,+DAA+D,iBAAM;AAAA,cAEtF;AAAA,gBAAC;AAAA;AAAA,kBACC,cAAY;AAAA,kBACZ,WAAU;AAAA,kBAET;AAAA;AAAA,cACH;AAAA;AAAA;AAAA,QACF;AAAA,QAEA,qBAAC,SAAI,WAAU,yBACb;AAAA,+BAAC,YAAO,WAAU,0IAChB;AAAA;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,cAAY,OAAO,aAAa;AAAA,gBAChC,iBAAe;AAAA,gBACf,iBAAe;AAAA,gBACf,KAAK;AAAA,gBACL,SAAS,MAAM,QAAQ,CAAC,aAAa,CAAC,QAAQ;AAAA,gBAC9C,WAAU;AAAA,gBAET,iBAAO,oBAAC,KAAE,MAAM,IAAI,aAAa,KAAK,eAAW,MAAC,IAAK,oBAAC,QAAK,MAAM,IAAI,aAAa,KAAK,eAAW,MAAC;AAAA;AAAA,YACxG;AAAA,YACC;AAAA,aACH;AAAA,UAEA,oBAAC,WAAQ,WAAU,wDAAwD,UAAS;AAAA,WACtF;AAAA;AAAA;AAAA,EACF;AAEJ;AAEA,IAAO,mBAAQ;","names":[]}
@@ -13,9 +13,9 @@ const VARIANT = {
13
13
  danger: "bg-(--danger) text-(--paper) border border-(--danger) hover:opacity-85"
14
14
  };
15
15
  const SIZE = {
16
- sm: "min-h-(--control-h-sm) px-(--control-px-sm) py-(--control-py-sm) text-[13px]",
17
- md: "min-h-(--control-h-md) px-(--control-px-md) py-(--control-py-md) text-sm",
18
- lg: "min-h-(--control-h-lg) px-(--control-px-lg) py-(--control-py-lg) text-[15px]"
16
+ sm: "min-h-(--control-h-sm) px-(--control-px-sm) py-(--control-py-sm) text-[13px] leading-(--control-lh)",
17
+ md: "min-h-(--control-h-md) px-(--control-px-md) py-(--control-py-md) text-sm leading-(--control-lh)",
18
+ lg: "min-h-(--control-h-lg) px-(--control-px-lg) py-(--control-py-lg) text-[15px] leading-(--control-lh)"
19
19
  };
20
20
  const ICON_SIZE = {
21
21
  sm: "size-(--control-h-sm) p-0",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/Button/Button.tsx"],"sourcesContent":["import { Slot } from '@radix-ui/react-slot'\nimport type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'\nimport { cn } from '../../lib/cn'\nimport { DEV, warn } from '../../lib/warn'\nimport { Spinner } from '../Spinner/Spinner'\n\nexport type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'\nexport type ButtonSize = 'sm' | 'md' | 'lg'\n\n/**\n * The base every variant shares.\n *\n * It reads `--radius`, the CONTROL step, which is the same corner an `Input`,\n * a `Select` trigger and a `Textarea` draw. It used to be a pill, and a pill\n * beside an 8px field is two different ideas of what a control is — the one\n * inconsistency in the system that every reader noticed and none of the\n * component pages could explain. Everything on the control line now agrees,\n * and the capsule is kept for the things that genuinely are one: a badge, a\n * status pill, a segmented strip, a progress track.\n *\n * It does not move on hover. An earlier base lifted a pixel and asked for a\n * shadow the White Reset does not have — `--shadow` resolves to `none`, so that\n * hover state was invisible on every button in the system while still costing a\n * transition. Opacity carries the filled hover; the border carries the outlined\n * one.\n *\n * The transition names its properties rather than using `all`, so a layout\n * change (a width that grows when the label becomes \"Saving…\") is instant\n * instead of sliding.\n */\nconst BASE =\n 'inline-flex items-center justify-center gap-(--control-gap) rounded-(--radius) font-sans transition-[opacity,color,border-color,background-color] duration-(--duration-fast) ease-(--ease-out-expo) disabled:opacity-(--disabled-opacity) disabled:pointer-events-none aria-disabled:opacity-(--disabled-opacity) aria-disabled:pointer-events-none'\n\nconst VARIANT: Record<ButtonVariant, string> = {\n primary: 'bg-(--accent) text-(--accent-foreground) border border-(--accent) hover:opacity-85',\n secondary: 'border border-(--rule-2) text-(--ink) bg-transparent hover:border-(--ink)',\n ghost: 'border border-transparent text-(--ink-2) hover:text-(--ink) hover:bg-(--stone)',\n /* The one place chroma is allowed on a control, and only because the action\n is destructive — that is a state, not a brand colour. */\n danger: 'bg-(--danger) text-(--paper) border border-(--danger) hover:opacity-85',\n}\n\n/**\n * `min-h` rather than `h`, so a button that wraps to two lines grows instead of\n * clipping.\n *\n * The heights come from the density tokens rather than from a literal, so a\n * subtree marked `data-density=\"compact\"` shrinks its controls without a single\n * call site being told. At the default density `md` is 44px — the pointer\n * target WCAG 2.5.5 asks for.\n */\nconst SIZE: Record<ButtonSize, string> = {\n sm: 'min-h-(--control-h-sm) px-(--control-px-sm) py-(--control-py-sm) text-[13px]',\n md: 'min-h-(--control-h-md) px-(--control-px-md) py-(--control-py-md) text-sm',\n lg: 'min-h-(--control-h-lg) px-(--control-px-lg) py-(--control-py-lg) text-[15px]',\n}\n\n/** Square, because an icon has no label to give the box its width. */\nconst ICON_SIZE: Record<ButtonSize, string> = {\n sm: 'size-(--control-h-sm) p-0',\n md: 'size-(--control-h-md) p-0',\n lg: 'size-(--control-h-lg) p-0',\n}\n\nconst SPINNER_SIZE: Record<ButtonSize, 'sm' | 'md'> = { sm: 'sm', md: 'sm', lg: 'md' }\n\ninterface CommonProps {\n children?: ReactNode\n /**\n * Which action this is. `primary` is the one thing the screen wants you to\n * do, so there is at most one per view; `danger` is reserved for destructive\n * actions and is the only place chroma is allowed on a control.\n */\n variant?: ButtonVariant\n /** 36 / 44 / 48px tall. `md` meets the pointer-target floor on its own. */\n size?: ButtonSize\n /** Optional mono keycap glyph rendered after the label (e.g. \"P\"). */\n keycap?: string\n /**\n * Swaps the leading content for a spinner and blocks interaction. The label\n * stays put — a button that empties out while it works loses its width, and\n * the page reflows under the pointer that just clicked it.\n */\n loading?: boolean\n /**\n * Square control with no label. REQUIRES `aria-label`: an icon-only button\n * with no accessible name is invisible to a screen reader, and this is the\n * single most common way a design system ships an unusable control.\n */\n iconOnly?: boolean\n /**\n * Render the child element instead of a `<button>`, keeping these styles.\n * Use it to hand the styling to a router's `Link` — `asChild` is what keeps\n * this package free of any one router.\n *\n * The decoration slots (`keycap`, `loading`) are not injected into a slotted\n * child; compose them inside it yourself.\n *\n * @example\n * <Button asChild><Link href=\"/work\">Work</Link></Button>\n */\n asChild?: boolean\n className?: string\n}\n\ntype ButtonAsLink = CommonProps &\n Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'className' | 'children'> & { href: string }\n\ntype ButtonAsButton = CommonProps &\n Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'children'> & { href?: undefined }\n\nexport type ButtonProps = ButtonAsLink | ButtonAsButton\n\nfunction Keycap({ glyph }: { glyph: string }) {\n return (\n <span className=\"rounded-(--radius-sm) border border-current px-2 py-0.5 font-mono text-[11px] opacity-55\">\n {glyph}\n </span>\n )\n}\n\n/**\n * The system's action.\n *\n * Renders a `<button>`, or an `<a>` when given `href`, or whatever you hand it\n * via `asChild`. No router is imported, so the package stays framework-agnostic\n * and a Next or React Router app wires its own `Link` at the call site.\n *\n * Server-component friendly: there is no client boundary here, so it renders in\n * a static page as well as an interactive one.\n *\n * @example\n * <Button variant=\"secondary\" keycap=\"P\">View projects</Button>\n * @example\n * <Button loading disabled>Saving…</Button>\n * @example\n * <Button iconOnly aria-label=\"Close\"><X size={16} /></Button>\n */\nexport function Button(props: ButtonProps) {\n const {\n children,\n variant = 'primary',\n size = 'md',\n keycap,\n loading = false,\n iconOnly = false,\n asChild = false,\n className,\n } = props\n\n const cls = cn(BASE, VARIANT[variant], iconOnly ? ICON_SIZE[size] : SIZE[size], className)\n\n if (DEV && iconOnly) {\n // The control renders correctly and is announced as \"button\" and nothing\n // else. Nothing about the rendered page says so, which is why this is the\n // single most common way a design system ships an unusable control.\n const named =\n typeof props['aria-label'] === 'string' && props['aria-label'].trim() !== ''\n const described = typeof props['aria-labelledby'] === 'string'\n if (!named && !described) {\n warn({\n code: 'BUTTON_ICON_ONLY_UNNAMED',\n problem:\n 'An iconOnly Button has no text, so without an accessible name a screen reader announces it as \"button\" and nothing else.',\n field: 'aria-label',\n fix: 'Add aria-label with the action the button performs, e.g. aria-label=\"Delete invoice\".',\n component: 'Button',\n })\n }\n }\n\n // A filled ground needs a spinner in the inherited colour; an outlined one\n // reads better against the page's own rule scale.\n const spinnerTone = variant === 'primary' || variant === 'danger' ? 'current' : 'default'\n const decoration = (\n <>\n {loading && <Spinner size={SPINNER_SIZE[size]} tone={spinnerTone} label={null} />}\n {children}\n {keycap && <Keycap glyph={keycap} />}\n </>\n )\n\n if (asChild) {\n const { children: _c, variant: _v, size: _s, keycap: _k, loading: _l, iconOnly: _i, asChild: _a, className: _cn, ...rest } = props\n return (\n <Slot className={cls} {...rest}>\n {children}\n </Slot>\n )\n }\n\n if (props.href !== undefined) {\n const { children: _c, variant: _v, size: _s, keycap: _k, loading: _l, iconOnly: _i, asChild: _a, className: _cn, href, ...rest } = props\n return (\n <a\n href={href}\n className={cls}\n // A link cannot be `disabled`; `aria-disabled` plus the pointer-events\n // rule in BASE is the accessible equivalent.\n aria-disabled={loading || undefined}\n {...rest}\n >\n {decoration}\n </a>\n )\n }\n\n const { children: _c, variant: _v, size: _s, keycap: _k, loading: _l, iconOnly: _i, asChild: _a, className: _cn, ...rest } = props\n return (\n <button\n type={rest.type ?? 'button'}\n aria-busy={loading || undefined}\n disabled={rest.disabled || loading}\n className={cls}\n {...rest}\n >\n {decoration}\n </button>\n )\n}\n\nexport default Button\n"],"mappings":"AAmHI,SA4DA,UA5DA,KA4DA,YA5DA;AAnHJ,SAAS,YAAY;AAErB,SAAS,UAAU;AACnB,SAAS,KAAK,YAAY;AAC1B,SAAS,eAAe;AA0BxB,MAAM,OACJ;AAEF,MAAM,UAAyC;AAAA,EAC7C,SAAS;AAAA,EACT,WAAW;AAAA,EACX,OAAO;AAAA;AAAA;AAAA,EAGP,QAAQ;AACV;AAWA,MAAM,OAAmC;AAAA,EACvC,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAGA,MAAM,YAAwC;AAAA,EAC5C,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAEA,MAAM,eAAgD,EAAE,IAAI,MAAM,IAAI,MAAM,IAAI,KAAK;AAiDrF,SAAS,OAAO,EAAE,MAAM,GAAsB;AAC5C,SACE,oBAAC,UAAK,WAAU,4FACb,iBACH;AAEJ;AAmBO,SAAS,OAAO,OAAoB;AACzC,QAAM;AAAA,IACJ;AAAA,IACA,UAAU;AAAA,IACV,OAAO;AAAA,IACP;AAAA,IACA,UAAU;AAAA,IACV,WAAW;AAAA,IACX,UAAU;AAAA,IACV;AAAA,EACF,IAAI;AAEJ,QAAM,MAAM,GAAG,MAAM,QAAQ,OAAO,GAAG,WAAW,UAAU,IAAI,IAAI,KAAK,IAAI,GAAG,SAAS;AAEzF,MAAI,OAAO,UAAU;AAInB,UAAM,QACJ,OAAO,MAAM,YAAY,MAAM,YAAY,MAAM,YAAY,EAAE,KAAK,MAAM;AAC5E,UAAM,YAAY,OAAO,MAAM,iBAAiB,MAAM;AACtD,QAAI,CAAC,SAAS,CAAC,WAAW;AACxB,WAAK;AAAA,QACH,MAAM;AAAA,QACN,SACE;AAAA,QACF,OAAO;AAAA,QACP,KAAK;AAAA,QACL,WAAW;AAAA,MACb,CAAC;AAAA,IACH;AAAA,EACF;AAIA,QAAM,cAAc,YAAY,aAAa,YAAY,WAAW,YAAY;AAChF,QAAM,aACJ,iCACG;AAAA,eAAW,oBAAC,WAAQ,MAAM,aAAa,IAAI,GAAG,MAAM,aAAa,OAAO,MAAM;AAAA,IAC9E;AAAA,IACA,UAAU,oBAAC,UAAO,OAAO,QAAQ;AAAA,KACpC;AAGF,MAAI,SAAS;AACX,UAAM,EAAE,UAAUA,KAAI,SAASC,KAAI,MAAMC,KAAI,QAAQC,KAAI,SAASC,KAAI,UAAUC,KAAI,SAASC,KAAI,WAAWC,MAAK,GAAGC,MAAK,IAAI;AAC7H,WACE,oBAAC,QAAK,WAAW,KAAM,GAAGA,OACvB,UACH;AAAA,EAEJ;AAEA,MAAI,MAAM,SAAS,QAAW;AAC5B,UAAM,EAAE,UAAUR,KAAI,SAASC,KAAI,MAAMC,KAAI,QAAQC,KAAI,SAASC,KAAI,UAAUC,KAAI,SAASC,KAAI,WAAWC,MAAK,MAAM,GAAGC,MAAK,IAAI;AACnI,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,WAAW;AAAA,QAGX,iBAAe,WAAW;AAAA,QACzB,GAAGA;AAAA,QAEH;AAAA;AAAA,IACH;AAAA,EAEJ;AAEA,QAAM,EAAE,UAAU,IAAI,SAAS,IAAI,MAAM,IAAI,QAAQ,IAAI,SAAS,IAAI,UAAU,IAAI,SAAS,IAAI,WAAW,KAAK,GAAG,KAAK,IAAI;AAC7H,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAM,KAAK,QAAQ;AAAA,MACnB,aAAW,WAAW;AAAA,MACtB,UAAU,KAAK,YAAY;AAAA,MAC3B,WAAW;AAAA,MACV,GAAG;AAAA,MAEH;AAAA;AAAA,EACH;AAEJ;AAEA,IAAO,iBAAQ;","names":["_c","_v","_s","_k","_l","_i","_a","_cn","rest"]}
1
+ {"version":3,"sources":["../../../src/components/Button/Button.tsx"],"sourcesContent":["import { Slot } from '@radix-ui/react-slot'\nimport type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'\nimport { cn } from '../../lib/cn'\nimport { DEV, warn } from '../../lib/warn'\nimport { Spinner } from '../Spinner/Spinner'\n\nexport type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'\nexport type ButtonSize = 'sm' | 'md' | 'lg'\n\n/**\n * The base every variant shares.\n *\n * It reads `--radius`, the CONTROL step, which is the same corner an `Input`,\n * a `Select` trigger and a `Textarea` draw. It used to be a pill, and a pill\n * beside an 8px field is two different ideas of what a control is — the one\n * inconsistency in the system that every reader noticed and none of the\n * component pages could explain. Everything on the control line now agrees,\n * and the capsule is kept for the things that genuinely are one: a badge, a\n * status pill, a segmented strip, a progress track.\n *\n * It does not move on hover. An earlier base lifted a pixel and asked for a\n * shadow the White Reset does not have — `--shadow` resolves to `none`, so that\n * hover state was invisible on every button in the system while still costing a\n * transition. Opacity carries the filled hover; the border carries the outlined\n * one.\n *\n * The transition names its properties rather than using `all`, so a layout\n * change (a width that grows when the label becomes \"Saving…\") is instant\n * instead of sliding.\n */\nconst BASE =\n 'inline-flex items-center justify-center gap-(--control-gap) rounded-(--radius) font-sans transition-[opacity,color,border-color,background-color] duration-(--duration-fast) ease-(--ease-out-expo) disabled:opacity-(--disabled-opacity) disabled:pointer-events-none aria-disabled:opacity-(--disabled-opacity) aria-disabled:pointer-events-none'\n\nconst VARIANT: Record<ButtonVariant, string> = {\n primary: 'bg-(--accent) text-(--accent-foreground) border border-(--accent) hover:opacity-85',\n secondary: 'border border-(--rule-2) text-(--ink) bg-transparent hover:border-(--ink)',\n ghost: 'border border-transparent text-(--ink-2) hover:text-(--ink) hover:bg-(--stone)',\n /* The one place chroma is allowed on a control, and only because the action\n is destructive — that is a state, not a brand colour. */\n danger: 'bg-(--danger) text-(--paper) border border-(--danger) hover:opacity-85',\n}\n\n/**\n * `min-h` rather than `h`, so a button that wraps to two lines grows instead of\n * clipping.\n *\n * The heights come from the density tokens rather than from a literal, so a\n * subtree marked `data-density=\"compact\"` shrinks its controls without a single\n * call site being told. At the default density `md` is 44px — the pointer\n * target WCAG 2.5.5 asks for.\n *\n * `--control-lh` is what makes those numbers true rather than aspirational. A\n * floor only binds while the box is under it, and the box is the line plus the\n * padding plus the border — so a label inheriting the body's 1.6 leading put\n * `sm` at 39px against its own 36px token, `md` at 46 against 44 and `lg` at 50\n * against 48. Every size was two to three pixels taller than the number\n * documenting it, which is also how a masthead sized from `--control-h-sm`\n * ended up with a search field standing proud of every icon beside it.\n */\nconst SIZE: Record<ButtonSize, string> = {\n sm: 'min-h-(--control-h-sm) px-(--control-px-sm) py-(--control-py-sm) text-[13px] leading-(--control-lh)',\n md: 'min-h-(--control-h-md) px-(--control-px-md) py-(--control-py-md) text-sm leading-(--control-lh)',\n lg: 'min-h-(--control-h-lg) px-(--control-px-lg) py-(--control-py-lg) text-[15px] leading-(--control-lh)',\n}\n\n/** Square, because an icon has no label to give the box its width. */\nconst ICON_SIZE: Record<ButtonSize, string> = {\n sm: 'size-(--control-h-sm) p-0',\n md: 'size-(--control-h-md) p-0',\n lg: 'size-(--control-h-lg) p-0',\n}\n\nconst SPINNER_SIZE: Record<ButtonSize, 'sm' | 'md'> = { sm: 'sm', md: 'sm', lg: 'md' }\n\ninterface CommonProps {\n children?: ReactNode\n /**\n * Which action this is. `primary` is the one thing the screen wants you to\n * do, so there is at most one per view; `danger` is reserved for destructive\n * actions and is the only place chroma is allowed on a control.\n */\n variant?: ButtonVariant\n /** 36 / 44 / 48px tall. `md` meets the pointer-target floor on its own. */\n size?: ButtonSize\n /** Optional mono keycap glyph rendered after the label (e.g. \"P\"). */\n keycap?: string\n /**\n * Swaps the leading content for a spinner and blocks interaction. The label\n * stays put — a button that empties out while it works loses its width, and\n * the page reflows under the pointer that just clicked it.\n */\n loading?: boolean\n /**\n * Square control with no label. REQUIRES `aria-label`: an icon-only button\n * with no accessible name is invisible to a screen reader, and this is the\n * single most common way a design system ships an unusable control.\n */\n iconOnly?: boolean\n /**\n * Render the child element instead of a `<button>`, keeping these styles.\n * Use it to hand the styling to a router's `Link` — `asChild` is what keeps\n * this package free of any one router.\n *\n * The decoration slots (`keycap`, `loading`) are not injected into a slotted\n * child; compose them inside it yourself.\n *\n * @example\n * <Button asChild><Link href=\"/work\">Work</Link></Button>\n */\n asChild?: boolean\n className?: string\n}\n\ntype ButtonAsLink = CommonProps &\n Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'className' | 'children'> & { href: string }\n\ntype ButtonAsButton = CommonProps &\n Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'children'> & { href?: undefined }\n\nexport type ButtonProps = ButtonAsLink | ButtonAsButton\n\nfunction Keycap({ glyph }: { glyph: string }) {\n return (\n <span className=\"rounded-(--radius-sm) border border-current px-2 py-0.5 font-mono text-[11px] opacity-55\">\n {glyph}\n </span>\n )\n}\n\n/**\n * The system's action.\n *\n * Renders a `<button>`, or an `<a>` when given `href`, or whatever you hand it\n * via `asChild`. No router is imported, so the package stays framework-agnostic\n * and a Next or React Router app wires its own `Link` at the call site.\n *\n * Server-component friendly: there is no client boundary here, so it renders in\n * a static page as well as an interactive one.\n *\n * @example\n * <Button variant=\"secondary\" keycap=\"P\">View projects</Button>\n * @example\n * <Button loading disabled>Saving…</Button>\n * @example\n * <Button iconOnly aria-label=\"Close\"><X size={16} /></Button>\n */\nexport function Button(props: ButtonProps) {\n const {\n children,\n variant = 'primary',\n size = 'md',\n keycap,\n loading = false,\n iconOnly = false,\n asChild = false,\n className,\n } = props\n\n const cls = cn(BASE, VARIANT[variant], iconOnly ? ICON_SIZE[size] : SIZE[size], className)\n\n if (DEV && iconOnly) {\n // The control renders correctly and is announced as \"button\" and nothing\n // else. Nothing about the rendered page says so, which is why this is the\n // single most common way a design system ships an unusable control.\n const named =\n typeof props['aria-label'] === 'string' && props['aria-label'].trim() !== ''\n const described = typeof props['aria-labelledby'] === 'string'\n if (!named && !described) {\n warn({\n code: 'BUTTON_ICON_ONLY_UNNAMED',\n problem:\n 'An iconOnly Button has no text, so without an accessible name a screen reader announces it as \"button\" and nothing else.',\n field: 'aria-label',\n fix: 'Add aria-label with the action the button performs, e.g. aria-label=\"Delete invoice\".',\n component: 'Button',\n })\n }\n }\n\n // A filled ground needs a spinner in the inherited colour; an outlined one\n // reads better against the page's own rule scale.\n const spinnerTone = variant === 'primary' || variant === 'danger' ? 'current' : 'default'\n const decoration = (\n <>\n {loading && <Spinner size={SPINNER_SIZE[size]} tone={spinnerTone} label={null} />}\n {children}\n {keycap && <Keycap glyph={keycap} />}\n </>\n )\n\n if (asChild) {\n const { children: _c, variant: _v, size: _s, keycap: _k, loading: _l, iconOnly: _i, asChild: _a, className: _cn, ...rest } = props\n return (\n <Slot className={cls} {...rest}>\n {children}\n </Slot>\n )\n }\n\n if (props.href !== undefined) {\n const { children: _c, variant: _v, size: _s, keycap: _k, loading: _l, iconOnly: _i, asChild: _a, className: _cn, href, ...rest } = props\n return (\n <a\n href={href}\n className={cls}\n // A link cannot be `disabled`; `aria-disabled` plus the pointer-events\n // rule in BASE is the accessible equivalent.\n aria-disabled={loading || undefined}\n {...rest}\n >\n {decoration}\n </a>\n )\n }\n\n const { children: _c, variant: _v, size: _s, keycap: _k, loading: _l, iconOnly: _i, asChild: _a, className: _cn, ...rest } = props\n return (\n <button\n type={rest.type ?? 'button'}\n aria-busy={loading || undefined}\n disabled={rest.disabled || loading}\n className={cls}\n {...rest}\n >\n {decoration}\n </button>\n )\n}\n\nexport default Button\n"],"mappings":"AA2HI,SA4DA,UA5DA,KA4DA,YA5DA;AA3HJ,SAAS,YAAY;AAErB,SAAS,UAAU;AACnB,SAAS,KAAK,YAAY;AAC1B,SAAS,eAAe;AA0BxB,MAAM,OACJ;AAEF,MAAM,UAAyC;AAAA,EAC7C,SAAS;AAAA,EACT,WAAW;AAAA,EACX,OAAO;AAAA;AAAA;AAAA,EAGP,QAAQ;AACV;AAmBA,MAAM,OAAmC;AAAA,EACvC,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAGA,MAAM,YAAwC;AAAA,EAC5C,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAEA,MAAM,eAAgD,EAAE,IAAI,MAAM,IAAI,MAAM,IAAI,KAAK;AAiDrF,SAAS,OAAO,EAAE,MAAM,GAAsB;AAC5C,SACE,oBAAC,UAAK,WAAU,4FACb,iBACH;AAEJ;AAmBO,SAAS,OAAO,OAAoB;AACzC,QAAM;AAAA,IACJ;AAAA,IACA,UAAU;AAAA,IACV,OAAO;AAAA,IACP;AAAA,IACA,UAAU;AAAA,IACV,WAAW;AAAA,IACX,UAAU;AAAA,IACV;AAAA,EACF,IAAI;AAEJ,QAAM,MAAM,GAAG,MAAM,QAAQ,OAAO,GAAG,WAAW,UAAU,IAAI,IAAI,KAAK,IAAI,GAAG,SAAS;AAEzF,MAAI,OAAO,UAAU;AAInB,UAAM,QACJ,OAAO,MAAM,YAAY,MAAM,YAAY,MAAM,YAAY,EAAE,KAAK,MAAM;AAC5E,UAAM,YAAY,OAAO,MAAM,iBAAiB,MAAM;AACtD,QAAI,CAAC,SAAS,CAAC,WAAW;AACxB,WAAK;AAAA,QACH,MAAM;AAAA,QACN,SACE;AAAA,QACF,OAAO;AAAA,QACP,KAAK;AAAA,QACL,WAAW;AAAA,MACb,CAAC;AAAA,IACH;AAAA,EACF;AAIA,QAAM,cAAc,YAAY,aAAa,YAAY,WAAW,YAAY;AAChF,QAAM,aACJ,iCACG;AAAA,eAAW,oBAAC,WAAQ,MAAM,aAAa,IAAI,GAAG,MAAM,aAAa,OAAO,MAAM;AAAA,IAC9E;AAAA,IACA,UAAU,oBAAC,UAAO,OAAO,QAAQ;AAAA,KACpC;AAGF,MAAI,SAAS;AACX,UAAM,EAAE,UAAUA,KAAI,SAASC,KAAI,MAAMC,KAAI,QAAQC,KAAI,SAASC,KAAI,UAAUC,KAAI,SAASC,KAAI,WAAWC,MAAK,GAAGC,MAAK,IAAI;AAC7H,WACE,oBAAC,QAAK,WAAW,KAAM,GAAGA,OACvB,UACH;AAAA,EAEJ;AAEA,MAAI,MAAM,SAAS,QAAW;AAC5B,UAAM,EAAE,UAAUR,KAAI,SAASC,KAAI,MAAMC,KAAI,QAAQC,KAAI,SAASC,KAAI,UAAUC,KAAI,SAASC,KAAI,WAAWC,MAAK,MAAM,GAAGC,MAAK,IAAI;AACnI,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,WAAW;AAAA,QAGX,iBAAe,WAAW;AAAA,QACzB,GAAGA;AAAA,QAEH;AAAA;AAAA,IACH;AAAA,EAEJ;AAEA,QAAM,EAAE,UAAU,IAAI,SAAS,IAAI,MAAM,IAAI,QAAQ,IAAI,SAAS,IAAI,UAAU,IAAI,SAAS,IAAI,WAAW,KAAK,GAAG,KAAK,IAAI;AAC7H,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAM,KAAK,QAAQ;AAAA,MACnB,aAAW,WAAW;AAAA,MACtB,UAAU,KAAK,YAAY;AAAA,MAC3B,WAAW;AAAA,MACV,GAAG;AAAA,MAEH;AAAA;AAAA,EACH;AAEJ;AAEA,IAAO,iBAAQ;","names":["_c","_v","_s","_k","_l","_i","_a","_cn","rest"]}
@@ -0,0 +1,67 @@
1
+ import * as react from 'react';
2
+
3
+ interface ColorPickerProps {
4
+ /**
5
+ * Names the control. Required — the trigger shows a colour, and a colour is
6
+ * not a name.
7
+ *
8
+ * Announced together with the value, the way `Select`'s is: a reader hears
9
+ * "Brand colour, #a78bfa". Inside a `Field` with a label, that label is used
10
+ * and this one is not repeated.
11
+ */
12
+ label: string;
13
+ /** The colour, as any absolute CSS colour string. */
14
+ value?: string;
15
+ defaultValue?: string;
16
+ /**
17
+ * Fires with a CSS colour string in whichever notation the panel is set to —
18
+ * so the notation a caller passes in is the notation they get back until
19
+ * somebody changes it in the panel.
20
+ */
21
+ onValueChange?: (value: string) => void;
22
+ disabled?: boolean;
23
+ /** Paints the resting border with `--danger` and reflects `aria-invalid`. */
24
+ invalid?: boolean;
25
+ className?: string;
26
+ /** The TRIGGER's id — the element a label points at. A `Field` sets it. */
27
+ id?: string;
28
+ /** Ids of the copy describing the control. A `Field` sets it from hint, error and description. */
29
+ 'aria-describedby'?: string;
30
+ /** The spelling a form library sets; read together with `invalid`. */
31
+ 'aria-invalid'?: boolean | 'true' | 'false';
32
+ /** Announced on the trigger. A `Field` sets it from `required`. */
33
+ 'aria-required'?: boolean;
34
+ }
35
+ /**
36
+ * A colour, chosen or typed.
37
+ *
38
+ * The panel works in OKLCH, and that is the reason to reach for this rather
39
+ * than `<input type="color">`. In HSV — which is what the native picker and
40
+ * most libraries use — dragging along a row of constant "lightness" walks
41
+ * through colours the eye reads as getting darker, so a reader tuning a palette
42
+ * is fighting the instrument. OKLCH's lightness is the one a person sees, so
43
+ * two colours at the same height on the plane genuinely match, and the hue
44
+ * strip stays at the lightness already chosen instead of showing a rainbow that
45
+ * belongs to some other colour.
46
+ *
47
+ * The plane is normalised to the gamut ROW BY ROW: its right edge is the most
48
+ * chroma that exists at that lightness and hue, so the whole surface is
49
+ * reachable rather than a lens of colour inside a field of clipped duplicates.
50
+ *
51
+ * **What it accepts.** Hex, `rgb()`, `hsl()`, `oklch()` and
52
+ * `color(display-p3 …)`, in both syntaxes. Named colours are not accepted:
53
+ * resolving them needs a table of every CSS name or a live DOM, and a box that
54
+ * takes "rebeccapurple" but not "papayawhip" is worse than one that takes
55
+ * neither.
56
+ *
57
+ * **Keyboard.** The plane is a group of two real sliders — chroma across,
58
+ * lightness up — so the arrows move it and a screen reader announces where it
59
+ * is. That is the part a 2D canvas usually leaves out, and leaving it out makes
60
+ * the control unusable rather than merely awkward.
61
+ *
62
+ * @example
63
+ * <Field label="Brand colour"><ColorPicker label="Brand colour" defaultValue="#a78bfa" /></Field>
64
+ */
65
+ declare function ColorPicker({ label, value: controlled, defaultValue, onValueChange, disabled, invalid, className, id, 'aria-describedby': describedBy, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, }: ColorPickerProps): react.JSX.Element;
66
+
67
+ export { ColorPicker, type ColorPickerProps, ColorPicker as default };