@appshoteditor/shot-dsl 0.3.0 → 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/README.md CHANGED
@@ -16,11 +16,18 @@ composer — so a layout composed by the skill renders identically in the editor
16
16
  `makeScreen`, `makeTemplate`.
17
17
  - **Device geometry** — `deviceFrames`, `getDeviceFrame`, `makeDeviceFrameLayer`, `calculateDeviceScale`,
18
18
  `computeScreenshotPlacement` (the screenshot-in-frame fit/clip math the editor uses).
19
- - **Composer** — `composeTemplate(plan)`: a benefit/screenshot plan → a validated, device-framed `Template`.
20
- Per screen: `headline`, optional `subheadline` / `headlineColor` / `subheadlineColor`, and `layout`
21
- (`text-top` default, `text-bottom`, `device-bleed` — see `COMPOSE_LAYOUTS`). All geometry (device
22
- scale/position, font sizes, padding) is derived from the canvas size, so a plan composes to the
23
- same proportions at 280×608 editor units or 1320×2868 native pixels.
19
+ - **Composer** — `composeTemplate(plan)`: a benefit/screenshot plan → a validated, device-framed `Template`
20
+ (`composeSet(plan)` returns the same template plus a `report` with lint warnings and per-screen
21
+ geometry). Per screen: `headline`, optional `subheadline` / `headlineColor` / `subheadlineColor`, and
22
+ `layout` (`text-top` default, `text-bottom`, `device-bleed` — see `COMPOSE_LAYOUTS`). All geometry
23
+ (device scale/position, font sizes, padding) is derived from the canvas size, so a plan composes to
24
+ the same proportions at 280×608 editor units or 1320×2868 native pixels.
25
+ - **Set layout system (0.4.0)** — `solveVertical` / `NO_TANGENT` (the no-tangent rule), `focusReach`,
26
+ `rotatedBox` (`layout-system.ts`); `makeVariants(plan)` (three concept plans, `variants.ts`); small
27
+ colour helpers (`color.ts`).
28
+ - **Art direction (0.5.0)** — `breakLines` (balanced line breaks, `typography.ts`), tonal palettes +
29
+ `harmonize` (one WCAG-AA text colour per set, `palette.ts`), panorama motifs (`decor.ts`), and in
30
+ `composeSet`: the hero, accent rhythm, callouts, mascots and shadows (see "Art direction" below).
24
31
 
25
32
  ```ts
26
33
  import { composeTemplate, validateTemplate } from '@appshoteditor/shot-dsl';
@@ -41,6 +48,207 @@ const template = composeTemplate({
41
48
  validateTemplate(template); // { valid: true, errors: [] }
42
49
  ```
43
50
 
51
+ ## Set layout system (0.4.0)
52
+
53
+ `composeSet` lays a plan out as ONE system, the way a screenshot designer would:
54
+
55
+ 1. **One system per set.** It measures every screen's text block first and reserves a text area
56
+ for the TALLEST one (per canvas size). The headline size is fixed for the whole set
57
+ (`HEADLINE_SIZE` × the type unit). Long copy is never shrunk: it gets a lint warning instead.
58
+ Screens that share a canvas size, presentation, device, layout and tilt (`report.screens[i].group`)
59
+ get the same subject scale and baseline, so copy length never moves or resizes anything.
60
+ 2. **No tangents.** A device (or frameless screenshot) either clears the far edge by ≥ 4% of H or
61
+ bleeds off it by ≥ 12% of its own height. The band in between is the "just touching" look, and
62
+ `solveVertical` never emits it. `style.bleed` picks how a tangent is resolved:
63
+ - `auto` (default): a device that already clears by the margin stays as it is. Otherwise it
64
+ bleeds decisively: it grows toward the 90%-of-W cap, then shifts down. It clears with the
65
+ margin only when every ≥ 12% bleed would crop its focus band.
66
+ - `none`: always fully visible.
67
+ - `deep`: bleed ≥ 25%.
68
+ 3. **No side tangents.** No subject is wider than 90% of W (`NO_TANGENT.maxWidth`). The part that is
69
+ actually on the canvas keeps ≥ 5% of W from both side edges. The only side bleed is a deliberate
70
+ panorama straddle across a seam.
71
+ 4. **Focus-aware, on all four edges.** Marked `focus` band corners (`{ top, bottom }`, fractions of
72
+ the screenshot height) always stay:
73
+ - ≥ 2% of H inside the top and bottom edges;
74
+ - ≥ 2% of W inside the side edges and any seam. This includes tilted subjects: their rotated
75
+ corners are checked.
76
+
77
+ A bleed that would crop the band is reduced, or turned into the clear-with-margin option. When a
78
+ tilt doesn't fit the side rules, the composer first shrinks the device, or the zoom card (keeping
79
+ its aspect), by at most 20%. After
80
+ that it reduces the tilt in quarter steps, down to straight if needed, and warns `tilt-reduced`.
81
+ In a group, the most restrictive focus decides for every screen. A zoom card that can't show a
82
+ whole band warns `zoom-focus-cropped`.
83
+ 5. **Room for the device.** The device must keep at least half the size it would get on this canvas
84
+ with NO copy. That size is height-limited for tall devices on landscape canvases, such as an iMac
85
+ on 608×380. If the text leaves less room, `composeSet` throws "copy too long for this canvas … cut
86
+ the subheadline or headline". If even the no-copy fit is under 30% of the target width, it throws
87
+ "device … doesn't fit a W×H canvas … use the device's own canvas", because the canvas, not the
88
+ copy, is the problem.
89
+
90
+ Text metrics are per font (`FONT_CHAR_WIDTH`, plus character classes: capitals and m/w count
91
+ wider, i/l and punctuation narrower). They are calibrated to be conservative for every
92
+ `COMPOSE_FONTS` entry, so an estimated 2-line headline never renders as 3.
93
+
94
+ Plan additions (all optional; a 0.3.0 plan composes and validates unchanged):
95
+
96
+ | Field | Where | Meaning |
97
+ | --- | --- | --- |
98
+ | `style.presentation` | plan | `device` (default) · `frameless` (rounded screenshot + shadow) · `zoom` (magnified crop of `crop`, else the centre of the `focus` band) |
99
+ | `style.tilt`, `style.tiltScreens` | plan | Degrees (clockwise) applied only to the listed screen indices. A tilted subject prefers a bleed under `auto`/`deep`; the tilt is reduced (`tilt-reduced`) if it can't keep the side margins. |
100
+ | `style.bleed` | plan | `auto` · `none` · `deep` (see above) |
101
+ | `style.palette` | plan | `{ mode: "tonal" \| "family" \| "sequence", colors: [hex…], tone? }`. Background for screens that omit one; ONE text colour per set (see Art direction). |
102
+ | `style.panorama` | plan | `{ spans?: [[0,1], …], straddle?: true \| [screen…], decoration?: "orbs" \| "honeycomb" \| "wave" \| "none" }`. Adjacent screens share one continuous background; `straddle` picks the span-start screens whose device crosses the seam (only by ≥ 18% of it). |
103
+ | `style.hero`, `style.rhythm`, `style.callouts`, `style.shadows` | plan | 0.5.0 — see Art direction. |
104
+ | `art` | plan | 0.5.0 `[{ id, url, width, height, faces? }]` brand art (uploaded-asset URLs only). |
105
+ | `callout`, `mascot` | screen | 0.5.0 — a magnified crop of the selling element · brand art placement. |
106
+ | `style.font` | plan | One of `COMPOSE_FONTS` (the editor's font list). |
107
+ | `focus`, `crop`, `presentation`, `tilt`, `badge` | screen | Focus band · zoom crop `{x,y,w,h}` · per-screen overrides · a social-proof pill above the headline |
108
+
109
+ Output shapes:
110
+
111
+ - **Text colour (0.5.0: ONE per set).** An explicit `headlineColor` / `subheadlineColor` always
112
+ wins. Otherwise EVERY screen takes part in choosing one set colour: `harmonize` tries a dark and a
113
+ light candidate (a deep / pale tint of the brand hue for tonal palettes, else `#111827` / white),
114
+ sampling every screen on a 5×7 grid. Palette / span backgrounds may be re-toned (moved away from
115
+ the text in small steps until the worst sample passes WCAG 4.5); explicit backgrounds are sampled
116
+ as they are and never recoloured. The candidate needing the least re-toning wins (the other only
117
+ if it also passes on the explicit backgrounds for ≤ 0.15 more). If the set colour still can't reach
118
+ 4.5 on an explicit background, ONLY that screen gets its own best colour (a panorama span whose
119
+ fill is explicit counts as ONE unit: one colour for the whole span, so the scene stays consistent) —
120
+ `report.screens[i].textColorSource === "screen"` (else `"set"`, or `"plan"` for an explicit
121
+ `headlineColor`). An explicit-only set keeps white whenever white passes (0.4.0). The headline and
122
+ subheadline are also checked densely right behind their boxes; a screen that no colour can bring to
123
+ 4.5 warns `contrast-low` — never silent. The subheadline's brand tint is used only when it clears
124
+ 4.6 (a margin, so it never lands at 4.49).
125
+ - **Frameless / zoom** is a plain `type: 'image'` layer:
126
+ - Its `src` is the uploaded screenshot, with `crossOrigin: 'anonymous'`.
127
+ - It has a rounded `clipPath` Rect in the image's local space, `imageCornerRadius` (canvas units,
128
+ the editor's corner-radius control) and a Fabric `shadow` (blur/offset in the object's own units,
129
+ like the editor's shadow control).
130
+ - Zoom adds Fabric `cropX`/`cropY` with `width`/`height` set to the crop size.
131
+ - Every screen of a zoom group uses the same card box.
132
+ - **Tilt** is the device layer's `angle`. The editor rotates the screenshot with the frame on attach, on
133
+ move/scale/rotate, and in the offscreen export.
134
+ - **Panorama** is pure DSL:
135
+ - Each screen of a span gets a locked `Rect` of size N·W × H at `left = N·W/2 − k·W`, painted with
136
+ the span-start screen's background.
137
+ - A solid colour stays solid.
138
+ - A linear gradient keeps its stops and `angle`, applied across N·W; without an `angle` it runs
139
+ corner to corner.
140
+ - Radial gradients and explicit `coords` throw a clear error.
141
+ - Optional seam orbs are moved or shrunk so they never overlap either neighbour's text block, and
142
+ dropped when there's no room.
143
+ - With `straddle` (`true` = every span, or a list of span-start screen indices), that span's first
144
+ device is shifted right so it crosses the seam. More than one crossing per set is lint-warned
145
+ (`panorama-straddle-count`). A copy of it,
146
+ named "… (continued)", is emitted on the next screen at `left − W`, below that screen's own device.
147
+ - The straddle is only ever a rightward shift. It must keep the left side margin, and when a
148
+ `focus` band is marked, the band stays ≥ 2% of W inside the seam. Otherwise the straddle is
149
+ skipped (`straddle-skipped`) and the device is placed like any other screen.
150
+ - In the editor, the halves are independent layers: moving one does not move the other.
151
+ - **Badge** is a rounded `Rect` pill plus a `Textbox` (`templateKey: 'badge'`). They sit in a badge row
152
+ above the headline, reserved set-wide, so they never collide with the text or the device.
153
+
154
+ ### Art direction (0.5.0)
155
+
156
+ - **Line breaks.** Headlines and subheadlines are set in `breakLines` lines. An explicit `\n` is ALWAYS
157
+ a break — lines the author broke are never merged, on any device or role. Only a line (explicit or
158
+ not) wider than the box is broken further: into the greedy line count, re-balanced (no single word
159
+ on the last line, breaks after punctuation preferred, never after "the"/"to"/…; past 40 tokens it
160
+ breaks greedily, so absurd copy fails fast). CJK / kana / Hangul count ~1 em per glyph and may
161
+ break between glyphs. The breaks are emitted as `\n` in the Textbox. A single word wider than the
162
+ box throws (Fabric can't wrap inside a word — it would widen the box past the margins).
163
+ Subheadline: 0.5× the headline, weight 600, full opacity, tinted 18% toward the brand hue when
164
+ that still passes AA. Text box 0.88 W.
165
+ - **Tonal palette.** `{ mode: "tonal", colors: [base, accent?], tone: "light" | "vivid" | "deep" }`:
166
+ screen i gets tonal step i % 4 of the base hue as a vertical gradient (the hero step 0). The accent
167
+ fills badge pills. Tonal panorama spans run one ramp through their members' tones.
168
+ - **Hero** (`style.hero`, default ON for screen 1; `false` = off): headline 1.25× (stepped down in
169
+ 0.05s — to 1× if needed — so it never has more lines than at the set size; `scale` 1–1.4), its own group with bleed `deep`, optional `layout`, `tilt`,
170
+ `badge`, `mascot`. `report.screens[i].role` is `hero`.
171
+ - **Rhythm** (`style.rhythm: { every, treatment }`): screens where `i % every === every − 1` are
172
+ `accent`s — `text-bottom` layout, or `callout` (a forced callout + deep bleed). All `set`-role
173
+ screens still share one group, type size, text area, scale and baseline.
174
+ - **Callouts** (`screen.callout` crop, or `style.callouts: "auto"` from a focus band ≤ 50% tall): an
175
+ image layer with the SAME uploaded `src` + `cropX`/`cropY`, rounded, shadowed, magnified 1.6–2.2×
176
+ (down to 1.25× to fit), inside 3.5% side margins, between the text block and a 4% far-edge margin.
177
+ It may cover its own source slice (the pop-out), but it hides at most `CALLOUT_MAX_FOCUS_COVER`
178
+ (35%) of the REST of the focus band: it is centred on its source when that allows, else moved
179
+ up/down or out to a side edge (breaking out of the device), else made smaller; if nothing works it
180
+ is skipped (`callout-skipped`). `report.screens[i].callout.focusCover` is the share hidden. Never on
181
+ zoom cards or a straddling device.
182
+ - **Mascots** (`plan.art` + `screen.mascot` / `style.hero.mascot`): an image layer (uploaded src,
183
+ `flipX` to face into the canvas when `faces` is given, shadow). Anchors `headline` / `device-top` /
184
+ `device-side` / `seam` are tried in order with fallbacks at 100/85/70% size; a placement must clear
185
+ every text line, the badge, the subheadline, the callout, every other mascot on the screen
186
+ (including a neighbour's seam half) and the focus band's core (its outer 10% W may be overlapped).
187
+ `seam` puts one mascot across a panorama seam, emitted on both screens (±W). Otherwise
188
+ `mascot-skipped`.
189
+ - **Badges** keep their label at WCAG ≥ 4.5 on the pill: a solid (tonal accent) pill is toned
190
+ toward black or white until it does; a translucent one gets fainter.
191
+ - **Shadows** (`style.shadows`, default true), tinted with the brand hue: device layers get
192
+ `fabricData.deviceShadow = { color, blur, offsetX, offsetY }` in CANVAS units — the editor casts it
193
+ from the frame's screenshot (a Fabric shadow on the frame PNG would fall into its transparent screen
194
+ hole); frameless / zoom / callouts / mascots get a Fabric `shadow` in object units.
195
+ `validateTemplate` checks `deviceShadow` (device layers only; colour ≤ 64 chars; blur 0…canvasWidth;
196
+ offsets ±canvasWidth).
197
+ - **Panorama motif** (`decoration: "honeycomb" | "wave"`): the motif is laid out across the whole span,
198
+ but each screen emits only ITS part (the hexagons / wave segments touching it plus a 2-stroke
199
+ overdraw) as one locked `Path` in its own coordinates, rounded to 0.1 unit; both neighbours emit
200
+ the same points around a seam, so it runs on continuously. (0.5.0-pre emitted the whole N·W path on
201
+ every screen — O(N²) bytes.)
202
+ - **Handoff size.** `MAX_HANDOFF_BYTES` (256 KiB, the server's cap) and `handoffBytes(template)` (UTF-8
203
+ size of the `{ template }` request body). The skill's `lint` / `compose` / `publish` / `variants`
204
+ error over the cap and warn over 80%. A 30-screen phone + iPad + Mac honeycomb panorama is ≈ 166 KB.
205
+
206
+ `report.warnings` (`ComposeWarning`) flags:
207
+
208
+ - a headline over 5 words, or estimated at more than 2 lines at the set size;
209
+ - a subheadline estimated at more than 1 line;
210
+ - more than 2 tilted screens;
211
+ - a seam crossing a headline or focus band, or a straddle that can't be checked because no focus
212
+ band is marked;
213
+ - skipped straddles, and more than one straddle per set;
214
+ - tilts reduced to keep the side margins;
215
+ - zoom crops that can't show their whole focus band;
216
+ - long badges;
217
+ - 0.5.0: a stranded last headline word (`headline-orphan`), a screen no colour can bring to AA
218
+ (`contrast-low`), a callout that can't be magnified or placed without hiding the focus band
219
+ (`callout-skipped`), no room for a mascot (`mascot-skipped`).
220
+
221
+ Backward compatibility (0.5.0): a 0.4.0 plan still composes, but screen 1 is now the hero (1.25×
222
+ headline, `deep` bleed, its own device pose) and devices get shadows — `style.hero: false` /
223
+ `style.shadows: false` restore the 0.4.0 look. Balanced line breaks can change where a headline
224
+ wraps, and the new warnings can make `--strict` fail for a plan that passed before.
225
+
226
+ Warnings never block composing.
227
+
228
+ `makeVariants(plan)` returns three distinct ART DIRECTIONS, intended as Product Page Optimization
229
+ test candidates. Each is named `<name> — A Brand Classic` / `— B Clean Frameless` / `— C Story Panorama`:
230
+
231
+ - **A Brand Classic:** device frames on the `vivid` tonal palette, hero with the mascot (first `art`)
232
+ + badge, `callouts: "auto"` (plus explicit ones), rhythm every 4 (`text-bottom`).
233
+ - **B Clean Frameless:** `light` tonal palette, full-width frameless screenshots with a `deep` bleed
234
+ (a zoom card only for an explicit `crop` — B1), callouts, hero at 1.3× with the mascot.
235
+ - **C Story Panorama:** `deep` tonal palette; spans = the input's `panorama.spans`, else triples and
236
+ pairs (`panoramaRuns`); `decoration` = the input's (not `orbs`), else `wave`; a mascot crossing the
237
+ first seam of every span but the hero's; the hero tilted (the input's `style.tilt`, or 8°); bleed
238
+ `deep`; a straddle only if the input asks.
239
+
240
+ **Kept from the input** in every concept:
241
+ - copy (headline / subheadline / badge) and `headlineColor` / `subheadlineColor`;
242
+ - `background`, `deviceId`, `screenshot`, `focus`, `crop`, `callout`, `mascot`, `art`;
243
+ - canvas size;
244
+ - the brand colours (`style.palette.colors`; else the first screen's background colour), `style.font`,
245
+ and `style.bleed: none`.
246
+
247
+ **Overridden:**
248
+ - every `layout` becomes `text-top` (the hero and rhythm accents differ on purpose);
249
+ - per-screen `presentation` and `tilt` are dropped;
250
+ - palette mode → `tonal` with the concept's tone; hero, rhythm, callouts, panorama per concept.
251
+
44
252
  ## Layer order
45
253
 
46
254
  A screen's `layers` array is **bottom → top**: index 0 is painted first (canvas add order), the last
@@ -102,7 +310,12 @@ and the editor converts it on import.
102
310
  `makeDeviceFrameLayer()` (returns the single device layer).
103
311
 
104
312
  > Consumers: a template in the 0.3.0 shape needs an editor that understands `fabricData.screenshot`
105
- > (appshoteditor.com with the matching importer).
313
+ > (appshoteditor.com with the matching importer). 0.4.0 adds no new layer shapes that the 0.3.0 validator
314
+ > or importer would reject: frameless/zoom are ordinary image layers, and panorama and badges are
315
+ > ordinary shape and text layers. 0.5.0 likewise: callouts and mascots are image layers with
316
+ > uploaded-asset srcs, motifs are `Path` shape layers — all accepted by the 0.4.0 validator (and by
317
+ > 0.5.0's, which additionally checks the shape of `deviceShadow`). Only the device shadow needs a
318
+ > 0.5.0-aware editor to render; older editors ignore it.
106
319
 
107
320
  Zero runtime dependencies. The `schemaVersion` is the compatibility contract between producers and the editor.
108
321
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@appshoteditor/shot-dsl",
3
- "version": "0.3.0",
4
- "description": "App Shot Editor layout DSL + device-frame geometry — framework-free building blocks for composing editable App Store screenshot layouts. Intended for use via a bundler (Vite, esbuild, etc.).",
3
+ "version": "0.5.0",
4
+ "description": "App Shot Editor layout DSL + device-frame geometry \u2014 framework-free building blocks for composing editable App Store screenshot layouts. Intended for use via a bundler (Vite, esbuild, etc.).",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./src/index.ts",
@@ -20,7 +20,13 @@
20
20
  "src/builders.ts",
21
21
  "src/compose.ts",
22
22
  "src/frames.ts",
23
- "src/device-frames.ts"
23
+ "src/device-frames.ts",
24
+ "src/layout-system.ts",
25
+ "src/color.ts",
26
+ "src/variants.ts",
27
+ "src/typography.ts",
28
+ "src/palette.ts",
29
+ "src/decor.ts"
24
30
  ],
25
31
  "sideEffects": false,
26
32
  "keywords": [
package/src/color.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tiny, dependency-free colour helpers for the composer's palette / panorama / badge styling.
3
+ * Only `#rgb` / `#rrggbb` hex input is understood; anything else is passed through untouched
4
+ * (callers fall back to sensible defaults).
5
+ */
6
+
7
+ export type RGB = { r: number; g: number; b: number };
8
+
9
+ const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
10
+
11
+ export function isHexColor(value: unknown): value is string {
12
+ return typeof value === 'string' && HEX.test(value);
13
+ }
14
+
15
+ export function parseHex(hex: string): RGB | null {
16
+ if (!isHexColor(hex)) return null;
17
+ let h = hex.slice(1);
18
+ if (h.length === 3) h = h.replace(/./g, (c) => c + c);
19
+ return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
20
+ }
21
+
22
+ export function toHex({ r, g, b }: RGB): string {
23
+ const c = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0');
24
+ return `#${c(r)}${c(g)}${c(b)}`.toUpperCase();
25
+ }
26
+
27
+ /** Linear mix of `a` toward `b` by `t` (0 → a, 1 → b). Non-hex input returns `a`. */
28
+ export function mixHex(a: string, b: string, t: number): string {
29
+ const x = parseHex(a);
30
+ const y = parseHex(b);
31
+ if (!x || !y) return a;
32
+ return toHex({ r: x.r + (y.r - x.r) * t, g: x.g + (y.g - x.g) * t, b: x.b + (y.b - x.b) * t });
33
+ }
34
+
35
+ export const lighten = (hex: string, t: number): string => mixHex(hex, '#ffffff', t);
36
+ export const darken = (hex: string, t: number): string => mixHex(hex, '#000000', t);
37
+
38
+ /** WCAG relative luminance (0 = black, 1 = white); 0 for non-hex input. */
39
+ export function luminance(hex: string): number {
40
+ const c = parseHex(hex);
41
+ if (!c) return 0;
42
+ const lin = (v: number) => {
43
+ const s = v / 255;
44
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
45
+ };
46
+ return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b);
47
+ }
48
+
49
+ /** `rgba(r,g,b,a)` from a hex colour (white for non-hex input). */
50
+ export function rgba(hex: string, alpha: number): string {
51
+ const c = parseHex(hex) ?? { r: 255, g: 255, b: 255 };
52
+ return `rgba(${c.r},${c.g},${c.b},${alpha})`;
53
+ }
54
+
55
+ /** WCAG contrast ratio between two hex colours (1 … 21). */
56
+ export function contrastRatio(a: string, b: string): number {
57
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
58
+ return (hi + 0.05) / (lo + 0.05);
59
+ }
60
+
61
+ export const DARK_TEXT = '#111827';
62
+ export const LIGHT_TEXT = '#FFFFFF';
63
+
64
+ /**
65
+ * The text colour (white or near-black) with the best WORST-case contrast against every sampled
66
+ * background colour (e.g. several points of a gradient behind a headline).
67
+ */
68
+ export function readableTextOn(backgrounds: string[]): string {
69
+ const worst = (text: string) => Math.min(...backgrounds.map((bg) => contrastRatio(text, bg)));
70
+ return worst(DARK_TEXT) > worst(LIGHT_TEXT) ? DARK_TEXT : LIGHT_TEXT;
71
+ }
72
+
73
+ /** Readable text colour on a background of `hex` (see readableTextOn). */
74
+ export function contrastText(hex: string): string {
75
+ return readableTextOn([hex]);
76
+ }