# Swivel Design System — adherence rules
#
# Machine-readable usage rules for reviewers, linters, and code-generating
# agents. These are the SEMANTIC rules a syntax linter cannot express:
# _adherence.oxlintrc.json (compiler-generated) covers token literals and
# prop-name checks; everything below covers whether a choice is CORRECT.
#
# Each rule: id, severity, applies_to, detect (how to find a violation),
# rule (what must hold), fix (what to do instead), why.
# Severities: error (never ship), warn (needs a reason), info (preference).

version: 1
canonical_sources:
  components: design-system/components.json
  patterns: design-system/patterns.json
  templates: design-system/templates.json
  # Chart rules live in their own registry with a different schema — axis
  # formatting, per-chart-type palettes, and chart selection. The rules here
  # cover only which chart COMPONENT to reach for; read both when reviewing a
  # data-viz surface.
  charts: design-system/charts.rules.yaml
  icons: design-system/icons.json
  voice: voice.yaml
  tokens: design-system/tokens.json
  copy: voice.yaml

rules:
  # ── Control semantics ────────────────────────────────────────────
  - id: button-group-is-not-an-action
    severity: error
    applies_to: [ButtonGroup, .btn-group, .bg-btn]
    detect: A segment whose label or handler saves, runs, deletes, exports, or opens something.
    rule: Button groups express filtering and segmented selection only.
    fix: Use Button, IconButton, or SplitButton for actions; keep the group for state selection.
    why: A segmented control reads as "which subset am I viewing", so an action inside it looks like a filter that silently mutates data.

  - id: no-radio-buttons
    severity: error
    applies_to: ['input[type=radio]']
    detect: A radio input, or a custom control imitating one.
    rule: The system has no radio button.
    fix: Use a ButtonGroup for 2–5 mutually exclusive options, or a SelectField above that.
    why: One selection idiom, one visual weight.

  - id: theme-toggle-is-a-switch
    severity: error
    applies_to: [ThemeSwitch, .theme-switch]
    detect: Light/dark offered as a ButtonGroup, two buttons, or a labelled Switch.
    rule: Light/dark is the canonical .theme-switch — sun, track, moon, no text label. aria-checked="true" means dark.
    fix: Render ThemeSwitch. Any other binary setting uses Switch.
    why: One control, one state source. State lives in aria-checked; never mirror it in a class.

  - id: theme-switch-must-work
    severity: error
    applies_to: [any page, any template]
    detect: A theme control with no handler, or a page that never writes data-theme.
    rule: Every artifact ships a working light/dark switch, applied before first paint and persisted to localStorage under swivel-theme. Dark is the default.
    fix: Wire the switch; set data-theme on the document root (or the template's own root inside a DC).
    why: A decorative toggle is worse than none.

  # ── Labelling and accessibility ──────────────────────────────────
  - id: icon-only-needs-label
    severity: error
    applies_to: [IconButton, .btn-icon, .bg-btn.icon-only, .tt-btn]
    detect: An icon-only control with no tooltip prop, no data-tip, and no aria-label.
    rule: Every icon-only control carries a tooltip AND an accessible name.
    fix: Pass `tooltip` on IconButton; on raw markup set both data-tip and aria-label to the same words.
    why: An unlabelled glyph is a guess for sighted users and invisible to screen readers.

  - id: status-icon-needs-text
    severity: error
    applies_to: [.cell-state, status glyphs]
    detect: A status rendered as a bare icon with no data-tip / aria-label.
    rule: An icon-only status carries the status word on hover (data-tip) and as aria-label.
    fix: Add both; keep the tone class (.is-success / .is-warning / .is-error / .is-neutral).
    why: Color plus shape alone fails colorblind users and assistive tech.

  - id: focus-ring-never-removed
    severity: error
    applies_to: [any interactive element]
    detect: outline:none or box-shadow:none on :focus / :focus-visible with no replacement ring.
    rule: Interactive elements show --focus-ring on :focus-visible.
    fix: Keep the ring, or move it to a child (e.g. .theme-switch rings its track).
    why: Keyboard operation depends on it.

  # ── Fields ───────────────────────────────────────────────────────
  - id: narrow-fields-use-modifier
    severity: warn
    applies_to: [TextField, SelectField, TagInput, .field, .field-inset]
    detect: A field wrapper narrower than 240px without an ancestor carrying .field-narrow.
    rule: Fields floor at min-width 240px. Anything narrower must sit in a .field-narrow host.
    fix: Add class="field-narrow" to the wrapper, or widen it to 240px.
    why: Without the modifier the field overflows its wrapper and paints over its neighbour.

  - id: multi-select-shows-chips
    severity: warn
    applies_to: [SelectField, TagInput]
    detect: A multi-select trigger rendering "N selected".
    rule: Multi-selects show their values as dismissible chips.
    fix: Pass `chips`.
    why: The user has to be able to see and remove individual values without opening the menu.

  - id: help-text-not-tooltip
    severity: warn
    applies_to: [fields, forms]
    detect: Field guidance living only in a tooltip.
    rule: Persistent field guidance sits under the field as help text.
    fix: Use the help / helpText prop; reserve tooltips for icon-only controls.
    why: Guidance a user must hover to find is guidance most users never read.

  - id: required-marker
    severity: warn
    applies_to: [fields, forms]
    detect: A required field with no red marker, or an "(optional)" suffix used instead.
    rule: Required fields carry the red marker via the `required` prop.
    fix: Pass `required`.
    why: The marker is how the user knows what will block a save; "(optional)" makes the reader diff two lists to work it out.

  # ── Tables ───────────────────────────────────────────────────────
  - id: table-needs-card-and-toolbar
    severity: error
    applies_to: [Table, .table]
    detect: A <table> not wrapped in .table-card, or a .table-card with no .table-toolbar.
    rule: A table is always .table-card + a variant class, wrapping .table-toolbar, the table, and .table-pagination.
    fix: Use the data-table-page template as the starting point.
    why: Sort, columns, filters, density, and reset are expected on every list.
    exception: A sub-table inside tr.subtable-row (Table.expandable_rows) has no toolbar or pagination. It is a peek at one row's children, governed by the parent table's toolbar, and capped at 10 rows.

  - id: subtable-opens-from-count
    severity: error
    applies_to: [Table, .subtable, .count-btn]
    detect: A tr.subtable-row opened by anything other than a .count-btn in the same row (a chevron column, a whole-row click, a trailing expand icon), or a .count-btn that opens a Drawer or Modal to list children.
    rule: Child records behind a count open in place as a sub-table beneath the row, triggered by that count's .count-btn. The button carries aria-expanded and aria-controls; the row takes .is-expanded while open.
    fix: Render the count as <button class="count-btn" aria-expanded aria-controls> and insert tr.subtable-row > td[colspan] > .subtable directly after the row. See Table.expandable_rows.
    why: The count is both the number and the way in. One standard trigger means operators never hunt for how to drill in, and the children stay next to the parent they belong to.

  - id: subtable-one-level-capped
    severity: warn
    applies_to: [.subtable]
    detect: A .count-btn inside a .subtable, more than one tr.subtable-row under the same parent row, or a sub-table with more than 10 body rows.
    rule: Sub-tables nest one level, one per row, and show at most 10 rows. Counts inside a sub-table are plain .cell-metric numbers. Past 10 rows, add a .subtable-foot with "Showing 10 of N" and a link to the full child list filtered to the parent.
    fix: Swap the open sub-table when another count in the same row is clicked; replace nested count buttons with numbers; cap and link out.
    why: A second level or a 200-row peek turns the list into a tree view the operator has to scroll past to reach the next parent.

  - id: subtable-zero-opens-nothing
    severity: warn
    applies_to: [.count-btn]
    detect: A .count-btn with a value of 0 that carries aria-expanded or opens an empty sub-table.
    rule: A zero count is .count-btn.is-zero with no aria-expanded and no click handler.
    fix: Render .is-zero and drop the toggle.
    why: Same reason as expander-must-open-something. A toggle that opens an empty table teaches the user not to trust the others.

  - id: table-hover-stays-slate
    severity: warn
    applies_to: [.table-card tbody tr]
    detect: A row hover fill using --graphite-10 (the header fill) or a --switch-100 wash.
    rule: Row hover is --slate-bg.
    fix: Remove the override; components.css owns it.
    why: Matching the header fill makes hovered rows read as headers; the accent reads as selection.

  - id: list-tables-are-outline
    severity: info
    applies_to: [list_page]
    detect: .table-card-elevation on a list page's primary table.
    rule: List-page tables use .table-card-outline.
    fix: Swap the variant. Reserve elevation for floating panels.
    why: A list table IS the page, so it needs no lift off the canvas — elevation reads as something floating above the content.

  - id: no-blank-async-region
    severity: error
    applies_to: [any data region]
    detect: A card or table with no loading, empty, or error branch.
    rule: 'Every async region handles all three: Skeleton while loading, EmptyState when a query returns nothing, ErrorState when a fetch fails.'
    fix: Add the missing branches; ErrorState must offer a retry or a way forward.
    why: A blank region is indistinguishable from a broken one, so the user waits instead of retrying.

  - id: skeleton-matches-shape
    severity: warn
    applies_to: [Skeleton]
    detect: A single generic block standing in for a table or a form.
    rule: A skeleton mirrors the shape of the content it replaces.
    fix: Compose per-row / per-field skeletons.
    why: A skeleton shaped like the content tells the user what is arriving and prevents the layout jumping when it lands.

  # ── Layout and pages ─────────────────────────────────────────────
  - id: screens-sit-in-the-shell
    severity: error
    applies_to: [any product screen]
    detect: A page rendering content without .app-shell + TopHeader + Sidenav.
    rule: Product screens sit in the app shell; the hamburger drives Sidenav `collapsed`.
    fix: Start from templates/app-shell.
    why: The shell owns navigation, the theme switch, and the page frame — a screen outside it is unreachable and unthemed.

  - id: action-placement
    severity: warn
    applies_to: [page headers, form footers]
    detect: A primary action outside the page header's right edge, or a destructive action beside Save.
    rule: Primary action top-right of the page header. Destructive bottom-left of a form footer; Cancel + Save bottom-right.
    fix: Move the action.
    why: Destructive controls sitting next to Save get mis-clicked.

  - id: filter-bar-order
    severity: info
    applies_to: [list_page]
    detect: A filter bar whose order differs from the pattern.
    rule: Search first, then field filters, then the status ButtonGroup, then a right-aligned reset IconButton.
    fix: Reorder the bar to match; the reset sits right-aligned, separated from the filters it clears.
    why: Operators scan left to right for the narrowest control first, and a reset next to the filters gets hit by accident.

  - id: control-heights
    severity: warn
    applies_to: [any control row]
    detect: A control in a row resolving to a height other than the row's --control-h step.
    rule: Controls in the same row share one height token — sm 24, md 34 (default), lg 40.
    fix: Set matching size props; never hand-set a height.
    why: Mismatched heights in one row read as a rendering bug, and hand-set heights drift the moment the token changes.

  - id: no-showcase-scaffolding-in-product
    severity: error
    applies_to: [any shipped page]
    detect: A pp-*, demo-*, fb-*, or *-demo class in product code.
    rule: Those classes are showcase scaffolding from the design-system pages.
    fix: Replace with the real component classes named in components.json.
    why: Showcase scaffolding is undocumented, unversioned, and absent from the bundle, so it silently breaks when a page is edited.

  # ── Visual language ──────────────────────────────────────────────
  - id: tokens-not-literals
    severity: error
    applies_to: [any CSS or inline style]
    detect: A hex color, rgb(), or raw px spacing where a token exists.
    rule: Colors, spacing, radii, motion, elevation, and type sizes come from tokens.
    fix: Use var(--token). See design-system/tokens.json.
    why: A literal cannot follow the theme, so it is correct in one mode and wrong in the other — the most common source of dark-mode defects.

  - id: status-color-is-reserved
    severity: error
    applies_to: [any element]
    detect: --success / --warning / --error / --info used for decoration or emphasis.
    rule: Status colors mark data states only.
    fix: Use graphite or slate steps for emphasis; --switch-100 for accent moments.
    why: If status colors also mean emphasis, the user can no longer read red as "something is wrong".

  - id: slate-is-supporting
    severity: warn
    applies_to: [any surface]
    detect: Slate as a dominant background or the main brand surface.
    rule: Slate is a mid-tone supporting color, never dominant.
    fix: Use --surface-canvas or --surface-base for the ground and keep slate for controls and accents.
    why: At full width slate competes with the content and leaves no value range for the controls sitting on it.

  - id: hover-changes-fill-and-label-together
    severity: warn
    applies_to: [any control with a hover fill]
    detect: A hover state that changes the background but leaves the label color.
    rule: If a hover fill moves toward the label's value, the label moves with it.
    fix: Set both, as .bg-btn.is-selected:hover does.
    why: Fill-only hovers drop the label to unreadable contrast.

  - id: partner-marks-untouched
    severity: error
    applies_to: [PlatformIcon, .pf-plate]
    detect: A recolored, restyled, or substituted partner logo.
    rule: Third-party marks render as shipped, on a neutral plate.
    fix: Use PlatformIcon, which supplies the plate and leaves the mark untouched.
    why: Partner brand guidelines forbid recoloring, and a restyled mark is also harder to recognise.

  - id: no-emoji
    severity: warn
    applies_to: [product and marketing copy]
    detect: An emoji in UI or marketing copy.
    rule: The brand does not use emoji.
    fix: Use a Material Symbol or the Switch accent for visual energy.
    why: Emoji render differently per platform and undercut the tone the rest of the system sets.

  - id: reduced-motion-respected
    severity: warn
    applies_to: [Skeleton, ChatSticker, any animation]
    detect: An animation with no prefers-reduced-motion branch.
    rule: Motion degrades under prefers-reduced-motion — Skeleton drops its sheen, ChatSticker falls back to staticSrc.
    fix: 'Wrap the animation in @media (prefers-reduced-motion: reduce) and shorten or remove it.'
    why: For a user who set the preference, looping motion is a physical symptom, not a nicety.

  # ── Design-component authoring (this project) ─────────────────────
  - id: no-style-holes-in-dc-templates
    severity: error
    applies_to: ['*.dc.html']
    detect: A style attribute containing {{ }}.
    rule: Styles in a DC template are literal; only a genuinely live runtime value may be a hole.
    fix: Move the switch to a class and expose the class name instead.
    why: A holed property cannot paint until the logic class lands, so the design flashes unstyled.

  - id: no-layout-via-createElement
    severity: warn
    applies_to: ['*.dc.html']
    detect: UI layout built in renderVals() with React.createElement and exposed through a hole.
    rule: Layout is template markup.
    fix: Move it into the template.
    why: A createElement subtree is invisible to the editor, so the user cannot click into it.

  - id: only-chip-scope
    severity: warn
    applies_to: [SelectMenu, .sm-only, Table]
    detect: .sm-only rendered inside the table toolbar's column picker, or in any list whose rows are not interchangeable values.
    rule: The "only" chip belongs to value menus — a filter's Values list, a Format or Status picker. Never the column picker.
    fix: Use Show/Hide All for the bulk move and Reset to return to the default column set.
    why: '"Only" replaces the whole selection with one row. A table showing one metric with no ID or name column is not a state anyone asked for, and the chip sits where the pointer travels to reach the checkbox.'

  - id: no-unguarded-self-mount
    severity: error
    applies_to: [ui_kits/**, prototypes/**]
    detect: A module-scope ReactDOM.createRoot(document.getElementById('root')) in any file under ui_kits/ or prototypes/.
    rule: 'Gate the mount on <html data-ui-kit> — these files are compiled into _ds_bundle.js, so an unguarded mount double-roots #root on every page that loads the bundle.'
    fix: '"if (document.documentElement.hasAttribute(''data-ui-kit'')) { … }" around the createRoot call, and data-ui-kit on the host index.html.'
    why: A second createRoot on the same container throws a React warning on every component card and can blank the card's own render.

  - id: button-family-slate-ramp
    severity: error
    applies_to: [Button, IconButton, ButtonGroup, SplitButton, .btn, .btn-icon, .bg-btn, .bs-action]
    detect: var(--slate-base) used as a fill or hover on any control in the button family.
    rule: The button family fills from --slate-100, hovers --slate-110, presses --slate-120.
    fix: Replace --slate-base with --slate-100.
    why: '--slate-base equals --slate-100 in light mode but resolves to #7B9BBF in dark, so the control silently diverges from the buttons beside it in one theme only.'

  - id: switch-fill-needs-dark-ink
    severity: error
    applies_to: [.btn-accent, .md-dp-cell, .md-tp-num-mark, '*']
    detect: var(--white) or var(--graphite-90) used as the text colour on a fill of --switch-100, --switch-contrast, or --md-primary.
    rule: Ink on a switch-cyan fill is --graphite-contrast.
    fix: Replace the text colour with var(--graphite-contrast).
    why: Switch cyan is bright in BOTH themes, so it needs ink that does not invert. --white lands at ~1.4:1 in dark mode and --graphite-90 inverts to near-white and does the same.

  # ── Newer components: constraints that were only in components.json ──

  - id: toast-is-not-the-task
    severity: error
    applies_to: [Toast, ToastRegion, useToasts]
    detect: A toast whose action is the only route to completing something, or a toast carrying validation, an error the user must resolve, or content they need to refer back to.
    rule: A toast is a receipt for something already done. Anything the user must read, act on, or return to is an inline Alert; a destructive confirmation is a Modal.
    fix: Move the content to an Alert beside what it concerns, or a Modal if an answer is required. Keep an "Undo" in the toast only as a convenience.
    why: The toast disappears on a timer, so a task that lives in it becomes unfinishable.

  - id: toast-error-must-pin
    severity: warn
    applies_to: [Toast]
    detect: tone "error" with a non-zero duration.
    rule: An error toast pins — duration 0 — until dismissed.
    fix: Pass duration={0}.
    why: A failure the user did not see is a failure they will repeat.

  - id: alert-not-modal-for-information
    severity: warn
    applies_to: [Alert, Modal]
    detect: A Modal whose only content is a message and a single dismiss button.
    rule: Do not block the page to tell the user something. Modals are for a decision that must be answered before continuing.
    fix: Use an inline Alert, or a Toast if it is a receipt.
    why: A modal spends the user's attention and a click on information they did not ask to be interrupted by.

  - id: drawer-holds-no-navigation
    severity: error
    applies_to: [Drawer]
    detect: Primary navigation — Sidenav items, top-level product areas — rendered inside a Drawer.
    rule: 'A Drawer holds secondary or ephemeral UI: filters, detail, settings. Primary navigation lives in the Sidenav.'
    fix: Move the navigation to Sidenav; keep the drawer for what acts on the current view.
    why: Navigation that closes with the panel is navigation the user cannot rely on being there.

  - id: list-table-needs-pagination
    severity: warn
    applies_to: [Table, Pagination, .table-card]
    detect: A .table-card with no Pagination, or hand-rolled rows-per-page / range markup instead of the component.
    rule: Every list table carries a Pagination — countOnly for a list that is not paged.
    fix: Add <Pagination total={…} /> below the .table inside the card.
    why: The row count and page controls are a contract; rebuilt by hand they drift in label format, alignment, and menu behaviour.

  - id: checkbox-versus-switch
    severity: error
    applies_to: [Checkbox, Switch]
    detect: A Switch for a value submitted with a form or a list selection; a Checkbox for a setting that applies the moment it is flipped.
    rule: Checkbox for values pending a save — multi-select, table selection, opt-ins. Switch for settings with immediate effect.
    fix: Swap the component to match whether a save step exists.
    why: The two controls promise different things about when the change takes effect.

  - id: breadcrumb-never-the-heading
    severity: error
    applies_to: [Breadcrumb, .breadcrumb]
    detect: A .breadcrumb rendered as an h1, at heading scale, or in place of the page title.
    rule: The breadcrumb is a --fs-ui-small nav sitting ABOVE the page's own h1. It never replaces the heading.
    fix: Render the trail above the h1 and keep the page title as its own element.
    why: The retired .pp-crumb treatment rendered the trail at title scale and left detail pages with no real heading.

  - id: object-page-shows-its-id
    severity: warn
    applies_to: [Breadcrumb]
    detect: A breadcrumb on an object page with no id on the segment that IS the object.
    rule: Every object segment carries its ID as a copyable .id-chip, ancestors included. Collection segments have none.
    fix: Add id to those items[] entries.
    why: The ID is what an operator quotes in a ticket or a query, so it must be on screen and one click from the clipboard.

  - id: donut-needs-anchor-and-four-slices
    severity: error
    applies_to: [Donut]
    detect: A Donut with no centre value, more than four slices, or slice values within 5% of each other.
    rule: A donut is one anchor number with up to four supporting slices. Otherwise use Chart type="bar-horizontal".
    fix: Switch to bar-horizontal, or reduce to four slices with a real anchor.
    why: The centre fact is what justifies the format; without it a donut asks the reader to rank arcs, which humans do worse than ranking lengths.

  - id: no-pie-no-gauge
    severity: error
    applies_to: [Chart, Donut, Bullet]
    detect: A pie chart, or a gauge or radial dial used to show a value against a target.
    rule: A pie becomes a Donut with an anchor or a horizontal bar. A gauge becomes a Bullet.
    fix: Use Donut or Bullet.
    why: Both formats spend a lot of space encoding one number as an angle, which reads worse than a length.

  - id: axis-uses-chartformat
    severity: warn
    applies_to: [Chart, ChartFormat]
    detect: Hand-formatted axis tick strings on a custom plot instead of ChartFormat.axis.
    rule: Axis labels come from ChartFormat.axis, which applies the currency, magnitude-suffix, one-suffix-per-axis, and precision rules together.
    fix: Call ChartFormat.axis(ticks, { format }) and render what it returns.
    why: The rules are easy to half-apply — "$0K" and a 2.5MM step rendering as "$3MM" were both real bugs before the formatter owned them.

  - id: kpi-card-is-one-number
    severity: warn
    applies_to: [KpiCard]
    detect: A KPI card carrying a second metric, a second delta, or a table.
    rule: One number, one label, one delta. The label states the window the delta covers.
    fix: If a second dimension is needed, use a Chart.
    why: A card with two numbers makes the reader decide which one it is about.

  - id: metric-uses-the-metric-token
    severity: error
    applies_to: [KpiCard, .kpi-value, .t-ui-metric]
    detect: A metric readout in --font-display, or at a headline size such as --fs-h2.
    rule: A metric is the UI/Metric token — --font-body 700 at --fs-ui-metric, line-height 0.95, in --switch-contrast.
    fix: Use .kpi-value or .t-ui-metric.
    why: Avantt is for headings and titles. A metric read in the display face looks like a section header.

  - id: date-field-label-follows-its-job
    severity: warn
    applies_to: [DateField]
    detect: variant="inset-label" on a date that is a property of the object being edited, or the stacked default on a control over a table's contents.
    rule: A date among an object's properties stacks its label, matching the TextFields beside it. A date that changes what a table shows insets it.
    fix: Set variant to match what the date belongs to.
    why: A stacked label reads as a form field being filled in; an inset label reads as a control acting on the view.

  - id: pickers-follow-m3
    severity: error
    applies_to: [DatePicker, TimePicker, .md-datepicker, .md-range, .md-timepicker]
    detect: A hand-built calendar or time control, or a picker interaction that departs from the M3 spec.
    rule: Date and time inherit Material Design 3 structure, interaction, and a11y wholesale. Only colour, type, and radius are Swivel.
    fix: Use DatePicker / TimePicker. If a picker feels wrong, fix the token mapping, not the component.
    why: Keyboard nav, locale, range selection, year jumps, and a11y are all solved in M3 and expensive to rebuild correctly.

  - id: never-two-single-date-pickers
    severity: error
    applies_to: [DatePicker]
    detect: Two single-date pickers or DateFields presented as a from/to pair.
    rule: A from/to pair is DatePicker mode="range".
    fix: Replace the pair with one range picker.
    why: Two separate pickers hide the relationship — the user cannot see the span or that the order is wrong.

  - id: textarea-is-not-a-control-height
    severity: error
    applies_to: [Textarea, 'textarea.field-input']
    detect: --control-h or a fixed 34px height applied to a textarea.
    rule: A textarea's height is a row count, never the control height.
    fix: Use rows, or autosize with maxRows.
    why: 34px shows one line of what is meant to be prose.

  - id: prose-is-not-a-text-field
    severity: warn
    applies_to: [TextField, Textarea]
    detect: A TextField collecting a note, description, reason, or any answer expected to run past one line.
    rule: A prose answer is a Textarea.
    fix: Swap to Textarea and set rows.
    why: A single-line input hides everything past the first line while the user is still typing it.

  - id: counter-means-an-enforced-limit
    severity: warn
    applies_to: [Textarea]
    detect: maxLength on a field whose limit the backend does not enforce.
    rule: Only show a character counter where a real limit exists.
    fix: Remove maxLength.
    why: A decorative counter invites the user to plan around a rule that is not there.

  - id: template-matches-its-manifest
    severity: warn
    applies_to: ['templates/**', design-system/templates.json]
    detect: A template whose entry file uses components or class hooks not listed in its templates.json entry, or vice versa.
    rule: templates.json is the manifest of what each template composes. Update it in the same change as the template.
    fix: Add or remove the components / class_hooks / behaviours entries so they match the file.
    why: A consuming agent picks a template from the manifest; a stale manifest sends it to the wrong starting point.

  - id: progress-is-not-a-skeleton
    severity: error
    applies_to: [ProgressBar, Skeleton]
    detect: A ProgressBar standing in for content that has not loaded, or an indeterminate bar used as a page-level loading state.
    rule: A progress bar says how far along something is. Content that has not arrived is a Skeleton shaped like what is coming.
    fix: Use Skeleton for the region; keep the progress bar for work with a measurable amount done.
    why: A bar tells the user a quantity they cannot act on, and hides the shape of what is about to appear.

  - id: progress-is-not-a-bullet
    severity: warn
    applies_to: [ProgressBar, Bullet]
    detect: A ProgressBar carrying more than one reference marker, or qualitative bands drawn behind the fill.
    rule: One target line is a ProgressBar. Qualitative ranges — behind, on, ahead — are a Bullet.
    fix: Switch to Bullet and pass bands.
    why: Two visual languages for the same comparison make the reader learn both.

  - id: progress-tone-means-a-state
    severity: error
    applies_to: [ProgressBar]
    detect: tone set from the value itself — warning because the bar is low, error because it is very low.
    rule: Tone marks a delivery state. warning is behind pace, error is a failed run, success is complete or ahead.
    fix: Derive tone from the state, not the percentage. Leave it default when the value is simply low.
    why: If a low bar always reads red, the user can no longer tell a slow campaign from a broken one.

  - id: progress-in-a-cell-needs-the-numbers
    severity: warn
    applies_to: [ProgressBar, ProgressCell, Table]
    detect: A progress bar in a table cell showing only a percentage, with no hover reading of the absolute figures.
    rule: Use ProgressCell with a metric — the cell carries "<value> of <max> <metric> (<n>%)" on hover and focus.
    fix: Replace the bare bar with <ProgressCell value max metric />.
    why: A percentage answers "how far along" but not "how much is left", which is the number the operator acts on.

  - id: flight-time-is-not-delivery
    severity: error
    applies_to: [FlightProgress, ProgressBar]
    detect: FlightProgress used to show impressions or budget delivered, or a plain ProgressBar used to show where a flight is in its window.
    rule: FlightProgress measures elapsed TIME across a flight and marks dark days. ProgressBar measures a quantity against a whole.
    fix: Pick by what the bar counts — days, or units delivered.
    why: A flight can be 80% elapsed and 30% delivered; one bar standing for both hides exactly the gap the operator is looking for.

  - id: dark-days-stay-graphite
    severity: error
    applies_to: [FlightProgress, .flight-dark]
    detect: A dark-day segment coloured from the slate family, or from a semantic token.
    rule: Elapsed time is --slate-100 and dark days are --graphite-80.
    fix: Leave .flight-dark on its token, hairline included.
    why: Dark days paint over the elapsed fill, so a related hue makes them vanish; a semantic colour would claim the outage is a system error rather than a fact about the flight.

  - id: overlapping-marks-separate-by-shape
    severity: error
    applies_to: [FlightProgress, .flight-dark, .progress-target]
    detect: A mark painted over another mark inside the same bar, separated only by a step on the same colour ramp.
    rule: Overlapping marks carry an OUTSET 1px --surface-base ring, so the boundary survives whatever they land on without consuming the mark.
    fix: Add the ring; do not chase separation by stepping further along the ramp, and never inset it — a fixed 1px inset is a constant cost that a narrow mark cannot pay.
    why: Two steps of one ramp measure under 3:1 against each other, and the further step just moves the collision to a different pair — the today marker on a dark day was 1.3:1.

  - id: dark-stretch-shows-its-dates
    severity: warn
    applies_to: [FlightProgress]
    detect: A dark segment with no data-tip, or one built as a pseudo-element rather than a real node.
    rule: Every dark stretch is a focusable element carrying its date, or its range and day count.
    fix: Pass darkDays and let the component render them; do not draw the segments yourself.
    why: The bar shows THAT the flight went dark; only the tooltip says when, which is what the operator needs to explain the shortfall.

  - id: measured-figures-are-not-axis-ticks
    severity: warn
    applies_to: [ChartFormat, ProgressCell]
    detect: ChartFormat.axis called on arbitrary measured values rather than a set of axis ticks.
    rule: ChartFormat.axis is for a scale — it snaps every tick to one suffix and raises precision until all of them round-trip. Individual figures use ChartFormat.tick, which picks a magnitude per value, or ChartFormat.exact for full precision.
    fix: Call ChartFormat.tick per figure.
    why: The round-trip guarantee that keeps a 2.5MM tick from reading "$3MM" also forces 336,000 to read "0.336MM" when the pair is arbitrary.

  - id: discrete-stages-are-not-a-progress-bar
    severity: warn
    applies_to: [ProgressBar, .lifecycle]
    detect: A ProgressBar used to show position in a fixed sequence of named stages (a lifecycle, an approval chain, an onboarding flow).
    rule: A countable sequence of stages renders as .lifecycle segments, one per stage. ProgressBar is for a continuous quantity.
    fix: Use the .lifecycle / .lc-bar markup and name the stage in the tooltip.
    why: A continuous rail invites reading a percentage that does not exist — "4 of 6 stages" is not 67% done.

  - id: filter-bar-never-wraps
    severity: error
    applies_to: ['.table-toolbar', 'templates/list-page-tabbed', 'templates/data-table-page']
    detect: A filter bar whose fixed-width controls, gaps, padding, and reset button sum past the bar's width at the design width — measurable as more than one distinct child bottom edge.
    rule: The filter bar is one row. Adding a control means shrinking the others or moving one into the criteria-builder panel.
    fix: Sum the control widths plus (n-1) gaps plus padding plus the reset button, and check it against the bar width at the design width before adding a control.
    why: A wrapped bar doubles the chrome above the table and pushes the data below the fold, and the fold is where an operator scans.

  - id: audit-history-is-an-activity-panel
    severity: error
    applies_to: [ActivityPanel, Table]
    detect: An audit log, change history, or activity feed built as a Table, or a hand-rolled timeline of expandable rows.
    rule: Use ActivityPanel. A table cannot carry a nested before/after diff, and it inherits sorting and selection an audit log does not have.
    fix: Render the stream with ActivityPanel and put its filters in a .filter-rail beside it.
    why: The whole value of an audit row is what it opens into; a table row can only show one line of it.

  - id: changes-show-before-and-after
    severity: error
    applies_to: [ActivityPanel]
    detect: An update event using detail mode "value", showing only the new state.
    rule: An event that changed something uses mode "diff". Only a creation uses "value".
    fix: Set mode to "diff" and supply before and after per row.
    why: '"What was it before" is the question an operator opens an audit row to answer; the new state alone is already visible on the object.'

  - id: expander-must-open-something
    severity: warn
    applies_to: [ActivityPanel, '.act-toggle']
    detect: A chevron rendered on an event with no detail rows.
    rule: Omit detail and the chevron is not rendered. Never render a toggle that opens an empty table.
    fix: Leave detail undefined for events with nothing to show.
    why: An expander that opens nothing costs a click and teaches the user not to trust the others.

  - id: date-affordance-is-trailing
    severity: warn
    applies_to: [DateField, '.range-trigger']
    detect: A calendar glyph in the leading position on a date or date-range control.
    rule: A date control's calendar affordance is trailing — DateField's .trailing slot, and .range-trigger orders its glyph to the end.
    fix: Move the glyph to the trailing position.
    why: The leading slot is the label's side; a control's affordance sits where the user's eye ends, next to the chevron and clear controls it lives among.

  - id: operational-ranges-offer-presets
    severity: warn
    applies_to: [DatePicker]
    detect: A range picker on a filter, report, or activity surface without `presets`.
    rule: An operational range picker offers relative presets. Omit them only when the span is inherently absolute — a flight, a contract term.
    fix: Pass presets (true for the default set).
    why: Almost every range an operator wants is relative, and reaching one through the grid means counting backwards from today.

  - id: a-tab-need-not-be-a-table
    severity: info
    applies_to: ['templates/list-page-tabbed']
    detect: An activity, audit, or history tab rendered as a table inside a tabbed list page.
    rule: Tabs group related content, not identical layouts. A stream tab renders ActivityPanel; only list tabs render the table shell.
    fix: Branch the body on the tab and render the activity_page composition for the stream.
    why: Forcing a stream into table rows loses the before/after detail that is the only reason to open the row.

  # ── Table cells: the kind decides the treatment ──

  - id: column-kind-decides-its-classes
    severity: warn
    applies_to: [Table, 'templates/data-table-page', 'templates/list-page-tabbed']
    detect: A td or th with hand-written alignment, font-family, or colour instead of the cell class its kind calls for.
    rule: A column has a kind, and the kind decides the header class, the body class, and the content. components.json Table.cell_kinds is the registry.
    fix: Apply the kind's th/td classes. Build the map from cell_kinds rather than styling cells individually.
    why: Hand-styled cells drift per page, so the same kind of data ends up left-aligned in one table and right-aligned in the next.

  - id: numbers-are-right-aligned
    severity: error
    applies_to: [Table]
    detect: A count, money, or metric column that is not right-aligned, or one using .cell-metric on its header.
    rule: Numeric columns take .cell-num on the header and .cell-metric on the body, both right-aligned.
    fix: Apply the pair from cell_kinds.
    why: Digits only line up for comparison when the column is right-aligned, and a header carrying the body class reads as data rather than a label.

  - id: flight-is-start-and-end
    severity: error
    applies_to: [Table, ActivityPanel, DetailPanel, '*']
    detect: A single "Flight" column, field, or detail row holding a date range (Oct 1 – Dec 15, 2026).
    rule: Flight is always two values — Flight start and Flight end — as separate columns in tables and separate rows in detail and activity views. Each uses the date cell kind.
    fix: 'Split into { key: ''start'', label: ''Flight start'', kind: ''date'' } and { key: ''end'', label: ''Flight end'', kind: ''date'' }.'
    why: Start and end are sorted, filtered, and edited independently; a combined range can do none of these and hides which end changed.

  - id: range-compare-sits-with-its-metric
    severity: error
    applies_to: [Table, '.has-delta']
    detect: A range-compare change in its own column, in absolute terms, or with arrows other than arrow_drop_up / arrow_drop_down.
    rule: Range compare renders after the value in the metric's own cell — percent change, one decimal, --fs-ui-small, arrow_drop_up / arrow_drop_down. Use Table column compare { prior }.
    fix: Move the change into the metric cell with compare on that column.
    why: The change is read as a property of the number beside it; a separate column doubles the width the metric takes and separates the two.

  - id: range-compare-no-prior-is-empty
    severity: error
    applies_to: [Table, '.has-delta']
    detect: A change slot showing a dash, 0%, 'New', '∞', or 'N/A' for a row with no prior value.
    rule: No prior value means no entry — the metric shows alone. A prior of 0 is treated the same.
    fix: Return null from compare.prior for those rows.
    why: Any placeholder reads as a measured change — 0% says it held steady, a dash says it was checked — when there was nothing to compare against.

  - id: range-compare-colour-is-good-bad
    severity: warn
    applies_to: ['.has-delta']
    detect: A delta coloured by direction on a lower-is-better metric, or coloured with --success / --error instead of the -text tokens.
    rule: Colour says good or bad, the arrow and sign say direction. Set compare.invert for lower-is-better metrics. Text uses --success-text / --error-text.
    fix: Add invert, or swap the tokens.
    why: A falling CPM paid is good news; painting it red trains the reader to ignore the colour. The base --success / --error drop to about 4:1 on the row-hover tint.

  - id: status-column-is-an-icon
    severity: warn
    applies_to: [Table, Badge]
    detect: A Badge rendered once per row in a status column.
    rule: A status column is .cell-state — one filled 18px icon with a data-tip naming the state. Badge is for a single prominent state, not a column.
    fix: Swap to the state cell kind.
    why: A column of pills is a wall of colour that makes the rest of the row harder to read.

  - id: id-column-hugs
    severity: warn
    applies_to: [Table]
    detect: An ID column without .cell-id, without an .id-chip inside it, or set to stretch.
    rule: An ID column is .cell-id with an .id-chip, hugging its content.
    fix: Add the class and wrap the value in an .id-chip.
    why: The chip is what makes the ID copyable and scannable, and a stretched ID column spends the row's most valuable horizontal space on its least readable value.

  - id: links-use-the-link-token
    severity: error
    applies_to: ['a', '.cell-name-slate', '.act-object']
    detect: Link or primary-name text coloured with --slate-100 or any fixed step of the slate ramp.
    also: 'A --slate-100 FILL under --white ink fails the same way and worse — --white inverts to #E0E0E0 in dark while the ramp does not, so the pair drops to 3.22:1 there. --slate-120 is the shallowest fill that clears 4.5:1 in both themes (6.32 / 4.79).'
    rule: Interactive text uses var(--link) and var(--link-hover).
    fix: Replace the colour with var(--link).
    why: The slate ramp steps (--slate-10 through --slate-150) have no dark-mode override — they are the same hex in both themes. --slate-100 lands at 4.25:1 in light and 3.82:1 in dark, under AA at 14px/400. --link inverts and clears both grounds.

  - id: measure-cell-text-on-the-hover-ground
    severity: error
    applies_to: [Table, '.table-card']
    detect: A table text colour verified only against --surface-base.
    rule: A table cell has two grounds — --surface-base and --slate-bg, the row-hover tint. Both must clear 4.5:1 at the cell's size.
    fix: Measure against the hover tint too, and step the token until both clear.
    why: Pointing anywhere in the row lights the whole row, and a:hover does not fire unless the pointer is on the anchor itself — so the hover ground is where a link is read most of the time. It is also the darker of the two, so passing on the card proves nothing.

  - id: filter-options-derive-from-data
    severity: error
    applies_to: [Table, SelectField, '.table-toolbar', 'templates/**']
    detect: An enum filter whose options are a hardcoded array rather than derived from the rows, or two filter surfaces on one page offering different options or labels for the same field.
    rule: Derive enum options from the data and share one list across every filter surface on the page.
    fix: Build the option list once (`[...new Set(DATA.flatMap(r => r.field))].sort()`) and reference it from the bar and the criteria builder alike.
    why: A hardcoded list drifts the moment a value is added, so the user sees a value on screen that the filter cannot select — and two surfaces naming the same field differently reads as two different filters.

  - id: renaming-a-column-renames-its-filter
    severity: error
    applies_to: [Table, 'templates/**']
    detect: A filter label or state key still carrying a column's previous name.
    rule: A column rename is not done until every filter surface, placeholder, state key, and handler that refers to it is renamed too.
    fix: Rename the label, the placeholder, and the state key and handler.
    why: A filter labelled with a column that no longer exists is read as filtering some other column — and a single-valued name on a set-valued field misleads whoever edits it next.

  - id: single-select-with-descriptions-is-a-choice-list
    severity: warn
    applies_to: ['.as-choice', ButtonGroup]
    detect: A radio input, or a ButtonGroup used for more than about four options or for options carrying descriptions.
    rule: Single-select over options that need descriptions is a .as-choice list — full-row targets with role="radio", a filled dot for the selection, and no radio control.
    fix: Render the options as .as-choice rows inside a role="radiogroup".
    why: This system has no radio control, and a button group cannot carry a description per option or stay legible past a handful of them.

  - id: reconcile-dont-override
    severity: error
    applies_to: ['design-system/components.css', ChatSticker]
    detect: A higher-specificity rule that silently overrides a component's own documented sizing or styling, especially one added in the same change that read the component's existing contract.
    rule: When a new context needs a component to look different, change the component's own variants/sizes, or use the size/variant it already offers — never add a selector that overrides it from outside.
    fix: Use the documented size/variant, or add a real one to the component if none fits, and update its own stylesheet and types rather than a caller-side override.
    why: An override in a different file is invisible to the component's own documentation, so a consumer reading ChatSticker's types gets a size that does not match what actually renders in the one context the component exists for.

  - id: one-contract-per-concept
    severity: error
    applies_to: ['design-system/components.css']
    detect: Two class families in components.css covering the same concept, or a showcase-only demo block living in the shared stylesheet.
    rule: Grep components.css for an existing contract before adding one. A specimen page uses the real classes rather than its own copy.
    fix: Delete the duplicate and point the specimen at the canonical contract.
    why: components.css is the shared stylesheet, so a second vocabulary is one a consuming project can find and build on. A "Beacon chat thread" existed twice — a demo block with hardcoded hex fallbacks and the real .as-* contract — and neither referenced the other.

  - id: no-token-fallback-hides-a-typo
    severity: error
    applies_to: ['design-system/components.css']
    detect: var(--token) referencing a name absent from tokens.css, with or without a fallback.
    rule: Every var() must name a real token. A hardcoded fallback is not a fix — it is theme-fixed by definition.
    fix: Use the real token. Check against design-system/tokens.json.
    why: Without a fallback the property drops silently (IconButton's tonal variant rendered with no background at all); with one it renders a fixed hex that cannot follow the theme.

  - id: object-card-badge-is-an-identifier
    severity: warn
    applies_to: [ObjectCard]
    detect: A coloured or stateful ObjectCard badge, or a differentiator (a status, a metric) rendered only in the badge and nowhere in stats.
    rule: The badge identifies the object — graphite, no tone. A real differentiator (a lifecycle state, a delivery figure) belongs in stats, with its own colour.
    fix: Move the differentiating value into stats and leave the badge as a plain identifier.
    why: A coloured badge makes the identifier double as a status, so the object's actual state has nowhere unambiguous to live.

  - id: object-card-badge-shape-follows-the-icon
    severity: warn
    applies_to: [ObjectCard]
    detect: isPlatform set on a type/lifecycle icon, or left off when badgeIcon is a platform mark and badgeId is that platform's own identifier.
    rule: Square icon-over-ID is the default, for a type or lifecycle icon that needs room to read as a symbol. isPlatform's short horizontal chip is only for a platform/third-party mark, where the icon is small and logo-like.
    fix: Set isPlatform to match what badgeIcon actually is.
    why: A type or lifecycle icon squeezed into the short chip reads as decoration rather than a symbol; a platform mark stretched into the square badge wastes the space a small logo does not need.

  - id: object-card-always-opens-detail
    severity: error
    applies_to: [ObjectCard]
    detect: An ObjectCard with no href/onClick, or a per-row action wired without onMore/actions (so its click also opens the card).
    rule: Every ObjectCard opens its detail page — there is no non-clickable variant. A per-row action goes through onMore (kebab) or actions (a control like a Switch), both of which stop their own click.
    fix: Pass href or onClick, and route any row action through onMore/actions.
    why: A card that sometimes opens and sometimes does not forces the user to test each one; and an action that does not stop its click also navigates by accident.

  - id: tabbable-tooltip-icon-needs-a-ring
    severity: error
    applies_to: ['[data-tip][tabindex]']
    detect: An element with tabindex="0" and data-tip/aria-label but no :focus-visible box-shadow.
    rule: A focusable icon-only element shows --focus-ring on :focus-visible, exactly like a button. One attribute-selector rule in components.css — [data-tip][tabindex]:focus-visible — covers every such element; do not restate it per component.
    fix: Confirm the element matches [data-tip][tabindex]. If it needs a different shape (e.g. a non-square hit area), add a component-local :focus-visible box-shadow instead of skipping the ring.
    why: aria-label makes a screen reader announce the element; nothing about it makes a sighted keyboard user able to SEE where focus is.

  - id: app-shell-has-a-skip-link
    severity: warn
    applies_to: ['templates/app-shell', .app-shell]
    detect: An app-shell page with no .skip-link as the first focusable element, or a <main> with no id for it to target.
    rule: 'Every app-shell page opens with a .skip-link to #main-content, and <main> carries that id.'
    fix: Copy the pattern from templates/app-shell/AppShell.dc.html.
    why: Without it, a keyboard user tabs through the whole nav before reaching the page's actual content on every single navigation.

  - id: semantic-icon-clears-every-ground
    severity: error
    applies_to: [--success, --warning, --info, --error]
    detect: A semantic token used as icon/text color measured against only one background.
    rule: --success/--warning/--info/--error are read on --surface-base, --graphite-10 (row hover), --slate-bg, AND their own -bg tint. All four must clear 3:1 (icon/non-text) or 4.5:1 (text) in both themes.
    fix: 'Step the token; --warning light was #ED6C02 (2.45–3.11:1 across those grounds) then #A64E00. It is now #D96600 for icons/fills (3:1 on base and row hover) with a separate --warning-text #9A4800 (5.0:1+ on all four) for any warning-coloured text.'
    why: A semantic color is read wherever a row or card can be — the token has to survive every surface it will actually sit on, not just the one it was designed against.

  - id: collapsible-block-is-a-section
    severity: warn
    applies_to: [Section, '.card-header', '.chart-card']
    detect: A hand-rolled titled block with its own chevron-toggle logic instead of Section.
    rule: A titled, collapsible block is Section — one chevron rotating -90deg, max-height + opacity collapse. Do not reinvent the toggle per page.
    fix: Replace with Section (outline for a stack inside an already-elevated page).
    why: Two ad hoc versions existed before Section (an icon-swap card-header and a separately-implemented chart card) and had already drifted from each other in motion and markup.

  - id: tooltip-text-is-sentence-case
    severity: warn
    applies_to: ['[data-tip]', 'tooltip', 'aria-label']
    detect: A tooltip, data-tip, or its paired aria-label starting with a lowercase letter, in ALL CAPS, or ending in a period.
    rule: Tooltip text is sentence case (first word capitalized, rest lowercase unless a proper noun) with no trailing period — "Copy ID", "Guaranteed delivery", "Reset filters". Never a raw data key or enum value rendered as-is.
    fix: Capitalize the first word; pull the label from the same STATE/vocabulary map the icon uses rather than the raw value.
    why: A tooltip that echoes an internal key ("live", "pg") instead of its label breaks the moment the data model's casing does, and reads as unfinished next to every other capitalized tooltip in the same toolbar.

  - id: inactive-object-name-is-italic
    severity: warn
    applies_to: ['.is-inactive-name', Table, ObjectCard, Breadcrumb]
    detect: An inactive object's name rendered in the same weight/style as an active one, or inactivity indicated only by a separate badge/icon with the name unchanged.
    rule: An inactive object's name takes .is-inactive-name — italic, --graphite-bg — wherever the name appears (table cell, object card, breadcrumb, detail header).
    fix: Add the class when the object's state resolves to inactive.
    why: The name is what's scanned first; italicizing it flags the object as inactive before the reader even reaches a status icon or badge.
