@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 +219 -6
- package/package.json +9 -3
- package/src/color.ts +76 -0
- package/src/compose.ts +2141 -123
- package/src/decor.ts +137 -0
- package/src/frames.ts +3 -0
- package/src/index.ts +6 -0
- package/src/layout-system.ts +302 -0
- package/src/palette.ts +186 -0
- package/src/typography.ts +200 -0
- package/src/validate.ts +46 -2
- package/src/variants.ts +222 -0
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
4
|
-
"description": "App Shot Editor layout DSL + device-frame geometry
|
|
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
|
+
}
|