import { CopyBlock } from '~/chrome/ui/copy'; /* * `Paragraph` and `SubHeading` are aliased back to the one-letter names this * document reads best with — `

` appears 55 times in a page that is prose. * `ProseTable` is not aliased on purpose: `Table` is the name `chrome/ui/tables.tsx` * exports, and shortening it here is how a future import of that one becomes a * collision. */ import { Finding, Hex, Paragraph as P, ProseTable, SubHeading as H3 } from '~/chrome/ui/prose'; import { useMessages } from '../i18n'; import { Link } from '../router'; /** * The narrative half of Figma parity: what was found, the evidence, and which * side has to move. * * Ported from `docs/FigmaParity.mdx` when Storybook became optional. It is * deliberately separate from `parity.tsx`, which is generated from * `figma-spec.json` — that page tells you *where* the two disagree, this one * tells you *why* and what to do about it. A finding only lands here once * somebody has resolved it against the variable table. * * Kept in English, unlike the rest of the site: it is an audit record aimed at * whoever is holding the Figma file open, and it is mostly node ids, hex values * and variable paths — none of which translate. The tab says so. */ export function ParityFindings() { const m = useMessages(); return (

File: CBAR - Design System (Copy) ·{' '} Date: 2026-08-02, §7 added 2026-08-06 ·{' '} Method: the local bridge plugin ( figma_variables, figma_node, figma_css), read live — no REST, no cached export.

Discrepancies found between CBAR’s Figma file and this kit, with the evidence for each and which side has to change. They are written down rather than fixed in passing because every one of them is a decision for the design file, not for the code.

{m.parity.findingsEnglishOnly}

Severity: high — Button is the most used component in the set, and the same wiring turned up in a second one.{' '} Where: component sets Button{' '} (node 2446:6959, page “Button”) and avatar (node{' '} 2446:757, page “Avatar”).{' '} Who is wrong: Figma. This kit is correct and must not be changed. } >

What renders — Button

Every colorPalette=primary variant of Button paints with the{' '} secondary (turquoise) role, and every colorPalette=secondary variant paints with the primary (navy) role. It holds across all five treatments, not just solid:

solid, primary, surface/colored/secondary, , surface/colored/primary, ], [ solid, secondary, surface/colored/primary, , surface/colored/secondary, ], [ subtle, primary, surface/subtle/secondary, , surface/subtle/primary, ], [ subtle, secondary, surface/subtle/primary, , surface/subtle/secondary, ], [ outline, primary, border/colored/secondary, , border/colored/primary, ], [ outline, secondary, border/colored/primary, , border/colored/secondary, ], [ surface, primary, surface/subtle/secondary + border/colored/secondary, , both …/primary , ], [ surface, secondary, surface/subtle/primary + border/colored/primary, , both …/secondary , ], ]} />

ghost draws neither a fill nor a border for either palette, so it is unaffected.

Scope: 160 of the set’s 560 variants — 2 palettes × 5 treatments × 4 states × 4 sizes.

The same crossing on the avatar set

Found while building{' '} the avatar Figma-layout tab , which reads each band’s fill rather than trusting its name. All three of avatar’s treatments are crossed the same way Button’s five are:

solid, primary, surface/colored/secondary, , surface/colored/primary, ], [ solid, secondary, surface/colored/primary, , surface/colored/secondary, ], [ subtle, primary, surface/subtle/secondary, , surface/subtle/primary, ], [ subtle, secondary, brand/primary/50, , surface/subtle/secondary, ], [ outline, primary, border/subtle/secondary, , border/subtle/primary, ], [ outline, secondary, border/subtle/primary, , border/subtle/secondary, ], ]} />

One row is wrong twice over. Avatar’s subtle /{' '} secondary cell binds brand/primary/50 — a raw ramp step rather than a semantic role, and one step lighter than the …/100 the other treatments read. Rebinding it to surface/subtle/secondary fixes both at once. Note also that avatar’s outline takes its stroke from{' '} border/subtle/* where Button takes it from border/colored/*; that difference is the set’s own choice and is left alone here.

Scope: 72 of the set’s 150 variants — 2 palettes × 3 treatments × 2 shapes × 6 sizes. The six{' '} avatar-type=avatar variants are drawn only on third, so they are unaffected.

Everything else about avatar matches: the box ladder (24 / 32 / 36 / 40 / 48 / 64px), the type ladder (10 / 12 / 14 / 16 / 20 / 24px) and the flat 8px square corner are identical to what the kit draws, measured rung by rung through the bridge.

Why Figma is the one that is wrong

Three independent places in the same file disagree with Button:

  1. The variable table ( figma_variables, the layer the REST API refuses):
                  {`brand/primary/500          = #004976   (navy)
    brand/secondary/500        = #4BC7B5   (turquoise)
    surface/colored/primary   -> brand/primary/500
    surface/colored/secondary -> brand/secondary/500
    border/colored/primary    -> brand/primary/300
    border/colored/secondary  -> brand/secondary/200`}
                
  2. IconButton (2399:522), Button’s closest sibling — variant=solid, state=default, size=md: primary , secondary . Correct.
  3. ProgressCircle (2264:23) — track stroke at size=md: primary (Primary-100), secondary (Secondary-100). Correct.

And this kit: src/styles/tokens.css names CBAR’s Primary ramp{' '} --ui-color-brand-* ( at 500, read by{' '} .palette-primary) and its Secondary ramp --ui-color-secondary-* ( ). Both match the variable table.

The two ways to fix it, and which to pick

A — the variant names are wrong (recommended).{' '} Swap the colorPalette value on all 232 crossed variants — 160 on Button, 72 on avatar: what is called secondary today becomes primary, and vice versa. Bindings are left alone.

The argument for A is each set’s own default. Button’s is{' '} colorPalette=secondary and renders navy, so CBAR’s default button already is the brand primary colour and only the label on it is wrong. Avatar’s is colorPalette=primary and renders turquoise, so the rename lands it on secondary — correct for what it draws. In both cases A changes no pixel of anything anyone has already placed.

B — the bindings are wrong. Rebind each cell’s fill and stroke to the opposite role. If you take this route you must{' '} also move each set’s default, or the default silently changes colour in every instance in every file — Button turning turquoise, avatar turning navy. Avatar needs one extra edit either way: its{' '} subtle / secondary cell has to leave{' '} brand/primary/50 for a semantic role, which no rename can do.

Either way, nothing changes in this kit.{' '} Do not “fix” this by swapping the ramps in tokens.css. {' '} That trades one wrong component for a whole kit that disagrees with the variable table it is generated from — and tokens.css is machine-owned, so the next{' '} figma-sync would overwrite the edit anyway.
Severity: low.{' '} Where: same set. } >

variant=outline, colorPalette=secondary carries a visible{' '} white fill ( ); variant=outline, colorPalette=primary carries none. An outline button should have no fill on either. Independent of §1 — fixing the palette crossing will not fix this.

Severity: was high (a visible divergence).{' '} Where: same set.{' '} Who moved: this kit. } >

Every solid fill in the set was read together with the label drawn on it:

secondary, , ], [primary, , ], [red, , ], [green, , ], [yellow, , ], [black, , ], [third, , ], ]} />

So CBAR’s rule is: white on every solid fill, with yellow the single exception — the one place it darkens the ink instead. The kit had made that same trade in two more places, on the turquoise and the green, because white measures{' '} 2.07:1 and{' '} 2.28:1 there against the 3:1 WCAG asks for a control.

That deviation is now removed: .palette-secondary and{' '} .palette-green set --ctl-solid-fg to{' '} --ui-color-neutral-0. Fidelity to the design system was chosen over the contrast number, deliberately and with the number on the record —{' '} test/contrast.test.ts still measures both pairs and prints them, with a floor of 1 so they no longer gate the suite. --ctl-fg, the ink every tinted treatment uses, did not move and still clears 4.5:1. Consumer-facing note in README §9c.

The design-side finding stands: if CBAR wants those two labels to pass AA, the fill has to move, not the ink. secondary-700{' '} and green-700 both carry white comfortably.

Severity: none — recorded so nobody “implements” a state that does not exist. } >

Across all five treatments of the primary band, each state=active variant paints exactly what its state=hover twin paints (2446:9576 solid,{' '} 2446:7384 subtle, 2446:7464 surface, 2446:7544{' '} outline, 2446:7624 ghost). The kit has no :active treatment at all, which lands on the same rendering. The showcase’s{' '} Figma layout {' '} tab therefore draws both rows with the same forced class and says so.

Two hover tints do differ, and are left alone for now:

disabled is a third: CBAR draws opacity: 0.3, the kit{' '} 0.5.

Severity: medium — it is the colour every secondary line of text on every page is painted in.{' '} Where: CBAR variable{' '} text/tertiary, the kit’s --ui-color-neutral-500 →{' '} --muted-foreground. Who moves:{' '} the design file, if anyone. The kit follows the ramp and should keep following it. } >

CBAR’s neutral ramp puts text/tertiary at . Read on the two surfaces it actually lands on:

neutral-500 , neutral-0 , 4.41:1 , 'fails, by 0.09', ], [ 'muted text on a muted surface', neutral-500 , neutral-100 , 3.87:1 , 'fails', ], [ 'dark: muted text on the page', neutral-400 , neutral-950 , 7.85:1 , 'passes comfortably', ], ]} />

The second row is the one worth reading twice. Muted text on a muted surface is the{' '} more common pairing in practice — table headers, code blocks, chips, every secondary line inside a Card — and it is well under, not marginally under.

The third row is why this is filed as a light-mode finding rather than a token finding. Dark mode drops --muted-foreground to neutral-400 and clears AA outright, so the ramp is not the problem; the choice of step on a white page is.

How it was found, and where it is recorded

The showcase’s own axe run — the Axe{' '} switch in the toolbar, or ?a11y=on on any page — reports it on every page in light mode and nothing in dark. After the five structural fixes made while that panel was built, this token is the only remaining violation anywhere on the site — which is the useful part: the panel is reporting a known trade, not a regression, and a new finding there stands out.

test/contrast.test.ts carries all three rows. The two light ones have a floor of 3 — enough to catch a real regression, low enough not to gate the suite on a decision already taken — and the dark one has the full 4.5, because nothing about it is a compromise.

What the design file could do

Nothing here is fixable in code without leaving the ramp, which is not worth doing for 0.09. The options belong to CBAR:{' '} text/tertiary moves one step to neutral-600 ( , 8.6:1, but it stops reading as tertiary), or the file states that{' '} text/tertiary is for large text and non-essential labels only and a second variable covers body copy. Until one of those happens the kit renders what the ramp says.

Severity: low — nothing is unreadable, an affordance is simply absent. Where:{' '} --ui-shadow-xs … xl in tokens.css; CBAR’s{' '} shadows/shadows light/* effect styles.{' '} Who moves: the design file — it has no dark elevation set to sync from. } >

Every one of the kit’s five shadows is painted in{' '} oklch(0.145 0 0) at 5–10% alpha. That value is not merely dark, it is{' '} exactly --ui-color-neutral-950{' '} — which is what .dark sets{' '} --background to. So in dark mode a raised surface casts a shadow in the colour of the page it is sitting on, at one tenth opacity. It is not subtle; it is not there.

theme.css’s .dark block overrides forty-odd roles and none of them is a shadow, so this is not an oversight in one component — the whole elevation scale is light-mode-only. It is visible on the{' '} tokens page : switch to dark and the five elevation swatches become five flat rectangles.

Why the sync cannot fix it

tokens.css marks this block UNMAPPED{' '} — CBAR’s file defines no elevation scale at all as a variable collection, and the only shadow effect anywhere in it is a 1px inner shadow used as a border. What it does publish is a set of effect styles named{' '} shadows/shadows light/* — and the name is the finding. There is no{' '} shadows dark/* beside it. The showcase’s own chrome uses those published values (--cbar-shadow-* in showcase.css) and inherits the same gap.

So a figma-sync run cannot close this. There is nothing on the other side to read.

What each side can do

Design: publish a{' '} shadows dark/* set. The usual answer is not a darker shadow — on a near-black page there is no room below — but a larger spread at higher alpha plus a light 1px ring, so the edge does the work the drop shadow does in light.

Kit: the convention CBAR already uses in light is available and costs no new token. It tints its cards one step off the page rather than relying on a shadow, which is why --card is a tinted surface here and not white — and .dark already lifts --card to{' '} neutral-900. Extending that lift to the popover/dialog layers would give dark mode a real elevation cue in the file’s own idiom. Left undone deliberately: it is a visual change to every floating surface, so it wants its own decision rather than a quiet fix inside a parity write-up.

Severity: low — one copy-paste artefact on three of 72 variants, plus two off-ramp colours.{' '} Where: set Alert ( 2446:316); nodes 2446:592, 2446:597,{' '} 2446:587. Who moves: the design file — the kit’s outline is border-only by definition. } >

Read in full while rebuilding the{' '} Alert Figma-layout tab . The headline is the good news, and it is the direct counter-example to §1: this set is bound to the variables correctly. Its status axis is what the kit expresses as{' '} colorPalette, and every band’s fill lands on the ramp step the variable table says it should.

, , , secondary], ['warning', , , , yellow], ['success', , , , green], ['error', , , , red], ['info-secondary', , , , primary], ['neutral', , , , black], ]} />

Note what info is: the turquoise brand/secondary/500, while{' '} info-secondary is the navy brand/primary/500. That is the same pair §1 finds crossed on Button — and here it is the right way round, which is a third witness (beside IconButton and ProgressCircle) that Button is the single mis-wired set rather than the ramps being backwards. The showcase’s band table was corrected to match; tokens.css was not touched.

The stray outline fill

variant=outline is supposed to be a border with nothing behind it, and on{' '} info, error and neutral it is. On{' '} warning (2446:592), success (2446:597) and info-secondary (2446:587) it carries a{' '} fill — brand/primary/100, the navy tint. On{' '} info-secondary that makes outline pixel-identical to{' '} surface; on the other two it puts a blue wash under a yellow or green border. It reads as three cells pasted from the surface row and re-bordered.

Not replicated. The kit’s outline stays{' '} border-(--ctl-border) with no background at every palette, so the Figma tab shows three cells that are lighter than the canvas. Same call as §2, which is the same bug on Button.

Two colours off the ramp, and one icon doing six jobs

The error border is — pure red, not{' '} red-600 . And warning’s subtle fill is , which is Tailwind’s yellow-100, not CBAR’s{' '} . The second one is the stale standalone Yellow{' '} collection surfacing again — the same one that put nine wrong steps in the kit before the Variables became readable. Neither is worth a token change; both are worth knowing before somebody eyedrops a value off this set.

Lastly, all 72 variants draw the same icon —{' '} Outline/Status/Info-triangle — including success and{' '} neutral. There is no INSTANCE_SWAP property to vary it, so a consumer reading the set alone would think an alert has one icon. The kit takes the icon as a child and imposes nothing, which is the right shape; the Figma tab draws{' '} InfoTriangleIcon on all six bands purely to match the canvas.

Severity: low — four of five states match to the byte; the fifth exposes an inconsistency inside the design file itself.{' '} Where: set Toast ( 2414:10758); nodes 2414:10757, 2414:10759; compared against Alert 2446:432 and Badge{' '} 2456:5339. Who moves: the design file — the kit cannot satisfy both spellings at once. } >

Read while building the{' '} Toast Figma-layout tab . This set is the easiest to audit so far: getCSSAsync() returns the bound variable names directly, so there is nothing to infer from hex values.

, , subtle-positive / colored-positive, success, 'exact'], ['error', , , subtle-negative / colored-negative, error, 'exact'], ['warning', , , subtle-warning / colored-warning, warning, 'exact'], ['info', , , subtle-secondary / colored-secondary, info, 'exact'], ['neutral', , , subtle-gray / colored-black, default, 'differs'], ]} />

info binds --surface-subtle-secondary — the turquoise again, the third independent witness after §7’s info band and Badge’s{' '} default colour that §1 is a Button-and-avatar defect rather than the ramps being backwards. kindPalettes in toast.tsx already maps the first four rows exactly, and the kit renders them byte-identical to the canvas.

Two neutral surfaces, applied inconsistently

The fifth row does not match, and the reason is not on the kit’s side. CBAR carries{' '} two variables for the same visual role, and different sets reach for different ones:

--surface-subtle-neutral, , ], ['Toast (2414:10757)', --surface-subtle-gray, , ], ['Badge (2456:5339)', --surface-subtle-gray, , ], ]} />

The kit has one palette-black, whose --ctl-subtle is{' '} and --ctl-border . So it agrees with Alert and reads a step lighter than Toast and Badge. Not changed: re-pointing{' '} --ctl-subtle at would fix two sets by breaking the one that is currently right, and tokens.css is machine-owned — a{' '} figma-sync would undo a hand edit anyway. The design file should collapse the two variables to one; until then the kit stays on the value the majority of its own components already render.

Geometry matches too, measured rather than assumed: 12px 16px padding, a 6px radius, a 3px border on the leading edge only, 10px between icon and text, title 14/600 over description 14/28 with 5px between them, glyphs at 20px. Two differences are the kit’s and both are deliberate — the canvas frames every toast at a fixed 306px where the kit’s column is 384px and the toast fills it, and the kit adds{' '} shadow-lg because a notification floating over live content has to read as elevated where a flat swatch on a canvas does not.

The same glyph five times — adopted

Every variant’s left slot instantiates Solid/Status/Info-circle ( 2163:653) — including success and error — and only the fill changes. Checked on all five through the bridge rather than inferred from one, and the close cross beside it takes the same colour:

, green-600, '3.00:1', '6.18:1'], ['error', , red-600, '3.65:1', '6.86:1'], ['warning', , yellow-ink, '5.31:1', '5.31:1'], ['info', , secondary-500, '1.74:1', '8.34:1'], ['neutral', , neutral-800, '15.11:1', '15.11:1'], ]} />

Every one is already a named ramp step, which is what made adopting them a token change rather than five literals. The close cross takes the same colour as the status glyph, so the table covers it too. The last column is --ctl-fg, what the title and description are set in and what the glyph used to use: on warning and{' '} neutral the two agree byte-for-byte, and on the other three the glyph is now the lighter of the two. info is the extreme — a turquoise glyph on a turquoise surface at 1.74:1 is decorative rather than legible, and the text beside it stays at 8.34:1.

Unlike Alert there is an INSTANCE_SWAP property (icon, defaulting to that node), so the set can vary it and simply does not.

This one was adopted rather than recorded.{' '} The kit used to pick a glyph per kind — a tick for success, a warning triangle for warning, an octagon for error, and none at all for a plain toast. It now draws CBAR’s single info-circle on all five and tells them apart by colour, which is the design file’s own scheme. loading keeps its spinner: it has no counterpart on the canvas, and a static circle would throw away the one thing a pending toast has to say.

The ink moved with it, through a new --ctl-icon role in{' '} theme.css bound to the ramp steps above. It is a lower-contrast role than --ctl-fg and must never be used for text — that is the cost, and it is the second time on this page that matching CBAR was chosen over a contrast number, after §3. Both tabs of /toasts now render the same thing, and the Figma-layout tab forces nothing at all: the two sides converged, which is the outcome a parity page exists to produce.

The close button defaults on

iconRight? is a boolean defaulting true, so every variant on the canvas carries a dismiss control. The kit’s{' '} Toaster closeButton defaults false. Left alone on purpose: flipping it would add a button to every toast in every app already consuming the kit, for a difference that is one prop away. The Figma-layout tab draws it on to match the canvas and says so.

A reporting bug this set exposed

Toast’s only axis is named state, and the parity report treats an axis by that name as an interaction concern drawn in CSS — correct for the fourteen other sets whose state holds hover / focused /{' '} disabled, and wrong here, where it holds statuses. Toast was therefore being reported with no comparable axis at all. Fixed with an explicit{' '} statusAxes opt-out on the registry entry rather than a cleverer heuristic, because Button already maps its own state axis onto a{' '} disabled prop while genuinely being a CSS state.

Checked and not a problem

Re-verifying this

Open the “CBAR Figma bridge” plugin in Figma, then from the project root:

Or, with no scripting at all: open{' '} the Button page {' '} and read the Figma panel at the bottom. It resolves the playground’s selection to the real variant and puts CBAR’s own render and computed style beside the kit’s — which is how §1 was found.

For §3 and §4 the faster route is the{' '} Figma layout tab : it arranges the live components the way the canvas arranges them — a band per palette, a column per treatment, a row per state — so the two can be compared cell for cell without resolving anything by hand.

); }