@texuli/deck 0.0.0-stage → 0.5.0
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/LICENSE +21 -0
- package/README.md +183 -2
- package/dist/authoring/builder.d.ts +351 -0
- package/dist/authoring/builder.js +64 -0
- package/dist/authoring/index.d.ts +6 -0
- package/dist/authoring/index.js +6 -0
- package/dist/authoring/lint.d.ts +36 -0
- package/dist/authoring/lint.js +325 -0
- package/dist/authoring/outlines.d.ts +34 -0
- package/dist/authoring/outlines.js +255 -0
- package/dist/authoring/patch.d.ts +23 -0
- package/dist/authoring/patch.js +166 -0
- package/dist/authoring/select-layouts.d.ts +19 -0
- package/dist/authoring/select-layouts.js +90 -0
- package/dist/engine/auto-slide.d.ts +32 -0
- package/dist/engine/auto-slide.js +67 -0
- package/dist/engine/canvas.d.ts +56 -0
- package/dist/engine/canvas.js +48 -0
- package/dist/engine/controller.d.ts +126 -0
- package/dist/engine/controller.js +248 -0
- package/dist/engine/controls.d.ts +128 -0
- package/dist/engine/controls.js +460 -0
- package/dist/engine/hash.d.ts +42 -0
- package/dist/engine/hash.js +86 -0
- package/dist/engine/index.d.ts +88 -0
- package/dist/engine/index.js +88 -0
- package/dist/engine/messages.d.ts +138 -0
- package/dist/engine/messages.js +140 -0
- package/dist/engine/motion.d.ts +64 -0
- package/dist/engine/motion.js +112 -0
- package/dist/engine/navigation.d.ts +87 -0
- package/dist/engine/navigation.js +156 -0
- package/dist/engine/presenter.d.ts +58 -0
- package/dist/engine/presenter.js +157 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/markdown/attributes.d.ts +24 -0
- package/dist/markdown/attributes.js +94 -0
- package/dist/markdown/attrs-closed.d.ts +52 -0
- package/dist/markdown/attrs-closed.js +520 -0
- package/dist/markdown/blocks.d.ts +112 -0
- package/dist/markdown/blocks.js +766 -0
- package/dist/markdown/frontmatter.d.ts +30 -0
- package/dist/markdown/frontmatter.js +165 -0
- package/dist/markdown/index.d.ts +29 -0
- package/dist/markdown/index.js +29 -0
- package/dist/markdown/issues.d.ts +68 -0
- package/dist/markdown/issues.js +83 -0
- package/dist/markdown/json.d.ts +21 -0
- package/dist/markdown/json.js +236 -0
- package/dist/markdown/limits.d.ts +32 -0
- package/dist/markdown/limits.js +32 -0
- package/dist/markdown/parse.d.ts +31 -0
- package/dist/markdown/parse.js +283 -0
- package/dist/markdown/scan.d.ts +26 -0
- package/dist/markdown/scan.js +138 -0
- package/dist/markdown/slides.d.ts +32 -0
- package/dist/markdown/slides.js +854 -0
- package/dist/markdown/text.d.ts +76 -0
- package/dist/markdown/text.js +447 -0
- package/dist/markdown/twin.d.ts +41 -0
- package/dist/markdown/twin.js +374 -0
- package/dist/markdown/write.d.ts +17 -0
- package/dist/markdown/write.js +688 -0
- package/dist/math/index.d.ts +14 -0
- package/dist/math/index.js +14 -0
- package/dist/math/parse.d.ts +9 -0
- package/dist/math/parse.js +648 -0
- package/dist/math/symbols.d.ts +44 -0
- package/dist/math/symbols.js +333 -0
- package/dist/math/types.d.ts +59 -0
- package/dist/math/types.js +69 -0
- package/dist/model/backgrounds/contrast.d.ts +77 -0
- package/dist/model/backgrounds/contrast.js +186 -0
- package/dist/model/backgrounds/index.d.ts +7 -0
- package/dist/model/backgrounds/index.js +7 -0
- package/dist/model/backgrounds/resolve.d.ts +86 -0
- package/dist/model/backgrounds/resolve.js +215 -0
- package/dist/model/backgrounds/schema.d.ts +178 -0
- package/dist/model/backgrounds/schema.js +203 -0
- package/dist/model/codes.d.ts +73 -0
- package/dist/model/codes.js +67 -0
- package/dist/model/deck.d.ts +392 -0
- package/dist/model/deck.js +338 -0
- package/dist/model/index.d.ts +19 -0
- package/dist/model/index.js +19 -0
- package/dist/model/layouts/catalog.d.ts +433 -0
- package/dist/model/layouts/catalog.js +495 -0
- package/dist/model/layouts/index.d.ts +5 -0
- package/dist/model/layouts/index.js +5 -0
- package/dist/model/layouts/props.d.ts +302 -0
- package/dist/model/layouts/props.js +272 -0
- package/dist/model/loader.d.ts +37 -0
- package/dist/model/loader.js +317 -0
- package/dist/model/migrate.d.ts +34 -0
- package/dist/model/migrate.js +453 -0
- package/dist/model/motion/index.d.ts +6 -0
- package/dist/model/motion/index.js +6 -0
- package/dist/model/motion/transition.d.ts +96 -0
- package/dist/model/motion/transition.js +127 -0
- package/dist/model/parse.d.ts +57 -0
- package/dist/model/parse.js +307 -0
- package/dist/model/plain.d.ts +40 -0
- package/dist/model/plain.js +275 -0
- package/dist/model/shared.d.ts +63 -0
- package/dist/model/shared.js +86 -0
- package/dist/model/steps/index.d.ts +7 -0
- package/dist/model/steps/index.js +7 -0
- package/dist/model/steps/phase.d.ts +34 -0
- package/dist/model/steps/phase.js +50 -0
- package/dist/model/steps/slide-steps.d.ts +129 -0
- package/dist/model/steps/slide-steps.js +256 -0
- package/dist/model/style/index.d.ts +8 -0
- package/dist/model/style/index.js +8 -0
- package/dist/model/style/resolve.d.ts +87 -0
- package/dist/model/style/resolve.js +174 -0
- package/dist/model/style/schema.d.ts +85 -0
- package/dist/model/style/schema.js +53 -0
- package/dist/model/style/theme.d.ts +69 -0
- package/dist/model/style/theme.js +84 -0
- package/dist/schema/builders.d.ts +146 -0
- package/dist/schema/builders.js +285 -0
- package/dist/schema/diagnostics.d.ts +78 -0
- package/dist/schema/diagnostics.js +81 -0
- package/dist/schema/index.d.ts +15 -0
- package/dist/schema/index.js +14 -0
- package/dist/schema/internal.d.ts +40 -0
- package/dist/schema/internal.js +209 -0
- package/dist/schema/json-schema.d.ts +12 -0
- package/dist/schema/json-schema.js +213 -0
- package/dist/schema/types.d.ts +166 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/validate.d.ts +51 -0
- package/dist/schema/validate.js +765 -0
- package/dist/templates/index.d.ts +2 -0
- package/dist/templates/index.js +2 -0
- package/dist/templates/templates.d.ts +11 -0
- package/dist/templates/templates.js +47 -0
- package/package.json +60 -4
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WCAG 2.x contrast math for deck backgrounds, on sRGB hex colors only.
|
|
3
|
+
*
|
|
4
|
+
* Every function here is pure and framework-free. Inputs are `#rgb` or
|
|
5
|
+
* `#rrggbb` (and, through `opaqueColor`, `rgb()`/`rgba()` composited over a
|
|
6
|
+
* base); `oklch()` and other color spaces are converted by the caller (the
|
|
7
|
+
* stage reads them from `@texuli/tokens`). Outputs are always lowercase
|
|
8
|
+
* `#rrggbb`, so a computed color can go into CSS without escaping.
|
|
9
|
+
*/
|
|
10
|
+
/** WCAG AA for normal-size body text: the floor every background guarantees. */
|
|
11
|
+
export const DECK_TEXT_CONTRAST_MIN = 4.5;
|
|
12
|
+
/** Scrim alphas move in steps of 0.05 … */
|
|
13
|
+
export const DECK_SCRIM_STEP = 0.05;
|
|
14
|
+
/** … up to 0.9 (an image or video always stays faintly visible). */
|
|
15
|
+
export const DECK_SCRIM_MAX = 0.9;
|
|
16
|
+
/** Light ink and the scrim under it; dark ink and the scrim under it. */
|
|
17
|
+
export const PURE_LIGHT = '#ffffff';
|
|
18
|
+
export const PURE_DARK = '#000000';
|
|
19
|
+
const HEX6 = /^#([0-9a-f]{6})$/i;
|
|
20
|
+
const HEX3 = /^#([0-9a-f]{3})$/i;
|
|
21
|
+
/** `#rgb` or `#rrggbb` (any case, surrounding spaces ignored) as 0–255 channels; anything else is `null`. */
|
|
22
|
+
export function parseHexColor(color) {
|
|
23
|
+
if (typeof color !== 'string' || color.length > 16)
|
|
24
|
+
return null;
|
|
25
|
+
const value = color.trim();
|
|
26
|
+
let hex;
|
|
27
|
+
const long = HEX6.exec(value);
|
|
28
|
+
if (long)
|
|
29
|
+
hex = long[1];
|
|
30
|
+
else {
|
|
31
|
+
const short = HEX3.exec(value);
|
|
32
|
+
if (!short)
|
|
33
|
+
return null;
|
|
34
|
+
hex = [...short[1]].map((c) => c + c).join('');
|
|
35
|
+
}
|
|
36
|
+
return [0, 2, 4].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
|
|
37
|
+
}
|
|
38
|
+
const byte = (n) => Math.round(Math.min(255, Math.max(0, n)))
|
|
39
|
+
.toString(16)
|
|
40
|
+
.padStart(2, '0');
|
|
41
|
+
/** Channels → lowercase `#rrggbb` (channels are clamped and rounded). */
|
|
42
|
+
export function toHex(rgb) {
|
|
43
|
+
return `#${rgb.map(byte).join('')}`;
|
|
44
|
+
}
|
|
45
|
+
/** A hex color in its canonical form (`#rrggbb`, lowercase), or `null`. */
|
|
46
|
+
export function normalizeHex(color) {
|
|
47
|
+
const rgb = parseHexColor(color);
|
|
48
|
+
return rgb ? toHex(rgb) : null;
|
|
49
|
+
}
|
|
50
|
+
/** sRGB → linear light (the IEC 61966-2-1 threshold; no 8-bit value differs from WCAG's 0.03928). */
|
|
51
|
+
function linear(channel) {
|
|
52
|
+
const c = channel / 255;
|
|
53
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
54
|
+
}
|
|
55
|
+
function requireHex(color, fn) {
|
|
56
|
+
const rgb = parseHexColor(color);
|
|
57
|
+
if (!rgb)
|
|
58
|
+
throw new TypeError(`${fn}(): expected a hex color, got ${JSON.stringify(color)}.`);
|
|
59
|
+
return rgb;
|
|
60
|
+
}
|
|
61
|
+
/** WCAG relative luminance (0 for black, 1 for white). Throws `TypeError` on a non-hex color. */
|
|
62
|
+
export function relativeLuminance(color) {
|
|
63
|
+
const [r, g, b] = requireHex(color, 'relativeLuminance');
|
|
64
|
+
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
65
|
+
}
|
|
66
|
+
/** WCAG contrast ratio, 1 to 21, order independent. Throws `TypeError` on a non-hex color. */
|
|
67
|
+
export function contrastRatio(a, b) {
|
|
68
|
+
const la = relativeLuminance(a);
|
|
69
|
+
const lb = relativeLuminance(b);
|
|
70
|
+
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
|
71
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
72
|
+
}
|
|
73
|
+
/** `top` over `base` at `alpha` (0–1), composited in sRGB as browsers paint it. */
|
|
74
|
+
export function compositeOver(base, top, alpha) {
|
|
75
|
+
const b = requireHex(base, 'compositeOver');
|
|
76
|
+
const t = requireHex(top, 'compositeOver');
|
|
77
|
+
const a = Number.isFinite(alpha) ? Math.min(1, Math.max(0, alpha)) : 0;
|
|
78
|
+
return toHex([0, 1, 2].map((i) => b[i] * (1 - a) + t[i] * a));
|
|
79
|
+
}
|
|
80
|
+
const RGB = /^rgba?\(\s*(\d{1,3})\s*[\s,]\s*(\d{1,3})\s*[\s,]\s*(\d{1,3})\s*(?:[,/]\s*(\d{0,3}(?:\.\d{1,6})?)(%?)\s*)?\)$/i;
|
|
81
|
+
/**
|
|
82
|
+
* A palette color as an opaque hex: hex passes through (canonicalized),
|
|
83
|
+
* `rgb()`/`rgba()` is composited over `base` (dark-mode borders carry alpha).
|
|
84
|
+
* Anything else is `null`, so the contrast math never guesses at a color.
|
|
85
|
+
*/
|
|
86
|
+
export function opaqueColor(color, base) {
|
|
87
|
+
const hex = normalizeHex(color);
|
|
88
|
+
if (hex)
|
|
89
|
+
return hex;
|
|
90
|
+
if (typeof color !== 'string' || color.length > 64)
|
|
91
|
+
return null;
|
|
92
|
+
const match = RGB.exec(color.trim());
|
|
93
|
+
if (!match)
|
|
94
|
+
return null;
|
|
95
|
+
const rgb = [match[1], match[2], match[3]].map((n) => Math.min(255, Number(n)));
|
|
96
|
+
const raw = match[4];
|
|
97
|
+
let alpha = 1;
|
|
98
|
+
if (raw !== undefined && raw !== '') {
|
|
99
|
+
alpha = Number(raw) / (match[5] === '%' ? 100 : 1);
|
|
100
|
+
if (!Number.isFinite(alpha))
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return compositeOver(base, toHex(rgb), alpha);
|
|
104
|
+
}
|
|
105
|
+
/** Points along a linear sRGB gradient: every stop and `perSegment - 1` points between each pair. */
|
|
106
|
+
export function sampleGradient(stops, perSegment = 16) {
|
|
107
|
+
const steps = Math.max(1, Math.min(256, Math.floor(perSegment)));
|
|
108
|
+
const out = [];
|
|
109
|
+
stops.forEach((stop, i) => {
|
|
110
|
+
out.push(toHex(requireHex(stop, 'sampleGradient')));
|
|
111
|
+
const next = stops[i + 1];
|
|
112
|
+
if (next === undefined)
|
|
113
|
+
return;
|
|
114
|
+
for (let k = 1; k < steps; k++)
|
|
115
|
+
out.push(compositeOver(stop, next, k / steps));
|
|
116
|
+
});
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** The lowest contrast of `ink` over any of `backgrounds` (21 for an empty list). */
|
|
120
|
+
export function worstContrast(ink, backgrounds) {
|
|
121
|
+
let worst = 21;
|
|
122
|
+
for (const background of backgrounds)
|
|
123
|
+
worst = Math.min(worst, contrastRatio(ink, background));
|
|
124
|
+
return worst;
|
|
125
|
+
}
|
|
126
|
+
/** Round to the scrim grid (0.05) without float noise. */
|
|
127
|
+
const stepAlpha = (step) => Math.round(step * DECK_SCRIM_STEP * 100) / 100;
|
|
128
|
+
const MAX_STEP = Math.round(DECK_SCRIM_MAX / DECK_SCRIM_STEP);
|
|
129
|
+
/** The first scrim step at or above `alpha` (an author overlay is never weakened). */
|
|
130
|
+
export function scrimStepFor(alpha) {
|
|
131
|
+
if (!Number.isFinite(alpha) || alpha <= 0)
|
|
132
|
+
return 0;
|
|
133
|
+
return Math.min(MAX_STEP, Math.ceil(alpha / DECK_SCRIM_STEP - 1e-9));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The lowest scrim (in 0.05 steps, from `minimumScrim` up to 0.9) under which
|
|
137
|
+
* `ink` reaches `minimum` over every background, or `null` when none does.
|
|
138
|
+
*/
|
|
139
|
+
export function inkWithScrim(tone, ink, backgrounds, minimumScrim = 0, minimum = DECK_TEXT_CONTRAST_MIN) {
|
|
140
|
+
const scrimColor = tone === 'light' ? PURE_DARK : PURE_LIGHT;
|
|
141
|
+
for (let step = scrimStepFor(minimumScrim); step <= MAX_STEP; step++) {
|
|
142
|
+
const alpha = stepAlpha(step);
|
|
143
|
+
const seen = alpha === 0 ? backgrounds : backgrounds.map((bg) => compositeOver(bg, scrimColor, alpha));
|
|
144
|
+
const contrast = worstContrast(ink, seen);
|
|
145
|
+
if (contrast >= minimum)
|
|
146
|
+
return { tone, ink, scrimColor, scrim: alpha, contrast };
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The ink for a set of backgrounds. Each tone tries the theme ink first and
|
|
152
|
+
* pure white or black second; the choice needs the lightest scrim, then
|
|
153
|
+
* prefers the theme ink, then the higher contrast. Pure ink over a 0.9 scrim
|
|
154
|
+
* always clears AA (about 15:1 against any pixel), so a result always exists.
|
|
155
|
+
*/
|
|
156
|
+
export function chooseInk(backgrounds, inks, options = {}) {
|
|
157
|
+
const minimum = Math.max(DECK_TEXT_CONTRAST_MIN, Math.min(21, options.minimum ?? 0));
|
|
158
|
+
const tones = options.forced ? [options.forced] : ['light', 'dark'];
|
|
159
|
+
let best;
|
|
160
|
+
for (const tone of tones) {
|
|
161
|
+
const pure = tone === 'light' ? PURE_LIGHT : PURE_DARK;
|
|
162
|
+
const themeInk = normalizeHex(inks[tone]) ?? pure;
|
|
163
|
+
const candidates = themeInk === pure ? [pure] : [themeInk, pure];
|
|
164
|
+
for (const ink of candidates) {
|
|
165
|
+
const choice = inkWithScrim(tone, ink, backgrounds, options.minimumScrim ?? 0, minimum);
|
|
166
|
+
if (!choice)
|
|
167
|
+
continue;
|
|
168
|
+
const themed = ink !== pure;
|
|
169
|
+
const better = !best ||
|
|
170
|
+
choice.scrim < best.choice.scrim ||
|
|
171
|
+
(choice.scrim === best.choice.scrim &&
|
|
172
|
+
((themed && !best.themed) ||
|
|
173
|
+
(themed === best.themed && choice.contrast > best.choice.contrast)));
|
|
174
|
+
if (better)
|
|
175
|
+
best = { choice, themed };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (best)
|
|
179
|
+
return best.choice;
|
|
180
|
+
// Only reachable with a `minimum` above what a 0.9 scrim allows: report the strongest attempt.
|
|
181
|
+
const tone = options.forced ?? 'light';
|
|
182
|
+
const ink = tone === 'light' ? PURE_LIGHT : PURE_DARK;
|
|
183
|
+
const scrimColor = tone === 'light' ? PURE_DARK : PURE_LIGHT;
|
|
184
|
+
const seen = backgrounds.map((bg) => compositeOver(bg, scrimColor, DECK_SCRIM_MAX));
|
|
185
|
+
return { tone, ink, scrimColor, scrim: DECK_SCRIM_MAX, contrast: worstContrast(ink, seen) };
|
|
186
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deck backgrounds (model v2 §3.5): presets, the spec schema, the resolver
|
|
3
|
+
* and the WCAG contrast math it relies on. Framework-free and dependency-free.
|
|
4
|
+
*/
|
|
5
|
+
export { type ChooseInkOptions, chooseInk, compositeOver, contrastRatio, DECK_SCRIM_MAX, DECK_SCRIM_STEP, DECK_TEXT_CONTRAST_MIN, type InkChoice, inkWithScrim, normalizeHex, opaqueColor, PURE_DARK, PURE_LIGHT, parseHexColor, type Rgb, relativeLuminance, sampleGradient, scrimStepFor, type TextTone, toHex, worstContrast, } from './contrast.js';
|
|
6
|
+
export { DECK_BG_VAR_PREFIX, type DeckBackgroundCssVar, type DeckBackgroundLayer, type ResolveDeckBackgroundOptions, type ResolvedDeckBackground, resolveDeckBackground, } from './resolve.js';
|
|
7
|
+
export { BACKGROUND_CODES, DECK_BACKGROUND_PRESET_IDS, DECK_BACKGROUND_PRESETS, DECK_GRADIENT_ANGLE, DECK_GRADIENT_IDS, DECK_GRADIENTS, DECK_MEDIA_FITS, DECK_MEDIA_POSITIONS, DECK_MEDIA_TONES, DECK_PALETTE_ROLES, DECK_PATTERNS, DECK_SOLID_TOKENS, DEFAULT_DECK_BACKGROUND, type DeckBackground, type DeckBackgroundKind, type DeckBackgroundPalette, type DeckBackgroundPresetId, type DeckBackgroundSpec, type DeckGradient, type DeckPaletteRole, type DeckPattern, type DeckSolidToken, deckBackground, deckBackgroundPreset, deckBackgroundSpec, HEX_COLOR_PATTERN, isDeckBackgroundPreset, } from './schema.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deck backgrounds (model v2 §3.5): presets, the spec schema, the resolver
|
|
3
|
+
* and the WCAG contrast math it relies on. Framework-free and dependency-free.
|
|
4
|
+
*/
|
|
5
|
+
export { chooseInk, compositeOver, contrastRatio, DECK_SCRIM_MAX, DECK_SCRIM_STEP, DECK_TEXT_CONTRAST_MIN, inkWithScrim, normalizeHex, opaqueColor, PURE_DARK, PURE_LIGHT, parseHexColor, relativeLuminance, sampleGradient, scrimStepFor, toHex, worstContrast, } from "./contrast.js";
|
|
6
|
+
export { DECK_BG_VAR_PREFIX, resolveDeckBackground, } from "./resolve.js";
|
|
7
|
+
export { BACKGROUND_CODES, DECK_BACKGROUND_PRESET_IDS, DECK_BACKGROUND_PRESETS, DECK_GRADIENT_ANGLE, DECK_GRADIENT_IDS, DECK_GRADIENTS, DECK_MEDIA_FITS, DECK_MEDIA_POSITIONS, DECK_MEDIA_TONES, DECK_PALETTE_ROLES, DECK_PATTERNS, DECK_SOLID_TOKENS, DEFAULT_DECK_BACKGROUND, deckBackground, deckBackgroundPreset, deckBackgroundSpec, HEX_COLOR_PATTERN, isDeckBackgroundPreset, } from "./schema.js";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a background against a palette into paint layers, a text tone and
|
|
3
|
+
* `--tx-deck-bg-*` CSS variables, with the body-text guarantee: the text
|
|
4
|
+
* color reaches WCAG AA (4.5:1) over everything behind it. Gradients are
|
|
5
|
+
* checked at their stops and 15 points between each pair, patterns on their
|
|
6
|
+
* base and their lines, media at the worst possible pixel (pure black and
|
|
7
|
+
* pure white). When the background alone is not enough, a scrim (black under
|
|
8
|
+
* light text, white under dark text) is added at the lowest alpha in 0.05
|
|
9
|
+
* steps, up to 0.9, that works; an author `overlay` only ever raises it.
|
|
10
|
+
*
|
|
11
|
+
* Pure and deterministic. It never throws: an invalid background or an
|
|
12
|
+
* unreadable palette color is reported in `issues` and replaced by a
|
|
13
|
+
* documented fallback.
|
|
14
|
+
*/
|
|
15
|
+
import { type DeckDiagnostic, type PathSegment } from '../../schema/index.js';
|
|
16
|
+
import { type TextTone } from './contrast.js';
|
|
17
|
+
import { type DeckBackgroundKind, type DeckBackgroundPalette, type DeckBackgroundPresetId, type DeckPattern } from './schema.js';
|
|
18
|
+
/** Every CSS variable a background sets starts with this prefix. */
|
|
19
|
+
export declare const DECK_BG_VAR_PREFIX = "--tx-deck-bg-";
|
|
20
|
+
export type DeckBackgroundCssVar = `--tx-deck-bg-${string}`;
|
|
21
|
+
/** One paint layer, bottom to top. Colors are always computed `#rrggbb`. */
|
|
22
|
+
export type DeckBackgroundLayer = {
|
|
23
|
+
type: 'color';
|
|
24
|
+
color: string;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'gradient';
|
|
27
|
+
angle: number;
|
|
28
|
+
stops: string[];
|
|
29
|
+
} | {
|
|
30
|
+
type: 'pattern';
|
|
31
|
+
pattern: DeckPattern;
|
|
32
|
+
color: string;
|
|
33
|
+
ink: string;
|
|
34
|
+
} | {
|
|
35
|
+
type: 'media';
|
|
36
|
+
media: 'image' | 'video';
|
|
37
|
+
/** Syntax-checked only: the stage applies the URL policy at the host tier. */
|
|
38
|
+
src: string;
|
|
39
|
+
full: boolean;
|
|
40
|
+
fit: 'cover' | 'contain';
|
|
41
|
+
position: 'center' | 'top' | 'bottom' | 'left' | 'right';
|
|
42
|
+
alt?: string;
|
|
43
|
+
poster?: string;
|
|
44
|
+
/** Background video is always muted, looping, inline and without controls. */
|
|
45
|
+
muted?: true;
|
|
46
|
+
autoplay?: true;
|
|
47
|
+
loop?: true;
|
|
48
|
+
playsInline?: true;
|
|
49
|
+
} | {
|
|
50
|
+
type: 'scrim';
|
|
51
|
+
color: string;
|
|
52
|
+
alpha: number;
|
|
53
|
+
};
|
|
54
|
+
export interface ResolvedDeckBackground {
|
|
55
|
+
kind: DeckBackgroundKind;
|
|
56
|
+
/** The preset id, when the background came from one (or fell back to the default). */
|
|
57
|
+
preset?: DeckBackgroundPresetId;
|
|
58
|
+
/** Bottom to top: a base color, then the gradient, pattern or media, then the scrim. */
|
|
59
|
+
layers: DeckBackgroundLayer[];
|
|
60
|
+
/** `light` text on dark backgrounds, `dark` text on light ones. */
|
|
61
|
+
textTone: TextTone;
|
|
62
|
+
textColor: string;
|
|
63
|
+
/** Scrim alpha: 0 to 0.9 in 0.05 steps (0 means none is painted). */
|
|
64
|
+
scrim: number;
|
|
65
|
+
/** Black under light text, white under dark text. */
|
|
66
|
+
scrimColor: string;
|
|
67
|
+
/** Worst-case contrast of `textColor` over the background and scrim, ≥ 4.5 (floored to 0.01). */
|
|
68
|
+
contrast: number;
|
|
69
|
+
/** `--tx-deck-bg-*` variables built only from computed colors, numbers and enum keywords. */
|
|
70
|
+
cssVars: Record<DeckBackgroundCssVar, string>;
|
|
71
|
+
/** Recovered problems (invalid background, unreadable palette colors). */
|
|
72
|
+
issues: DeckDiagnostic[];
|
|
73
|
+
}
|
|
74
|
+
export interface ResolveDeckBackgroundOptions {
|
|
75
|
+
/** Raise the body-text floor (values below 4.5 are ignored). */
|
|
76
|
+
minimumContrast?: number;
|
|
77
|
+
/** A prefix for issue paths, e.g. `['slides', 2, 'background']`. */
|
|
78
|
+
path?: readonly PathSegment[];
|
|
79
|
+
slideId?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve `background` (a preset id, a spec, or `undefined` for the default
|
|
83
|
+
* `solid-color`) against `palette` (semantic role → hex, see
|
|
84
|
+
* `DeckBackgroundPalette`).
|
|
85
|
+
*/
|
|
86
|
+
export declare function resolveDeckBackground(background: unknown, palette: DeckBackgroundPalette, options?: ResolveDeckBackgroundOptions): ResolvedDeckBackground;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a background against a palette into paint layers, a text tone and
|
|
3
|
+
* `--tx-deck-bg-*` CSS variables, with the body-text guarantee: the text
|
|
4
|
+
* color reaches WCAG AA (4.5:1) over everything behind it. Gradients are
|
|
5
|
+
* checked at their stops and 15 points between each pair, patterns on their
|
|
6
|
+
* base and their lines, media at the worst possible pixel (pure black and
|
|
7
|
+
* pure white). When the background alone is not enough, a scrim (black under
|
|
8
|
+
* light text, white under dark text) is added at the lowest alpha in 0.05
|
|
9
|
+
* steps, up to 0.9, that works; an author `overlay` only ever raises it.
|
|
10
|
+
*
|
|
11
|
+
* Pure and deterministic. It never throws: an invalid background or an
|
|
12
|
+
* unreadable palette color is reported in `issues` and replaced by a
|
|
13
|
+
* documented fallback.
|
|
14
|
+
*/
|
|
15
|
+
import { createDiagnostic, validate, } from "../../schema/index.js";
|
|
16
|
+
import { chooseInk, compositeOver, DECK_TEXT_CONTRAST_MIN, opaqueColor, PURE_DARK, PURE_LIGHT, relativeLuminance, sampleGradient, } from "./contrast.js";
|
|
17
|
+
import { BACKGROUND_CODES, DECK_BACKGROUND_PRESETS, DECK_GRADIENT_ANGLE, DECK_GRADIENTS, DECK_PALETTE_ROLES, DEFAULT_DECK_BACKGROUND, deckBackground, isDeckBackgroundPreset, } from "./schema.js";
|
|
18
|
+
/** Every CSS variable a background sets starts with this prefix. */
|
|
19
|
+
export const DECK_BG_VAR_PREFIX = '--tx-deck-bg-';
|
|
20
|
+
/** Any pixel of an image or video: the scrim must hold against both extremes. */
|
|
21
|
+
const ANY_PIXEL = [PURE_DARK, PURE_LIGHT];
|
|
22
|
+
/** Points per gradient segment the guarantee is checked at. */
|
|
23
|
+
const GRADIENT_SAMPLES = 16;
|
|
24
|
+
/** Fade steps between the background (0) and the border (full strength) for pattern lines. */
|
|
25
|
+
const PATTERN_INK_STEPS = 20;
|
|
26
|
+
function normalizePalette(input, issues, options) {
|
|
27
|
+
const where = (role) => [...(options.path ?? []), 'palette', role];
|
|
28
|
+
const raw = (role) => input !== null && typeof input === 'object' && Object.hasOwn(input, role)
|
|
29
|
+
? input[role]
|
|
30
|
+
: undefined;
|
|
31
|
+
const bad = (role, fallback) => {
|
|
32
|
+
issues.push(createDiagnostic(BACKGROUND_CODES.invalidPaletteColor, `Palette role "${role}" is not a hex or rgb() color; using ${fallback}.`, where(role), { severity: 'warning', fix: 'Pass the role as #rrggbb.', slideId: options.slideId }));
|
|
33
|
+
};
|
|
34
|
+
let background = opaqueColor(raw('background'), PURE_LIGHT);
|
|
35
|
+
if (!background) {
|
|
36
|
+
background = PURE_LIGHT;
|
|
37
|
+
bad('background', background);
|
|
38
|
+
}
|
|
39
|
+
const out = { background };
|
|
40
|
+
for (const role of DECK_PALETTE_ROLES) {
|
|
41
|
+
if (role === 'background')
|
|
42
|
+
continue;
|
|
43
|
+
const color = opaqueColor(raw(role), background);
|
|
44
|
+
if (color)
|
|
45
|
+
out[role] = color;
|
|
46
|
+
else {
|
|
47
|
+
// Foreground falls back to the pure ink that reads on the background; the rest to the background.
|
|
48
|
+
const fallback = role === 'foreground'
|
|
49
|
+
? relativeLuminance(background) > 0.179
|
|
50
|
+
? PURE_DARK
|
|
51
|
+
: PURE_LIGHT
|
|
52
|
+
: background;
|
|
53
|
+
bad(role, fallback);
|
|
54
|
+
out[role] = fallback;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const inverse = raw('inverse');
|
|
58
|
+
if (inverse === undefined)
|
|
59
|
+
out.inverse = out.foreground;
|
|
60
|
+
else {
|
|
61
|
+
const color = opaqueColor(inverse, background);
|
|
62
|
+
if (!color)
|
|
63
|
+
bad('inverse', out.foreground);
|
|
64
|
+
out.inverse = color ?? out.foreground;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** The theme's text-bearing pair, as the light and the dark ink. */
|
|
69
|
+
function themeInks(palette) {
|
|
70
|
+
const fg = palette.foreground;
|
|
71
|
+
const bg = palette.background;
|
|
72
|
+
return relativeLuminance(fg) >= relativeLuminance(bg)
|
|
73
|
+
? { light: fg, dark: bg }
|
|
74
|
+
: { light: bg, dark: fg };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The line color of a dot or grid pattern: the theme `border`, faded toward
|
|
78
|
+
* the base only as far as text stays at AA on the lines without any scrim
|
|
79
|
+
* (a pattern paints none). Strong 3:1 borders would otherwise be as dark as text.
|
|
80
|
+
*/
|
|
81
|
+
function patternInk(base, border, inks) {
|
|
82
|
+
for (let step = PATTERN_INK_STEPS; step > 0; step -= 1) {
|
|
83
|
+
const ink = step === PATTERN_INK_STEPS ? border : compositeOver(base, border, step / PATTERN_INK_STEPS);
|
|
84
|
+
if (chooseInk([base, ink], inks).scrim === 0)
|
|
85
|
+
return ink;
|
|
86
|
+
}
|
|
87
|
+
return base;
|
|
88
|
+
}
|
|
89
|
+
function solidColor(token, palette) {
|
|
90
|
+
return palette[token];
|
|
91
|
+
}
|
|
92
|
+
function pickSpec(input, issues, options) {
|
|
93
|
+
if (input === undefined) {
|
|
94
|
+
return {
|
|
95
|
+
spec: DECK_BACKGROUND_PRESETS[DEFAULT_DECK_BACKGROUND],
|
|
96
|
+
preset: DEFAULT_DECK_BACKGROUND,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (isDeckBackgroundPreset(input))
|
|
100
|
+
return { spec: DECK_BACKGROUND_PRESETS[input], preset: input };
|
|
101
|
+
const checked = validate(deckBackground, input, { path: options.path ?? [], maxIssues: 20 });
|
|
102
|
+
if (checked.ok && typeof checked.value === 'object')
|
|
103
|
+
return { spec: checked.value };
|
|
104
|
+
for (const issue of checked.issues) {
|
|
105
|
+
issues.push(options.slideId === undefined ? issue : { ...issue, slideId: options.slideId });
|
|
106
|
+
}
|
|
107
|
+
// The validation issues explain the problem; the fallback is documented, not repeated.
|
|
108
|
+
if (checked.issues.length > 0) {
|
|
109
|
+
return {
|
|
110
|
+
spec: DECK_BACKGROUND_PRESETS[DEFAULT_DECK_BACKGROUND],
|
|
111
|
+
preset: DEFAULT_DECK_BACKGROUND,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
issues.push(createDiagnostic(BACKGROUND_CODES.unknownBackground, `The background is not valid; using ${DEFAULT_DECK_BACKGROUND}.`, options.path ?? [], {
|
|
115
|
+
fix: `Use a preset id or a background spec (${DEFAULT_DECK_BACKGROUND} is the default).`,
|
|
116
|
+
slideId: options.slideId,
|
|
117
|
+
}));
|
|
118
|
+
return {
|
|
119
|
+
spec: DECK_BACKGROUND_PRESETS[DEFAULT_DECK_BACKGROUND],
|
|
120
|
+
preset: DEFAULT_DECK_BACKGROUND,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Resolve `background` (a preset id, a spec, or `undefined` for the default
|
|
125
|
+
* `solid-color`) against `palette` (semantic role → hex, see
|
|
126
|
+
* `DeckBackgroundPalette`).
|
|
127
|
+
*/
|
|
128
|
+
export function resolveDeckBackground(background, palette, options = {}) {
|
|
129
|
+
const issues = [];
|
|
130
|
+
const colors = normalizePalette(palette, issues, options);
|
|
131
|
+
const { spec, preset } = pickSpec(background, issues, options);
|
|
132
|
+
const inks = themeInks(colors);
|
|
133
|
+
const minimum = Math.max(DECK_TEXT_CONTRAST_MIN, options.minimumContrast ?? 0);
|
|
134
|
+
const layers = [];
|
|
135
|
+
const cssVars = {};
|
|
136
|
+
let base;
|
|
137
|
+
let choice;
|
|
138
|
+
switch (spec.kind) {
|
|
139
|
+
case 'solid': {
|
|
140
|
+
base = spec.color ? spec.color.toLowerCase() : solidColor(spec.token ?? 'background', colors);
|
|
141
|
+
layers.push({ type: 'color', color: base });
|
|
142
|
+
choice = chooseInk([base], inks, { minimum });
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'gradient': {
|
|
146
|
+
const stops = DECK_GRADIENTS[spec.gradient].map((role) => colors[role]);
|
|
147
|
+
const angle = spec.angle ?? DECK_GRADIENT_ANGLE;
|
|
148
|
+
base = stops[0];
|
|
149
|
+
layers.push({ type: 'color', color: base }, { type: 'gradient', angle, stops });
|
|
150
|
+
choice = chooseInk(sampleGradient(stops, GRADIENT_SAMPLES), inks, { minimum });
|
|
151
|
+
cssVars['--tx-deck-bg-gradient'] = `linear-gradient(${angle}deg, ${stops.join(', ')})`;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case 'pattern': {
|
|
155
|
+
base = colors.background;
|
|
156
|
+
const ink = patternInk(base, colors.border, inks);
|
|
157
|
+
layers.push({ type: 'color', color: base }, { type: 'pattern', pattern: spec.pattern, color: base, ink });
|
|
158
|
+
choice = chooseInk([base, ink], inks, { minimum });
|
|
159
|
+
cssVars['--tx-deck-bg-pattern'] = spec.pattern;
|
|
160
|
+
cssVars['--tx-deck-bg-pattern-ink'] = ink;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
default: {
|
|
164
|
+
// image, image-full, video, video-full: light text over a dark scrim unless declared light.
|
|
165
|
+
const forced = spec.tone === 'light' ? 'dark' : 'light';
|
|
166
|
+
base = forced === 'light' ? PURE_DARK : PURE_LIGHT;
|
|
167
|
+
const isVideo = spec.kind === 'video' || spec.kind === 'video-full';
|
|
168
|
+
const fit = 'fit' in spec && spec.fit ? spec.fit : 'cover';
|
|
169
|
+
const position = spec.position ?? 'center';
|
|
170
|
+
layers.push({ type: 'color', color: base }, {
|
|
171
|
+
type: 'media',
|
|
172
|
+
media: isVideo ? 'video' : 'image',
|
|
173
|
+
src: spec.src,
|
|
174
|
+
full: spec.kind.endsWith('-full'),
|
|
175
|
+
fit,
|
|
176
|
+
position,
|
|
177
|
+
...('alt' in spec && spec.alt ? { alt: spec.alt } : {}),
|
|
178
|
+
...('poster' in spec && spec.poster ? { poster: spec.poster } : {}),
|
|
179
|
+
...(isVideo
|
|
180
|
+
? { muted: true, autoplay: true, loop: true, playsInline: true }
|
|
181
|
+
: {}),
|
|
182
|
+
});
|
|
183
|
+
choice = chooseInk(ANY_PIXEL, inks, {
|
|
184
|
+
forced,
|
|
185
|
+
minimumScrim: spec.overlay ?? 0,
|
|
186
|
+
minimum,
|
|
187
|
+
});
|
|
188
|
+
cssVars['--tx-deck-bg-media-fit'] = fit;
|
|
189
|
+
cssVars['--tx-deck-bg-media-position'] = position;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (choice.scrim > 0)
|
|
194
|
+
layers.push({ type: 'scrim', color: choice.scrimColor, alpha: choice.scrim });
|
|
195
|
+
const ordered = {
|
|
196
|
+
'--tx-deck-bg-color': base,
|
|
197
|
+
'--tx-deck-bg-text': choice.ink,
|
|
198
|
+
'--tx-deck-bg-tone': choice.tone,
|
|
199
|
+
'--tx-deck-bg-scrim': choice.scrim > 0 ? choice.scrimColor : 'transparent',
|
|
200
|
+
'--tx-deck-bg-scrim-alpha': String(choice.scrim),
|
|
201
|
+
...cssVars,
|
|
202
|
+
};
|
|
203
|
+
return {
|
|
204
|
+
kind: spec.kind,
|
|
205
|
+
...(preset ? { preset } : {}),
|
|
206
|
+
layers,
|
|
207
|
+
textTone: choice.tone,
|
|
208
|
+
textColor: choice.ink,
|
|
209
|
+
scrim: choice.scrim,
|
|
210
|
+
scrimColor: choice.scrimColor,
|
|
211
|
+
contrast: Math.floor(choice.contrast * 100) / 100,
|
|
212
|
+
cssVars: ordered,
|
|
213
|
+
issues,
|
|
214
|
+
};
|
|
215
|
+
}
|