# @pie-players/pie-theme

Shared PIE theming primitives and the `pie-theme` custom element.

This file is the reference. For how the pieces fit — why a host stylesheet
cannot override a mounted element, what the provider choice actually decides, and
how a host carries an accommodation into its own chrome — see
[`docs/theming/how-theming-works.md`](../../docs/theming/how-theming-works.md).

`pie-theme` resolves PIE theme tokens (`--pie-*`) with this precedence:

1. Base PIE theme (`theme=light|dark|auto`)
2. Provider adapter output (for example DaisyUI tokens)
3. Resolved registered color scheme (`scheme`)
4. Explicit `variables` overrides

Base themes and built-in color schemes are authored once in TypeScript. The
runtime resolver and the checked-in CSS adapters use those same definitions, so
the managed custom-element path and the stylesheet-only path cannot carry
different palettes.

`--pie-surface` is a registered, public `canonical-semantic` token for raised
surfaces such as answer pools and inline TTS panels. It is a required Scheme
Participant, with values in both base themes, every built-in scheme, and the
DaisyUI adapter. It is distinct from the page role `--pie-background`; text and
controls on it must retain their contrast. Hosts can override it through
`variables`, or the normal cascade in stylesheet-only delivery.

## Entrypoints and styles

Importing the package root registers `<pie-theme>`:

```ts
import "@pie-players/pie-theme";
import "@pie-players/pie-theme/tokens.css";
import "@pie-players/pie-theme/color-schemes.css";
import "@pie-players/pie-theme/font-sizes.css";
```

The lower-level element entrypoint is intentionally side-effect-free:

```ts
import { definePieTheme } from "@pie-players/pie-theme/theme-element";

definePieTheme();
```

Keep that distinction when integrating or bundling the package. Existing hosts
depend on the root entrypoint registering the element and on `theme-element`
waiting for an explicit call.

The four stylesheet artifacts remain available at both their package export and
literal `dist` paths:

- `tokens.css`
- `color-schemes.css`
- `font-sizes.css`
- `components.css`

They are unlayered deliberately. In stylesheet-only delivery, a host may
continue to override public tokens through normal source order, specificity, or
`!important`.
The element-name portion of the base-theme adapter uses `:where(...)`, keeping
its specificity below a later `[data-color-scheme]` rule. In a stylesheet-only
integration, that lets a host scheme override the generated base theme through
the normal cascade without `!important`.

```html
<pie-theme theme="auto" scope="document">
  <pie-section-player></pie-section-player>
</pie-theme>
```

## Custom element interface

- `theme`: `light | dark | auto`
- `scope`: `self | document`
- `provider`: provider id, `auto` (default), or `none`
- `scheme`: requested color-scheme id (`default` by default)
- `variables`: JSON object of CSS custom-property overrides

`default` means no named color scheme; the base theme and provider still apply.
For any other value, requested and resolved scheme are separate states. If the
id is unavailable, `<pie-theme>` keeps it in both `scheme` and
`data-color-scheme`, renders the safe base/provider result plus any explicit
`variables`, and automatically uses it if a matching custom scheme is registered
later. Retaining `data-color-scheme` preserves a selector hook, but does not give
an unregistered scheme managed precedence. A mounted `<pie-theme>` writes its
resolved tokens inline, so a competing host selector must use `!important`; use
a Registered Custom Scheme when the scheme needs normal managed precedence. A
stylesheet-only integration without a mounted `<pie-theme>` continues to use
the normal cascade.

## Provider Adapter API

```ts
import {
  registerPieThemeProvider,
  type ThemeProviderAdapter,
} from "@pie-players/pie-theme";

const myProvider: ThemeProviderAdapter = {
  id: "district-theme",
  canRead: (target) => Boolean(getComputedStyle(target).getPropertyValue("--district-primary").trim()),
  read: (target) => ({
    "--pie-primary": getComputedStyle(target).getPropertyValue("--district-primary").trim(),
  }),
};

registerPieThemeProvider(myProvider);
```

Every copy of this package on a page shares one provider registry, held on
`window.PIE_THEME_PROVIDERS`. A host and a remote that bundle their own copies
register into it alike, and `<pie-theme>` sees each provider whichever copy
defined the element.

## Runtime theme interface

The package exposes four operations:

```ts
resolvePieTheme(input): ThemeResolution
listPieColorSchemes(): ColorSchemeSnapshot
observePieColorSchemes(listener): Unsubscribe
registerPieColorSchemes(entries): RegistrationReceipt
```

`resolvePieTheme()` accepts `baseTheme` (`light` or `dark`),
`requestedScheme`, `providerVariables`, and final `variables`. Its result
contains `baseTheme`, `requestedScheme`, `resolvedScheme`, `status` (`default`,
`built-in`, `custom`, or `unavailable`), final `variables`, and `diagnostics`.

`resolvePieTheme()` returns the requested id, resolution status, resolved
descriptor when available, final immutable token values, and diagnostics.
`listPieColorSchemes()` returns an immutable snapshot whose `schemes` list keeps
the built-in order and starts with the `default` descriptor. Catalog previews
resolve each scheme over PIE's canonical light base, then project
`--pie-background`, `--pie-text`, and `--pie-primary`; they are not separately
authored swatches or a promise to mirror a host-specific provider. Projection
preserves a deterministic opaque subset: named colors, three- or six-digit hex,
non-alpha `rgb()`, `hsl()`, `hwb()`, `lab()`, `lch()`, `oklab()`, `oklch()`,
and standard CSS Color 4 `color()` spaces. Transparent, explicit-alpha,
relative, nested, context-dependent, malformed, or unsupported forms fall back
to the canonical opaque preview swatch, so a preview never inherits accidental
colors from the picker itself. The authored theme variable is not changed by
that fallback.

`observePieColorSchemes()` calls the listener immediately with the current
snapshot, then once after each successful catalog-changing registration or
unregistration. Listener failures are isolated, and reentrant mutations produce
later coherent snapshots rather than interrupting the current notification.

## Registered custom schemes

Register consumer-defined schemes without modifying framework source:

```ts
import { registerPieColorSchemes } from "@pie-players/pie-theme";

const registration = registerPieColorSchemes([
  {
    id: "district-high-contrast",
    name: "District High Contrast",
    description: "District accessibility palette",
    variables: {
      "--pie-background": "#000000",
      "--pie-text": "#ffffff",
      "--pie-primary": "#00ffff",
    },
  },
]);

// Removes only this registration. Safe to call more than once.
registration.unregister();
```

Then activate with `scheme="district-high-contrast"` on `pie-theme`.

Registered custom schemes are partial overlays. Each entry validates atomically
against the token registry's scheme-participation metadata; an invalid entry is
rejected without dropping valid sibling entries. Built-in ids, `default`,
unknown tokens, and excluded/private/legacy tokens cannot be registered. The
latest valid registration for a custom id wins, and an older receipt cannot
remove that newer definition. Validation returns structured diagnostics and
warns concisely by default; it does not throw for ordinary invalid input.
Contrast diagnostics inspect affected semantic relationships in the fully
resolved custom palette and remain non-blocking because that palette is
host-owned.

Use the final `variables` property for deliberate per-instance overrides. Use a
CSS selector keyed by `data-color-scheme` only when a CSS-only scheme and its
cascade requirements are intentional. Such a selector follows the normal
cascade in a stylesheet-only integration; when it competes with a mounted
`<pie-theme>`'s inline tokens, it needs `!important`.

## SchoolCity scheme parity

SchoolCity offers 15 schemes. Four are built in here — Black on White, White on
Black, Black on Rose, Yellow on Blue — and the other eleven are host palettes a
programme registers itself, from these values:

| SchoolCity token | Value |
| --- | --- |
| blue | `#0028a1` |
| red | `#bf0d00` |
| green | `#008272` |
| yellow | `#ffe072` |
| light gray | `#c0c3cf` |
| dark gray | `#9297a6` |
| rose | `#f8d1ce` |

All 15 clear 4.5:1 for ordinary text; Green on White is the tightest at 4.73:1.
Ordinary text is not the whole cost, though, because the light base chose every
semantic colour against white. What an overlay has to carry scales with how far
its background sits from white:

| Background | Tokens | Why |
| --- | --- | --- |
| white | 3 | ink, page, and the select-text hover fill (`--pie-blue-grey-300`), which the light base chose for black ink |
| white, with a mid-tone ink | 6 | the ink misses the tinted recessed and raised surfaces |
| `#000000` | 20 | inverted page; borrow the dark base theme's inks and control family |
| mid-tone (blue, red, green, dark gray) | 27 in White on Blue | neither light nor dark inks hold throughout, so icons, boundaries and focus rings are re-chosen too |

```ts
registerPieColorSchemes([
  {
    id: "sc-blue-on-white",
    name: "Blue on White",
    variables: {
      "--pie-text": "#0028a1",
      "--pie-background": "#ffffff",
      "--pie-blue-grey-300": "#75a2ff",
    },
  },
]);
```

`packages/theme/tests/schoolcity-scheme-registration.test.ts` carries a validated
palette for one scheme of each cost class and is the place to copy from.

Read the receipt. Contrast diagnostics are warnings, not errors, because the
palette is host-owned — a two-token White on Blue registers successfully and
returns contrast warnings, and a host that filters on `severity === "error"`
ships cyan links on a mid-blue page. `registerPieColorSchemes` checks only the
relationships whose tokens the overlay touches, so covering a flagged token is
what clears its relationship.

These are host palettes rather than built-ins on purpose. A built-in is a full
48-token palette, since a two-colour scheme is a promise the whole surface has to
keep, and which schemes a programme wants is still open on
[PIE-472](https://illuminate.atlassian.net/browse/PIE-472).

## Fixed hues

Some components paint a hue the palette does not own: a data encoding, like the
periodic table's category fills. `--pie-fixed-hue-collapse` is the share by
which such a hue folds into the palette — the component mixes its own value
towards `--pie-background-dark` and `--pie-text` by this share, so `0%` renders
the authored hue exactly and `100%` removes it.

Base Themes set `0%`, because a full palette leaves a hue encoding readable.
Every colour scheme sets `100%`: a two-colour palette is a promise, and a hue
that survives it is a colour the learner did not choose. A registered custom
scheme collapses without declaring anything, and keeps its encodings by setting
`--pie-fixed-hue-collapse: 0%` itself.

Both ends of the mix are exact, so this costs nothing under a Base Theme. What
it costs under a scheme is the encoding: a component collapsing hues has to
carry category, state or series somewhere else — a label, a filter, or the
accessible name.

## DaisyUI Integration

- If DaisyUI tokens are present on the target scope, `pie-theme` uses the built-in `daisyui` provider adapter.
- Override precedence is: base PIE -> provider output -> scheme -> `variables`.
- `provider="none"` (`PIE_THEME_PROVIDER_NONE`) resolves no provider at all, leaving this package's shipped defaults. It is how a host reproduces the palette it had before adopting a provider, which is the first thing to check when colours differ between two environments.
- The adapter is the whole integration. `DAISYUI_PIE_TOKEN_MAP` is its sole source, and it corrects a slot that would land illegible because it reads resolved colours — which a stylesheet cannot do. The separate `pie-theme-daisyui` package that shipped the same table as static CSS was removed: `<pie-theme scope="document">` writes `--pie-*` as inline styles, so a stylesheet declaring them lost to it and the import did nothing.
- A host on some other token vocabulary aliases its own names to `--pie-*` in its own stylesheet, which is what a non-DaisyUI design system needs anyway. Do that under `[data-color-scheme]` to make an accommodation reach host chrome, since `<pie-theme>` never writes `--color-*` or any other host prefix.

## Font size scaling

`font-sizes.css` carries four presets on `--pie-font-scale` — `normal` (1),
`large` (1.25), `xlarge` (1.5), `xxlarge` (1.75). They are Learnosity's steps,
which K-12 accommodation profiles are already written against, so the numbers are
a contract: changing one changes what `large` means for every learner assigned it.

A host selects a preset with `data-font-size` on any ancestor of the player, or
on a player element itself:

```html
<html data-font-size="large">
<div data-font-size="xlarge"><pie-section-player></pie-section-player></div>
```

A host already driving `<pie-theme>` sets the token instead and needs nothing
from the stylesheet:

```html
<pie-theme variables='{"--pie-font-scale":"1.25"}'>
```

The content path scales completely. The rules set `font-size` on the content
hosts — `pie-item-scope`, `pie-passage-shell`, `pie-item-player` and the
externally loaded `pie-player` wrapper — and `font-size` inheritance crosses
shadow boundaries, so text that inherits its size follows. Every font size in
`components.css`, which styles item content, is relative (`em`, `%`,
`larger`/`smaller`) and follows too. The learner-facing text that declares its
own size and therefore cannot inherit the scale — the item and passage card
titles, the formative status line, the tabbed layout's labels, the item player's
build warning — reads `--pie-font-scale` directly, because a card wraps the shell
that gets scaled rather than sitting inside it.

Tool and debug chrome does not scale, deliberately: an accommodation applies to
what the learner reads, and a calculator keypad growing with the passage is a
layout problem rather than an accommodation.

A rule elsewhere that sizes text in `rem` or `px` still will not follow — `rem`
resolves against the document root and `px` against nothing, and no rule inside a
subtree can change what either means — so a host adding its own content chrome
either sizes it relatively or reads the token as these rules do. Browser zoom is
unaffected throughout, so WCAG 2.2 1.4.4 does not depend on this feature.

The scale is applied as `calc(1rem * var(--pie-font-scale))` rather than an `em`
factor because the content hosts nest — an `em` factor would compound, turning a
requested 1.25 into 1.56 wherever an item shell sits inside a themed region.

There is no student-facing control here. The picker is host chrome; this package
owns the token, the presets, and the rules that consume them.

## Token registry

`@pie-players/pie-theme/token-registry.json` lists every `--pie-*` token with its
owner, scope, category, status, fallback policy, and scheme participation
(`required`, `optional`, or `excluded`). `PieThemeTokenRegistryEntry` types it.

It is published so a host can show a person what a token is for and who owns it. Read the registry rather than deriving grouping from token names or keeping a local copy: both drift as soon as a token is added here, and `check:theme-tokens` holds the registry against source on every commit.

## Generated CSS

`tokens.css` and `color-schemes.css` are checked-in output adapters. Generation
is explicit so builds and releases never rewrite tracked source:

```sh
bun --cwd packages/theme run generate:css
bun --cwd packages/theme run check:generated-css
```

The writer is `packages/theme/scripts/generate-theme-css.ts --write`; `--check`
performs the non-mutating comparison. `bun run check:theme-tokens` also rejects
stale generated CSS, and the theme package build runs the stale check before it
copies the existing files to `dist`.

The package-internal `renderPieThemeCss()` implementation lives in
`src/theme-css.ts`; it is not a public export. Generated `color-schemes.css`
contains one unlayered `[data-color-scheme="..."]` rule per built-in scheme and
no base-theme or grouped exception rules.

## Light DOM and Shadow DOM

- `--pie-*` variables are the stable runtime contract for all components.
- Light DOM components read variables from document or scoped host as normal.
- Shadow DOM components should consume `--pie-*` internally; variables inherit across shadow boundaries through the host.
- Avoid relying on global selectors for shadow internals; prefer variable-driven styling.

## Style Ownership

Use `@pie-players/pie-theme/components.css` for shared visual styles that are intentionally reused across multiple PIE custom elements.

`@pie-players/pie-item-player` installs this stylesheet itself, so new hosts do
not need a second copy. Existing hosts may still own the exported stylesheet;
that published path remains supported. Mounting `<pie-theme>` does not load it —
the element only writes `--pie-*` custom properties. The item player bundles the
stylesheet as text and installs it once per document at import time, unless the
host already loads a copy; see
[content styles](../item-player/README.md#content-styles) for host ownership.
Its rules apply inside a `[data-pie-content]` element only, the root each player
renders content into, except rules keyed on KDS or MathJax names or legacy
content classes, which stay document-wide so authored markup an element portals
to `<body>` keeps them.

Note for players adding this: a plain `import "…/components.css"` does **not**
work in these packages' library builds. Vite extracts it to an unreferenced
`dist/assets/*.css` that nothing loads and no exports entry exposes — a silent
no-op that left authored passage markup unstyled in production. Import the text
with `?raw` and hand it to `installContentStyles` from
`@pie-players/pie-players-shared`.

`components.css` declares `--pie-content-styles` on `:root` as a presence
sentinel so players can recognise a copy the host loaded itself, scoped or not,
and install none of their own. It is not a themeable value; do not consume it for
styling.

- Theme-owned shared `pie-*` class families include:
  - `pie-section-player-tools-pnp-debugger*`
  - `pie-section-player-tools-session-debugger*`
  - `pie-answer-eliminator-*` and `pie-answer-masked-*`
- Keep runtime behavior, DOM mutation logic, and element-specific layout mechanics in the owning package.
- Prefer stable `pie-*` / `data-pie-*` hooks in component markup; avoid introducing new generic class contracts.
