@pie-players/pie-theme 0.3.66 → 0.3.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  Shared PIE theming primitives and the `pie-theme` custom element.
4
4
 
5
+ This file is the reference. For how the pieces fit — why a host stylesheet
6
+ cannot override a mounted element, what the provider choice actually decides, and
7
+ how a host carries an accommodation into its own chrome — see
8
+ [`docs/theming/how-theming-works.md`](../../docs/theming/how-theming-works.md).
9
+
5
10
  `pie-theme` resolves PIE theme tokens (`--pie-*`) with this precedence:
6
11
 
7
12
  1. Base PIE theme (`theme=light|dark|auto`)
@@ -177,6 +182,59 @@ cascade requirements are intentional. Such a selector follows the normal
177
182
  cascade in a stylesheet-only integration; when it competes with a mounted
178
183
  `<pie-theme>`'s inline tokens, it needs `!important`.
179
184
 
185
+ ## SchoolCity scheme parity
186
+
187
+ SchoolCity offers 15 schemes. Four are built in here — Black on White, White on
188
+ Black, Black on Rose, Yellow on Blue — and the other eleven are host palettes a
189
+ programme registers itself, from these values:
190
+
191
+ | SchoolCity token | Value |
192
+ | --- | --- |
193
+ | blue | `#0028a1` |
194
+ | red | `#bf0d00` |
195
+ | green | `#008272` |
196
+ | yellow | `#ffe072` |
197
+ | light gray | `#c0c3cf` |
198
+ | dark gray | `#9297a6` |
199
+ | rose | `#f8d1ce` |
200
+
201
+ All 15 clear 4.5:1 for ordinary text; Green on White is the tightest at 4.73:1.
202
+ Ordinary text is not the whole cost, though, because the light base chose every
203
+ semantic colour against white. What an overlay has to carry scales with how far
204
+ its background sits from white:
205
+
206
+ | Background | Tokens | Why |
207
+ | --- | --- | --- |
208
+ | white | 2 | ink and page; every base colour already holds |
209
+ | white, with a mid-tone ink | 4 | the ink misses the tinted recessed surfaces |
210
+ | `#000000` | 10 | inverted page; borrow the dark base theme's inks |
211
+ | mid-tone (blue, red, green, dark gray) | ~18 | neither light nor dark inks hold, so icons, boundaries and focus rings are re-chosen too |
212
+
213
+ ```ts
214
+ registerPieColorSchemes([
215
+ {
216
+ id: "sc-blue-on-white",
217
+ name: "Blue on White",
218
+ variables: { "--pie-text": "#0028a1", "--pie-background": "#ffffff" },
219
+ },
220
+ ]);
221
+ ```
222
+
223
+ `packages/theme/tests/schoolcity-scheme-registration.test.ts` carries a validated
224
+ palette for one scheme of each cost class and is the place to copy from.
225
+
226
+ Read the receipt. Contrast diagnostics are warnings, not errors, because the
227
+ palette is host-owned — a two-token White on Blue registers successfully and
228
+ returns fourteen warnings, and a host that filters on `severity === "error"`
229
+ ships cyan links on a mid-blue page. `registerPieColorSchemes` checks only the
230
+ relationships whose tokens the overlay touches, so covering a flagged token is
231
+ what clears its relationship.
232
+
233
+ These are host palettes rather than built-ins on purpose. A built-in is a full
234
+ 48-token palette, since a two-colour scheme is a promise the whole surface has to
235
+ keep, and which schemes a programme wants is still open on
236
+ [PIE-472](https://illuminate.atlassian.net/browse/PIE-472).
237
+
180
238
  ## Fixed hues
181
239
 
182
240
  Some components paint a hue the palette does not own: a data encoding, like the
@@ -201,6 +259,59 @@ accessible name.
201
259
  - If DaisyUI tokens are present on the target scope, `pie-theme` uses the built-in `daisyui` provider adapter.
202
260
  - Override precedence is: base PIE -> provider output -> scheme -> `variables`.
203
261
  - `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.
262
+ - 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.
263
+ - 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.
264
+
265
+ ## Font size scaling
266
+
267
+ `font-sizes.css` carries four presets on `--pie-font-scale` — `normal` (1),
268
+ `large` (1.25), `xlarge` (1.5), `xxlarge` (1.75). They are Learnosity's steps,
269
+ which K-12 accommodation profiles are already written against, so the numbers are
270
+ a contract: changing one changes what `large` means for every learner assigned it.
271
+
272
+ A host selects a preset with `data-font-size` on any ancestor of the player, or
273
+ on a player element itself:
274
+
275
+ ```html
276
+ <html data-font-size="large">
277
+ <div data-font-size="xlarge"><pie-section-player></pie-section-player></div>
278
+ ```
279
+
280
+ A host already driving `<pie-theme>` sets the token instead and needs nothing
281
+ from the stylesheet:
282
+
283
+ ```html
284
+ <pie-theme variables='{"--pie-font-scale":"1.25"}'>
285
+ ```
286
+
287
+ The content path scales completely. The rules set `font-size` on the content
288
+ hosts — `pie-item-shell`, `pie-passage-shell`, `pie-item-player` and the
289
+ externally loaded `pie-player` wrapper — and `font-size` inheritance crosses
290
+ shadow boundaries, so text that inherits its size follows. Every font size in
291
+ `components.css`, which styles item content, is relative (`em`, `%`,
292
+ `larger`/`smaller`) and follows too. The learner-facing text that declares its
293
+ own size and therefore cannot inherit the scale — the item and passage card
294
+ titles, the formative status line, the tabbed layout's labels, the item player's
295
+ build warning — reads `--pie-font-scale` directly, because a card wraps the shell
296
+ that gets scaled rather than sitting inside it.
297
+
298
+ Tool and debug chrome does not scale, deliberately: an accommodation applies to
299
+ what the learner reads, and a calculator keypad growing with the passage is a
300
+ layout problem rather than an accommodation.
301
+
302
+ A rule elsewhere that sizes text in `rem` or `px` still will not follow — `rem`
303
+ resolves against the document root and `px` against nothing, and no rule inside a
304
+ subtree can change what either means — so a host adding its own content chrome
305
+ either sizes it relatively or reads the token as these rules do. Browser zoom is
306
+ unaffected throughout, so WCAG 2.2 1.4.4 does not depend on this feature.
307
+
308
+ The scale is applied as `calc(1rem * var(--pie-font-scale))` rather than an `em`
309
+ factor because the content hosts nest — an `em` factor would compound, turning a
310
+ requested 1.25 into 1.56 wherever an item shell sits inside a themed region.
311
+
312
+ There is no student-facing control here. The picker is host chrome, which in the
313
+ Renaissance context is Quiz Engine's surface; this package owns the token, the
314
+ presets, and the rules that consume them.
204
315
 
205
316
  ## Token registry
206
317
 
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Find the nearest `<pie-theme>` that owns `from`'s scheme, walking out
3
+ * across shadow-root boundaries. `Element.closest()` alone stops at a
4
+ * shadow root: a caller mounted inside its own `shadow: "open"` root would
5
+ * never find a `<pie-theme>` ancestor that lives in the light DOM outside
6
+ * it, so this repeats the search from each shadow root's host until it
7
+ * either finds one or reaches the document.
8
+ *
9
+ * Falls back to a document-scoped `<pie-theme>` anywhere in the page, then
10
+ * any `<pie-theme>` at all, matching how a page with exactly one theme host
11
+ * is normally set up.
12
+ */
13
+ export declare function resolvePieThemeHost(from?: Node | null): HTMLElement | null;
14
+ export interface ApplyPieColorSchemeOptions {
15
+ /** Search from this node outward for the nearest `<pie-theme>` ancestor. */
16
+ from?: Node | null;
17
+ /** localStorage key to persist the requested scheme under. Pass `null` to skip persistence. */
18
+ persistenceKey?: string | null;
19
+ }
20
+ /**
21
+ * Apply a requested color scheme and persist it, the one canonical way —
22
+ * shared by every caller that offers scheme selection outside `<pie-theme>`
23
+ * itself (a picker tool, a demo host's own theme controls).
24
+ */
25
+ export declare function applyPieColorScheme(schemeId: string, options?: ApplyPieColorSchemeOptions): void;
@@ -0,0 +1,65 @@
1
+ const DEFAULT_COLOR_SCHEME_STORAGE_KEY = "pie-color-scheme";
2
+ /**
3
+ * Find the nearest `<pie-theme>` that owns `from`'s scheme, walking out
4
+ * across shadow-root boundaries. `Element.closest()` alone stops at a
5
+ * shadow root: a caller mounted inside its own `shadow: "open"` root would
6
+ * never find a `<pie-theme>` ancestor that lives in the light DOM outside
7
+ * it, so this repeats the search from each shadow root's host until it
8
+ * either finds one or reaches the document.
9
+ *
10
+ * Falls back to a document-scoped `<pie-theme>` anywhere in the page, then
11
+ * any `<pie-theme>` at all, matching how a page with exactly one theme host
12
+ * is normally set up.
13
+ */
14
+ export function resolvePieThemeHost(from) {
15
+ if (typeof document === "undefined")
16
+ return null;
17
+ let current = from instanceof Element ? from : null;
18
+ while (current) {
19
+ const found = current.closest("pie-theme");
20
+ if (found)
21
+ return found;
22
+ const root = current.getRootNode();
23
+ current = root instanceof ShadowRoot ? root.host : null;
24
+ }
25
+ return (document.querySelector('pie-theme[scope="document"]') ||
26
+ document.querySelector("pie-theme"));
27
+ }
28
+ /**
29
+ * Apply a requested color scheme and persist it, the one canonical way —
30
+ * shared by every caller that offers scheme selection outside `<pie-theme>`
31
+ * itself (a picker tool, a demo host's own theme controls).
32
+ */
33
+ export function applyPieColorScheme(schemeId, options = {}) {
34
+ if (typeof document === "undefined")
35
+ return;
36
+ const nextScheme = (schemeId || "default").trim() || "default";
37
+ const themeHost = resolvePieThemeHost(options.from);
38
+ if (themeHost) {
39
+ if (themeHost.getAttribute("scheme") !== nextScheme) {
40
+ themeHost.setAttribute("scheme", nextScheme);
41
+ }
42
+ }
43
+ else {
44
+ // No <pie-theme> owns `data-color-scheme` on this document, so this is
45
+ // the only way the shared color-scheme CSS can still pick up the
46
+ // requested scheme. A scope="document" <pie-theme> that mounts later
47
+ // captures whatever is already on `documentElement` as its restore
48
+ // baseline rather than silently discarding this write.
49
+ const root = document.documentElement;
50
+ if (nextScheme === "default")
51
+ root.removeAttribute("data-color-scheme");
52
+ else
53
+ root.setAttribute("data-color-scheme", nextScheme);
54
+ }
55
+ if (options.persistenceKey === null)
56
+ return;
57
+ try {
58
+ if (typeof localStorage === "undefined")
59
+ return;
60
+ localStorage.setItem(options.persistenceKey ?? DEFAULT_COLOR_SCHEME_STORAGE_KEY, nextScheme);
61
+ }
62
+ catch {
63
+ // Storage may be unavailable (privacy mode, quota); the scheme still applies this session.
64
+ }
65
+ }
@@ -1,4 +1,4 @@
1
- import { createBuiltInColorSchemeDescriptor, createPieColorSchemePreview, diagnoseThemeContrast, getBaseThemeVariables, getBuiltInColorSchemeDefinition, getDefaultColorSchemeDescriptor, getSchemeParticipation, listBuiltInColorSchemeDefinitions, } from "./theme-definitions.js";
1
+ import { createBuiltInColorSchemeDescriptor, createPieColorSchemePreview, diagnoseThemeContrast, getBaseThemeVariables, getBuiltInColorSchemeDefinition, getDefaultColorSchemeDescriptor, getSchemeParticipation, listBuiltInColorSchemeDefinitions, resolvePaletteColorScheme, } from "./theme-definitions.js";
2
2
  import { normalizePieThemeVariables, } from "./theme-types.js";
3
3
  const customSchemes = new Map();
4
4
  const observers = new Set();
@@ -455,12 +455,20 @@ export function resolvePieTheme(input = {}) {
455
455
  }
456
456
  for (const item of diagnostics)
457
457
  warnDiagnostic(item);
458
+ // Only a resolved scheme decides polarity. Without one -- including a requested
459
+ // scheme that turned out unavailable -- the host's theme still owns
460
+ // `color-scheme`, and deciding it from the base palette would take that
461
+ // ownership away from every host that never asked for an accommodation.
462
+ const colorScheme = status === "built-in" || status === "custom"
463
+ ? resolvePaletteColorScheme(variables)
464
+ : null;
458
465
  return Object.freeze({
459
466
  baseTheme,
460
467
  requestedScheme,
461
468
  resolvedScheme,
462
469
  status,
463
470
  variables: freezeVariables(variables),
471
+ colorScheme,
464
472
  diagnostics: freezeDiagnostics(diagnostics),
465
473
  });
466
474
  }
@@ -735,11 +735,14 @@ table {
735
735
 
736
736
  .pie-answer-eliminator-toggle {
737
737
  /* em, not px: PNP font scaling raises the surrounding text size without
738
- touching px boxes, which left a fixed 28px control under grown text. At the
739
- default 16px base these are the 28px/18px the control used to pin, and the
740
- box stays past SC 2.5.8's 24px minimum for any base at or above 14px. */
741
- width: 1.75em;
742
- height: 1.75em;
738
+ touching px boxes, which left a fixed 28px control under grown text. The
739
+ box is 1.75x the surrounding text and the glyph 1.125x the 28px/18px the
740
+ control used to pin at a 16px base — and the box stays past SC 2.5.8's 24px
741
+ minimum for any base at or above 14px. The box divides by the glyph factor
742
+ because `em` in a length resolves against this element's own font-size, not
743
+ the parent's; a bare 1.75em here measures 31.5px, not 28px. */
744
+ width: calc(1.75em / 1.125);
745
+ height: calc(1.75em / 1.125);
743
746
  padding: 0;
744
747
  border: 1px solid var(--pie-border, #ccc);
745
748
  border-radius: 4px;
@@ -780,6 +783,22 @@ table {
780
783
  );
781
784
  }
782
785
 
786
+ /*
787
+ * Dims the choice as well as striking it, so elimination survives a strike
788
+ * colour the learner cannot distinguish. Kept at 0.6 rather than lower: the text
789
+ * stays readable, because eliminating a choice is reversible and the learner has
790
+ * to be able to reconsider it.
791
+ *
792
+ * Keyed on the attribute rather than on the fallback class, because the class is
793
+ * added only by the path for browsers without the CSS Highlight API. The
794
+ * attribute is set by both paths, and a highlight pseudo cannot carry `opacity`
795
+ * at all — declaring it there dimmed nothing, so the modern path shipped the
796
+ * strike as its only cue.
797
+ */
798
+ [data-pie-answer-eliminated="true"] {
799
+ opacity: 0.6;
800
+ }
801
+
783
802
  .pie-answer-eliminator-image-strike {
784
803
  position: relative;
785
804
  display: inline-block;
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * The one table that says which DaisyUI slot each `--pie-*` token comes from,
3
- * and one renderer that turns it into variables.
3
+ * and one renderer that turns it into variables. Sole source: the `daisyui`
4
+ * provider adapter is the only thing that reads it, and a host reaches it by
5
+ * mounting `<pie-theme provider="daisyui">` or leaving `provider` on `auto`.
4
6
  *
5
- * It exists because the same 47-row table was written out four times — the
6
- * provider adapter here, two mappers in `@pie-players/pie-theme-daisyui`, and
7
- * that package's `bridge.css` — and copies drift. Two defects lived in the drift:
8
- * `--pie-missing` was corrected to `--color-warning` in one copy while three kept
9
- * it on `--color-error`, and the parity test that was supposed to catch this
10
- * compared only the token names, never the slot each one derived from.
11
- *
12
- * CSS cannot import a table, so `bridge.css` stays hand-written and is held to
13
- * this one by `tests/daisyui-mapping-parity.test.mjs` instead.
7
+ * It was consolidated here after the same table had been written out four
8
+ * times — this adapter, two mappers in a separate `pie-theme-daisyui` package, and
9
+ * that package's static `bridge.css` — and copies drift. Two defects lived in the
10
+ * drift: `--pie-missing` was corrected to `--color-warning` in one copy while three
11
+ * kept it on `--color-error`, and the parity test meant to catch that compared only
12
+ * the token names, never the slot each one derived from. The copies are gone; the
13
+ * package was removed once its CSS turned out to be outranked by the inline
14
+ * variables `<pie-theme>` writes, leaving it inert wherever it was imported.
14
15
  */
15
16
  import { type ColorMeasure } from "./contrast.js";
16
17
  /** The DaisyUI slots this mapping reads. Not all of DaisyUI's palette. */
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * The one table that says which DaisyUI slot each `--pie-*` token comes from,
3
- * and one renderer that turns it into variables.
3
+ * and one renderer that turns it into variables. Sole source: the `daisyui`
4
+ * provider adapter is the only thing that reads it, and a host reaches it by
5
+ * mounting `<pie-theme provider="daisyui">` or leaving `provider` on `auto`.
4
6
  *
5
- * It exists because the same 47-row table was written out four times — the
6
- * provider adapter here, two mappers in `@pie-players/pie-theme-daisyui`, and
7
- * that package's `bridge.css` — and copies drift. Two defects lived in the drift:
8
- * `--pie-missing` was corrected to `--color-warning` in one copy while three kept
9
- * it on `--color-error`, and the parity test that was supposed to catch this
10
- * compared only the token names, never the slot each one derived from.
11
- *
12
- * CSS cannot import a table, so `bridge.css` stays hand-written and is held to
13
- * this one by `tests/daisyui-mapping-parity.test.mjs` instead.
7
+ * It was consolidated here after the same table had been written out four
8
+ * times — this adapter, two mappers in a separate `pie-theme-daisyui` package, and
9
+ * that package's static `bridge.css` — and copies drift. Two defects lived in the
10
+ * drift: `--pie-missing` was corrected to `--color-warning` in one copy while three
11
+ * kept it on `--color-error`, and the parity test meant to catch that compared only
12
+ * the token names, never the slot each one derived from. The copies are gone; the
13
+ * package was removed once its CSS turned out to be outranked by the inline
14
+ * variables `<pie-theme>` writes, leaving it inert wherever it was imported.
14
15
  */
15
16
  import { LEGIBLE_NON_TEXT_MINIMUM, LEGIBLE_TEXT_MINIMUM, UNMEASURED_HUE_WEIGHT, UNMEASURED_NON_TEXT_HUE_WEIGHT, legibleColorAgainst, } from "./contrast.js";
16
17
  export const DAISY_SLOT_CSS_VARIABLES = {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The full set of built-in DaisyUI theme ids. `<pie-theme>`'s DaisyUI
3
+ * provider adapter maps any of these onto the `--pie-*` token contract, so
4
+ * every demo host offering a DaisyUI theme picker draws from this one list
5
+ * instead of each hand-typing its own copy.
6
+ */
7
+ export declare const DAISYUI_THEME_CATALOG: readonly ["light", "dark", "cupcake", "bumblebee", "emerald", "corporate", "synthwave", "retro", "cyberpunk", "valentine", "halloween", "garden", "forest", "aqua", "lofi", "pastel", "fantasy", "wireframe", "black", "luxury", "dracula", "cmyk", "autumn", "business", "acid", "lemonade", "night", "coffee", "winter", "dim", "nord", "sunset", "caramellatte", "abyss", "silk"];
8
+ export type DaisyUIThemeId = (typeof DAISYUI_THEME_CATALOG)[number];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The full set of built-in DaisyUI theme ids. `<pie-theme>`'s DaisyUI
3
+ * provider adapter maps any of these onto the `--pie-*` token contract, so
4
+ * every demo host offering a DaisyUI theme picker draws from this one list
5
+ * instead of each hand-typing its own copy.
6
+ */
7
+ export const DAISYUI_THEME_CATALOG = [
8
+ "light",
9
+ "dark",
10
+ "cupcake",
11
+ "bumblebee",
12
+ "emerald",
13
+ "corporate",
14
+ "synthwave",
15
+ "retro",
16
+ "cyberpunk",
17
+ "valentine",
18
+ "halloween",
19
+ "garden",
20
+ "forest",
21
+ "aqua",
22
+ "lofi",
23
+ "pastel",
24
+ "fantasy",
25
+ "wireframe",
26
+ "black",
27
+ "luxury",
28
+ "dracula",
29
+ "cmyk",
30
+ "autumn",
31
+ "business",
32
+ "acid",
33
+ "lemonade",
34
+ "night",
35
+ "coffee",
36
+ "winter",
37
+ "dim",
38
+ "nord",
39
+ "sunset",
40
+ "caramellatte",
41
+ "abyss",
42
+ "silk",
43
+ ];
@@ -1,141 +1,94 @@
1
- /* PIE Font Size System - Learnosity-compatible font scaling for K-12 assessments
1
+ /* PIE font size scaling.
2
2
  *
3
- * Based on Learnosity's font size accommodation feature which provides 4 preset sizes:
4
- * - Normal (100% - default)
5
- * - Large (125% - 1.25x scale)
6
- * - Extra Large (150% - 1.5x scale)
7
- * - Extra Extra Large (175% - 1.75x scale)
3
+ * Four presets on `--pie-font-scale`, selected by `data-font-size` on any
4
+ * ancestor of the player — matching Learnosity's font-size accommodation
5
+ * (100/125/150/175%), which is the scale K-12 programs already specify against.
8
6
  *
9
- * All sizes meet WCAG 2.2 Level AA requirements for text sizing:
10
- * - Users can scale text up to 200% without assistive technology
11
- * - Text remains readable and functional at all scale levels
12
- * - No horizontal scrolling required in scaled states
7
+ * <html data-font-size="large"> host owns the whole page
8
+ * <div data-font-size="large"> host scopes it to a region
13
9
  *
14
- * Usage: Apply data-font-size attribute to document root or pie-player element
15
- * Example: <html data-font-size="large">
10
+ * A host that drives theming through `<pie-theme>` sets the token directly
11
+ * instead, and needs nothing from this file:
16
12
  *
17
- * Implementation Note: Uses CSS custom property (--pie-font-scale) to scale all
18
- * typography consistently. Base font sizes are defined in the PIE player components.
13
+ * <pie-theme variables='{"--pie-font-scale":"1.25"}'>
14
+ *
15
+ * WHAT SCALES. Text that inherits its size scales, because the rules below set
16
+ * `font-size` on the content hosts and inheritance crosses shadow boundaries.
17
+ * Text whose own rule names `rem` or `px` does not: `rem` resolves against the
18
+ * document root and `px` against nothing, and no rule inside a subtree can
19
+ * change what either means. A host that needs those to follow as well has to
20
+ * scale the root font size itself, which is a page-wide decision this package
21
+ * does not make on its behalf. WCAG 2.2 1.4.4 is satisfied either way — browser
22
+ * zoom remains available and unaffected — so this is a comfort accommodation
23
+ * layered over that, not a substitute for it.
24
+ *
25
+ * `calc(1rem * var(--pie-font-scale))` is deliberately root-relative rather than
26
+ * `em`-relative: the hosts nest (an item shell inside a section player inside a
27
+ * themed region), and an `em` factor would compound at each level and multiply
28
+ * 1.25 into 1.95.
19
29
  */
20
30
 
21
- /* Default font size (100% - no scaling) */
31
+ /* The unset default. Also declared by the base theme, so the token resolves
32
+ * whether a host loads this file, `tokens.css`, or drives `<pie-theme>`. */
22
33
  :root {
23
34
  --pie-font-scale: 1;
24
35
  }
25
36
 
26
- /* Normal (100% - explicit setting) */
27
37
  [data-font-size="normal"] {
28
38
  --pie-font-scale: 1;
29
39
  }
30
40
 
31
- /* Large (125%) - Learnosity "large" preset */
32
41
  [data-font-size="large"] {
33
42
  --pie-font-scale: 1.25;
34
43
  }
35
44
 
36
- /* Extra Large (150%) - Learnosity "xlarge" preset */
37
45
  [data-font-size="xlarge"] {
38
46
  --pie-font-scale: 1.5;
39
47
  }
40
48
 
41
- /* Extra Extra Large (175%) - Learnosity "xxlarge" preset */
42
49
  [data-font-size="xxlarge"] {
43
50
  --pie-font-scale: 1.75;
44
51
  }
45
52
 
46
- /* Apply font scaling to assessment content
47
- * Target PIE player elements and assessment content
48
- * Exclude tool UIs (calculator, graph, etc.) which should remain at normal size
53
+ /* Content hosts.
49
54
  *
50
- * Using !important to override PIE's inline styles and component-specific styles
55
+ * `pie-item-shell` and `pie-passage-shell` are this repo's section-player
56
+ * content hosts and `pie-item-player` its standalone one; each renders PIE
57
+ * elements directly into its own shadow tree. `pie-player` is the externally
58
+ * loaded item wrapper some hosts render items through instead, so both delivery
59
+ * paths are covered — targeting only the wrapper, as this file previously did,
60
+ * missed every item this repo renders itself.
61
+ *
62
+ * Scoped under `[data-font-size]` so the declaration only exists once a host has
63
+ * opted in, and so a host's own rules keep the upper hand at equal specificity.
51
64
  */
52
-
53
- /* Apply to pie-player root element */
65
+ [data-font-size] pie-item-shell,
66
+ [data-font-size] pie-passage-shell,
67
+ [data-font-size] pie-item-player,
68
+ [data-font-size] pie-player,
69
+ pie-item-shell[data-font-size],
70
+ pie-passage-shell[data-font-size],
71
+ pie-item-player[data-font-size],
54
72
  pie-player[data-font-size] {
55
- font-size: calc(1rem * var(--pie-font-scale)) !important;
56
- }
57
-
58
- /* Apply to all text elements within pie-player - using inherit to cascade from root */
59
- pie-player[data-font-size] *,
60
- pie-player[data-font-size] p,
61
- pie-player[data-font-size] span,
62
- pie-player[data-font-size] div,
63
- pie-player[data-font-size] li,
64
- pie-player[data-font-size] label,
65
- pie-player[data-font-size] input,
66
- pie-player[data-font-size] textarea,
67
- pie-player[data-font-size] select,
68
- pie-player[data-font-size] button,
69
- pie-player[data-font-size] h1,
70
- pie-player[data-font-size] h2,
71
- pie-player[data-font-size] h3,
72
- pie-player[data-font-size] h4,
73
- pie-player[data-font-size] h5,
74
- pie-player[data-font-size] h6,
75
- pie-player[data-font-size] td,
76
- pie-player[data-font-size] th,
77
- pie-player[data-font-size] option {
78
- font-size: inherit !important;
79
- }
73
+ font-size: calc(1rem * var(--pie-font-scale, 1));
80
74
 
81
- /* Also apply to parent container if data-font-size is on document root */
82
- [data-font-size] pie-player {
83
- font-size: calc(1rem * var(--pie-font-scale)) !important;
84
- }
85
-
86
- [data-font-size] pie-player *,
87
- [data-font-size] pie-player p,
88
- [data-font-size] pie-player span,
89
- [data-font-size] pie-player div,
90
- [data-font-size] pie-player li,
91
- [data-font-size] pie-player label,
92
- [data-font-size] pie-player input,
93
- [data-font-size] pie-player textarea,
94
- [data-font-size] pie-player select,
95
- [data-font-size] pie-player button,
96
- [data-font-size] pie-player h1,
97
- [data-font-size] pie-player h2,
98
- [data-font-size] pie-player h3,
99
- [data-font-size] pie-player h4,
100
- [data-font-size] pie-player h5,
101
- [data-font-size] pie-player h6,
102
- [data-font-size] pie-player td,
103
- [data-font-size] pie-player th,
104
- [data-font-size] pie-player option {
105
- font-size: inherit !important;
106
- }
107
-
108
- /* Preserve normal size for tool UIs and controls */
109
- [data-font-size] .pie-tool-toolbar,
110
- [data-font-size] .pie-tool-toolbar *,
111
- [data-font-size] .pie-tool-calculator,
112
- [data-font-size] .pie-tool-calculator *,
113
- [data-font-size] .tool-settings-panel,
114
- [data-font-size] .tool-settings-panel *,
115
- [data-font-size] [class*="pie-tool-"],
116
- [data-font-size] [class*="pie-tool-"] *,
117
- [data-font-size] header,
118
- [data-font-size] header *,
119
- [data-font-size] footer,
120
- [data-font-size] footer *,
121
- [data-font-size] nav,
122
- [data-font-size] nav *,
123
- [data-font-size] button:not(.pie-button),
124
- [data-font-size] .btn,
125
- [data-font-size] .btn * {
126
- font-size: 1rem !important;
75
+ /* Long words at 175% overflow a column that fits them at 100%. */
76
+ overflow-wrap: break-word;
127
77
  }
128
78
 
129
- /* Ensure proper line-height scaling for readability */
79
+ /* Line height has to open up with the type or the block tightens as it grows.
80
+ * A ratio, so a host that sets its own line-height still wins. */
81
+ [data-font-size="large"] pie-item-shell,
82
+ [data-font-size="large"] pie-passage-shell,
83
+ [data-font-size="large"] pie-item-player,
130
84
  [data-font-size="large"] pie-player,
85
+ [data-font-size="xlarge"] pie-item-shell,
86
+ [data-font-size="xlarge"] pie-passage-shell,
87
+ [data-font-size="xlarge"] pie-item-player,
131
88
  [data-font-size="xlarge"] pie-player,
89
+ [data-font-size="xxlarge"] pie-item-shell,
90
+ [data-font-size="xxlarge"] pie-passage-shell,
91
+ [data-font-size="xxlarge"] pie-item-player,
132
92
  [data-font-size="xxlarge"] pie-player {
133
93
  line-height: 1.6;
134
94
  }
135
-
136
- /* Prevent layout issues with scaled text */
137
- [data-font-size] pie-player {
138
- overflow-wrap: break-word;
139
- word-wrap: break-word;
140
- hyphens: auto;
141
- }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,9 @@ import { PieThemeElement, definePieTheme } from "./theme-element.js";
2
2
  export { PieThemeElement, definePieTheme };
3
3
  export { DAISYUI_THEME_PROVIDER_ADAPTER, getPieThemeProvider, PIE_THEME_PROVIDER_NONE, listPieThemeProviders, registerPieThemeProvider, resolveProviderVariables, unregisterPieThemeProvider, type ThemeProviderAdapter, } from "./providers.js";
4
4
  export { DAISY_SLOT_CSS_VARIABLES, DAISYUI_PIE_TOKEN_MAP, resolveDaisyPieVariables, type DaisyMappingEntry, type DaisySlot, } from "./daisyui-mapping.js";
5
+ export { DAISYUI_THEME_CATALOG, type DaisyUIThemeId, } from "./daisyui-theme-catalog.js";
5
6
  export { createCanvasColorMeasure, type ColorMeasure, } from "./contrast.js";
6
7
  export { isThemeMode, isThemeScope, normalizePieThemeVariables, type ColorSchemeSnapshot, type PieColorSchemeDescriptor, type PieColorSchemePreview, type PieThemeDiagnostic, type PieThemeDiagnosticCode, type PieThemeObserver, type PieThemeResolutionStatus, type RegisteredPieColorScheme, type RegistrationReceipt, type ResolvePieThemeInput, type ThemeResolution, type ThemeMode, type ThemeScope, type ThemeTokenName, type ThemeVariables, type Unsubscribe, } from "./theme-types.js";
7
8
  export type { PieThemeSchemeParticipation, PieThemeTokenRegistry, PieThemeTokenRegistryEntry, PieThemeTokenScope, PieThemeTokenStatus, } from "./token-registry-types.js";
8
9
  export { listPieColorSchemes, observePieColorSchemes, registerPieColorSchemes, resolvePieTheme, } from "./color-schemes.js";
10
+ export { applyPieColorScheme, resolvePieThemeHost, type ApplyPieColorSchemeOptions, } from "./apply-color-scheme.js";
package/dist/index.js CHANGED
@@ -2,7 +2,9 @@ import { PieThemeElement, definePieTheme } from "./theme-element.js";
2
2
  export { PieThemeElement, definePieTheme };
3
3
  export { DAISYUI_THEME_PROVIDER_ADAPTER, getPieThemeProvider, PIE_THEME_PROVIDER_NONE, listPieThemeProviders, registerPieThemeProvider, resolveProviderVariables, unregisterPieThemeProvider, } from "./providers.js";
4
4
  export { DAISY_SLOT_CSS_VARIABLES, DAISYUI_PIE_TOKEN_MAP, resolveDaisyPieVariables, } from "./daisyui-mapping.js";
5
+ export { DAISYUI_THEME_CATALOG, } from "./daisyui-theme-catalog.js";
5
6
  export { createCanvasColorMeasure, } from "./contrast.js";
6
7
  export { isThemeMode, isThemeScope, normalizePieThemeVariables, } from "./theme-types.js";
7
8
  export { listPieColorSchemes, observePieColorSchemes, registerPieColorSchemes, resolvePieTheme, } from "./color-schemes.js";
9
+ export { applyPieColorScheme, resolvePieThemeHost, } from "./apply-color-scheme.js";
8
10
  definePieTheme();
@@ -6,6 +6,23 @@ type BuiltInColorSchemeDefinition = Readonly<{
6
6
  description?: string;
7
7
  variables: Readonly<ThemeVariables>;
8
8
  }>;
9
+ /**
10
+ * The CSS `color-scheme` keyword a palette implies, or `null` when the token
11
+ * values cannot decide it.
12
+ *
13
+ * A scheme replaces every colour, but nothing in CSS infers polarity from custom
14
+ * properties, so UA-styled controls -- `input`, `select`, scrollbars, native form
15
+ * widgets -- keep whatever polarity the host's theme declared. A dark
16
+ * accommodation on a light host therefore renders their text in the light-mode
17
+ * system colour: measured at roughly 1.1:1 against `yellow-on-blue`.
18
+ *
19
+ * Decided by whether black or white contrasts better against the resolved
20
+ * background, which is the same test that picks a legible foreground. `null` for
21
+ * a background `parseOpaqueColor` rejects -- a translucent value, a `var()`
22
+ * reference out to a host property, the transparent light base -- because
23
+ * polarity then depends on the host's backdrop and is the host's to declare.
24
+ */
25
+ export declare function resolvePaletteColorScheme(variables: Readonly<ThemeVariables>): "light" | "dark" | null;
9
26
  export declare function diagnoseThemeContrast(variables: Readonly<ThemeVariables>, schemeId?: string, relevantTokens?: ReadonlySet<string>): readonly PieThemeDiagnostic[];
10
27
  export declare function getBaseThemeVariables(baseTheme: "light" | "dark"): Readonly<ThemeVariables>;
11
28
  export declare function listBuiltInColorSchemeDefinitions(): readonly BuiltInColorSchemeDefinition[];
@@ -807,6 +807,17 @@ const PIE_THEME_CONTRAST_RELATIONSHIPS = deepFreeze([
807
807
  minimum: 3,
808
808
  role: "incorrect feedback icon",
809
809
  },
810
+ {
811
+ // The error-surface pair. `--pie-incorrect-secondary` is a tint of the
812
+ // page, about 1.1:1 against it in most schemes, so a banner painted with
813
+ // it reads as a banner only by its `--pie-incorrect` edge -- and the ink
814
+ // on it is the page's own, not the error hue: `--pie-incorrect` against
815
+ // this tint falls to 4.14:1 under Black on White.
816
+ foreground: "--pie-text",
817
+ background: "--pie-incorrect-secondary",
818
+ minimum: 4.5,
819
+ role: "incorrect feedback surface text",
820
+ },
810
821
  {
811
822
  foreground: "--pie-missing-icon",
812
823
  background: "--pie-background",
@@ -958,6 +969,31 @@ function resolveColor(variables, token, seen = new Set()) {
958
969
  ? resolveColor(variables, reference[1], seen)
959
970
  : value;
960
971
  }
972
+ /**
973
+ * The CSS `color-scheme` keyword a palette implies, or `null` when the token
974
+ * values cannot decide it.
975
+ *
976
+ * A scheme replaces every colour, but nothing in CSS infers polarity from custom
977
+ * properties, so UA-styled controls -- `input`, `select`, scrollbars, native form
978
+ * widgets -- keep whatever polarity the host's theme declared. A dark
979
+ * accommodation on a light host therefore renders their text in the light-mode
980
+ * system colour: measured at roughly 1.1:1 against `yellow-on-blue`.
981
+ *
982
+ * Decided by whether black or white contrasts better against the resolved
983
+ * background, which is the same test that picks a legible foreground. `null` for
984
+ * a background `parseOpaqueColor` rejects -- a translucent value, a `var()`
985
+ * reference out to a host property, the transparent light base -- because
986
+ * polarity then depends on the host's backdrop and is the host's to declare.
987
+ */
988
+ export function resolvePaletteColorScheme(variables) {
989
+ const value = resolveColor(variables, "--pie-background");
990
+ const background = value ? parseOpaqueColor(value) : null;
991
+ if (!background)
992
+ return null;
993
+ const againstWhite = contrastRatio({ r: 255, g: 255, b: 255 }, background);
994
+ const againstBlack = contrastRatio({ r: 0, g: 0, b: 0 }, background);
995
+ return againstWhite > againstBlack ? "dark" : "light";
996
+ }
961
997
  export function diagnoseThemeContrast(variables, schemeId, relevantTokens) {
962
998
  const diagnostics = [];
963
999
  for (const relationship of PIE_THEME_CONTRAST_RELATIONSHIPS) {
@@ -28,6 +28,22 @@ export declare class PieThemeElement extends HTMLElementBase {
28
28
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
29
29
  protected getTarget(): HTMLElement;
30
30
  protected applyTheme(): void;
31
+ /**
32
+ * Provider adapters resolve by reading custom properties off the target, and
33
+ * those are selected by the target's own `data-theme` -- daisyUI's `--color-*`
34
+ * are the case that matters. So the target has to be carrying the incoming
35
+ * theme before the read: resolving first resolves the palette of the theme
36
+ * being replaced and leaves every `--pie-*` value one selection behind, with
37
+ * nothing to re-resolve it until some other attribute changes.
38
+ *
39
+ * The attributes are restored rather than left in place. For
40
+ * `scope="document"` the ownership arbitration in `applyTheme` decides whether
41
+ * this element may stamp them at all, and `ensureDocumentThemeBaseline` has to
42
+ * see the host's pre-existing value to have something to restore on
43
+ * disconnect. Stamp and restore happen in one synchronous pass, so the
44
+ * transient state is never painted.
45
+ */
46
+ private resolveProviderVariablesForState;
31
47
  private resolveThemeState;
32
48
  private clearPreviousTarget;
33
49
  private clearPreviousKeys;
@@ -48,6 +48,31 @@ function parseVariableOverrides(value) {
48
48
  }
49
49
  return output;
50
50
  }
51
+ /**
52
+ * `color-scheme` decides how UA-styled controls paint -- `input` and `select`
53
+ * text, scrollbars, native form widgets -- and no amount of custom properties
54
+ * reaches them. A scheme that replaces the palette without it leaves those
55
+ * controls on the host theme's polarity, which under a dark accommodation on a
56
+ * light host means dark-on-dark.
57
+ *
58
+ * `null` restores rather than removes. Clearing a scheme has to undo this
59
+ * element's keyword, and the thing to return to is whatever the host declared --
60
+ * removing outright would take a host's own `color-scheme` away the first time a
61
+ * document-scoped element resolved without a scheme, which is most of the time.
62
+ * A self-scoped target has no host declaration to preserve, so its caller passes
63
+ * no baseline and `null` does remove.
64
+ */
65
+ function applyColorScheme(target, colorScheme, baseline) {
66
+ if (colorScheme) {
67
+ target.style.setProperty("color-scheme", colorScheme);
68
+ }
69
+ else if (baseline) {
70
+ target.style.setProperty("color-scheme", baseline.value, baseline.priority);
71
+ }
72
+ else {
73
+ target.style.removeProperty("color-scheme");
74
+ }
75
+ }
51
76
  const documentThemeOwners = new Map();
52
77
  let documentThemeBaseline = null;
53
78
  let documentThemeAppliedKeys = new Set();
@@ -55,6 +80,12 @@ function ensureDocumentThemeBaseline(target, tokens) {
55
80
  documentThemeBaseline ??= {
56
81
  dataTheme: target.getAttribute("data-theme"),
57
82
  dataColorScheme: target.getAttribute("data-color-scheme"),
83
+ colorScheme: target.style.getPropertyValue("color-scheme")
84
+ ? {
85
+ value: target.style.getPropertyValue("color-scheme"),
86
+ priority: target.style.getPropertyPriority("color-scheme"),
87
+ }
88
+ : null,
58
89
  variables: new Map(),
59
90
  };
60
91
  for (const token of tokens) {
@@ -92,6 +123,7 @@ function applyDocumentThemeState(target, state) {
92
123
  else {
93
124
  target.removeAttribute("data-color-scheme");
94
125
  }
126
+ applyColorScheme(target, state.colorScheme, documentThemeBaseline?.colorScheme ?? null);
95
127
  for (const [token, value] of Object.entries(state.variables)) {
96
128
  target.style.setProperty(token, value);
97
129
  }
@@ -111,6 +143,12 @@ function restoreDocumentThemeBaseline(target) {
111
143
  else {
112
144
  target.setAttribute("data-color-scheme", baseline.dataColorScheme);
113
145
  }
146
+ if (baseline.colorScheme) {
147
+ target.style.setProperty("color-scheme", baseline.colorScheme.value, baseline.colorScheme.priority);
148
+ }
149
+ else {
150
+ target.style.removeProperty("color-scheme");
151
+ }
114
152
  restoreDocumentThemeVariables(target);
115
153
  documentThemeBaseline = null;
116
154
  documentThemeAppliedKeys.clear();
@@ -209,10 +247,8 @@ export class PieThemeElement extends HTMLElementBase {
209
247
  if (this.previousTarget && this.previousTarget !== target) {
210
248
  this.clearPreviousTarget();
211
249
  }
212
- const providerVariables = resolveProviderVariables({
213
- target,
214
- provider: this.provider,
215
- });
250
+ const dataColorScheme = this.scheme === "default" ? null : this.scheme;
251
+ const providerVariables = this.resolveProviderVariablesForState(target, dataTheme, dataColorScheme);
216
252
  const resolution = resolvePieTheme({
217
253
  baseTheme: effectiveTheme,
218
254
  providerVariables,
@@ -221,7 +257,8 @@ export class PieThemeElement extends HTMLElementBase {
221
257
  });
222
258
  const state = Object.freeze({
223
259
  dataTheme,
224
- dataColorScheme: this.scheme === "default" ? null : this.scheme,
260
+ dataColorScheme,
261
+ colorScheme: resolution.colorScheme,
225
262
  variables: resolution.variables,
226
263
  });
227
264
  if (this.scope === "document") {
@@ -245,6 +282,7 @@ export class PieThemeElement extends HTMLElementBase {
245
282
  else {
246
283
  target.removeAttribute("data-color-scheme");
247
284
  }
285
+ applyColorScheme(target, state.colorScheme, null);
248
286
  this.clearPreviousKeys(target);
249
287
  for (const [key, value] of Object.entries(resolution.variables)) {
250
288
  target.style.setProperty(key, value);
@@ -253,6 +291,40 @@ export class PieThemeElement extends HTMLElementBase {
253
291
  this.previousTarget = target;
254
292
  this.previousKeys = new Set(Object.keys(resolution.variables));
255
293
  }
294
+ /**
295
+ * Provider adapters resolve by reading custom properties off the target, and
296
+ * those are selected by the target's own `data-theme` -- daisyUI's `--color-*`
297
+ * are the case that matters. So the target has to be carrying the incoming
298
+ * theme before the read: resolving first resolves the palette of the theme
299
+ * being replaced and leaves every `--pie-*` value one selection behind, with
300
+ * nothing to re-resolve it until some other attribute changes.
301
+ *
302
+ * The attributes are restored rather than left in place. For
303
+ * `scope="document"` the ownership arbitration in `applyTheme` decides whether
304
+ * this element may stamp them at all, and `ensureDocumentThemeBaseline` has to
305
+ * see the host's pre-existing value to have something to restore on
306
+ * disconnect. Stamp and restore happen in one synchronous pass, so the
307
+ * transient state is never painted.
308
+ */
309
+ resolveProviderVariablesForState(target, dataTheme, dataColorScheme) {
310
+ const setOrRemove = (name, value) => {
311
+ if (value === null)
312
+ target.removeAttribute(name);
313
+ else
314
+ target.setAttribute(name, value);
315
+ };
316
+ const previousTheme = target.getAttribute("data-theme");
317
+ const previousColorScheme = target.getAttribute("data-color-scheme");
318
+ setOrRemove("data-theme", dataTheme);
319
+ setOrRemove("data-color-scheme", dataColorScheme);
320
+ try {
321
+ return resolveProviderVariables({ target, provider: this.provider });
322
+ }
323
+ finally {
324
+ setOrRemove("data-theme", previousTheme);
325
+ setOrRemove("data-color-scheme", previousColorScheme);
326
+ }
327
+ }
256
328
  resolveThemeState() {
257
329
  const rawTheme = this.getAttribute("theme")?.trim();
258
330
  if (rawTheme === "auto") {
@@ -286,6 +358,9 @@ export class PieThemeElement extends HTMLElementBase {
286
358
  this.clearPreviousKeys(target);
287
359
  target.removeAttribute("data-theme");
288
360
  target.removeAttribute("data-color-scheme");
361
+ // Self-scoped targets are the element itself, so anything written here
362
+ // was written by this element -- no host baseline to preserve.
363
+ target.style.removeProperty("color-scheme");
289
364
  }
290
365
  this.previousTarget = null;
291
366
  this.previousKeys.clear();
@@ -41,6 +41,11 @@ export type ThemeResolution = Readonly<{
41
41
  resolvedScheme: PieColorSchemeDescriptor | null;
42
42
  status: PieThemeResolutionStatus;
43
43
  variables: Readonly<ThemeVariables>;
44
+ /**
45
+ * The CSS `color-scheme` keyword this resolution implies, or `null` when the
46
+ * host keeps ownership of it -- which is every resolution without a scheme.
47
+ */
48
+ colorScheme: "light" | "dark" | null;
44
49
  diagnostics: readonly PieThemeDiagnostic[];
45
50
  }>;
46
51
  export type RegisteredPieColorScheme = Readonly<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/pie-theme",
3
- "version": "0.3.66",
3
+ "version": "0.3.68",
4
4
  "type": "module",
5
5
  "description": "Shared PIE theme tokens, css variables, and theme custom element",
6
6
  "license": "MIT",
@@ -46,7 +46,7 @@
46
46
  "test": "bun test --dom tests"
47
47
  },
48
48
  "devDependencies": {
49
- "@biomejs/biome": "^2.5.7",
49
+ "@biomejs/biome": "^2.5.8",
50
50
  "@happy-dom/global-registrator": "^20.11.1",
51
51
  "typescript": "^5.9.3"
52
52
  },