Clipper Kit v3.2

audit 0/0/0

The Staple design system, current. Every component in the kit is shown live beside the rules that govern it, rendered from its own spec sheet — the 21 rebuilt in the v3.2 gap-closure pass and the 25 that predate it. Switch theme in the header; all three are verified. Typography is Noto Sans throughout, on a shadcn-named --text-* / --font-weight-* scale.

48live components
49spec sheets
0blocking audit findings
3themes
Noto Sansone family, 100–900

UI Sanity Bible

QAUI-SANITY-BIBLE.md

One self-contained file for auditing a built screen against the kit — the seven audit passes in order, every token value printed, the 83 universal checks, the known-good exemptions and the measurement traps. Hand this to a developer or to QA; nothing else needs to be open. This section is generated from the file itself, so it cannot drift from it.

RULES — STAPLE-UI-QA/REFERENCES/UI-SANITY-BIBLE.MD

One file. Everything needed to audit an existing Staple flow against Clipper Kit v3.2 and write up what is wrong.

Self-contained on purpose — no need to open the kit, the token file or a component sheet to run an audit. Every value you need to compare against is printed here.

  • Version: Clipper Kit v3.2 token set · 55 components
  • Reconciled: 2026-09-16 against design-system.css?v=7ab031ca + clipper-kit.css?v=8da91cd1. Check for drift with python3 scripts/audit_tokens.py --tokens design-system.css?v=7ab031ca clipper-kit.css?v=8da91cd1
  • Use it for: auditing a built screen, cross-verifying a design file, reviewing a PR, deciding whether a new component may enter the kit
  • Do not use it for: guessing. If a value is not in here, it is not a rule, and inventing one is worse than leaving the inconsistency

How to Run an Audit

Seven passes, in this order. Each finds a different class of defect and later passes assume the earlier ones are clean.

PassWhatWhy this order
1. MechanicalRun audit_tokens.pyCatches undefined tokens, literals, magic numbers, !important in seconds. No point eyeballing a screen whose tokens do not resolve
2. Dark themeLook at every surface in darkWrong surface levels are invisible in light — all three surfaces are #ffffff there. This is the single highest-yield visual pass
3. StatesHover, active, focus, disabled, invalid on every control23 of 28 components shipped with no disabled state. Assume it is missing
4. MeasureAlignment, radius, size, contrast — with a tool, not an eyeSub-pixel drift is font metrics; 2px asymmetry is a defect. Only measurement separates them
5. KeyboardTab through it. Force-hover nothingHover-reveal without :focus-within, missing focus rings, focus traps. Found twice independently in this system
6. Per componentWalk each present component's section in UI-SANITY-CHECKS.mdCatches what the universal checks cannot — a badge wider than 200px, or stretched to a fixed width in a table column, a menu covering the row it belongs to, a crumb that wraps. Only walk sections for components the flow actually uses
7. Write upSeverity-ranked, each finding citing a ruleA finding that cites no rule is an opinion

Report format. Group by severity, fix in severity order, and never report a finding without naming the rule it breaks.

SevMeaning
1Broken — undefined token, invalid declaration, body-text contrast failure, missing focus ring, focusable-but-invisible control
2Wrong — unpaired surface, crossed families, wrong surface level, hardcoded value, missing state, off-scale size or radius
3Off-system — value not on a scale, !important, duplicated kit CSS, undocumented asymmetry
4Note — a token-set defect the screen inherits. Cite § 11; do not raise per screen

§ 1 — The four laws

Everything else is elaboration.

Law 1 — Names are shadcn's. background, foreground, card, popover, primary, secondary, muted, accent, destructive, border, input, ring, chart-1..5, sidebar*, radius. A surface carries no suffix; its text colour is the same name plus -foreground.

The legacy Clipper names are dead. Any of these in a flow is a Sev-2:

DeadUse
--bg--background
--fg--foreground
--primary-fg--primary-foreground
--muted-fg--muted-foreground
--border-strong--input
--focus--ring
--font-body--font-sans
--radius-base--radius-md
--on-primary / --on-destructive / --on-success / --on-warningthe matching -foreground
--switcher-on / -off / -thumb--primary / --toggle-track / --background
--header-bg / -fg / -border / -icon / -icon-hover--background / --foreground / --border / --muted-foreground / --muted
--space-1…--space-6--space-xs…--space-xl
--radius-btn / -card / -pill--radius-md / --radius-2xl / --radius-full
--cmd-backdrop--scrim
--nav-icon-active--primary
--icon-hover-bg--hover-bg§ 8 already makes --hover-bg the hover fill for any ghost control
--ck-sw-w / -h / -thumb / -inset--switch-w / -h / -thumb / -inset
.ck-clear-btn.ck-btn.ghost.destructiveone variant combination, not a component
.ck-fulfil / -label / -set.ck-pill + .ck-pill-counta count beside a label is the pill's own anatomy
.ck-divider.is-strong / .is-accent / .is-thick.ck-dividerone divider, one colour, one weight
mock-tag / mock-indicator / mock-label.ck-chip / .ck-status / .ck-field-label

Law 2 — Values come from tokens. No hex, rgb(), hsl(), oklch(), and no magic number — a hardcoded radius, duration or z-index is the same defect as a hardcoded colour. The only exception is geometry that is one component's identity (a dropdown panel's width: 320px), which must be recorded in that component's spec.

Law 3 — A surface never appears without its foreground. Setting a surface with no color, or crossing families, is what breaks dark and high-contrast.

Law 4 — A new component mints tokens in the same semantics, named for the role and never for the component or its appearance. --warning, not --yellow. Never --reconciliation-panel-bg.

§ 2 — Colour notation

Every colour value is 6-digit hex. Zero exceptions. No rgba(), hsl(), oklch(), color-mix(), 8-digit #RRGGBBAA, 3-digit shorthand, or none / transparent where a colour belongs.

Where alpha used to be needed, there is a 6-digit counterpart:

Alpha wasCounterpart
Tinting a knowable surfacePre-composited into the value
A shadow colourComposited against the page ground
ZeroZero geometry with a real hex: 0 0 0 0 #000000
An arbitrary backdropA hex plus a companion --*-opacity token
A color-mix() derivationResolved per theme, or minted as a token

opacity applies to descendants, so the --*-opacity split cannot go on an element containing something you do not want faded. The dialog scrim contains the dialog, so its tint is on a ::before.

§ 2a — Alpha that varies over time

The counterparts above cover alpha that is *fixed*. A ring that fades has neither escape available: pre-composing its trough against the page ground produces an opaque ring that expands and then sits there, and box-shadow has no opacity channel to split off.

An animated ramp rides a ::after ring. The colour stays a 6-digit hex token, the alpha rides a --pulse-*-opacity token, and the trough is always 0.

.thing { position: relative; }              /* the ring inherits radius */
.thing::after {
  content: ''; position: absolute; inset: 0; border-radius: inherit;
  box-shadow: 0 0 0 0 var(--destructive);
  opacity: var(--pulse-attention-opacity);
  animation: ck-pulse-attention 1.4s ease-in-out infinite;
}

Four ramps, named for what the pulse means — never for how strong it looks.

RampColourTokenLightDarkHCMeans
ck-pulse-attention--destructive--pulse-attention-opacity.42.50.70this needs action
ck-pulse-activity--info--pulse-activity-opacity.55.62.80this just changed
ck-pulse-input--primary--pulse-input-opacity.18.24.40waiting for entry
ck-pulse-guide--background--pulse-guide-opacity.55.62.80the tour spotlight

A fifth ramp is a defect, not a variant. If a pulse needs another meaning, put that meaning in the label or a status pill — the same rule § 8 applies to icon colour.

Every ramp stops under prefers-reduced-motion: reduce. The kit does this for .ck-pulse; a hand-rolled ramp must do it too.

A masked or edge-fade shadow is the one place a raw rgba() survives — a spotlight's 0 0 0 9999px cut-out and a sticky column's edge fade are gradients of transparency by definition, and have no token form. Both must carry a comment saying so, or QA cannot tell them from a missed literal.

§ 3 — Core tokens

TokenLightDarkHigh Contrast
--canvas#eef1f3#142226#000000
--group#e0f0ee#18302f#101c1c
--canvas-foreground#05262e#ffffff#ffffff
--background#ffffff#142226#000000
--foreground#05262e#ffffff#ffffff
--card#ffffff#233a3e#000000
--card-foreground#05262e#ffffff#ffffff
--popover#ffffff#2f5155#000000
--popover-foreground#05262e#ffffff#ffffff
--primary#013c4b#e7f9fe#66d9ef
--primary-foreground#ffffff#05262e#000000
--primary-bg#f0f3f4#1f2e32#081113
--secondary#f5f5f5#1b292d#1a1a1a
--secondary-foreground#292f32#ffffff#ffffff
--muted#f5f5f5#1b292d#1a1a1a
--muted-foreground#5a7278#bcd7dd#e0e0e0
--accent#e7f9fe#233a3e#111111
--accent-foreground#05262e#ffffff#ffffff
--destructive#e50600#fe9b98#ff4444
--destructive-foreground#ffffff#05262e#000000
--success#2e9e52#42c070#44ff88
--success-foreground#ffffff#142226#000000
--warning#f2a618#f7b83d#ffbb33
--warning-foreground#362207#142226#000000
--info#387ff9#92c4fe#44aaff
--info-foreground———
--border#f5f5f5#233a3e#666666
--panel-border#ebeff0#43575a#99a7ab
--input#8b9292#5f7073#999999
--field-border#d0d3d3#5f7073#999999
--ring#1c5260#e7f9fe#66d9ef
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)
--selected-fgvar(--primary)var(--primary)var(--primary)
--scrim#08272e#000000#000000
--icon-color#5a7278#bcd7dd#e0e0e0
--icon-color-destructive#d90500#fe9b98#ff4444
--skeleton-sheen#e8eaeb#26383d#2b2b2b

§ 4 — Surfaces and elevation

Four levels, and they are semantic, not decorative:

TokenFor
--canvasThe app ground — the page the app sits on. Viewport shell only. A panel on --canvas is a defect: it reads as a hole in the page
--backgroundThe default surface in it, and the default control fill
--cardElevated but in-flow: cards, panels, settings blocks, table bodies
--popoverFloating, out of flow: dropdowns, menus, tooltips, dialogs, drawers, toasts, popovers

Picking the wrong one is invisible in light theme — --background, --card and --popover are all #ffffff — and obvious in dark, where they are #142226 / #233a3e / #2f5155. Check dark, or you will not find this. Eight components had it wrong.

How Much Shadow

One shadow, and only overlays get it.

What It IsShadow
A main panel, card, table panel or toolbar — anything resting in the page, right above the canvasnone. Its border and its surface are what separate it
An overlay — menu, dropdown panel, dialog, drawer, popover, toast, flyout--shadow-md: x 0, y 1, blur 3. Nothing deeper
A tooltipnone, ever. A tooltip is a floating label, not a surface

--shadow-panel, --shadow-overlay and --shadow-raised are all that same drop; in dark and high contrast the panel and overlay tokens add a 1px hairline ring, which is a border substitute for a floating edge, not an elevation effect.

A shadow under something that is not floating says "floating" about something that is not, and where a resting bar and an overlay above it both cast one, the two collide.

Borders

TokenForvs page
--borderHairlines inside a surface: table rows, list separators, menu dividers1.09
--panel-borderThe visible stroke around a panel or widget1.16
--field-borderThe resting edge of every field control1.51
--inputOutline-style controls and any unlabelled control surface3.17

--panel-border is also the divider colour as of v3.2 — .ck-divider in all four of its forms. --border stays scoped to hairlines *inside* a surface.

--border against the page ground is effectively invisible. Using it where you mean --panel-border leaves a container looking unbounded — and a 2px structural rail on --border (the old tab bar) is a Sev-2.

§ 5 — The soft-semantic pattern

A tinted surface, a same-family border, and --*-soft-foreground text.

.pill-success {
  background: var(--success-bg);
  color: var(--success-soft-foreground);   /* NOT var(--success) */
  border: 1px solid var(--success-border);
}

Never use the solid token as text on its own tint. It fails 4.5:1 in 12 of 66 family/theme pairs — worst --warning at 1.83:1. All 22 palette families have a -soft-foreground:

TokenLightDarkHigh Contrast
--amber-soft-foreground#b45309#fbbf24#fbbf24
--blue-soft-foreground#245fe8#92c4fe#92c4fe
--crimson-soft-foreground#b91c1c#fca5a5#fca5a5
--cyan-soft-foreground#0e7490#67e8f9#67e8f9
--destructive-soft-foreground#d90500#fe9b98#ff4444
--emerald-soft-foreground#047857#6ee7b7#6ee7b7
--fuchsia-soft-foreground#a21caf#eb88fa#e879f9
--gold-soft-foreground#a16207#facc15#facc15
--indigo-soft-foreground#4338ca#a5b4fc#a5b4fc
--info-soft-foreground#2c67cc#92c4fe#44aaff
--lime-soft-foreground#4d7c0f#a3e635#a3e635
--orange-soft-foreground#ad531b#f6bb7b#f6bb7b
--pink-soft-foreground#be185d#f88ac1#f472b6
--purple-soft-foreground#6c45c1#c4b0f0#c4b0f0
--rose-soft-foreground#be123c#ff8a98#fb7185
--sage-soft-foreground#3d6b50#81c784#81c784
--sky-soft-foreground#0369a1#7dd3fc#7dd3fc
--slate-soft-foreground#475569#a4b1c3#cbd5e1
--success-soft-foreground#227c3f#6dcc8a#44ff88
--teal-soft-foreground#0f766e#5eead4#5eead4
--violet-soft-foreground#6d28d9#b6a1fc#a78bfa
--warning-soft-foreground#96650a#f7b83d#ffbb33

The Relation Folder Family

A folder glyph is not the soft-semantic pattern, so it does not take -bg / -border / -soft-foreground. It has two parts, body and tab, across two roles, target and source, and the pair carries meaning: the amber folder is the document the links start FROM, the teal ones are what it links TO.

TokenLightDarkHigh ContrastWhat it paints
--relation-node#3ca4af#6fbcc3#5eead4a target folder's body
--relation-node-tab#74c1c8#9ecdd1#99f6e4its tab, always the lighter of the pair
--relation-node-source#ce8d31#d9ab63#fbbf24the source folder's body
--relation-node-source-tab#d9ae6d#e7c592#fde68aits tab

The tab is lighter than its body in every theme — that is the rule the four values keep, and it is what makes the shape read as a folder rather than a rectangle. Check it when changing any of them.

Hue is the signal, not luminance. Teal and amber sit within 1.01:1 of each other by design, so the two roles are told apart by hue and by the label under each folder — never by brightness. A greyscale print of a relation tree shows one family of folders, which is correct: the labels still name them.

Light trades contrast for brightness, deliberately. The bodies measure 2.16:1 and 2.13:1 against --canvas, under the 3:1 that WCAG 1.4.11 asks of a meaningful graphic. What identifies a node is its label and its hue, not the fill's separation from the ground. Dark clears it comfortably at 8.45:1. If a future flow makes the folder itself the only identifier, this is the number to revisit.

High contrast is at the top of its range and stays there. Brightening it further collapses teal and amber toward each other, which costs the one signal that distinguishes them.

§ 6 — Sizes

The Control Height Scale

<div class="sh-demo">
  <span class="sh-demo-h">The three steps &mdash; a uniform 8px apart</span>
  <div class="sh-demo-r">
    <button class="ck-btn size-sm">28px &middot; .size-sm</button>
    <button class="ck-btn">36px &middot; default</button>
    <button class="ck-btn size-lg">44px &middot; .size-lg</button>
  </div>
  <div class="sh-demo-r"><div style="flex:1;min-width:240px">
    <span class="sh-demo-y">Right &mdash; one height across the row</span>
    <div class="sh-demo-r">
      <input class="ck-input" value="Orchard" style="width:110px" aria-label="Vendor">
      <button class="ck-btn primary">Apply</button>
      <button class="ck-icon-btn" aria-label="More">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
      </button>
    </div>
  </div><div style="flex:1;min-width:240px">
    <span class="sh-demo-n">Wrong &mdash; a .size-sm input beside a default button</span>
    <div class="sh-demo-r">
      <input class="ck-input size-sm" value="Orchard" style="width:110px" aria-label="Vendor">
      <button class="ck-btn primary">Apply</button>
    </div>
  </div></div>
</div>

Give any two controls that sit side by side the same height. If they share a row, they share a size class. Before this rule was written the kit had five different heights in circulation — 34, 32, 35, 37 and 35 — so every toolbar came out visibly ragged.

Use 28px (.size-sm) when the control repeats or space is tight. Reach for it in dense contexts: table row actions, inline filter bars, toolbars, and the controls inside a panel header.

Use 36px (the default, no class) everywhere else. This is the right answer for forms, page-level actions, dialog footers and field grids. If you are not sure which size to reach for, reach for this one.

Use 44px (.size-lg) for a single prominent action, at most once per view. Reach for it on touch-first or sparse surfaces — auth and onboarding screens, or an empty state's main call to action. It is exactly WCAG 2.5.5's AAA target size (44×44), so a large control clears that floor on its own.

Apply this scale to six components, and only these six. .ck-btn, .ck-icon-btn, .ck-input, .ck-select, .ck-textarea and .ck-dd. On .ck-dd the size class goes on the wrapper, never on the trigger.

Always set height explicitly, and pad on the inline axis only. Write height: 36px; padding: 0 11px — never vertical padding. A control sized by padding alone ends up at whatever its font metrics happen to produce, which is exactly how input (35px) and select (37px) drifted away from button (34px).

Keep the type size the same as the box grows. A row of mixed-size controls should still share one baseline. The only exception is .size-sm, which drops to 12px because 13px inside a 28px box leaves too little breathing room.

What "size" means per component

Confusing these is the most common size defect.

MeaningComponents
Control height — must match its rowButton, icon button, input, select, textarea, dropdown
Width — height is content-drivenDialog, drawer, popover, menu
Density — padding or gapTable, card, toolbar, empty state
Own scale — aligns within a row, does not set itSwitcher, tabs, pill, counter, status, spinner, clear-all
None, deliberatelyCheckbox, radio, toast, tooltip, icon, skeleton, member card, header bar, folder navigator

The Full Register

ComponentClassSizes
Button.ck-btn28 / 36 / 44
Icon button.ck-icon-btn24 / 28 / 36 / 44
Switch.switch (legacy .ck-switch)32×20 / 40×24 / 48×28
Checkbox.ck-check16 fixed
Radio.ck-radio16 fixed
Input / select / textarea.ck-input .ck-select .ck-textarea28 / 36 / 44
Search box.ck-searchinherits the input's
Dropdown.ck-dd28 / 36 / 44 (on the wrapper)
Tabs.ck-tabs36 / 44 bar
Badge / chip / pill.ck-pill .ck-badge .ck-chip20 / 24 / 28 tall · hugs its text, 200px cap at every size
Counter.ck-counter16 / 20 / 24
Status.ck-status11 / 12 / 13
Dialog.ck-dialog380 / 460 / 720 / full — *widths*
Drawer.ck-drawer320 / 420 / 560 / 880 — *widths*
Popover.ck-dialog.is-popover240 / 320 / 400 — *widths*
Menu.ck-menu168 / 208 / 280 — *min-widths*
Table.ck-tabledensity sm / default / lg · rows 32 / 40 / 48 via --table-row-height
Card.ck-cardpadding sm / default / lg
Toolbar.ck-toolbarsm / default gap
Empty state.ck-emptysm / default / lg padding
Spinner.ck-spinner16 / 20 / 28
Icon.ck-iconone — 16px glyph in a 24px box
Tooltip.ck-tipnone — 2 variants × 4 placements = 8, 1 size
Toast.ck-toastnone — 8 tones: 4 feedback + 4 action
Divider.ck-dividernone — solid / dashed / labelled / vertical
Alert.ck-alertsm / default / lg padding
Progress.ck-progress4 / 8 / 12 track · .is-count for a file count
Breadcrumb.ck-breadcrumbs16px trail, 18px current · .size-sm 13/14
Slider.ck-slidernone — 8px rail, 20px thumb
Kbd.ck-kbd16 / 20 / 24
Toggle.ck-toggle28 / 36 / 44
Pagination.ck-pag.is-barnone — a table footer bar; inner controls carry their own size
Accordion.ck-accordion36 / 44 / 52 trigger · .is-error .is-disabled
Document accordion.ck-accordion.is-documentnone — 120px thumbnail tracks, 3:4 pages
Canvas.ck-canvasnone — viewport shell
Pick row · Member card · Header bar · Folder nav · Skeletonnone

Full register: rulebook.md § 14.1 (46 rows). The rows above are the ones an audit hits most often.

Every value in this table is a multiple of 4. The control scale steps by a uniform 8px (28 / 36 / 44) and .size-lg at 44px is exactly WCAG 2.5.5's AAA target size. Two things are deliberately off the grid: icon glyphs (13 / 14 / 15px in a few places, where the rule is a flat 16px — tracked as a defect) and status dots (5 / 6 / 7 and 6 / 8 / 10), which are optical marks sized against their text rather than boxes on the layout grid.

A size class not in this table does not exist. .ck-btn.size-xl, .ck-pill.lg, .ck-dd-trigger.size-sm (it goes on the wrapper) are defects.

§ 7 — Radius

Keyed to what a thing is, not how big it is.

<div class="sh-demo">
  <span class="sh-demo-h">The five tiers</span>
  <div class="sh-demo-r">
    <div class="sh-tier"><div class="sh-swatch" style="border-radius:var(--radius-2xl)">16</div><span class="sh-demo-c">--radius-2xl<br>panel</span></div>
    <div class="sh-tier"><div class="sh-swatch" style="border-radius:var(--radius-md)">8</div><span class="sh-demo-c">--radius-md<br>control</span></div>
    <div class="sh-tier"><div class="sh-swatch" style="border-radius:var(--radius-sm)">6</div><span class="sh-demo-c">--radius-sm<br>inner</span></div>
    <div class="sh-tier"><div class="sh-swatch" style="border-radius:var(--radius-xs)">4</div><span class="sh-demo-c">--radius-xs<br>mark</span></div>
    <div class="sh-tier"><div class="sh-swatch" style="border-radius:var(--radius-full)">999</div><span class="sh-demo-c">--radius-full<br>round</span></div>
  </div>
  <div class="sh-demo-r"><div style="flex:1;min-width:200px">
    <span class="sh-demo-y">Right &mdash; an icon control is a rounded square</span>
    <div class="sh-demo-r">
      <button class="ck-icon-btn" data-force="hover" aria-label="Add">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
      </button>
      <span class="sh-demo-c">--radius-md, hover forced</span>
    </div>
  </div><div style="flex:1;min-width:200px">
    <span class="sh-demo-n">Wrong &mdash; a circular hover fill</span>
    <div class="sh-demo-r">
      <div class="sh-wrong-round">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
      </div>
      <span class="sh-demo-c">--radius-full on a control</span>
    </div>
  </div></div>
</div>

Give every container --radius-2xl (16px). If a thing holds other things, it gets 16px: cards, settings cards, tables, empty states, member cards, the folder navigator, dialogs, popovers, menus, dropdown panels, toasts and tooltips.

Give every control --radius-md (8px). That covers buttons, icon buttons, inputs, selects, textareas and dropdown triggers.

Give a ROW or a small control nested inside a container --radius-sm (6px). Menu items, pick rows, dropdown options, .size-xs icon buttons, the search clear button, skeletons and breadcrumb items. Radii step inward — the container is rounder than the row sitting inside it.

A PANEL nested inside a panel keeps --radius-2xl (16px). It is still a container, and the tier is decided by what a thing IS, not by how deep it sits. This used to read "anything nested inside a control or panel", which swept a card inside a card down to 6px and made a nested panel read as an oversized menu row. .ck-panel and .ck-card are --radius-2xl at every depth, and the kit has no rule that reduces them — the step inward applies to rows and controls, and stops there.

<div class="sh-demo">
  <span class="sh-demo-h">A container stays a container, however deep it sits</span>
  <div class="sh-demo-r"><div style="flex:1;min-width:260px">
    <span class="sh-demo-y">Right &mdash; the inner panel keeps 16px</span>
    <div class="ck-panel" style="padding:var(--space-base)">
      <div class="ck-card" style="padding:var(--space-md)">
        <div class="ck-card-title">Nested card</div>
        <button class="ck-btn size-sm">A control &mdash; 8px</button>
        <div class="ck-menu is-static" style="min-width:0;margin-block-start:var(--space-sm)">
          <button class="ck-menu-item">A row &mdash; 6px</button>
        </div>
      </div>
    </div>
    <span class="sh-demo-c">16 outside, 16 inside, 8 on the control, 6 on the row</span>
  </div><div style="flex:1;min-width:260px">
    <span class="sh-demo-n">Wrong &mdash; the inner panel stepped down to 6px</span>
    <div class="ck-panel" style="padding:var(--space-base)">
      <div class="ck-card" style="padding:var(--space-md);border-radius:var(--radius-sm)">
        <div class="ck-card-title">Nested card</div>
        <span class="sh-demo-c">reads as an oversized menu row, not a panel</span>
      </div>
    </div>
  </div></div>
</div>

Give marks --radius-xs (4px). Use it on checkbox boxes, search highlights and inline code. It used to be 2px, which read as a rendering artefact on a 16px box, and 4px also matches what the React kit already ships as rounded-[4px].

Reserve --radius-full (999px) for things that are genuinely round. Pills, badges, chips, counters, avatars, switches, radios, spinners and status dots.

Never make an icon control round. An icon button is a control, so give it --radius-md, and give .size-xs --radius-sm. Four of them were circular until v3.2 — .icon-btn, .ck-ico.is-interactive, .ck-input-clear and the pill dismiss — and three CSS comments actually asserted the opposite of this rule, so read the comment as sceptically as the code.

Never reach for --radius-lg (10px) or --radius-xl (14px). Both are unused, and a panel on either is a defect. Do not vary radius by size class either: a container's roundness must never depend on how much padding it carries.

Give .ck-drawer no radius at all. This is the one documented exception, because a drawer sits flush to three viewport edges and a rounded corner there reads as a rendering error.

Never write a corner value as a literal. Any hardcoded radius is a Law 2 defect. The reference pages still hold 130+ of them.

§ 8 — Icons

Every icon comes from Lucide. https://lucide.dev — MIT. The root <svg> must read exactly:

<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
     stroke-width="2" stroke-linecap="round" stroke-linejoin="round">

Children use only path, line, polyline, polygon, circle, ellipse, rect. No transforms, filters, fills, explicit stroke colours or <use>.

<div class="sh-demo">
  <span class="sh-demo-h">A 16px glyph in a 24px box &mdash; dashed line is the box</span>
  <div class="sh-demo-r">
    <span class="sh-iconbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg></span>
    <span class="sh-iconbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg></span>
    <span class="sh-iconbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.3-4.3"/></svg></span>
    <span class="sh-iconbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></span>
    <span class="sh-demo-c">Four different shapes. Lay out against the <strong>box</strong>,
      not the glyph &mdash; that is what keeps a column of icons aligned.</span>
  </div>
  <div class="sh-demo-r">
    <span class="sh-demo-c" style="min-width:100%">Two colours, and only two:</span>
    <span class="ck-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg></span>
    <span class="sh-demo-c">--icon-color &mdash; everything that is not destructive</span>
    <span class="ck-ico is-destructive" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6M14 11v6"/></svg></span>
    <span class="sh-demo-c">--icon-color-destructive &mdash; delete, remove, disconnect, error</span>
  </div>
</div>

Set the stroke to 2 on every icon, in every context. That is what --icon-stroke holds. It is unitless, so it drops straight into stroke-width.

Draw every glyph at 16px, and never introduce a second size. That is --icon-size, and it is the only icon size in the product. A new component does not get its own glyph size.

Lay out against the 24px box, not against the glyph. That is --icon-box: the glyph sits centred inside it with 4px clear all round. Laying out against the box is what keeps a column of icons aligned no matter what shape each individual glyph happens to be.

Inside a control, keep the 16px glyph but drop the 24px box. A 24px box leaves no room in a 20px .ck-pill.size-sm, so let the glyph sit in that control's own padding instead. This is the single exception to the box rule.

Use two colours only, and never let their states cross. --icon-color for everything that is not destructive, and --icon-color-destructive for delete, remove, disconnect and error. A third tone is a defect rather than a variant — if an icon needs to carry another meaning, put that meaning in the label or in a status pill. A destructive icon must never hover to a neutral fill, and a neutral icon must never hover to red.

Give every interactive icon an aria-label naming the action, plus a tooltip on hover and focus — everywhere in the product, with no exceptions. There are two ways to satisfy this, and the automatic one is the default.

ckAutoTip in clipper-kit.js?v=3ce65335 finds icon-only controls that already carry an accessible name and renders that name as a position: fixed .ck-tip on <body>, on hover and on keyboard focus. It removes title where it finds one and preserves the text as aria-label. Opt a control out with data-ck-no-tip. Because the tip is fixed on <body>, no ancestor can clip it.

The explicit form still works and wins where you need custom content: wrap the control in .ck-tip-host and the kit shows its .ck-tip child on both hover and :focus-within.

Auditing this: a bare icon button with a good aria-label and no wrapper is correct — do not raise it. The finding is an icon-only control with *no accessible name at all* (nothing for ckAutoTip to render), a title on a page that does not load clipper-kit.js?v=3ce65335, or a label naming the glyph instead of the action. A tooltip that appears on hover only is invisible to a keyboard and fails WCAG 2.1.1 — the most common tooltip defect there is. Label the action rather than the glyph — "Delete folder", not "Trash icon". Do not use title as a substitute: it does not appear on keyboard focus, cannot be styled, and is announced unreliably.

Expect to find violations of this in older markup. Before the rule existed the bundle held 328 SVGs across eight different stroke widths, 142 missing round joins, 52 with no stroke width at all, and seven different glyph sizes inside controls.

§ 9 — States

Six Interaction States

Every interactive control owes all six. 23 of 28 components shipped with no disabled state — assume it is missing.

StateRule
Default—
HoverNever opacity — it dims the label with the fill and reads as disabled
ActiveFilled surfaces use --*-active
Focus:focus-visible, not :focus — except text-entry controls, where :focus is correct because clicking places a caret
DisabledTokens, not opacity; pointer-events: none. A pure-geometry control with no text or glyph (the switch) may use --disabled-opacity instead — see check 5
Invalid--destructive border + --focus-ring-error

Deriving a hover or active value: move away from the value's own lightness extreme (a light value darkens, a dark value lightens), then verify the result still clears its binding requirement — 4.5:1 against the family foreground for a labelled fill, 3:1 against the page ground for an unlabelled track. If it fails, shift the other way.

TokenLightDarkHigh Contrast
--primary-hover#26515f#c3d2d6#55b7ca
--primary-active#3e6370#a6b3b6#479bab
--destructive-hover#c10400#d6827f#d73838
--destructive-active#a40300#ffb2ae#ff776f
--input-hover#696f6f#7a888b#747474
--input-border-hover#4d6b72#9caeb2#82b6c0
--skeleton-sheen#e8eaeb#26383d#2b2b2b
--highlight-bg#d1dcdf#3a494d#12272b

Three Content States

Every component that can hold content which is absent, arriving or failed needs all three. Most shipped with none.

StateClassTreatment
Loading.ck-skeleton*, .ck-loading-overlayFilled shimmer in the shape of the content, sweeping --skeleton-sheen. The spinner track is the same token — one loading family
Empty.ck-emptyDashed outline, icon, title, one line, one action
Error.ck-error-state--destructive-bg, destructive border, a retry

Dashed means empty, filled means loading. Swapping them is the defect. An empty state with no action is a dead end.

§ 10 — The 83 universal checks

Run these on any component, in a built screen or in a design file. Each one is written as the thing that should be true, and then what a failure looks like.

Colour and Pairing

#CheckIt fails when
1Every value comes from a tokenA hex, an rgb(), or a magic number sits in a component rule. If no token fits, mint one — never inline it (§ 1 Law 2)
2Every surface brings its own text colourA surface is set with no color, or a surface from one family is paired with text from another (§ 1 Law 3). An exemption is allowed, but it has to say why in the CSS
3The surface is the right levelA floating panel painted on --background. This is invisible in light mode, so check it in dark (§ 4)

States

#CheckIt fails when
4All six states existDefault, hover, active, focus-visible, disabled or invalid is missing (§ 9)
5Disabled looks unavailable, not brokenA disabled control loses so much contrast that it reads as an empty box or a rendering fault. Drain it, but keep it legible. Do not use opacity on anything with a label — it dims the words along with the box, so the text reads as a rendering fault. The one exception is a control that is pure geometry with no text and no glyph, such as a switch: there --disabled-opacity is the sanctioned way, and fading is the whole message
6Empty, loading and error are all designedAnywhere content can be absent, only one or two of the three exist (§ 9)

Size, Spacing and Alignment

#CheckIt fails when
7The size is on the registerA size class that is not in § 6, or a control whose height does not match the row it sits in
8Anything on one line shares one heightA field, a button and an icon button side by side at different heights. They all come off the 28 / 36 / 44 scale, and every one of those is an even number so the row centres land on whole pixels
9Alignment holds to the pixelOptical centring, padding symmetry, row centres or a field's text inset off by more than 1px
10The radius is on the scaleAny of the five tiers in § 7 broken
11Icons keep their bufferGlyphs crammed against a bar's edge or against each other. Use the standard glyph size and leave real space around it — a control is the glyph plus its breathing room
12Grouping is carried by spacingRelated icons spaced the same as unrelated ones. Things that belong together sit closer together than things that do not; a divider confirms the grouping, it does not create it
64A component that declares width:100% declares box-sizing:border-boxA rule setting a percentage width alongside its own padding or border while box-sizing is content-box. The element then measures wider than the track it was given, and overflows its container by exactly its own horizontal padding. There is no global reset in the kit — each component sets box-sizing itself — so this is a per-component obligation, not something the sheet handles once. The tell is that it looks fine until something sits on the element's trailing edge: .ck-dd-opt overflowed every list it was in by 18px and only became visible on the one list whose rows carried a trailing count
65A component reachable as a heading resets the browser's marginA class applied to <h1>–<h6> that sets no margin. The user-agent stylesheet then adds its own — roughly 13px top and bottom at --text-xl — inside a container whose padding was already chosen, so the block grows and its rhythm is off by a value nothing in the kit declares. Any title, legend or label a consumer would reasonably mark up as a heading for document structure carries margin:0. Writing the anatomy with a <div> is not a fix: it trades a spacing bug for a structure one, and the consumer is right to use the heading

Contrast and Targets

#CheckIt fails when
13Contrast floors are metText under 4.5:1, or a non-text affordance under 3:1 (§ 11)
14Every target is at least 24×24An interactive area smaller than that. The target can be bigger than the glyph it holds, and usually should be

Typography

#CheckIt fails when
15Every text size is a tokenAny font-size or font: shorthand carrying a literal. Text reads --text-*
16The size matches the roleA card title at 16px, a panel title at 14px, a column header at 12px regular. The right token used in the wrong place is still wrong — see the role table below

Icons and Tooltips

#CheckIt fails when
17Icons follow § 8Wrong glyph size, stroke, box or colour count
18Every icon-only control has a tooltipA glyph with no accessible name at all, so ckAutoTip has nothing to render — or a title= on a page that does not load clipper-kit.js?v=3ce65335, which never appears on keyboard focus. A bare button with a good aria-label and no .ck-tip-host wrapper is correct (§ 8); do not raise it. The tooltip and the aria-label say the same words, and both name the action, not the picture. An icon never carries meaning on its own — it is paired with a visible label, or it has a tooltip and an accessible name that say what it does. A purely decorative glyph is the other case and takes aria-hidden="true", so a screen reader does not announce furniture
19A tooltip never has a shadowAny box-shadow on a .ck-tip. A tooltip is a floating label, not a surface. A shadow makes it read as a second panel, and over a bar or card that already casts one the two shadows collide
20A control's label names the action, not the stateA tooltip or aria-label that describes what the user is already looking at. A sort header offers *Sort A to Z*, then *Sort Z to A*, then *Clear sorting* — never "sorted ascending", which the glyph has already said. The tooltip and the aria-label carry the same words, so pointer and keyboard users are offered the identical action

Elevation

#CheckIt fails when
21A panel resting on the canvas casts nothingA shadow under a main panel, card or toolbar that sits in the page. Its border and its surface are what separate it; a shadow there says "floating" about something that is not
22Panels sit on one spacing rhythmAnything other than: 12px between panels, 0 above the first row (it meets the header bar — a strip of canvas there reads as a rendering gap), 12px below the last row, 12px at the sides. The top asymmetry is deliberate and is the rule most likely to be "corrected" by someone tidying up
23The header bar and the left main nav are always presentA screen that drops either without an explicit reason. .ck-shell.is-immersive is that reason — a screen whose whole job is one document hides both and brings them back on hover *and* :focus-within, so a keyboard can still reach them
24A soft tone's stroke is its -soft-foregroundA pale tint used as the edge of a soft-semantic surface. At #bce5cd the success stroke measured 1.38:1 on the page and 1.25:1 against its own fill — the pill was read entirely by its fill. All six tones now stroke at 4.5–10.8:1
25An overlay casts one subtle shadowA menu, dialog, drawer, popover or toast with no shadow, or with a deep multi-layer one. There is exactly one drop shadow in the system — --shadow-md, x 0, y 1, blur 3 — and every overlay uses it

Components That Get Confused for Each Other

#CheckIt fails when
26An alert is not a toastA floating message that dismisses itself built as .ck-alert, or an inline message that stays put built as .ck-toast. Their cross and their icons are interchangeable; their placement and lifetime are not
27A link button is not a ghost button or an icon buttonA labelled link built as .ck-btn.ghost, a glyph-only action built as .ck-btn.link, or a link with its underline taken off
28Nothing hovers to greyA grey hover fill anywhere. Every hover in the system is --hover-bg, a --primary-* step, a --destructive-* step, or the sidebar accent. Grey was only ever the retired secondary button's, and it read as a different design system on the same page
29A track never disappears into its containerA progress bar or slider whose unfilled part vanishes into the panel behind it. Both read --track; --muted measures 1.00:1 on a muted panel
30A progress bar actually movesA determinate bar parked at one value while work carries on. If the length is unknown use .is-indeterminate; if it is known, keep the number climbing
31A toggle sits centredA label or glyph off-centre in its control, or a segmented group whose items do not line up with each other

Long Text and Responsiveness

#CheckIt fails when
32Read-at-a-glance text truncates from the middle; typed text scrollsA label, chip or dropdown value that wraps, overflows, or ends in a trailing ellipsis — or a field that hides what the user typed behind one. Shortened text is always head…tail — About…ered for "About to be delivered": the head keeps the odd character, a filename keeps its extension in the tail. Never a trailing ellipsis (About…), never a leading one (…ered), never a bare …. The kit draws no other form: ckTruncate() shortens every one-line kit label that overflows, and CSS only clips, because the end of a name is usually what distinguishes it: Invoice-2026-0… could be any of a thousand files, Invoice…-0412 is one. Pick one form and keep it — a trailing ellipsis on one screen and a middle one on the next is the defect, even where each looks fine alone. Anything shortened carries a tooltip with the full text. An input and a search box scroll sideways; a text area scrolls down; labels, chips and dropdown values truncate
33Width follows the parentA fixed pixel width that forces its column wider or spills out of it. A control takes the width it is given and can shrink inside a flex or grid track
34A squeezed bar scrolls, it does not reflowA footer or toolbar that re-stacks into two or three lines as the panel narrows, moving controls out from under the cursor and changing the panel's height. Keep the run intact and let it scroll
35A clear appears on hover and while editing, never at restA cross sitting permanently inside every filled field, so a form reads as a row of crosses — or one that only appears on hover, so a keyboard user can focus a control they cannot see. It shows on hover and on :focus-within, which is the field in edit mode, and it sits at the trailing edge inside the field box — the same place in an input, a search box and a text area, so the eye learns one spot. Clearing puts focus back in the field: the reader emptied it to type something else. This binds every field control, not just search

Behaviour and Honesty

#CheckIt fails when
36State is announced, not just drawnSelection or checkedness carried by a class with no aria-selected / aria-checked, or a glyph-only control with no aria-label
37No data-force in product markupdata-force~="hover|active|focus|disabled" paints a state the user is not in. It exists so a spec sheet can render the state matrix from the kit's own rules; in a real flow it is a lie about what the control is doing. Sev-2
38A row with a checkbox is clickable across its whole widthA 16px box next to a 400px row is a poor target, and a reader who clicks the name of the thing they want has said what they want. Prefer a <label> around the row — native, and nothing can desync from the input's real state. Where the row carries other controls (a drag handle, a rename button), leave it a plain element with data-ck-rowcheck and let ckRowCheck() decide per click. Never a <button> wrapping an <input>: that is invalid, and browsers only vary in how they cope
39No interactive element inside another interactive elementA <button> inside a <button>, or an <input> inside a <button>, is invalid — and the parser does not merely tolerate it, it closes the outer element early, so everything after the nested control spills out of its container. Four times in the kit: .ck-dd-clear in a trigger, .ck-folder-row as a button holding a chevron button, option rows wrapping a checkbox, and a multi trigger holding dismissible chips. The fixes are always the same three: make the container a <div> with the right role (combobox, treeitem) and wire its keyboard, make it a <label> when it is just a checkbox and a name, or move the inner control out as a sibling. Grep for <button and <input inside a button before shipping markup — it renders fine until it does not
40An atom or molecule used inside an organism brings its behaviour with itReach for the real class — .ck-search, .ck-check, .ck-btn, .ck-pill — and let the organism place it and nothing more. Never re-implement it under a local name, and never restyle it from inside: a copy drifts until one of them is missing a control. .ck-menu-search was a parallel search with its own input, its own magnifier offset, its own spinner and no clear button at all, so the cross never appeared once you typed — the organism looked right and behaved wrong. If the atom is wrong for the job, fix the atom. Check every state the atom owns still works in the organism, not just how it looks at rest
41A parent checkbox reflects its children — checked, indeterminate, or clearA "Select all" that only pushes DOWN. Tick three of ten children and the parent still reads empty, so the control lies about the selection. The parent is checked when every child is, indeterminate when some are, clear when none are — and indeterminate is a property set in JS, not an attribute or a class: el.indeterminate = true. The kit already styles .ck-check:indeterminate, so the dash is free once the property is set. Wire both directions: parent→children on its own change, children→parent on theirs
42A colour token with an --*-opacity companion is never used without itPainting --scrim or --thumb-overlay at full strength. These are the tokens § 2 split precisely *because* the backdrop is arbitrary, so the colour alone is the unmixed extreme — a dim becomes a blackout and the content underneath disappears entirely. If a rule reads the colour, it reads the companion too, or states why not
43A bulk-action bar is disabled while the selection is emptyDelete, merge, rotate and export sitting live and clickable above a list where nothing is ticked, so the only feedback that an action was meaningless arrives after the click. Every control that operates ON a selection carries disabled until there is one. This is also how the bar teaches what the selection is for
44One tooltip mechanism per pageA hand-rolled data-tooltip handler on a page that also loads clipper-kit.js?v=3ce65335, whose ckAutoTip reads aria-label. Any control carrying both attributes fires two tooltips, at two positions, often with two different strings. Use the kit's — aria-label alone for the automatic tip, .ck-tip-host where the content is custom — and delete the local one. Opt a control out with data-ck-no-tip, never by racing it
45The variant set is fixed — never invent one inlineA one-off button, badge or alert style written into a screen because the existing set did not quite fit. Every component ships a closed set of variants, and a new visual treatment is a change to the kit, not to the screen. The tell is a local class beside a kit class, or a style= on a kit component. If the set genuinely lacks a case, add it to the kit so every screen gets it
46One semantic colour map, reused everywhereSuccess green on one screen and teal on the next, or a status invented locally because the map had no entry for it. Success, warning, error, info and neutral each have one pair, and a status that does not fit them is a naming problem, not a colour problem. This binds badges, alerts, toasts and status dots alike — they read the same five
47Feedback after an action is a toast; anything the user must not miss is notA destructive confirmation or a blocking error shown as a toast that dismisses itself, or a silent action with no feedback at all. Every action gets a response — a toast is the default. Reserve the dialog and the inline alert for what must be read before the user moves on
48A control that applies instantly is a switch; one that waits for Save is a checkboxA switch on a screen with a Save button, so the user cannot tell whether flipping it did anything. The two are not interchangeable styling choices: the switch states that the change is already made, the checkbox states that it is pending
49A non-modal overlay closes on Escape and on an outside clickA dropdown, menu, popover or flyout that only closes when you click its own trigger again — which is how two of them end up open at once, one of them forgotten behind the other. Both exits, always, and focus returns to the trigger so a keyboard user is not dropped at the top of the page. A modal dialog or drawer is the separate case: Escape closes it, an outside click may not
50A class is a contract — a control wears a kit class only if it is that componentA local control given a kit class because it looked similar, or for the styling. The kit's initialisers walk classes: .ck-select means "this element has a .ck-dropdown and a .ck-select-value and I will drive them". A pagination picker with its own markup wearing .ck-select threw inside forEach, which abandoned the iteration and left 13 of 16 dropdowns on the page dead — with one console error and nothing visibly wrong until a user clicked. Two rules follow: never borrow the class, and never let an initialiser assume the structure — skip what it cannot drive
51A counter abbreviates past four figures — 10K, 2M, capitalA raw 10000 or 2,000,000 sitting in a badge, pill or tab, widening the control it rides on and pushing the row around. From 1,000 up it shortens: 1.2K, 12K, 999K, 2M, 2.5M, 1B — one decimal only where it changes the answer, so 12K rather than 12.0K. The suffix is capital: a lowercase k is the SI prefix for kilo, and this is a count, not a unit. The full number stays as the accessible name, so a screen reader reads "twelve thousand" rather than "twelve kay". ckCount does both
52Every mark on a selected surface reads from that surface's foregroundA row, tab or card that flips to a solid fill and recolours only its label, leaving the leading glyph, the chevron or the count on the resting --icon-color. On --primary that measures 2.35:1 and the glyph all but disappears at the moment the row is the one thing the reader is looking at. When the surface changes, everything drawn on it changes with it
53A confirm and a cancel are coloured, not just shapedA tick and a cross side by side in the neutral icon colour, so the reader has to decode the glyph to tell accept from discard. The confirm is .tone-success, the cancel .tone-danger, and both step the same three: the soft foreground at rest, the tint on hover, the solid fill when pressed. Use the soft foreground at rest — --success itself is 3.43:1 on white and a 16px glyph is not large text
54An error message is hidden until the logic it describes has actually failedA form that opens with its errors already showing, or a red line that appears on the first keystroke of a field the reader has not finished typing. The message ships in the markup at display:none and is revealed by the control's own aria-invalid="true" — the same attribute a screen reader reads, so the two can never disagree. Three moments are wrong to validate at: on load (the reader has done nothing), on first input (they are mid-answer), and on leaving an empty field they were only tabbing past. The right moments are: leaving a field that has a value, and submitting. And the error must clear the instant the value becomes valid — an error that outlives its cause is worse than none, because the reader fixes the field and the form still calls them wrong. The hint line hides while the error shows; two lines of small print under one control, one grey and one red, is noise exactly when a single instruction is needed
55An overlay sits fully inside the window and never covers the control that opened itA menu hung off a control near the right edge that pushes the document sideways and gives the whole page a horizontal scrollbar — the panel is then partly unreachable and it has broken the page behind it. Or a long menu on a short window that clamps upward until it lands on top of its own trigger, hiding the thing the reader just clicked. The panel picks the side with the most room — below, then above, then the inline sides — keeps --overlay-gap from the anchor and --overlay-margin from the window edge, and scrolls inside the space it chose rather than overflowing it. Nothing is cut off, and the page never scrolls sideways. ckPlaceOverlay() does all of it; a component doing its own arithmetic is the defect. Pair with check 49 — Escape and an outside click both close it
56Every chevron turns at one speedTwo chevrons on one screen rotating at different rates, which reads as one of them lagging. There is a single token, --duration-chevron, and every chevron in the product animates on it — named separately from --duration-moderate precisely so a change to general motion cannot desync the chevrons from each other. A chevron built on a transition rather than the clockwise keyframes is the usual offender: it inherits whatever duration the transition declares, and it unwinds anticlockwise on close, against the rotation law. Under prefers-reduced-motion every one of them drops to 1ms — still ending in the right place, because the end state is what the glyph means
57A dialog is always behind a full-screen overlayA dialog drawn straight onto the page with no scrim, so the content behind it stays bright, live-looking and apparently clickable — the reader cannot tell what is still available to them. Every .ck-dialog sits inside a .ck-dialog-scrim, which is position:fixed; inset:0 and paints --scrim at --scrim-opacity across the whole window. The scrim is what makes the overlay modal in the eye, and it is the surface an outside click lands on. The one exception is .ck-dialog-scrim.is-popover, which is deliberately transparent because a popover is not a dialog: it is non-modal, it does not trap focus, and it must not dim the page it is annotating
58A badge, pill or chip centres its labelA pill whose text sits left of its own centre. The control hugs its content, so the two only diverge when something fixes the width — the 200px cap with a long label, a column of pills sharing a width, a truncated label — and that is exactly when it shows. Left-aligned there reads as a mis-set label rather than a narrow one, and down a column every text start lands at a different offset from its own pill. justify-content: center on the control and text-align: center on .ck-pill-label: the first centres the icon-plus-label group, the second centres a label that has been ellipsised. Both are needed — the flex rule alone does nothing once the label is the one thing filling the row
59A list whose rows cannot be chosen must not look choosableA read-only panel — a value list, a legend, a summary — built from menu rows that still take a hover fill, a pointer cursor and a focus ring. Every one of those says "click me", and clicking does nothing. If a row is a value being read rather than an action being picked, strip the affordances: cursor: default, no hover background, no row focus ring. The panel, the placement and the scroller are worth reusing; the interaction is not
60When the order is the content, the markup carries itA chronology, a ranking or a sequence of steps built from <div>s. With stylesheets off, or to a screen reader, the order is then gone — and the order was the whole point. Use <ol>; if the visual rail or numbering is drawn by CSS, that is decoration on top of a real ordered list, never a replacement for one
61A component sizes its content; the container sizes the componentA component that declares its own outer gutter or caps its own height, so adopting it means deleting its rules rather than adding yours. .ck-timeline carries no padding — the gutter belongs to whatever holds it — and dropping it into a pane took 13 local rules deleted, none added. The counter-example shipped in the same release: .ck-dialog-body.is-split set max-height:min(60vh,520px), so the link picker was clipped from 655px to 520px — 135px of rows gone — while its rail measured a perfect 260px. The dialog already owns the viewport cap; a body that caps itself competes with its own parent and wins by being the inner box. Outer margin, outer padding and any height cap belong to the container. Where a cap is genuinely wanted it is opt-in (.is-capped), never inherent. The same law is why a part must be styled on its own class rather than only as a child of its parent — scoping is what makes a component impossible to adopt piecemeal
62Every label, CTA, heading, title and tooltip is Title CaseA button that reads "Save invoice" beside one that reads "Add Vendor", or a dialog titled "Confirm reconciliation" above one titled "Import Complete". Capitalise the first letter of every significant word. It binds: CTA and button labels, page and section headings, card, dialog, form and panel titles, field labels, menu items, tab labels, accordion labels, and tooltip text — including the aria-label a tooltip is generated from, because that string is the tooltip. Four things stay as written: acronyms and codes (PDF, CSV, PO-4417, INV-20871, SGD); short joining words mid-title — a, an, and, as, at, by, for, in, of, on, or, the, to, up, via, with — which take a capital only when first or last; each half of a hyphenated pair (Non-PO Invoice); and prose, which is not a label — hint lines, descriptions, body copy, empty-state sentences and checkbox options that read as sentences stay in sentence case. The test is whether the string names a thing or says a thing: a name is Title Case, a sentence is not
63A control drawn inside a field reserves its lane — the value stops before itA clear cross, a magnifier or a chevron positioned over the end of a field while the field's own trailing padding stays where it was. The text box runs on underneath the control, so a value long enough to reach the edge collides with it — on Profile Settings the final letter of a company name sat on top of the cross. Positioning a control over a field does not move the text out of its way; only padding does. Reserve inset + control width + a gap of trailing padding, and bind it to .has-value rather than declaring it flat, because an empty field has nothing to clear and no reason to carry the gutter. .ck-search sets the pattern at 60px for two controls; .ck-input-wrap takes 40px for one. The alternative is to truncate instead — .ck-dd-value does, with text-overflow: ellipsis — but a field being TYPED into must not truncate, because the reader cannot see what they are writing. A page-level style="padding-inline-end" patching this per screen is the tell that the kit is not doing it
66Every gap is a token on the 4px scale — and the kit obeys this tooA literal gap: 6px or gap: 10px. The scale is multiples of 4 (--space-2xs 2 is the one sub-step, for text lines that need a nudge rather than a gap); nothing between the steps exists. This binds the KIT, not only screens, and that is the point: a screen cannot be fully on-scale while composing components that are not. The kit carried 21 off-scale gaps — 6px in .ck-btn, .ck-field and the pill family, 10px in .ck-dialog-head and .ck-menu-item, plus 5, 7, 14 and 28 — so every screen built on it inherited them and no amount of screen-side discipline could clear the finding. Round to the nearest multiple of 4; on an exact tie round DOWN, because rounding ties upward inflates every stacked component by 2px and the error compounds down a form. The one exemption is 1px used as a seam rather than a gap — the hairline that stops two stacked buttons reading as one split rectangle — and it must say scale-exempt in the CSS
67An icon-only control declares padding: 0A <button> arrives with the UA's own padding — 1px 6px in Chrome — and on an icon-only control that is not cosmetic. A 24px box becomes a 12px content box, the 16px glyph no longer fits inside it, and place-items: center then centres the glyph in the CONTENT box rather than the border box: 6px from one edge, 2px from the other. The control looks square and the mark sits off to one side. It is invisible in review because nothing in the CSS says 6px — the number comes from the browser. .ck-icon-btn always declared padding: 0 and was always right; .ck-input-clear, .ck-search-clear, .ck-dd-clear and .ck-sidebar-pin never did and were all 2px out, with .ck-sidebar-item and .ck-sidebar-logo latent — correct only because their glyph happened to still fit. Measure the two side gaps rather than trusting place-items: center: centring is only true when the content box and the border box agree
68A multi-select field shows its placeholder, never its selectionChips packed inside the trigger. The control grows as the selection grows, so the field moves under the pointer while it is being used; at 37 values the "field" is a paragraph of chips with a caret somewhere in it. A field is a stable target that opens a list — it is not a display surface for the answer. The placeholder stays put whatever is chosen (Please Multi-Select…), and the answer lives BELOW the field in two parts, in this order: a summary row with the count on the leading edge (37 selected) and Clear All on the trailing one, then the chips under it. The count answers "how many?" without the reader counting, and the trailing edge is where a bulk action belongs. Both parts are hidden while nothing is chosen — a count of zero and a Clear All with nothing to clear are two controls that cannot be used. A chip's dismiss here is destructive at rest, not neutral-until-hovered: it deletes a value the reader picked, which is not the same act as closing something The single-select form is the one exception, and it is narrow. .ck-dd.is-chip puts the chosen value IN the field as a chip with its own dismiss, because the reason for this rule is absent there: one chip cannot grow the control the way thirty-seven can, its width is capped at 200px like every other pill, and the field’s height is fixed. A status you picked belongs where you will look for it. The exception is single select only — the moment a second value is possible, the placeholder rule is back
69A multi-select saves as it is used — no Confirm, no CancelA Confirm button under a list of checkboxes. Ticking a row IS the change, so a Confirm afterwards asks the reader to agree to something already visibly done, and a Cancel promises an undo the panel cannot honour once the list behind it has already moved. This is check 48 — instant means a switch, waiting-for-Save means a checkbox — applied to a panel: a control that applies instantly must not wear the chrome of one that waits. The two exits are the ones every non-modal overlay already has (check 49): an outside click, and Escape from anywhere inside the panel rather than only from the trigger, with focus returning to the field. And the panel hangs off the FIELD, not the control. A multi-select is three stacked parts — field, summary, chips — so anchoring at top: 100% of the whole thing opens the list below the selection instead of below the thing that opens it, and it drifts further away as the selection grows. It anchors to the field's own height and sits OVER the summary and chips: opening a list is temporary and must not move the page under the reader
70A page never positions a kit component in a style attributestyle="position:static;display:flex" on a dropdown panel, style="max-width:420px;position:static" on a dialog, style="padding-inline-end:36px" on an input. Each one is a page deciding a component's geometry, and each is a place where what the reader sees stops being what the kit does: the specimen passes review while the real component, in a real flow, behaves differently. The hand-picked values drift off the scale as well, because no rule is watching them — the panels that prompted this carried a 6px gap. If a component needs to sit differently, that is a modifier in the kit, not a style attribute on the page. The documentation case has one: .is-static puts an overlay — .ck-dd-panel, .ck-menu, .ck-card.is-dock, .ck-card.is-tour — into the flow of the page and shows it, with the gap on the 4px scale. Like data-force (check 37) it is documentation-only: a product flow never uses it, because there an overlay floats. A page may still set a specimen's own width; what it may not set is how the component is placed, shown or padded
71A control the kit already has as an atom is never built a second timeA filter rule’s remove built as a 16px circle with its own ring, its own glyph size and its own hover, sitting beside .ck-icon-btn, which is that control. A second implementation does not stay a copy: this one had drifted to --radius-full where § 7b says an icon control is a rounded square, to a 10px glyph where § 7c says 16px in a 24px box, and to a hover that jumped straight to solid --destructive where every other destructive control steps through the tint first. Compose the atom and give it a class for where it sits, not for how it looks. The kit has removed three of these already — a second avatar, a second icon button, a second dismiss. The tell is a rule setting width, height, radius and colour on something whose name ends in a role a kit atom already fills
72A toast lays its row out one way: everything centred across the row, the words starting at the leftA glyph sitting at the top of a toast while its message runs beside it, or a message centred in the box. A notification is read in one pass, in passing — the eye should land on the glyph and run straight along the words, and it cannot do that if the two are on different baselines or if the text starts somewhere different in every toast. So: align-items: center on the toast, text-align: start on its text. This binds the tone glyph, the message, any action and the dismiss alike. It was align-items: flex-start in the kit, which top-aligns a 16px glyph against a 13px line and reads as a misprint on the one-line toasts that make up almost all of them; a screen had already worked around it locally, which is how a kit default gets found An action is a member of that row, not a second line under it. .ck-toast-actions is a DIRECT CHILD of the toast, sitting between the content and the dismiss; it carries no margin-top and no align-self, because the toast centres its row and every child accepts that. It used to live inside .ck-toast-content with a top margin, and .ck-toast-action pinned itself to the top with align-self: flex-start — between them a toast came apart, glyph and button on one line, words on another, dismiss somewhere else again. A long message still wraps to two lines inside the content block; the ROW does not, and flex-wrap: nowrap on the toast is what holds it. The measurable form of this: the vertical centres of a toast’s children are all the same, to the pixel
73An anchored overlay follows its trigger — it re-places on scroll and on resizeA dropdown, menu, popover or picker placed once when it opens and never again. These are positioned in viewport coordinates, so every later scroll moves the trigger and leaves the panel exactly where it was: it floats across the page, detached from the control it belongs to and sitting over unrelated content, and the reader cannot tell what it is a menu *of*. Re-place on scroll in the capture phase — an inner scroller does not bubble, so listening on the window alone misses a panel inside a drawer, a dialog or a scrolling table — and on resize, coalescing a burst through one requestAnimationFrame so a scroll costs one placement rather than hundreds. The placement itself is always ckPlaceOverlay(); these listeners are the contract around it. A tooltip is the exception and closes instead — it is transient, it was not chosen, and there is nothing to come back to. Found twice: a flow's label picker drifted 157px after a 200px scroll of its drawer, and the kit's own ckMenuButton closed on scroll while ckMenu re-placed, so two kit menus answered the same question differently. Pair with check 49 (Escape and an outside click close it) and check 55 (it stays inside the window and off its anchor)
74A reveal keyed on an ancestor reveals on :focus-within, never :focus-visibleth:is(:hover,:focus-visible) .ck-th-delete{opacity:1} — and the focusable element is not the th, it is the th’s child. :focus-visible matches only the element that holds focus, never an ancestor of it, so that branch never fires and the control stays at opacity: 0 while focused. :hover works, because hover DOES inherit up the tree — which is exactly why no amount of mouse testing finds this, and why it survived in the kit’s own table. Measured on the DES-667 folder listing: 9 of 160 tab stops were focusable and invisible, seven of them the sort control, and sort is a table’s primary action. The second form of this is pointer-events: none standing in for hidden: it stops the mouse and does nothing to the keyboard, so the control is still a tab stop. .ck-input-clear and .ck-dd-clear both hid that way and were reachable on an empty field — where they would not have done anything even if found. display: none is what removes a control from the tab order, and .ck-search-clear had always used it, which is the tell that the pattern was already settled and two siblings had drifted off it. Fixed 2026-09-22. The test is where the reveal is declared, not where the state is: a rule that names a parent and paints a child takes :focus-within. Verify by focusing the control and reading its opacity AFTER the transition settles — read in the same tick and the value lies (§ 16)
75An overlay's show/hide gate outranks every selector the kit can bringA page hides its overlays with .modal-overlay{display:none} — one class, (0,1,0) — and shows them with .open. That works only as long as the kit's own rule is also one class: .ck-dialog-scrim{display:grid} ties, and the page wins on source order because its <style> comes after the link. A tie is not a win, it is a coincidence waiting to be broken. The kit then gained a variant — .ck-dialog-scrim.is-popover{display:block}, (0,2,0) — and every overlay carrying it stopped being hidden: the Intelligent Split panel stood open from the moment the page loaded, empty, over the header, because the list is only injected when the trigger is clicked. Nothing in the page changed and nothing threw; a kit sync did it. Write the gate at a specificity no variant can reach — .modal-overlay:not(.open) is (0,2,0) for one extra character — and never with !important, which loses to the next !important and takes the cascade with it. Verify at rest, before any interaction: no scrim, menu, drawer or popover may be visible on a freshly loaded page. A probe that only opens things never looks at what was already open. Pair with check 49 (Escape and an outside click close it) and check 73 (an anchored overlay follows its trigger)
76An element hidden with the hidden attribute is actually hidden — the design system must not paint over it[hidden]{display:none} is a user-agent rule, and every author rule beats the user-agent sheet however unspecific it is. So a single .ck-icon-btn svg{display:block} un-hides every icon any page hides with the attribute, and the kit sets a painting display on an icon in 23 places. The two-state toggle is the common casualty: both glyphs paint, stacked, and the control reads as a duplicated icon — which is what the Profile Settings header showed, two panel glyphs one above the other in a 44px box. Nothing throws, the markup is correct, and the attribute is right there in the DOM, so this is read as a mystery rather than a cascade problem. The system owes every consumer one guard rule, placed last and specific enough to clear its own most specific icon rule — !important is not the answer, it only loses to the next one. Exclude hidden="until-found", which means *hidden but findable* and is the browser's to reveal. Test it by reading computed display on an element you hid, not by reading the markup — the attribute being present proves nothing. Pair with check 75 (an overlay's gate outranks every selector the kit can bring): both are the same failure, a page's intent losing a cascade it never knew it was in
77A container insets its content on all four sides, by one named propertyA panel body at 8px beside a dialog body at 18/20 and a settings card at 20, five of them asymmetric so content sits closer to one edge than the other for no stated reason. A container is a container: panel body, dialog body, drawer body, card, accordion drawer, dock body, settings card, tab panel, carded form, picker body and popover all take --container-inset, and they take it on all four sides. The ladder is --container-inset-xs 4px (a row inside a list, or the frame around one — a folder navigator row, a menu), --container-inset-sm 12px, --container-inset 16px, --container-inset-lg 24px, and --container-inset-bar 12 / 16px (with -bar-sm and -bar-lg) for a one-line bar whose height is its own — toast, alert, notification head, accordion trigger. Every container component's tier is in the table in the tokens rulebook § Container inset, and a number off the ladder is a finding unless that table lists it as a declared exception, and which step a component takes is a density decision, not one number everywhere — a fields panel, a chat dock, a board card and a caption under an image are read close up and in bulk and take -sm; a dialog body, a drawer, a panel and a card are the primary surface of what you are looking at and take the base. The step belongs in that component’s sheet, with the reason. What does NOT vary is that it applies to all four sides. A one-sided or three-sided padding on a container is the tell — it means someone was nudging one edge to fix something else, usually a gap that belonged to the child. Content that must reach the edge takes .is-flush, which zeroes it: a table filling a panel, an image in a media card, the halves of a split dialog. That is a decision someone makes, not a default anything falls into
78The header bar’s two runs are fixed — order, size and gapFour flows, four different headers: a different set of controls, a different order, controls at three sizes, and no gap between them because .header-right was gap: 0 and every flow spaced its own by hand. The leading run is panel toggle · breadcrumb or title · info. The trailing run is search · settings · notifications · avatar. That order is not a preference: search acts on the page you are looking at so it leads, settings and notifications are account-level and sit together, and the avatar closes the run because it is the way out of the product. Every control in either run is a 24px frame around a 16px glyph — § 7c’s pair, not the rail’s 20-in-40 — the avatar closes at the same 24px in its default size, and every gap is --header-control-gap, 8px. The geometry is declared on the RUN, not on each control, so a flow that forgets .size-xs still gets it and the header cannot drift by omission. ClipperHeader builds both runs from one definition; a flow opts its trailing run in with data-actions and keeps its own handlers by data-hb="search|settings|notifications|avatar". The test: in any two headers in the product, the same control sits at the same index, at the same size, the same distance from its neighbour
79Everything that shares a line sits on it — check it on the gridDraw the grid's lines behind the layout ([data-ck-grid-lines]) and measure (scripts/audit_grid_lines.py): items in a row whose centres are more than 1px off one horizontal line (text measured by its ink), or parts of a column whose left edges are more than 1px off the container's inset line. Every panel and organism is laid on a 4, 6, 8, 10 or 12-column .ck-grid with --grid-gutter, and a new one is checked on the lines before it enters the kit. A part given a fixed pixel width that ignores the columns is the same finding. A centred container has a centre line, not a left one, and a deliberate full-bleed part is exempt
80A list's items carry no separator lines — and nor does its search rowA rule — solid, dotted or dashed — between the items of any list: dropdown and picker options, menu items, accordion items, dialog rows, the child rows of a grouped table (.ck-table.is-grouped tr.is-child); or a line under a menu's or dropdown's search row or select-all row. A list is one list, told apart by its rows' own padding and hover; a line under every row turns it into a stack of one-row tables and doubles the noise of a long list. What may separate is a boundary between GROUPS — a group heading's band, a .ck-dd-group rule — never the items inside one.
81A date and a time are written one way: 01/01/2026 - 13:00:00Any other form of a date or timestamp shown in the UI: 2026-08-14, 14 Aug 2026, Aug 14, 2026, 09:20, 1:00 PM, 4 minutes ago. A timestamp is DD/MM/YYYY - HH:MM:SS, 24-hour, with a spaced dash; a date alone is DD/MM/YYYY. Mark it up as <time datetime="ISO"> and the kit writes it in this format (ckFormatDateTime / ckFormatDate), so a flow follows the rule — and any future change to it — without an edit of its own. A native date input keeps the browser's own picker format
82A search highlights its matches in the list as you typeA searchable list — a searchable dropdown, a menu with a search row, a picker, the folder navigator, a drawer's column or activity list — that filters its rows but leaves the matching text unmarked, or marks it only in some lists. While the search box holds a query, every row that stays shows the part of its text that matches, wrapped in mark.ck-match on --highlight-bg with the row's own text colour; every occurrence is marked, and clearing the query clears the marks. ckHighlight(el, query) does it, and the kit's list filters call it, so a hand-written list wired through ckListFilter gets it too
83A search over a table highlights the keyword in the table as you typeA global or toolbar search above a table that leaves the matching words in the cells unmarked, marks them only after Enter, or leaves marks behind when the query is cleared. On every keystroke, each cell that holds the keyword shows it wrapped in mark.ck-match on --highlight-bg — the same mark the lists use (check 82). ckTableSearch() pairs a search with the nearest .ck-table after it (or the one data-ck-search-target names) and does it, so a flow with a search above its table gets it from the kit with no edit; data-ck-search-filter also hides the rows with no match

The Typography Roles, in Full

Every one of these is a token, and the role decides which:

RoleSizeWeight
Header bar title, and the active breadcrumb18pxsemibold
Inactive breadcrumbs16pxmedium
Panel / dialog / drawer header titles16pxsemibold
Card titles14pxsemibold
Small labels, timestamps, input, dropdown, search box13pxregular
Button and toggle labels13pxmedium
Table cell data, hints, helper text12pxregular
Badge text, and a status label12pxmedium
Table column header titles11pxmedium

Adhere to the tokens when building a flow, not just when building the kit. A flow that hardcodes 14px has opted out of the scale, and the next size change will miss it.

Type-Specific Checks

If the component is…Also check
A modal overlay (dialog, drawer)Focus trap, focus on open, focus return, Escape, inert behind
A non-modal overlay (menu, popover, toast, dropdown)Focus is not trapped — trapping there is the defect
An unlabelled control (switch track, checkbox box, radio ring, tab rail, spinner track)Its own shape clears 3:1 against the page. It is the only affordance there is
A control with a hover-reveal actionIt also reveals on :focus-within, or a keyboard user can focus an invisible control
Using a soft semantic tintText is --*-soft-foreground, never the solid (§ 5)
A glyph-only buttonaria-label describes the action, not the icon
A dismiss inside a pillNeutral at rest, destructive on hover — not .tone-danger
Nested inside another controlIt is .ck-icon-btn.size-xs (24px), not a bespoke button

The per-component layer — UI-SANITY-CHECKS.md

The 69 numbered checks above are what applies to everything. The rules that apply to one component live in design-system/docs/UI-SANITY-CHECKS.md, as straight pointers under a heading per component — 93 of them across 31 sections, curated from the design owner's list and from the rules already written into the 45 component sheets.

The two documents do not overlap on purpose. A rule that is a numbered universal check is not restated per component, and a per-component rule is not promoted here unless it genuinely applies product-wide. Copying rules between them is how two sources of truth start contradicting each other — which is the failure this whole bible exists to prevent.

Read this file to check a screen in general. Read UI-SANITY-CHECKS.md when you are building or reviewing one particular component.

§ 11 — Token defects a screen inherits

Not screen defects. Cite this section; do not raise a bug per screen.

The Muted Layer — closed 2026-09-15

--muted-foreground in light is #5a7278. Dark and high contrast are untouched at 10.81:1 and 15.91:1 and always passed.

History, because the shape of this matters. The light value was #5e767d, lightened to #738f96 on 2026-09-10 as a design decision, then darkened to #5a7278 on 2026-09-15 to meet AA. The lightening is often blamed for the failure, but #5e767d measured 4.81 / 4.41 / 4.24 / 4.44 on the four light surfaces — it passed on --background only. The muted layer had never met 4.5:1 on three of its four surfaces. Reverting would not have fixed it; only #5a7278 clears all four.

--icon-color moves with it and is #5a7278 in light — the two are meant to read as one tone, and standalone icons take their colour from it.

Light SurfaceWas #738f96Now #5a7278Floor
--background3.44:15.11:14.5
--muted3.16:14.68:14.5
--canvas3.04:14.50:14.5
--accent3.18:14.71:14.5

All ~74 text sites — hints, helper text, placeholders, timestamps, meta, breadcrumbs, card subtitles, disabled labels — now meet WCAG 2.2 SC 1.4.3 Level AA. All ~19 glyph sites already cleared the 3:1 affordance floor and improved with the change.

--canvas is the tight one at 4.50:1. It has no headroom. Any future lightening of either token reopens this, so re-measure against --canvas first, not --background.

This does not make the muted layer a place to put anything. It is still the de-emphasised tone. Something a user must act on — a required-field hint, an error reason, a value they need — belongs in --foreground, for hierarchy reasons that have nothing to do with contrast.

SevDefectMeasuredAffects
1--focus-ring vs page ground1.04–1.20:1 vs a 3:1 floorEvery focusable component
1--warning as a non-text signal2.05:1Warning toast rule and icon, warning pill border
4--success-foreground on --success3.43:1Any solid success fill with white text
~~4~~~~--muted-foreground on --muted~~3.16 → 4.68:1Closed 2026-09-15
~~4~~~~--muted-foreground on --background~~3.44 → 5.11:1Closed 2026-09-15 — the whole muted-text layer, and via --icon-color the standalone icons
~~4~~~~--muted-foreground on --accent~~4.44 → 4.71:1Closed 2026-09-15
4--panel-border on --popover, dark1.00:1Dialog, drawer, popover, menu, toast edges in dark
4--border / --panel-border vs page1.09 / 1.16:1Hairlines, tab rail, card strokes
~~4~~~~No type-scale token family~~—Closed in v3.2 — --text-2xs…--text-4xl + --font-weight-*, shadcn names on Clipper values
4--panel-border vs page, and it is now the divider colour1.16:1Every divider, panel and card stroke. Raising it is a token-value change with product-wide blast radius

Contrast Floors

ContentFloor
Normal text4.5:1
Large text (≥18.66px bold or ≥24px)3:1
Non-text UI boundary, icon, focus indicator3:1 (WCAG 1.4.11)
Interactive target size24×24 (WCAG 2.5.8)

A 12px/500 badge label is not large text. 4.5:1 applies.

§ 12 — Structural rules

1. One copy of the kit. Link clipper-kit.css?v=8da91cd1; never paste parts of it into a page. 2. No shadow token set. A page must not redefine, in its own :root or [data-theme] blocks, any token the source of truth owns. Page <style> parses after linked sheets, so the page's copy wins and the source of truth silently stops reaching it. The molecules page had 340 such declarations. This is the most damaging defect in the system and it is invisible from inside the page. 3. A page style block must not restyle a kit component. Flow-specific layout only. 4. ck- prefix. A non-prefixed class in the kit is flow CSS that leaked in. 5. No !important. Raise specificity instead — a repeated class (.ck-folder-nav.ck-folder-nav …) lifts 0-2-0 to 0-3-0. 6. One dialect, one notation. Any token file that is not design-system.css?v=7ab031ca is either generated from it or deleted.

§ 13 — Known-good exemptions

A disabled glyph is below the contrast floor, and that is correct. --icon-color-disabled measures 1.71:1 in light and 2.45:1 in dark against the ground, and 2.99:1 against the enabled glyph beside it, which is the separation that carries the meaning. WCAG exempts inactive user interface components from 1.4.3 and 1.4.11 outright, so a disabled control has no contrast requirement; what it has is a perceptibility requirement, which these clear. High contrast keeps 4.96:1 because dimming it to the same ratio would defeat the theme. Do not raise this as a finding.

These look like defects and are not. Each must state its reason in the rule, or QA cannot tell it from a mistake.

Three of these changed character on 2026-09-15. When --muted-foreground was darkened to meet AA (§ 11), the nominal partner became legal at 4.68:1, so .ck-table th, .ck-toast/.ck-alert and the pill family are no longer *forced* onto --foreground by a contrast floor — they are kept there by design. They stay exempt, but an exemption defended by taste is a weaker thing than one defended by a measurement, and it should be re-argued rather than inherited. This is what it looks like when a token fix cascades into the exemption list: the exemptions do not disappear, their reasons do.

PatternWhy it is allowed
.ck-table th — --foreground on --mutedA design choice, no longer a contrast necessity. 14.56:1 against the nominal --muted-foreground partner's 4.68:1 — since 2026-09-15 that partner is legal, so this is kept because a column header is a landmark you scan, not because the alternative fails
.ck-empty and .ck-dropzone — --muted-foreground on --backgroundDeliberately de-emphasised placeholder copy. 5.11:1 in light since 2026-09-15 (was 3.44:1), 10.81:1 dark, 15.91:1 high contrast — compliant, and a cross-pair only in the bookkeeping sense. The .ck-dropzone-title above it carries --foreground
.ck-pill / .ck-badge / .ck-chip — --foreground on --mutedA design choice, no longer a contrast necessity. 14.56:1 against the partner's 4.68:1. A pill's 12px label carries status the user acts on, so it takes the stronger pair
.ck-member-av — --primary on --backgroundThe outline pattern, 12.01:1
Outline button — --primary label on --backgroundLabel and border share one token, 12.01:1
A switch track, tab rail, skeleton, radio dot or .ck-check box with no colorCarries no text; there is no foreground to pair with. Judge the border against the page instead — --input at 3.17:1 clears WCAG 1.4.11's 3:1 floor for a non-text control. The tick has its own colour on ::after
.ck-menu-item:focus-visible — inset 2px rule, not --focus-ringA 3px outer ring is clipped by the panel's 4px padding and renders as a smudge
Text inputs on :focus, not :focus-visibleClicking places a caret; showing focus is correct there
Disabled controls under 3:1 against the pageWCAG 1.4.11 exempts inactive components
An overlay close ✕ (dialog, drawer, side panel, notification panel, toast, alert) or a field clear ✕ (input, dropdown, search box) resting in --icon-color, not --icon-color-destructive.ck-icon-btn.is-close, by rule: closing is leaving, not a warning, so it turns destructive only on hover (--destructive-bg) and press (--destructive). A red-at-rest ✕ there is the finding, not this
A labelled field control resting on --field-border (#d0d3d3, 1.51:1)The design owner's decision: a subtle resting edge so an empty form does not shout. The label identifies the field; hover and focus carry the strong edge. Only a field whose stroke is not --field-border is a finding
.ck-btn.ghost.destructive destructive at rest, pill dismiss neutral at restOne prominent action per view vs one inside every chip in a filter bar
.ck-toast / .ck-alert neutral tone — --foreground on --mutedA design choice, no longer a contrast necessity. 14.56:1 against the partner's 4.68:1. A notification is read once, in passing, and takes the stronger pair
A tooltip tail with no colorCarries no text; it inherits the bubble's fill and stroke
.ck-dd.is-picker rows not tinted when selectedThe checkbox carries selection; tinting doubles an unambiguous indicator and makes a long list stripey
16px glyph with no 24px box inside a controlA 24px box leaves no room in a 20px .ck-pill.size-sm
A dismiss inside a pill sizes its glyph from the control, not from --icon-sizeThe button is 12 / 16 / 20px and the glyph is 4px smaller, so it keeps 2px of buffer. A flat 16px glyph in a 16px button touches its bounds. Hit area stays 24×24 on a ::before

§ 14 — Entry conditions for a new component

A component may not enter the kit until all of these hold. These are entry conditions, not review notes.

  • [ ] Every value is a token (§ 1 Law 2) — colours and numbers
  • [ ] Every surface has its partner foreground, or a stated exemption (§ 1 Law 3)
  • [ ] Correct surface level, verified in dark theme (§ 4)
  • [ ] All six interaction states, if interactive (§ 9)
  • [ ] Sizes on the register, or documented as having none (§ 6)
  • [ ] Radius on the scale (§ 7)
  • [ ] Icons are Lucide, 16px glyph, 2px stroke (§ 8)
  • [ ] Empty, loading and error states if it can hold content (§ 9)
  • [ ] Contrast floors met, measured not estimated (§ 11)
  • [ ] Hit areas ≥ 24×24 (§ 11)
  • [ ] Alignment measured (§ 10 check 8)
  • [ ] State announced via ARIA, not only drawn (§ 10 check 12)
  • [ ] ck- prefixed (§ 12)
  • [ ] A spec sheet exists recording what it is, its sizes, its tokens, and any exemption with its measured ratio

A component with no spec sheet is itself a finding.

§ 15 — Motion

The Duration Scale

Four tokens. Every transition and animation on the interaction path sits on one of them; a hardcoded duration is a magic number exactly like a hardcoded radius (§ 1 Law 2).

TokenValueUse For
--duration-fast.1sColour and opacity on hover — a tint, an icon swap
--duration-base.15sThe default. Most state changes
--duration-moderate.2sSomething that moves or resizes — a chevron, a disclosure
--duration-slow.3sA panel entering or leaving — drawer, dialog, toast
--duration-chevron.2sEvery chevron rotation in the product, without exception

--duration-chevron is the same .2s as --duration-moderate today, and it is a separate token anyway. The chevrons have to agree with *each other* — two of them turning at different rates on one screen reads as one of them lagging — and that is a stronger constraint than agreeing with general motion. Kept as one token, a future change to --duration-moderate cannot silently desync them.

A chevron rotates on keyframes, never on a transition. A transition reverses its own path, so the glyph unwinds anticlockwise on close; the kit's chevrons turn clockwise in both directions, opening on one keyframe pair and closing on the other so the two together complete one revolution. A chevron still built on a transition is the one that will be running at the wrong speed, because it carries whatever duration that transition declares — .ck-dd-arrow and .ck-combo-arrow were both found that way at --duration-base.

Easing is --ease-default (ease), --ease-out, or --ease-spring (cubic-bezier(.34, 1.56, .64, 1)) where an element should overshoot slightly.

Two carve-outs, both deliberate — do not "fix" either.

  • A zero duration paired with a delay is not a rounding error. visibility 0s linear .22s switches visibility *after* the fade; rounding the 0s up to .1s would animate visibility instead of switching it, and the element would be click-through while still visible. 19 sites rely on this.
  • Anything over .4s is off the scale on purpose. Ambient loops, progress indicators and the pulse ramps below are not interaction timing, and they stay literals.

The Animated Alpha Ramps

Four roles, named for what the pulse *means*, not how strong it looks. Each is a hex colour token plus a --pulse-*-opacity peak, riding an ::after ring with the trough at 0 (§ 2).

RoleColour TokenMeans
attention--destructiveThis needs action
activity--infoThis just changed
input--primaryThis is waiting for entry
guide--backgroundThe guided-tour spotlight

Peak opacity is per theme — high contrast runs hotter (e.g. attention is .42 / .50 / .70 across light, dark and high contrast) because a faint ring disappears against #000000.

Reduced Motion

The kit honours prefers-reduced-motion in ten places. A flow that adds its own animation and does not is a Sev 2. The test is not "does it still work" but "does anything still move" — a ramp, a slide, a spinner that keeps spinning under the media query is the defect.

§ 16 — Probe traps

For whoever writes or extends the audit scripts, not for a design-versus-build review. Every trap below produced a false finding in this system's own audit. Read it before trusting a probe's output.

Measuring

TrapWhat HappensDo Instead
Measuring text ink on a control with a leading iconReports 20–40px off-centre. The text is deliberately off-centre; the icon+label group is centredMeasure the content box
el.children for the content boxMisses bare text nodes, so "icon + Label" looks wildly off-centreInclude text nodes via a Range
Forcing only :focus-visibleMisses every :focus rule, so text inputs look out of sync with the dropdown triggerForce both :focus and :focus-visible
Reading borderRightColor for an indeterminate checkboxThe tick uses border-right/bottom, the dash uses border-topRead the side that state actually sets
Reusing a radio name across theme blocksThey merge into one group; only one renders checkedUnique name per cell
Reading computed style in the same frame as a JS mutationHeadless Chrome with --virtual-time-budget returns the pre-mutation value. Setting el.disabled = true then reading getComputedStyle(el).color reports the enabled colour, and toggling data-theme then reading a background reports the old theme. Custom properties resolve correctly, which makes it look like a cascade bug rather than a probe bug — this cost a whole diagnosis, and the "defect" did not exist. The tell: an element you create *after* the mutation gets the correct new value while the elements already on the page keep the old one — same document, same frame, two answersRender the state you want to measure: put the attribute in the markup, or drive the page's own code path and load it that way
Walking document.styleSheets[i].cssRules to find which rule winsOn a file:// page every linked stylesheet is cross-origin, so cssRules throws and a try/catch sweep reports "no rule sets this property" — which reads as a smoking gun rather than as a dead probeTest the hypothesis instead: override the token and see whether the value moves, and compare against a freshly-created element carrying the same classes
Asserting a box-shadow contains rgbcolor-mix(in oklab, …) serialises as oklab(…)Accept all colour function forms

A synthetic event proves nothing about a pointer. el.dispatchEvent() hands the event to an element you picked: it never triggers :active and it never hit-tests, so it cannot tell you whether the thing under the cursor is the thing that receives the click. A rule that merged :active with :disabled — and so applied pointer-events: none on press — killed twelve controls mid-click, and every dispatchEvent test passed. Drive Chrome through CDP with Input.dispatchMouseEvent when the question is about pointers.

A state set from JS may not repaint under a virtual clock. Under --virtual-time-budget the clock never advances a CSS transition, so a computed read after a state change returns the PRE-transition value and requestAnimationFrame can be starved outright. Setting data-force from script and reading immediately gives the old paint; the same attribute present at parse time gives the right one. For anything behind a transition, set transition: none, put the state in the markup, or use a real clock via CDP.

Parsing

TrapWhat HappensDo Instead
Blanking comments before a pairing scan/* pairing-exempt: … */ lives in comments; every declared exemption becomes a defectStrip comments for the literal scan only
Auditing the token file as a consumerFlags its own literals — holding literals is its purposeExempt files passed as --tokens
color-mix\([^)]*\) regexStops at the ) inside var(--ring), leaving invalid CSSMatch balanced parens
Splitting a :root block on ;The preceding comment stays attached to the next declaration, so ^\s*(--[\w-]+)\s*: never matches and the pass silently does nothingMatch against a comment-stripped copy; keep the original text when keeping
Counting var(--x-${y}) as a token referenceA name built in a JS template literal is only known at runtime; it is not a reference and cannot be resolvedRequire a ), , or whitespace after the name

Converting

TrapWhat HappensDo Instead
Converting rgba → 8-digit → compositeRounds twice; 26 values drifted 1/255Composite from the original in one step
Trusting a retired-token map--border-strong → --border erases every control border — --border is #f5f5f5, and the live alias layer resolved it to --input (#8b9292). --text-h1 → --text-3xl is 36px → 20pxMatch on the resolved value, not the name. The alias layer is ground truth
Nearest-colour matching across familiesA dark row tint lands on --amber-bg where light used --orange-border — ΔE 9–14, visible side by sidePreserve the family the original named; move a step within it
A -bg suffix rule admitting an inverting token--tip-code-bg is dark in light and light in dark, so it passes a "background" filter and flips the surface across themesGuard on L\*: reject a candidate more than ~18 apart from the original
Preferring by property as a tie-breakWhen the nearest colour overall is a text token, it wins and a body background turns white in darkRestrict the candidate set by property before ranking

A pseudo-CLASS cannot be read with getComputedStyle. Its second argument takes pseudo-*elements* — ::before, ::after — and nothing else. getComputedStyle(el, ':focus-visible') returns the element's ordinary style, so a probe built on it reports every control on the page as having no focus ring. It produced twelve of those in one pass on the webhooks flow. Read a state by forcing it — [data-force~="focus"], which the kit ships beside its own :focus rules — and comparing the painted result against the same element unforced.

Chrome refuses cssRules on a file:// linked stylesheet. It throws SecurityError, and a probe that catches and continues ends up walking an empty rule list while believing it has read the cascade — so every element looks unstyled and nothing is styled by anyone. Inline the linked sheets into the page before the run; an inline <style> is same-origin and readable.

CSS Nesting gave every CSSStyleRule a .cssRules. The old shape of a stylesheet walk — if (r.cssRules) return walk(r.cssRules) — assumed only a grouping rule had children, so it now recurses into every style rule's empty list and never reads a single selector. One run reported 1703 rules visited and 0 selectors examined, which reads as a clean sheet rather than a broken walk. Recurse only when r.cssRules.length, and read the rule's own selectorText regardless.

An element's own opacity says nothing about whether it is visible. A control inside a closed panel reports opacity: 1 and a zero-sized box, so a check for "focusable but painted invisible" fires on everything in every collapsed section — 26 false findings in one pass. checkVisibility({ opacityProperty: true, visibilityProperty: true }) answers for the whole ancestor chain, which is the question being asked.

A theme stamped on the source is not the theme that renders. A screen with its own theme switcher writes the stored preference back onto the root element on load, so a probe that stamps data-theme="dark" in the file measures light and reports a clean dark mode it never looked at. Set the theme inside the probe, after load, and read it back before trusting the numbers.

A scale is a set of values, not an arithmetic rule. Testing a gap for divisibility by 4 reports eight real token values as off-scale, because the space scale has a 2px step; testing a font size against a hand-written list of scale names misses --text-3xs and condemns the notification badge. Read the token values off :root at runtime and test membership.

Forcing an overlay open skips the code that fills it. An audit that sweeps overlays by adding the open class reaches the markup and never runs the trigger, so anything that throws on the way — a bad selector, a missing field, a branch that only exists for one channel — is invisible to every check in the sweep. The webhooks flow had querySelector('[id="rcpList0" data-rcp-list="0"]'), two attribute conditions inside one bracket pair, which is invalid CSS: querySelector throws SyntaxError rather than returning null, openEditor() died before classList.add('open'), and Create Alert and Edit Alert had not opened at all while eight overlays measured clean. Worse, an exception inside a click handler does not propagate to .click(), so the call site returns normally and a synthetic test of the button passes. Exercise the trigger, and assert the overlay opened — then force the class only to inspect what is inside.

Plant a failure before believing a clean result. Every trap above produced a confident, wrong answer that looked like a finding or like a pass. The cheap defence is a canary: add one element that must fail the check being run, and confirm the probe reports exactly it. The webhooks flow's contrast pass came back zero in all three themes — that only became evidence once a deliberately illegible line was added and the probe caught it alone.

The pattern behind all twenty-six: a probe that measures the wrong thing fails *confidently*. Treat a surprising result as a suspect probe until you have reproduced it by hand.

§ 17 — Publishing

A kit change can be committed, pushed and deployed and still reach nobody. Both failures below have happened, and both look exactly like the change was never made.

The Order

1. Change the kit in design-system/ on main — never on a branch (§ 12). 2. Refresh .claude/skills/staple-ui-qa/reference-build/ in the same commit. It is the byte-copy an audit compares a flow against, so a stale one reports defects that no longer exist. build_kit.py does this. 3. Commit and push main. 4. Deploy the kit: design-system/build/deploy.sh. 5. Then bump each prototype's ?v= and redeploy that prototype.

Step 5 is last for a reason. The CDN sends cache-control: immutable for a year, so a page only sees a kit change when its ?v= moves. Bumping before the deploy pins the page to the OLD file under a new key — the change is live and unreachable, and every check you run against the source says it shipped.

Verify against the versioned URL, not the bare one

curl -s <page> | grep -o 'clipper-kit.css?v=8da91cd1?v=[0-9a-z]*'
curl -s "https://clipper-kit-vercel.vercel.app/clipper-kit.css?v=8da91cd1?v=<that value>" | shasum
shasum design-system/clipper-kit.css?v=8da91cd1

The two shas must match. Fetching the bare URL always serves the new file and proves nothing about what the page actually gets.

Skills live on main, and the sync carries them

.claude/skills/ is committed, so every branch holds its own copy and they drift — a bible change on main does not reach a prototype worktree by itself. design-system/build/sync_branches.sh is what closes that: it copies .claude/skills and design-system from main onto every branch carrying the kit, including branches with no worktree, and fails loudly on a push it could not complete. Run it after any kit or bible change; do not hand-copy.

Parity is a content test, not an ancestry one. The sync copies files and commits on the branch, so a synced branch never contains main's commit — git merge-base reports every branch behind. Compare blobs instead.

Canvas — the app ground

foundationcanvas.md

The page an app sits on, as distinct from the default surface a control sits in. shadcn gives both the same #ffffff, so the distinction never surfaced and this kit inherited it — meanwhile the flows set body{background:#f4f6f8} by hand because the kit never defined it. --canvas started at that measured value and is now one step darker, so a --card panel separates from the ground instead of floating on an identical white. It could not just be --background repointed: 22 rules use that as the default control fill, so every field would have turned grey. Hence a fourth surface level (§ 4).

Live
the four levels, nested as they are used
.ck-canvas — the ground
--card — elevated, in flow
A panel laid on the canvas needs no shadow: in light the value difference carries it, in dark --card does.

TokenLightDarkHigh ContrastRole
--canvas#eef1f3#142226#000000The page the app sits ON. Viewport shell only
--background#ffffff#142226#000000The default surface in it, and the default control fill
--card#ffffff#233a3e#000000Elevated but in flow: cards, panels, table bodies
--popover#ffffff#2f5155#000000Floating out of flow: menus, dialogs, drawers
.ck-canvas goes on the viewport owner, never on a panel. A panel on --canvas reads as a hole in the page rather than a surface on it. And do not reach for --muted or --secondary (#f5f5f5) as a page ground — they are recessed fills inside a surface, and being near-identical to --canvas in light is exactly how a wrong level stays invisible until someone switches to dark.
Rules — references/components/canvas.md

Canvas

--canvas · --canvas-foreground · .ck-canvas

What It Is

The app ground — the page an application sits on, as distinct from the default surface a control sits in.

Basic Information

Classes
ClassRole
.ck-canvasthe shell. Goes on the element that owns the viewport
.ck-canvas-inseta region sitting back from the surface around it — a preview well, an empty column. Carries the panel radius

Tokens

Why this had to be a new token

shadcn gives --background and --card the same value (#ffffff), so the distinction never surfaces. This kit inherited that: a panel had no separation from the page, and the level was carried entirely by --panel-border.

But --background couldn't simply become grey — the kit uses it as the default control fill in 22 places: .ck-input, .ck-select, .ck-textarea, .ck-dd-trigger, .ck-btn's base, the .ck-switch thumb, the .ck-check and .ck-radio boxes, .ck-empty. Repointing it would turn every field in the product grey.

So the token set has four surface levels rather than three.

The value was already in the product as a literal — reconciliation-flow.html sets body { background:#f4f6f8 } by hand, and the kit never defined it, so the app ground was a per-page decision. The token started as that measured value and is now #eef1f3, one step darker — which lifts a --card panel's separation from the ground from 1.08 to 1.13:1.

The Four Surface Levels
TokenLightDarkHigh ContrastWhat It Is For
--canvas#eef1f3#142226#000000the page the app sits on — viewport shell only
--background#ffffff#142226#000000#ffffff · the default surface in it, and the default control fill
--card#ffffff#233a3e#000000#ffffff · elevated but in flow: cards, panels, table bodies
--popover#ffffff#2f5155#000000#ffffff · floating out of flow: menus, dialogs, drawers, tooltips

Light is the only theme where canvas and background diverge. Dark (#142226) and high contrast (#000000) already separate the ground from --card, so --canvas equals --background there. A fourth value would be a distinction without a difference — and in high contrast, a near-black competing with #000000 would be actively worse.

Special Rules

Rules That Matter
  1. .ck-canvas goes on the element that owns the viewport, never on a panel. A panel on --canvas reads as a hole in the page rather than a surface on it.
  2. Anything laid on the canvas takes --card or --popover, whichever its role calls for. A card on the canvas needs no shadow: the value difference carries it in light, and --card carries it in dark.
  3. Never use --muted or --secondary (#f5f5f5) as a page ground. They're recessed fills inside a surface, and both are near-identical to --canvas in light — which is exactly how a wrong level stays invisible until someone switches to dark.
  4. Check --input against --canvas, not only against --background, for anything whose edge against the ground is its only affordance.
Accessibility
  • --canvas-foreground on --canvas is well above AA in all three themes (light is #05262e on #eef1f3, 13.99:1). It exists because Law 3 requires the pair, and it's the partner to use for text laid directly on the ground.
  • The canvas isn't an affordance, so the 3:1 non-text floor doesn't bind on it.
Notes

.ck-canvas sets min-height: 100vh, which is layout rather than a value.

Role Tokens

v3.2tokens.md

Four token families from the DES-667 review audit. Each names an intent that screens were expressing with a borrowed palette hue or a component-scoped token: the line in focus, the AI-extracted mark, the extraction confidence scale, and categorical row identity. Every one is declared in all three themes and inside a light-scoped region.

Live

Match — the line in focus

INV-2026-0412 · Line 3
Line 4 · Orchard Apples · 120 × 4.20 — in focus
Line 5
--match surface, --match-foreground text, --match-border edge

AI — the extracted mark

.ck-input.is-ai · --ai-border
Orchard Provisionsan OCR mark on --ai

Confidence

--confidence-ok
--confidence-confirmed
--confidence-warn

Series — categorical row identity

--series-1
--series-2
--series-3
--series-4
--series-5
--series-6
--series-7
--series-8
Spec
Tokens
TokenLightDarkHigh ContrastWhat It Is ForReplaces
--match#fef3c7#3d4a3b#2a2000the line in focus across the documents being compared — its surfacewas --amber-bg
--match-foreground#b45309#fbbf24#fbbf24text on itwas --amber-soft-foreground
--match-border#f2a618#f7b83d#ffbb33its edgewas --warning
--ai#ede9fc#364853#1a0a2athe AI-extracted mark's surfacewas --purple-bg
--ai-foreground#6c45c1#c4b0f0#c4b0f0text or glyph in the AI tonewas --purple-soft-foreground
--ai-border#6c45c1#c4b0f0#c4b0f0the AI mark's stroke — .is-ai fields, the OCR box; focus adds --focus-ring-aiwas --purple-soft-foreground
--confidence-ok#99b7bf#99b7bf#5eead4extraction confidence: good--dfp-ok reads it
--confidence-confirmed#3d707c#66a3b0#66d9efconfirmed by a person--dfp-confirmed reads it
--confidence-warn#f19546#f5a95e#fbbf24low confidence — check it--dfp-warn reads it
--series-1#047857#6ee7b7#6ee7b7categorical row identity 1 of 8was --emerald
--series-2#2562ee#92c4fe#92c4fecategorical row identity 2 of 8was --blue
--series-3#be123c#fb7185#fb7185categorical row identity 3 of 8was --rose
--series-4#6d28d9#a78bfa#a78bfacategorical row identity 4 of 8was --violet
--series-5#b91c1c#fca5a5#fca5a5categorical row identity 5 of 8was --crimson
--series-6#3d6b50#81c784#81c784categorical row identity 6 of 8was --sage
--series-7#b45309#fbbf24#fbbf24categorical row identity 7 of 8was --amber
--series-8#0e7490#67e8f9#67e8f9categorical row identity 8 of 8was --cyan
Rules
  1. Name the intent, not the hue. A row in focus is --match, not --amber-bg; an AI mark is --ai-border, not --purple-soft-foreground.
  2. A surface takes its own foreground (Law 3): --match with --match-foreground, --ai with --ai-foreground.
  3. Confidence is one scale everywhere. A table cell's low-confidence mark and the fields panel's read the same --confidence-*; --dfp-* is an alias.
  4. Series is for identity, not status. Eight categorical colours for rows that must be told apart; never to say good, bad or pending.

Typography — Noto Sans

foundationtypography.md

One family for everything: --font-sans is Noto Sans and carries everything, including values and IDs — there is no --font-mono. There is no display, serif or mono face either — one family carries the product (§ 6). The variable axis covers 100–900, so 400 / 500 / 600 all come from one file. The scale is whole pixels only — 8, 9, 10, 11, 12, 13, 14, 16, 18, 20, 22, 24, 28, 32. It used to carry 10.5, 11.5, 12.5, 13.5 and 15px; a half-pixel is not a difference a reader can perceive, and it made adjacent slots indistinguishable. Every font size in the kit is now a token — there are no literals left, not even the 8px notification badge. Reuse a slot; do not invent a size.

Live

Bold at Every Step — .ck-text.weight-bold

Handgloves 0123.size-3xs · 8px
Handgloves 0123.size-2xs · 9px
Handgloves 0123.size-xs · 10px
Handgloves 0123.size-sm · 11px
Handgloves 0123.size-md · 12px
Handgloves 0123.size-base · 13px
Handgloves 0123.size-lg · 14px
Handgloves 0123.size-xl · 16px
Handgloves 0123.size-2xl · 18px
Handgloves 0123.size-3xl · 20px
Handgloves 0123.size-4xl · 22px
Handgloves 0123.size-5xl · 24px
Handgloves 0123.size-6xl · 28px
Handgloves 0123.size-7xl · 32px

One class, two axes. .size-* names the step in the type scale, .weight-* the weight — both are the kit’s existing scales rather than new ones, so .ck-text.size-2xl.weight-bold is 18px at 700. Until now the kit gave nothing to carry a type token with, so a flow that wanted bold wrote font:700 18px and the scale went unreferenced — this page’s own weight specimen did exactly that. Every step is here, not just the ones in use today: a scale you can only reach half of is one people go around.

Handgloves 0123400 regular
Handgloves 0123500 medium
Handgloves 0123600 semibold
Handgloves 0123700 bold

TokenpxWeightSpecimenUsed For
--text-3xs8500Reconciled 42 invoices · Bao ShengNotification badge digits — the smallest mark
--text-2xs9600Reconciled 42 invoices · Bao ShengCounter digits at .size-sm
--text-xs10600Reconciled 42 invoices · Bao ShengUppercase group labels
--text-sm11500Reconciled 42 invoices · Bao ShengTable column header titles
--text-md12400Reconciled 42 invoices · Bao ShengTable cell data, hints, helper text, body copy
--text-md12600Reconciled 42 invoices · Bao ShengBadge / pill text
--text-base13400Reconciled 42 invoices · Bao ShengBody: labels, timestamps, input, dropdown, search, rows, options
--text-base13600Reconciled 42 invoices · Bao ShengButton label; selected option
--text-lg14600Reconciled 42 invoices · Bao ShengCard titles
--text-xl16600Reconciled 42 invoices · Bao ShengPanel / dialog / drawer titles; inactive breadcrumb
--text-2xl18600Reconciled 42 invoices · Bao ShengHeader bar title; active breadcrumb
--text-3xl20600Reconciled 42 invoices · Bao ShengAuth / onboarding title
--text-4xl22600Reconciled 42 invoices · Bao ShengReserved — a page hero
--text-5xl24600Reconciled 42 invoices · Bao ShengReserved
--text-6xl28600Reconciled 42 invoices · Bao ShengReserved
--text-7xl32600Reconciled 42 invoices · Bao ShengReserved

Handgloves 0123 agRQ--font-sans (Noto Sans)
PO-30-58440 · 12,480.00--font-sans + tabular-nums
Handgloves 0123--font-sans, semibold, larger step
Loaded by design-system.css?v=7ab031ca itself via an @import of the Google Fonts variable axis, so linking the token file is enough. Before this pass nothing fetched the family, so every standalone prototype rendered in -apple-system and the token was unenforceable.
Rules — references/components/typography.md

Typography

--font-sans

What It Is

One family for everything. Noto Sans carries all product UI — including values, IDs, amounts and code, which line up on font-variant-numeric:tabular-nums rather than on a second typeface. There is no display, serif or mono face.

Basic Information

Loading the Font

design-system.css?v=7ab031ca imports the Google Fonts variable axis itself:

@import url("https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap");

A few things about that line are deliberate:

  • It's in the token file, not per page, so "link design-system.css?v=7ab031ca only" actually holds.
  • The variable axis (100..900) means 400 / 500 / 600 all come from one file rather than three static faces.
  • display=swap paints text in the fallback immediately and reflows when Noto arrives, rather than blocking first paint.
  • The @import must stay ahead of every rule in the file. A stylesheet's @import is only valid before any style rule. Comments before it are fine.
  • There is no display face. Fraunces was marketing-only and never fetched, so any product screen that asked for it rendered in Times New Roman — a third typeface arriving by fallback rather than by choice. Display weight is --font-sans at a heavier weight and a larger step.

The Google family name is "Noto Sans", second in the chain. "Noto Sans Variable" ahead of it is the @fontsource name and resolves only inside the React app, which loads the font through npm.

Optionally add <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> to a page head if first paint matters. It shortens the handshake but isn't needed for correctness.

Worth knowing: before v3.2 nothing in the kit fetched the family at all. --font-sans asked for Noto Sans, no @font-face or link existed anywhere, and the machine didn't have it installed — so every standalone prototype silently rendered in -apple-system. The token was declared and inert.

Tokens

The Families
TokenLightDarkHigh ContrastWhat It Is For
--font-sans"Noto Sans Variable", "Noto Sans", -apple-system, BlinkMacSystemFont, sans-serif—var(--font-sans)"Noto Sans Variable", "Noto Sans", -apple-system, BlinkMacSystemFont, sans-serif · everything
The Scale
TokenLightDarkHigh ContrastWhat It Is For
--text-3xs8px—var(--text-3xs)8px · notification badge digits — the smallest mark in the product
--text-2xs9px—var(--text-2xs)9px · counter digits at .size-sm
--text-xs10px—var(--text-xs)10px · uppercase group labels, .ck-kbd.size-sm
--text-sm11px—var(--text-sm)11px · table column headers (medium), small counters, keycaps
--text-md12px—var(--text-md)12px · table cell data, hints, helper text, badge text, body copy
--text-base13px—var(--text-base)13px · body — labels, timestamps, input, dropdown, search, list rows, options, buttons
--text-lg14px—var(--text-lg)14px · card titles (semibold)
--text-xl16px—var(--text-xl)16px · panel / dialog / drawer titles, inactive breadcrumb (semibold)
--text-2xl18px—var(--text-2xl)18px · header bar title and the active breadcrumb (semibold)
--text-3xl20px—var(--text-3xl)20px · auth and onboarding title
--text-4xl22px—var(--text-4xl)22px · reserved — a page hero
--text-5xl24px—var(--text-5xl)24px · reserved
--text-6xl28px—var(--text-6xl)28px · reserved
--text-7xl32px—var(--text-7xl)32px · reserved

Whole pixels only. The scale used to carry 10.5, 11.5, 12.5, 13.5 and 15px. A half-pixel is not a difference a reader can perceive, and it made adjacent slots indistinguishable — so the scale is now 8, 9, 10, 11, 12, 13, 14, 16, 18, 20, 22, 24, 28, 32.

Where Each Size Is Used

18px semibold — the header bar title and the active breadcrumb. The active crumb steps up in size as well as weight, against the trail's 16px. It is keyed to aria-current="page", so the drawing cannot drift from what a screen reader announces.

16px semibold — panel, dialog and drawer titles, and the inactive breadcrumb. That covers .ck-dialog-title, .ck-drawer-title, .ck-notif-title, .ck-folder-title, .ck-dfp-title and .ck-dd-picker-title. One size for every floating or panel header.

14px semibold — card titles. .ck-card-title, .ck-empty-title, .ck-error-state-title, .ck-dropzone-title, .ck-connector-name, .ck-feature-title.

13px regular — the body size. Small labels, timestamps, inputs, selects, textareas, dropdown triggers, search boxes, list rows, menu items, options and button labels. If you are unsure what size a piece of text should be, it is this one.

12px regular — table cell data, hints and helper text. Also badge and pill text, sub-titles, alert messages and card descriptions.

11px medium — table column header titles. Deliberately lighter than the cells beneath them: a column header is a label, not data, so it steps down in size and up in weight rather than shouting.

10px semibold — uppercase group labels. .ck-menu-label, .ck-dfp-group-label, .ck-sidebar-flyout-title.

9px and 8px — digits only. Counters at .size-sm and the notification badge. Never body text.

TokenLightDarkHigh ContrastWhat It Is For
--font-weight-normal400—var(--font-weight-normal)400
--font-weight-medium500—var(--font-weight-medium)500
--font-weight-semibold600—var(--font-weight-semibold)600

Special Rules

Every piece of text in a flow must carry a token

This binds when building flows, not only when building the kit. A flow that writes font-size: 14px has opted out of the scale, and the next size change will silently miss it. There is no font size anywhere in the kit that is not a token — hold a flow to the same standard.

Use the token the role calls for, not the one whose number happens to match: a card title is 14px semibold because it is a card title, and if that size ever moves, the token moves with it.

Rules That Matter
  1. Never write a literal font family or size. Always through the token: font: var(--font-weight-semibold) var(--text-base) var(--font-sans).
  2. Don't invent a new size for a new component — reuse a slot. The scale has ten, which is more than enough.
  3. There is no display face. Display weight is --font-sans at --font-weight-semibold and a larger step — and there is no --font-weight-bold, so asking for one silently invalidates a font shorthand.
  4. Never go below 11px. Pill text at 11px/600 is the floor, and it doesn't count as large text, so the full 4.5:1 contrast requirement applies.
  5. Weight is emphasis, not state. Don't communicate meaning through weight alone.
Accessibility
  • Amounts, IDs and any column of digits take font-variant-numeric: tabular-nums, so they line up without a second typeface. That is what --font-mono used to be for, and why it is gone.
Checking it actually loaded

document.fonts.check('600 16px "Noto Sans"') must return true, and a string set in var(--font-sans) must measure differently from the same string in -apple-system.

Notes

The font shorthand does accept var() substitutions — worth knowing, since a shorthand that fails to parse drops the whole declaration rather than degrading.

Nothing is a literal any more. The notification badge, the pill counter and the standalone counter all read the scale now, so there is no font size in the kit that is not a token.

Icon

kiticon.md

Every icon is Lucide: 24×24 viewBox, currentColor, 2px stroke, round caps and joins. One glyph size (16px) in one box (24px). Two colours only — a third tone is a defect, not a variant.

Live

Sizes — small, default, navigation

.size-sm · 12 in 18
default · 16 in 24
.size-nav · 20 in 40 (left rail only)
.size-sm.is-destructive
--icon-size-sm 12px in --icon-box-sm 18px — the same 2:3 ratio, colour, stroke and tones as the default; for dense rows where a 24px frame crowds.

A Count On An Icon — .ck-ico-count

The icon is the subject and the count qualifies it, so the bubble is half the box — 12px on the 24px icon, 16px on the 40px rail icon. Matched to the glyph it stops being a count on an icon and becomes two marks the same size sharing a corner. Past two digits it grows sideways, never taller. The ring is --background, so it separates from the glyph in all three themes.
3.ck-ico — 12px
12two digits
99+grows sideways
5.is-error
8.is-neutral
4.size-nav — 16px
.ck-icon-btn.size-xs
.ck-icon-btn — 16px
default
destructive
interactive
selected
disabled
destructive interactive
Rules — references/components/icon.md

Icon

.ck-ico

What It Is

The glyph that sits beside a label, inside a button, or on its own as an icon-only control. Every icon in the product is a Lucide icon (lucide.dev, MIT).

Basic Information

How to Write One
<span class="ck-ico" aria-hidden="true">
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
       stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
    <path d="M12 5v14M5 12h14"/>
  </svg>
</span>

Use only these children: path, line, polyline, polygon, circle, ellipse, rect. No transforms, filters, fills, explicit stroke colours, or <use>.

One exception to the geometry: a filled glyph that has no stroke is left alone. Forcing a stroke onto it would hollow it out.

Size
TokenLightDarkHigh ContrastWhat It Is For
--icon-size16px16px16px16px · the glyph — the only icon size in the product
--icon-box24px24px24px24px · the layout box; glyph centred, 4px clear all round
--icon-stroke2222 · unitless, drops straight into stroke-width

Inside a control, the glyph keeps 16px but loses the 24px box. A 24px box leaves no room in a 20px .ck-pill.size-sm, so within a control the glyph sits in that control's own padding. This is the one exception, and the alternatives were worse: a second glyph size, or a small pill that can't hold an icon.

Colour
TokenLightDarkHigh ContrastWhat It Is For
--icon-color#738f96#bcd7dd#e0e0e0everything that is not destructive
--icon-color-destructive#d90500#fe9b98#ff4444delete, remove, disconnect, error
Radius

The hover fill is a rounded square: --radius-md (8px), or --radius-sm (6px) at .size-xs. Never a circle.

Tokens

States

A bare .ck-ico is decorative and has no states. States apply when you add .is-interactive, or when you use Icon Button.

NormalDestructive
Rest--icon-color--icon-color-destructive
Hover--foreground on --hover-bg--destructive-soft-foreground on --destructive-bg
Active--selected-bg--destructive fill, --destructive-foreground glyph
Selected--primary on --selected-bg--destructive-soft-foreground on --destructive-bg
Focus--focus-ring--focus-ring-error
Disabled--input, pointer-events: nonesame

The two colour families never cross. A destructive icon never hovers to a neutral fill; a normal icon never hovers to red.

Hover fill comes from --ck-icon-hover, which :root points at --hover-bg — the same hover fill every other ghost control in the product uses. It is set in one place deliberately, so every icon control agrees by construction.

Special Rules

Rules That Matter
  1. Lucide only — never hand-drawn, never a second library. If a glyph you need isn't in Lucide, draw it to Lucide's own contribution rules (below) so nobody can tell the difference.
  2. A 16px glyph in a 24px box, everywhere. One glyph size in the whole product. Lay out against the box, not the glyph — that's what makes a column of icons line up no matter what shape each glyph is.
  3. Two resting colours, and only two: normal and destructive. A third icon tone is a defect, not a variant — if an icon needs to carry another meaning, put the meaning in the label or a status pill.
  4. stroke="currentColor", always. Hardcode a stroke colour and the icon stops following its container, its theme, and its hover state.
  5. An interactive icon needs a real <button>, a label, and a tooltip. An icon carries no text, so on its own it's unusable and unannounced. Use Icon Button — which is what an interactive icon almost always is.
Accessibility
  • Interactive icons must be a real <button>. .ck-ico.is-interactive styles a focusable element; it does not turn a <span> into a button.
  • Label the action, not the glyph: aria-label="Delete Folder", not "Trash icon". Add a Tooltip with the same wording, shown on hover and focus.
  • Decorative icons get aria-hidden="true" so they aren't announced twice alongside the label they sit next to.
  • A toggle needs aria-pressed. .is-selected is visual only.
  • 20px is under WCAG 2.5.8's 24×24 target floor, so an interactive .ck-ico needs its target expanded — which .ck-icon-btn.size-xs already does with a 24px ::before. Prefer the button.
Don't
  • Don't use title instead of a tooltip. It doesn't appear on keyboard focus, can't be styled, and is announced unreliably.
  • Don't build a second tooltip. Use the kit's.
  • Don't set a per-control glyph size.
  • Don't convey status by icon colour alone — the two tones separate destructive from everything else, not one status from another.

Avatar

v3.2avatar.md

A tinted plate identifying a person, on the icon-box scale so it lines up with a glyph in the same column. Three modes and exactly one per plate: a photo, two initials, or a glyph where there is no person yet — an unclaimed seat, a system actor. Without that third mode call sites invent one, and the usual invention is a "?" that reads as an error rather than an absence.

Live
VJ20 · size-sm
VJ24 · default
VJ32 · size-lg
RKinitials
glyph · unassigned
photo
+4AMRKVJstack · overlap and count
Markup
<span class="ck-avatar">VJ</span>
<span class="ck-avatar"><img alt="Vaibhav Joshi" src="…"></span>
<span class="ck-avatar is-icon"><svg>…</svg></span>

<span class="ck-avatar-stack">
  <span class="ck-avatar is-more">+4</span>
  <span class="ck-avatar">RK</span>
  <span class="ck-avatar">VJ</span>
</span>

The stack is row-reverse, so the first avatar in the markup paints on top. Read order and paint order are opposites — put the overflow count first in the DOM and it lands last on screen.

Initials are two characters, uppercase. The class uppercases them; three or more overflow the plate at size-sm.

An image avatar carries alt — the person's name, unless their name already sits beside it, in which case the image is decorative and takes an empty alt.

Editor — the signed-in user's own photo

The signed-in user's own photo with the control that changes it. Distinct from .ck-avatar, which identifies someone in a list at 20–32px and is not editable. 104px is off the avatar scale on purpose — this is the one place a face is shown large enough to judge. The ring is --card, a cut-out of the surface beneath.

Live
Profile photo
104px photo, 28px button — size-sm on the control scale

Button

kitbutton.md

Three variants plus link and destructive-ghost, three sizes, six states. The default IS the second-tier button — there is no separate grey secondary, and there should not be one: the outlined default was always doing that job, and shipping both meant every screen had to pick between two things that meant the same thing. The base is a --background surface carrying a --primary label and border, the recognised outline pairing (§ 3), at 12.01:1, and it hovers to --hover-bg. Nothing in the kit hovers to grey. Filled variants darken their own fill on hover via the --*-hover tokens; they must never use opacity, which dims the label with the fill and reads as disabled (§ 8). Button is the reference for the control height scale — 28 / 36 / 44, a uniform 8px step — so every other control was corrected to match it. The link variant lives here too: it is a .ck-btn variant, not a separate component, so it keeps the control height and aligns beside a filled button.

Live

Holds a Value — .has-value

at rest
holds a value
icon, at rest
is-selected — the chosen one
holds a value
both at once

“Configured” is not “selected”. A control that opens a popover and comes back with something set was reaching for .is-selected because it was the nearest state — and that one means the control is the chosen one among several. .has-value is already the kit’s word for “this holds something” on fields; this is the same word for a control that is not a field. The signal is a dot, not a fill and not a stroke: the fill is selection’s, and .ck-btn is already --primary-bordered at rest so a stroke would say nothing. The two stack — the last one is selected and carries a value, which is a thing a control needs to be able to say. Compared on .ck-icon-btn because .ck-btn.is-selected carries no paint of its own.

default — the second-tier button
primary
destructive
ghost

Destructive outline — .ck-btn.destructive.is-outlined

A labelled destructive action that has to be findable at rest. The base .ck-btn already is the outline pattern, so this is that pattern in the destructive family and nothing more. Ghost gives a red word with no edge, which reads as a link in a row of buttons; the solid fill shouts.

outline · ghost · filled — all three agree on press
link
ghost + destructive

Link variant — .ck-btn.link, all six states

default
hover
active
focus-visible
disabled
sm (28)
default (36)
lg (44)
with a leading icon
beside a filled button
sm (28)
default (36)
lg (44)
leading icon
trailing icon
icon + destructive
disabled
primary disabled
destructive disabled
ghost destructive disabled
the control height scale — every control in a row shares 36px
Rules — references/components/button.md

Button

.ck-btn

What It Is

A labelled action. Icon-only actions use Icon Button instead.

Basic Information

Anatomy
<button class="ck-btn primary size-lg">
  <svg>…</svg>          <!-- optional leading icon -->
  Add folder
</button>

display: inline-flex with align-items: center and a gap, so an icon and a label share a baseline without any wrapper.

Variants
ClassSurfaceLabelBorderUse For
(none) — outline--background--primary--primarythe default, and the second-tier button — any action beside a primary
.primary--primary--primary-foreground--primarythe one main action in a view
.destructive--destructive--destructive-foreground--destructivedelete, remove, disconnect
.ghosttransparent--foregroundtransparenttoolbars, dense rows, tertiary actions
.linktransparent--linktransparenta button-shaped link — see below

The outline variant pairs a --background surface with a --primary label rather than --foreground. That's the outline pattern: legal because the label and the border are the same token, and primary on background measures 12.01:1.

Don't use .ghost as the main action — it has no affordance at rest.

Sizes

Button is the reference for the control height scale; the other controls were aligned to these numbers.

ClassHeightPaddingGapIconReach for it when
.size-sm28px0 10px4px14pxthe button repeats per row — table row actions, inline filter bars, a toolbar, a card footer in a dense grid. Also anything beside a .size-sm input
(none)36px0 14px6px15pxdefault — forms, dialog footers, page headers, field grids. If unsure, this
.size-lg44px0 18px8px16pxone prominent action on a touch-first or sparse surface: auth screens, onboarding, an empty state's CTA. At most one per view

Every height is a multiple of 4, stepping by a uniform 8px.

Type stays medium (500) / 13px at every size, so a row of mixed-size buttons shares one baseline and one weight. Only the box grows.

Geometry
PropertyValue
radius--radius-md (8px), unchanged at every size
border width1px
transition--duration-base on background, border-color, color
white-spacenowrap — labels never wrap mid-button
Link Variant — .ck-btn.link

A button-shaped link, and the only place it is documented — it is a fifth .ck-btn variant, not a component of its own, so its rules, states, sizes and tokens all live in this sheet. It's a fifth variant rather than a new component, and it keeps the control height scale, so it still aligns in a dialog footer beside a filled button.

SlotTokenWhy
label--linkexists precisely for this, and is distinct from --primary — a link is its own semantic
fill / bordertransparentthe underline carries the affordance
active--hover-bga press needs a surface, or the only feedback is the underline thickening
disabled--muted-foregroundtokens, not opacity
  • Never remove the underline — colour alone is not an affordance. text-underline-offset: 2px, thickening to 2px on hover.
  • It's declared after the .ck-btn base so it wins on source order.
  • It keeps the button's padding, so the hit area still clears 24×24.
Destructive Ghost — .ck-btn.ghost.destructive

A labelled destructive action with no fill: "Clear all", "Remove all filters", "Discard changes". This replaces the retired .ck-clear-btn, which was a whole component for what is one variant combination.

TokenLightDarkHigh ContrastWhat It Is For
--destructive#e50600#fe9b98#ff4444rest · it announces what it does before being hovered, without a standing red fill
--destructive-bg#ffeaea#3d3e41#330000hover · the soft pair, so the label stays at 4.5:1 on the tint
--destructive-soft-foreground#d90500#fe9b98#ff4444hover · the soft pair, so the label stays at 4.5:1 on the tint
--destructive#e50600#fe9b98#ff4444active · a firmer press, solid pair
--destructive-foreground#ffffff#05262e#000000active · a firmer press, solid pair
--focus-ring-error0 0 0 3px #f9c8c70 0 0 3px #473d3f0 0 0 3px #380f0ffocus · destructive actions take the error ring
--muted-foreground#738f96#bcd7dd#e0e0e0disabled · tokens, not opacity

Specificity matters here. .ck-btn.ghost is declared after .ck-btn.destructive, so at equal specificity ghost wins and the pair rendered as a plain neutral ghost. These rules use two classes (0-3-0) to beat it — don't "fix" that by reordering the variants, which would break either variant used on its own.

It carries the same treatment as .ck-icon-btn.tone-danger, so a labelled and an unlabelled destructive action agree. A dismiss inside a pill is different and deliberately so — neutral at rest, destructive on hover. See Badge / Chip / Pill.

Layout — the glyph leads, always
<button class="ck-btn ghost destructive"><svg>…</svg>Clear All</button>

The glyph comes first, then the label. Not the reverse, and never glyph-less. A trailing cross reads as dismiss this control; a leading cross reads as this action clears. The whole point of the variant is announcing what it does before it is hovered, and a trailing glyph undoes that.

This is the reference layout for every "Clear all" / "Reset" / "Remove all" in the kit — and both use it.

Tokens

States — every variant, including link

The link variant carries the same six states as the filled variants:

StateLink Variant
rest--link label, transparent fill, 1px underline at a 2px offset
hoverlabel stays --link; the underline thickens to 2px
active--hover-bg fill behind the label, label stays --link
focus-visible--focus-ring
disabled--muted-foreground, with the underline in the same colour

Hover changes the underline, not the colour. There is no --link-hover token — a link is already the emphasised thing in its row, so thickening the rule it already carries is a clearer signal than shifting its hue.

States
StateOutline (default)PrimaryDestructiveGhost
hover--hover-bg--primary-hover--destructive-hover--hover-bg + --primary label
active--selected-bg--primary-active--destructive-active--selected-bg
disabled--muted surface, --muted-foreground label, --input border — identical for every variant
focusbox-shadow: var(--focus-ring); destructive uses --focus-ring-error

Disabled matches on :disabled, .disabled and [aria-disabled="true"], so the class the reference pages use and the real attribute behave the same. It sets pointer-events: none so hover can't override it.

Hover and Active Fills

Derivation rule: a hover or active fill moves away from its own lightness extreme — a light surface darkens, a dark surface lightens — so there's always room to move. The result must still clear 4.5:1 against the family foreground; if it doesn't, it shifts the other way instead.

TokenLightDarkHigh ContrastWhat It Is For
--primary-hover#26515f#c3d2d6#55b7ca#26515f · #c3d2d6 · #55b7ca
--primary-active#3e6370#a6b3b6#479bab#3e6370 · #a6b3b6 · #479bab
--destructive-hover#c10400#d6827f#d73838#c10400 · #d6827f · #d73838
--destructive-active#a40300#ffb2ae#ff776f#a40300 · #ffb2ae · #ff776f

Two values shift lighter rather than darker, because their family foreground is dark: dark --destructive-active and high-contrast --destructive-active. A first pass darkened those and high-contrast destructive-active measured 3.41:1.

Special Rules

Link, ghost and icon button are three different things

The distinction is fundamental, not cosmetic:

  • .ck-btn.link carries a text label with an underline. Use it for navigation or a tertiary action that should read as a link. The underline is the affordance — never remove it.
  • .ck-btn.ghost carries a text label with no underline and no fill. Still a button, still performs an action in place.
  • Icon Button carries a glyph and no text, and needs an aria-label plus a tooltip.

A glyph-only action must never be .ck-btn.link. A labelled link must never be .ck-btn.ghost.

Rules That Matter
  1. One .primary per view. Two primaries compete and neither wins.
  2. Size is density, not emphasis. Use .primary to make an action matter and .size-lg to fit a sparse or touch-first surface. A .size-lg default button in a dense table row gets both wrong.
  3. Match the height of whatever it sits beside. A default button next to a .size-sm input is the ragged row the control scale exists to prevent.
  4. Let the token carry the hover — never opacity. Opacity dims the label along with the fill, so the button reads as disabled.
  5. .destructive is for irreversible actions, never for "Cancel".
  6. Icon before label. The only exception is a trailing chevron.
  7. Native <button>. Don't build one from a <div>.
  8. There is no grey secondary variant — the default is the second-tier button. It was retired because it duplicated the default's job, so every screen had to choose between two things that meant the same thing, and because its hover was the only grey hover in the kit: --secondary-hover and --secondary-active are gone with it. If you want an action to sit beside a primary, use the bare .ck-btn.
  9. Nothing in the kit hovers to grey. Every hover is --hover-bg, a --primary- step, a --destructive- step, or the sidebar accent. A grey hover reads as a different design system on the same page.
Accessibility
  • The focus ring is :focus-visible, so it appears for keyboard and not mouse.
  • Disabled uses pointer-events: none. For a button that must stay focusable to explain why it's unavailable, use aria-disabled="true" and keep it in the tab order.
Known Defects

Both are token-set issues inherited by every component — don't patch them locally.

  • --muted-foreground on --muted is 3.16:1, under AA for body text. The disabled label inherits this in all five variants.
  • The focus ring fails the 3:1 non-text floor. --focus-ring is a 25%-alpha tint of --ring, measuring 1.13:1 (light), 1.04:1 (dark) and ~1.2:1 (high contrast) against the page ground. It clears 3:1 against the button fill, but the ring's outer edge sits on the page, and that's what a keyboard user sees. Raising the alpha to 60% / 35% / 45% clears it.

Icon Button

kiticon-button.md

A button whose whole label is its glyph, so it always needs an aria-label and a tooltip. On the § 7a control scale — 28 / 36 / 44, square, a uniform 8px step — plus a 24px size-xs for controls that sit inside another control.

Live

.size-2xs — 18px, the 12px glyph

rest
hover
pressed
focus
selected
disabled
.tone-danger
.is-close
2xs 18 · xs 24 · sm 28 · default 36
Every state, tone and interaction is the icon button’s own; the target stays 24 × 24.

Switched on — .is-on

off
on
on + hover
.is-selected — picked, not on

On keeps the ground and tints the glyph. .is-selected fills the button, which is right for “this is the one you picked out of several”. It is wrong for a toggle that is simply on: a filled square in a row of unfilled ones reads as the current selection, so a bold button and an italic button both lit would look like a choice between them rather than two independent switches. aria-pressed is what makes it true; the class only paints it.

xs (24)
sm (28)
default (36)
lg (44)
tone-accent
tone-danger
tone-primary
is-selected
disabled
Rules — references/components/icon-button.md

Icon Button

.ck-icon-btn

What It Is

A button whose entire label is its glyph. Used in toolbars, table rows, panel headers, and anywhere a text label wouldn't fit.

Basic Information

Anatomy
<button class="ck-icon-btn" aria-label="Delete Folder">
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">…</svg>
</button>
Sizes

On the control height scale. Square, so width tracks height.

ClassBoxGlyphReach for it when
.size-xs24px16pxa control inside another control: a chip's dismiss, a dialog's close, an inline remove, a search box's clear. 24px is the target-size floor — don't go below it
.size-sm28px16pxthe button repeats per row — table row actions, an inline toolbar, a compact panel header. Also whenever it sits beside a .size-sm input or button
(none)36px16pxdefault — page headers, dialog headers, standard toolbars
.size-lg44px16pxtouch-first surfaces, or a lone control with real prominence. Rare in product UI. 44px is the AAA target size

The glyph is a flat 16px at every size — .ck-icon-btn svg reads --icon-size, with no per-size override. The box grows; the glyph does not. That is the icon law applying here like everywhere else.

.size-xs is deliberately off the control scale. It's never a peer of a button or an input — it's a control nested inside one. In a toolbar row it would sit 10px short of its neighbours.

.sm and .lg survive as aliases because pages already use them. New markup uses .size-sm / .size-lg.

Tokens

Tones

Tone is two local properties rather than a variant class per colour, so adding one is two declarations and no new rules:

ClassHover FillHover GlyphUse For
(none)--hover-bg--foregroundneutral actions
.tone-accent--hover-bg--primarythe affirmative action in a group
.tone-danger--destructive-bg--destructive-soft-foregrounddelete, remove, disconnect
.tone-primary--primary--primary-foregrounda single emphasised control
.on-dark--toolbar-dark-hover--toolbar-dark-foregroundinside a --toolbar-dark surface
/* a new tone, in full */
.ck-icon-btn.tone-warning{--ck-icon-hover:var(--warning-bg);--ck-icon-hover-fg:var(--warning)}
.tone-danger is destructive at rest

A cross, a trash, a disconnect reads destructive at rest — not neutral with a red hover. All six states:

TokenLightDarkHigh ContrastWhat It Is For
--icon-color-destructive#d90500#fe9b98#ff4444rest
--destructive-soft-foreground#d90500#fe9b98#ff4444hover
--destructive-bg#ffeaea#3d3e41#330000hover
--destructive#e50600#fe9b98#ff4444active
--destructive-foreground#ffffff#05262e#000000active
--destructive-soft-foreground#d90500#fe9b98#ff4444selected
--destructive-bg#ffeaea#3d3e41#330000selected
--focus-ring-error0 0 0 3px #f9c8c70 0 0 3px #473d3f0 0 0 3px #380f0ffocus
--input#8b9292#5f7073#999999disabled

.ck-alert's dismiss shares this rule rather than duplicating the values.

PartToken
resting glyph--muted-foreground
resting filltransparent
hover--ck-icon-hover / --ck-icon-hover-fg (set by tone)
active--selected-bg
selected--selected-bg + --selected-fg
disabled glyph--input
focus--focus-ring
radius--radius-md; .size-xs is --radius-sm

The hover fill defaults to --hover-bg — the product-wide hover fill for any ghost control. It's set once in :root as --ck-icon-hover, so every icon control in the product agrees by construction. That matters: it used to be overridden locally here, so an icon button hovered blue while the header bar's .icon-btn, which had no override, hovered grey.

--ck-icon-hover and --ck-icon-hover-fg are component-scoped custom properties, not theme tokens — set per tone on the element, not per theme in :root.

The hover fill is a rounded square, never a circle — --radius-md, or --radius-sm at .size-xs so a 24px box doesn't read as a circle.

States
StateTreatment
defaulttransparent, --muted-foreground glyph
hovertone's fill and glyph
active--selected-bg
selected (.is-selected)--selected-bg + --selected-fg — a persistent state, e.g. an active view toggle
disabled--input glyph, transparent, pointer-events: none
focus--focus-ring on :focus-visible

Disabled uses a token rather than opacity, so the glyph keeps its contrast rather than fading into the surface.

Rendering the states in a spec sheet

data-force~="hover|active|focus|disabled" paints a state without the user being in it, so a documentation page can show all six from the kit's own rules rather than re-declaring them in page CSS. Documentation only — never in product markup, where a forced state is a lie about what the control is doing.

Special Rules

Not to be confused with a link button

Three labelled-or-not actions get mixed up. The difference is fundamental, not cosmetic:

CarriesUse For
icon buttona glyph, no textan action with no room for a label — needs aria-label + a tooltip
.ck-btn.linka text label with an underlinenavigation, or a tertiary action that reads as a link
.ck-btn.ghosta text label, no underline, no filla tertiary action that is still a button

A glyph-only action must never be .ck-btn.link, and a labelled link must never be .ck-btn.ghost — the underline is what tells a reader it navigates.

Outlined — .is-outlined

A bordered icon control, for a run of them that must read as a group against a busy row — Pagination's first / prev / next / last.

StateTreatment
rest--background fill, --input border, --muted-foreground glyph
hover--ck-icon-hover fill, --input-border-hover border
active--selected-bg fill, --primary border
disabled--muted fill, --muted-foreground glyph, --input border

It takes the field stroke set, so it stays in sync with .ck-input and .ck-dd-trigger — a footer's nav buttons and its rows-per-page chooser sit in the same row and must carry the same edge.

The resting pair is a declared exemption: --muted-foreground on --background is 3.44:1, the same de-emphasis Empty State carries. A run of four chevrons must read quieter than the data above it.

The pill dismiss is the exception

Inside a .ck-pill / .ck-badge / .ck-chip, the dismiss cross does not use a tone. It rests in the pill's own foreground and only shows destructive tokens on hover — a cross that's red at rest reads as a standing warning on every chip in a filter bar.

So .tone-danger is wrong there. See Badge / Chip / Pill for the full state table and the three specificity traps involved.

Rules That Matter
  1. aria-label is not optional. There's no text, so without it the button announces as "button" and nothing else. This is the single most common defect in this component. Label the action, not the icon: "Delete folder", not "Trash icon".
  2. stroke="currentColor" on the glyph, or the tone system can't recolour it.
  3. Match the neighbour's size. A 36px icon button beside a 28px input is the failure this component's size scale exists to prevent.
  4. .tone-danger means destructive — not "close" or "cancel."
  5. A toggle needs aria-pressed. .is-selected is visual only.
Accessibility
  • .size-lg is exactly 44×44, the AAA target size. .size-sm at 28px clears the 24×24 AA floor. Don't go below 28px except for .size-xs nested controls at 24px.
  • The focus ring is :focus-visible, so it appears for keyboard and not mouse.
Merged One-Offs

Two bespoke buttons duplicated this component and are now aliases of it:

WasNowWhat it gained
.ck-remove — 16px, --destructive, own hover.ck-icon-btn.tone-danger.size-xs8px of hit area (16px failed the 24×24 floor), a disabled state, an active state
.ck-dialog-x — 24px, own hover.ck-icon-btn.size-xsa disabled state, an active state, the tone system

New markup uses .ck-icon-btn directly. The aliases exist so that if the classes turn up in an unsearched flow, it still renders.

Notes

Sizes were 26 / 32 / 38, which left an icon button 2px short of the button beside it at every step. They're now 28 / 36 / 44, matching .ck-btn, .ck-input, .ck-select and .ck-dd.

Some non-ck--prefixed siblings (.nav-btn, .theme-opt) share its rules. That's flagged, not fixed — it needs triage.

Checkbox

kitcheckbox.md

16px to match Radio, an --input border at rest and a --primary fill with a --primary-foreground mark when checked. Both are bare outlines at rest, which is why § 9 binds on their border.

Live

Sizes — 16px default · 12px .size-sm, the table checkbox

Same states, same interaction. Every checkbox inside a .ck-table is 12px by default; the target stays 24px.
rest
hover
focus
checked
indeterminate
disabled
disabled checked
invalid
rest
hover
focus
checked
indeterminate
disabled
disabled checked
invalid
default
checked
indeterminate
invalid
disabled
checked + disabled
With a label — every stateThe bare atom carries no label, which is right inside a table row or a menu where something else names it. A control standing on its own in a form needs a name, and the name has to answer to the pointer. The wrapper is a <label>, so the browser forwards a click on the text to the control — one target, no script, nothing that can desync. The label follows the control’s state: disabled greys it, invalid turns it destructive.
rest
hover
focus
checked
checked — hover
indeterminate
invalid
disabled
disabled — checked
disabled — indeterminate
with a hint
label first (settings row)
Rules — references/components/checkbox.md

Checkbox

.ck-check

What It Is

For a value that applies on submit. For a setting that takes effect immediately, use Switch.

Basic Information

Anatomy
<input type="checkbox" class="ck-check">
<input type="checkbox" class="ck-check" checked>
<input type="checkbox" class="ck-check" aria-invalid="true">

The tick and the indeterminate dash are drawn on ::after — no icon font, no SVG, no wrapper.

Geometry
PropertyValueNote
size16pxmatches .ck-radio and shadcn's size-4. It was 14px, so a checkbox and a radio in the same form were visibly different sizes
border1.5px
radius--radius-xs (4px)at 2px a 16px box reads as a rendering artefact rather than a deliberate corner. 4px is also what the React kit already shipped
tick3.5 × 7px, rotated 45°two borders with a 1px radius, so the elbow and both ends are rounded — the round joins and caps § 7c asks of every icon. Centred with left/top: 50% and a -50% translate
dash7 × 1.5pxa filled bar with --radius-full, so both ends are round. It was a border-top, which cannot take a radius
Sizes

None. 16px, fixed.

shadcn ships no size variants either, and the reason holds here: a checkbox is read against its label, not against neighbouring controls, so it has no row-height obligation to meet. What it does have to match is .ck-radio, and it does.

If a dense table needs a smaller target, shrink the row, not the box. Below about 14px the tick stops being legible and the hit area fails the target-size floor. Keep the box and let the <label> carry the hit area.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--input#8b9292#5f7073#999999resting border · border-input
--background#ffffff#142226#000000resting fill · (light default)
--primary#013c4b#e7f9fe#66d9efchecked fill · bg-primary
--primary#013c4b#e7f9fe#66d9efchecked border · border-primary
--primary-foreground#ffffff#05262e#000000tick / dash · text-primary-foreground
--primary#013c4b#e7f9fe#66d9efhover border · —
--primary-hover#26515f#c3d2d6#55b7cachecked hover fill · (shadcn has none)
--primary-active#3e6370#a6b3b6#479babchecked active fill · (shadcn has none)
--destructive#e50600#fe9b98#ff4444invalid border · aria-invalid:border-destructive
--focus-ring-error0 0 0 3px #f9c8c70 0 0 3px #473d3f0 0 0 3px #380f0finvalid focus ring · ring-destructive/20
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363cfocus ring · ring-ring/50
--muted#f5f5f5#1b292d#1a1a1adisabled fill · (shadcn uses opacity-50)
--input#8b9292#5f7073#999999disabled checked fill · —
--background#ffffff#142226#000000disabled mark · —

One deliberate divergence from shadcn. shadcn disables with opacity-50, which fades the tick along with the box. We use tokens instead, so the tick keeps its contrast on a dimmer box — with opacity the mark loses contrast and the control reads as broken rather than unavailable.

One divergence we don't take. shadcn tints the unchecked fill in dark mode (dark:bg-input/30) because a bare border on a dark ground is weak. We rely on the corrected --input border instead, which measures 3.15:1 against the dark page — perceivable on its own, so no tint token is needed.

States
StateUncheckedChecked / Indeterminate
default--input border on --background--primary fill, --primary-foreground mark
hover--primary border--primary-hover fill
active--primary border, --hover-bg fill--primary-active fill
focus--focus-ring--focus-ring
invalid--destructive border, --focus-ring-error on focussame, fill unchanged
disabled--muted fill, --input border--input fill, --background mark

Hover has to change the fill once checked — moving only the border is invisible on a filled box.

Disabled sets pointer-events: none so hover can't override it, and matches :disabled as well as [aria-disabled="true"].

Contrast
CheckLightDarkHigh Contrast
resting border vs page3.173.157.37
checked fill vs page12.0115.0612.74
tick on checked fill12.0115.0612.74
invalid border vs page4.828.056.16
disabled mark on fill3.173.157.37

An unchecked checkbox is a bare outline, so the 3:1 non-text floor binds on its border the same way it binds on a Switch track. This is the second component depending on the corrected --input.

Special Rules

Rules That Matter
  1. Use the native <input type="checkbox"> with appearance: none. Not a styled <div> with role="checkbox".
  2. indeterminate is a DOM property, not an attribute. Set it in script — el.indeterminate = true. indeterminate="true" in markup does nothing, and the DOM property is also what makes it announce as "mixed".
  3. It needs an accessible name. Wrap it in a <label> or use for / aria-label. A checkbox with no name is a defect however it looks.
  4. aria-invalid="true" is what assistive tech reads. A red border alone conveys nothing to a screen reader.
  5. There are no size variants, and that's deliberate — see below.
Accessibility
  • Space toggles, for free, from the native input.
  • 16px is under the 44×44 AAA recommendation but clears the 24×24 AA floor when the label is part of the hit area — so wrap it in a <label>.
  • State is carried by fill and mark, not colour alone.

#### Both glyphs are rounded and centred

Rounded. A CSS border cannot take stroke-linecap, and a data-URI SVG would hardcode a colour and stop following the theme — so the tick is two borders carrying a 1px border-radius, which rounds the elbow where they meet and both open ends. The minus is drawn as a filled bar rather than a border-top, because a border has square caps and cannot be rounded at all.

Centred. Both sit at left: 50%; top: 50% with a translate(-50%, -50%). Note those percentages resolve against the padding box, not the border box — 7px inside a 16px box with a 1px border — which is still the true centre, because the padding box is inset equally on every side. The tick previously carried an optical nudge to 45%; it is exact geometry now.

Notes

The 4px radius comes from the --radius-xs token, not from the component, so the rest of the mark tier follows it: the search-highlight <mark> in the dropdown and menu, and .ck-tip code. There's no 4px literal anywhere.

Radio is unaffected — it's --radius-full, being a circle.

Radio

kitradio.md

Same 16px box as Checkbox so the two line up in a shared form. Selection is a --primary ring plus a --primary dot.

Live
unselected
selected
invalid
disabled
selected + disabled
Match strategy
Rules — references/components/radio.md

Radio

.ck-radio

What It Is

One choice from a set of two or more mutually exclusive options, all visible at once.

Basic Information

Anatomy
<div class="ck-radio-set">
  <span class="ck-radio-legend">Match strategy</span>
  <div class="ck-radio-row">
    <label class="ck-radio"><input type="radio" name="strategy" checked><span>Exact</span></label>
    <label class="ck-radio"><input type="radio" name="strategy"><span>Fuzzy</span></label>
  </div>
</div>

The dot is drawn on the input's ::after.

ClassRole
.ck-radio-setvertical group, 10px gap; consecutive sets get 20px between them
.ck-radio-legendgroup label, 500 / 13px in --foreground
.ck-radio-rowhorizontal option row, 28px gap, wraps
.ck-radioone option — the label wrapper
Geometry
PropertyValue
size16px, matching .ck-check and shadcn size-4
border1.5px
radius--radius-full
dotinset: 3px — so 10px across, and it scales with the control
label gap8px
label type400 / 13px

The dot is positioned by inset rather than a fixed width, so changing the control size needs no second value — the same reasoning as the Switch's derived travel.

Sizes

None. 16px, fixed, matching Checkbox — a radio is read against its label rather than against neighbouring controls.

Density in a radio set comes from the wrappers, not the control. .ck-radio-set sets the vertical gap (10px), .ck-radio-row the horizontal one (28px). Tighten those for a dense form; leave the 16px control alone.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--input#8b9292#5f7073#999999resting border · border-input
--background#ffffff#142226#000000resting fill · (light default)
--primary#013c4b#e7f9fe#66d9efchecked border · border-primary
--primary#013c4b#e7f9fe#66d9efdot · fill-primary
--primary#013c4b#e7f9fe#66d9efhover border · —
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)active fill · —
--primary-hover#26515f#c3d2d6#55b7cachecked hover dot · (shadcn has none)
--primary-active#3e6370#a6b3b6#479babchecked active dot · (shadcn has none)
--destructive#e50600#fe9b98#ff4444invalid border · aria-invalid:border-destructive
--destructive#e50600#fe9b98#ff4444invalid dot · —
--focus-ring-error0 0 0 3px #f9c8c70 0 0 3px #473d3f0 0 0 3px #380f0finvalid focus ring · ring-destructive/20
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363cfocus ring · ring-ring/50
--muted#f5f5f5#1b292d#1a1a1adisabled fill · (shadcn uses opacity-50)
--muted-foreground#738f96#bcd7dd#e0e0e0disabled dot · —
--muted-foreground#738f96#bcd7dd#e0e0e0disabled label · —

Same divergence from shadcn as Checkbox: tokens rather than opacity-50, so the dot keeps its contrast on a dimmer control.

A checked radio keeps a --primary border and fills only the inner dot — it never fills the whole circle. That's the platform convention, and it's what distinguishes a radio from a checkbox at a glance.

States
StateUncheckedChecked
default--input border--primary border, --primary dot
hover--primary border--primary-hover dot
active--hover-bg fill--primary-active dot
focus--focus-ring--focus-ring
invalid--destructive border--destructive border and dot
disabled--muted fill, muted label--input border, --muted-foreground dot, muted label

Hover has to move the dot once checked — the border is already --primary when checked, so moving only the border changes nothing.

Disabled uses :has(input:disabled) on the wrapper so the label mutes together with the control, plus .is-disabled as a fallback.

Contrast
CheckLightDarkHigh Contrast
resting border vs page3.173.157.37
checked border vs page12.0115.0612.74
dot on resting fill12.0115.0612.74
invalid border vs page4.828.056.16
disabled dot on fill3.169.9213.18
label vs page15.8716.3221.00

A radio is a bare ring at rest, so the 3:1 non-text floor binds on its border — the third component depending on the corrected --input.

Special Rules

Rules That Matter
  1. The class goes on a wrapping <label>, not on the input. That's what makes the text part of the hit area and lets the whole row go muted when disabled — and it's what carries the 16px control past the 24×24 target floor.
  2. One name per logical group. Sharing a name across what should be separate groups makes them one group, so only one can ever be checked. Easy to do accidentally when generating markup.
  3. A single radio is never right — use a Checkbox. Past about seven options, use a Searchable Dropdown.
  4. The group needs a real name. .ck-radio-legend is visual only — use a <fieldset> + <legend>, or role="radiogroup" with aria-labelledby pointing at the legend.
  5. Preselect a sensible default. Don't leave a required group with nothing checked.
Accessibility
  • Arrow keys move within the group and check as they go; Tab enters and leaves the group as a single stop. All native.
  • aria-invalid="true" belongs on the inputs — the red ring alone conveys nothing to a screen reader.
  • State is carried by the dot's presence, not by colour alone.
Notes

There's a trap when building a test harness for this: give every radio cell its own name. Reusing names across theme blocks silently merges them into one group, so only one cell renders checked.

Switch

kitswitcher.md

shadcn name Switch, Clipper legacy name Switcher. It commits a binary choice the moment it is flipped — there is no Save step. The canonical class is .switch and state is expressed the shadcn way with data-state="checked" / "unchecked", which is what the React kit's switch.tsx keys on, so the two halves of the kit now agree. The legacy .ck-switch class, the .on flag and aria-checked are all still honoured, so shipped flows keep rendering. Geometry is --switch-w, --switch-h, --switch-thumb and thumb travel is derived from them, so a size variant is a token change and never a new rule.

Live
Variants × states × modesAll three modes rendered together — a mode you have to toggle to is a mode nobody checks
light:root
default
hover
focus
pressed
disabled
off
on
dark[data-theme="dark"]
default
hover
focus
pressed
disabled
off
on
high contrast[data-theme="high-contrast"]
default
hover
focus
pressed
disabled
off
on
Size variantsshadcn ships one size; sm and lg are Clipper extensions on the kit scale
sm · 32 × 20thumb 12px
default · 40 × 24thumb 16px
lg · 48 × 28thumb 20px
In context
Auto-reconcileMatch invoices as soon as both documents land.
Notify on ExceptionEmail the folder owner when a match fails.
With a label — every stateSame mechanism as the checkbox. The switch is a <button role="switch"> rather than an input, and a button is a labelable element, so label.control still resolves to it and the click still forwards. .is-reversed gives the settings-row reading: the name on the left, the control lined up on the right.
off
off — hover
on
on — hover
focus
on — focus
disabled — off
disabled — on
with a hint
label first (settings row)
small
large
Rules — references/components/switcher.md

Switcher

.switch · .ck-switch

What It Is

A toggle for a setting that takes effect immediately. For a value that only applies on submit, use a Checkbox.

shadcn calls this component Switch; the Clipper legacy name is Switcher.

Basic Information

Anatomy
<button class="switch" role="switch" aria-checked="false" data-state="unchecked"></button>

State is expressed the shadcn way, with data-state="checked" / "unchecked". aria-checked is also required — data-state drives the paint, aria-checked drives the announcement, and a <button> with neither announces no state at all.

.ck-switch and .on are honoured for markup already shipped; new markup uses .switch and data-state.

Geometry

Four local custom properties — --switch-w, --switch-h, --switch-thumb, --switch-inset — and the thumb's travel is derived from them (w − thumb − 2·inset). So a size variant is a token change and never a new rule, and the travel can't fall out of sync with the geometry.

ClassTrackThumbInsetTravelReach for it when
.size-sm32 × 2012px4px12pxone switch per row in a dense list — a settings table, a permissions grid, a column-visibility menu
(none)40 × 2416px4px16pxdefault — settings panels, forms, .ck-switch-block rows with a title and description
.size-lg48 × 2820px4px20pxtouch-first surfaces, or a single headline toggle that's the point of the screen. Rare

The inset is 4px at every size, so a small switch looks smaller, not tighter. Every number here is a multiple of 4.

shadcn ships one size; sm and lg are Clipper extensions.

Radius is --radius-full on both track and thumb.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--toggle-track#a9b6b7#4a5c5f#666666the OFF track
--toggle-track-hover#95a0a1#637375#7b7b7bOFF track, hover and pressed
--primary#013c4b#e7f9fe#66d9efthe ON track
--primary-hover#26515f#c3d2d6#55b7caON track, hover
--primary-active#3e6370#a6b3b6#479babON track, pressed
--background#ffffff#142226#000000the knob, in every state
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363cfocus
--radius-full999px——track and knob
PartOFFON
track--toggle-track--primary
track, hover--toggle-track-hover--primary-hover
track, pressed--toggle-track-hover--primary-active
knob--background--background
disabledthe same colours at --disabled-opacitythe same colours at --disabled-opacity
The OFF track has its own token

--toggle-track is #a9b6b7 in light — the v2.0 kit's --switcher-off, brought back because it is the switch the product's users already recognise.

It is not --input, and that matters in both directions. --input is the border of every field and the secondary button; it is #8b9292, darker, and it has to stay there to hold those strokes at 3:1. And --track is no use either — that is the rail behind a fill on a progress bar, at #d7dddf, paler still.

So the switch gets its own value, in all three themes. This is not a component-named token in the sense the kit forbids — --switcher-on was retired because it was literally var(--primary), an alias carrying no information. --toggle-track carries a value nothing else has.

#### The knob is white, in every state

It is --background throughout — OFF, ON, hover, pressed, focus and disabled. Nothing recolours it.

#### Disabled is a faded switch, not a different one

The whole control drops to --disabled-opacity (0.4). The track keeps its real colour and the knob keeps its white, so a disabled switch is visibly the same switch, just out of play. This renders pixel-identical to the v2.0 kit: #dce1e2 track OFF, #98b0b6 track ON, white knob, in light.

This is the kit's one opacity-based disabled state, and it is deliberate. The rule against opacity for disabled exists because opacity dims a control's label along with its box, so the words read as a rendering fault. A switch has no label and no glyph — it is a track and a circle. There is nothing for opacity to damage, and fading is precisely the message.

Re-colouring was tried instead: a --muted track with a --muted-foreground knob. It measured better at the time but it changed what the control was rather than dimming it, so disabled-OFF and disabled-ON stopped reading as one component in two states.

The knob follows the theme's --background, and that is deliberate. In dark theme --primary is near-white (#e7f9fe), so a knob forced to white would measure 1.08:1 on a checked switch — invisible. The knob inverts with the theme precisely so it always reads against its track.

Contrast

An unchecked switch has no label inside it, so the track is the entire affordance and the 3:1 non-text floor applies to it.

CheckLightDarkHigh Contrast
OFF track vs page2.092.323.66
ON track vs page12.0115.0612.74
knob vs OFF track2.092.323.66
knob vs ON track12.0115.0612.74

The OFF track is under 3:1 in light and dark, and that is a stated choice rather than an oversight. --toggle-track is the switch the product already ships and the one its users recognise; matching it was the requirement. The earlier --input track measured 3.17 / 3.15 / 7.37 and cleared the floor, so the trade is recorded here: recognisability against 1.08 points of contrast on a control whose state is also carried by knob position, which is not a colour signal at all.

If the floor has to be met later, the fix is a darker --toggle-track — not a different mechanism.

Disabled tracks sit lower again, and that part is uncontroversial: WCAG exempts inactive components.

This component is why --input was corrected, back when the switch read from it. It measured 1.52:1 in light and 1.36:1 in dark — under the floor in two of three themes. That correction stands and still matters: --input is the border of .ck-input, .ck-textarea, .ck-dd-trigger, the secondary button and every outline-style control, all of which gained a perceivable boundary they did not have. The switch has since moved to its own --toggle-track, but the fix it prompted is load-bearing everywhere else.

Special Rules

Rules That Matter
  1. role="switch" and aria-checked are required, not optional. A bare <button> announces as "button" with no state, so a screen reader user can't tell on from off.
  2. data-state drives the paint; aria-checked drives the announcement. Set both. The visual keys off the attribute precisely so it can't drift from what's announced.
  3. A switch is not on the 28/36/44 control scale, deliberately. It's a fixed-ratio track, so stretching it to a 36px control height would make it a slab. It aligns within a row — centre it against the row's text — rather than setting the row's height.
  4. The label goes beside the switch, never inside the track. Wrap it in .ck-switch-row or .ck-switch-block, or give the switch an aria-label. A switch with no accessible name is a defect however it looks.
  5. Thumb position carries the state in every theme, so it never depends on colour alone.
  6. The knob sits inside the track with room to spare — a 4px inset at every size, so 16px of knob in a 24px track. At 20px the knob filled the track and read as a disc jammed into a slot rather than a switch.
  7. Disabled is drained, not blanked. OFF fills the knob with --muted-foreground on the --muted track; ON keeps the white knob because its track is the much darker --input (3.17:1). A white knob on the pale OFF track measures 1.09:1 and reads as an empty outline, which is why this one state does not use the white knob.
  8. The universal checks apply here like anywhere else. Four of them bite on this component in particular: check 5 (disabled is drained but legible — and this is the one control allowed to use --disabled-opacity to do it), check 8 (a switch on a line with a field or a button shares that row's height, and every height on the scale is even), check 26 (the knob is optically centred, and a row of switches lines up with itself), and check 28 (a switch never sets its own row's width — it is flex-shrink: 0 and the label beside it takes the slack).
Accessibility
  • Native <button type="button">, so Space and Enter both toggle for free.
  • The hit area is 32×20 at the smallest — under the 44×44 AAA recommendation though it clears the 24×24 AA floor. In dense rows, give the row itself a larger click target.
Don't
  • Don't use a styled <div>.
  • Don't toggle .on and forget the attribute.
  • Don't invent a fourth size.
data-force — documentation only

data-force~="hover|active|focus|disabled" paints a state without the user being in it, so a spec page can render the full 2 values × 5 states × 3 modes matrix from the kit's own rules rather than re-declaring them in page CSS, which goes stale.

Never in product markup. A forced state is a lie about what the control is doing.

All three themes render side by side on the showcase rather than behind the theme toggle — a mode you have to toggle to is a mode nobody checks. [data-theme] is a plain selector, not a media query, so a subtree can carry its own mode.

Slider

v3.2slider.md

A real <input type="range"> so arrow keys, Home/End and PageUp/Down work (§ 14.9 check 11). The fill is a gradient at --ck-slider-pct, kept in step by ckSlider().

Live

Positions

10
50
90
40
70
Rules — references/components/slider.md

Slider

.ck-slider

What It Is

A single-value range input.

Basic Information

Classes
ClassRole
.ck-sliderthe input
.ck-slider-rowthe input with a value readout beside it
.ck-slider-valmonospace tabular readout
Sizes

None.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--track#d7dddf#2b4045#2b2b2bthe rail — the unfilled remainder, shared with Progress
--primary#013c4b#e7f9fe#66d9effill
--background#ffffff#142226#000000thumb
--primary#013c4b#e7f9fe#66d9efthumb
--shadow-toggle———thumb lift
States
StateTreatment
restas above
hover--primary-hover
active--primary-active
focus-visible--focus-ring on the thumb
invalid--destructive + --focus-ring-error
disabled--input filled portion + --track remainder; thumb goes --muted

The disabled state keeps the fill. Flattening the whole rail to one grey threw the position away, so a disabled slider stopped saying what it was set to.

Special Rules

The rail must not camouflage into its container

The rail reads --track, the same token as a Progress bar's unfilled remainder. It used to read --muted, which measures 1.00:1 against a --muted panel and 1.04:1 against --canvas — on either surface the rail simply was not there, and the control read as a floating thumb.

Check the rail against the panel it actually sits on, not against white.

Rules That Matter
  1. Use the native <input type="range">. Not a <div> with a drag handler. The native control announces as a slider and gives you arrow keys, Home/End and PageUp/PageDown for free.
  2. The affordance is the thumb and the filled portion, both --primary. The unfilled remainder is a container, so it's --muted.
  3. Focus goes on the thumb, not the rail.
  4. A slider accepts input; a Progress bar reports.
Accessibility
  • aria-label or a visible <label>. Point aria-describedby at the .ck-slider-val so the readout is announced.
  • The thumb and the control are 20px — the slider's own geometry, recorded here as § Law 2 requires. It used to read --icon-box; when that grew to 24px the thumb would have stayed 20 and the rail centring would have drifted 2px, so the slider now states its height itself. The whole control height is the target.
Controller

ckSlider() in clipper-kit.js?v=3ce65335 keeps --ck-slider-pct in step with the value and mirrors it into .ck-slider-val. It's idempotent, so it's safe to call again after inserting markup.

WebKit has no ::-webkit-slider-progress, which is why the fill is a gradient rather than a pseudo-element. If the fill looks missing, the page probably isn't loading clipper-kit.js?v=3ce65335.

Notes

The 20px thumb is deliberately larger than --icon-size — it reads as a grabbable control rather than a glyph.

The rail's colour is a declared deviation from the rulebook's "slider rail" wording, stated here so the next reader can weigh it rather than discover it.

Progress

v3.2progress.md

The affordance is the FILL, which carries the contrast; the track is only its container. The track has its own token as of v3.2 — --progress — because --muted measured 1.04:1 against --canvas, so a bar on the app ground was invisible. Warning inherits the documented 2.05:1 token defect.

Live

Fill Levels

0%
25%
50%
75%
100%

Semantic Tones at 60%

success
warning
destructiveindeterminate
sizes sm / default / lg
Extracting fields62%

Count Variant — .is-count

measured in files, not percent. The fill is derived from the two counts
Uploading files0 of 12 files
Uploading files3 of 12 files
Uploading files12 of 12 files
with a failed segment on the same track
Uploading files9 of 12 files · 1 failed
sizes carry over: sm / default / lg at 5 of 8
Rules — references/components/progress.md

Progress

.ck-progress

What It Is

A determinate bar for work with a known length, plus .is-indeterminate for work without one.

Basic Information

Classes
ClassRole
.ck-progressthe track
.ck-progress-barthe fill; width from --ck-prog-val
.is-success / .is-warning / .is-errorfill tone
.is-indeterminatea 30% fill sliding the track
.ck-progress-rowthe bar with a meta line above it
.ck-progress-metalabel left, value right
.ck-progress-valtabular numerals
The Count Variant — .is-count

For work measured in things, not percent: "3 of 12 files uploaded".

<div class="ck-progress-row">
  <div class="ck-progress-meta">
    <span>Uploading files</span>
    <span class="ck-progress-count"><strong>3</strong> of 12 files</span>
  </div>
  <div class="ck-progress is-count" role="progressbar"
       aria-valuemin="0" aria-valuemax="12" aria-valuenow="3"
       aria-valuetext="3 of 12 files uploaded"
       style="--ck-prog-done:3;--ck-prog-total:12">
    <div class="ck-progress-bar"></div>
  </div>
</div>
PropertyMeaning
--ck-prog-donehow many are finished
--ck-prog-totalhow many there are
--ck-prog-failedhow many failed, if any

The fill width is derived from the counts, not passed in separately. The component computes done / total, so the bar and the readout cannot disagree — the same reasoning as the switch's derived thumb travel. There is no percentage anywhere in the markup.

A failed segment abuts the completed one on the same track, so done, failed and not-yet-attempted read as one bar rather than three indicators:

<div class="ck-progress is-count" role="progressbar"
     aria-valuemin="0" aria-valuemax="12" aria-valuenow="9"
     aria-valuetext="9 of 12 files uploaded, 1 failed"
     style="--ck-prog-done:9;--ck-prog-total:12;--ck-prog-failed:1">
  <div class="ck-progress-bar"></div>
  <div class="ck-progress-fail"></div>
</div>

Where both segments are present their inner edges square off, so the pair reads as one continuous bar; the track's own clip rounds the outer ends.

ClassRole
.ck-progress.is-countthe count-driven track
.ck-progress-failthe failed segment
.ck-progress-countthe readout. <strong> carries the done number; .is-failed tints a failure count
Sizes

.size-sm 4px · default 8px · .size-lg 12px — track thickness.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--track#d7dddf#2b4045#2b2b2bthe unfilled remainder of the track — shared with Slider
--primary#013c4b#e7f9fe#66d9efthe completed fill
--success#2e9e52#42c070#44ff88completed fill, .is-success
--warning#f2a618#f7b83d#ffbb33completed fill, .is-warning
--destructive#e50600#fe9b98#ff4444completed fill .is-error, and the failed segment of a count bar
--foreground#05262e#ffffff#ffffffthe done number in a count readout
--muted-foreground#738f96#bcd7dd#e0e0e0the total and the noun in a count readout

The track has its own token as of v3.2. It used to read --muted, which measures 1.04:1 against --canvas and 1.00:1 against a --muted panel — a bar laid on either was invisible. --track is 1.21 against the canvas, 1.37 against a card and 1.26 against a muted panel, and the --primary fill still dominates it at 8.75:1.

--track is named for the role, not the component. It serves the progress bar and the Slider rail, so a --progress token would have been the component-named redundancy that retired --switcher-on.

It is a bare non-text token, alongside --border, --input and --ring: a track carries no text, so it has no -foreground partner. The filled portion stays on the semantic tones rather than being duplicated into a --track-foreground.

States

No interaction states — it isn't a control.

Under prefers-reduced-motion the indeterminate animation is slowed to 3.6s and the width transition removed, not dropped.

Special Rules

A determinate bar must actually move

If the work is running, the value has to change. A bar parked at one value reads as a hung process, and it is worse than no bar at all.

  • Length known → update --ck-prog-val, or the two counts, as work completes. The component transitions the width for you, so the flow only has to set the number.
  • Length unknown → use .is-indeterminate. Do not fake motion by animating a determinate bar to a value you have not reached.
The track must not camouflage into its container

The track reads --track for exactly this reason. Check it against the panel it sits on — --muted measured 1.00:1 on a muted panel and 1.04:1 on --canvas, which is invisible.

A count bar must carry aria-valuetext

Without it, assistive tech computes a percentage from aria-valuemin / aria-valuemax and announces "25%" — the wrong unit for the thing being measured, and the reason this variant exists at all. Write what a person would say: aria-valuetext="3 of 12 files uploaded".

Keep aria-valuenow / min / max as the counts themselves (3, 0, 12), not a percentage.

Rules That Matter
  1. A progress bar reports; a Slider accepts input. And it isn't a skeleton either — a shimmer stands in for content that's coming, a progress bar says how far along a task is.
  2. The track is light grey (--muted); the fill is --primary. The fill is what conveys the value, and it's what has to be legible. A heavier track competes with the fill, which is the opposite of what a progress bar should read as.
  3. Watch where you put it. A --muted track reads correctly on --card or --background, but laid directly on --canvas it's nearly invisible — #eef1f3 and #f5f5f5 are only 1.04:1 apart. That's a placement mistake, not a token one.
  4. The bar is not a label. Pair it with .ck-progress-meta or an aria-label.
Accessibility
  • role="progressbar" with aria-valuenow / aria-valuemin / aria-valuemax. Omit aria-valuenow when indeterminate.
  • A task finishing is a visual-only change unless it's announced.
Notes

Track heights are 4 / 8 / 12px, and the indeterminate fill is 30% wide.

A .is-warning fill inherits --warning's 2.05:1, which is under the 3:1 floor for a non-text affordance.

Progress Ring

v3.2progress.md

The linear progress bar bent into a circle, for where a bar has no room. Same fill tokens, same --track, same tones, same indeterminate state and value transition; four sizes on the icon and control scale.

Live

Sizes

size-sm · 16 / 2px
default · 24 / 3px
size-lg · 40 / 4px
62%size-xl · 64 / 6px, value inside

Fill Levels

0%
25%
50%
75%
100%

Tones — the bar’s six

default · --primary
.is-running
.is-info
.is-success
.is-warning
.is-error

Indeterminate

size-sm
default
size-lg
the bar’s 30% fill, going round — 1.4s a turn, 3.6s under reduced motion; no aria-valuenow

With Its Value — .ck-progress-meta

Invoice_220700734.pdf62%
Spec
Tokens
TokenLightDarkHigh ContrastWhat It Is For
--track#d7dddf#2b4045#2b2b2bthe unfilled arc
--primary#013c4b#e7f9fe#66d9efthe fill — running
--progress-running#229fbf#56c2dc#2fb8d6the fill — .is-running, work under way
--info#387ff9#92c4fe#44aaffthe fill — .is-info
--success#2e9e52#42c070#44ff88the fill — .is-success
--warning#f2a618#f7b83d#ffbb33the fill — .is-warning
--destructive#e50600#fe9b98#ff4444the fill — .is-error
--foreground#05262e#ffffff#ffffffthe value inside an xl ring
New Tokens — Geometry
SizeClassSize tokenStroke tokenValueStroke in viewBox units
Small.size-sm--progress-ring-size-sm--progress-ring-stroke-sm16 / 2px4.5
Default.ck-progress-ring--progress-ring-size--progress-ring-stroke24 / 3px4.5
Large.size-lg--progress-ring-size-lg--progress-ring-stroke-lg40 / 4px3.6
Extra large.size-xl--progress-ring-size-xl--progress-ring-stroke-xl64 / 6px3.375
Rules
  1. It is the progress bar, bent round. Same fill tokens, same --track, same five tones, same indeterminate state, same value transition. Use it where a bar has no room: a file row, a table cell, a toast, a card corner.
  2. The value is --ck-ring-val, 0–100 — the same number as aria-valuenow. pathLength="100" makes the dash the percentage.
  3. Round caps, like the bar’s fill. That is why it is SVG and not a conic gradient. At 0 nothing is drawn: a round cap would otherwise leave a dot.
  4. A ring is not a label. Put the value beside it in .ck-progress-meta; only .size-xl carries it inside.
  5. Put it on --card or --background, as the bar: --track is only its container.
Accessibility
  • role="progressbar" with aria-valuenow / -valuemin / -valuemax and an aria-label; omit aria-valuenow when indeterminate.
  • The SVG is aria-hidden; the host carries the role.
  • Under reduced motion the spin slows to 3.6s a turn and the fill stops animating.

Badge / Chip / Pill

kitpill.md

One component, three names — .ck-pill, .ck-badge and .ck-chip are aliases. Always --radius-full; there is no rounded-rectangle badge. Text is always --*-soft-foreground, never the solid token.

Live

The One-Colour Formula

Every badge is one colour at three strengths: 100% for the label and the glyph, 20% for the stroke, 8% for the fill. That is what keeps differently-toned badges reading as one family instead of five unrelated chips. The three swatches under each badge are its own tokens, so they cannot drift from what the badge is actually painted with. Neutral is a tone like any other and is built the same way — it used to borrow --muted and --border, which are the same hex in this theme, so it drew its stroke in its own fill and shipped with no border at all.
Matched
--success-*
Partially Matched
--warning-*
Failed
--destructive-*
In Review
--info-*
Unreconciled
--neutral-*
Light is the theme the formula is stated in. Dark and high contrast keep the same ORDER — label strongest, stroke in the middle, fill lightest — but pick their own strengths, because an 8% tint over a dark ground is not a surface and a 20% stroke on it disappears.

Two Glyphs, Fixed Box — .is-duo

Reconciledsuccess
Partial Matchwarning
Failederror
Locked by Finance Teamneutral

hugs its label up to 200px, with both glyph lanes fixed. It is a grid, not a flex row, and that is the point: under flex the label is sized by its own content, so a long one squeezes the glyphs and a short one lets them drift inward, and the badge stops reading as one object down a column. Three tracks fix both lanes at --ck-pill-ico and give the label exactly what is left — 150 − 16 padding − 8 gaps − 32 glyphs. Past that it ellipsises and the glyphs never move. The glyphs are the markup’s: any Lucide pair, both inheriting the tone through currentColor. A trailing button is a different thing — that is a dismiss, and it is .ck-icon-btn.size-xs in a plain pill.

Icon-Only Badge — .ck-pill.is-icon

success
error
warning
neutral

Square at --radius-md, not a lozenge — a single glyph in a round box reads as an avatar or a button, and this is neither. It keeps the pill’s frame, tone map and sizes and drops only the text. It is not a .ck-status: that is a dot beside a label, and removing the label leaves nothing but the dot. A glyph alone is not an accessible name, so aria-label is required.

Draftneutral
Reconciledsuccess
Needs Reviewwarning
Failederror

Solid brand counter — .ck-counter.is-primary

The one counter variant that is not a soft tint. An unread count pinned to a bell has to read as “there is something here” from across the header, which --muted at 1.04:1 against a white page does not do. Every other variant stays soft — a chip in a table column is being read, not noticed.

2020312
.is-primary · default · .is-error · .is-success
Processinginfo
Smallsm
Mediumdefault
Largelg
Livewith dot
Invoicewith icon
Exceptions7with counter
Bao Shengdismissible
Disableddisabled

Fulfilment Status — .ck-fulfil

a quantity, and under it what is still true about that quantity: a delta counter plus a one-word state. The label is plain tone-coloured text, not a pill. Wrapping the word in a filled pill and putting the count inside it made two different facts look like one object, and a column of them read as a wall of tinted rectangles rather than numbers you can scan. The number is the data, the counter is the delta, the word says which.
491Remainingremaining
522Excessexcess
30Fulfilledfulfilled — no counter
—10Remainingquantity not known
Fulfilled carries no counter — its delta is zero by definition, and a chip reading “0” is noise the reader has to interpret. The absence of the chip is the signal, and the word still says it in full. An unknown quantity is an em dash in --muted-foreground, never an empty cell: blank reads as “nothing here”, the dash reads as “not known yet”.tones map to the semantic families: remaining is destructive (work outstanding), excess is --violet — over-delivery is neither good nor an error and must not be confused with either — and fulfilled is success.

A line, as the product shows it

two quantities with the delta chooser between them. The dashed stroke says “editable, not yet committed”, which a solid one does not.
491Remaining522Excess
30Fulfilled237Remaining
522Excess3515Remaining
—10Remaining—9Remaining

Standalone counter — built like a badge

three tokens, one per job: a -bg fill, a -soft-foreground numeral and a -border stroke. It was a transparent circle with a currentColor ring, which made one colour do all three and left the surface inherited from whatever sat behind it.
12neutral
9is-success
7is-warning
3is-error
2is-info
3sm (16)
3default (20)
3lg (24)
Members12on-primary — the one transparent form
Reconciledstatus
Partialstatus is-warning

Counts — abbreviated past four figures

7
999 — the last unabbreviated value
1,200 → one decimal, it changes the answer
12,000 → no decimal, it would not
2,000,000
2,500,000
The suffix is capital — a lowercase k is the SI prefix for kilo, and this is a count, not a unit. The full number stays as the accessible name, so a screen reader reads “twelve thousand”, not “twelve kay”.
Rules — references/components/pill.md

Badge / Chip / Pill

.ck-pill · .ck-badge · .ck-chip

What It Is

One component, three names. .ck-badge and .ck-chip are aliases of .ck-pill — use whichever reads better in context. There is no behavioural or visual difference and there must not be one.

Basic Information

Sizes and Height

Height is fixed per size so a row of pills never staggers. The container sizes to the pill, not the reverse.

ClassHeightPaddingTypeIconCounterDot
.size-sm20px0 8px11px10px9px5px
(none)24px0 10px12px12px10px6px
.size-lg28px0 12px13px14px11px7px

Icon size scales with the pill, because a 12px glyph in a 20px pill is cramped. Use one size per row of pills.

Width — hugs its text, capped at 200px

One cap, 200px (--badge-max-width), the same at every size and in every context. The pill is exactly as wide as its label up to that point; past it the label truncates head…tail (abc…apso) and the pill stops growing.

It is a cap, never a fixed width — not in a table cell either, where it used to be a fixed 150px. A badge sized to its text says exactly what it holds; a fixed footprint left a column of half-empty capsules.

Counter — hollow, and only hollow
<span class="ck-pill is-success">
  <span class="ck-pill-label">Approved</span>
  <span class="ck-pill-count">999</span>
</span>

Transparent fill, with 1px border and text both currentColor — which resolves to the tone's --*-soft-foreground. --radius-full so it matches the pill it sits in, and font-variant-numeric: tabular-nums so a column of counters aligns.

The pages shipped three forms — solid, hollow and neutral-grey. Only the hollow form survives.

A negative margin-inline-end pulls the counter into the pill's padding, so adding one doesn't make the pill taller or push its right edge out.

Icons — either side, or both
<span class="ck-pill is-warning"><svg/><span class="ck-pill-label">Left</span></span>
<span class="ck-pill is-info"><span class="ck-pill-label">Right</span><svg/></span>
<span class="ck-pill is-error"><svg/><span class="ck-pill-label">Both</span><svg/></span>

Icons take currentColor, so they follow the tone with no extra rule. .ck-pill-dot is the small status dot.

Standalone Counter and Status

Same protocol, so a counter in a nav item and a counter inside a badge are the same shape, height and treatment.

<span class="ck-counter is-error">3</span>
<span class="ck-status is-success"><span class="ck-status-dot"></span>Matched</span>
ComponentSizesNotes
.ck-counter16 / 20 / 24pxbuilt like a badge — a -bg fill, a -soft-foreground numeral and a -border stroke, --radius-full, tabular numerals. .on-primary is the one transparent form, for sitting on a filled surface
.ck-status11 / 12 / 13pxdot plus label. The dot is 6 / 8 / 10px in the tone's -soft-foreground
Fulfilment Status — a pill variant, not a component
StatePill
fulfilled.ck-pill.is-success
remaining.ck-pill.is-info
excess.ck-pill.is-warning
not fulfilled.ck-pill.is-error
<span class="ck-pill is-success">
  <span class="ck-pill-label">Fulfilled</span>
  <span class="ck-pill-count">42</span>
</span>

A count beside a label in a toned pill is the pill's own anatomy — .ck-pill-label plus .ck-pill-count, both of which already ship. A second set of classes for it (.ck-fulfil*) was duplication, so it's retired and there's no CSS for it.

The label is never dropped in favour of the count — status lives in the text and the tone is redundancy, not the message.

Tones map to the semantic families, not to --dfp-*. Those four are the Document Fields Panel's confidence scale, and sharing them would tie two unrelated components to one value.

Tokens

Tones — 22 families

Every tone is the soft quartet: -bg surface, -border border, -soft-foreground text.

GroupClasses
Semantic.is-success / .ok, .is-warning / .warn, .is-error / .err, .is-info / .info, .is-neutral / .off
Palette.teal .cyan .indigo .pink .rose .amber .lime .emerald .violet .fuchsia .sky .slate .gold .crimson .sage .purple .orange .blue

Why -soft-foreground exists: 12 of 66 family/theme pairs failed 4.5:1 using the solid token as text — --warning at 1.83, --success at 3.10, --info at 3.26, dark --violet at 3.68, light --orange at 3.82, and more. A soft foreground was minted for all 22 families across all three themes, and every pair now clears 4.5:1.

States
StateTreatment
defaulttone surface, tone border, -soft-foreground text
hover (.is-interactive)--hover-bg
active (.is-interactive)--selected-bg
focus (.is-interactive)--focus-ring
selected (aria-selected="true")--selected-bg + --selected-fg, --primary border
disabled (.is-disabled)--muted surface, --muted-foreground text, --input border
The Dismiss Cross — the exception to the tone system
<span class="ck-chip is-neutral">
  <span class="ck-pill-label">Vendor: Orchard</span>
  <button class="ck-icon-btn size-xs" aria-label="Remove Orchard">…</button>
</span>
StateIconFill
restinherit — the pill's own --*-soft-foregroundtransparent
hover--destructive-soft-foreground--destructive-bg
active--destructive-foreground--destructive (solid)
focus—--focus-ring-error
disabled pill--muted-foregroundtransparent

It rests in the pill's foreground, not in --destructive. A cross that's red at rest reads as a standing warning on every chip in a filter bar. The destructive tokens appear only on hover, when the action is actually imminent.

This is why .tone-danger is wrong here — that tone is destructive at rest. The pill's dismiss inverts it: neutral at rest, destructive on approach. It's the one place in the kit where a destructive control doesn't announce itself by colour until hover, so it needs its own rules rather than a tone.

Confirmed 2026-09-09. Put to the design lead after the reconciliation flow shipped the cross red at rest for a week: the rule stands unchanged, and the flow was brought back into line. The counter-argument was that a red cross reads as "this removes something"; the answer is that it only reads that way when there's one of them, and a filter bar is the one place where there are twelve. "Clear all" is where the standing red belongs — and .ck-btn.ghost.destructive carries the other half of the same ruling: its label is destructive at rest, and both controls share one hover fill, --destructive-bg.

Active uses the solid pair, not --destructive-border. In high contrast --destructive-border and --destructive-soft-foreground are the same colour, so the icon vanished on its own fill at 1.0:1.

Visual size vs hit area. The dismiss button matches the counter's size so it fits the pill and is round like it — 12 / 16 / 20px by pill size. The glyph inside it is that size less 4px, so there are exactly 2px of clear space on every side: 8-in-12, 12-in-16, 16-in-20.

That derivation is the one place the kit's flat "16px glyph everywhere" does not apply, and it is deliberate. A 16px cross in a 16px button touches the button's bounds, reads a size too large for a 24px chip, and breaks § 14.3 check 11. The 16px rule is about an icon laid out in its own box; a glyph living inside a control takes that control's padding, and a 16px-tall control has none to give.

The hit area is unaffected at 24×24, carried by a ::before that overflows the paint box — WCAG 2.5.8 sizes the target, not the paint. Measured: button 16px, glyph 12px, target 24×24.

Special Rules

Three Specificity Traps

All three are live, so don't "simplify" these selectors:

  1. .ck-icon-btn.size-xs is declared later at equal specificity, so it won and the cross computed to 24px inside a 22px pill — taller than the pill, with a 6px radius inside a round one. The pill's rules use a doubled class (0-3-0) to win.
  2. Lifting to 0-3-0 then meant color: inherit beat .ck-icon-btn:hover (0-2-0), so hover rendered identically to rest and the destructive colour never appeared. Hover and active are now declared at pill specificity too.
  3. --ck-count-h was defined on .ck-pill but not on the .ck-badge / .ck-chip aliases, so the cross and every counter inside a badge or chip collapsed to 0px. There's now one variable block per size covering all three names — an alias must never be able to miss a variable.
Rules That Matter
  1. Every pill is --radius-full. There is no rounded-rectangle variant and there must not be one. A 6px radius on a pill is a defect, not a variant.
  2. The text carries the status, never the colour. A dot or icon adds redundancy; neither replaces the label.
  3. Use -soft-foreground for the text, never the solid tone token. The solid token as pill text is the 1.83:1 case.
  4. A pill is a label, not a control. Add .is-interactive when it becomes a filter chip or clickable status, and it then owes the full six states. Non-interactive pills stay out of the tab order.
  5. A counter is constructed like a badge, and that is the only form.
  6. The dismiss cross is neutral at rest, which makes it the one exception to the tone system. See below.
Truncation
<span class="ck-pill is-warning">
  <span class="ck-pill-label ck-mid-trunc">Missing Document Attachment</span>
</span>

.ck-mid-trunc keeps the head and the tail, because the tail of a status label usually carries the distinguishing word. Use it for anything where the tail matters, which is most statuses.

Full ValueRenderedOn Hover
Missing Document AttachmentMissing Doc…tachmentfull string
Over-billed Quantity Variance DetectedOver-bill…Detectedfull string

How much head survives depends on the string and the size class — the algorithm keeps as much as fits rather than cutting to a fixed character count.

Without .ck-mid-trunc, .ck-pill-label falls back to end-ellipsis.

Accessibility
  • A truncated label keeps its full value in title=. For anything essential, add aria-label with the full string — title isn't reliably announced.
  • .ck-counter needs context: "3 errors", not a bare "3". Use aria-label.
  • Interactive pills need aria-selected or aria-pressed — the fill is visual.
  • Dismiss buttons need an aria-label naming what's removed.
Notes

Five separate implementations existed before the merge — four page-local (.mock-badge at 96 usages, .badge-counter at 51 with three counter forms, .filter-chip and .td-counter both at border-radius: 6px) and the kit's own, used once.

.is-disabled measures 3.16:1 — the known --muted-foreground on --muted defect, inherited from the token set rather than local to this component.

Kbd

v3.2kbd.md

--secondary / --secondary-foreground, because a keycap carries 11px text and muted-foreground on muted is 3.16:1 (§ 9 defect 1).

Live
Ksingle
Cmd+Kcombo
Cmd+Shift+Ptriple
Enterenter
Ssm
Rules — references/components/kbd.md

Kbd

.ck-kbd

What It Is

A keycap shown in running text — a shortcut next to a menu item, a hint in an empty state.

Basic Information

Classes
ClassRole
.ck-kbdone key — use a <kbd> element
.ck-kbd-comboa combination, with a translatable joiner
Sizes

.size-sm 16px · default 20px · .size-lg 24px.

The default matches --icon-box, so a keycap and an icon line up in the same row.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--secondary#f5f5f5#1b292d#1a1a1asurface
--secondary-foreground#292f32#ffffff#ffffffsurface
--input#8b9292#5f7073#999999stroke
--radius-smcalc(var(--radius) - 4px)—var(--radius-sm)radius
States

None — a keycap isn't interactive. If the shortcut itself is actionable, put the keycap inside a .ck-menu-item.

Special Rules

Rules That Matter
  1. A keycap is an object, not a code span. Inline code is .ck-tip code on --radius-xs; a keycap gets --radius-sm, because 2px on a 20px cap just looks like a rendering glitch.
  2. Use --secondary / --secondary-foreground, not --muted. A keycap carries 11px text, and the muted pair only reaches 3.16:1 — under AA at that size. The secondary pair clears it.
  3. The + in a combination is real text, so it can be translated and read aloud.
Accessibility
  • <kbd> carries the meaning. Don't use it for anything that isn't a key.
Don't
Notes

The 2px bottom border is the key's bevel, and the 20px cap is tied to --icon-box.

Divider

v3.2divider.md

Four forms: solid, dashed, labelled and vertical. One colour for all of them — --panel-border, the § 5 visible structural stroke. It was --border, which is 1.09:1 against the page: § 5 scopes that to hairlines inside a surface, not to a rule separating regions, and a divider you cannot see is not doing its job. .is-strong existed only to reach this value, so it is retired along with .is-accent and .is-thick — one divider, one colour, one weight.

Live
solid
dashed
labelled
or
left
middle
right
vertical
Rules — references/components/divider.md

Divider

.ck-divider

What It Is

A rule between regions. Four forms: solid, dashed, labelled, and vertical.

Basic Information

The Four Forms
FormClass
solid.ck-divider
dashed.ck-divider.is-dashed
labelled.ck-divider-label
vertical.ck-divider.is-vertical

The labelled form is the "or" in an Auth Card — a centred label with a rule either side.

Sizes

None. There is one weight.

Tokens

States

None. A divider isn't interactive.

Special Rules

Rules That Matter
  1. One colour, one weight, for every form: --panel-border. A rule that varies by emphasis invites a second decision at every call site for no gain.
  2. A divider is its own element, not a border on the thing above it. That's what lets it be dashed, vertical, or labelled without touching its neighbours.
  3. Use <hr>. It's already role="separator". A styled <div> announces nothing.
  4. Don't use --border here. It's scoped to hairlines inside a surface — table rows, list separators, menu dividers — and at 1.09:1 against the page it's invisible. A divider you can't see isn't doing its job.
Accessibility
  • aria-orientation="vertical" on .is-vertical.
  • A decorative divider inside an already-labelled group can take aria-hidden="true".
Notes

.is-vertical is 1px wide and stretches to its row.

.ck-menu-sep is separate and unchanged: it's the menu's own scoped divider, a hairline inside a surface, and correctly reads --border.

There's a contrast ceiling here. --panel-border measures 1.16:1 against the page — more visible than --border, but still low. Making a divider genuinely high-contrast means changing that token's value, which moves every panel stroke in the product with it. That's a token-set decision, not a component one.

Tooltip

kittooltip.md

Text-only help on hover and focus. If it holds a control it is a popover. Two variants — default on --primary, .is-light on --popover — across four placements, so eight tooltips. Placement names follow Radix / shadcn, where the side is where the tooltip sits: .is-top means it is above its anchor, so the tail is on its bottom edge. One size: a tooltip is as wide as its content up to 320px, and anything that needs to be bigger is a popover.

Live
top
bottom
start
end
default
Reconcile against the purchase order
Reconcile against the purchase order
Reconcile against the purchase order
Reconcile against the purchase order
is-light
Reconcile against the purchase order
Reconcile against the purchase order
Reconcile against the purchase order
Reconcile against the purchase order

Multiline — a title line plus body reads better than one long run

Delivery NoteMatched on quantity and SKU across all three lines.3 of 3 linesDelivery NoteMatched on quantity and SKU across all three lines.3 of 3 lines
Rules — references/components/tooltip.md

Tooltip

.ck-tip

What It Is

Text-only help, on hover and focus.

Basic Information

A tooltip never carries a shadow

This is a universal rule, not a preference. A tooltip is a transient label, not a surface. Give it a shadow and two things go wrong:

  • it reads as a second panel floating above the thing it describes, when it should read as an annotation of it;
  • stacked over a bar or a card that already casts a shadow, the two collide and the result looks like a rendering fault.

Its own fill and border carry it: the default variant is a solid --primary bubble, and .is-light has a --panel-border edge. Neither needs help.

Attaching One

<span class="ck-tip-host">
  <button class="ck-icon-btn" aria-label="Download Document">…</button>
  <span class="ck-tip is-top" role="tooltip">Download document</span>
</span>

The host becomes the bubble's containing block, so .ck-tip switches from fixed to absolute and the placement classes set the offset. The tail geometry is unchanged.

Placement offsets are 8px, which clears the tail's 4px protrusion.

The tooltip text and the aria-label say the same thing, and both name the action — "Download document", never "Download icon". Two different strings is a defect: a screen reader user and a sighted user end up with different labels for one control.

Two Variants

<span class="ck-tip open" role="tooltip" id="t1">Rules apply to every document</span>
<span class="ck-tip is-light open" role="tooltip" id="t2">Over dark content</span>
VariantSurfaceTextUse For
default--primary--primary-foregroundthe product default
.is-light--popover--popover-foregroundover dark or busy content, where a primary-filled bubble would compete — a dark toolbar, an image, a preview pane

Neither carries a shadow — see the rule below. Both are --radius-2xl — a tooltip is a floating panel and belongs on the same radius as the rest of them.

Four Placements

Two variants × four placements = eight tooltips.

Placement names follow Radix / shadcn, where the side is where the tooltip sits — so .is-top means the tooltip is above its anchor, and its tail is on the bottom edge pointing down.

ClassTail on
.is-topbottom edge
.is-bottomtop edge
.is-startinline-end edge
.is-endinline-start edge

The tail is an 8px square rotated 45°, half protruding, inheriting the bubble's fill and stroke — so a .is-light tail carries --panel-border, and a default one is invisible against its own --primary edge.

Two things about the tail are easy to get wrong.

Rotating a square moves its edges. After rotate(45deg) the top border draws the upper-right facet, right draws lower-right, bottom draws lower-left, and left draws upper-left. Each placement keeps the two outward facets and zeroes the two facing the bubble — otherwise the stroke draws a line across the bubble it's attached to.

The tail needs the bubble as its containing block. .ck-tip is position: fixed, which satisfies that. Force position: static and the tail is handed to some ancestor and flies off across the page — use position: relative when showing one inline in a demo.

There is no size scale

One padding (8px 10px), no shadow, max-width: 320px. A tooltip is as wide as its content up to that cap, and as tall as it needs.

Multiline

Supported and expected: white-space: normal, overflow-wrap: anywhere, and line-height: 1.5.

<span class="ck-tip open" role="tooltip">
  <span class="ck-tip-title">Linking rules</span>
  They decide which documents get matched together, in order.
</span>

.ck-tip-title gives a bold lead line. A title plus a body reads better than one long run — it's what makes a three-line tooltip scannable.

.ck-tip code renders an example value in --tip-code-bg, and .ck-tip .ck-tip-eg a dimmed secondary line.

Special Rules

Rules That Matter
  1. It must appear on focus, not only hover. Hover-only is keyboard-inaccessible, and it's the most common tooltip defect there is.
  2. Attach it with .ck-tip-host. Wrap the control, put the .ck-tip inside as a sibling of it, and the kit shows the bubble on hover and on :focus-within — no controller, no per-flow wiring. This is what makes rule 1 achievable rather than aspirational.
  3. It must be dismissible with Escape, and must not disappear when the user hovers over it. Neither is enforceable in CSS, so those two still need the flow's JS.
  4. Never put the only copy of essential information in a tooltip.
  5. title is not a tooltip. It doesn't appear on keyboard focus, can't be styled, has an uncontrollable delay, and is announced unreliably. Use it as a redundant fallback, never as the mechanism.
  6. Anything that needs to be bigger is a . A "large tooltip" is a category error — past a few lines the user needs to select the text, click something in it, or keep it open, and all three are popover behaviours.

If it holds a control it's a ; if it's a list of actions it's a Menu.

Truncation

The truncated element and its tooltip are the same component, so they can't diverge in style. That works in both directions:

  • A truncated element's tooltip carries its full value. Anything with .ck-mid-trunc — a pill label, a filename, a table cell — shows its full string in a .ck-tip. ckMiddleTruncate puts it in title for free, but title isn't enough on its own, so wire a real tooltip.
  • A tooltip's own text truncates the same way. If the tooltip content is itself too long, .ck-mid-trunc applies inside it — and .ck-tip.is-single-line forces one line with an end ellipsis where that reads better.
Accessibility
  • role="tooltip", and the trigger needs aria-describedby pointing at it.
  • Every interactive icon gets a real tooltip.

Spinner & Skeleton

kitfeedback.md

The spinner's track is --input, not --border: at 1.09:1 the track was invisible, so it read as a lone rotating arc rather than a ring. Skeleton primitives exist so a loading state can be built to the shape of the thing it replaces rather than as a grey rectangle.

Live
sm
default
lg
text
title
button
input
pill
Rules — references/components/feedback.md

Feedback and Utilities

What It Is

Spinner · Skeleton · Middle truncation

Three small pieces that share one theme: they communicate state or shape rather than content, so their contrast floor is 3:1, not 4.5:1.

The tooltip used to live here. It has its own sheet now: Tooltip.

Basic Information

Spinner — .ck-spinner
<span class="ck-spinner size-sm" role="status" aria-label="Loading"></span>

An indeterminate ring: a full border in --skeleton-sheen with the top edge in --primary, rotating.

It is display: inline-block. Without that it is inline, width and height are ignored, and a standalone spinner renders about 4px wide — it only looked right inside a flex row.

Sizes

Its own scale — a spinner sits inside other things.

ClassBoxBorderReach for it when
.size-sm16px2pxinside a control — a search box, a button, a dropdown's search row
(none)24px2pxdefault — inline beside a label, in a toolbar. It reads --icon-box, so a spinner swapped in for an icon never resizes the row
.size-lg28px3pxa panel or region loading as a whole

.sm / .lg survive as aliases; new markup uses .size-*.

Never centre a spinner with transform

ck-spin animates transform, so a transform: translateY(-50%) used for centring is replaced by the rotation the moment the animation starts. With a single-keyframe animation the browser then interpolates from the translate to the rotation, so the spinner slides downward through every revolution and snaps back — a drift that looks like a rendering fault.

Centre it with inset-block: 0; margin-block: auto instead, which leaves transform free. This bit the search box, the dropdown's search row and the menu's search row, all three of which position the spinner absolutely.

Radius

--radius-full.


Skeleton — .ck-skeleton
<div class="ck-skeleton-row">
  <div class="ck-skeleton ck-skeleton-circle" style="width:32px;height:32px"></div>
  <div class="ck-skeleton ck-skeleton-text" style="flex:1"></div>
</div>
ClassRole
.ck-skeletonthe shimmer: --muted → --skeleton-sheen → --muted
.ck-skeleton-text12px tall
.ck-skeleton-title16px tall, capped at 40% width
.ck-skeleton-circle--radius-full, for an avatar
.ck-skeleton-rowflex row with a 10px gap
Radius

--radius-sm, except .ck-skeleton-circle which is --radius-full.


Middle Truncation — .ck-mid-trunc
<span class="ck-mid-trunc">PO-2024-00266 · Orchard Provisions.pdf</span>

A utility, not a component. Paired with ckMiddleTruncate in clipper-kit.js?v=3ce65335, which keeps the head and tail and drops the middle — so PO-2024-00266 · Orchard Provisions.pdf becomes PO-2024-00266 · Orch…s.pdf rather than losing its extension.

Tokens

The loading family shares one tone

Spinner track, skeleton sweep and the reduced-motion flat fill all run on --skeleton-sheen — one step off --muted, per theme: #e8eaeb / #26383d / #2b2b2b.

That token was minted for exactly this. Both used to run through --input, a control-border grey at 3.17:1 — correct for a border, far too heavy for a placeholder, which should read as absent content rather than as a filled bar. It also made the spinner's ring compete with the arc that carries the motion.

The affordance still carries the contrast: the spinner's arc is --primary at 12.01:1, as is the progress fill. The track is context in both cases — deliberately quiet, not invisible.


Special Rules

Rules That Matter
  1. A spinner is not a progress bar. If the duration is known, show progress.
  2. role="status" with an aria-label, or an adjacent visible label. A bare rotating div announces nothing.
  3. Under prefers-reduced-motion the animation stops; the element stays. Hiding it would leave no indication anything is happening.
Rules That Matter
  1. A skeleton means "content is coming"; an empty state means "there is none." The filled shimmer versus the dashed outline is the whole distinction — never use a skeleton for an empty result.
  2. No sizes. A skeleton is sized to the thing it stands in for, inline or by its container.
  3. Under prefers-reduced-motion the gradient flattens to --muted and the animation stops.
Rules That Matter
  1. **Use it where the tail carries meaning** — filenames, identifiers. For prose, ordinary text-overflow: ellipsis is right.
  2. Screen readers read the visible text, so the truncated form is what's announced. title holds the full value for hover, but where the full value matters, add an aria-label with the complete string.

It binary-searches the head length against a canvas measurement of the element's own computed font, so it's exact at any width, and re-runs on resize.

Field controls — input, label, textarea, select, search

v3.2input.md

One family, one stroke. .ck-input, .ck-select, .ck-textarea, the input inside .ck-search and .ck-dd-trigger all carry the same 1px --input border and the same five stroke states, so a column of mixed controls has one edge. Heights are the control scale — 28 / 36 / 44 — set explicitly with inline padding only, because a control sized by padding alone lands on its own font metrics. Trailing order is fixed: text, then the clear cross, then the search glyph or chevron.

Live

Input — all six states

default
hover
focus
invalid
invalid + focus
disabled
read-only — text keeps full contrast
placeholder
.ck-field.is-locked

Combobox — .ck-combo, a field you can type a new value into

A field you type into that filters a list, and which can accept a value that is not in the list at all. That last part is why it is not a dropdown: .ck-dd chooses among known options; this is for a value that might already exist or might be one you are inventing as you type it. The control joins .ck-input's own selector, so the field base is inherited rather than restated.

.ck-dd-opt.is-add — the create row, --primary and medium

Dropdown with a left icon — .ck-dd.has-icon

.ck-dd.has-icon.is-compact — a flag, a currency mark, an avatar

A glyph that identifies the value before the value is read. This replaced a whole .ck-phone molecule: a country picker is a dropdown with a leading glyph, and so is a currency picker and an account picker — one variant, not one component each. .is-compact keeps it content-width beside the field it belongs to.

Sizes — 28 / 36 / 44, and they must match across a row

sm (28)
default (36)
lg (44)
one size per row — right
mixed sizes — the ragged row the scale prevents

Label, hint, error, required, optional

Matches on exact reference
Pick a vendor before saving
Disabled by a rule upstream
the asterisk is a ::after — punctuation, so it never reaches the accessibility tree as a word. “(optional)” is a real span, so it can be translated. The field must also carry required, or the asterisk is decoration only.

Trailing icons — position and combination

search glyph only
glyph + clear cross (.has-value)
spinner replaces the glyph (.is-loading)
select — a filled caret, drawn with gradients
dropdown trigger — a stroked chevron
input + inline clear — hover it

The clear reveals on hover — never resident

hover or Tab into each field. A row of filled fields should not be a row of crosses, so the clear is hidden until the field is hovered or holds focus — and it is the same 16px glyph in a 24px target everywhere.
input
search
dropdown
textarea
forced hover, so the reveal is visible here
dropdown, forced hover
Trailing order never varies: text → clear cross → search glyph or chevron. The cross sits inboard of the glyph, both on logical offsets, so the order flips correctly in RTL. A field carries one trailing affordance plus an optional cross — never a chevron and a search glyph together.

Textarea — sizes and the resize handle

sm (60 min)
default (76 min)
lg (96 min)
invalid
disabled
.no-resize — handle suppressed
The handle resizes vertically only. Horizontal is off because a field’s width belongs to its .ck-field-grid column — dragging one field wider would break the grid's alignment and every field beside it. Dragging never goes below the size class’s min-height, so a textarea cannot be collapsed to nothing. Use .no-resize where the row height is fixed, such as an editable table cell.
Rules — references/components/input.md

Input / Field

.ck-input · .ck-select · .ck-textarea · .ck-field

What It Is

Two layers: .ck-field is the labelled wrapper (label, control, hint, error), and .ck-input / .ck-select / .ck-textarea are the bare controls. .ck-field-grid lays fields out in columns.

Basic Information

Anatomy
<div class="ck-field-grid cols-2">
  <div class="ck-field">
    <label class="ck-field-label" for="po">Purchase order
      <svg class="ck-info">…</svg>
    </label>
    <input id="po" class="ck-input" placeholder="PO-2024-00266">
    <span class="ck-field-hint">Matches on exact reference</span>
  </div>
</div>
ClassRole
.ck-field-grid3 columns, 16px gap. .cols-2 / .cols-1 to narrow
.ck-fieldone field — column flex, 6px gap
.ck-field-label500 / 13px in --foreground; holds an optional .ck-info
.ck-field-hint400 / 12px in --muted-foreground
.ck-field-error400 / 12px in --destructive
.ck-input-wrappositioning context for .ck-input-clear
Sizes
ClassHeightPaddingTypeReach for it when
.size-sm28px0 9px12pxinline filter bars, a search box in a panel header, editable table cells, anything repeated per row
(none)36px0 11px13pxdefault — every .ck-field-grid, every dialog form, settings panels
.size-lg44px0 13px13pxauth and onboarding, a single prominent search, touch-first layouts

Textarea has no fixed height, so its sizes move padding and min-height:

ClassMin-heightPadding
.size-sm60px6px 9px
(none)76px8px 11px
.size-lg96px10px 13px

Use .cols-2 / .cols-1 in narrow panels — three columns in a 320px drawer doesn't fit.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--input#8b9292#5f7073#999999border · border-input
--background#ffffff#142226#000000fill · bg-background
--foreground#05262e#ffffff#fffffftext · text-foreground
--muted-foreground#738f96#bcd7dd#e0e0e0placeholder · placeholder:text-muted-foreground
--input-border-hover#4d6b72#9caeb2#82b6c0hover border · —
--primary#013c4b#e7f9fe#66d9effocus border · focus-visible:border-ring
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363cfocus ring · ring-ring/50
--destructive#e50600#fe9b98#ff4444invalid border · aria-invalid:border-destructive
--focus-ring-error0 0 0 3px #f9c8c70 0 0 3px #473d3f0 0 0 3px #380f0finvalid focus ring · ring-destructive/20
--muted#f5f5f5#1b292d#1a1a1adisabled fill · disabled:opacity-50
--muted-foreground#738f96#bcd7dd#e0e0e0disabled text · —
--radius-mdcalc(var(--radius) - 2px)—var(--radius-md)radius · rounded-md

Same divergence from shadcn as the other controls: disabled uses tokens rather than opacity-50, so the text keeps its contrast.

States
StateTreatment
default--input border on --background
hover--input-border-hover
focus--primary border + --focus-ring
invalid--destructive border; --focus-ring-error on focus
disabled--muted fill, --muted-foreground text, not-allowed
read-only--muted fill, text stays --foreground
locked (.ck-field.is-locked)same as disabled, applied from the field wrapper

Special Rules

Trailing icon order never varies

text → clear cross → search glyph or chevron.

FieldTrailing AffordanceWith a Value
.ck-inputnonean optional .ck-input-clear cross
.ck-searchthe search glyph, 15px at inset-inline-end: 10pxthe cross at 30px, inboard of the glyph
.ck-search.is-loadinga .ck-spinner.size-sm in the glyph's placecross unchanged
.ck-selecta filled caret, drawn with two gradients—
.ck-dd-triggera stroked chevron—

Four rules hold this together:

  1. One trailing affordance, plus an optional cross. Never a chevron and a search glyph on the same field — two trailing glyphs read as two different controls.
  2. The cross sits inboard of the glyph, never outboard. The glyph says what the field is; the cross acts on its content, so it belongs closer to the text.
  3. Both offsets are logical (inset-inline-end), so the order flips correctly in RTL without a second rule.
  4. The spinner replaces the glyph in place. It must not appear on the opposite side — a loading indicator that jumps across the field lands on top of what the user just typed.

The end padding that reserves room is fixed: 36px for a glyph, 56px once a cross is showing. Change a glyph size and the padding has to move with it.

Required and Optional
MarkupRendersWhy
<label class="ck-field-label" data-required>a --destructive asterisk after the textit is punctuation, not content, so it is a ::after and never reaches the accessibility tree as a word
<span class="ck-field-optional">(optional)</span>muted 12px inside the labelit is a word, so it must be a real element and translatable. A ::after with content:'(optional)' would hardcode English in CSS
  • The asterisk is decoration only. The field must also carry required (or aria-required="true"), or the requirement exists for sighted users alone.
  • Never both on one label. A field is one or the other.
  • Prefer marking the optional ones when most fields in a form are required — fewer marks, same information.
The Textarea Resize Handle

Vertical only. resize: vertical is deliberate:

  • Horizontal is off because a field's width belongs to its .ck-field-grid column. A horizontal handle would let one field break the grid's alignment and every field beside it.
  • Dragging cannot go below the size class's min-height (60 / 76 / 96px), so a textarea can never be collapsed to nothing.
  • Dragging up is unbounded, which is correct — the user is asking to see more of their own text, and no maximum we pick would be right for every container.
  • .no-resize suppresses the handle where the row height is fixed, such as an editable table cell.

The handle is the browser's own, so it inherits the platform's affordance rather than a drawn one. Don't reimplement it.

Rules That Matter
  1. Every control needs a programmatic label — for / id, or aria-label. A placeholder is not a label; it disappears on the first keystroke.
  2. Height is explicit; padding is inline only. Sized by padding alone, .ck-input computed to 35px and .ck-select to 37px against a 34px button, because each landed on its own font metrics. An explicit height makes a row align across fonts and zoom levels.
  3. Size every control in a row together. .ck-dd takes the same .size-* classes precisely so a dropdown and a text field can share a grid.
  4. aria-invalid="true" and .ck-field-error go together. A red border with no message says nothing; a message with no aria-invalid isn't announced. Point aria-describedby at the error so the reason is read, not just the state.
  5. readonly and disabled are different. Read-only text stays full-contrast because it's still meaningful content; disabled text goes muted because the field is out of play. Don't disable a field the user may still need to read.
  6. Never put required information in a placeholder.
  7. Long text: read-at-a-glance truncates, typed text scrolls. A label and a dropdown value end in an ellipsis, because they are scanned. An input and a search box scroll sideways and a text area scrolls down, because hiding what the user just typed behind an ellipsis is worse than a scroll.
  8. Width follows the parent. width: 100% with max-width: 100% and min-width: 0, so the field takes the column it is given and can shrink inside a flex or grid track instead of forcing that track wider. Never a fixed pixel width.
  9. Everything on one line shares one height, and it is an even number. A field, a button and an icon button beside each other all come off the 28 / 36 / 44 scale. Even heights mean the row's centre line lands on a whole pixel, so glyphs and text do not sit a half-pixel apart.
  10. The clear cross appears on hover, never at rest. A form of filled fields should not be a row of crosses. It is revealed by the wrapper — has-value plus :hover or :focus-within — because a control at opacity: 0 cannot be hovered into view by itself, and a keyboard user has to be able to reach it. Same standard 16px glyph in a 24px target on all four variants.
One Base, Four Variants

.ck-input, .ck-select, .ck-textarea and .ck-dd-trigger are one rule:

.ck-input,.ck-select,.ck-textarea,.ck-dd-trigger{ … }

Height, padding, type, colours, stroke, radius and transition are declared once, there. Each variant then overrides only what it genuinely differs in:

VariantWhat it adds
.ck-inputnothing — it is the base
.ck-selectappearance: none and the caret
.ck-textareaheight: auto, a min-height, block padding, resize
.ck-dd-triggerdisplay: flex, align-items, gap, cursor, text-align — five properties, because it lays a value and a chevron out as a row and is clickable

Hover and aria-invalid are shared on the same selector list, so a state added to the family reaches all four.

.ck-search is not a variant — it is a wrapper. It requires a .ck-input inside it and adds exactly one declaration, padding-inline-end. That is deliberate: being a wrapper is what lets it inherit all three sizes and every input state for free.

Why this matters. .ck-dd-trigger used to re-declare 9 of the base's 10 properties. That is the same duplication that once bit .ck-search, which had its own copy of the input's rules — so when aria-invalid, :disabled, readonly and ::placeholder were added to .ck-input, the search box silently got none of them, and nothing in either rule showed the problem. The duplication is now zero.

Stroke — one contract, four controls

1px, --input, all four sides, at every state, with no thickness or colour deviation and no uneven sides — now by construction rather than by hand.

Focus keys on :focus here, not :focus-visible, and that's deliberate: clicking into a text field places a caret and should show focus. .ck-dd-trigger uses :focus-visible because clicking it opens a panel and picks up the same ring via .ck-dd.open — so the two look identical in practice.

When measuring, force both :focus and :focus-visible. Forcing only the latter reports a false mismatch.

The Select Alignment Defect

.ck-select shipped with appearance: auto. Chrome then paints the option text inside its own inner box on top of the kit's padding, so a select stacked under an input in the same grid had its text visibly further right — with identical CSS and identical padding boxes. Invisible in a rule diff and obvious on screen.

appearance: none removes the UA's inner box. The caret is redrawn with two linear-gradient layers rather than a data-URI SVG, so its colour stays var(--muted-foreground) and follows the theme.

**One ordering trap: the caret must be declared after the shared .ck-input,.ck-select,.ck-textarea rule.** That rule uses the background shorthand, which resets background-image — declare the caret before it and it never paints.

The caret is a filled triangle where .ck-dd-trigger uses a stroked chevron. A <select> is a replaced element and can't take a pseudo-element, so the two differ. Use .ck-dd where that matters.

Accessibility
  • .ck-field-hint should also be wired with aria-describedby.
  • .ck-info in a label needs aria-label or adjacent text — a bare help glyph announces nothing.
  • Placeholder contrast is 3.44:1 in light — under the 4.5:1 body-text floor, though it clears 3:1. --muted-foreground was lightened to #738f96 deliberately, and the placeholder is the most exposed thing in the kit, so never put required information in one (rule 6) matters more than ever.
  • Use .ck-field-hint for standing guidance. A hint that only appears on error should be .ck-field-error.
Notes

The input border is why --input was corrected — an input's border is its affordance, so the 3:1 non-text floor binds on it.

Disabled text at 3.16:1 is the known --muted-foreground on --muted defect, inherited from the token set. Don't patch it here.

AI Field — .is-ai

a field whose content came from, or is going to, the model — the AI feedback box. It reads --purple-soft-foreground on its stroke so it is legible at a glance as “this one is the AI’s”, without changing its shape, height or type. It is the same control, marked — not a different control.
default
hover
focus
invalid
disabled
textarea — the family shares the variant
dropdown trigger
AI suggestion.ck-field-ai — the label that names it
Purple, not a semantic tone, on purpose. Success, warning and destructive all say something about the value; this says where the value came from, which is an orthogonal fact — borrowing a semantic tone would make an AI field look like a passing or failing one. The mark is a stroke and a ring, never a fill: a tinted field reads as read-only. Invalid still wins — a field that is wrong has to say wrong first. Disabled and read-only drop the mark entirely. 6.44:1 light, 8.41 dark, 10.82 high contrast.

Search Box

kitsearch.md

A wrapper around a .ck-input, not a parallel control — it adds only the trailing glyph, the clear button and the loading swap. It has no sizes of its own: size the input inside it.

Live
Rules — references/components/search.md

Search Box

.ck-search

What It Is

An Field controls — input, label, textarea, select, search with a trailing search glyph, an optional clear button, and a loading state.

Basic Information

Anatomy
<div class="ck-search has-value">
  <svg viewBox="0 0 24 24" …>…</svg>            <!-- trailing glyph -->
  <button class="ck-search-clear" aria-label="Clear Search">…</button>
  <input class="ck-input" value="Orchard" aria-label="Search Folders">
</div>
ClassRole
.ck-searchpositioning wrapper. Adds nothing to the input but padding
.ck-search > svgtrailing glyph, pointer-events: none
.ck-search > .ck-spinnerreplaces the glyph while .is-loading
.ck-search-clearclear button, revealed by .has-value
.is-loadingswaps glyph for spinner
.has-valuereveals the clear button and widens the input's end padding

.is-loading and .has-value are set by the flow's JS. The kit doesn't own the input's value.

Sizes

It has none, deliberately — being a wrapper means it inherits all three steps of the control scale (28 / 36 / 44) plus every input state, for free.

<div class="ck-search"><svg…><input class="ck-input size-sm" …></div>
UseWhy
.ck-input.size-smthe common case — a panel header, toolbar, or filter bar, all dense contexts
.ck-inputa search box that's a form field like any other
.ck-input.size-lga page's primary search, or a touch-first surface

The glyph does not scale with the input. It stays 15px at every size, because the end padding reserving room for it is fixed (36px, or 56px with a clear button). If a size ever needs a different glyph size, the padding has to move with it.

Geometry
PropertyValue
glyph15px, inset-inline-end: 10px
clear button24px, inset-inline-end: 30px
input end padding36px; 56px with .has-value

Trailing order is text → clear cross → search glyph, cross inboard of the glyph — so in edit mode the cross appears immediately to the left of the search icon, never outboard of it and never on the opposite side. When .is-loading replaces the glyph with a spinner, the cross does not move. Both offsets use inset-inline-end, not right, so the order flips correctly in RTL without a second rule.

The clear button is 24px — the WCAG target-size floor.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--background#ffffff#142226#000000field fill
--foreground#05262e#ffffff#fffffftyped text
--input#8b9292#5f7073#999999field stroke
--input-border-hover#4d6b72#9caeb2#82b6c0stroke, hover
--muted-foreground#738f96#bcd7dd#e0e0e0the magnifier, the placeholder, the clear glyph
--primary#013c4b#e7f9fe#66d9efstroke and ring on focus
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363cfocus
--destructive#e50600#fe9b98#ff4444stroke when invalid
--muted#f5f5f5#1b292d#1a1a1afill when disabled or read-only
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)the clear button's hover pad
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)the clear button's pressed pad
--radius-mdcalc(var(--radius) - 2px)—var(--radius-md)the field
--radius-smcalc(var(--radius) - 4px)—var(--radius-sm)the clear button's pad
--icon-size16px16px16pxevery glyph on the field

Special Rules

Rules That Matter
  1. It's a wrapper, not a variant. .ck-input, .ck-select, .ck-textarea and .ck-dd-trigger are four variants of one base rule; the search box is a container around one of them, adding a single declaration. That distinction is the whole point of this component — being a wrapper is what makes it inherit every size and state for free.
  2. The input inside must carry .ck-input. A bare <input> will be unstyled. .ck-search > .ck-input sets exactly one thing: padding-inline-end.
  3. Size the input, not the wrapper. .ck-search.size-sm does nothing. Put the size class on the .ck-input.
  4. The input needs a real accessible name. A placeholder disappears on the first keystroke, so it is not a label.
  5. Clearing returns focus to the input — otherwise focus is stranded on a button that has just hidden itself.
  6. Long text scrolls sideways — a search box is a field the user types in, so what they typed is never hidden behind an ellipsis.
  7. The clear cross appears on hover, never at rest, and sits on the left, next to the magnifier — see Field controls — input, label, textarea, select, search for the shared contract.
Accessibility
  • The clear button needs aria-label="Clear Search" — it's glyph-only.
  • For async results, announce the result count in a live region. The spinner is visual only.
  • Everything else — invalid, disabled, contrast, focus — is Field controls — input, label, textarea, select, search's, which is the point.
Don't
  • Don't show the clear button when there's no value.
  • Don't leave .is-loading on a spinner that never resolves.
Notes

The spinner is centred with inset-block: 0; margin-block: auto, never with transform. ck-spin animates transform, so a translateY(-50%) would be replaced by the rotation — the spinner slid down through every revolution and snapped back. See Spinner & Skeleton § Never centre a spinner with transform.

Why the wrapper matters. .ck-search input used to re-declare the input from scratch, sharing 7 of its 8 declarations with .ck-input. The cost wasn't the bytes — it was that every input improvement had to be made twice, and the ones added in the Input pass (aria-invalid, :disabled, readonly, ::placeholder) landed on .ck-input and not on .ck-search input. So a search box couldn't show an invalid or disabled state at all, and nobody would have noticed from reading either rule alone.

Breadcrumb

v3.2breadcrumb.md

Standalone as of v3.2 — it had lived only inside the header bar, on unprefixed classes. .ck-breadcrumb* are canonical now and the unprefixed names stay as aliases. The current crumb steps up to 18px semibold against the trail's 16px, keyed to aria-current="page" so the drawing cannot drift from what is announced. A crumb is interactive, so it now carries a focus ring — it had none.

Live
default — the current crumb is a span, not a linka root icon as the first crumbslash separator — the separator reads --breadcrumb-size, so it scales with the text.size-sm — 13/14px, for a trail inside a dialog, drawer or panel headercollapsed middle — keeps the root and the current crumb, hides the rest behind a real buttonstates — rest, hover, pressed, focus-visible, current
Rules — references/components/breadcrumb.md

Breadcrumb

.ck-breadcrumbs · .ck-breadcrumb-item · .ck-breadcrumb-sep

What It Is

The trail showing where the current page sits in a hierarchy, and a way back up it. Standalone as of v3.2 — it had lived only inside Header Bar, on unprefixed classes.

.ck-breadcrumb* are the canonical names. The unprefixed .breadcrumbs / .breadcrumb-item / .breadcrumb-sep stay as aliases because pages already use them, the same arrangement as .switch / .ck-switch.

Basic Information

Anatomy
<nav class="ck-breadcrumbs" aria-label="Breadcrumb">
  <a class="ck-breadcrumb-item" href="#">Purchase Orders</a>
  <span class="ck-breadcrumb-sep" aria-hidden="true">/</span>
  <a class="ck-breadcrumb-item" href="#">Pending</a>
  <span class="ck-breadcrumb-sep" aria-hidden="true">/</span>
  <span class="ck-breadcrumb-item" aria-current="page">Folder Settings</span>
</nav>
ClassRole
.ck-breadcrumbsthe trail — a <nav> with an aria-label
.ck-breadcrumb-itemone crumb. A link, except the current one
.ck-breadcrumb-septhe separator; aria-hidden
.ck-breadcrumb-morethe collapsed middle — a real <button>
Sizes
ClassTrailCurrent CrumbReach for it when
(none)16px medium18px semibolddefault — the page-level trail in a header bar
.size-sm13px14pxa trail inside a dialog, drawer or panel header, where 16/18px would out-shout the panel's own title

Density is a token decision: override --breadcrumb-size (16px) and --breadcrumb-gap (4px) on the trail.

Collapsing a Deep Trail

A trail deeper than about four levels stops being scannable. Collapse the middle behind one .ck-breadcrumb-more control that reveals the rest:

<a class="ck-breadcrumb-item" href="#">Purchase Orders</a>
<span class="ck-breadcrumb-sep" aria-hidden="true">/</span>
<button class="ck-breadcrumb-more" aria-label="Show 3 Hidden Levels">…</button>
<span class="ck-breadcrumb-sep" aria-hidden="true">/</span>
<span class="ck-breadcrumb-item" aria-current="page">Folder Settings</span>

Keep the first and the current crumb visible — the root and where you are are the two a user actually needs.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--muted-foreground#738f96#bcd7dd#e0e0e0a resting crumb, and the separator
--foreground#05262e#ffffff#ffffffthe current crumb, and any crumb on hover
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)hover fill on a crumb
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)pressed fill
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363cthe focus ring
--breadcrumb-size16px——trail type size, 16px at medium weight — the separator reads it too
--breadcrumb-gap4px——crumb ↔ separator spacing, 4px
States
StateTreatment
rest--muted-foreground
hover--foreground on --hover-bg
active--selected-bg
focus-visible--focus-ring
current--foreground, 18px semibold, not interactive, no hover fill

Special Rules

The trail is medium; only the current crumb is semibold

Two axes separate them, weight and size — 16px medium for where you came from, 18px semibold for where you are.

The inactive crumbs used to be semibold too, which left 2px of size as the only difference between the two, and at a glance a five-level trail read as five titles rather than one path with a destination.

  1. Mark the current crumb with aria-current="page". That is what the styling keys on, so the drawing cannot drift from what a screen reader announces. .current is a transitional alias — don't use it in new markup.
  2. The current crumb is not a link. Make it a <span>. A link to the page you are already on is a dead control.
  3. The trail must be a <nav> with an aria-label. Without the label it is an unnamed landmark, and a page usually has several.
  4. Separators are decorative — aria-hidden="true". They are punctuation, not content, and a screen reader reading "slash" between every level is noise.
  5. A crumb is interactive, so it owes a focus ring. It had none until v3.2: the browser default outline doesn't follow the theme, and a missing focus ring is a Sev-1.
  6. The separator reads --breadcrumb-size, so changing the trail's size moves the chevrons with the text instead of leaving them behind. A glyph separator sizes at 1em for the same reason.
  7. Don't hand-roll the trail inside a header bar. Header Bar builds it from data-crumbs; a per-page copy is how the header drifts between flows. Use this component directly only where there is no header bar.
Accessibility
  • Crumb hit areas are 4px 6px of padding on 16px text, which is under the 24×24 target floor. It is tolerable for an inline text link in a trail, but don't shrink the padding further.
  • The collapsed control is a real <button> with an aria-label naming how many levels it hides — "Show 3 hidden levels", not "…".

Tabs

kittabs.md

Three variants of one component — underline (the default), pill and segmented. They differ only in CSS: the behaviour, the keyboard pattern and the ARIA are identical, which is the point of them being one component rather than three. All three are live — click them, or focus one and use the arrow keys.

Live

With Counts

A raw 12,000 would widen the tab and shove the rail sideways.

Underline — .ck-tabs

for tabs that are sections of a page. The active indicator is 2px of --primary sitting in the 2px --panel-border rail, pulled up by exactly the rail's thickness — no gap between the underline and the separator.
Showing all 847 items across every status.

Pill — .ck-tabs.is-pill

for peer modes of the same data. A recessed --segment trough with the active tab lifted out of it.

Segmented — .ck-tabs.is-segmented

for a small closed set of choices that form a scale. One bordered block split by 1px --input dividers, the active segment filled solid.

How each one shows selection

underline — the coloured segment of the rail. Pill — three signals at once, because the white plate alone is 1.13:1 against the trough: the plate lifts, the label goes from --segment-foreground to --foreground, and the weight goes semibold. Segmented — selection can rest on the fill, since --primary-foreground on --primary is 12.01:1.

Labels with Icons

leading, trailing or both, at --icon-size and --icon-stroke. A leading glyph classifies the tab; a trailing one points at what selecting it does. A count goes in a badge, never in a glyph.
pill
segmented

Sizes

underline · size-sm
underline · default
pill · default
pill · size-lg
segmented · size-sm
segmented · default

Disabled

a disabled tab is skipped by the arrow keys, not focused and ignored.

Error — a tab whose panel failed to load

The tab reports it, not the panel — the reader is looking at the strip, and needs to know the tab is broken before they click it. The dot survives the tab being unselected, which is the case that matters.
Selected on a segmented strip, the whole segment goes destructive so the dot stays legible on it.
Rules — references/components/tabs.md

Tabs

.ck-tabs · .ck-tab

What It Is

Switches between sibling views of the same object without navigating away.

Basic Information

Anatomy
<div class="ck-tabs" role="tablist">
  <button class="ck-tab" role="tab" aria-selected="true"  aria-controls="p1" id="t1">Groups</button>
  <button class="ck-tab" role="tab" aria-selected="false" aria-controls="p2" id="t2">Linking rules</button>
  <button class="ck-tab" role="tab" aria-selected="false" disabled>Audit log</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1">…</div>
Three Variants of One Component

They differ only in CSS. The behaviour, the keyboard pattern and the ARIA are identical, which is the point of them being one component rather than three.

VariantClassReach for it when
Underline(none)the tabs are sections of a page. The default
Pill.is-pillthe tabs are peer modes of the same data — grid vs list, one dataset four ways
Segmented.is-segmenteda small closed set of mutually exclusive choices that form a scale — Day / Week / Month / Year
Stroke Widths, Stated Exactly
WhereWidthToken
Underline rail2px--panel-border
Underline active indicator2px--primary
Segmented outer edge1px--input
Segmented divider between segments1px--input
Pill trough0 — it is a fill--segment
Pill active plate1px + --shadow-raised--panel-border

Anything thicker reads as a structural rule; anything thinner disappears against the page.

No gap between the underline and the separator

The active indicator is pulled up by exactly the rail's own thickness, so it sits in the rail's band rather than below it:

.ck-tabs{--ck-tab-rail:2px; border-bottom:var(--ck-tab-rail) solid var(--panel-border)}
.ck-tab[aria-selected="true"]::after{bottom:calc(-1 * var(--ck-tab-rail)); height:var(--ck-tab-rail)}

The two read as one continuous line whose active segment is coloured. Offset it by anything else and you get either a hairline of --panel-border showing through beneath the indicator, or a 1px step where the two colours meet. Measured flush: indicator 2px at bottom: -2px against a 2px rail.

How Each Variant Shows Selection

Underline — the 2px --primary segment of the rail, plus --primary on the label.

Pill — three signals, not one. The plate lifts to --background, the label moves from --segment-foreground to --foreground, and the weight goes semibold. The plate alone is 1.13:1 against the trough, so on colour it would be no signal at all.

Segmented — selection can rest on the fill here, because --primary-foreground on --primary is 12.01:1: that is a genuine surface change, not a tint.

In all three, selecting a tab deselects its siblings — a tablist is single-select by definition, and ckTabs() enforces it rather than trusting the markup.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--panel-border#ebeff0#43575a#99a7abthe underline rail, and the pill's active plate
--primary#013c4b#e7f9fe#66d9efthe active indicator, and the segmented active fill
--primary-foreground#ffffff#05262e#000000the segmented active label
--muted-foreground#738f96#bcd7dd#e0e0e0an inactive underline label
--foreground#05262e#ffffff#ffffffan active pill label, and a hovered underline label
--segment#eef1f3#1b292d#1a1a1athe pill trough
--segment-foreground#3f5359#bcd7dd#e0e0e0an inactive pill label
--input#8b9292#5f7073#999999the segmented edge and its dividers, and a disabled label
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)hover on any variant
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)pressed
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363cfocus on the underline variant
--card#ffffff#233a3e#000000the connected panel
--shadow-raisedvar(--shadow-md)var(--shadow-md)var(--shadow-md)the pill's active plate

--segment / --segment-foreground were minted for the pill trough. No existing surface fitted: --muted is 1.04:1 against the page so the trough vanished, and --canvas is the app ground, which § 4 forbids putting a control group on. The label clears 7.14:1 on it in light, 9.92 in dark, 13.18 in high contrast.

Sizes

A tab bar is a structural strip, not a control in a row. Its two heights happen to be the top two steps of the control scale (36 and 44), but it is sized as a strip: there is no 28px step, because a tab bar that dense stops reading as a region boundary.

ClassBar HeightTab PaddingTypeReach for it when
.size-sm36px0 12px12pxtabs inside something — a dialog, drawer, panel or card. Also secondary tabs under a primary set
(none)44px0 16px13pxdefault — the primary tab bar of a page or full-width view

There's no .size-lg. Past 44px a tab bar stops reading as a strip and starts competing with the page header.

Radius

None on the bar; the active indicator is a square 2px rule. A tab bar is a strip, not a panel.

Icons

Lucide, 16×16, stroke-width 2, currentColor. The glyph size doesn't vary with the size class; only the box around it does.

Special Rules

Rules That Matter
  1. Tabs are for views of one object. For moving between different objects, use navigation. If a tab set needs more presence than a strip, it's probably navigation.
  2. Selection is driven by aria-selected, not a class. .active survives as a transitional alias, but new markup uses the attribute so the visual state can't drift from what a screen reader announces.
  3. Size the bar, not the tabs. .size-sm on .ck-tabs cascades down. A size class on an individual .ck-tab gives you a ragged bar.
  4. 2–6 tabs. Nine tabs that wrap to a second line means you want a dropdown or navigation instead.
  5. Every tab needs a panel, wired with aria-controls and aria-labelledby.
  6. Pick the variant from what the tabs mean, not from how they look. Sections take the underline, modes take the pill, a scale takes the segmented block. Three variants exist so a screen can say which of those it is; using them interchangeably throws that away.
  7. Icons are --icon-size at --icon-stroke, either side or both. A leading glyph classifies the tab; a trailing one points at what selecting it does. A count goes in a badge, never in a glyph.
  8. The active indicator never leaves a gap above the separator. Offset it by the rail's own thickness — see above.
  9. A disabled tab is skipped by the arrow keys, not focused and ignored. ckTabs() filters them out of the roving-tabindex list.
Accessibility
  • role="tablist" on the bar, role="tab" on each tab, role="tabpanel" on each panel.
  • Arrow keys move between tabs and Tab exits the set. That's the ARIA tabs pattern and it needs JS — the CSS doesn't provide it, and the kit has no tab controller yet, so each flow wires its own. Worth extracting.
  • Once arrow-key navigation is wired, only the selected tab is in the tab order (tabindex="0", the others -1).
  • Disabled tabs use pointer-events: none. A tab that must stay focusable to explain why it's unavailable should use aria-disabled="true" instead.
Don't
  • Don't build a row of buttons with a class and call it a tablist.
  • Don't put 44px tabs inside a 380px dialog — that's what .size-sm is for.
Notes

The active indicator is an ::after bar at bottom: -2px, so it overlaps the bar's own 2px rail rather than stacking below it. .ck-tab needs position: relative for that, which it has.

The rail inherits --panel-border's low contrast (1.16:1 in light). It's tolerable here because it separates two labelled regions rather than acting as a control's affordance, and the indicator that actually conveys selection is at 12:1.

Pagination

v3.2pagination.md

One form: a table footer — rows selected, total, rows-per-page, “Page X of Y” and first/prev/next/last. Ported from the v2.0 reference and re-expressed in v3.2 terms: its own version used dead token names, opacity for disabled, a scale(.95) press and off-scale 32px buttons. The numbered page list was removed — the product pages sequentially through tables, so a run of numerals was a pattern nothing used. Nav buttons are .ck-icon-btn.is-outlined and the chooser is a .ck-dd — neither is reimplemented.

Live

What The Two Numbers Count

Selected counts the CURRENT PAGE: rows ticked on this page out of the rows it shows. Total rows counts the WHOLE TABLE, every page — it is the flow’s data-total, never the page’s row count. Tick a row: the selected count moves, the total does not.
DocumentVendor
INV-2026-0411.pdfOrchard Provisions
INV-2026-0412.pdfOrchard Provisions
INV-2026-0413.pdfOrchard Provisions
INV-2026-0414.pdfOrchard Provisions
INV-2026-0415.pdfOrchard Provisions
0 of 5 Row(s) SelectedTotal Rows 26
page 1 of 6 · 5 rows on this page · 26 rows in the table

Bar Form — .ck-pag.is-bar

first page: first/prev disabled
0 of 100 Row(s) SelectedTotal Rows 712
Rows Per Page
Page 1 of 24
a middle page: all four enabled, next forced hover
12 of 100 Row(s) SelectedTotal Rows 712
Rows Per Page
Page 9 of 24
last page: next/last disabled
0 of 100 Row(s) SelectedTotal Rows 712
Rows Per Page
Page 24 of 24
.is-compact — nothing selected and no total to show, so the run collapses right
Rows Per Page
Page 3 of 24

The bar sits on the panel’s bottom edge — always

the footer belongs to the panel, not to the last row. One row, two hundred rows, or still loading: it is in the same place, so the panel’s height never jumps and the buttons never move as the user reaches for them.
DocumentAmount
INV-90114.pdf4,120.00
Rows Per Page
Page 1 of 1
one row, tall panel
DocumentAmount
Rows Per Page
Page —
still loading, no rows yet

Squeezed, the bar scrolls — it never re-stacks

drag the footer sideways. Wrapping into two lines would change the panel’s height mid-resize and slide the buttons out from under the cursor.
INV-90114.pdf
INV-90115.pdf
Total Rows 712
Rows Per Page
Page 9 of 24

The Rows-Per-Page Chooser, Open

the kit's own dropdown overlay — 10 / 20 / 30 / 40 / 50 / 100 / 200, no search row
10
20
30
40
50
100
200

Outlined nav button — all six states

rest
hover
active
focus
disabled
28 / 36 / 44
Rules — references/components/pagination.md

Pagination

.ck-pag

What It Is

The page list, previous/next, a gap marker, a row count, and a per-page chooser.

Basic Information

One Form: a Table Footer Bar

Rows selected, total, rows-per-page, "Page X of Y", and first / prev / next / last.

Sequential only. The product pages through tables one step at a time, so the bar offers first, previous, next and last — there is no numbered page list.

<div class="ck-pag is-bar">
  <span class="ck-pag-info">0 of 100 Row(s) Selected</span>
  <span class="ck-pag-total">Total Rows <strong>712</strong></span>
  <div class="ck-pag-group">
    <div class="ck-pag-rpp">
      <span class="ck-pag-rpp-label">Rows per page</span>
      <div class="ck-dd size-sm">…</div>
    </div>
    <span class="ck-pag-page">Page 9 of 24</span>
    <div class="ck-pag-nav">
      <button class="ck-icon-btn is-outlined size-sm" aria-label="First Page">…</button>
      <button class="ck-icon-btn is-outlined size-sm" aria-label="Previous Page">…</button>
      <button class="ck-icon-btn is-outlined size-sm" aria-label="Next Page">…</button>
      <button class="ck-icon-btn is-outlined size-sm" aria-label="Last Page">…</button>
    </div>
  </div>
</div>
ClassRole
.ck-pag.is-barthe footer bar — 48px min-height, top hairline
.ck-pag.is-bar.is-compactnothing selected and no total, so the run collapses right
.ck-pag-info"0 of 100 Row(s) Selected"
.ck-pag-total"Total Rows 712" — the number in <strong>
.ck-pag-groupthe right-hand cluster
.ck-pag-rpp / -rpp-labelrows-per-page, holding a .ck-dd.size-sm
.ck-pag-page"Page X of Y" — fixed width so it can't jitter
.ck-pag-navthe four nav buttons
The Rows-Per-Page Chooser

Write it as a native <select> and let the kit upgrade it:

<select class="ck-select size-sm" data-ck-dd data-searchable="off"
        aria-label="Rows Per Page">
  <option>10</option><option>20</option><option>30</option>
  <option>40</option><option>50</option><option>100</option><option>200</option>
</select>

ClipperDropdown upgrades any <select data-ck-dd> on load into the real overlay dropdown, so a flow never hand-builds the panel and every instance behaves the same. The size class and any inline width carry across the upgrade.

The options are 10 / 20 / 30 / 40 / 50 / 100 / 200. Use this set — a per-screen variation is a defect, because a user who sets 50 on one table expects 50 to exist on the next.

data-searchable="off" is required here. The dropdown turns its search row on at seven or more options, and seven page sizes do not want a search box.

The nav buttons are .ck-icon-btn.is-outlined, a bordered variant of the icon button rather than a pagination-only control. It takes the field stroke set, so it stays in sync with .ck-input and .ck-dd-trigger.

.ck-pag-page has a fixed minimum width so stepping page 9 → 10 doesn't shift the buttons beside it under the user's cursor.

Below 768px the bar wraps: the selected-count drops to its own full-width row and the "Rows per page" label hides, keeping the chooser.

Classes
ClassRole
.ck-pagthe bar — the size class goes here
.ck-pag-infothe range, in tabular numerals
Sizes

None on .ck-pag. The bar's density comes from its own padding, and the controls inside it carry their own size — the nav buttons are .size-sm (28px) and the chooser is a .size-sm dropdown.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--primary#013c4b#e7f9fe#66d9efcurrent page
--primary-foreground#ffffff#05262e#000000current page
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)hover
--input#8b9292#5f7073#999999disabled
--radius-mdcalc(var(--radius) - 2px)—var(--radius-md)radius
States

All six live on the nav buttons — see Icon Button § Outlined. The bar itself is not a control and has none.

Special Rules

The outlined nav button is a declared pairing exemption

At rest it is --muted-foreground on --background — 3.44:1, the same deliberate de-emphasis Empty State carries. A run of four chevrons in a table footer must read quieter than the rows above it; --foreground would make the chrome as loud as the data.

Rules That Matter
  1. Disable first/prev on page 1 and next/last on the last page — don't hide them. Hiding makes the bar reflow under the user's cursor.
  2. Keep the option set at 10 / 20 / 30 / 40 / 50 / 100 / 200. A user who sets 50 on one table expects 50 to exist on the next.
  3. .ck-pag-page has a floor width so stepping page 9 → 10 can't shift the buttons beside it.
  4. Don't reimplement a button here. Prev/next are Icon Button; the per-page chooser is a .ck-dd or .ck-select.size-sm.
  5. The bar belongs to the panel, not to the last row. It sits on the panel's bottom edge whether the table holds two hundred rows or one. Put it in a .ck-table-panel and it stays there; let it follow the rows and it floats up under a short table, which makes the footer look like part of the data.
  6. Show it while the table is still loading. Render the bar with the skeleton rows, with em-dashes where the counts will go. A footer that appears once the data lands makes the panel jump and moves the buttons as the user reaches for them.
  7. When the panel narrows, the bar scrolls sideways — it does not re-stack. Reflowing into two lines changes the panel's height mid-resize and slides controls out from under the cursor.
Accessibility
  • Wrap it in <nav aria-label="Pagination">.
  • aria-current="page" on the current page — also what the styling keys on.
  • Prev/next need an aria-label.
  • The gap marker takes aria-hidden="true" and isn't focusable.
Notes

Button min-width tracks the height, so numerals stay square until a label needs more room.

Toggle

v3.2toggle.md

A button whose pressed state is a mode — not a switch, which commits a setting and announces role="switch". On the § 7a control scale; state is aria-pressed so the drawing cannot drift from the announcement.

Live
default
pressed
disabled

Segment variant — .ck-toggle-group.is-segment

A small set of mutually exclusive options, all visible at once, drawn as a trough with the chosen one raised out of it. Not tabs — a tab changes what the page is showing, a segment changes the value of a setting. Reaching for .ck-tab here fails loudly: its 16px side padding collapses an icon-only option to zero width.

Icon-only

.ck-toggle.size-sm — 28px, on the control scale

Labelled, Full Width

.ck-toggle-group.is-stretch — for a dialog header
pressed disabled
sm
default
lg
group (segmented)
Rules — references/components/toggle.md

Toggle

.ck-toggle

What It Is

A button whose pressed state is a mode — bold, a filter, a view.

Basic Information

Classes
ClassRole
.ck-togglethe button
.ck-toggle-groupa segmented group, role="group"
Sizes

.size-sm 28px · default 36px · .size-lg 44px — the standard control scale, because a toggle shares rows with buttons and icon buttons.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--foreground#05262e#ffffff#ffffffrest
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)hover
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)pressed
--selected-fgvar(--primary)var(--primary)var(--primary)pressed
--primary#013c4b#e7f9fe#66d9efpressed
--muted#f5f5f5#1b292d#1a1a1adisabled
--muted-foreground#738f96#bcd7dd#e0e0e0disabled
--input#8b9292#5f7073#999999disabled

Group radius steps inward: --radius-md outside, --radius-sm on the inner buttons.

States

All six: default, hover, active, focus-visible, disabled, pressed.

Special Rules

Alignment is central, in both axes

A toggle is inline-flex with align-items:center and justify-content:center, so its label or glyph sits dead centre. Never let it go lopsided — an off-centre glyph reads as a rendering fault, and in a segmented .ck-toggle-group one misaligned item makes the whole group look broken.

Two things cause it in practice: padding applied on one side only, and a glyph left unsized so it takes the whole box. Both are defects, not styling choices.

Rules That Matter
  1. A toggle is not a Switch. A switch commits an on/off setting immediately and announces role="switch"; a toggle is a button. They aren't substitutes — swapping one for the other changes what a screen reader says.
  2. aria-pressed is required. It's what carries the state, and it's what the styling keys on, so the drawing can't drift from what's announced. A class alone draws the state without announcing it.
  3. A glyph-only toggle needs a label and a tooltip — aria-label for the action, plus a .ck-tip tooltip.
Accessibility
  • .ck-toggle-group takes role="group" and an aria-label.
Don't
  • Don't use opacity for the disabled state. Use the disabled tokens.
Notes

Heights come from the control scale, and min-width matches the height so a glyph-only toggle is square.

Action Bar

v3.2action-bar.md

Two grounds: --card for a bar over the page, .on-dark for one over a document or image, which is what the --toolbar-dark family exists for. Buttons inside are .ck-icon-btn; .on-dark retunes their hover pair rather than restyling them. Every icon carries a tooltip that appears on hover AND on keyboard focus — wrap it in .ck-tip-host and the kit does the rest, no controller needed. A glyph-only control without one is a defect (check 21).

Live
default
.on-dark

Sample — the Document Editor preview toolbar

groups and order follow the app: image transforms on the left, zoom centred, page navigation on the right. The zoom read-out and the page number are the same component — .ck-action-bar-field, a bordered slot, as a span when it is only read and an input when it can be typed into. The page arrows are .size-xs (24px), a step below the bar's 36px controls, because they are secondary to them. .is-split keeps the centre group centred whatever flanks it; a flex row lets it drift once the two sides differ in width. Hover any icon, or Tab to it.

The tooltip, on all four sides

hover a button, or Tab to it — these are not forced open. They are spaced so each bubble has room to appear without meeting its neighbour.
Document Detailsis-top (default)
Document Detailsis-bottom
Document Detailsis-start
Document Detailsis-end
the label in the tooltip and the aria-label say the same thing, and both name the action — “Download document”, never “Download icon”. title= is not a substitute: it never appears on keyboard focus.

Floating — .is-floating, lifted off the surface

a selection's bulk actions, floating over the list they act on. It takes the deeper --shadow-overlay, not --shadow-panel: a bar that floats has to read as detached, or it looks like part of the surface it covers. The shipped component is position:fixed at the foot of the viewport — staged here so it can be seen inline.
the destructive action is separated by a rule and carries .tone-danger, so it is not adjacent to the action a user reaches for most. The labelled CTA is a plain .ck-btn.primary at the bar's own 36px — it needs no tooltip, because check 21 binds on glyph-only controls and this one carries its label. One labelled control per bar: a second turns the bar into a row of buttons and the icons stop reading as a set. A floating bar must also stay clear of what it acts on — a bar covering its own target is a usability defect, not a styling one.
Rules — references/components/action-bar.md

Action Bar

.ck-action-bar

What It Is

A floating run of icon actions over content — a document preview's tools, or the bulk actions for a selection.

Basic Information

Classes
ClassRole
.ck-action-barthe bar
.on-darkover a document or image
.is-floatingfixed, centred, above the content
.ck-action-bar-labela page counter or selection count
Sizes

.size-sm and default — padding only.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--card#ffffff#233a3e#000000default
--card-foreground#05262e#ffffff#ffffffdefault
--panel-border#ebeff0#43575a#99a7abdefault
--toolbar-dark#08272e——.on-dark
--toolbar-dark-foreground#ffffff——.on-dark
--toolbar-dark-hover#2b454b——.on-dark hover
--toolbar-dark-active#395258——.on-dark hover

Radius is --radius-2xl — a bar is a container, not a pill.

.on-dark retunes .ck-icon-btn's two hover properties rather than restyling the button.

States

None of its own. The buttons inside are Icon Button and carry theirs.

Special Rules

Every icon in the bar carries a tooltip

A bar is glyphs and nothing else, so without tooltips it is a row of guesses. Wrap each control in .ck-tip-host — the tooltip then appears on hover and on keyboard focus. See Tooltip § Attaching one.

Show them on hover, not forced open. A specimen row with every bubble pinned visible documents the appearance and hides the interaction — and four adjacent bubbles overlap each other, so it does not even show the appearance honestly. Space the placements apart and let the real hover do the work.

A bar is glyphs and nothing else, so without tooltips it is a row of guesses. Wrap each control in .ck-tip-host — the tooltip then appears on hover and on keyboard focus. See Tooltip § Attaching one.

Floating — .is-floating

position: fixed, centred at the foot of the viewport, with --shadow-raised — x 0, y 1, blur 3. That is the whole lift, and it is the only shadow the bar ever has: at rest the bar casts nothing. It sits in the page, so its border and its --card surface are what separate it.

Do not reach for a deep multi-layer shadow here. An earlier one made the bar read as a modal panel hovering over the page rather than a control attached to it. Every shadow in the system is now that same x 0 / y 1 / blur 3 drop, so there is nothing deeper to reach for.

The canonical case is a selection's bulk actions floating over the list they act on: a count, the actions, a separated destructive action, a labelled CTA and a dismiss.

One labelled CTA, and it needs no tooltip

A bar may carry one labelled control — a plain .ck-btn.primary at the bar's own height, such as "Save". Two things follow:

  • It needs no tooltip. The tooltip rule binds on glyph-only controls; this one carries its label already.
  • One is the limit. A second labelled button turns the bar into a row of buttons, and the icons beside it stop reading as a set.

Put the destructive action behind its own rule so it is never adjacent to the control a user reaches for most.

.is-split for a centred group

.ck-action-bar.is-split is a three-column grid: leading run, centred group, trailing run. A flex row cannot hold the middle group centred once the two sides differ in width — it drifts as the content changes. Use .ck-action-bar-group with .is-center / .is-end for the three cells.

Sample — the Document Editor preview toolbar
GroupContents
leftrotate clockwise, rotate counterclockwise, flip horizontal, flip vertical
centrezoom out, 100 %, zoom in, full resolution
right"Pages", the current page (editable), / 12, previous, next

Groups follow the work, not the chrome: transforms act on the image, zoom acts on the view, navigation moves between pages. Mixing them — a download button sitting beside a rotate — makes the bar a drawer of unrelated actions.

Four things it demonstrates:

  • The zoom read-out and the page number are one component. .ck-action-bar-field is a bordered slot: a <span> where the value is only read, an <input> where it can be typed into. They look identical because they are the same kind of thing — a value on the bar.
  • The page arrows are .size-xs (24px), one step below the bar's 36px controls, because they are secondary to them. Same reason .size-xs exists at all: a control nested inside another control's context.
  • .on-dark retunes the buttons rather than restyling them — it swaps the two hover custom properties, so .ck-icon-btn stays one definition.
  • The separator takes --toolbar-dark-sep on a dark bar, or it vanishes into the surface. .ck-divider.is-vertical is retuned by .on-dark, not replaced.

More Document Editor components will land under this section.

Special Rules

Rules That Matter
  1. It floats and carries elevation. That's what separates it from .ck-toolbar, which is an in-flow row inside a card.
  2. Over a document or image, use .on-dark. It swaps in the --toolbar-dark family, which was minted for exactly this — a bar sitting on content whose colour you don't control.
  3. A fixed bar must stay clear of what it acts on. A bar covering its own target is a usability defect, not a styling one.
  4. Every icon on the bar has a tooltip on hover. No exceptions — a bar of unlabelled glyphs is a guessing game. .ck-tip-host gives it on keyboard focus too.
  5. Use the standard glyph size and leave real space around it. A control is the glyph plus its buffer: 4px between icons in a cluster, 8px of bar padding. Glyphs pressed against each other or against the bar's edge read as one smear rather than separate actions.
  6. Put clear space between clusters. 12px between groups against 4px within one, plus a divider. Spacing is what says "these four belong together and that one does not"; the divider only confirms it.
  7. The resting bar casts no shadow. It sits in the page, so its border and surface are what separate it. Only .is-floating lifts, and then by exactly --shadow-md — x 0, y 1, blur 3.
Accessibility
  • role="toolbar" with an aria-label.
  • Every glyph-only button needs aria-label plus a .ck-tip.

Alert

v3.2alert.md

An inline banner — not a toast (transient) and not .ck-error-state (replaces the content). Tones are the soft-semantic triplet; text is always --X-soft-foreground.

Live
Neutral
A declared cross-pair: --foreground on --muted is 15.87:1.
Information
Three documents are still being processed.
Reconciled
All 42 line items matched the purchase order.
Check the totals
Two rows differ from the delivery note by under 1%.
Upload failed
The file exceeded the 25 MB limit.
Rules — references/components/alert.md

Alert

.ck-alert

What It Is

A persistent inline banner — something the user should know while they carry on working. Neutral by default, with four semantic tones.

Basic Information

Classes
ClassRole
.ck-alertthe banner
.is-info / .is-success / .is-warning / .is-errortones
.ck-alert-icon24px box, 16px glyph
.ck-alert-contenttitle + message
.ck-alert-title600 / 13px
.ck-alert-msg400 / 12px
.ck-alert-actiona .ck-btn under the message
> .ck-icon-btnthe dismiss
Sizes

.size-sm · default · .size-lg — padding, i.e. density. An alert's height is its content's.

Tokens

SlotTokens
neutral fill--muted + --foreground
toned fill--X-bg
toned text--X-soft-foreground
toned stroke--X-border
radius--radius-2xl — an alert is a container

The neutral fill is a declared cross-pair: --muted + --foreground is 15.87:1, where the nominal --muted-foreground partner is 3.16:1 and fails AA for body copy. .ck-table th carries the same exemption.

Toned text is always --X-soft-foreground, never the solid --X on its own tint.

States

The banner isn't interactive. The dismiss is .ck-icon-btn.tone-danger.size-xs and carries its own six states.

The component also sets the destructive tone on .ck-alert > .ck-icon-btn, so an alert whose markup omits the class still renders correctly.

Special Rules

An alert is not a toast, and the two are not interchangeable

An alert is an inline message: it lives inside a dialog, a panel, a form or any other container, in the flow of the content it concerns, and it stays until something changes. A Toast Notifications floats above the page, is transient, and dismisses itself.

Building one as the other is a defect in both directions.

The icon on the left and the cross on the right are interchangeable — an alert may carry either, both, or neither. What is not optional is that the title carries the meaning; the tone and the glyph are redundancy.

Rules That Matter
  1. An alert sits beside content that's still there. A Toast Notifications is transient, floats, and self-dismisses. .ck-error-state replaces the content it stands in for. All three exist and none is a substitute for another.
  2. The dismiss is a destructive icon. A cross is destructive wherever it appears, so it carries the destructive tone and the full destructive interaction set — not the alert's own tone. Use <button class="ck-icon-btn is-close size-xs" aria-label="Dismiss">.
  3. Tone is never the only carrier. The title says what happened.
  4. role="alert" for errors only. It interrupts a screen reader, so informational and neutral banners take role="status".
Accessibility
  • The dismiss needs an aria-label.
Notes

--warning measures 2.05:1 against the page, so the warning tone's border is weaker than the others. That's inherited from the token set, not local to this component.

Toast Notifications

kittoast.md

Eight variants — four feedback (success / error / warning / info) and four action (download / upload / undo / default), taken from the v2.0 molecules reference. Each is the soft-semantic pattern: an --X-bg fill inside an --X-border stroke with the icon on the solid --X. That replaces the leading 3px accent bar this kit had shipped, which read as a hairline on a --popover card and lost the tone almost entirely. Transient and self-dismissing — for anything the user must answer, use a dialog.

Live

Feedback Toasts

success
Reconciliation complete
12 invoices have been reconciled and locked.
error
Import failed
Could not connect to Dropbox. Check your connection and try again.
warning
Rate limit approaching
You've used 450 of 500 API calls this hour.
info
Maintenance scheduled
System will be briefly unavailable on May 18 from 2–3 AM IST.

Action Toasts

download
Exporting invoices…
Preparing 48 invoices as CSV
upload
Upload complete
6 documents uploaded to Purchase Orders / Inwards.
undo
3 invoices archived
INV00028, INV00076, and INV00034 moved to archive.
default
Reminder
You have 5 invoices pending review before end of day.
Rules — references/components/toast.md

Toast

.ck-toast · .ck-toast-stack

What It Is

Transient, non-blocking confirmation that something happened.

Basic Information

Anatomy
<div class="ck-toast-stack" role="region" aria-label="Notifications">
  <div class="ck-toast is-success is-shown" role="status">
    <span class="ck-toast-icon">…</span>
    <div class="ck-toast-content">
      <div class="ck-toast-title">Folder created</div>
      <p class="ck-toast-msg">3 linking rules were copied from the template.</p>
    </div>
    <button class="ck-icon-btn size-xs ck-toast-action" aria-label="Dismiss">…</button>
  </div>
</div>

The stack is pointer-events: none and each toast re-enables them for itself, so the empty area of the stack never blocks the page beneath it. That's easy to break by adding a background or padding to the stack — don't.

.is-shown and .is-leaving drive the transition; the flow's JS sets them.

Eight Variants

Every one is the soft-semantic pattern: an --X-bg fill inside an --X-border stroke, with the icon on the solid --X.

Four feedback tones:

TokenLightDarkHigh ContrastWhat It Is For
--success#2e9e52#42c070#44ff88.is-success
--destructive#e50600#fe9b98#ff4444.is-error
--warning#f2a618#f7b83d#ffbb33.is-warning
--info#387ff9#92c4fe#44aaff.is-info

Four action tones:

TokenLightDarkHigh ContrastWhat It Is For
--primary-bg#f0f3f4#1f2e32#081113.is-download
--primary#013c4b#e7f9fe#66d9ef.is-download
--cyan#0e7490#67e8f9#67e8f9.is-upload
--violet#6d28d9#a78bfa#a78bfa.is-undo
--muted#f5f5f5#1b292d#1a1a1a(none)
--input#8b9292#5f7073#999999(none)
--muted-foreground#738f96#bcd7dd#e0e0e0(none)

This replaced a 3px leading accent bar, which read as a hairline on a --popover card and lost the tone almost entirely.

Sizes

None. One width, from the stack: min(420px, 100vw - 48px).

A stack of different-width toasts reads as broken rather than as a hierarchy, and a toast is too short-lived to justify a density decision. If a message needs more room than 420px, it isn't a toast.

Special Rules

Rules That Matter
  1. For anything the user must answer, use a Dialog. For a persistent condition, use an Alert. A toast is for something that already happened.
  2. role="status" announces politely; role="alert" interrupts. Pick by urgency, not by tone class. A success message with role="alert" cuts the user off mid-sentence.
  3. A toast that auto-dismisses must not be the only record of an error. Auto-dismiss success after 4–6s; never auto-dismiss something the user has to act on.
  4. It's non-modal and must not be focus-trapped — ClipperOverlay doesn't apply.
  5. Max three stacked. A queue of twelve is a log, and that's a Notification Panel.
  6. The title states what happened — "Folder created", not "Success!".
Accessibility
  • The stack is role="region" with an aria-label, so it's reachable as a landmark rather than an unlabelled div.
  • The dismiss button needs an aria-label — it's glyph-only.
  • WCAG expects the user to be able to extend or dismiss timed content.
Known Defect

--warning measures 2.05:1 against the surface in light theme, under the 3:1 floor for a non-text affordance. It's an amber that's inherently low-contrast on white — a token-set defect, not something to patch here.

Until it's fixed, a warning toast must not rely on its tone alone. Say "Warning" or name the specific condition in the title text.

Notes

What was deliberately not copied from the v2.0 reference, because reuse beats minting:

Reference hadWe useWhy
its own .toast-btn.ck-btn.size-smthere's no toast-only button
its own .toast-progress.ck-progress.size-smthere's no toast-only progress bar
rgba(0,59,74,.06)--primary-bgrgba is forbidden, so the tint is pre-composited per theme: #f0f3f4 / #1f2e32 / #081113. Named for the role — the primary family's soft surface — not for the toast

Its 10px radius isn't copied either: a toast is a panel, and every panel in the kit is --radius-2xl.

Card, Settings Card & Toolbar

kitsurfaces.md

Four surface levels, and they are semantic: --canvas is the ground the app sits on, --background the default surface in it, --card anything elevated but in flow, --popover anything floating. The last three are all #ffffff in light and diverge in dark, so a wrong level is invisible until you switch theme.

Live

Secondary Card — .ck-card.is-secondary

the tinted card surface option: --card-secondary #eaf4f6 light · #2b4348 dark · #122a30 high contrast; text --card-secondary-foreground, stroke --card-secondary-border
Card
--card · the default
Secondary Card
--card-secondary
Secondary, Small
.is-secondary.size-sm
Matching Rules
Applied to every document in this folder.

Medallion Variant — .ck-card.is-medallion

A card introduced by an icon sitting astride its top edge, for a short list of destinations chosen at a glance. Two things follow from the medallion and are not free choices: the card cannot clip its overflow, and its top padding is 52px rather than a step on the scale, because it is the medallion's radius plus a gap.

whole card is the target — hover, focus and press live on it
Automation
Small
.ck-card.size-sm
Large
.ck-card.size-lg
Rules — references/components/surfaces.md

Surfaces

What It Is

Card · Settings card · Toolbar

Three containers. They hold other components and contribute almost no visual language of their own — which is exactly why getting their surface token right matters: everything inside them inherits the consequence.

Basic Information

Card — .ck-card
<div class="ck-card">
  <div class="ck-card-title">Linking rules <svg class="ck-info">…</svg></div>
  <p class="ck-card-sub">3 rules, applied in order</p>
  …
</div>
ClassRole
.ck-card--card surface, --panel-border, --radius-2xl, 16px padding
.ck-card-title600 / 14px, flex row so an .ck-info glyph can ride along
.ck-card-sub400 / 12px in --muted-foreground
.ck-card-title .ck-info14px help glyph in --muted-foreground
Sizes

Padding only.

ClassPaddingReach for it when
.size-sm12pxa card inside another card, or a dense grid of small cards
(none)16pxdefault
.size-lg24pxa page's primary panel, or a card that is the whole content of a view

Settings Card — .ck-settings-card

A tinted variant for a settings group, with a ruled title.

PartToken
surface--accent + --accent-foreground
border--panel-border
title rule--panel-border
radius--radius-2xl
padding20px

No sizes. It's a page-level grouping, so there's nothing for it to align against. Adjust density by sizing the controls inside it.

Inputs, selects, dropdown triggers and tables inside it all set --background explicitly, so their borders stay readable against the tint.


Toolbar — .ck-toolbar
<div class="ck-toolbar">
  <div class="ck-search grow">…</div>
  <button class="ck-btn size-sm">Filter</button>
  <button class="ck-icon-btn size-sm" aria-label="Refresh">…</button>
</div>

A flex row above a table or list. .grow on a child takes the remaining space, and flex-wrap: wrap means it degrades to two lines rather than overflowing.

ClassGapMargin-bottom
.size-sm8px12px
(none)16px16px

A toolbar is the canonical case for the control height scale. It's where the five-different-heights defect was most visible before the scale existed: a 34px button beside a 35px input beside a 32px icon button. The scale is now 28 / 36 / 44. Put a .size-sm toolbar above a .size-sm table.


Special Rules

Rules That Matter
  1. --card for an in-flow panel, never --background. --background is invisible in light theme (both are #ffffff) and reads flat in dark.
  2. --accent for a tinted grouping — never a color-mix of a badge palette colour.
  3. Anything inside a tinted card that needs its own edge sets --background explicitly. A white control on a tinted card is what makes its border readable.
  4. One control size per toolbar, and usually .size-sm.
  5. Radius does not scale with size. All three containers are --radius-2xl at every size; only padding changes.

Accessibility
  • .ck-card-title should be a real heading when the card is a titled region, so it lands in the heading outline. The class styles it; it doesn't make it one.
  • .ck-info is a help glyph with no text — it needs aria-label or adjacent text, or it announces nothing. cursor: help is not an affordance for a screen reader.
  • A toolbar is a <div>, not role="toolbar", unless you implement arrow-key navigation between its controls. The role promises that behaviour.
  • None of these three is interactive, so none has states. If a card becomes clickable it owes all six.
Notes

.ck-card once had no base rule at all — it existed only as .ck-card-title .ck-info, a descendant selector for an icon inside a title that itself had no rule. Anything marked up with class="ck-card" inherited nothing: no surface, no border, no radius, no padding.

That's the most invisible class of defect in this system, because a page using it doesn't error — it just renders flat, and whoever built the page adds padding in a page <style> block to compensate, which then becomes debt.

The settings-card tint used to be a badge colour — color-mix(in srgb, var(--teal) 5%, var(--background)), with two descendant rules using --teal at 8% and 15%. --teal exists to tint a pill, so a brand change to --accent would never have reached this card, and nobody looking for "the settings card colour" would search for --teal. Same substitution as Member Card, which used the identical mix.

Both container borders sit under 3:1 — the known --panel-border weakness. Tolerable for a container holding labelled content. Worth knowing that in dark theme a card's edge against the page is carried more by the surface difference (--card #233a3e vs page #142226) than by the border.

Member Card

kitmember-card.md

Row actions are hidden until hover, which is fine for a mouse and invisible to a keyboard — so they also reveal on :focus-within. The surface is --accent, not a colour-mix of --teal: --teal is a badge palette colour, and using it as a tint means a brand change to --accent would never reach the card.

Live
VJ
Vaibhav JoshiOwner
vaibhav_j@staple.io
AK
Anita KaurMember
anita_k@staple.io
Rules — references/components/member-card.md

Member Card

.ck-member

What It Is

A person in a list — name, email, role pill, and row actions. Used in team and permissions views.

Basic Information

Anatomy
<div class="ck-member-grid">
  <div class="ck-member">
    <div class="ck-member-av">VS</div>
    <div class="ck-member-main">
      <div class="ck-member-top">
        <div class="ck-member-name">Vaibhav Sharma</div>
        <span class="ck-pill size-sm off ck-member-role">Owner</span>
      </div>
      <p class="ck-member-mail">vaibhav@staple.io</p>
    </div>
    <button class="ck-icon-btn size-xs" aria-label="Member Options">…</button>
  </div>
</div>
.ck-member .ck-icon-btn{opacity:0}
.ck-member:hover .ck-icon-btn,.ck-member:focus-within .ck-icon-btn{opacity:1}
Sizes

None. A member card is a list item read against its siblings, not a control in a row. Density comes from the grid: .ck-member-grid is 2 columns, .cols-1 for a narrow panel or drawer.

The avatar is fixed at 36px.

Special Rules

Rules That Matter
  1. min-width: 0 on .ck-member-main is not optional. Name and email both truncate with an ellipsis; without it flex refuses to shrink and the row overflows.
  2. Hover-reveal always needs a focus equivalent. The row action is revealed with :hover and :focus-within — with hover alone, a keyboard user can focus a completely invisible button at opacity: 0.
  3. The role pill is .size-sm. A default 24px pill next to a 13px name crowds the top row.
  4. Name the person in the action's label — "Options for Vaibhav Sharma". "Options" repeated twenty times down a list is useless in a screen reader's element list.
  5. The card is not interactive as a whole. If it becomes clickable, the nested action button becomes a nested interactive control — which needs restructuring, not styling.
Accessibility
  • The avatar's initials are decorative when the name is adjacent, so aria-hidden is correct there. Use initials as the fallback, never an empty circle.
Known Defect

The email is at 4.44:1 in light theme, which fails AA by 0.06. --muted-foreground does not clear 4.5:1 against --background (3.44), nor against the slightly darker --accent tint.

This is the general hazard of a tinted surface: a foreground verified against the page ground is not automatically valid on a tint. The fix is a token revision, not a local override — so until then, don't put uniquely-identifying information only in the email line.

Navigation List

v3.2navlist.md

A vertical list of destinations inside a page — the sections of Settings. Not the sidebar rail, which is the app's own edge: that one is icon-only, global, and there is exactly one of it. The chosen row insets itself so it reads as a lozenge lifted out of the list rather than a band welded to the panel edge.

Live

Variant 1 — with a leading icon

.ck-navlist — the default. A glyph identifies the section before the label is read.

Variant 2 — without a leading icon

.ck-navlist.is-plain — the row keeps its height, so a list of each lines up with the other. There is no third variant.

All Six States

Properties, tokens and rules

Anatomy

PartClassNotes
panel.ck-navlistfills its column, --card, 16px radius, 1px --panel-border
row.ck-navlist-item44px minimum — the large step on the control scale
leading glyph.ck-ico16px in a 24px box; hidden by .is-plain
chevron.ck-navlist-chevronalways present, pushed to the trailing edge

States

StateSurfaceMarks
Default--card--icon-color
Hover--hover-bg--icon-color
Pressed--selected-bg--icon-color
Focusunchanged--focus-ring
Selected--primarylabel, glyph and chevron all --primary-foreground
Disabledtransparent--muted-foreground, cursor:not-allowed

Tokens

TokenValueWhere
--card#ffffffthe panel surface — white in light, the elevated surface in dark
--panel-border#ebeff0the panel stroke
--primary#013c4bthe selected lozenge
--primary-foreground#ffffffevery mark on it — label, glyph, chevron
--hover-bg#e7f9fehover
--selected-bg#e5f8fepressed
--icon-color#5a7278the glyph and chevron at rest
--muted-foreground#5a7278disabled

Rules

  1. The chevron is always there, and it turns clockwise both ways. Closed it points down; open it points right. Opening is three quarters of a turn, closing is the last quarter, and the pair completes one revolution — so the control never unwinds.
  2. The panel fills its column, on --card. A nav that stops where its items stop leaves the page ground showing below it and reads as a card that ran out rather than the side of the screen.
  3. Selected turns every mark white. On the --primary lozenge the label, the leading glyph and the chevron all read --primary-foreground. A glyph left on --icon-color there measures 2.35:1.
  4. Two variants, no third. With a leading glyph or without. The row height does not change between them.
  5. It is not the sidebar rail. The rail is the app's own edge — icon-only, global, one of it. This is a list of sections inside a page.
  6. Long names truncate from the middle and carry a tooltip, per the universal rule.

Empty State

kitempty-state.md

Structure, not just centred text: a glyph, a title, a line of explanation, and one action. An empty state with no action is a dead end. The dashed outline is what tells it from a loading shimmer at a glance.

Live
No Documents YetDrop a PDF here, or connect a folder to pull them in automatically.
No Documents YetDrop a PDF here, or connect a folder to pull them in automatically.
Rules — references/components/empty-state.md

Empty State

.ck-empty

What It Is

What a container shows when it has nothing in it.

Basic Information

Anatomy
<div class="ck-empty">
  <span class="ck-empty-icon"><svg …></svg></span>
  <div class="ck-empty-title">No linking rules yet</div>
  <p class="ck-empty-msg">Rules decide which documents get matched together.</p>
  <button class="ck-btn primary ck-empty-action">Add a rule</button>
</div>
ClassRole
.ck-emptycentred column, dashed --input border
.ck-empty-icon32px glyph in --input
.ck-empty-title600 / 14px in --foreground
.ck-empty-msgbody copy, max-width: 44ch
.ck-empty-actionthe one thing to do next
Sizes

Padding, not a box.

ClassPaddingGapReach for it when
.size-sm20px 12px6pxinside a small container — a dropdown list, a panel section, a narrow table
(none)36px 16px8pxdefault — a card, a tab panel, a drawer body
.size-lg56px 24px12pxa whole page or view with nothing in it: first-run, or a cleared search on a full-screen list
Icons

Lucide, 16×16, stroke-width 2, currentColor.

Special Rules

Rules That Matter
  1. An empty state with no action is a dead end. It tells the user there's nothing there without telling them how to change that. Always give it one action, and make it the obvious next step.
  2. The dashed border is what distinguishes "empty" from "still loading." A skeleton has a filled shimmer; an empty state has an outline. Don't swap them.
  3. The title names what is missing — not "Nothing here" — and the message says why it matters rather than repeating the title.
  4. At .size-sm, drop the message. Icon and title only. Four stacked elements in 20px of padding reads as cramped, not compact.
Accessibility
  • The title should be a real heading (<h2> / <h3>) when the empty state replaces a titled region, so it appears in the heading outline.
  • The icon is decorative — aria-hidden="true".
  • If the container became empty after an action — a filter cleared it, a delete emptied it — announce that in a live region. The empty state appearing is a visual change only.
  • The action is a real .ck-btn, not a link styled as one.
Don't
  • Don't use a dashed border for loading. Use a skeleton.
  • Don't use .size-lg inside a card.
Notes

max-width: 44ch on the message is deliberate: centred text past about 45 characters per line becomes hard to track back to the start of the next line.

.ck-dd-none, .ck-menu-empty and .ck-table-empty are separate one-line empty messages inside their own components. They're intentionally not folded in here — each is a single line in a constrained container and doesn't need the icon/title/action structure. If any grows past one line, it should become a .ck-empty.size-sm.

Empty, Loading & Error

kitstates.md

Three distinct answers to "there is nothing to show yet", and they must not be swapped: a shimmer in the shape of the content that is coming, a dashed outline saying there is none plus a way to change that, and a destructive-tinted panel with a retry.

Live
loading — filled shimmer, in the shape of the content
error — a retry, not a dead end
Could not load documents
The connector timed out after 30 seconds.
table loading — 10 rows x 8 columns, column widths held
InvoiceSupplierPODOStatusDueCurrencyAmount
image placeholderNo preview available
Rules — references/components/states.md

Empty, Loading and Error States

What It Is

Three distinct answers to "there is nothing to show", and they must not be swapped.

Basic Information

Skeleton Primitives
ClassShape
.ck-skeleton-text12px line
.ck-skeleton-title16px line, capped at 40% width
.ck-skeleton-avatar20px circle
.ck-skeleton-ico--icon-box (24px) rounded box, so it stands in for an icon without resizing the row
.ck-skeleton-image16:10, panel radius
.ck-skeleton-pill72 × 24px, --radius-full
.ck-skeleton-btn88 × 36px, control radius
.ck-skeleton-inputfull width × 36px
.ck-skeleton-list + .ck-skeleton-rowa list of rows
<div class="ck-skeleton-list">
  <div class="ck-skeleton-row">
    <div class="ck-skeleton ck-skeleton-avatar"></div>
    <div style="flex:1">
      <div class="ck-skeleton ck-skeleton-title"></div>
      <div class="ck-skeleton ck-skeleton-text"></div>
    </div>
    <div class="ck-skeleton ck-skeleton-pill"></div>
  </div>
</div>

That renders as a recognisable member row rather than three grey bars — which is the whole difference between a skeleton and a placeholder.

Table Loading
<table class="ck-table is-loading size-sm">
  <thead>…</thead>
  <tbody><tr><td><div class="ck-skeleton ck-skeleton-text"></div></td>…</tr></tbody>
</table>

.is-loading keeps the cell padding and the row height, so the column widths hold and the header doesn't jump when the real rows arrive. A skeleton that reflows on resolve is worse than a spinner.

Panel Loading
<div class="ck-card ck-loading">
  <div class="ck-loading-overlay">
    <span class="ck-spinner" role="status" aria-label="Loading"></span>
    <span class="ck-loading-label">Loading documents…</span>
  </div>
</div>

.ck-loading sets a min-height so the page doesn't reflow when it resolves, and border-radius: inherit on the overlay clips it to whatever panel it's in.

Use this only where the panel's shape is unknown — a chart, a preview. Where the shape is known, a skeleton says more.

Images
ClassUse
.ck-imagea real <img> — 16:10, object-fit: cover, --muted while it loads
.ck-image-emptyno image available: dashed outline, icon, one line
.ck-skeleton-imagestill fetching

.ck-image is a real element rather than a background, so alt text and the browser's broken-image fallback both work.

Radius

--radius-2xl for .ck-empty, .ck-error-state, .ck-image and .ck-image-empty. .ck-skeleton uses --radius-sm, and .ck-skeleton-avatar --radius-full.

Tokens

The Three States
StateClassTreatmentSays
Loading.ck-skeleton*, .ck-loading-overlayfilled shimmer in the shape of the content"it's coming"
Empty.ck-emptydashed --input outline, icon, title, one line, one action"there is none, and here's how to change that"
Error.ck-error-state--destructive-bg fill, --destructive-border, a retry"it failed, and you can try again"

The shimmer runs --muted → --skeleton-sheen → --muted, and the spinner's track is --skeleton-sheen with a --primary leading edge. Skeletons and spinners share one tone family on purpose — they appear together, and two different greys read as a bug.

--skeleton-sheen is deliberately quiet. A loud shimmer competes with the real content arriving next to it.

Special Rules

Rules That Matter
  1. Every component that can hold absent, arriving or failed content needs all three states. Most components in this kit shipped with none.
  2. Dashed outline means empty; a filled shimmer means loading. That distinction is what tells the two apart at a glance. Don't swap them.
  3. An empty state with no action is a dead end. An error state that only apologises is the same defect.
  4. Announce the change. A panel emptying, resolving or failing is a visual change only — put the result in a live region. This is the most commonly missed part of all three states.
  5. **Build a skeleton to the shape of what it replaces,** not as a grey rectangle.
Accessibility
  • .ck-spinner needs role="status" and an aria-label. A bare rotating div announces nothing.
  • Skeletons are decorative (aria-hidden="true"), and the region they fill carries aria-busy="true" while loading.
  • An error state's retry is a real <button>, and the message should say what failed, not just that something did.
  • Under prefers-reduced-motion the shimmer flattens and the spinner slows — neither is removed, because removing them would leave no indication that anything is happening.
Where all three are required

Tables, lists, cards, panels, images, avatars, dropdown and menu option lists, folder trees, member grids, search results, and any icon whose glyph depends on fetched state.

Notes

Some components have a one-line empty message rather than the full .ck-empty structure — .ck-dd-none, .ck-menu-empty, .ck-table-empty. That's intentional: each is a single line in a constrained container. If one grows past a line, it becomes a .ck-empty.size-sm.

Canvas layout — panels on the ground

kitcanvas.md

The blank white panel a screen’s content actually lives in, laid on the canvas. It is not a card: a card is a discrete object in a list of objects, a panel is a region of the screen. Same surface, different job — which is why its rules are all about how panels sit next to each other rather than what they hold.

LIVE

The Container Inset — 16px On All Four Sides

One property, every container: --container-inset. The tinted block is the content box, so the inset reads as an even frame on all four sides. Each of these used to pick its own number — 8, 18/20, 0/12/12, 12/16/16, 20 — and five were asymmetric, so content sat closer to one edge than the other for no stated reason. .size-sm is 12, .size-lg is 24, and .is-flush is 0 for content that must reach the edge.
content
.ck-panel-body · 16
content
.ck-card · 16
content
.size-sm · 12
content
.size-lg · 24
content
.is-flush · 0

Every Container’s Inset

Each container takes one tier of the ladder, on all four sides (a bar: block / inline). A component never invents a number.
TierTokenValueComponentsWhy this tier
xs--container-inset-xs4px.ck-folder-row, .ck-folder-doc (folder navigator rows); .ck-menu frame, and a menu list under a header or searcha row inside a list, or the frame around a list of rows: the row's own height sets the rhythm
bar-sm--container-inset-bar-sm8 / 12px.ck-alert.size-sm; .ck-accordion.size-sm triggera one-line bar, small
bar--container-inset-bar12 / 16px.ck-toast; .ck-alert; .ck-notif-head; .ck-accordion-triggera one-line bar whose height is its own
bar-lg--container-inset-bar-lg16 / 24px.ck-alert.size-lg; .ck-accordion.size-lg triggera one-line bar, large
sm--container-inset-sm12px.ck-card.size-sm; .ck-menu-header; .ck-col-card; .ck-filter-rule; .ck-dfp-body; .ck-activity-itemread close up and in bulk: a card in a list, a fields panel
base--container-inset16px.ck-card; .ck-panel-head / -body / -foot; .ck-dialog-head / -foot; .ck-drawer-head / -tools / -body / -foot; .ck-accordion-panel; .ck-member; .ck-settings-card; .ck-feature; .ck-connector; .ck-form.is-carded; .ck-tab-panelthe primary surface of what you are looking at
lg--container-inset-lg24px.ck-card.size-lga spacious, standalone card
Declared exceptions, each with its reason:
ComponentInsetWhy
.ck-empty, .ck-dropzone36 / 16 and 32 / 16a placeholder centred in the space it fills; the block inset is the breathing room around the message
.ck-tip8 / 10a compact label, not a container you read inside
.ck-action-bar8a tray of 36px controls; the controls set its height
.ck-auth32a page-level card standing alone on the canvas
.ck-btn, .ck-input, .ck-dd-trigger, .ck-tab, .ck-pill / .ck-chip, .ck-menu-item, .ck-dd-opt and table cells are CONTROLS: their padding comes from their fixed height on the size scale, not from a container inset, so they are not in this table.

Full screen — header, main nav, canvas, three panels

the left navigation runs the full height of the page and the header bar starts to its right — the rail is the app’s fixed edge, while the header belongs to the page inside it and changes as you navigate. The header is transparent, so the canvas runs behind it and its bottom rule is what separates it from the first row of panels. Both are always present unless a screen has an explicit reason to hide them. Everything below the header is .ck-canvas-area; each white region is a .ck-panel.
Queue
Awaiting match
INV-001234New
INV-001235New
INV-001236Held
In Review
3 documents
PO-5032-0098Matched
PO-5032-0099Partial
GRN-7293-0045Failed
Recently Closed
Last 24 hours
INV-001180Reconciled
INV-001181Reconciled
INV-001182Reconciled

Panels You Can Size — .ck-panel.is-resizable

Documents
drag my right edge — handle forced visible here
Detail
I absorb the difference
Activity
so do I

Drag the first panel’s trailing edge. It stops flexing and holds a width; the two beside it keep flex:1 and share the difference. The ceiling is not a fixed number — it is the row minus every sibling’s own --ck-panel-min floor, recomputed on each drag, so a panel can never crush the one next to it. Narrow the window and the panels shrink with it rather than overflowing; below 900px the row stacks and the widths are dropped, because a row of sized panels on a phone is a row of unreadable columns.

The Four Spacing Rules

12px between panels, always, whenever more than one is on screen. 0 at the top — the first row meets the header bar with no gap, because the header is the page’s own edge and a strip of canvas between the two reads as a rendering gap. 12px at the bottom, above the base footer. 12px at the sides, so the gutter matches the gap and the rhythm is one number everywhere.the asymmetry at the top is deliberate, and it is the rule most likely to be “corrected” by someone tidying up. It is not a mistake.

Why the edge is --panel-stroke and not --panel-border

--panel-border measures 1.02:1 against the canvas — no edge at all, so a panel was read only by its white fill at 1.13:1. --panel-stroke is 1.63:1 against the panel and 1.44:1 against the canvas: evident, without becoming a hard rule around every region of the screen. In high contrast it carries the whole panel, because there --card and --canvas are both black.No shadow. A panel rests on the page; only an overlay lifts (§ 14.3 check 21). And the fill is --card, not a literal white — white would black out in dark theme.

Grid Layout

v3.2grid.md

The columns every layout sits on — 4, 6, 8, 10 and 12 — and the lines that show them. Each grid below has its lines on: the tinted bands are columns, the fine rules are the 8px horizontal lines. Anything that should share a line either sits on it or is a finding.

Live

An Organism On The Lines

A panel laid on the 12-column grid with the lines on: the icons, the names, the badges and the buttons each share one vertical line, and every row's items share one horizontal line. Turn the lines off to see the finished layout.
Invoices
INV-2026-0412.pdfMatched
INV-2026-0413.pdfPartial
INV-2026-0414.pdfFailed
Summary
3 documents

4 Columns — .ck-grid.cols-4

narrow panels, a drawer, a phone
.span-4
.span-2
.span-2
.span-1
.span-1
.span-1
.span-1

6 Columns — .ck-grid.cols-6

a card grid, a settings page
.span-6
.span-4
.span-2
.span-3
.span-3

8 Columns — .ck-grid.cols-8

a main area beside a side panel
.span-8
.span-5
.span-3
.span-2
.span-2
.span-2
.span-2

10 Columns — .ck-grid.cols-10

a wide form with a margin
.span-10
.span-7
.span-3
.span-5
.span-5

12 Columns — .ck-grid.cols-12

the full canvas — the default
.span-12
.span-3
.span-6
.span-3
.span-4
.span-4
.span-4
Spec
Rules
  1. Everything sits on a line. Draw the grid's lines behind a layout: items in a row share one horizontal line through their centres (text by its ink), and the parts of a column share one vertical line — the container's inset.
  2. Measure it, then look. scripts/audit_grid_lines.py draws both sets of lines in a browser and reports anything more than 1px off; [data-ck-grid-lines] shows the same lines by eye.
  3. Every panel and organism is laid on a grid — 4, 6, 8, 10 or 12 columns, gutter --grid-gutter (the canvas gap). A new one is checked on the lines before it enters the kit.
  4. Spans, not widths. A part takes .span-N columns; a fixed pixel width that ignores the columns is a finding.
  5. Under 720px every grid is 4 columns and wide spans take the whole row.
Tokens
TokenValueFor
--grid-gutter12px (--canvas-gap)between columns and rows
--grid-baseline-step8px (--space-sm)the horizontal line spacing in the overlay
--grid-guide#cde9f0 · #35565c · #1f4a55the column tint in the overlay (light · dark · high contrast)
--grid-baseline#e3eaec · #2a3e42 · #262626the horizontal lines in the overlay

Header Bar

kitheader-bar.md

One markup definition, built by ClipperHeader in clipper-kit.js?v=3ce65335 — a per-page copy is how the header drifts between flows. The breadcrumb trail is a first-class part of it, tokenised as --breadcrumb-gap and --breadcrumb-size.

The two specimens below sit on a bare --canvas ground with no frame around them, on purpose — so everything you can see belongs to the bar itself. They used to sit in a bordered white card, and that card’s 1px edge and white fill read as the bar’s own chrome when neither was. The bar is background: transparent in every mode, so on a white sheet it looks exactly like a white bar and the change is invisible — against the canvas you can see the ground running behind it. It has no chrome at all — no fill, no border, no shadow. What separates it from the content below is that content’s own top edge, which is exactly why the first row of panels meets it with a 0 gap.side gutters are a fixed 16px, matching the gap the canvas puts either side of its panels, so the header’s content lines up with the panel edges directly beneath it. At 8px the breadcrumb sat half a gutter inboard and the page read out of register.
Live
48px tall, down from 64. The height was never set by the bar — it was set by the 48px icon frames inside it, which put 16px of padding a side around a 16px glyph. The frames are 40px now, still well clear of the 24px target floor and on the 4px grid, and the bar closes to 48 with the same breathing room.

Settings Menu — the gear

Built by ClipperHeader under the gear — click it in the bar above. One list, in this order: Personal Information, Team Members, Role Management, Company Information, Subscription Billing, Help and Support. Picking an item fires ck:settings with its key; the flow decides what opens.

Plain — .header-bar.is-plain

a title and the trailing controls, no trail. For a page that is the destination — a dashboard, a settings root, a single-level view. Reach for the breadcrumb only when a user can be three levels deep and needs to know where; on a flat page it is chrome that says nothing. 48px, because one line of title needs no more.
Reconciliation

The panel toggle reflects state — it is not a back button

panel open — collapse
panel shut — expand
Rules — references/components/header-bar.md

Header Bar

.header-bar · .header-left · .breadcrumbs

What It Is

The same bar on every screen, tweaked per situation through attributes and tokens — never by rewriting the markup in a page.

Basic Information

Anatomy

The kit owns the leading run and nothing else:

[ panel open/close control ]  [ breadcrumb trail OR title ]  [ info icon ]
<header class="header-bar">
  <div class="header-left"
       data-crumb-header
       data-crumbs="Purchase Orders|Pending|Folder Settings"
       data-info="Optional tooltip text"
       data-panel="#folder-panel"></div>
  <div class="header-right"> …the flow's own buttons, untouched… </div>
</header>
Sizes

No size variants — it's one bar per screen, so there's nothing for it to align against. Density is a token decision:

TokenLightDarkHigh ContrastWhat It Is For
--foreground#05262e#ffffff#ffffffthe title and the active crumb
--muted-foreground#738f96#bcd7dd#e0e0e0inactive crumbs, the separator, and an idle icon button
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)crumb and icon-button hover
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)crumb pressed
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363cfocus, on the crumbs and the controls
--primary#013c4b#e7f9fe#66d9efthe avatar plate and the notification badge
--primary-foreground#ffffff#05262e#000000their text
--radius-mdcalc(var(--radius) - 2px)—var(--radius-md)the icon buttons
--radius-smcalc(var(--radius) - 4px)—var(--radius-sm)a crumb's hover pad
--radius-full999px——the avatar and the notification badge
--breadcrumb-size16px——crumb and separator type
--breadcrumb-gap4px——crumb ↔ separator spacing
--header-control-gap8px——toggle ↔ trail ↔ info spacing

The three sizing tokens and the radii carry no colour, so they show no chip — that is correct, not a gap. --radius-2xl used to be listed here as “the bar”; the bar has no radius at all, because it spans the full width and rounding one edge of a full-bleed bar reads as a rendering error.

The separator reads --breadcrumb-size too, so the two stay in step; a glyph separator uses .breadcrumb-sep .ico at 1em for the same reason.

The panel toggle and info icon are .ck-icon-btn, so they are on the control height scale. In a compact header, use .ck-icon-btn.size-sm (28px) and drop --breadcrumb-size to 14px together — changing one without the other leaves the glyphs and the text on different optical sizes.

Radius

None on the bar — a header bar is a strip, not a panel. Crumb items are --radius-sm; icon buttons --radius-md.

Special Rules

Rules That Matter
  1. Never hand-roll the leading run. One data-crumb-header host, and the module builds it. A per-page copy is how the header drifts between flows.
  2. .header-right belongs to the flow, not the kit. Each flow wires its own settings menu, profile menu and theme switcher there. The kit stops at the leading run on purpose — replacing that group would break those handlers.
  3. The leading control is a panel toggle, not a back button. There is no back button in the header bar.
  4. data-crumbs for a path, data-title for a destination — never both.
  5. Tweak density with tokens, not per-page CSS.
Attributes — this is the tweak surface
AttributeEffect
data-crumb-headermarks the host. Required; the module skips anything without it
data-crumbspipe-separated trail. The last entry renders as current
data-titlea single label instead of a trail
data-infotooltip text on the info icon. Omit the attribute to omit the icon; leave it empty for an icon with no tooltip
data-panelselector for the panel the control collapses. That element gets is-closed toggled. Omit it and the control still renders and still fires the event
data-toggle="off"suppress the panel control on pages that already ship their own wired one — rendering a second would duplicate the affordance or silently replace a working handler

Event: clipper:panel on document, detail { open: boolean }.

Trail and title render into the same slot with the same metrics, so the header's shape doesn't change between them.

The Panel Toggle

Its glyph reflects state — panel-left-close when the panel is open, panel-left-open when it's shut. A fixed chevron reads as the wrong affordance the moment the panel is closed.

ClipperHeader in clipper-kit.js?v=3ce65335 owns this: it swaps the glyph, keeps aria-expanded in step, toggles is-closed on the panel named by data-panel, and emits clipper:panel. Don't hand-roll it.

Accessibility
  • The trail is a <nav aria-label="Breadcrumb">. The last crumb carries aria-current="page" and is a <span>, not a link.
  • The panel control reflects state in all three places: glyph, aria-label, and aria-expanded.
  • The info icon needs an aria-label — it has no text.
Notes

The breadcrumb trail is part of the header bar. Build it. An earlier version of this documentation carried a hard prohibition — "never reintroduce a breadcrumb into the header bar" — which was wrong. It was almost certainly a decision about one specific header during the nav revamp, recorded as a universal ban. The kit contradicts it in three places: clipper-kit.js?v=3ce65335 builds the trail, clipper-kit.css?v=8da91cd1 styles it in nine rules, and design-system.css?v=7ab031ca defines --breadcrumb-gap and --breadcrumb-size as first-class tokens.

Two things still open:

  • The breadcrumb is now its own component — see Breadcrumb. .ck-breadcrumb* are the canonical names; the unprefixed ones stay as aliases. .header-bar and .header-left are still unprefixed, so they remain flow CSS living in the kit. Either prefix them or move them out.
  • No reference page uses data-crumbs, so the breadcrumb path is shipped but undemonstrated — which is part of why a wrong rule about it went unchallenged for so long.
  • Crumb hit areas are 4px 6px padding on 16px text, which is small. Worth revisiting against the 24×24 target floor.

Main Left Navigation

kitsidebar-nav.md

The app’s main left navigation, rebuilt from the shipped sidebar-nav.html. One variant, and there is no other — no round/square switch, no expanded mode, no size scale. A product has exactly one main navigation, so options here only invite two screens to disagree about what the app’s edge looks like.

LIVE

The rail — one variant, live

hover a button. The last two — Workflow and Compliance — open a panel of pages; the first three are destinations and show only a tooltip. Pin the panel to lock it into the layout.
Workflow
Click pin to lock open
Workspace

Every Interaction

tooltip on a destination button — the kit’s .ck-tip in its .is-end placement, hung off the button as a .ck-tip-host. That brings :focus-within with it, so a keyboard gets the label too, and it carries no shadow.
Workflow
Click pin to lock open
Workspace
panel open on a section button — floating beside the rail at left: 57px with --shadow-md. The tooltip is suppressed while it is open: the panel already names the section, and two labels for one thing is noise.
Compliance
Click pin to lock open
Workspace
panel pinned — .is-locked takes it from absolute to in-flow and drops the shadow, so the workspace resizes around it instead of being covered. That is why the pin is a toggle with aria-pressed and not a close button.
Workflow
Locked open
Workspace

What the rail is made of

56px column · 16px/8px padding · 12px between blocks, 4px inside one · 40px round buttons on a 20px glyph · a full-width hairline between runs · the tenant logo at the foot.the 12/4 split is the whole grouping mechanism — a run reads as one object because its buttons are three times closer to each other than to the next run. That is why no run needs a label in a rail this narrow.hover is --hover-bg, never grey. The reference hovers to --muted; the kit’s protocol is that nothing hovers to grey, and --hover-bg is the same tint the active state uses — so hover reads as “on the way to selected” rather than as a different idea.the tenant logo takes no tone of its own: a customer’s brand colour is not ours to place on a semantic surface. And the reference’s open/close toggle at the foot is deliberately absent — the pin already owns that state.

Folder Navigator

kitfolder-nav.md

Two variants. .ck-folder-nav is the default everywhere in the product — folder listing, folder settings, the reconciliation flows: orange counts and two levels. .ck-folder-nav.is-document is the document editor: blue counts and a third level of documents with checkboxes. The two colours are a real distinction — in the editor a count is pages loaded here, in a listing it is folders needing attention. Rebuilt from scratch on the kit’s own atoms: the search is .ck-search, the checkbox is .ck-check, the count is .ck-counter and every icon-only control carries a tooltip. Both trees are live — click a folder to select it, a chevron to open it without moving the selection, and type in the search to filter.

Live

A Long Nested List Scrolls

Q3 Freight0
Invoice_220700734.pdf
Invoice_220700735.pdf
GRN_88213.pdf
PO_4417_Northwind.pdf
Remittance_Sep.pdf
Delivery_Note_119.pdf
Credit_Note_22.pdf
Statement_Q3.pdf
Invoice_220700736.pdf
GRN_88214.pdf
PO_4418_Cormorant.pdf
Receipt_5521.pdf
Q4 Freight0
Archive0
12 documents, capped at just under 8 rows

The Folders Below Stay Reachable. A folder with sixty documents used to push every folder under it off the bottom of the panel. Nothing was lost — the outer tree scrolls — but the reader had to scroll past one folder’s contents to reach the next, and the tree stopped reading as a list of folders. The partial row at the fold is deliberate: a row cut by the edge says there is more, where a clean edge looks like the list ends. overscroll-behavior:contain is the load-bearing part — without it, hitting the bottom of the inner list hands the gesture to the outer tree and the whole panel lurches.

Standardorange counts, two levels, folder settings on hover
All Folders
Purchase Orders35
Awaiting Approval24
Approved8
On Hold3
Invoices1396
Unmatched112
Matched1284
Shipping Notes17
Archive0
.is-documentblue counts, three levels, multi-select documents
Documents
Q3 Reconciliation14
Bank Statements6
HSBC — July.pdf
HSBC — August.pdf
HSBC — September.pdf
Supplier Invoices8
INV-20481.pdf
INV-20482.pdf
Q2 Reconciliation31
Bank Statements12
Supplier Invoices19
.is-closedthe panel stays mounted and its width animates, so the table beside it reflows once instead of twice. The toggle sits outside the panel — one inside would become unclickable the moment it closed the thing it sits in.
All Folders
Purchase Orders35
Awaiting Approval24
Approved8
On Hold3
Invoices1396
Unmatched112
Matched1284
Shipping Notes17
Archive0
Rules — references/components/folder-nav.md

Folder Navigator

A folder tree in a panel, sitting beside the thing it navigates.

Core Behaviour

Clicking a folder selects it and the panel beside it changes. Clicking a chevron opens that folder without moving the selection. Typing in the search hides rows that do not match and opens every folder still holding one.

The Two Variants
ClassWhere It Is UsedLevelsCounter
.ck-folder-navEverywhere else — folder listing, folder settings, the reconciliation flowsFolders, any depthamber
.ck-folder-nav.is-documentThe document editor3, the third being documents with checkboxesblue

The two counter colours are a real distinction, not decoration. In the editor a count is *pages loaded here*; in a listing it is *folders needing attention*. Two different facts should not wear one colour.

Levels

Every level of the tree is a folder. There is no workspace level — the concept is gone from the system — so nothing in the tree is named, drawn or created as one. In the editor the level below a folder is a document.

LevelWhat It IsGlyph
AnyFolderStandard: one folder glyph at every level, in every state. Editor: folder / folder-open
Below a folder (editor)Documentnone
Folder Row (Standard)

Every folder row in the standard variant carries three things at its trailing edge, in this order from the left:

#PartClassWhen It Shows
1Horizontal kebab — the folder's actions.ck-folder-tool.is-more (ellipsis)On hover or focus
2New Folder — a folder inside this one.ck-folder-tool.is-newfolder (folder-plus)On hover or focus
3Amber counter.ck-counter.amberAlways

The kebab reports ck:folder-more with the folder and its button; the page opens its own menu against it. New Folder reports ck:folder-create with the parent folder.

Creating a Folder

The head's primary plus (.ck-icon-btn.is-add.is-primary, tooltip “New Folder”) opens the Create Folder modal directly — no menu of choices in between, because a folder is the only thing the navigator creates. Point the plus at the dialog with data-ck-folder-new="#id"; the dialog is a kit .ck-dialog inside a .ck-dialog-scrim hidden, holding one Folder Name field, Cancel (data-ck-close) and a primary Create Folder (data-ck-folder-create-submit). The kit opens it through ClipperOverlay: focus lands in the name field, Escape, the scrim and Cancel close it — no ✕, because Cancel is there, and focus returns to the plus. Create reports ck:folder-create with the name.

Rules That Matter
  1. Every part is an existing atom. The search row is .ck-search with a .ck-input, the document checkbox is .ck-check, the header actions are .ck-icon-btn inside a .ck-tip-host, and the count is .ck-counter with a palette tone. The tree contributes layout and nothing else. The two shipped versions this was rebuilt from had drifted into three different search inputs and two checkbox sizes between them; pointing the tree at the atoms is what stops that happening again.
  1. A document row carries no icon and no chevron lane. The checkbox already says what the row is, and a file glyph on every one competes with the folder glyphs the eye uses to read the tree's shape. A document can never expand either, so reserving a chevron lane for it is 23px of nothing in front of every row — and the selection highlight draws that emptiness. Dropping the lane also lands the checkbox exactly under the parent folder's icon, which is the indent rule below. Folders keep both; documents keep neither.
  1. A nested row begins under its parent's folder icon, not its chevron. The indent is therefore calc(var(--icon-size) + var(--ck-row-gap)) — derived, not guessed. A flat 20px leaves every level a few pixels adrift of the glyph above it.
  1. One folder glyph, at every level, in every state (standard). A folder is a folder however deep it sits and whether or not it is open — the chevron already says that. In the editor a selected folder shows the open glyph, whether or not it expands: a leaf has nothing to expand, so keying the open icon only off the expanded state draws the very row the reader is looking at as shut.
  1. The sort control opens a menu, it does not cycle. Two options, A to Z and Z to A, each naming the order it produces and each carrying its own icon; the current one is aria-checked. A button that silently changes an invisible setting gives the reader no way to know what it did. Sorting is applied within each parent, never flattened — a tree sorted flat tears children away from their folders.
  1. There is no workspace. The tree holds folders and, in the editor, documents — nothing else. Do not reintroduce a top level with its own glyph, its own create option or its own wording.
  1. Creating is one button, straight to the modal. The head's primary plus opens the Create Folder modal; there is no overlay menu of options, because there is only one thing to create. Every folder row also offers New Folder on hover, which needs no choice — the row already says where it goes.
  1. Only the standard variant creates anything. The document editor's tree is a picture of what is open, not a place to build one, so .is-document carries no create control at any level and no refresh in its head. Refresh belongs to the standard variant, where the folder list is the thing that goes stale.
  1. The chevron turns clockwise both ways. closing runs 90° → 180° rather than rewinding. The integrations screen collapses with rotate(-90deg), which turns the opposite way to every other disclosure in the kit — § 14.9 check 7 calls that a defect, so it was not carried over.
  1. The count is information, so it never hides, and it never moves. It sits permanently at the trailing edge. The row's controls come *before* it in the markup and the count takes the auto margin, so the gap opens ahead of the count and the count stays put. Put the controls after it and every count in the tree jogs sideways under the pointer.
  1. Every folder row shows its amber count, at every level. A parent's count is a fact about that folder, the same as a leaf's, so the standard variant does not hide it on a group heading. In the editor the count is the pages loaded under that folder.
  1. Row controls reveal on :focus-within as well as :hover. They animate from width: 0, so the counter slides instead of the row reflowing. A control that only appears on hover cannot be reached by keyboard, which § 14.3 check 31 does not allow.
  1. A leaf FOLDER still reserves the chevron's lane. Hide it with .ck-folder-chev.is-empty, never by omitting the element — drop it and the label starts 23px left of its siblings' and the folder column goes crooked. This applies to folders only: a document has no siblings that expand, so it has no column to hold.
  1. The highlight begins at the row's leftmost icon, not at its box. A reserved-but-empty chevron lane would otherwise put 23px of colour in front of the first thing the reader can see. The row cannot simply move — its edge *is* the highlight's edge, so shifting it would carry the icon along and break the column — so the paint goes on a ::before inset by one chevron plus one gap. What is left in front of the icon is the row's own 8px padding, the same lead every other row has, so every highlight shares one rhythm. Hover, selection and the focus ring all follow it.
  1. Selection is one row; document checkboxes are a set. Selecting is a cursor, so a new selection clears the old one. Checkboxes govern which documents are *open*, which is genuinely multi-select and independent of where the cursor sits.
  1. A group row in a listing only toggles. In the editor it toggles *and* selects, because a folder there is itself a destination.
  1. Search restores the tree it found. Clearing the field puts every folder back to open or closed as the reader left it. A search should not quietly reorganise someone's tree.
  1. Hover is --hover-bg. Never grey, per check 24.
  1. The collapse control lives outside the panel and names it by id through data-ck-folder-collapse. A toggle inside would become unclickable the moment it closed the thing it sits in.
  1. One round button in the head, at most. The primary add is round because it is the head's single affirmative action; sort, settings and refresh are plain .ck-icon-btn on the control radius.
Classes
ClassWhat It Is
.ck-folder-navThe panel. 300px, --radius-2xl, --panel-stroke
.is-documentThe editor variant — blue counts, three levels
.is-closedCollapsed. Width animates to 0; the panel stays mounted
.ck-folder-headTitle row plus search
.ck-folder-title20px semibold, truncates
.ck-folder-actionsHead actions; .ck-icon-btn.is-add.is-primary is the round primary and opens the Create Folder modal
.ck-folder-treeThe scroll area
.ck-folder-groupA folder that contains rows. Carries aria-expanded
.ck-folder-rowOne row, all three levels
.ck-folder-childrenThe nested block, indented 20px
.ck-folder-chevThe disclosure turn. .is-empty keeps the lane, hides the glyph
.ck-folder-glyphFolder icon. Standard: one folder glyph at every level. Editor: open and shut crossfade so width never shifts. Folders only — never on a document row
.ck-folder-tool.is-moreHorizontal kebab — the folder's actions. Standard variant, every folder row, first
.ck-folder-tool.is-newfolderNew Folder inside this folder. Standard variant, every folder row, second
.ck-folder-nameThe label; truncates
.ck-folder-toolA row control, shown on hover or focus — .is-more, .is-newfolder (standard); .is-settings (editor)
.ck-folder-docA document row: .ck-check then name. No glyph, no chevron lane
[data-ck-menu]Any button that opens a .ck-menu beside itself, by id
[data-ck-folder-sort]On the menu: the tree it sorts
[data-ck-folder-new]On the head's plus: the Create Folder dialog it opens, by selector
[data-ck-folder-create-submit]The dialog's Create Folder button
.ck-folder-emptyShown when a search matches nothing
Sizes
PartValue
Panel width300px
Row padding--space-sm
Row gap7px
Indent per level--icon-size + row gap = 23px
Row control24px target, --icon-size glyph
Count20px (.ck-counter)
Top-level folder weightsemibold
Nested weightnormal; semibold when selected
Tokens
TokenLightDarkHigh contrastWhat it is for
--card#ffffff#233a3e#000000the panel's surface
--card-foreground#05262e#ffffff#fffffftext on it
--panel-stroke#c3ccd0#4f666a#99a7abthe panel's edge
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)a row under the pointer
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)the selected row's fill
--selected-fgvar(--primary)var(--primary)var(--primary)its text
--foregroundanything a user must act on belongs there for hierarchy, not here. */ --muted-foreground: #5a7278#ffffff#ffffffa row's label at rest
--muted-foreground—#bcd7dd#e0e0e0chevron, folder glyph, row controls
--input--input is also the kit's disabled text and disabled stroke in ~40 places, and a field edge is a quieter job. A subtle #d0d3d3 at rest (1.51:1 on white — below the 1.4.11 3:1 line by the design owner's decision#5f7073#999999a disabled row's label
--blue-bg#e3f0fe#304b55#001a30the editor's count fill
--blue-soft-foreground#245fe8#92c4fe#92c4feits numeral
--blue-border#b7d6fd#3f5c6e#92c4feits edge
--amber-bg#fef3c7#3d4a3b#2a2000a listing's count fill
--amber-soft-foreground#b45309#fbbf24#fbbf24its numeral
--amber-border#fde68a#595b38#fbbf24its edge
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363ckeyboard focus on a row or control
States
StateWhat Changes
RestTransparent row, --muted-foreground glyphs
Hover--hover-bg; row controls animate in
Focus--focus-ring; controls reveal via :focus-within
Selected--selected-bg / --selected-fg, semibold; the kebab and New Folder stay shown on the selected row
OpenChevron at 90°, open folder glyph, children shown
Selected (editor)Open folder glyph, even on a leaf. Highlight starts at that glyph. Standard keeps the one folder glyph
Disabled--input label, no pointer events
Refreshing.is-spinning on the refresh tool for 900ms
Filtered outhidden
No matches.ck-folder-empty shown
Accessibility

A row is a real <button>, so Enter and Space work with no extra code. Up and down arrows walk the visible rows. aria-expanded on the group carries disclosure state, aria-current="page" carries selection, and every icon-only control has both an aria-label and a tooltip. The panel gets

aria-hidden="true" while collapsed so a screen reader does not read a tree nobody can see.

Events
EventDetail
ck:folder-select{name, level}
ck:folder-toggle{name, open}
ck:folder-check{name, checked, selected} — selected is every checked document
ck:folder-sort{direction} — asc or desc
ck:folder-newnone — the head's plus was pressed; the modal is opening
ck:folder-create{kind: 'folder', name} from the modal, or {kind: 'folder', parent} from a row's New Folder
ck:folder-more{folder, button} — a row's kebab; the page opens its menu against button
Don'ts
  • Don't add tree-* or folder-* rules. Everything is ck- prefixed now, and

the unprefixed names are what caused the trouble described in Notes.

  • Don't hide the count on hover. It is information; the controls move, not it.
  • Don't put a file icon or a chevron lane on a document row.
  • Don't bring back a workspace level, a stacked-folders glyph or a Create Workspace option.
  • Don't put a menu between the head's plus and the Create Folder modal.
  • Don't reorder the row's trailing parts — kebab, New Folder, then the count.
  • Don't offer creation in .is-document.
  • Don't set the indent to a round number. Derive it from the icon and the gap.
  • Don't omit the chevron on a leaf row — use .is-empty.
  • Don't put the collapse toggle inside the panel.
  • Don't reach for !important. The old version needed nine of them; none of

them were the fix.

Icons

All taken verbatim from the references' own sprite, so the kit and the product draw the same glyph: folder, folder-open, chevron-right, sliders (settings), refresh-cw, arrow-up-down (sort), arrow-down-a-z and

arrow-down-z-a (the two sort options), plus, search, x. Two more are canonical Lucide, because the references' sprite does not carry them:

ellipsis (a row's kebab) and folder-plus (a row's New Folder).

Notes

This replaces a version spread across seven disconnected runs of

clipper-kit.css?v=8da91cd1 and built on unprefixed tree-* and folder-* classes that the product pages also owned. The kit and the pages were fighting at equal specificity, which is why it carried nine !important declarations and a set of doubled class selectors, and almost certainly why it looked wrong. The rename to ck-folder-* removes the collision, and with it every !important and every doubled selector.

Relation Tree

kitrelation-tree.md

A canvas of folders joined by arrows — which things feed which. Nodes are drawn as folders rather than icons on a plate, because the tree is a picture of folders and a plate around each one turns it back into a list. .is-source marks the folder the tree is drawn from — one folder differs, which is what makes it findable at a glance. An organism assembled from atoms: the zoom control is .ck-action-bar holding the kit's .ck-slider and an actual-size button — dragging is continuous, because the 25% step belongs to the button, not to the value. Both trees are live. Click a node and the others dim. The zoom bar is pinned to the panel, not to the canvas and not to the plot — anchored to the canvas it would scroll away with the content, anchored to the plot it would scale with the zoom it controls. It holds its corner and its size through every view change. Zoom with it, or hold Ctrl/⌘ and use the wheel; a bare wheel stays a scroll. Past 100% the canvas scrolls rather than clipping. Range 50–300%, continuous on the slider.

Live
Default — 64px foldersamber is the source folder · click to select · zoom pinned top-right
Invoice-PO Linking
100%
Small — 48px folders.size-sm
Linked Set
100%

Form

newform.md

The container the field atoms were always meant to sit in. Three variants, and the only thing that changes between them is the column count — one, two or three. The row rhythm, the label treatment, where the buttons go and what happens when a field fails are identical across all three, which is the whole reason the component exists. All three below are live: press the primary action on an empty form and watch where the errors come from.

Ground rules
  1. One form, one primary action. The action row holds exactly one .ck-btn.primary. A second one asks the reader to rank two things the design already said were equal.
  2. Error messages ship hidden and stay hidden until the logic fails. The text is in the markup from the start at display:none; what reveals it is the control’s own aria-invalid="true". Nothing is judged on load, and nothing is judged on the first keystroke — a form that opens red has told the reader off for doing nothing.
  3. The error clears the instant the value becomes valid. An error that outlives its cause is worse than no error: the reader fixes the field and the form still calls them wrong.
  4. Column count follows the content, not the window. Three columns is for short values only. Anything that holds a sentence takes .span-all.
  5. Columns fold, they never squeeze. Three becomes two under 900px and everything becomes one under 620px. A 90px input is not a narrow field, it is a broken one.
  6. Rows align at the top, never stretch. align-items:start on the grid — otherwise the one field showing an error makes every control beside it taller and the whole row loses its baseline.
  7. Required is marked on the label, optional is marked in words. data-required on .ck-field-label. Do not mark both; pick whichever is rarer in that form and mark only that.
  8. The hint and the error never show together. Two lines of small print under one control, one grey and one red, is noise at the moment the reader most needs a single instruction.
  9. A section is a <fieldset> with a real <legend>. The grouping has to survive with stylesheets off, and a screen reader announces the legend with every field inside it.
  10. Every control in the form comes off the same size step. Mixing a 28px select with a 36px input in one row is the most common form defect in the product.
Tokens and colour chips

Every token this component resolves

TokenValueWhere it lands
--card#ffffffthe panel surface when the form is carded
--card-foreground#05262eevery word on that surface
--panel-border#ebeff0the panel stroke
--foreground#05262ethe form title and every field label
--muted-foreground#5a7278the subtitle, the section legend and the hint line
--border#f5f5f5the rule above the action row
--input#8b9292the resting stroke on every control in the form
--background#ffffffthe field surface
--ring#1c5260the stroke of the focused control
--primary#013c4bthe one primary CTA
--primary-foreground#ffffffits label
--destructive#e50600a field's error line, once that field has actually failed
--destructive-bg#ffeaeathe surface of the form-level summary
--destructive-border#d90500its stroke
--destructive-soft-foreground#d90500its text — the soft foreground, never the solid

Values are the light theme. Each one is defined in all three themes; the component names tokens only, so dark and high contrast follow without a second rule.

Live — the three variants

Variant 1 — one column

Add a Vendor
Used on the invoice matching screen, where the panel is narrow.
Vendor
Enter the vendor’s registered name
Remittance advice goes here.Enter a valid email address
Optional. Shown to whoever approves the first invoice.
Payment Terms

A narrow panel, a drawer, or any form under about 480px. Every field is full width and the eye makes one pass down the page.

Variant 2 — two column

Invoice Details
The default. Two columns is what most Staple forms want.
Header
Enter the invoice number
Enter the invoice date
Leave blank for a non-PO invoice.
Enter the invoice total

The default. Pairs that belong together sit on one line; the description takes .span-all because a sentence in a half-width box wraps four times.

Variant 3 — three column

Document Metadata
Three columns, and only because every field here is short.
Classification
Enter the document ID
Comma separated. 2.4K documents already carry at least one tag.

Short values only — ids, dates, single-word statuses. The action row is .is-split so the destructive action cannot be hit on the way to Save.

Anatomy, properties and states

Anatomy

PartClassNotes
form.ck-formcolumn flow, --space-lg between blocks
carded form.ck-form.is-cardedadds --card, 16px radius, 1px --panel-border
head.ck-form-head.ck-form-title + .ck-form-sub
summary.ck-form-errorhidden until .ck-form.is-invalid
section.ck-form-sectiona <fieldset>; .ck-form-legend is its <legend>
grid.ck-form-grid.cols-1 / .cols-2 / .cols-3 — the three variants
wide field.span-2 / .span-allon a .ck-field inside the grid
action row.ck-form-foot.is-split pushes a destructive action away from Save

When an error is true

StateWhat Is TrueWhat the reader sees
At restnothing has been submittedno red anywhere; every .ck-field-error is display:none
Leaving a filled fieldfocusout and the field has a valuethat one field is judged, the rest are untouched
Leaving an empty fieldfocusout, no value, never submittednothing — tabbing through must not light the form up
Submit failscheckValidity() falsesummary appears, each bad field turns red, the first takes focus
Recoveringthe value becomes validthat field’s error goes immediately
Submit passesevery control validno red, the form submits

Wiring

data-ck-form on the <form> is the whole setup. Validity comes off the native constraint attributes — required, type, minlength, pattern — so there is no second source of truth about what valid means. Override any message with data-error-required, data-error-type, data-error-pattern on the control.

ckForm.validate(form), ckForm.clear(form), ckForm.setError(control, message) and ckForm.clearError(control) are on window for anything the constraints cannot express — a server rejection, a cross-field rule.

Event Timeline

newtimeline.md

A vertical rail with one colour-coded dot per event, each carrying an actor, a change and a timestamp. The audit trail is built on it. Nothing in the kit expressed chronology before — a list of rows says what happened, not the order or the gaps between.

Live

The Audit Trail

  1. CreatedVaibhav·
  2. Linked to PO-4417Unlinked→PO-4417System·
  3. Quantity Amended40→52Mei·
  4. UnlinkedMei·

One dot per event, colour-coded by outcome. The rail stops at the last dot.

Rules, tokens and colour chips

Rules

  1. It is a real <ol>. The Order is the content — with stylesheets off, or to a screen reader, the sequence has to survive. A stack of divs loses it.
  2. The rail is drawn by the item, not the list. A border on the container overshoots past the final dot; a pseudo-element per row ends exactly where the last event does.
  3. The dot carries a --background ring. It sits on the rail, so without the ring it reads as a bead threaded on a line rather than a marker breaking it.
  4. Colour follows the semantic map, not the event name. Success, error, warning, info, neutral — the same five every other component reads (§ 14.3 check 46).
  5. Actor and timestamp are one line. They answer one question; two lines of small print would outweigh the event they describe.
  6. Timestamps are tabular-nums. A column of times that does not align reads as a list of strings rather than a chronology.
  7. A from/to pair puts the weight on the new value. The old one stays --muted-foreground; only the destination takes --foreground.

Tokens

TokenValueWhere it lands
--border#f5f5f5the rail
--muted-foreground#5a7278a neutral dot, the actor and the timestamp
--background#ffffffthe ring that breaks the rail behind a dot
--success#00875aa completed event
--destructive#e50600a reversal
--warning#b95000an amendment
--info#0b6bcban informational event
--foreground#05262ethe event title and the new value in a from/to pair

Card — dock, tour and preview

newsurfaces.md

Three card variants that reconciliation needed. Each is a modifier on .ck-card rather than a new object, because each one is still a card: a bounded surface holding related content. What changes is where it sits and what it holds. The dock is live — type in it and press Enter.

Live

Comments dock — live, type and press Enter

Comments · INV-208713
Mei
Quantity on line 3 does not match the PO.
Vaibhav
Checked with the vendor — they shipped 12 extra.
Mei
Then I will mark it as excess rather than a mismatch.

Shown in flow here so the page can hold it; in the product it is position:fixed at the bottom-right.

Guided Tour

Step 2 of 5
Link a Document
Pick the purchase order this invoice belongs to. Staple suggests the closest match first.

The spotlight is .ck-tour-mask — a full-page mask with the target cut out of it.

Preview Card

INV-20871.pdf
Invoice

The header bleeds to the card edge, which is why the variant moves the padding off .ck-card and onto the body.

Rules, tokens and colour chips

Rules

  1. The dock is a card, not a drawer, dialog or menu. It does not dim the page, does not trap focus and does not close on an outside click — the reader keeps working with it open. That is a card that floats.
  2. It takes --popover, not --card. It floats, and § 4 binds the surface to the elevation. Invisible in light, obvious in dark.
  3. Enter sends; Shift+Enter is a newline. A composer where Enter breaks the line makes the reader hunt for a button on every message.
  4. The thread sticks to the bottom only if the reader is already there. Forcing a scroll on arrival yanks them away from what they were reading.
  5. Anything a person typed is written with textContent. Never innerHTML.
  6. The tour mask is the one sanctioned uncomposited alpha. The cut-out shows the live page, not a knowable surface, so it cannot be pre-composited — the colour is still a token and the alpha still rides --scrim-opacity (§ 2).
  7. The preview card moves padding to the body. A header that bleeds to the edge cannot sit in a padded container; overriding the padding per consumer is how the two drifted apart before.
  8. A document type is not a status. The header tint comes from the palette tones, not from the five semantic ones.

Tokens

TokenValueWhere it lands
--popover#ffffffthe dock and the tour card surface
--popover-foreground#05262eevery word on them
--border#f5f5f5the head and composer rules
--hover-bg#e7f9fethe reader’s own message bubble
--muted#f1f5f6the preview header at rest
--muted-foreground#5a7278timestamps, the step label
--scrim#08272ethe tour mask
--primary#013c4bthe current step dot and the primary action
--input#8b9292the remaining step dots
--info-bg#e7f1fdan invoice preview header

Menu list, split button and split dialog

newmenu.md · button.md · dialog.md

Three variants on components that already existed. None of them needed a new component: a split button is two buttons, a value list is a menu nothing is chosen from, and a two-pane body is a dialog body with a rail in it.

Live

Menu — the value list, .ck-menu.is-list

Apples, Oranges, Bananas, Cherries

Nothing here is chooseable — no hover fill, no pointer. Reveal it only when the chip is genuinely cut.

Split Button — .ck-btn-group

primary
default

Two real .ck-btn sharing an edge — every variant, size and state the button already has comes with it.

Dialog — the two-pane body, .ck-dialog-body.is-split

A 260px rail that does not scroll, beside a pane that does — the rail matches the folder navigator’s own width.

Rules, tokens and colour chips

Rules

  1. A split button is a group, never a component. Two real .ck-btn sharing an edge, so every variant, size and state comes free and nothing is re-declared.
  2. The seam is one border, not two. The trailing half drops its leading border, so the pair reads as one control divided rather than two that happen to touch.
  3. A focused half lifts above its neighbour. Otherwise the ring is clipped by the adjacent border on one side.
  4. The value list is a menu that cannot be chosen from. No hover fill, no pointer, no row focus ring — the rows are values being read, not actions being picked.
  5. Reveal it only when the chip is genuinely truncated. A list repeating what is already legible is noise (§ 14.3 check 33).
  6. The split dialog body owns no padding. Each pane pads itself, or the divider cannot run the full height.
  7. The rail does not scroll with the pane. A rail that scrolls away is a heading that left, and the reader loses where they are.
  8. Both fold to one column under 620px. A 260px rail beside a pane on a phone leaves neither usable.

Tokens

TokenValueWhere it lands
--popover#ffffffthe value list panel
--popover-foreground#05262eits rows
--panel-border#ebeff0its stroke
--primary#013c4bthe split button’s filled half
--primary-foreground#ffffffits label, and the seam between two filled halves
--muted#f1f5f6the dialog rail
--muted-foreground#5a7278the rail’s text
--border#f5f5f5the rail divider and the separators between fulfilment states
--destructive-soft-foreground#d90500a Remaining count
--success-soft-foreground#00875aa Fulfilled count

Table

kittable.md

Sizes are row density, which is what a table's size actually means. The --muted header band with --foreground text is a declared pairing exemption: 15.87:1 where the nominal partner would be 3.16:1.

Live

Search Over The Table — highlights as you type

Type in the search above the table: the keyword is marked in every cell that holds it, on every keystroke. This one also filters (data-ck-search-filter); without it, the rows stay and only the matches are marked.
DocumentVendorAmountStatus
INV-2026-0412Orchard Provisions4,120.00Matched
INV-2026-0413Northwind Trading880.50Partial
INV-2026-0414Orchard Lane Dairy1,904.00Matched
PO-30-58440Bao Sheng Trading12,480.00Failed

Child Rows — tr.is-child

DocumentVendorAmount
INV-20871Northwind Trading18,400.00
PO-876567Royal ApplesJurong → Tuas12,000.00
PO-876567Club ApplesJurong4,000.00
PO-876568Pineapple—2,400.00
INV-20872Cormorant Logistics6,120.00
PO-876570StorageTuas6,120.00

Press a chevron to close a group. The children sit on a --canvas band so the group reads as one block whatever the column widths are, each marked with a turn-down glyph and the reference it came from — a child with no reference is just an unexplained extra row. State lives on the parent’s aria-expanded, which is also what rotates the chevron, so the eye and a screen reader cannot disagree.

InvoiceSupplierStatusAmount
220700734Bao Sheng TradingReconciled12,480.00
220700735Rong Fong ProducePartial3,910.50
220700736Ocean Blue SeafoodFailed870.00
size-smAmount
Dense row1,200.00
loading — .ck-table.is-loading, 10 rows x 8 columns
InvoiceSupplierPODOStatusDueCurrencyAmount

Matrix Variant — .ck-table.is-matrix

Row labels down the left, entities across the top, a mark at each crossing. It differs from a data table in one structural way and the rest follows: a matrix grows past its container because a column per entity has no upper bound, so it is max-content in a scroller with both the header row and the label column pinned. border-collapse goes to separate — a collapsed table drops a sticky cell's borders as it scrolls out.

PermissionOwnerAdminViewer
Billing
View billing
Edit billing
the crossing is a real .ck-check — the atom, not a second checkbox

Sortable columns — three states, and the label names what happens NEXT

hover a header. The glyph is visible whether a column is sorted or not, because a column that can be sorted has to say so whether the cursor is there or not. The cycle is unsorted → ascending → descending → unsorted, and the tooltip offers Sort A to Z, then Sort Z to A, then Clear Sorting — never a description of the state you can already see. One column sorts at a time.It is always the same chevron pair, and the state is which half is lit. Unsorted, both halves sit at --muted-foreground: neither direction is in force and both are one click away. Ascending lights the up half in --primary and fades the down half to --input; descending does the reverse. So the lit half says which direction you are in and the faded half says which one you can go to. The fade is a token change, never opacity — a half-transparent stroke over the --muted header band is a different colour, not a dimmer one.
InvoiceSort a to ZSupplierSort a to ZStatusAmountSort a to Z
220700734Bao Sheng TradingReconciled12,480.00
220700735Rong Fong ProducePartial3,910.50
SupplierSort a to Z
Bao Sheng
unsorted — both halves lit, always visible
SupplierSort a to Z
Bao Sheng
ascending — up lit, down faded
SupplierSort a to Z
Ocean Blue
descending — down lit, up faded

Errored row, a dotted separator, and a row delete

the separator is a dotted --panel-border rule. An errored row sits on the soft destructive triplet, and hovering it deepens the RULE and drops a rail on the leading edge — not the fill, because at 4.58:1 a soft tint has no headroom left to darken without failing AA. The delete only appears on row hover or focus, and its cell holds its width either way so the row never reflows under the cursor.
InvoiceSupplierAmount
220700734Bao Sheng Trading12,480.00Delete
220700736Ocean Blue Seafood870.00Delete
220700737Golden Harvest Ltd2,140.00Delete
rows 1 and 3 are forced to hover so the reveal and the destructive rail are visible here; row 2 is an errored row at rest.

Data Types — .ck-table.is-data

the same table carrying every kind of value the product shows, one per column. Nothing here restyles a component: a badge in a cell is a badge, a dropdown is a dropdown, a switch is a switch. The cell classes handle only what a CELL owns — how the value aligns, and what it does when it is longer than its column. It is wide on purpose, so it sits in a .ck-table-panel and scrolls with its header pinned.
InvoiceSort a to ZOwnerSort a to ZEmailBadge + CounterBadgeCountDropdownEditableQtySort a to ZAmount (SGD)Sort a to ZReceivedSort a to ZProgressRatingNote (wraps)Reference (truncates)AutoActionsCTA
220700734WLWei LinReconciled12Reconciled12
48012,480.00
100%
5.0All three pages matched on the first pass; no manual intervention needed.PO-2024-00266-REV-C-FINALPO-2024-00266-REV-C-FINAL
ViewDuplicate
Delete
220700735PRPriya RamanPartial3Partial3
1183,910.50
62%
3.0Two line items are short-shipped. The supplier has been asked to confirm the balance.PO-2024-00271-AMENDED-2PO-2024-00271-AMENDED-2
ViewDuplicate
Delete
220700736SOSam OkaforFailed1Failed1
24870.00
18%
2.0The scan is unreadable from page two onward, so nothing below the header could be matched.PO-2024-00288-RESCAN-PENDINGPO-2024-00288-RESCAN-PENDING
ViewDuplicate
Delete
Rows Per Page
Page 1 of 9
Rules — references/components/table.md

Table

.ck-table

What It Is

A data grid for reading and selecting rows.

Basic Information

Anatomy
<table class="ck-table size-sm is-sticky">
  <thead><tr><th>Document</th><th>Vendor</th><th class="num">Total</th></tr></thead>
  <tbody>
    <tr aria-selected="true"><td>PO-2024-00266</td><td>Orchard</td><td class="num">1,240.00</td></tr>
  </tbody>
</table>
Sizes — density

Row padding, not a fixed box. A table's size is how many rows fit on screen.

ClassHeader PadCell PadTypeRowReach for it when
.size-sm7px 10px7px 10px12px32pxscanning many rows — a reconciliation list, an audit log. The common case for data-heavy screens
(none)10px 14px11px 14px12px40pxdefault — most tables
.size-lg13px 16px15px 16px12px48pxfew rows, each substantial — a summary of four or five items, or rows with two lines
Modifiers
ClassEffect
.is-stickypins th at --z-sticky for a table scrolling in a fixed-height container
.num on a cellright-aligns and uses font-variant-numeric: tabular-nums
aria-selected="true" / .is-selected on tr--selected-bg + --selected-fg
.is-loadingskeleton rows at the same row height
.ck-table-emptythe no-rows message
Sortable Columns

Three states, cycled in one order: unsorted → ascending → descending → unsorted.

aria-sort carries the state, so the CSS paints from it and a screen reader is told the same thing the glyph shows. Only one column sorts at a time — activating a header clears every sibling back to none.

It is always the same glyph — one chevron pair, an up chevron above a down chevron — and the state is WHICH HALF IS LIT.

aria-sortUp HalfDown HalfReads asTooltip and aria-label
none--muted-foreground--muted-foregroundneither direction is in force; both are one click awaySort A to Z
ascending--primary--inputsorted up; down is availableSort Z to A
descending--input--primarysorted down; up is availableClear Sorting

The lit half says which direction you are in; the faded half says which one you can go to. Swapping in a different glyph per state was wrong twice over: the mark moved under the cursor between states, and a single arrow can only say one of those two things.

The fade is a token change, never opacity. A half-transparent stroke over the --muted header band is a different colour, not a dimmer one, and it would shift again over a selected or errored row.

The sort glyph is always visible. An unsorted sortable column shows it at rest, both halves lit; a sorted column lights the half for its direction. Only the column’s delete control is revealed on hover.

The label names the NEXT action, not the current state. A tooltip that describes what you are already looking at tells you nothing the glyph has not already said. The same string goes on the tooltip and the aria-label, so pointer and keyboard users are offered the identical action.

ckTableSort() owns the state, the label and the announcement, and fires ck:sort with {column, direction}. It sorts nothing itself — sorting real rows means sorting real data, which belongs to whatever owns that data.

Row Separator, Errored Rows, and Row Actions

The separator is a 1px dotted --panel-border rule. It reads --panel-border rather than --border because a dotted rule shows less ink than a solid one of the same colour, and at --border (#f5f5f5, 1.09:1) it disappeared entirely.

An errored row sits on the soft destructive triplet. Hovering it deepens the stroke, not the fill — the rules go solid --destructive and a 2px rail lands on the leading edge. That is forced rather than chosen: § 8's derivation rule requires a hover fill to still clear 4.5:1 against its family foreground, and a soft tint has no headroom left (rest is already 4.58:1 in light, and reusing --destructive-border as a fill measures 3.57:1 in light and 1.00:1 in high contrast, where the text would vanish outright). Text contrast is untouched in all three themes, and the row still answers the cursor destructively instead of reverting to the neutral --hover-bg.

Row actions live in .ck-table-actions inside a td.ck-cell-actions. They appear on row hover and on :focus-within, and the cell holds its width either way so the row never reflows under the cursor.

Data Types — .ck-table.is-data

The same table carrying every kind of value the product shows, one per column.

Nothing here restyles a component. A badge in a cell is a badge, a dropdown is a dropdown, a switch is a switch. These classes handle only the two things a cell owns: how the value aligns, and what it does when it is longer than its column.

Cell ClassCarriesBehaviour
.ck-cell-flag.ck-flag tone rail4px wide, flush to the table's left edge and the row's full height, square ends. Never the only carrier of status
.ck-cell-chev.ck-row-chevexpander; clockwise both ways, same keyframes as the accordion
—.ck-cell-name + .ck-avataravatar then name, name truncates
—.ck-pill + .ck-pill-countbadge with a counter
—.ck-pillbadge without one
.ck-cell-counter.ck-countercentred, shrink-to-fit
—.ck-dd.size-sma dropdown in a cell
.ck-cell-edit.ck-inputreads as text until row hover or focus, then shows its stroke
—.ck-cell-emaila real link: --link, underlined, thickens on hover
.ck-cell-numplain figuresright-aligned, tabular
.ck-cell-amountmoneyright-aligned, tabular, semibold. Currency goes in the header, never per row
.ck-cell-datea dateno wrap, tabular
.ck-cell-progress.ck-progress + a numbera bar alone is not a label
—.ck-ratingfive stars plus the score in text — counting glyphs is not reading a value
.ck-cell-multilong textwraps, clamped to 3 lines
.ck-cell-trunclong texttruncates, full text in a tooltip on hover and focus
.ck-cell-switch.switch.size-smshrink-to-fit
.ck-cell-bar.ck-action-bar.size-smshrink-to-fit; every icon keeps its tooltip
.ck-cell-cta.ck-btn.size-smshrink-to-fit

.ck-cell-multi and .ck-cell-trunc are not interchangeable. Wrap a value the user has to read in full; truncate one that is scanned in a column that has to stay narrow. Clamping at three lines stops one long note making a row twice the height of its neighbours.

The variant is wide by design. Put it in a .ck-table-panel > .ck-table-scroll with .is-sticky on the table, and it scrolls sideways with its header pinned and its pagination on the panel's bottom edge.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--card#ffffff#233a3e#000000the table surface
--card-foreground#05262e#ffffff#ffffffcell text
--muted#f5f5f5#1b292d#1a1a1athe header band
--foreground#05262e#ffffff#ffffffheader text — a declared cross-pair at 15.87:1
--panel-border#ebeff0#43575a#99a7abthe dotted row separator
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)row hover
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)a selected row
--selected-fgvar(--primary)var(--primary)var(--primary)that row's text
--primary#013c4b#e7f9fe#66d9efa sorted column's glyph, and the errored row's hover rail
--muted-foreground#738f96#bcd7dd#e0e0e0an unsorted column's glyph
--destructive-bg#ffeaea#3d3e41#330000an errored row
--destructive-soft-foreground#d90500#fe9b98#ff4444that row's text
--destructive-border#fdc7c7#5a4345#ff4444that row's separator at rest
--destructive#e50600#fe9b98#ff4444that row's separator and rail on hover
--input#8b9292#5f7073#999999a flag with no tone, and the rating's empty stars
--warning#f2a618#f7b83d#ffbb33the rating's filled stars
--group#e0f0ee#18302f#101c1cthe avatar plate
--group-foreground#05262e#ffffff#ffffffavatar initials
--link#2562ee#92c4fe#88ccffan email cell

Special Rules

Rules That Matter
  1. Row height is set, not emergent. --table-row-height is 40px by default, 32px at .size-sm, 48px at .size-lg, and td takes its height from it. .ck-table.is-loading inherits the same property, so a loading row and a data row match by construction rather than by luck — the table can't jump when the real rows arrive.
  2. Match the controls to the density. A .size-sm table's row actions should be .ck-icon-btn.size-xs, and any inline control .size-sm. A 36px control in a 7px-padded row forces the row taller than its class implies.
  3. .num on every figure column. Proportional digits make a column of figures impossible to compare down the column, because the digit widths differ.
  4. aria-selected on the <tr> is what announces selection. The fill is visual only.
  5. For anything editable per cell, this is the wrong component — use a field grid.
  6. Wrap a paginated table in .ck-table-panel. The rows scroll inside .ck-table-scroll and the pagination bar rides the panel's bottom edge, at any row count and while the data is still loading.
  7. A sort label names what will happen, not what has happened. Sort A to Z → Sort Z to A → Clear Sorting. And one column sorts at a time.
  8. The sort glyph never hides. Only the column’s delete control is hover-revealed.
  9. A flag is never the only carrier of a row's status. A flagged row also says why in words, in a badge or a cell. It is flush to the wall and the row's full height — a floating capsule inset from the edge reads as a dot in a margin rather than as the row's own edge, and the dotted separator is dropped on that cell so the rule does not cut across the rail.
  10. A rating states its score in text. Five identical glyphs are not a value a screen reader can read, and counting them is not reading.
Accessibility
  • <th> in <thead> gives column headers for free. Add scope="col" when the table has both row and column headers.
  • A sticky header is position: sticky, not fixed, so it doesn't trap focus.
  • Sortable columns need aria-sort on the th and a real button inside it. The kit provides no sort control yet — that's a gap.
  • Row hover is a mouse affordance. Keyboard row navigation needs aria-activedescendant or focusable rows, neither of which the CSS provides.
Don't
  • Don't leave a scrolling table without .is-sticky — it loses its column labels.
  • Don't render a header with nothing under it. Use .ck-table-empty.
  • Don't use divs with role="table" unless you have a specific reason.
Notes

.ck-table is --card — an elevated in-flow surface, not the page ground.

Cells use text-align: start / end rather than left / right, so they flip in RTL.

The row separator inherits --border's 1.09:1 in light. Tolerable here — it separates rows of labelled content rather than acting as an affordance — but a very long table will read as a wall of text. Row hover is what carries row-tracking, not the separator.

Accordion

v3.2accordion.md

The chevron points down when collapsed and up when expanded — down means “there is more below”. It runs 0→180° opening and 180→360° closing, so it turns clockwise both ways; these are the same two keyframes ClipperDropdown uses, so the two disclosures animate identically. Item strokes are --accordion-stroke and open panels --accordion-bg. An errored item takes the soft-semantic triplet; a disabled one takes tokens, never opacity.

Live

States

Three-way matching compares the invoice against its purchase order and delivery note.
errored: --destructive-bg fill, --destructive-border stroke, --destructive-soft-foreground label and an error glyph. The tone is redundancy — the title still says what went wrong. Disabled uses tokens, so the label keeps its contrast.

Sizes — 36 / 44 / 52 trigger

A 36px trigger.
A 44px trigger.
A 52px trigger.

Header Row — .ck-accordion-head

4 of 4
View billing
Manage plan
2 of 6
a count and a switch beside the trigger — no page CSS

The row owns the padding, the hover and the focus ring; the trigger keeps only its flex share. The count and the switch are siblings of the trigger, never inside it — a control inside a button is invalid and the parser closes the button early, so composing them as siblings makes check 39 hold by construction.

Special case — document accordion (.is-document)

from the Document Editor: a file row that expands into page thumbnails, with actions in the title area and a per-file error state
Page 11
Page 22
Page 33
Page 44
Page 55
Page 66
The row reads left to right: file icon, name, size — then the actions, then the chevron LAST. The chevron is rightmost because it acts on the row as a whole, while an action acts on the file, so it sits inboard of it. The name and size pack together on the left so the eye reads one run of information instead of a name at one edge and its size at the other.The actions are siblings of the trigger, never inside it. A <button> cannot contain other buttons — invalid HTML, and a screen reader announces one control where there are five. They reveal on hover and focus-within, and stay visible on an errored row. The chevron is a sibling too, and it is aria-hidden with tabindex="-1": a pointer affordance only, because the trigger already gives keyboard users the disclosure and two controls in the accessibility tree for one action is noise.

The Four Thumbnail States

click an eye to preview a page — one at a time per grid — and tick a checkbox to select one. Selection and previewing are orthogonal: a page can be ticked and open at once, so they cannot share a colour.
Page 77
unselected — muted counter
Page 88
selected — primary ring, dark counter
Page 99
previewing — 2px amber ring, amber counter
Page 44
unreadable page — destructive
Page 66
hover — the scrim and the eye
an unselected page's number is deliberately quiet — a grid of near-black badges reads as fifteen selected pages. It drops to --muted-foreground on a --muted chip at 3.16:1, under the 4.5:1 text floor: a stated trade, because the number is a positional label being scanned rather than content that must be read, and both the selected and previewing states raise it back to full contrast. Previewing is amber because it is neither selection nor a problem — it is “you are looking at this one”, a third orthogonal state. The ring goes 2px and the badge turns with it, so the state is carried twice: --warning against a white page is only 2.05:1, but the badge numeral is 7.38:1 on that fill.
Rules — references/components/accordion.md

Accordion

.ck-accordion

What It Is

Stacked disclosure — a settings group, a set of rules, a FAQ.

Basic Information

Classes
ClassRole
.ck-accordionthe panel. data-multi allows more than one open
.ck-accordion-itemone row plus its panel
.ck-accordion-triggerthe <button>
.ck-accordion-labeltruncates
.ck-accordion-chevthe chevron
.ck-accordion-panelthe disclosed content; hidden when shut
.is-collapsingapplied by ckAccordion() for the close animation
Sizes

.size-sm 36px · default 44px · .size-lg 52px — trigger height.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--card#ffffff#233a3e#000000container
--card-foreground#05262e#ffffff#ffffffcontainer
--panel-border#ebeff0#43575a#99a7abcontainer
--accordion-stroke#e4e8e9#2c5156#666666item stroke
--accordion-bg#f4f6f7#19292d#1a1a1aopen panel
--radius-2xl16px——radius
States

All six on the trigger. Expanded also takes --primary on the label.

Special Rules

Item States
ClassTreatment
.is-error--destructive-bg fill, --destructive-border stroke, --destructive-soft-foreground label and chevron, --focus-ring-error on focus
.is-disabled--muted fill, --muted-foreground label, pointer-events: none

The error tone is redundancy, never the message. The trigger's own title has to say what went wrong — "Extraction failed — 2 pages unreadable", not a red row labelled "Delivery note".

Disabled uses tokens, not opacity, so the label keeps its contrast and reads as unavailable rather than broken.

Document Accordion — .is-document

A file row that expands into a grid of page thumbnails, with actions in the title area. Generalised from the Document Editor's split list.

<div class="ck-accordion is-document">
  <div class="ck-accordion-item">
    <div class="ck-doc-head">
      <button class="ck-accordion-trigger" aria-expanded="true" aria-controls="p1">
        <input type="checkbox" class="ck-check ck-doc-check" aria-label="Select …">
        <span class="ck-accordion-label">Invoice_220700734.pdf</span>
        <span class="ck-doc-meta">6 pages · 2.4 MB</span>
        <span class="ck-doc-spacer"></span>
      </button>
      <div class="ck-doc-actions">…icon buttons…</div>
      <span class="ck-doc-chev" aria-hidden="true" tabindex="-1">…</span>
    </div>
    <div class="ck-accordion-panel" id="p1">
      <div class="ck-thumb-grid">
        <div class="ck-thumb">
          <img class="ck-thumb-img" alt="Page 1 of Invoice_220700734.pdf">
          <input type="checkbox" class="ck-check ck-thumb-check" aria-label="Select Page 1">
          <span class="ck-thumb-badge">1</span>
          <div class="ck-thumb-overlay">
            <button class="ck-thumb-eye" aria-label="Preview Page 1">…</button>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

1. The row reads left to right: file icon, name, size — then the actions, then the chevron LAST. The chevron is the rightmost thing in the row because it acts on the row as a whole, while an action acts on the file, so an action sits inboard of it. The name and size pack together on the left, via .ck-doc-spacer taking the slack inside the trigger, so the eye reads one run of information rather than a name at one edge and its size at the other.

2. The actions are siblings of the trigger, never children of it. A <button> cannot contain other buttons — it is invalid HTML, and a screen reader announces one control where there are five. So .ck-doc-head is a row: the toggle is a button covering the icon, name and meta, and .ck-doc-actions sits beside it. This is the same restructuring Member Card needs when a card becomes clickable.

The chevron is a sibling too, and it is aria-hidden with tabindex="-1" — a pointer affordance only. The trigger already gives keyboard users the disclosure, and two controls in the accessibility tree for one action is noise, not redundancy.

**3. Actions reveal on hover and :focus-within. They stay in the tab order either way, so hover-only would leave a keyboard user focusing an invisible button. On an errored row they stay visible** — the user needs the re-upload and delete controls without hunting for them.

4. A thumbnail's hover scrim carries the preview control. --scrim at --scrim-opacity, revealed on :hover or :focus-within. The overlay is pointer-events: none with its children re-enabled, so it never swallows a click meant for the checkbox beneath it.

5. The checkbox sits above the scrim (z-index: 2), so selection stays clickable while the preview control is showing. It is a real .ck-check — selection is a form control, not a decoration.

The Four Thumbnail States
StateRingPage Number
unselected1px --input--muted-foreground on a --muted chip
selected1px --primary + focus ring--toolbar-dark-foreground on --toolbar-dark
previewing2px --warning--warning-foreground on --warning
unreadable page1px --destructive--destructive-foreground on --destructive

Selection and previewing are orthogonal, so they cannot share a colour. A page can be ticked and open in the preview pane at the same time. Selection is --primary; previewing is amber, because it is neither selection nor a problem — it is "you are looking at this one", a third independent state.

Previewing carries its state twice: the ring and the badge. --warning against a white page is only 2.05:1, which is a weak boundary on its own — so the ring goes 2px and the badge turns amber with it, and the badge's numeral is 7.38:1 on that fill. ckThumbPreview() allows one previewing page per grid: activating one clears its siblings, because an amber ring means "this is the one" and two of them would mean nothing.

An unselected page's number is deliberately quiet. A grid of near-black badges reads as fifteen selected pages, so the badge only shouts when the page is actually chosen. The muted pair measures 3.16:1, under the 4.5:1 text floor — a stated trade, because the number is a positional label being scanned rather than content that must be read, and both the selected and previewing states raise it back to full contrast. If it has to clear AA, keep the fill and make the numeral --foreground at 14.56:1.

6. The page badge takes the dark-toolbar family when selected. --toolbar-dark + --toolbar-dark-foreground, minted for chrome sitting on content whose colour we do not control. A page image can be any colour, so --muted or --card would sometimes vanish.

6. Thumbnails are fixed 120px tracks, not 1fr. repeat(auto-fill, 120px) — stretching made the page width drift with the viewport, so a portrait page floated between sizes. The 3:4 aspect ratio then fixes the height.

7. Every thumbnail needs real alt text naming the page and its document — "Page 1 of Invoice_220700734.pdf". A grid of images with empty alt is a grid of nothing to a screen reader.

8. .ck-thumb.is-error marks a page that could not be read, with a --destructive border. The file's own row carries .is-error too, so the problem is visible without expanding.

Rules That Matter
  1. An accordion expands in place; Tabs show one of several peers. With data-multi, several sections can be open at once — tabs never can.
  2. The trigger is a real <button> with aria-expanded and aria-controls pointing at its panel.
  3. A shut panel uses hidden, not height: 0. hidden takes the content out of the tab order along with the disclosure; a zero height doesn't.
  4. The chevron points down when collapsed and up when expanded — down means "there is more below". It runs 0→180° opening and 180→360° closing, so it turns clockwise both ways.
  5. Row height is fixed; width follows the parent. 36 / 44 / 52px by size, with the label truncating. A row that grows to fit a long title turns a list of them into a ragged stack, and the chevrons stop lining up.
  6. A page thumbnail shows the page. Use a real <img class="ck-thumb-img"> with a describing alt, never an empty tinted box — a grid of blank rectangles tells the user nothing about which page is which, which is the only reason the grid exists.
  7. The chevron goes last in a document row, and actions sit inboard of it.
Controller

ckAccordion() owns aria-expanded, the hidden toggle, single-vs-multi open, Home/End key handling, and .is-collapsing.

It uses ck-chev-open / ck-chev-close — the same two keyframes ClipperDropdown uses, so the kit's two disclosures animate identically. A single transform would interpolate 180 → 0 on close and read as counter-clockwise.

Under prefers-reduced-motion the duration drops to 1ms rather than being removed — removing it would leave the chevron pointing the wrong way.

Accessibility
  • Home and End move to the first and last trigger.

Reordering by Hand — .is-dragging and .is-placeholder

resting
being dragged
where it will land
resting

Two states, because a drag has two subjects — the thing you are moving and the place it is going — and one class cannot be both. The tile in flight is dimmed, not hidden: the reader needs to see what they are carrying, and removing it collapses the grid under the pointer. The slot is the same dashed language .ck-empty uses for “there is nothing here yet”, because for the length of the drag that is exactly what it is. cursor:grab hangs off [draggable], not off a class, so it is true exactly when the attribute that makes the drag possible is present.

Searchable Dropdown

kitdropdown.md

One component, three forms. Single select is the default; .is-multi is the field form — a trigger showing what is chosen as chips over a checkable list; .is-picker is the panel form, for when choosing is the task. Multi-select used to be a separate organism with its own trigger, option row and focus ring, all drifting from these. Every option row is a <label>, so clicking anywhere on it toggles the checkbox — natively, with no script. It was a <button> wrapping an <input>, which is invalid and meant only the 16px box responded. Clear all sits with the chips, not in the title.

Live
.is-pickerchoosing is the whole task. Clear all sits with the chips it clears
Select Folders
TMC_list_2024CI_TMC_list
.is-chip — single select, the value sits IN the fieldrows are badges: no checkbox, no tick, the kit’s own hover and selection fill. Pick one and it moves into the field as a chip with its own dismiss — the one exception to check 68, because one chip cannot grow the control the way thirty-seven can.
.is-multithe same options as one field in a form
2 selected
TMC_list_2024CI_TMC_list
.is-multi, openclick any part of a row — the label, the gap, the name — to toggle it
Please Multi-Select…
2 selected
TMC_list_2024CI_TMC_list
Rules — references/components/dropdown.md

Searchable Dropdown

.ck-dd

What It Is

A single-select that filters as you type. Use it in place of a native

<select> where the option list is long enough that scanning is slower than typing.

Behaviour lives in clipper-kit.js?v=3ce65335 (ClipperDropdown); this sheet covers the CSS contract. ClipperMenu shares the panel and search styling for action menus.

Basic Information

### Anatomy

```js const dd = ClipperDropdown.create({ value, options, placeholder, searchPlaceholder, ariaLabel, onChange }); ClipperDropdown.upgrade(selectElement); // replaces a native <select>

```

ClassRole
.ck-ddwrapper — sizes go here
.ck-dd-triggerthe closed control; matches .ck-input exactly
.ck-dd-valueselected label; .is-placeholder when nothing is picked
.ck-dd-arrowchevron, rotates when open
.ck-dd-panelthe floating list. .is-portal when moved to <body>
.ck-dd-searchsearch row; .is-loading swaps the glyph for a spinner
.ck-dd-listscroll container, 220px max
.ck-dd-optone option
.ck-dd-noneempty result

### Sizes

Applied to the wrapper:

```html <div class="ck-dd size-sm">…</div>

```

ClassTrigger HeightPaddingTypeReach for it when
.size-sm28px0 9px12pxinline filter bars, a column picker in a table header, a panel toolbar
*(none)*36px0 11px13pxdefault — field grids, dialog forms, settings
.size-lg44px0 13px13pxauth and onboarding, touch-first, a single prominent selector

These are identical to .ck-input by design. A dropdown and a text field in the same grid must line up — before this, the trigger computed to 35px against an input's 35px and a button's 34px. Three controls, three heights.

The Two Multi Forms

Multi-select is not a separate component. It is a .ck-dd whose options are checkable, and it was a separate organism only by accident of history: it had grown its own trigger, its own option row and its own focus ring, all parallel to the dropdown's and already drifting from them.

ClassWhat It Is
.ck-dd.is-multiThe field form — a trigger showing what is chosen as chips, a checkable list, a footer to commit
.ck-dd.is-pickerThe panel form, always open — a title, a search, the chosen set as chips with Clear all beside them, a select-all band over a bordered list, and a footer. For when choosing *is* the task

One parent means the panel, the search row, the empty state and every field state are defined once.

### Rules

  1. The option row is a <label>, never a <button>. It was a button wrapping an <input class="ck-check"> — invalid, because a button may not contain interactive content, and browsers only vary in how they cope. It also meant clicking the row did nothing: the row and its checkbox were two separate targets, and only the 16px box worked. A label makes the whole row the checkbox's target natively — no JS, nothing that can desync from the input's real state.
  1. The .is-multi trigger is a div[role="combobox"], not a <button>. Its chips carry dismiss buttons, and a button may not contain a button — the parser closes the outer one at the first nested <button>, so the trigger ends after the first chip and every chip, glyph and arrow after it spills onto the page below. ARIA 1.2 puts role="combobox" on exactly this kind of element, so the valid markup is also the correct one; ckMultiTrigger() gives it the keyboard a button would have had.
  1. A multi-select list is a checkbox group, not a listbox. "Several of these can be true" is what a checkbox means, so the rows are real checkboxes in a role="group" and each announces as "*name*, checkbox, checked". Reserve role="option" and aria-selected for the single-select list, where exactly one wins.
  1. The row lays the checkbox out and sizes nothing. No width, height or border on .ck-check — the Checkbox atom stays the single definition of what a checkbox looks like. Sizing it in the row is the v2.0 defect the June feedback flagged as a checkbox that "does not appear properly".
  1. Clear all sits with the chips, not in the title. A title says what the panel *is*; an action parked there reads as applying to the whole panel rather than to the chosen set. It also only has anything to do when there are chips, so it lives where they do — a destructive ghost button at the trailing edge of the chips row.
  1. In the picker, the checkbox alone carries selection. A chosen row is not also tinted and bolded: tinting every chosen row in a long list makes it stripey and doubles an indicator that is already unambiguous. The field form keeps the tint, because there the row is the only thing showing state.
  1. The select-all band is tri-state and reads its state off the rows. A count kept alongside them drifts the first time a row is added by anything else.

### Classes

ClassWhat It Is
.ck-dd-opt.has-checkAn option row holding a checkbox
.ck-dd-tagsThe chip run inside a .is-multi trigger
.ck-dd-arrowThe chevron. The class goes on the <svg>; a wrapper form is guarded too
.ck-dd-picker-head / -titleThe panel form's header
.ck-dd-picker-chipsThe chosen set; owns Clear all at its trailing edge
.ck-dd-picker-allThe select-all band. A <label>, so the band toggles
.ck-dd-picker-list / -scrollThe bordered list and its scroller
Tokens
TokenLightDarkHigh ContrastWhat It Is For
--input#8b9292#5f7073#999999trigger border
--background#ffffff#142226#000000trigger fill / text
--foreground#05262e#ffffff#fffffftrigger fill / text
--muted-foreground#738f96#bcd7dd#e0e0e0placeholder
--input-border-hover#4d6b72#9caeb2#82b6c0trigger hover border
--primary#013c4b#e7f9fe#66d9eftrigger focus / open
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363ctrigger focus / open
--destructive#e50600#fe9b98#ff4444trigger invalid
--muted#f5f5f5#1b292d#1a1a1atrigger disabled
--muted-foreground#738f96#bcd7dd#e0e0e0trigger disabled
--muted-foreground#738f96#bcd7dd#e0e0e0arrow
--popover#ffffff#2f5155#000000panel surface
--popover-foreground#05262e#ffffff#ffffffpanel surface
--shadow-panelvar(--shadow-md)0 0 0 1px var(--panel-border), var(--shadow-md)0 0 0 1px var(--border), var(--shadow-md)panel elevation
--radius-2xl16px——panel radius
--border#f5f5f5#233a3e#666666search divider
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)option hover / active
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)option selected
--selected-fgvar(--primary)var(--primary)var(--primary)option selected
--radius-smcalc(var(--radius) - 4px)—var(--radius-sm)option radius
--highlight-bg#d1dcdf#3a494d#12272bmatch highlight (mark)
--primary#013c4b#e7f9fe#66d9eftick
--muted-foreground#738f96#bcd7dd#e0e0e0empty state

Three radius tiers in one component, stepping inward: trigger on the control tier, panel on the panel tier, options on the inner tier.

The panel is --popover, not --background. That distinction is invisible in light theme (both #ffffff) and load-bearing in dark, where --popover is

#2f5155 against a #142226 page — so the panel reads as floating rather than flat. The panel's shadow is --shadow-panel for the same reason: in dark it adds a 1px hairline ring that keeps the edge visible where a drop shadow alone would not be.

### States

TokenLightDarkHigh ContrastWhat It Is For
--input#8b9292#5f7073#999999default
--input-border-hover#4d6b72#9caeb2#82b6c0hover
--primary#013c4b#e7f9fe#66d9effocus / open
--focus-ring0 0 0 3px #c6d4d70 0 0 3px #49585c0 0 0 3px #1a363cfocus / open
--destructive#e50600#fe9b98#ff4444invalid
--focus-ring-error0 0 0 3px #f9c8c70 0 0 3px #473d3f0 0 0 3px #380f0finvalid
--muted#f5f5f5#1b292d#1a1a1adisabled
StateOption
defaulttransparent
hover / .active--hover-bg
.selected--selected-bg, --selected-fg, weight 600, tick visible
pressed--selected-bg + --selected-fg
focus-visibleinset 2px 0 0 --primary on a --hover-bg fill
disabled--input text, out of the tab order

Option focus is an inset rule, not a ring — the same reason as [[menu]]'s: a 3px outer ring is clipped by the list's 4px padding and reads as a smudge along the edge.

Special Rules

### Rules that matter

  1. Sizes go on .ck-dd, not on the trigger. The wrapper is what a .ck-field-grid cell contains.
  2. .selected and .active are different things and must look different. .selected is the committed value; .active is the keyboard cursor. Both can be on screen at once — style them the same and the keyboard cursor disappears.
  3. The panel does not scale with the trigger. Its type, row height and 220px max stay fixed at every size: a .size-sm trigger in a dense filter bar still opens a comfortably readable list. Shrinking the panel would make a dense context *harder* to use, not denser.
  4. Match .ck-input's size in the same grid.
  5. Use ClipperDropdown.upgrade() on an existing <select> rather than hand-building the panel markup.
  6. A dropdown for two options is wrong — use [[radio]]. Past about seven, it's right.
  7. The value truncates. A dropdown is read at a glance, so a long option ends in an ellipsis rather than scrolling or wrapping.
  8. The stroked chevron is the dropdown's one visual addition to the field base — --icon-size, --muted-foreground, and it turns clockwise both ways. Everything else about the trigger comes from [[input]].
  9. The clear cross appears on hover, never at rest. It is a sibling of the trigger, not a child — a button inside a button is invalid markup and the parser lifts it straight back out. It sits 2px inboard of the chevron so the two 24px targets do not overlap, and the value holds a 20px lane open for it so revealing it never nudges the text.

### Behaviour contract

Worth knowing, because it constrains the CSS:

  • Search appears at 7+ options by default (minSearch), or force it with

searchable. Below that, scanning beats typing.

  • The panel is portalled to <body> and positioned fixed when open

(.is-portal). An in-flow panel gets clipped by any ancestor with overflow: hidden and pushes the rest of a form down — which is why the panel must not rely on inherited positioning context.

  • It flips up when there's more room above (.drop-up).
  • Keyboard: type to filter, Up/Down to move .active, Enter to commit, Escape

to close and return focus to the trigger.

  • Async: loadOptions shows .is-loading and a skeleton, and stale responses

are discarded if a newer keystroke has landed.

### Stroke and chevron

The trigger is a variant of the field base, not a lookalike. It sits in the same rule as .ck-input, .ck-select and .ck-textarea, and overrides only five properties — display, align-items, gap, cursor, text-align — because it lays a value and a chevron out as a row and is clickable. Its stroke, height, padding, type and radius therefore cannot drift from a text field's; they are the same declaration. See [[input]] § One base, four variants.

It keys focus on :focus-visible where the input uses :focus; both end up looking the same, because clicking a trigger opens it and .ck-dd.open applies the same --primary border and ring.

The chevron turns clockwise in both directions — 0° → 180° opening, 180° → 360° closing. A two-state transform: rotate(180deg) can't do that: it interpolates 180 → 0 on close, which reads as counter-clockwise. So there are two keyframes, and ClipperDropdown.close() adds .is-closing for the animation's duration:

```css @keyframes ck-chev-open{from{transform:rotate(0deg)}to{transform:rotate(180deg)}} @keyframes ck-chev-close{from{transform:rotate(180deg)}to{transform:rotate(360deg)}}

```

Under prefers-reduced-motion the duration drops to 1ms rather than the animation being removed — removing it would leave the arrow pointing the wrong way.

.ck-select can't do this: its caret is a background gradient on a replaced element. Use .ck-dd where the rotation matters.

### Accessibility

  • The trigger carries aria-haspopup="listbox" and aria-expanded; the panel

is role="listbox" and options are role="option" with aria-selected. All set by clipper-kit.js?v=3ce65335.

  • It needs an accessible name — a .ck-field-label with for, or ariaLabel.
  • The search input gets its own aria-label from searchPlaceholder.
  • Escape closes and returns focus to the trigger, so keyboard focus is never

stranded on a removed panel.

  • The match highlight is a <mark> with color: inherit, so it's a background

tint and never reduces text contrast.

### data-force — documentation only

data-force~="hover|active|focus|disabled" paints a state without the user being in it, so this sheet can render the full set from the kit's own rules instead of re-declaring them in page CSS. It's on .ck-dd-trigger,

.ck-dd-opt and the .ck-input / .ck-select / .ck-textarea family — the last so the stroke-sync contract can be shown rather than asserted.

Never in product markup. A forced state is a lie about what the control is doing.

### Notes

A trap worth knowing: .ck-dd-search's glyph sits at right: 17px, and the spinner that *replaces* it was once pinned at left: 17px. So entering the loading state moved the indicator across the field and dropped it on top of whatever had been typed. Both it and .ck-menu-search now sit where the glyph sat.

Menu

kitmenu.md

An action list anchored to a trigger; it shares its panel treatment with the dropdown. Focus is an inset rule rather than the outer ring, which would be clipped by the panel's 4px padding. Four forms: plain, sectioned, searchable, and with an identity header. A context menu is the same component — ClipperMenu.open(anchor) anchors it at the pointer instead of a button.

Live
sectioned — label, separator, disabled, destructive
item states — rest, hover, pressed, focus, selected
with an identity header
searchable — automatic at 7+ items

Selection menus — .ck-menu.is-select

rows are values, not actions. Four choices compose rather than multiplying into named variants: .is-select makes the rows values, .is-multi gives each one a checkbox, a .ck-menu-search child adds the search row, and a .ck-menu-item-badge child adds a trailing badge. So "multi-select with search and badges" is two classes and two children, not a fifth named form.The role changes with the variant, not just the paint. An action list is role="menu" with menuitem rows and it closes when you pick. A selection menu is role="listbox" with option rows and aria-selected; a multi one adds aria-multiselectable and stays open. Getting that wrong is the defect, not the tick.
single — no search
single — with search
multi — no search
multi — with search and a select-all row

Selection Menus with Badges

the badge sits at the trailing edge and never replaces the label: the label says what the row is, the badge qualifies it. In the single form the tick sits outboard of it.
single + badges
single + badges + search
multi + badges
multi + badges + searchBadges onlythe row is the badge — no label beside it. Used for picking a status, a reconcile outcome, a label. The badge moves to the leading edge because it is the content, not a qualifier. Every other menu rule still applies: hover, selection, focus, keyboard, and a real .ck-check in the multi form.
single — no search
single — with search
multi — no search
multi — with search
.ck-dd.is-picker is the panel form of the same component: it is the panel form — a title, chips and a Cancel/Confirm footer — a decision the user commits, not a dropdown they pick from.
Rules — references/components/menu.md

Menu

.ck-menu

What It Is

A list of actions anchored to a trigger. For choosing a value, use Searchable Dropdown — that distinction decides which one you want.

Behaviour is ClipperMenu in clipper-kit.js?v=3ce65335.

Basic Information

Anatomy
ClipperMenu.open(anchorEl, {
  items: [{ label, icon, onSelect }],
  searchable,            // defaults to true at 7+ items
  searchPlaceholder
});
ClipperMenu.close();
ClassRole
.ck-menuthe floating panel, position: fixed
.ck-menu.has-searchadds the search row, widens to 248px, drops panel padding
.ck-menu-searchsearch row with glyph and spinner
.ck-menu-listscroll container, 260px max when searchable
.ck-menu-itemone action
.ck-menu-sep1px divider
.ck-menu-labelgroup heading, uppercase 10px
.ck-menu-emptyno-matches state
Sizes

Menus size by min-width, not height — the height is however many items there are.

ClassMin-widthReach for it when
.size-sm168pxshort verbs only — "Edit", "Duplicate", "Delete". A row's overflow menu
(none)208pxdefault
.size-lg280pxitems with longer labels, or labels plus a trailing hint
.has-search248pxset automatically by ClipperMenu at 7+ items

.has-search overrides the min-width, so combining it with .size-sm does nothing useful.

Pick Row — .ck-pick

Structurally a menu item that persists rather than firing and closing: a selectable row in a list, used where a menu item would be transient.

<button class="ck-pick" aria-selected="true">
  <span class="ck-folder-glyph">…</span><span>Purchase Orders</span>
</button>
TokenLightDarkHigh ContrastWhat It Is For
--foreground#05262e#ffffff#ffffffdefault
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)hover
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)active
--primary#013c4b#e7f9fe#66d9effocus
--selected-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)selected
--selected-fgvar(--primary)var(--primary)var(--primary)selected
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)selected + hover
--input#8b9292#5f7073#999999disabled

Selection is aria-selected; .is-on survives as an alias.

No sizes: it fills its list's width, and its height comes from its own padding.

Special Rules

Rules That Matter
  1. A menu is for actions; a dropdown is for values.
  2. It's non-modal, so ClipperOverlay must not apply. Trapping focus in a menu would strand the keyboard. Escape closes and returns focus to the anchor, which ClipperMenu handles.
  3. Verb-first labels — "Duplicate folder", not "Duplication".
  4. One destructive item, last, after a separator. Never in the middle.
  5. Let ClipperMenu decide on the search row. Below 7 items scanning beats typing, which is why it doesn't appear by default.
  6. Item icons are decorative — the label carries the meaning. Don't build an icon-only menu item.
The focus ring is deliberately different here
.ck-menu-item:focus-visible{outline:0;background:var(--hover-bg);box-shadow:inset 2px 0 0 var(--primary)}

An inset 2px leading rule, not --focus-ring. **A 3px outer ring on a menu item would be clipped by the panel's own 4px padding and render as a smudge along the panel edge.**

This is the one documented exception to the box-shadow: var(--focus-ring) pattern, and it's worth knowing before someone "fixes" it to match. It's also more robust: the inset rule is --primary at 12:1 against the surface, where --focus-ring currently fails the non-text floor everywhere.

Accessibility
  • ClipperMenu sets role="menu" on the panel and role="menuitem" on items.
  • Arrow keys move the active item, Enter selects, Escape closes. In the searchable variant the arrow keys are bound to the search input, so typing and navigating share one focus.
  • Disabled items use pointer-events: none. An item that must stay focusable to explain why it's unavailable needs aria-disabled="true" instead.
  • Use .ck-menu-label to group 8+ items rather than shipping a flat list of 15.
Known Duplication, Not Yet Fixed

.ck-menu-search input and .ck-dd-search input are byte-identical to each other and share 5 of 8 declarations with .ck-input, differing only in font size, radius and padding. Same problem Search Box had.

Fixing it properly means changing clipper-kit.js?v=3ce65335, which generates both inputs, to emit class="ck-input size-sm" plus a compact modifier. That's a behaviour change in two components at once, so it belongs in a pass that can verify the dropdown and menu controllers together.

Notes

In dark theme the panel border measures 1.00 against the surface, so the panel's edge is carried by --shadow-panel alone there.

Dialog

kitdialog.md

For a decision that blocks. Sizes are widths, not heights. Every text part reads --popover-foreground rather than --foreground — identical today, but it would break the moment the popover surface diverges. Needs ClipperOverlay for focus trap, Escape and focus return.

Live

Six variants, all built from atoms

the only thing added for these was the tone plate — a tinted --radius-md square holding one 16px glyph, in the soft-semantic triplet so it matches the inline alert a body often carries. Everything else is the kit: .ck-alert, .ck-check, .ck-field, .ck-input, .ck-kbd, .ck-btn. The plate is never the only signal — the title and the button still say what will happen in words.
1 — destructive confirm
2 — confirm with a choice
3 — warning, with an escape hatch
4 — result
5 — reference list
6 — form

Notes on two of these

3 — the escape hatch goes to the leading edge. Discard changes is the one irreversible option on that dialog, so it sits away from where a hand lands by reflex, as a link rather than a button. .ck-dialog-foot.is-split does the layout.4 — the green Done is the SOFT success triplet, not a solid green fill. That is measurement, not taste: a solid --success with its white partner is 3.43:1 in light, under AA for a button label and a standing Sev-4. The soft triplet is 4.72 light / 4.54 dark / 10.79 high contrast. It also sits at the right volume — a solid green CTA shouts louder than the primary action it stands in for.variant 2 uses no tone at all: a neutral --muted plate, because confirming a routine action is not a warning and colouring it as one spends a signal you need for the times that are..is-popoverthe same component, anchored to a trigger instead of centred, and non-modal: a transparent click-catcher instead of a tinted scrim, and focus free to leave. Never point ClipperOverlay at one.
Rename Folder
Rules — references/components/dialog.md

Dialog

.ck-dialog

What It Is

A modal that interrupts the flow and requires a decision. For something the user can ignore, use a [[toast]]; for a side surface they can work alongside, use a [[drawer]].

Basic Information

### Anatomy

```html <div class="ck-dialog-scrim" role="presentation"> <div class="ck-dialog" role="dialog" aria-modal="true" aria-labelledby="dt" aria-describedby="ds"> <div class="ck-dialog-head"> <div style="flex:1"> <div class="ck-dialog-title" id="dt">Delete linking rule?</div> <p class="ck-dialog-sub" id="ds">This removes the rule from every document.</p> </div> <button class="ck-icon-btn size-xs is-close" aria-label="Close">…</button> </div> <div class="ck-dialog-body">…</div> <div class="ck-dialog-foot"> <button class="ck-btn">Cancel</button> <button class="ck-btn destructive">Delete rule</button> </div> </div> </div>

```

ClassRole
.ck-dialog-scrimfixed backdrop at --z-overlay, centres the dialog, 24px inset
.ck-dialogthe surface
.ck-dialog-headtitle, optional sub, close button; bottom hairline
.ck-dialog-title600 / 16px
.ck-dialog-sub400 / 12px in --muted-foreground
.ck-dialog-bodycolumn flex, 16px gap
.ck-dialog-footright-aligned actions, 8px gap; top hairline
.ck-dialog-notesecondary body copy

The close button is .ck-icon-btn.size-xs. .ck-dialog-x still works as an alias but is no longer a component.

### Sizes — widths, not heights

A dialog is as tall as its content up to calc(100vh - 48px), so the scale is horizontal. The control height scale doesn't apply here at all.

ClassMax WidthReach for it when
.size-sm380pxa confirmation with one sentence and two buttons — nothing to read, nothing to fill in
*(none)*460pxdefault — a short form, a few fields, a paragraph of explanation. Most dialogs
.size-lg / .wide720pxcontent that needs width: a two-column form, a table, a diff, a preview
.size-full100vw - 48pxa dialog that's really a workspace. Consider a drawer or a page instead

.wide is the original class and stays as an alias for .size-lg.

Special Rules

### Rules that matter

  1. ClipperOverlay.open() every time — never just toggle a class. A modal without a focus trap is worse than no modal, because focus ends up somewhere the user can't see.
  2. Pick the smallest width that fits. A 720px dialog holding one sentence reads as an error; a 380px dialog holding a form makes every field a scroll. Width is the signal for how much the user has to do.
  3. The controls inside keep their own scale. A .size-sm dialog still uses default 36px buttons in its footer — dialog width has nothing to do with control height.
  4. A title states the decision — "Delete linking rule?", not "Are you sure?".
  5. One dialog at a time. A dialog opening a dialog is a design problem.
  6. Never make the destructive action the only button. Cancel sits to its left.
  7. A tone plate is never the only signal. The title still says "Delete 3 invoices?" and the button still says "Delete invoices". A user who cannot separate red from amber must get the same warning from the words.
  8. Not every dialog has a tone. Confirming a routine action takes the neutral --muted plate. Colouring it as a warning spends a signal you need for the times that are.
  9. A low-emphasis escape hatch goes to the leading edge, as a link, not a button — .ck-dialog-foot.is-split. "Discard changes" is the one irreversible option on that dialog, so it sits away from where a hand lands by reflex.
  10. .ck-btn.success is the soft triplet, not a solid green fill. A solid --success with its white partner is 3.43:1 in light — under AA for a label, and a standing Sev-4. The soft triplet is 4.72 / 4.54 / 10.79. Reach for it only to confirm a completed result; a green Save is a primary action wearing the wrong colour.

### Accessibility

The kit's ClipperOverlay provides what CSS cannot. All of it is required, not optional:

  • role="dialog" and aria-modal="true" on .ck-dialog.
  • aria-labelledby pointing at the title, aria-describedby at the sub.
  • Focus trap — Tab cycles within the dialog. Without it, focus walks out

into the page behind the scrim, which is still there but visually unreachable.

  • Focus on open goes to the first interactive element; focus on close

returns to whatever opened it.

  • Escape closes.
  • The page behind gets inert and aria-hidden.

The scrim is role="presentation" — decorative. Clicking it to dismiss is a convention, not a substitute for a visible close control, and should never be the only way out.

Popover — .ck-dialog.is-popover

A small panel holding controls — a quick filter, an inline edit, a confirmation — opened from a trigger rather than over the whole page.

It is the same component, and that is the point: the surface, the stroke, the radius, the title, the body, the footer and the tone plate are all the dialog's. Three things change — where it sits, how wide it is, and whether it blocks the page.

### Which one do you want?

UseWhen
.is-popoverit holds controls and is anchored to a trigger
Plain dialogit blocks until answered
[[menu]]it is a list of actions
[[tooltip]]it is text only, and appears on hover

### The modality is what must not be shared

A dialog is modal. A popover is not. Everything you can see is one component; what you can *do* is two behaviours.

Dialog.is-popover
ARIArole="dialog" aria-modal="true"role="dialog", no aria-modal
Scrimtinted, --scrim at --scrim-opacitytransparent click-catcher
Focustrapped, rest of the page inertfree to leave
ClipperOverlayrequiredmust not be used
Positioncentred by the scrimposition: fixed, anchored by the flow
  1. Never point ClipperOverlay at a popover. Trapping focus in a non-modal panel strands a keyboard user in a box they never asked to enter. Handle Escape and outside-click yourself, and return focus to the trigger on close.
  1. The scrim is transparent, and that is deliberate. It is a full-viewport click-catcher that dismisses on an outside click without dimming the page — which is why a popover feels lighter than a dialog. Never give it --scrim. If the interaction warrants dimming, it warrants a dialog.
  1. The invisible scrim cannot be the only way out. Escape *and* a close button are both required.
  1. Positioning is the flow's job. The panel is position: fixed and the kit sets no coordinates.
  1. Past 400px, use a dialog or a [[drawer]]. A popover is anchored to a trigger, so a wide one runs off the viewport edge on whichever side the trigger happens to sit.
  1. One padded box, not three banded ones. The head, body and footer keep their type and their roles but drop their padding and rules: at this size a line above the footer spends more height on the rule than on the row it separates.

### Sizes — widths

ClassWidth
.size-sm240px
default320px
.size-lg400px

Each caps at calc(100vw - 32px), so an anchored panel cannot run off a narrow viewport.

### Notes

Surface-vs-page measures 1.00 in light and high contrast, because the dialog and the page are the same colour there. That's intentional — the scrim is what separates the dialog from the page, not the surface. Dark theme gets both, at 1.89.

In dark theme the border measures 1.00 against the surface (--panel-border and

--popover are close enough to be identical), so the dialog's edge is carried entirely by --shadow-overlay there. That works, but the border is doing nothing — worth raising with the token set rather than papering over per component.

Drawer

kitdrawer.md

One parent, two variants. Filters and Manage columns are not separate components — they are the same drawer wearing two bodies. Same scrim, same panel, same head, same footer, same focus trap; only .ck-drawer-body differs. They used to be two organisms, and the filter drawer had grown its own head, title and footer that had already drifted from the parent’s. Both bodies are assembled from atoms — .ck-dd, .ck-input, .ck-search, .ck-btn, .ck-chip, .ck-check, .ck-pill — and nothing here restyles one. Both are live: type a value and press Enter, remove a rule, drag a column or hold Alt and press an arrow key.

Live
.is-activitylive progress across the folders — from the document editor, composed of the kit’s drawer head, search, scroll-x tabs, small cards and progress bars. Filter by status (All, In Progress, Uploaded, Scanned, Failed), search a name, a folder or an ID, and drag the left edge to resize it between 540 — every tab visible at once — and 720.
.is-filtersa stack of rule cards, each one a sentence: field, operator, then the values that answer it
.is-columnsa reorderable checklist — show, rename, reorder, and where the column comes from
Rules — references/components/drawer.md

Drawer

A side panel for work the user does alongside the page.

Core Behaviour

The drawer slides in from the trailing edge over a scrim, traps focus, closes on Escape, and returns focus to whatever opened it. For a decision that blocks, use a dialog; for a small anchored panel, a popover.

The Two Variants
ClassWhat It Is
.ck-drawer.is-filtersFilter rules for the current table
.ck-drawer.is-columnsWhich columns show, what they are called, and in what order

These are variants, not components. Same scrim, same panel, same head, same footer, same focus trap — only .ck-drawer-body differs. They were two separate organisms once, and the filter drawer had grown its own head, title, description and footer that duplicated the parent's and had already drifted from them.

Rules That Matter
  1. The chrome belongs to the parent, always. A variant styles what goes inside .ck-drawer-body and nothing else. If a variant needs its own title or its own footer, the parent is wrong and should be fixed — a second copy is how the two drifted apart last time.
  1. Every part is an existing atom. .ck-dd for the dropdowns, .ck-input, .ck-search, .ck-btn, .ck-chip, .ck-check, .ck-pill, .ck-icon-btn. The variant rules place atoms; they never restyle one. The two shipped drawers this was rebuilt from had three different search inputs and two checkbox sizes between them.
  1. A filter rule is a sentence. Field, then operator, then the values that answer it. The field reads as prose so its trigger drops its box and keeps only the chevron; the operator keeps a surface because it changes what the rule means.
  1. An operator that cannot vary is stated in words, using .ck-filter-rule-op-static — never a dropdown that opens to one option.
  1. Removing the last filter rule leaves a blank one. An empty filter drawer with nothing to click is a dead end.
  1. Clear all filters is a link button in the destructive tone, not a filled red button. It is destructive, but it is not the drawer's main action and must not compete with Apply.
  1. The value ceiling is enforced at entry and the hint turns destructive when it is reached. A keystroke that is silently dropped is worse than a warning.
  1. Add filter sits under the last rule at the trailing edge. It belongs to the stack, not to the footer, where it would muddy the two footer actions.
  1. The columns master checkbox is genuinely tri-state, using the browser's own indeterminate property, and it reads its state off the rows. A count kept alongside them drifts the first time a row is added by anything else.
  1. Filtering the column list never changes what is on. Hiding a row is not deselecting it; treat it as such and the user silently loses their choices.
  1. Reordering works by keyboard as well as by drag. The grip is a real button and Alt with the arrow keys moves a row. Drag-and-drop alone is not an accessible way to state an order.
  1. Renaming happens in place, swapping the row's content rather than overlaying it, so the row keeps its height and the list does not jump. Enter commits, Escape abandons.
  1. A column that is off stays listed. It has to be, or you could not turn it back on — so it steps down in weight and colour instead of disappearing.
Classes
ClassWhat It Is
.ck-drawer-scrimThe backdrop. Tint is on ::before, never the element
.ck-drawerThe panel. .size-sm .size-lg .size-full, .from-start
.ck-drawer-headTitle, subtitle, head actions
.ck-drawer-title / .ck-drawer-sub20px semibold / 13px muted
.ck-drawer-toolsA fixed row under the head that does not scroll
.ck-drawer-bodyThe scrolling middle — the only part a variant changes
.ck-drawer-footTrailing-aligned actions
.ck-filter-ruleOne rule card on --accent
.ck-filter-rule-headField, operator, remove
.ck-filter-rule-field / -op / -op-staticThe two dropdowns, or the fixed operator
.ck-filter-rule-removeThe hollow destructive ring; 24×24 target via ::before
.ck-filter-rule-rowThe value control, spanning the card
.ck-filter-hintn/max values entered; .is-full at the ceiling
.ck-filter-rule-chipsThe entered values
.ck-filter-add-rowTrailing-aligned Add filter
.ck-col-toolsSearch plus Reset
.ck-col-segSource chips, single-choice via aria-pressed
.ck-col-masterSelect-all, tri-state
.ck-col-list / .ck-col-cardThe rows
.ck-col-gripDrag handle and keyboard reorder button
.ck-col-name / .ck-col-keyLabel and field key; .is-link for a URL
.ck-col-editThe in-place rename, shown by .is-editing
.drop-before / .drop-afterThe 3px drop indicator
Sizes
PartValue
Drawer420px default · 320 sm · 560 lg · 880 full
.is-filters480px
.is-columns520px
Rule card--radius-2xl, 12px 16px
Operator pill28px
Column card--radius-2xl, 12px 16px
Remove ring16px glyph box, 24px target
Drop indicator3px
Tokens
TokenLightDarkHigh ContrastWhat It Is For
--popover#ffffff#2f5155#000000the drawer's surface
--popover-foreground#05262e#ffffff#fffffftext on it
--scrim#08272e#000000#000000the backdrop
--panel-border#ebeff0#43575a#99a7abthe drawer's leading edge
--border#f5f5f5#233a3e#666666the rules under the head and above the footer
--accent#e7f9fe#233a3e#111111a filter rule card's surface
--accent-foreground#05262e#ffffff#ffffffthe field name on it
--segment#eef1f3#1b292d#1a1a1athe operator pill
--segment-foreground#3f5359#bcd7dd#e0e0e0its label
--muted-foreground#738f96#bcd7dd#e0e0e0subtitles, keys, hints, the grip
--destructive#e50600#fe9b98#ff4444clear-all, and the remove ring
--destructive-bg#ffeaea#3d3e41#330000an invalid rule's surface
--destructive-soft-foreground#d90500#fe9b98#ff4444its text
--card#ffffff#233a3e#000000a column row's surface
--panel-stroke#c3ccd0#4f666a#99a7ab-
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)a column row under the pointer
--input#8b9292#5f7073#999999a column row's edge on hover, and the grip at rest
--primary#013c4b#e7f9fe#66d9efthe drop indicator
--link#2562ee#92c4fe#88ccffa column key that is a URL
--ring#1c5260#e7f9fe#66d9effocus
States
StateWhat Changes
Rule at rest--accent card, no stroke
Rule invalid--destructive-bg / -soft-foreground / -border, plus words
Remove hoverRing fills --destructive
Hint at ceiling.is-full → --destructive
Column row hover--hover-bg, edge to --input
Column off.is-off → name drops to --muted-foreground, medium
Column renaming.is-editing swaps the row's content
Dragging.is-dragging at --disabled-opacity; one drop bar on screen
Column list empty.ck-col-empty
Accessibility

The panel is role="dialog" aria-modal="true" labelled by its title, and

ClipperOverlay supplies the focus trap, Escape, focus return and inert on the rest of the page. Source chips are toggle buttons in a labelled group with

aria-pressed, so exactly one is pressed at a time. The master checkbox uses the real indeterminate property, which screen readers announce as mixed. Each grip names its row and says how to move it. Every icon-only control has an

aria-label and a tooltip.

Events
EventDetail
ck:filters-change{rules} — each {field, operator, values}
ck:filters-apply{rules}
ck:columns-change{visible, order}
ck:columns-reorder{order}
Don'ts
  • Don't give a variant its own head, title or footer.
  • Don't restyle an atom from inside a variant. Place it; if it is wrong, fix

the atom.

  • Don't let filtering the column list change what is selected.
  • Don't ship drag-only reordering.
  • Don't let Clear all filters empty the drawer to nothing.
  • Don't put the tint on .ck-drawer-scrim itself — nest the panel once and

group opacity fades the panel with no z-index able to rescue it.

Notes

Rebuilt from the shipped reconciliation drawers. That source defined the same rules in three different style blocks with conflicting values — header padding at 16px, 18px 20px 12px and 20px 24px 16px, the title at 20px, 16px and 19px — so the images were taken as ground truth for appearance and the kit's scale for everything else.

File Upload

v3.2file-upload.md

The dashed --input outline is .ck-empty's treatment, for the same reason (§ 7d): an outline says "nothing yet", a filled shimmer says "it is coming". On drag-over it commits to --accent with a solid --primary edge. Rows reuse .ck-progress and a 24px .ck-icon-btn.size-xs remove.

Live
Drop documents herePDF, PNG or JPG · up to 25 MB each
Release to upload.is-dragover
PO-30-58440.pdf1.2 MB · uploaded
Bao Sheng invoice.pdf
scan-2026-09-08.tiffUnsupported format
Rules — references/components/file-upload.md

File Upload

.ck-dropzone

What It Is

A drop target, then a list of what was dropped.

Basic Information

Classes
ClassRole
.ck-dropzonethe target
.is-dragovera file is over it
.ck-dropzone-icon / -title / -hintthe invitation
.ck-file-listthe results
.ck-fileone row
.ck-file.is-done / .is-erroroutcome
.ck-file-icon / -main / -name / -metathe row
Sizes

.size-sm · default · .size-lg — padding.

Tokens

SlotTokens
outline1px dashed --input — an outline says "nothing yet"; a filled shimmer says "it's coming"
dragover--accent + --accent-foreground, solid --primary edge
row--card + --card-foreground + --panel-border
error row--destructive-bg + --destructive-soft-foreground + --destructive-border
radius--radius-2xl on the target, --radius-md on rows
States

hover · dragover · invalid · focus-visible · disabled. The target is focusable so a keyboard user can open the file picker.

Special Rules

Rules That Matter
  1. The target wraps a real <input type="file">. Drag-and-drop is an enhancement, never the only route in.
  2. **The dragover state changes the border style, not just its colour** — dashed to solid — so it isn't a colour-only signal.
  3. A dropzone is not an Empty State, even though it borrows the dashed outline. .ck-empty says there's nothing here; a dropzone invites an action.
  4. A finished or failed upload has to be announced, not just redrawn.
Accessibility
  • tabindex="0" and an Enter/Space handler if the target itself is the control.
  • Transfer progress uses .ck-progress with role="progressbar".
  • The remove control is .ck-icon-btn.tone-danger.size-xs — 24px, which meets the target-size floor.

Document Fields Panel

v3.2doc-fields-panel.md

The only component entitled to --dfp-*, which is a confidence scale rather than the semantic state family — which is why Fulfilment status does not borrow it. Tools reveal on hover AND focus-within (§ 14.4).

Live
Extracted Fields4
Header
Invoice Number220700734
Purchase OrderPO-30-58440
SupplierBao Sheng Trading
TotalSGD 12,480.00
Rules — references/components/doc-fields-panel.md

Document Fields Panel

.ck-dfp

What It Is

Extracted fields shown beside the document they came from: a confidence dot, the label, the value, and per-row tools.

Basic Information

Classes
ClassRole
.ck-dfpthe panel
.ck-dfp-head / -titlethe header
.ck-dfp-bodyscrolls
.ck-dfp-group / -group-labela field group
.ck-dfp-rowone field
.is-ok / .is-confirmed / .is-matched / .is-warnconfidence
.ck-dfp-dotthe confidence dot
.ck-dfp-label / -valuethe pair
.ck-dfp-value.is-empty / .is-monono value / an identifier
.ck-dfp-toolsrevealed on hover and focus-within
.ck-dfp-multia multi-value field, one chip per value

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--dfp-ok#99b7bf#99b7bf#5eead4extracted, nothing to check
--dfp-confirmed#3d707c#66a3b0#66d9efa person has confirmed it
--dfp-matched#5ea3fc#7ab8ff#7dd3fcmatched against another document
--dfp-warn#f19546#f5a95e#fbbf24needs a look

The panel is --card + --card-foreground + --panel-border, with --radius-2xl on the panel and --radius-sm on rows.

States

Rows take hover and selected.

Special Rules

Rules That Matter
  1. The confidence dot is redundancy, never the only signal. Every row also carries its state in text or in its tooltip.
  2. **Tools reveal on :hover and :focus-within.** Hover alone is invisible to a keyboard.
  3. **This is the only component entitled to the --dfp-* tokens.** They're a confidence scale, not the semantic state family — which is why Fulfilment status maps onto success/info/warning/error instead of borrowing these.
  4. It's a form-like list, not a Table. Each row's value can be edited in place.
Accessibility
  • A selected row takes aria-selected, not just a class.
  • Tools are real buttons with aria-labels.
Notes

The dot is --space-sm (8px).

Notification Panel

v3.2notification-panel.md

--popover, because it floats out of flow (§ 4) — the v2.0 panel read --background, which is invisible in light theme and wrong in dark. Unread is a dot AND the row fill, so it survives a colour-blind reading.

Live
Notifications2

Error — the panel could not load

Notifications
Could not load notifications
Check your connection and try again.
It composes .ck-error-state rather than inventing its own, so a failure here looks like a failure anywhere else. It sits in the list, so the header and the footer stay put and Retry lands where the reader is already looking.

Error — one notification that failed

A single notification can fail on its own — an action that did not go through. The row keeps its shape and takes the destructive tint.
Rules — references/components/notification-panel.md

Notification Panel

.ck-notif-panel

What It Is

An anchored list of what happened, with unread state and a mark-all action.

Basic Information

Classes
ClassRole
.ck-notif-panelthe panel
.ck-notif-head / -titlethe header
.ck-notif-listscrolls
.ck-notifone row
.is-unreadunread
.ck-notif-dotthe unread dot
.ck-notif-text / -timethe row
.ck-notif-actionsinline actions
.ck-notif-footmark all as read
Sizes

.size-sm 320px · default 380px · .size-lg 440px — widths.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--popover#ffffff#2f5155#000000panel
--popover-foreground#05262e#ffffff#ffffffpanel
--panel-border#ebeff0#43575a#99a7abpanel
--shadow-panelvar(--shadow-md)0 0 0 1px var(--panel-border), var(--shadow-md)0 0 0 1px var(--border), var(--shadow-md)panel
--info#387ff9#92c4fe#44aaffunread
--hover-bgvar(--nav-item-bg-active)var(--nav-item-bg-active)var(--nav-item-bg-active)unread
--radius-2xl16px——radius
--radius-smcalc(var(--radius) - 4px)—var(--radius-sm)radius
States

All six, on the row.

Special Rules

Rules That Matter
  1. This is a durable log, not a Toast Notifications stack. A toast is transient and self-dismissing; the user opens this deliberately.
  2. It's non-modal — do not use ClipperOverlay. No focus trap.
  3. **Unread is a dot and a row fill,** so it survives a colour-blind reading — and it's announced, not only drawn.
  4. The panel is --popover, not --background. The v2.0 panel used --background, which is invisible in light theme (all three surfaces are #ffffff) and wrong in dark.
  5. Empty and loading are both required, not optional: .ck-empty with an action, and .ck-skeleton-row.
Accessibility
  • New arrivals need a live region, or the count changes silently.
  • Unread needs an aria-label on the row or visually-hidden text.
Notes

max-height leaves --space-8xl of viewport clear.

In dark theme --panel-border on --popover measures 1.00:1, so the panel's edge is carried by --shadow-panel there.

Auth Card

v3.2auth-card.md

A 400px --card panel; everything inside is existing kit — .ck-field, .ck-input, .ck-btn.primary.size-lg (§ 7a's stated case for lg), .ck-btn.link, .ck-divider-label, .ck-alert for a failed attempt.

Live
Staple
Sign in
Use your work account to continue.
That password did not match.
or
No account?
Rules — references/components/auth-card.md

Auth Card

.ck-auth

What It Is

Sign-in, sign-up, reset — a card centred on the page ground.

Basic Information

Classes
ClassRole
.ck-auth-shellthe centring ground
.ck-auththe card
.ck-auth-brandlogo + wordmark
.ck-auth-headtitle + sub
.ck-auth-title600 / 20px
.ck-auth-sub400 / 12px, muted
.ck-auth-formthe field stack
.ck-auth-rowa split row
.ck-auth-actionsthe action stack
.ck-auth-footthe secondary route
Sizes

.size-sm 340px · default 400px · .size-lg 480px — widths. Height is content-driven.

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--background#ffffff#142226#000000ground
--foreground#05262e#ffffff#ffffffground
--card#ffffff#233a3e#000000card
--card-foreground#05262e#ffffff#ffffffcard
--panel-border#ebeff0#43575a#99a7abcard
--radius-2xl16px——radius
States

None of its own. Everything inside is existing kit and carries its own.

Special Rules

Rules That Matter
  1. It's the whole page, not a Dialog. No scrim, no focus trap, no close button.
  2. One <h1> — this is the page's only heading.
  3. A failed attempt is a .ck-alert.is-error with role="alert", placed before the fields so it's read first.
  4. Never disable the submit button as validation feedback. Say what's wrong.
  5. It's a real <form>, so Enter submits.
Notes

The default width is 400px — a login form wider than that starts to read as a settings page.

The primary action is .ck-btn.primary.size-lg. This is the stated case for the large size: a touch-first surface whose action is the only one on screen.

Connector Card

v3.2connector-card.md

The mark sits on --secondary so a white vendor logo still has a plate under it in dark theme. State is a pill tone plus the button label, never the tone alone.

Live
Xero
Accounting
Push reconciled invoices straight into Xero.
Connected
SFTP
File transfer
Watch a folder and pull documents as they land.
Needs Attention
NetSuite
ERP
Sync purchase orders and delivery notes.
Not Connected
Rules — references/components/connector-card.md

Connector Card

.ck-connector

What It Is

One integration: its mark, its name, what it does, whether it's connected, and the action that changes that.

Basic Information

Classes
ClassRole
.ck-connector-gridauto-fill, 280px minimum
.ck-connectorthe card
.is-connecteda --success-border edge
.ck-connector-markthe vendor plate
.ck-connector-name / -meta / -descthe copy
.ck-connector-footpill + action

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--card#ffffff#233a3e#000000card
--card-foreground#05262e#ffffff#ffffffcard
--panel-border#ebeff0#43575a#99a7abcard
--secondary#f5f5f5#1b292d#1a1a1amark plate
--secondary-foreground#292f32#ffffff#ffffffmark plate
--success-border#bce5cd#3e655c#44ff88connected edge
--radius-2xl16px——radius
--radius-mdcalc(var(--radius) - 2px)—var(--radius-md)radius
States

Hover lifts the border to --input. That's the whole of it — the card isn't a control.

Special Rules

Rules That Matter
  1. The card is not a button. Put the action on the button inside it.
  2. **State is a pill tone plus the button's label** — never the tone on its own.
  3. The vendor plate is --secondary, not --background. A white vendor logo needs something under it in dark theme.
Accessibility
  • A logo <img> needs alt. A decorative plate glyph takes aria-hidden.
Notes

Grid minimum is 280px; the mark plate is --space-3xl (40px).

Feature / Support Card

v3.2feature-card.md

The icon plate reads --accent so the card has a focal point without introducing a second surface level. Support level is a pill tone with its word in the label.

Live
Three-way matchingFull
Invoice, purchase order and delivery note compared line by line.
Folder rulesBeta
Route documents by supplier, amount or folder path.
ApprovalsPlanned
Multi-step sign-off before an invoice is posted.
Rules — references/components/feature-card.md

Feature / Support Card

.ck-feature

What It Is

A capability and how well it's supported.

Basic Information

Classes
ClassRole
.ck-feature-gridauto-fill, 240px minimum
.ck-featurethe card
.ck-feature-iconthe accent plate
.ck-feature-titletitle + support pill
.ck-feature-descthe copy
.ck-feature-listsub-points

Tokens

TokenLightDarkHigh ContrastWhat It Is For
--card#ffffff#233a3e#000000card
--card-foreground#05262e#ffffff#ffffffcard
--panel-border#ebeff0#43575a#99a7abcard
--accent#e7f9fe#233a3e#111111icon plate
--accent-foreground#05262e#ffffff#fffffficon plate
--radius-2xl16px——radius
--radius-mdcalc(var(--radius) - 2px)—var(--radius-md)radius
States

None — it isn't interactive.

Special Rules

Rules That Matter
  1. It has no state and no action. If there's something to connect, disconnect, or configure, that's a Connector Card.
  2. The support level is a word in the pill, not a colour. Tone alone never carries meaning.
  3. The icon plate uses --accent, which gives the card a focal point without introducing a second surface level.
Accessibility
  • The plate glyph is decorative: aria-hidden="true".
Notes

Grid minimum is 240px; the plate is --space-2xl (32px).