@appshoteditor/shot-dsl 0.2.0 → 0.4.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 +206 -7
- package/package.json +5 -2
- package/src/color.ts +76 -0
- package/src/compose.ts +1272 -107
- package/src/frames.ts +132 -89
- package/src/index.ts +3 -0
- package/src/layout-system.ts +302 -0
- package/src/types.ts +37 -1
- package/src/validate.ts +200 -1
- package/src/variants.ts +125 -0
package/README.md
CHANGED
|
@@ -10,15 +10,21 @@ composer — so a layout composed by the skill renders identically in the editor
|
|
|
10
10
|
## What's inside
|
|
11
11
|
|
|
12
12
|
- **Types** — `Template`, `ScreenLayersJSON`, `LayerJSON`, `BackgroundJSON`, `CURRENT_SCHEMA_VERSION`.
|
|
13
|
-
- **Validators** — `validateTemplate()` (strict, error-reporting), `isValidTemplate`, `migrateScreenLayersJSON
|
|
13
|
+
- **Validators** — `validateTemplate()` (strict, error-reporting), `isValidTemplate`, `migrateScreenLayersJSON`,
|
|
14
|
+
`validateDeviceScreenshot`, `isUploadedScreenshotSrc`.
|
|
14
15
|
- **Builders** — `makeTextLayer`, `makeImageLayer`, `makeShapeLayer`, `makeSolid/GradientBackground`,
|
|
15
16
|
`makeScreen`, `makeTemplate`.
|
|
16
|
-
- **Device geometry** — `deviceFrames`, `getDeviceFrame`, `
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
(`
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
- **Device geometry** — `deviceFrames`, `getDeviceFrame`, `makeDeviceFrameLayer`, `calculateDeviceScale`,
|
|
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
|
+
(`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`).
|
|
22
28
|
|
|
23
29
|
```ts
|
|
24
30
|
import { composeTemplate, validateTemplate } from '@appshoteditor/shot-dsl';
|
|
@@ -39,6 +45,199 @@ const template = composeTemplate({
|
|
|
39
45
|
validateTemplate(template); // { valid: true, errors: [] }
|
|
40
46
|
```
|
|
41
47
|
|
|
48
|
+
## Set layout system (0.4.0)
|
|
49
|
+
|
|
50
|
+
`composeSet` lays a plan out as ONE system, the way a screenshot designer would:
|
|
51
|
+
|
|
52
|
+
1. **One system per set.** It measures every screen's text block first and reserves a text area
|
|
53
|
+
for the TALLEST one (per canvas size). The headline size is fixed for the whole set
|
|
54
|
+
(`HEADLINE_SIZE` × the type unit). Long copy is never shrunk: it gets a lint warning instead.
|
|
55
|
+
Screens that share a canvas size, presentation, device, layout and tilt (`report.screens[i].group`)
|
|
56
|
+
get the same subject scale and baseline, so copy length never moves or resizes anything.
|
|
57
|
+
2. **No tangents.** A device (or frameless screenshot) either clears the far edge by ≥ 4% of H or
|
|
58
|
+
bleeds off it by ≥ 12% of its own height. The band in between is the "just touching" look, and
|
|
59
|
+
`solveVertical` never emits it. `style.bleed` picks how a tangent is resolved:
|
|
60
|
+
- `auto` (default): a device that already clears by the margin stays as it is. Otherwise it
|
|
61
|
+
bleeds decisively: it grows toward the 90%-of-W cap, then shifts down. It clears with the
|
|
62
|
+
margin only when every ≥ 12% bleed would crop its focus band.
|
|
63
|
+
- `none`: always fully visible.
|
|
64
|
+
- `deep`: bleed ≥ 25%.
|
|
65
|
+
3. **No side tangents.** No subject is wider than 90% of W (`NO_TANGENT.maxWidth`). The part that is
|
|
66
|
+
actually on the canvas keeps ≥ 5% of W from both side edges. The only side bleed is a deliberate
|
|
67
|
+
panorama straddle across a seam.
|
|
68
|
+
4. **Focus-aware, on all four edges.** Marked `focus` band corners (`{ top, bottom }`, fractions of
|
|
69
|
+
the screenshot height) always stay:
|
|
70
|
+
- ≥ 2% of H inside the top and bottom edges;
|
|
71
|
+
- ≥ 2% of W inside the side edges and any seam. This includes tilted subjects: their rotated
|
|
72
|
+
corners are checked.
|
|
73
|
+
|
|
74
|
+
A bleed that would crop the band is reduced, or turned into the clear-with-margin option. When a
|
|
75
|
+
tilt doesn't fit the side rules, the composer first shrinks the device, or the zoom card (keeping
|
|
76
|
+
its aspect), by at most 20%. After
|
|
77
|
+
that it reduces the tilt in quarter steps, down to straight if needed, and warns `tilt-reduced`.
|
|
78
|
+
In a group, the most restrictive focus decides for every screen. A zoom card that can't show a
|
|
79
|
+
whole band warns `zoom-focus-cropped`.
|
|
80
|
+
5. **Room for the device.** The device must keep at least half the size it would get on this canvas
|
|
81
|
+
with NO copy. That size is height-limited for tall devices on landscape canvases, such as an iMac
|
|
82
|
+
on 608×380. If the text leaves less room, `composeSet` throws "copy too long for this canvas … cut
|
|
83
|
+
the subheadline or headline". If even the no-copy fit is under 30% of the target width, it throws
|
|
84
|
+
"device … doesn't fit a W×H canvas … use the device's own canvas", because the canvas, not the
|
|
85
|
+
copy, is the problem.
|
|
86
|
+
|
|
87
|
+
Text metrics are per font (`FONT_CHAR_WIDTH`, plus character classes: capitals and m/w count
|
|
88
|
+
wider, i/l and punctuation narrower). They are calibrated to be conservative for every
|
|
89
|
+
`COMPOSE_FONTS` entry, so an estimated 2-line headline never renders as 3.
|
|
90
|
+
|
|
91
|
+
Plan additions (all optional; a 0.3.0 plan composes and validates unchanged):
|
|
92
|
+
|
|
93
|
+
| Field | Where | Meaning |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| `style.presentation` | plan | `device` (default) · `frameless` (rounded screenshot + shadow) · `zoom` (magnified crop of `crop`, else the centre of the `focus` band) |
|
|
96
|
+
| `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. |
|
|
97
|
+
| `style.bleed` | plan | `auto` · `none` · `deep` (see above) |
|
|
98
|
+
| `style.palette` | plan | `{ mode: "family" \| "sequence", colors: [hex…] }`. Background for screens that omit one. |
|
|
99
|
+
| `style.panorama` | plan | `{ spans: [[0,1], …], straddle?: true \| [screen…], decoration?: "orbs" \| "none" }`. Adjacent screens share one continuous background; `straddle` picks the span-start screens whose device crosses the seam. |
|
|
100
|
+
| `style.font` | plan | One of `COMPOSE_FONTS` (the editor's font list). |
|
|
101
|
+
| `focus`, `crop`, `presentation`, `tilt`, `badge` | screen | Focus band · zoom crop `{x,y,w,h}` · per-screen overrides · a social-proof pill above the headline |
|
|
102
|
+
|
|
103
|
+
Output shapes:
|
|
104
|
+
|
|
105
|
+
- **Headline colour.** An explicit `headlineColor` / `subheadlineColor` always wins. Otherwise, on a
|
|
106
|
+
palette background or in a panorama span, it is white or `#111827`, whichever has the better
|
|
107
|
+
worst-case WCAG contrast. The contrast is checked against the background actually behind the text
|
|
108
|
+
block, sampled over it. For a span, that is the span's N·W gradient, which uses the FIRST
|
|
109
|
+
screen's background, at this screen's offset. A screen with its own explicit background outside a
|
|
110
|
+
span keeps the white default.
|
|
111
|
+
- **Frameless / zoom** is a plain `type: 'image'` layer:
|
|
112
|
+
- Its `src` is the uploaded screenshot, with `crossOrigin: 'anonymous'`.
|
|
113
|
+
- It has a rounded `clipPath` Rect in the image's local space, `imageCornerRadius` (canvas units,
|
|
114
|
+
the editor's corner-radius control) and a Fabric `shadow` (blur/offset in the object's own units,
|
|
115
|
+
like the editor's shadow control).
|
|
116
|
+
- Zoom adds Fabric `cropX`/`cropY` with `width`/`height` set to the crop size.
|
|
117
|
+
- Every screen of a zoom group uses the same card box.
|
|
118
|
+
- **Tilt** is the device layer's `angle`. The editor rotates the screenshot with the frame on attach, on
|
|
119
|
+
move/scale/rotate, and in the offscreen export.
|
|
120
|
+
- **Panorama** is pure DSL:
|
|
121
|
+
- Each screen of a span gets a locked `Rect` of size N·W × H at `left = N·W/2 − k·W`, painted with
|
|
122
|
+
the span-start screen's background.
|
|
123
|
+
- A solid colour stays solid.
|
|
124
|
+
- A linear gradient keeps its stops and `angle`, applied across N·W; without an `angle` it runs
|
|
125
|
+
corner to corner.
|
|
126
|
+
- Radial gradients and explicit `coords` throw a clear error.
|
|
127
|
+
- Optional seam orbs are moved or shrunk so they never overlap either neighbour's text block, and
|
|
128
|
+
dropped when there's no room.
|
|
129
|
+
- With `straddle` (`true` = every span, or a list of span-start screen indices), that span's first
|
|
130
|
+
device is shifted right so it crosses the seam. More than one crossing per set is lint-warned
|
|
131
|
+
(`panorama-straddle-count`). A copy of it,
|
|
132
|
+
named "… (continued)", is emitted on the next screen at `left − W`, below that screen's own device.
|
|
133
|
+
- The straddle is only ever a rightward shift. It must keep the left side margin, and when a
|
|
134
|
+
`focus` band is marked, the band stays ≥ 2% of W inside the seam. Otherwise the straddle is
|
|
135
|
+
skipped (`straddle-skipped`) and the device is placed like any other screen.
|
|
136
|
+
- In the editor, the halves are independent layers: moving one does not move the other.
|
|
137
|
+
- **Badge** is a rounded `Rect` pill plus a `Textbox` (`templateKey: 'badge'`). They sit in a badge row
|
|
138
|
+
above the headline, reserved set-wide, so they never collide with the text or the device.
|
|
139
|
+
|
|
140
|
+
`report.warnings` (`ComposeWarning`) flags:
|
|
141
|
+
|
|
142
|
+
- a headline over 5 words, or estimated at more than 2 lines at the set size;
|
|
143
|
+
- a subheadline estimated at more than 1 line;
|
|
144
|
+
- more than 2 tilted screens;
|
|
145
|
+
- a seam crossing a headline or focus band, or a straddle that can't be checked because no focus
|
|
146
|
+
band is marked;
|
|
147
|
+
- skipped straddles, and more than one straddle per set;
|
|
148
|
+
- tilts reduced to keep the side margins;
|
|
149
|
+
- zoom crops that can't show their whole focus band;
|
|
150
|
+
- long badges.
|
|
151
|
+
|
|
152
|
+
Warnings never block composing.
|
|
153
|
+
|
|
154
|
+
`makeVariants(plan)` returns three distinct concepts, intended as Product Page Optimization test
|
|
155
|
+
candidates. Each is named `<name> — A Framed` / `— B Frameless` / `— C Panorama`:
|
|
156
|
+
|
|
157
|
+
- **A:** device frames, straight, all `text-top`.
|
|
158
|
+
- **B:** frameless. Screens with a `crop`, or a `focus` band ≤ 45% tall, become zoom cards.
|
|
159
|
+
- **C:** panorama spans: the input's `panorama.spans`, or adjacent pairs, with a continuous
|
|
160
|
+
background and seam orbs. Only screen 1 (the hero) is tilted (the input's `style.tilt`, or 8°)
|
|
161
|
+
and straddles its seam, unless the input sets `straddle`. Every other device stays centred and
|
|
162
|
+
straight.
|
|
163
|
+
|
|
164
|
+
**Kept from the input** in every concept:
|
|
165
|
+
- copy (headline / subheadline / badge) and `headlineColor` / `subheadlineColor`;
|
|
166
|
+
- `background`, `deviceId`, `screenshot`, `focus`, `crop`;
|
|
167
|
+
- canvas size;
|
|
168
|
+
- `style.palette`, `style.font` and `style.bleed` (default `auto`).
|
|
169
|
+
|
|
170
|
+
**Overridden:**
|
|
171
|
+
- every `layout` becomes `text-top`: screens only share one device scale and baseline when they
|
|
172
|
+
share a layout;
|
|
173
|
+
- per-screen `presentation` and `tilt` are dropped;
|
|
174
|
+
- A and B are straight, with no panorama.
|
|
175
|
+
|
|
176
|
+
## Layer order
|
|
177
|
+
|
|
178
|
+
A screen's `layers` array is **bottom → top**: index 0 is painted first (canvas add order), the last
|
|
179
|
+
entry is frontmost. A composed screen is `[device, headline, subheadline?]`; the editor's built-in
|
|
180
|
+
templates list background shapes, then the device, then text. (The editor's layers *panel* shows the
|
|
181
|
+
reverse — top first.)
|
|
182
|
+
|
|
183
|
+
## Device screenshots (0.3.0)
|
|
184
|
+
|
|
185
|
+
A device mockup is **one** layer. Its screenshot is a property of the device layer, at
|
|
186
|
+
`fabricData.screenshot`:
|
|
187
|
+
|
|
188
|
+
```json
|
|
189
|
+
{
|
|
190
|
+
"id": "device-frame-layer-1790080000000-abc123def",
|
|
191
|
+
"name": "iPhone 17 Pro Max",
|
|
192
|
+
"type": "device",
|
|
193
|
+
"visible": true,
|
|
194
|
+
"locked": false,
|
|
195
|
+
"fabricData": {
|
|
196
|
+
"type": "image",
|
|
197
|
+
"src": "/devices/iphone-17-pro-max.webp",
|
|
198
|
+
"left": 140, "top": 438.69, "width": 1520, "height": 3068,
|
|
199
|
+
"scaleX": 0.175, "scaleY": 0.175, "originX": "center", "originY": "center",
|
|
200
|
+
"layerId": "device-frame-layer-1790080000000-abc123def",
|
|
201
|
+
"layerType": "deviceFrame",
|
|
202
|
+
"layerRole": "frame",
|
|
203
|
+
"deviceFrameId": "device-frame-layer-1790080000000-abc123def",
|
|
204
|
+
"deviceId": "iphone_17_pro_max",
|
|
205
|
+
"deviceScale": 0.175,
|
|
206
|
+
"screenshot": { "src": "/api/screenshots/<asset-id>/raw", "width": 1320, "height": 2868 }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`screenshot` is `{ src, width, height, rotation? }` (`DeviceScreenshotJSON`; `rotation` ∈ 0/90/180/270).
|
|
212
|
+
Producers emit **no** screenshot geometry or clip path: the editor places the image under the frame
|
|
213
|
+
and clips it to the screen with the same code path it uses when a user drops a screenshot onto a
|
|
214
|
+
frame (`computeScreenshotPlacement`), so the screenshot belongs to the frame (it is not a separate
|
|
215
|
+
layer). `validateTemplate` requires `src` to be an uploaded-asset URL (`/api/screenshots/<id>/raw`) —
|
|
216
|
+
external, protocol-relative and `data:` URLs are rejected. It also requires every device layer to have a
|
|
217
|
+
known `deviceId` and a frame-asset `src` (`/devices/<name>.webp|png`), and rejects duplicate layer ids
|
|
218
|
+
or `deviceFrameId`s within a screen. Every other image reference in a handoff (image layers, pattern
|
|
219
|
+
`fill.source`, nested objects) must be an uploaded screenshot or a `/devices/` asset. Frame metadata
|
|
220
|
+
(`layerRole: 'frame'`, `deviceFrameId`, `deviceId`, `deviceScale`) is only allowed on device layers,
|
|
221
|
+
`layerRole: 'screenshot'` only on legacy image layers, and editor-internal props
|
|
222
|
+
(`EDITOR_INTERNAL_PROPS`, e.g. `pendingScreenshot`) are rejected everywhere, as are `__proto__` /
|
|
223
|
+
`constructor` / `prototype` keys at any depth (`FORBIDDEN_KEYS`).
|
|
224
|
+
|
|
225
|
+
`computeScreenshotPlacement` centres the screenshot on the frame's screen bounds; the screen-centre
|
|
226
|
+
offset scales with the frame's scale *relative to* `deviceScale` (0.2.x editors multiplied by the
|
|
227
|
+
absolute frame scale, misplacing off-centre screens such as MacBooks by a few canvas units).
|
|
228
|
+
|
|
229
|
+
**Legacy (≤ 0.2.0):** screenshots were a separate `type: 'image'` layer with
|
|
230
|
+
`fabricData.layerRole: 'screenshot'` and a `deviceFrameId` matching its frame, placed directly below
|
|
231
|
+
the frame. `validateTemplate` still accepts that shape (its `src` must also be an uploaded-asset URL)
|
|
232
|
+
and the editor converts it on import.
|
|
233
|
+
`makeDeviceFrameLayers()` (which returned `{ screenshot, frame }`) was replaced by
|
|
234
|
+
`makeDeviceFrameLayer()` (returns the single device layer).
|
|
235
|
+
|
|
236
|
+
> Consumers: a template in the 0.3.0 shape needs an editor that understands `fabricData.screenshot`
|
|
237
|
+
> (appshoteditor.com with the matching importer). 0.4.0 adds no new layer shapes that the 0.3.0 validator
|
|
238
|
+
> or importer would reject: frameless/zoom are ordinary image layers, and panorama and badges are
|
|
239
|
+
> ordinary shape and text layers.
|
|
240
|
+
|
|
42
241
|
Zero runtime dependencies. The `schemaVersion` is the compatibility contract between producers and the editor.
|
|
43
242
|
|
|
44
243
|
> Intended for use via a bundler (Vite, esbuild, etc.). Device `frameAsset` values are URL paths the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appshoteditor/shot-dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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.).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,10 @@
|
|
|
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"
|
|
24
27
|
],
|
|
25
28
|
"sideEffects": false,
|
|
26
29
|
"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
|
+
}
|