ComponentsLayout & themingTheme
On this page
Layout & theming

Theme released

The theme as an attribute: light, dark, or the system's own answer.

  • Import@pacit/components/theme
  • Selector[pctTheme]

No APG pattern applies, and none is invented — the reasoning is under Accessibility ↓

A dark island: one attribute, every token below it flips.

theme.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctTheme } from '@pacit/components/theme';
ExportedWhat it is
PctThemeNametype'light' | 'dark'The two themes the shipped skin keys its blocks on.
PctThemedirective[pctTheme]Theme as a template binding: [pctTheme]="'dark'" writes data-theme="dark" on the host — the attribute the generated skin keys its theme blocks on — and null removes it, so the system preference speaks again (req-token-system).
<section pctTheme="dark">
  <p>A dark island: every token below it flips.</p>
</section>
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

An island that switches

The directive writes one attribute; the tokens under it do the rest. Bind it to a signal and a whole region of the page changes theme without touching the document.

This island is dark.

API

What it reads, and what it writes back

Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.

Theme as a template binding: [pctTheme]="'dark'" writes data-theme="dark" on the host — the attribute the generated skin keys its theme blocks on — and null removes it, so the system preference speaks again (req-token-system).

Inputs

NameTypeWhat it does
pctThemerequiredPctThemeName | nullThe theme to pin on this subtree, or null to follow the page.

Outputs

None of its own. The native events — (click), (focus), the form's submit — are the platform's, and two-way models above carry the rest.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
data-themepctTheme()
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctThemeHarness from @pacit/components/testing reads a part by the name below and a state by its attribute — the harness gate holds the names to the package.

No parts of its own — the element you write is the whole of it.

Tokens

Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.

No tokens of its own — it paints from the semantic tier.

Accessibility

Measured, not assumed

Pattern: none — the directive writes one data-* attribute and touches nothing the accessibility tree carries. Named in the class JSDoc.

Keyboard

keyeffecttest

Empty by design: not interactive, adds nothing focusable, intercepts nothing.

What is measured, and what is not yet

Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.

MeasuredARIA pattern named in the class JSDoclibs/components/theme/src/theme.ts
MeasuredKeyboard map tested key by keythe empty map is the claim: theme.spec.ts › "writes nothing else" — one attribute is the whole surface
Measuredaxe audit on the component's own sandbox viewapps/sandbox-e2e/src/a11y.spec.ts — the directive drives the demo stage on every view of the sweep, and the kitchen-sink's directive panel sits in /all
MeasuredVisual screenshotdeliberately none of its own: the directive paints nothing — every dark-stage baseline in visual.spec.ts is already a picture of its effect
n/aforced-colors: active — no state carried by colour alonenot applicable — it paints nothing
n/aprefers-reduced-motion — duration from a tokennot applicable — nothing moves
n/aTouch target ≥ 24×24 px outrightnot applicable — nothing here is a target
n/aSize axis aligned to --pct-control-height-*not applicable
GapDensity axisnone — gap: the same one every component here has (req-token-density)
n/aRTL — no physical properties + a dir="rtl" screenshotnot applicable — no stylesheet at all
MeasuredSSR + hydration with no NG05xxapps/sandbox-e2e/src/hydration.spec.ts — the stage carries the directive on every route of the sweep
n/aFormsnot applicable
MeasuredParts registered in the inventorylibs/components/parts.snapshot.md — a component with no parts is itself the entry
n/aTokens registered + an entry in contrast.policy.jsonnot applicable — no tokens of its own; the pairs it switches between are the skin's, measured per theme by every tokens:build
n/aStrings through PCT_TEXTSnot applicable
MeasuredEntrypoint size budgetlibs/components/size.snapshot.md — the ./theme row, on @angular/core alone: one host binding is one host binding
MeasuredScreen-reader logRead 2026-09-16 from docs/acr/at/ — three readers, whole sandbox, and it is measured on every view rather than on one: the scaffold carries a theme switch to all thirty-six, and two of the three read the CONTROL while the third names only the box around it: Orca Theme · panel · light · selected radio button. and VoiceOver light selected radio button, 1 of 2 Theme radio group both give the option and its state, while NVDA gives Theme, grouping at every one of its stops and says neither light nor selected — a user arriving there is told a group exists and not which theme is on. The theme itself is never announced: no reader said a word when the attribute changed the colours, which is the promise here
MeasuredA docs page with live examplesapps/sandbox/src/app/views/kitchen-sink/ (the /all view: the raw panel and the directive panel side by side). The published site: /components/theme — prerendered, the demo's own source is the code tab
MeasuredUnit + mutationlibs/components/theme/src/theme.spec.ts — 3 cases; in mutation.policy.json from day one — the day's shared full run generated zero mutants for one host binding, so the snapshot owes it no row until the directive grows logic
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

—mutation scoreno mutants to kill — the policy says why
4 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
0colour pairs measuredlight and dark, at every token build
0visual baselinesChromium, the sandbox's own pictures
4elements in the preview0 listeners · 1 render to settle · 1811 µs in jsdom, held exactly but for the clock

Why it is built this way

Sugar over the cascade (0059): [pctTheme]="'dark'" writes data-theme="dark" — the attribute the generated skin keys its theme blocks on — and null removes it, so the system preference speaks again. The mechanism stays the cascade itself; the sandbox measures a hand-written panel and a directive panel reading the same computed surface.

Known limitations

  • The app root keeps writing data-theme by hand — a bootstrap component has no template around it to bind an input on. One writer the directive cannot replace (0059).
  • Two theme names, no registry of skins. 'light' | 'dark' mirrors the blocks the skin actually ships; a third theme is a token-build feature first, and the union widens the day the build emits it.
  • No 'system' value. Following the system is the absence of the attribute, so it is spelled null — a 'system' string would be a third state pretending the attribute can express it.