@codefusion-cc/ui
The design tokens every CodeFusion app is built on, and the components built on them: one contract of roles (page, ink, accent, the status colours, six chart colours, fonts, radii, shadows, control heights, motion), looks that fill it, a Vite plugin that turns the looks into one stylesheet at build time, and a React kit drawn with Tailwind in those roles. A look is checked as you write it: TypeScript refuses a role left out or one the contract does not have, and the build fails on any pair of colours under WCAG 2.2 AA.
npm install @codefusion-cc/ui
| Entry | What it holds |
|---|---|
@codefusion-cc/ui/looks | defineLook, defineAppTokens, lookProblems, looksCss, lookTokens, the contract (ROLES, PAIRS) and its types |
@codefusion-cc/ui/vite | uiLooks(): the looks as CSS, and the /__looks preview in development; uiButtons(): data-button="secondary" in static HTML becomes buttonClass's classes (bare data-button is the primary); expandClassAttribute for other such attributes |
@codefusion-cc/ui/button-class | buttonClass without React, for pages and build tools |
@codefusion-cc/ui/react | the kit: Button, buttonClass, buttonSizeClass, Loading, ErrorState, Spinner, UiProvider, usePageLock |
@codefusion-cc/ui/words | the kit's words in Polish, English and German (pl, en, de) |
@codefusion-cc/ui/tailwind.css | the roles as Tailwind's theme, and the kit's sources for Tailwind to scan |
@codefusion-cc/ui/tailwind-merge | createCx: the app's cx, tailwind-merge knowing the kit's theme values (tailwindMergeConfig) |
@codefusion-cc/ui/react/states | the kit's state lists, for /__looks and visual tests |
@codefusion-cc/ui/page-lock | lockPageBehind, pageScrollY: the page behind a modal dialog or a drawer stays still, on iOS Safari with the keyboard open too |
@codefusion-cc/ui/testing | assertLook, assertLookComplete, three fixture looks, defineStates and the state registry, synthetic touches (drag, touch) |
@codefusion-cc/ui/client | the types of virtual:codefusion-ui/looks.css, for an app's tsconfig |
@codefusion-cc/ui/tokens.css | the contract alone at its fallbacks, for a page with no looks of its own |
@codefusion-cc/ui/tokens.json | the contract as Design Tokens (DTCG 2025.10) |
A look
A look module default-exports defineLook(...): its name (the value of data-look), every colour and shadow for light
and for dark, and the values the same in both.
// src/looks/kuznia.ts
import { defineLook } from '@codefusion-cc/ui/looks'
export default defineLook({
name: 'kuznia',
light: {
page: 'oklch(0.965 0.012 85)',
surface: 'oklch(0.99 0.006 85)',
raised: 'oklch(0.93 0.016 85)',
ink: 'oklch(0.2 0.012 60)',
muted: 'oklch(0.45 0.02 60)',
line: 'oklch(0.86 0.016 80)',
accent: 'oklch(0.5 0.19 35)',
'accent-2': 'oklch(0.42 0.09 250)',
'on-accent': 'oklch(0.99 0.006 85)',
inverse: 'oklch(0.2 0.012 60)',
'on-inverse': 'oklch(0.965 0.012 85)',
media: 'oklch(0.9 0.012 85)',
focus: 'var(--cf-accent)',
ok: 'oklch(0.48 0.11 150)',
warn: 'oklch(0.52 0.12 65)',
danger: 'oklch(0.5 0.19 27)',
info: 'oklch(0.45 0.12 250)',
'chart-1': 'oklch(0.5 0.19 35)',
'chart-2': 'oklch(0.42 0.09 250)',
'chart-3': 'oklch(0.5 0.1 150)',
'chart-4': 'oklch(0.55 0.13 65)',
'chart-5': 'oklch(0.45 0.13 320)',
'chart-6': 'oklch(0.35 0.02 60)',
'shadow-card': '0 1px 2px oklch(0.2 0.012 60 / 0.08), 0 6px 20px -8px oklch(0.2 0.012 60 / 0.18)',
'shadow-lift': '0 2px 4px oklch(0.2 0.012 60 / 0.1), 0 18px 40px -12px oklch(0.2 0.012 60 / 0.3)',
},
dark: {
page: 'oklch(0.17 0.008 60)',
surface: 'oklch(0.21 0.01 60)',
raised: 'oklch(0.26 0.012 60)',
ink: 'oklch(0.94 0.014 85)',
muted: 'oklch(0.74 0.018 80)',
line: 'oklch(0.34 0.014 60)',
accent: 'oklch(0.74 0.15 40)',
'accent-2': 'oklch(0.76 0.09 250)',
'on-accent': 'oklch(0.17 0.008 60)',
inverse: 'oklch(0.94 0.014 85)',
'on-inverse': 'oklch(0.17 0.008 60)',
media: 'oklch(0.3 0.012 60)',
focus: 'var(--cf-accent)',
ok: 'oklch(0.78 0.13 150)',
warn: 'oklch(0.82 0.13 80)',
danger: 'oklch(0.72 0.15 27)',
info: 'oklch(0.76 0.1 250)',
'chart-1': 'oklch(0.74 0.15 40)',
'chart-2': 'oklch(0.76 0.09 250)',
'chart-3': 'oklch(0.78 0.12 150)',
'chart-4': 'oklch(0.82 0.13 80)',
'chart-5': 'oklch(0.74 0.13 320)',
'chart-6': 'oklch(0.85 0.02 85)',
'shadow-card': '0 0 0 1px oklch(1 0 0 / 0.06), 0 6px 20px -8px oklch(0 0 0 / 0.6)',
'shadow-lift': '0 0 0 1px oklch(1 0 0 / 0.08), 0 18px 40px -12px oklch(0 0 0 / 0.75)',
},
both: {
'font-display': '"Fraunces", "Iowan Old Style", Georgia, serif',
'font-body': '"Inter", system-ui, sans-serif',
'font-mono': '"JetBrains Mono", ui-monospace, monospace',
'radius-card': '14px',
'radius-ctl': '10px',
'control-touch': '44px',
'control-mouse': '32px',
'duration-press': '90ms',
'duration-quick': '160ms',
'duration-move': '280ms',
'duration-settle': '480ms',
'ease-out': 'cubic-bezier(0.2, 0.8, 0.2, 1)',
'ease-in': 'cubic-bezier(0.4, 0, 1, 1)',
'ease-state': 'cubic-bezier(0.34, 1.3, 0.5, 1)',
},
})
- Colours may be in any CSS notation (oklch, hex, rgb, hsl, lab,
color(display-p3 …)),var(--cf-<role>)of another role, orcolor-mix()of these. They are written out as OKLCH, and what is measured is that written value read back, as Chromium, Firefox and WebKit paint it (a colour past the screen clipped channel by channel), so rounding never moves a pair across its line; a pair passes at its ratio exactly. Refused, since browsers paint them differently or they cannot be measured: a mix in hsl or hwb,noneinside a mix, a colour past OKLCH chroma 1,light-dark()(a look gives each mode its own value),currentColor, system colours and relative colours. Colours that refer to each other in a loop are refused too, light and dark together, since CSS reads them as onelight-dark(). - Fonts end in a generic family (
serif,sans-serif,system-ui, …), and the mono font inmonospace. A family's name holds letters, digits, spaces and. _ & + -. - Control heights are at least 44px for touch and 24px for a mouse, in px: a rem follows the root font size,
which the app sets, not the look.
--cf-controlis the one for the pointer in use. - Durations become
0swhen the system asks for reduced motion. - Bounds: a value and an app role's description have at most 4,000 characters, a look's name, a role's name and
an app's prefix 64, and color-mix() nests 8 deep at most. Lengths stay within 10,000 px or rem either way, a
target's min too, so a declared min can always be met. Durations run up to a minute, and an easing's y values stay
within -10 to 10. An app has at most 200 roles and 400 pairs, and
looksProblemstakes at most 100 looks. The bounds apply to what a look or an app gives, once: the CSS written from it may be longer (a colour written as oklch()). Something past a bound is refused before any work in proportion to it, with one exception: before a check can count an object's keys, the JavaScript engine lists all of them, so an object of a million keys costs that listing however soon it is refused. - Work: a check (
lookProblems,looksProblems,appTokensProblems,defineAppTokens) has one work budget,WORK_BUDGETsteps, and each step is a bounded amount of work: an element gone through, a problem found, a character read, a colour computed. Past the budget, it answers that the input is too large to measure, a problem that stops the look from being written. Each value is written once however often it is asked for, and the two fixture looks ten times over spend a third of the budget. A check answers at most 100 problems, those that stop a look from being written first, and a last one saying how many more there are and how many of them block. - Optional roles have a fallback mixed from the others, so a look sets them only to differ from it, and is
measured either way:
<tone>-tint(a wash on the surface),<tone>-ink(text in that tone) and<tone>-line(its edge) for accent, ok, warn, danger and info;accent-hover,inverse-hoverandhover; the secondary button'scontrol-fill,control-line,control-inkand their-hovers, the quiet one'sghost-ink(under the pointer it keeps its text and takes thehoverveil), the link one'slink-ink(the accent's ink);shadow-control(none); and inboth,control-pad(20px),control-case(none, oruppercasefor buttons set in capitals),control-tracking(0) andcontrol-disabled(0.6, how much of a disabled or busy control shows, 0.25 to 1). A colour role a look sets is set in both modes or in neither.
One mode only
A look made for one mode says so. The page then stays in that mode even when the theme asks for the other; the other mode is never made up from it.
export default defineLook({ name: 'papier', only: 'light', light: { … }, both: { … } })
TypeScript refuses a dark part on it, and /__looks shows the missing mode as such.
An app using it
// vite.config.ts
import { defineConfig } from 'vite'
import { prePaintTheme } from '@codefusion-cc/theme/vite'
import { uiLooks } from '@codefusion-cc/ui/vite'
import { themeConfig } from './src/themeConfig.ts'
export default defineConfig({
plugins: [
prePaintTheme(themeConfig),
uiLooks({ looks: ['./src/looks/kuznia.ts', './src/looks/noc.ts'], default: 'kuznia' }),
],
})
// src/main.ts, before anything else: the contract and every look, as CSS. No look code reaches the page.
import 'virtual:codefusion-ui/looks.css'
Import it from a script, not with @import from a stylesheet another tool post-processes. Its light-dark() must reach
the browser as written, and the plugin makes sure Vite's own minifier leaves it alone.
TypeScript learns of the module from the package's client types, next to Vite's:
// tsconfig.json
{ "compilerOptions": { "types": ["vite/client", "@codefusion-cc/ui/client"] } }
The theme package picks the look and the mode. It sets data-look and color-scheme on <html>, and the CSS reads
both. The default look is shown on the page wherever data-look names no other look: before the theme sets it, and
when it names a look the app no longer has, as a visitor's old pick may.
// src/themeConfig.ts
import { defineTheme } from '@codefusion-cc/theme'
export const themeConfig = defineTheme({
storageKey: 'myapp-theme',
html: { class: 'dark' },
choices: { look: { storageKey: 'myapp-look', values: ['kuznia', 'noc'], default: 'kuznia', attribute: 'data-look' } },
})
Then use the roles anywhere:
.card {
background: var(--cf-surface);
color: var(--cf-ink);
border-radius: var(--cf-radius-card);
box-shadow: var(--cf-shadow-card);
}
.card:hover { background: color-mix(in oklab, var(--cf-surface), var(--cf-hover)); }
button { min-height: var(--cf-control); transition: background var(--cf-duration-quick) var(--cf-ease-out); }
:focus-visible { outline: 2px solid var(--cf-focus); outline-offset: 2px; }
With Tailwind 4.1 or later, import the roles as Tailwind's theme once, after Tailwind, and write bg-surface,
text-muted, border-danger-line, rounded-card, min-h-control, shadow-lift, ease-state:
/* src/index.css */
@import "tailwindcss";
@import "@codefusion-cc/ui/tailwind.css";
Every colour role is a colour (bg-<role>, text-<role>, border-<role>, bg-accent/40), and the shadows, radii
(rounded-card, rounded-ctl), fonts (font-display, font-body), min-h-control, px-control-pad,
tracking-control and ease-state are there too. Each class reads its custom property itself, so a part of the page
in another look follows it. Nothing of Tailwind's own is redefined: font-sans, shadow-sm and rounded-xl stay as
they were. The file also points Tailwind at the kit's sources, so the kit's classes land in the app's stylesheet once,
shared with its screens.
The cascade
The stylesheet declares one layer, cf.tokens, and the looks are in it. Roles are custom properties, so the layer
only decides who sets them: an app's own --cf-* wins over the looks whatever its selectors.
Another look in one part of the page
A colour role is computed where data-look is set. A part of the page shown in another look or mode sets both on the
same element:
<section data-look="noc" style="color-scheme: dark">…</section>
An app's own tokens
What only one app has (a conversation's two sides, a drawn piece's sizes) goes under the app's prefix, typed and checked like the contract's:
// src/looks/tokens.ts
import { defineAppTokens } from '@codefusion-cc/ui/looks'
export const hh = defineAppTokens(
'hh',
{
me: { type: 'color', description: 'Your side of a conversation.', fallback: { light: '#b8460f', dark: '#f19359' } },
ring: { type: 'dimension', description: 'The width of the speaking ring.', fallback: '3px' },
},
{ pairs: [{ fg: '--hh-me', bg: '--cf-page', kind: 'ui' }] },
)
// in a look: app: { tokens: hh, light: { me: '#b8460f' }, dark: { me: '#f19359' }, both: { ring: '3px' } }
They are written as --hh-me and --hh-ring, registered and given their fallbacks like the contract's. Every look
sets them, a look without its own values at their fallbacks, so a look inside another takes nothing from it. A
fallback is checked as a look's value is when the tokens are defined (its type, a target's min, the pairs it is in,
over the contract's fallbacks), and the build measures every look with each other app's fallbacks too.
Checking a look
The build fails on any problem, whether or not the app imports the stylesheet, and the dev server warns of them.
looksProblems(looks) returns the same list and lookProblems(look) one look's (neither throws), for looks loaded
from anywhere. In an app's tests:
import { assertLook } from '@codefusion-cc/ui/testing'
import kuznia from '../src/looks/kuznia.ts'
test('kuznia passes', () => assertLook(kuznia))
It checks every role in both modes; each text pair (ink and muted on page, surface and raised; on-accent on accent;
each tone's ink on its tint, …) against 4.5:1, and each part of the interface (accent, focus, status and chart colours
on their grounds) against 3:1. Translucent colours are blended over what they are painted on, and a colour outside
sRGB is measured on both an sRGB and a Display P3 screen. line is decorative and not measured.
The kit
React components in the look's roles, styled with Tailwind classes the app's own Tailwind generates (the import above). They need React 19 with React DOM, and Tailwind 4.1 with its preflight.
import { Button, ErrorState, Loading, UiProvider, buttonClass } from '@codefusion-cc/ui/react'
import { pl } from '@codefusion-cc/ui/words'
<UiProvider words={pl}>
<Button pending={saving} pendingLabel="Zapisywanie…" onClick={save}>Zapisz</Button>
<Link to="/koszyk" className={buttonClass({ tone: 'secondary' })}>Koszyk</Link>
{orders.failed ? <ErrorState onRetry={reload}>Nie udało się wczytać zamówień.</ErrorState> : <Loading />}
</UiProvider>
Button:toneisprimary(the accent, the one action of a screen),inverse,secondary(outlined),ghost(no fill until hovered),linkorplain(the shape alone).sizeismd(--cf-controltall: 44px at least on touch),smoricon(40px; an icon-only button takes anaria-label). It istype="button"unless told otherwise. Whilependingit keeps its focus and its place in the tab order but takes no click and submits no form (a second press, or Enter in a field, does nothing), turns itsiconor shows a spinner, and tells a screen reader it is working.disabledis the browser's own: no focus, no press. No tone changes under the pointer while disabled or pending; a hover an app adds toplain(or to any tone) takes the same guard,not-disabled:not-aria-disabled:hover:…, or a busy button lights up as if it could be pressed.link: a quiet action among words ("Try again" after a failure, "Remove" in a row), drawn as a link. No fill, edge, padding or text size of its own: it is as big as the text around it and sits on its baseline, inlink-ink, semibold, underlined under the pointer. It is still a button to the keyboard and a screen reader, with the focus ring, pending and disabled states of the others, and keeps its underline in forced colours. It takes nosizeorheight. Another colour is the app's:cx(buttonClass({ tone: 'link' }), 'text-muted').- Sizes:
smandiconstay 40px on touch screens too. WCAG 2.2 AA (2.5.8, target size minimum) asks for 24px; 44px is AAA. The apps' small buttons are 40px today, and making them 44px on phones would change their looks, which the kit must not do. Do not raise them to 44px without each app's owner agreeing. height="padding": for an app whose buttons are as tall as their padding and label make them (44px formdwith a line oftext-sm, 36px forsm), whatever the pointer; the default,control, givesmdthe look's control height andsm40px at least. Withpaddingthe app answers for the target size itself.buttonClass({ tone, size, height }): the same classes for a link or anything else that should look like a button.buttonSizeClass(size, { height }): a size's classes alone, for a size added to a tone of the app's own.Loading: a spinner and "Loading…" (orlabel), announced politely (role="status").ErrorState: the failure in the danger tone, announced at once (role="alert"), with a "Try again" button when givenonRetry. WhenonRetryreturns a promise the button waits for it, fulfilled or rejected;retryingis for a retry the app tracks itself. It takes adiv's attributes andreftoo: anidfor a field'saria-describedby,tabIndex={-1}for a form that moves the focus to its message (which shows no ring).- Words:
UiProvider words={pl | en | de}sets the few words the kit says itself; English without one. classNameplaces a component (margin, width, a grid or flex item). It does not restyle one: two classes for one property apply in Tailwind's order, not in the order they are written. To restylebuttonClass()on an element of the app's own, merge the classes with tailwind-merge (the app's, 3 or later), which needs to know the kit's theme values:export const cx = createCx()from@codefusion-cc/ui/tailwind-merge, thencx(buttonClass({ size: 'sm' }), 'min-h-11 px-5').createCx({ extend: { theme: { radius: ['panel'] } } })adds the app's own values, as tailwind-merge'sextendTailwindMergetakes them.- A word too long for the whole line breaks instead of pushing the page sideways; in a crowded row a button keeps its
words whole, as text beside it does. That holds for a label of plain text (
Cart ({count})too); give an icon throughiconrather than among the words. The spinner turns slowly where motion is to be reduced, and in forced colours every button keeps an edge (a link its underline).
Windowed lists
WindowedList mounts only the fixed-height rows in the window plus overscan (4 by default) and the focused row.
It scrolls with the page, with no inner scroll box. Rows absent from the page are absent from browser find-in-page,
so provide a search or filter for large collections. Each row is an li with its position and the full list size.
rowClassName supplies its role classes; as="ol" uses an ordered list. Other attributes go on the list itself:
aria-label names it, aria-busy says a page is loading. Stable, unique keys keep the reader's first visible row in place when rows are
inserted or removed above it, or its breakpoint height changes. An appended page needs no scroll correction. While a
list is mounted, the browser's own scroll anchoring is off for the page (overflow-anchor: none on <html>): it
would anchor on what follows the list and carry a reader at the end along with every page appended.
import { useCallback } from 'react'
import { WindowedList } from '@codefusion-cc/ui/react'
const getKey = useCallback((index: number) => orders[index].id, [orders])
<WindowedList
aria-label="Orders"
count={orders.length}
getKey={getKey}
className="[--row-height:56px] sm:[--row-height:64px]"
renderRow={index => <a href={`/orders/${orders[index].id}`}>{orders[index].number}</a>}
/>
Use a positive rowHeight in pixels, or a positive px-only --row-height on the list. While --row-height cannot be
read (its stylesheet still loading), the first rows stay, one error is logged, and the next resize measures again. CSS is read once per
ResizeObserver callback; content must fit that fixed row height. Lists whose contents wrap to different heights
need a different layout or a variable-height implementation. The list owns its positioning and total height;
keep its own padding at zero. Put row padding inside the fixed height, through rowClassName or its contents.
A scroll, a jump, a Tab step or a breakpoint change renders its new rows before the browser paints that frame,
so keep renderRow cheap.
useWindowedList({ count, rowHeight?, overscan?, getKey }) exposes { listProps, rows, scrollToIndex } for a list
that supplies its own row attributes. Spread listProps on the ul or ol; give each li its row's key and
style, aria-setsize={count}, and aria-posinset={index + 1}. Keep keys stable and unique across updates.
Every key is rebuilt when getKey changes, so memoize it with useCallback, for the hook and the component alike.
Tab and Shift+Tab mount the adjacent row at a window boundary, while focus keeps a row mounted after it leaves
the viewport. scrollToIndex(i) scrolls smoothly unless motion is reduced, when it jumps (behavior: 'instant',
which a page's scroll-behavior: smooth does not animate).
On the server the first overscan rows render without measuring CSS; hydration measures before the first paint.
windowRange({ scrollY, listTop, viewport, rowHeight, count, overscan? }) returns the pure half-open range.
Invalid geometry, duplicate keys, or an out-of-range scroll index are programming errors and throw.
The page behind a dialog
overflow: hidden on the body does not hold on iOS Safari: with the keyboard open, a drag on the dialog moves the page
behind it. lockPageBehind(dialog) pins the body at its scroll offset and cancels any one-finger drag that nothing
inside the topmost dialog can scroll, unless it lands on a slider or an element with touch-action: none. Two
fingers still zoom. Locks nest: the last unlock puts the body's styles and the scroll offset back. Every copy of the
package on the page shares one lock.
import { usePageLock } from '@codefusion-cc/ui/react'
usePageLock(dialogRef, open)
Code outside React calls lockPageBehind(element) of @codefusion-cc/ui/page-lock and the function it returns.
The pinned body keeps sticky elements in place (Firefox moves them with the page), but window.scrollY reads 0 while it
is pinned: a header that changes its look once the page is scrolled reads pageScrollY() of
@codefusion-cc/ui/page-lock instead, so it stays the same behind the dialog. usePageLock pins the page after the
dialog's first paint, so the frame that answers the tap does not wait for it, and gives it back in a layout effect, so a
dialog closed by a navigation gives the page back before a router's scroll restoration runs.
A test drives it with drag(target, from, to) from @codefusion-cc/ui/testing, which is true when the move was
cancelled. Headless engines have no touch screen, so what iOS itself does is checked on a phone.
The preview
npm run dev, then open /__looks: the token sheet and every registered state list, in each look, light and dark
side by side, with the contrast of each pair. A look file changed updates it in place. ?look=kuznia shows one look;
?list=button&look=kuznia&mode=dark shows one list in one look and mode, the whole page in it, with nothing around:
what a visual test shoots.
State lists are how the shared components (and an app's own) show each state they can be in. Name the modules in
uiLooks({ states: ['./src/states/buttons.ts'] }), and for states drawn with the app's classes, its stylesheet in
styles. The kit's own lists are @codefusion-cc/ui/react/states:
uiLooks({ looks: ['./src/looks/kuznia.ts'], states: ['@codefusion-cc/ui/react/states', './src/states/buttons.ts'], styles: ['./src/index.css'] })
A list names the page widths its visual test shoots it at (default [390], a phone; add 1280 where the layout
changes).
// src/states/buttons.ts
import { defineStates } from '@codefusion-cc/ui/testing'
export default defineStates({
id: 'buttons',
title: 'Buttons',
states: [
{ name: 'Idle', props: { label: 'Save' } },
{ name: 'Busy', props: { label: 'Saving', busy: true } },
],
render(target, props) {
const button = document.createElement('button')
button.textContent = props.label
button.disabled = Boolean(props.busy)
target.append(button)
},
})
Visual tests
packages/web/ui/visual/ is an app of the three fixture looks and the kit's state lists. Its suite shoots every list in
every look, light and dark, at each of its widths, one forced-colours picture a list, and checks that every look and
mode gives a screen reader the same tree. The pictures are compared in the pinned Playwright image only
(@codefusion-cc/testing), through a container:
pnpm visual
The Visual workflow runs it on every pull request that touches packages/web/ui, in the same image, on Tenki's Linux
runner (the repository variable CI_RUNNER_DOCKER names another): our own runners give a job no Docker, and GitHub's
hosted runners are refused at the organization's $0 spending limit. Run it locally too before a look change goes up.
On a Mac it needs a container runtime that runs linux/amd64 images, such as Colima with Rosetta
(colima start --vm-type vz --vz-rosetta).
A change that alters how the kit looks on purpose takes the pictures again with pnpm visual:update and commits
them; the pull request shows each one changed. Without a container runtime, or for a new Playwright image, the Visual
workflow takes them in CI: gh workflow run visual.yml --ref <branch> -f update=true, then commit what its
visual-baselines artifact holds.
Design tokens
@codefusion-cc/ui/tokens.json is the contract in the DTCG format, and lookTokens(look) turns a look into the same
format, for design tools. Colours are OKLCH components with their hex. A colour that only names another role stays an
alias ({color.accent}). Both modes and the custom property are under $extensions["cc.codefusion"], and an app's
tokens under app.<prefix>.<type> (app.hh.color.me).