Kontour UI Consumer Guide#
@kontourai/ui ships three layers:
- CSS tokens:
@kontourai/ui/tokens - React primitives:
@kontourai/ui/react - Light-DOM custom elements:
@kontourai/ui/elements
and a white-label contrast validator, @kontourai/ui/contrast (see
Validating a white-label theme).
Theme Boundary#
Apply exactly one product theme class on a stable root:
<html class="theme-console">
Available product classes:
theme-consoletheme-flowtheme-stationtheme-surveytheme-surface
Use data-theme="light" on the same root, an ancestor, or a descendant of the theme root when a
product needs the light token skin; each resolves to the product's light values. Modes and themes
nest: the nearest data-theme picks the mode and the nearest theme class picks the product, so
data-theme="dark" inside a light page restores the dark skin, and a light element below a nested
theme takes that inner theme's light values. A nested theme class layers over the theme around it.
One case is not covered: a theme class below a mode that switches back (light, dark, then light
again above it); put data-theme on that theme element. See "Theme and mode scoping" in
DESIGN.md.
Dark is the default mode: a page that sets no data-theme renders dark, and the tokens ship no
prefers-color-scheme rule. To follow the OS preference, set data-theme="light" from the media
query yourself; "Modes" in DESIGN.md has the script.
White-label color#
The token layer keeps the product identity slot (--k-brand) separate from the interaction
roles. Overriding --k-brand changes identity accents (eyebrows, panel counts, topbar edge,
progress, spinner) without recoloring primary buttons, checked controls, or focus.
Where to put an override:
- Give each mode its own values.
- Declare them in a stylesheet loaded after the tokens, using the theme's own selectors:
.theme-flow,
[data-theme="dark"]:where(.theme-flow *):where([data-theme="light"] *):where(:not(.theme-survey, .theme-console, .theme-surface, .theme-station, .theme-flow :is(.theme-survey, .theme-console, .theme-surface, .theme-station) *)) {
--k-brand: #f0a868;
--k-action: #f0a868;
--k-action-contrast: #06080b;
--k-focus: #f0a868;
}
[data-theme="light"].theme-flow,
[data-theme="light"] .theme-flow:where(:not([data-theme="dark"], [data-theme="light"] [data-theme="dark"] *)),
:where(.theme-flow) [data-theme="light"]:where(:not(.theme-survey, .theme-console, .theme-surface, .theme-station, .theme-flow :is(.theme-survey, .theme-console, .theme-surface, .theme-station) *)) {
--k-brand: #9a4418;
--k-action: #9a4418;
--k-action-contrast: #ffffff;
--k-focus: #9a4418;
}
Copy the selectors exactly as tokens/themes.css spells them. Where a form has a
:where(:not(...)) tail, copy that too: the tail keeps the block to the nearest theme and mode. The dark values go on the theme's base
block and on its dark-island block (the second selector of the first rule, from the end of
tokens/themes.css), which is where a data-theme="dark" element inside a light page resolves.
A browser test loads this example straight from this file.
Without a theme class, override :root and [data-theme="dark"]:where([data-theme="light"] *)
together for dark (the second is where a dark element below a light one resets), and
[data-theme="light"] for light.
A declaration on :root loses to the theme's light block when a theme class is present, and an
inline style on <html> does not reach a theme class on <body>.
Migrating overrides. Dark elements inside a light page now really resolve dark, so an override written with the earlier selectors can misplace values:
.theme-flowalone (dark): add the dark-island block. Without it, adata-theme="dark"element below a light one shows the shipped Flow brand, not yours.[data-theme="light"] .theme-flowwithout its:where(:not(...))tail: add the tail. Without it, a.theme-flowelement withdata-theme="dark"on a light page takes your light values onto dark surfaces.:where(.theme-flow) [data-theme="light"]without its tail: add it, so your light values stop at a nested theme.:rootonly (no theme class): add[data-theme="dark"]:where([data-theme="light"] *).
Rules for the values:
- Brand used as text must meet 4.5:1 on the panel in each mode. If you put text on a brand fill,
set
--k-brand-contrastand check that pair too. - Override
--k-actionand--k-action-contrasttogether, per mode. A fill without its matching text can drop below 4.5:1. --k-focusmust reach 3:1 against both the page and the panel.- A runtime that applies themes dynamically must reject a pair that fails these thresholds (the
ones
npm run check:contrastenforces for the shipped themes). See Validating a white-label theme.
Validating a white-label theme#
@kontourai/ui/contrast is for a runtime that applies an override from data (a config file, an
API) instead of a reviewed stylesheet. It is plain ESM with no imports, so the same module runs in
the browser and on a Node server, and npm run check:contrast rates the shipped themes through
the same functions and thresholds, so the package and a runtime cannot disagree.
import { validateBrandOverride } from "@kontourai/ui/contrast";
const { violations, accepted } = validateBrandOverride({
base: "flow", // the shipped theme the override is applied over
overrides: JSON.parse(themeJson), // pass the parsed object itself
// e.g. {"dark": {"--k-brand": "#f0a868", "--k-action": "#f0a868", "--k-action-contrast": "#06080b", "--k-focus": "#f0a868"},
// "light": {"--k-brand": "#9a4418", "--k-action": "#9a4418", "--k-action-contrast": "#ffffff", "--k-focus": "#9a4418"}}
});
if (violations.length > 0) {
// Messages echo caller data (cut to 64 characters): escape before rendering.
for (const violation of violations) console.warn(violation.message);
// Fall back to the shipped theme; never apply part of a rejected override.
} else {
applyTheme(accepted); // your code: write accepted.dark / accepted.light to the theme's selectors
}
What it checks:
- Shape. Pass the object
JSON.parsereturned. Only plain objects are read: aMap, a class instance, an object from another realm (an iframe), or a non-object input is aninvalid-shapeviolation, so an unusual input fails closed. An unknownbasethrows. - Keys. Only
--k-brand,--k-brand-contrast,--k-action,--k-action-contrast, and--k-focus(BRAND_SLOT_PROPERTIES), under adarkorlightkey. Anything else is adisallowed-propertyorinvalid-shapeviolation. - Values.
#rgbor#rrggbbonly (isHexColor). Alpha hex, keywords,rgb(),var(), and surrounding whitespace areinvalid-value: they are rejected, not interpreted. - The action pair.
--k-actionwithout--k-action-contrast(or the reverse) in a mode isunpaired-action, even when the ratio would pass. - Contrast (
BRAND_SLOT_PAIRS): action text on the action fill 4.5:1; the action fill on the panel 3:1; brand as text on the page, the panel, and the raised panel 4.5:1; brand-contrast text on the brand 4.5:1; focus on the page and on the panel 3:1. A mode's override is laid over the base theme's shipped values for that mode, and only pairs that include an overridden property are rated, so an override is judged on what it changes.
accepted is all or nothing. When violations is empty it holds fresh, frozen, null-prototype
copies of the validated values for each mode sent; otherwise it is empty, so applying it can never
land half an override (light applied while dark was rejected). Apply accepted rather than the
input: each input value is read once, so what lands is exactly what was rated. A getter or Proxy
trap on the input that throws propagates the exception; JSON.parse output has neither, so pass
that.
The surfaces come from the package rather than the caller: an override cannot change --k-bg,
--k-panel, or --k-panel-raised, so the only surfaces it can land on are the shipped ones. SHIPPED_THEMES exposes
them for information; check:contrast fails if it drifts from the token files. Validate with the
same @kontourai/ui version whose CSS you serve, since a different version's surfaces may differ.
contrastRatio(a, b) and relativeLuminance(hex) are exported too and throw a TypeError for
anything but #rgb / #rrggbb.
Consumer CSS should read --k-focus for focus color. --k-focus-ring is kept as an alias for
existing styles, but it does not follow an inline --k-focus override on a descendant element.
Migration from brand-only retints. Before this change, overriding --k-brand also repainted
primary buttons, toggles, checkboxes, and focus rings. To keep that behavior (for example, a
release-channel accent), set --k-action, --k-action-contrast, and --k-focus alongside
--k-brand.
React Consumer#
Install locally while unpublished:
{
"dependencies": {
"@kontourai/ui": "file:../../ui"
}
}
Import tokens and primitive styles once:
import "@kontourai/ui/tokens";
import "@kontourai/ui/react/styles.css";
@kontourai/ui/tokens is the whole token layer — base tokens, themes, and the brand
@font-face rules. Importing tokens.css and themes.css individually gives you the
--k-font-* variables without the faces they name, so the surface renders in fallbacks.
Use primitives from the React entry:
import { Badge, Button, Panel, StatusBadge, Topbar } from "@kontourai/ui/react";
export function Header() {
return (
<Topbar
eyebrow="Console"
title="Run Review"
body="Package-backed primitives read the active --k-* theme."
actions={<Button>Refresh</Button>}
/>
);
}
Form controls share one control contract and pair with Field for the
label/hint/error row:
import { Field, Input, Select, Checkbox, Toggle } from "@kontourai/ui/react";
export function RunSettings() {
return (
<>
<Field label="Project name" htmlFor="name" hint="Shown in the run header.">
<Input id="name" placeholder="my-service" />
</Field>
<Field label="Runtime" htmlFor="runtime">
<Select
id="runtime"
placeholder="Choose a runtime"
options={[
{ label: "Claude Code", value: "claude" },
{ label: "Codex", value: "codex" },
]}
/>
</Field>
<Checkbox label="Require evidence" defaultChecked />
<Toggle label="Gate on readiness" defaultChecked />
</>
);
}
Dialog is a controlled modal built on the native <dialog> element (top-layer,
backdrop, focus trap, and Esc handling come from the platform):
import { useState } from "react";
import { Button, Dialog } from "@kontourai/ui/react";
export function ConfirmMerge() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="primary" onClick={() => setOpen(true)}>Merge</Button>
<Dialog
open={open}
onClose={() => setOpen(false)}
title="Confirm merge"
actions={
<>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="positive" onClick={() => setOpen(false)}>Merge</Button>
</>
}
>
Merging closes the gate. This action is recorded in the run receipt.
</Dialog>
</>
);
}
Dialog is a stateful primitive (it uses React hooks), unlike the pure-factory
display primitives. Vanilla consumers use the mirrored k-dialog custom element,
which owns the same behavior and class contract — toggle its open attribute to
show/hide and listen for the k-dialog-close event.
Toast/ToastHost are presentational — render them and drive their lifecycle
from app state. For an imperative "fire from anywhere" stack with auto-dismiss,
mount the k-toast-host element once and call its notify() method:
<k-toast-host id="toasts" placement="bottom"></k-toast-host>
<script type="module">
document.getElementById("toasts").notify({
tone: "positive",
title: "Readiness met",
message: "All gates passed.",
duration: 4000, // 0 keeps it until dismissed
});
</script>
Tooltip is a CSS-driven hover/focus bubble (pure factory); Popover is a
click-triggered panel (stateful — outside-click and Esc dismiss). Both anchor to
their trigger and layer at the --k-z-popover tier:
import { Button, Popover, Tooltip } from "@kontourai/ui/react";
<Tooltip label="Recorded in the run receipt">
<Button>Merge</Button>
</Tooltip>
<Popover trigger={<Button>Actions</Button>} placement="bottom-start">
<ul>…menu…</ul>
</Popover>;
Vanilla consumers use k-tooltip (label attribute + trigger child) and
k-popover (mark children with data-popover-trigger / data-popover-panel).
Custom Elements Consumer#
Load CSS and the element module:
<link rel="stylesheet" href="./vendor/ui/tokens/index.css">
<link rel="stylesheet" href="./vendor/ui/react/styles.css">
<script type="module" src="./vendor/ui/dist/elements/elements/src/index.js"></script>
Render light-DOM components:
<k-badge value="verified"></k-badge>
<k-status-badge status="connected"></k-status-badge>
<k-button label="Accept" variant="positive"></k-button>
Custom elements emit the same class contracts as the React primitives, so the shared react/styles.css file styles both renderers.
Static HTML Consumer#
Static sites should not depend on symlinks at runtime. Copy package assets into the served tree during build and run a drift check in verification.
Required assets:
tokens/— includingtokens/fonts/, which holds the woff2 facesfonts.csspoints atreact/styles.cssif using primitives or custom elementsreact/trust-state.cssinstead, if the only primitive is a string-rendered trust-state chipdist/react/trust-states.jsif the page renders trust-state chips as strings in the browserdist/elements/elements/src/if using custom elements
Rules:
- Do not fork token files without a sync/drift check.
- Keep the product identity in a theme class, not in copied primitive CSS.
- Use
--k-*tokens for local styles and derive product-specific aliases from--k-*.
Trust States#
Use a trust state, not a status tone, for the status of a claim. The states are Surface's claim
statuses, in Surface's order, with Surface's display names as default labels: unknown (No
evidence), proposed (Pending review), assumed, verified, stale (Needs refresh),
disputed, superseded, rejected, revoked. Each renders a text label, an SVG glyph, and a
line style, so the meaning survives without color; put the evidence in the detail.
import { TrustState } from "@kontourai/ui/react";
<TrustState state="verified" detail="12 source records matched" />
<TrustState state="stale" label="Expired" detail="Verification expired 3 days ago" />
<k-trust-state state="disputed" detail="Invoice and contract disagree on the amount"></k-trust-state>
<k-trust-state state="verified"><a href="#evidence">12 source records</a></k-trust-state>
labelreplaces the visible wording; the default label stays available to assistive technology as visually hidden text, and an empty label falls back to the default.- The element takes its detail from the
detailattribute, or else from its children. Children are read once, on the first render; children added later are not picked up. - A string detail is trimmed, and a blank one renders no detail, in React, the element, and
renderTrustStateHtmlalike. trustStateFor(value)accepts any casing and surrounding space and returnsnullfor anything else. An unrecognized state renders as its own text with no state styling or glyph.- Chart series can reuse the ink
--k-trust-<state>. The line token--k-trust-<state>-lineis a CSS border-style keyword, not a stroke value, so translate it (DESIGN.md, "Data Visualization"): solid is no dash array, dashed and dotted become astroke-dasharraypattern, and double has no stroke equivalent (draw two strokes or use the glyph as a marker).
Trust states as HTML strings#
Renderers that build markup as template strings (a server page, a shadow-DOM panel, a console
built without a framework) import @kontourai/ui/trust-state. It imports nothing, so it pulls in
neither React nor the custom elements, and it runs in Node or a browser.
import { renderTrustStateHtml } from "@kontourai/ui/trust-state";
panel.innerHTML = renderTrustStateHtml(claim.status, {
label: claim.statusLabel, // optional; blank falls back to the default label
detail: "12 source records matched", // optional, plain text
});
- The output is the markup
<k-trust-state>renders for the samestate,label,detail, andclass-name(a browser test compares them for every state). Every value written into the markup is HTML-escaped: the label, detail, class name, and an unrecognized state's text, and also the state name and glyph path. Pass plain text, not markup. - An unrecognized state renders exactly as the element renders it: its own text, with no state
class,
data-trust-state, or glyph. It is never coerced into a state. - The module also exports the data the three renderers share. The tables are frozen, so they
cannot be changed at runtime:
trustStates,trustStateLabels,trustStateGlyphs(the SVG path per state). It also exportstrustStateForandtrustStatePresentation(state, label, className, detail)(the classes, visible and hidden labels, glyph, and detail for one chip; a string detail is trimmed, and a blank one is none).
A string consumer must put two things on the page:
- The token layer:
@kontourai/ui/tokens(ortokens.cssandthemes.css, plusfonts.cssfor the faces). The chip reads the trust, neutral, space, type, and radius tokens. - The chip rules:
@kontourai/ui/trust-state.css. It holds only the trust-state rules and no tokens, so it can be inlined at build time. It is generated fromreact/styles.css(npm run check:trust-state-cssfails when they differ), so do not load both; a page that already loadsreact/styles.csshas these rules.
Trust Basis#
The trust-basis line follows a trust-state chip and says how the claim's status was established:
1 cited only · Extracted from a source · 2 entail the claim (caveats first). Surface computes it: pass the
view from claimBasisView in @kontourai/surface/display as-is. Kontour UI does not depend on
Surface; your app does.
import { claimBasisView } from "@kontourai/surface/display";
import { TrustBasis, TrustState } from "@kontourai/ui/react";
// Pass the claim's evidence or the whole bundle's: only evidence linked to this
// claim (claimId === claim.id) is used, never execution-trail tool calls.
const basis = claimBasisView(claim, bundle.evidence);
<TrustState state={claim.status} />
<TrustBasis basis={basis} />
<TrustBasis basis={basis} density="inspector" />
<k-trust-state state="verified"></k-trust-state>
<k-trust-basis id="basis"></k-trust-basis>
<script type="module">
import { claimBasisView } from "@kontourai/surface/display";
document.querySelector("#basis").basis = claimBasisView(claim, bundle.evidence);
</script>
<!-- Or serialize the view into an attribute: -->
<k-trust-basis density="inspector" basis-json='{"state":"not-recorded","label":"Basis not recorded"}'></k-trust-basis>
- Do not build, filter, reorder, or relabel facets yourself; the line's order (caveats first) and words are Surface's, so every product shows the same basis.
- For a permission denial or a failed read, which only the host knows about, pass
missingClaimBasisView("restricted")ormissingClaimBasisView("unavailable"), ormissingClaimBasisView("not-available")when you cannot or should not say which. - The line is never blank. A missing or malformed view renders "Basis not available" and logs a console warning. One malformed facet or detail row is enough: the component never renders a partial line, since that could drop a caveat.
density="inspector"adds one labelled row per detail (How, Support, Results, Derived, Review, Producer rating, Calibrated confidence, Sources) below the line.- The
basisproperty takes precedence overbasis-json; setting it tonullorundefinedfalls back to the attribute. Abasisset before the element is defined is kept. - Caveat facets are underlined with a dashed line in the text's own color
(
--k-basis-caveat-decoration). Style the line with neutral tokens only; do not color it by status or caveat.
Marks#
Product marks: ProductIcon (or StationIcon, FlowIcon, …) in React, <k-product-icon product="station"> as an element, and @kontourai/ui/icons/<name>.svg as files. Corporate
marks: BrandMark (KontourSymbol, KontourWordmark, KontourLockup), <k-brand-mark mark="kontour-lockup-horizontal">, and the kontour-*.svg files. The owner decided the
corporate symbol and the "Kontour" wordmark on 2026-10-05. The lockup, the wordmark's cut and
the usage rules were adopted with them as proposed; DESIGN.md records which is which.
Every mark paints with currentColor, so it takes the surrounding text color. size is the
height in px.
<KontourLockup size={32} /> {/* announced as the image "Kontour" */}
<KontourLockup size={32} decorative /> {/* hidden: visible text beside it says the name */}
<KontourSymbol size={24} title="Kontour" /> {/* the symbol needs a title to be announced */}
<k-brand-mark mark="kontour-wordmark"></k-brand-mark>
<k-brand-mark mark="kontour-lockup-horizontal" decorative></k-brand-mark>
The wordmark and lockup spell the company name, so they are named "Kontour" for assistive tech
unless you pass title or decorative. The symbol and the product marks are hidden unless
given a title. An unknown mark renders nothing.
The package declares sideEffects, so a bundler drops the marks you do not import; importing
@kontourai/ui/elements for its effect still registers every element.
Tone Mapping#
Product-specific domain words should map to the shared semantic scale:
positivecautionnegativeactiveneutral
Examples:
- Survey
verified-> positive - Flow
current-> active - Review
blocked-> negative - Queue
pending-> caution or neutral, depending on product semantics
Verification#
Package consumers should run their own build/test command plus any asset drift check. The current cross-adopter command matrix lives in docs/release-readiness.md.
Explorer#
docs/gallery.html is the package's development/reference explorer. Its
docs/explorer-manifest.json is generated from the real public React exports,
custom-element registration AST, CSS token declarations, and CSS theme selectors.
Each curated component declares the CSS classes its implementation renders; the
manifest derives the complete var(--k-*) dependency set for those classes,
including spacing, typography, focus, and motion tokens. The explorer check
independently recomputes that mapping, so incomplete token lists are rejected.
Its generated text controls use visible native labels, so placeholders are only
supplemental hints. Regenerate it with
node scripts/generate-explorer-manifest.mjs --write; npm run check:explorer
rejects stale manifests or incomplete metadata. The explorer is documentation
only and is not an importable production bundle for adopters.