capture-flow 0.0.0-stage → 0.1.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 +238 -2
- package/bin/capture-flow.mjs +12 -0
- package/package.json +55 -4
- package/src/build.ts +186 -0
- package/src/capture.ts +77 -0
- package/src/cli.ts +139 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +98 -0
- package/src/render.ts +75 -0
- package/src/verify.ts +90 -0
- package/vendor/wajha/LICENSE +21 -0
- package/vendor/wajha/NOTICE +21 -0
- package/vendor/wajha/engine/background.ts +55 -0
- package/vendor/wajha/engine/bidi.ts +589 -0
- package/vendor/wajha/engine/cache.ts +96 -0
- package/vendor/wajha/engine/color.ts +125 -0
- package/vendor/wajha/engine/devices/android.ts +53 -0
- package/vendor/wajha/engine/devices/browser.ts +170 -0
- package/vendor/wajha/engine/devices/finish.ts +154 -0
- package/vendor/wajha/engine/devices/geometry.ts +326 -0
- package/vendor/wajha/engine/devices/index.ts +143 -0
- package/vendor/wajha/engine/devices/ipad.ts +21 -0
- package/vendor/wajha/engine/devices/iphone.ts +55 -0
- package/vendor/wajha/engine/devices/macbook.ts +187 -0
- package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
- package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
- package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
- package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
- package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
- package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
- package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
- package/vendor/wajha/engine/devices/mock/art.ts +557 -0
- package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
- package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
- package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
- package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
- package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
- package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
- package/vendor/wajha/engine/devices/paths.ts +113 -0
- package/vendor/wajha/engine/devices/screen.ts +75 -0
- package/vendor/wajha/engine/devices/shadow.ts +124 -0
- package/vendor/wajha/engine/devices/slab.ts +200 -0
- package/vendor/wajha/engine/devices/vision.ts +43 -0
- package/vendor/wajha/engine/devices/watch.ts +14 -0
- package/vendor/wajha/engine/geometry.ts +152 -0
- package/vendor/wajha/engine/images.ts +96 -0
- package/vendor/wajha/engine/index.ts +20 -0
- package/vendor/wajha/engine/linebreak.ts +226 -0
- package/vendor/wajha/engine/markup.ts +84 -0
- package/vendor/wajha/engine/mock.ts +158 -0
- package/vendor/wajha/engine/paint.ts +474 -0
- package/vendor/wajha/engine/patterns.ts +263 -0
- package/vendor/wajha/engine/render.ts +364 -0
- package/vendor/wajha/engine/shapes.ts +260 -0
- package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
- package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
- package/vendor/wajha/engine/text.ts +694 -0
- package/vendor/wajha/engine/types.ts +190 -0
- package/vendor/wajha/export/paths.ts +197 -0
- package/vendor/wajha/export/png.ts +223 -0
- package/vendor/wajha/export/types.ts +60 -0
- package/vendor/wajha/model/adapt.ts +889 -0
- package/vendor/wajha/model/catalog.ts +369 -0
- package/vendor/wajha/model/clone.ts +58 -0
- package/vendor/wajha/model/colorMath.ts +128 -0
- package/vendor/wajha/model/defaults.ts +386 -0
- package/vendor/wajha/model/geometry.ts +95 -0
- package/vendor/wajha/model/ids.ts +13 -0
- package/vendor/wajha/model/instantiate.ts +280 -0
- package/vendor/wajha/model/mirror.ts +43 -0
- package/vendor/wajha/model/resolve.ts +87 -0
- package/vendor/wajha/model/rules.ts +249 -0
- package/vendor/wajha/model/stub.ts +4 -0
- package/vendor/wajha/model/types.ts +456 -0
- package/vendor/wajha/model/typography.ts +28 -0
- package/vendor/wajha/model/validate.ts +917 -0
- package/vendor/wajha/templates/builtin/aurora.ts +163 -0
- package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
- package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
- package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
- package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
- package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
- package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
- package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
- package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
- package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
- package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
- package/vendor/wajha/templates/builtin/noor.ts +107 -0
- package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
- package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
- package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
- package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
- package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
- package/vendor/wajha/templates/builtin/sage.ts +94 -0
- package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
- package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
- package/vendor/wajha/templates/index.ts +55 -0
- package/vendor/wajha/templates/kit.ts +761 -0
- package/vendor/wajha/ui/sortShots.ts +239 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drawing primitives for mock screens. Mock code works in "points": the
|
|
3
|
+
* context is scaled so a phone is 390 points wide, like iOS layout, and
|
|
4
|
+
* every size below reads like a real UI spec. Text is language-neutral:
|
|
5
|
+
* rounded skeleton bars, plus digits where a real app shows numbers.
|
|
6
|
+
*/
|
|
7
|
+
import type { Ctx2D } from '../../types';
|
|
8
|
+
import { roundRectPath, type Radii } from '../paths';
|
|
9
|
+
import type { Rng } from './rng';
|
|
10
|
+
import type { MockTheme } from './theme';
|
|
11
|
+
|
|
12
|
+
export interface Area {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
w: number;
|
|
16
|
+
h: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Form = 'phone' | 'tablet' | 'desktop';
|
|
20
|
+
|
|
21
|
+
/** Everything a mock painter needs. */
|
|
22
|
+
export interface Mk {
|
|
23
|
+
ctx: Ctx2D;
|
|
24
|
+
t: MockTheme;
|
|
25
|
+
rng: Rng;
|
|
26
|
+
/** Canvas pixels per point; shadow blur ignores transforms, so it is scaled by hand. */
|
|
27
|
+
px: number;
|
|
28
|
+
os: 'ios' | 'android';
|
|
29
|
+
form: Form;
|
|
30
|
+
/** Non-negative integer below 1e6 (normalised by paintMockScreen), safe as an array offset. */
|
|
31
|
+
seed: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const FONT = '"Inter Variable", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
|
|
35
|
+
|
|
36
|
+
export type Paint = string | CanvasGradient;
|
|
37
|
+
|
|
38
|
+
export function rr(m: Mk, x: number, y: number, w: number, h: number, r: number | Radii): void {
|
|
39
|
+
m.ctx.beginPath();
|
|
40
|
+
roundRectPath(m.ctx, x, y, w, h, r);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Filled rounded rectangle. */
|
|
44
|
+
export function box(m: Mk, x: number, y: number, w: number, h: number, r: number | Radii, fill: Paint): void {
|
|
45
|
+
rr(m, x, y, w, h, r);
|
|
46
|
+
m.ctx.fillStyle = fill;
|
|
47
|
+
m.ctx.fill();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A skeleton line of text: a pill of height `h`. */
|
|
51
|
+
export function bar(m: Mk, x: number, y: number, w: number, h: number, color: Paint = m.t.bar2): void {
|
|
52
|
+
if (w <= 0) return;
|
|
53
|
+
box(m, x, y, w, h, h / 2, color);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Title-weight skeleton text for a font of `size` points, top at `y`. */
|
|
57
|
+
export function title(m: Mk, x: number, y: number, w: number, size = 17, color: Paint = m.t.bar): void {
|
|
58
|
+
bar(m, x, y + size * 0.22, w, size * 0.56, color);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Body skeleton text for a font of `size` points, top at `y`. */
|
|
62
|
+
export function body(m: Mk, x: number, y: number, w: number, size = 13, color: Paint = m.t.bar2): void {
|
|
63
|
+
bar(m, x, y + size * 0.27, w, size * 0.46, color);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A paragraph: `count` lines, the last one shorter. */
|
|
67
|
+
export function paragraph(m: Mk, x: number, y: number, w: number, count: number, size = 13, color: Paint = m.t.bar2): number {
|
|
68
|
+
const lh = size * 1.45;
|
|
69
|
+
for (let i = 0; i < count; i++) {
|
|
70
|
+
const last = i === count - 1 && count > 1;
|
|
71
|
+
body(m, x, y + i * lh, last ? w * (0.45 + 0.3 * m.rng.next()) : w * (0.88 + 0.12 * m.rng.next()), size, color);
|
|
72
|
+
}
|
|
73
|
+
return count * lh;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function circle(m: Mk, cx: number, cy: number, r: number, fill: Paint): void {
|
|
77
|
+
if (!(r > 0)) return;
|
|
78
|
+
m.ctx.beginPath();
|
|
79
|
+
m.ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
80
|
+
m.ctx.fillStyle = fill;
|
|
81
|
+
m.ctx.fill();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function ringStroke(m: Mk, cx: number, cy: number, r: number, lw: number, color: Paint): void {
|
|
85
|
+
if (!(r > 0)) return;
|
|
86
|
+
m.ctx.beginPath();
|
|
87
|
+
m.ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
88
|
+
m.ctx.lineWidth = lw;
|
|
89
|
+
m.ctx.strokeStyle = color;
|
|
90
|
+
m.ctx.stroke();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Progress arc from 12 o'clock, clockwise, with round caps over a track. */
|
|
94
|
+
export function progressRing(m: Mk, cx: number, cy: number, r: number, lw: number, p: number, color: Paint, track: Paint | null): void {
|
|
95
|
+
const ctx = m.ctx;
|
|
96
|
+
if (track) ringStroke(m, cx, cy, r, lw, track);
|
|
97
|
+
const k = Math.min(Math.max(p, 0), 1);
|
|
98
|
+
if (k <= 0) return;
|
|
99
|
+
ctx.beginPath();
|
|
100
|
+
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * k);
|
|
101
|
+
ctx.lineWidth = lw;
|
|
102
|
+
ctx.lineCap = 'round';
|
|
103
|
+
ctx.strokeStyle = color;
|
|
104
|
+
ctx.stroke();
|
|
105
|
+
ctx.lineCap = 'butt';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function linear(m: Mk, x0: number, y0: number, x1: number, y1: number, stops: ReadonlyArray<readonly [number, string]>): CanvasGradient {
|
|
109
|
+
const g = m.ctx.createLinearGradient(x0, y0, x1, y1);
|
|
110
|
+
for (const [o, c] of stops) g.addColorStop(o, c);
|
|
111
|
+
return g;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function radial(m: Mk, cx: number, cy: number, r: number, stops: ReadonlyArray<readonly [number, string]>): CanvasGradient {
|
|
115
|
+
const g = m.ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(r, 0.001));
|
|
116
|
+
for (const [o, c] of stops) g.addColorStop(o, c);
|
|
117
|
+
return g;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The theme's hero gradient across a rect, top-left to bottom-right. */
|
|
121
|
+
export function brandFill(m: Mk, x: number, y: number, w: number, h: number): CanvasGradient {
|
|
122
|
+
return linear(m, x, y, x + w, y + h, [
|
|
123
|
+
[0, m.t.brand[0]],
|
|
124
|
+
[1, m.t.brand[1]],
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Runs `draw` with a soft drop shadow given in points. */
|
|
129
|
+
export function shadowed(m: Mk, blur: number, dy: number, color: string, draw: () => void): void {
|
|
130
|
+
const ctx = m.ctx;
|
|
131
|
+
ctx.save();
|
|
132
|
+
ctx.shadowColor = color;
|
|
133
|
+
ctx.shadowBlur = blur * m.px;
|
|
134
|
+
ctx.shadowOffsetX = 0;
|
|
135
|
+
ctx.shadowOffsetY = dy * m.px;
|
|
136
|
+
draw();
|
|
137
|
+
ctx.restore();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A raised card: soft shadow in light mode, a hairline in dark mode where
|
|
142
|
+
* shadows disappear. `fill` defaults to the surface colour.
|
|
143
|
+
*/
|
|
144
|
+
export function card(m: Mk, x: number, y: number, w: number, h: number, r = 22, fill: Paint = m.t.surface, lift = 1): void {
|
|
145
|
+
if (w <= 0 || h <= 0) return;
|
|
146
|
+
if (!m.t.dark && lift > 0) {
|
|
147
|
+
shadowed(m, 18 * lift, 7 * lift, m.t.shadow, () => box(m, x, y, w, h, r, fill));
|
|
148
|
+
} else {
|
|
149
|
+
box(m, x, y, w, h, r, fill);
|
|
150
|
+
if (m.t.dark && lift > 0) {
|
|
151
|
+
rr(m, x + 0.5, y + 0.5, w - 1, h - 1, Math.max(0, r - 0.5));
|
|
152
|
+
m.ctx.lineWidth = 1;
|
|
153
|
+
m.ctx.strokeStyle = 'rgba(255, 255, 255, 0.045)';
|
|
154
|
+
m.ctx.stroke();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Draws digits/symbols (never words). Returns the advance width in points. */
|
|
160
|
+
export function num(m: Mk, text: string, x: number, y: number, size: number, weight: number, color: Paint, align: CanvasTextAlign = 'left'): number {
|
|
161
|
+
const ctx = m.ctx;
|
|
162
|
+
ctx.font = `${weight} ${size}px ${FONT}`;
|
|
163
|
+
ctx.textAlign = align;
|
|
164
|
+
ctx.textBaseline = 'alphabetic';
|
|
165
|
+
ctx.fillStyle = color;
|
|
166
|
+
ctx.fillText(text, x, y);
|
|
167
|
+
return ctx.measureText(text).width;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Width of `text` at a size/weight without drawing. */
|
|
171
|
+
export function measure(m: Mk, text: string, size: number, weight: number): number {
|
|
172
|
+
m.ctx.font = `${weight} ${size}px ${FONT}`;
|
|
173
|
+
return m.ctx.measureText(text).width;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Smooth curve through points (Catmull-Rom as cubic Béziers); adds to the current path. */
|
|
177
|
+
export function smoothPath(ctx: Ctx2D, pts: ReadonlyArray<readonly [number, number]>, moveFirst = true, tension = 1): void {
|
|
178
|
+
if (pts.length === 0) return;
|
|
179
|
+
if (moveFirst) ctx.moveTo(pts[0][0], pts[0][1]);
|
|
180
|
+
else ctx.lineTo(pts[0][0], pts[0][1]);
|
|
181
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
182
|
+
const p0 = pts[Math.max(0, i - 1)];
|
|
183
|
+
const p1 = pts[i];
|
|
184
|
+
const p2 = pts[i + 1];
|
|
185
|
+
const p3 = pts[Math.min(pts.length - 1, i + 2)];
|
|
186
|
+
const k = tension / 6;
|
|
187
|
+
ctx.bezierCurveTo(p1[0] + (p2[0] - p0[0]) * k, p1[1] + (p2[1] - p0[1]) * k, p2[0] - (p3[0] - p1[0]) * k, p2[1] - (p3[1] - p1[1]) * k, p2[0], p2[1]);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A smooth random walk of `n` values in 0..1, for charts. */
|
|
192
|
+
export function series(rng: Rng, n: number, start = 0.45, vol = 0.22, drift = 0.02): number[] {
|
|
193
|
+
const out: number[] = [];
|
|
194
|
+
let v = start;
|
|
195
|
+
for (let i = 0; i < n; i++) {
|
|
196
|
+
v += (rng.next() - 0.5) * vol + drift;
|
|
197
|
+
v = Math.min(0.92, Math.max(0.08, v));
|
|
198
|
+
out.push(v);
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Format helpers for believable numbers. */
|
|
204
|
+
export function money(v: number, decimals = 2): string {
|
|
205
|
+
const fixed = Math.abs(v).toFixed(decimals);
|
|
206
|
+
const [int, frac] = fixed.split('.');
|
|
207
|
+
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
208
|
+
return frac ? `${grouped}.${frac}` : grouped;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function clock(h: number, min: number): string {
|
|
212
|
+
return `${h}:${String(min).padStart(2, '0')}`;
|
|
213
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Small seeded PRNG (mulberry32) so mock screens are identical on every run and platform. */
|
|
2
|
+
|
|
3
|
+
export interface Rng {
|
|
4
|
+
/** Uniform in [0, 1). */
|
|
5
|
+
next(): number;
|
|
6
|
+
/** Uniform in [lo, hi). */
|
|
7
|
+
range(lo: number, hi: number): number;
|
|
8
|
+
/** Integer in [lo, hi] inclusive. */
|
|
9
|
+
int(lo: number, hi: number): number;
|
|
10
|
+
pick<T>(items: readonly T[]): T;
|
|
11
|
+
chance(p: number): boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Folds any number (fractions, negatives, NaN) into a well-mixed 32-bit seed. */
|
|
15
|
+
export function hashSeed(n: number): number {
|
|
16
|
+
const v = Number.isFinite(n) ? n : 0;
|
|
17
|
+
let h = Math.imul((Math.floor(v) | 0) ^ 0x9e3779b9, 0x85ebca6b);
|
|
18
|
+
h ^= Math.round((v - Math.floor(v)) * 1e6);
|
|
19
|
+
h = Math.imul(h ^ (h >>> 15), 0xc2b2ae35);
|
|
20
|
+
h ^= h >>> 13;
|
|
21
|
+
h = Math.imul(h, 0x27d4eb2f);
|
|
22
|
+
return (h ^ (h >>> 16)) >>> 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createRng(seed: number): Rng {
|
|
26
|
+
let a = hashSeed(seed) || 0x6d2b79f5;
|
|
27
|
+
const next = () => {
|
|
28
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
29
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
30
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
31
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
next,
|
|
35
|
+
range: (lo, hi) => lo + (hi - lo) * next(),
|
|
36
|
+
int: (lo, hi) => Math.floor(lo + (hi - lo + 1) * next()),
|
|
37
|
+
pick: (items) => items[Math.min(items.length - 1, Math.floor(next() * items.length))],
|
|
38
|
+
chance: (p) => next() < p,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colours of a mock app, derived from the project palette. Palettes often
|
|
3
|
+
* contain background colours (white, near-black, beige) next to the brand
|
|
4
|
+
* colour, so accents are chosen by colourfulness and nudged until they read
|
|
5
|
+
* on the mock's own background.
|
|
6
|
+
*/
|
|
7
|
+
import type { Color } from '../../../model/types';
|
|
8
|
+
import { chroma, contrast, darken, fromHsl, lighten, luminance, mix, parseColor, rotateHue, toHex, toHsl } from '../../color';
|
|
9
|
+
|
|
10
|
+
export interface MockTheme {
|
|
11
|
+
dark: boolean;
|
|
12
|
+
bg: string;
|
|
13
|
+
surface: string;
|
|
14
|
+
surface2: string;
|
|
15
|
+
sidebar: string;
|
|
16
|
+
/** Numerals and icons. */
|
|
17
|
+
text: string;
|
|
18
|
+
text2: string;
|
|
19
|
+
/** Skeleton text: titles, body copy, faint placeholders. */
|
|
20
|
+
bar: string;
|
|
21
|
+
bar2: string;
|
|
22
|
+
bar3: string;
|
|
23
|
+
line: string;
|
|
24
|
+
shadow: string;
|
|
25
|
+
/** Four harmonious accents, the first one is the brand colour. */
|
|
26
|
+
accents: [string, string, string, string];
|
|
27
|
+
accent: string;
|
|
28
|
+
/** Text/bars on top of an accent fill. */
|
|
29
|
+
onAccent: string;
|
|
30
|
+
/** Accent tint for chips and icon tiles. */
|
|
31
|
+
soft: string;
|
|
32
|
+
/** A colour close in hue to the accent, so two-colour gradients never turn muddy. */
|
|
33
|
+
companion: string;
|
|
34
|
+
/** Rich single-hue gradient for hero cards: start and end colours. */
|
|
35
|
+
brand: [string, string];
|
|
36
|
+
good: string;
|
|
37
|
+
bad: string;
|
|
38
|
+
warn: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Vivid, modern defaults for projects without a usable palette. */
|
|
42
|
+
export const FALLBACK_PALETTE: readonly string[] = ['#6c5ce7', '#ff7a59', '#00b894', '#0984e3'];
|
|
43
|
+
|
|
44
|
+
const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
45
|
+
|
|
46
|
+
/** Palette colours that can carry an accent: colourful enough and not almost white or black. */
|
|
47
|
+
export function usableAccents(palette: readonly Color[]): string[] {
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
const vividOnes: string[] = [];
|
|
50
|
+
const dullOnes: string[] = [];
|
|
51
|
+
for (const raw of palette) {
|
|
52
|
+
if (typeof raw !== 'string' || !HEX.test(raw.trim())) continue;
|
|
53
|
+
const rgb = parseColor(raw);
|
|
54
|
+
if (rgb.a < 0.5) continue;
|
|
55
|
+
const c = toHex({ ...rgb, a: 1 }, false);
|
|
56
|
+
if (seen.has(c)) continue;
|
|
57
|
+
seen.add(c);
|
|
58
|
+
const L = luminance(c);
|
|
59
|
+
const ch = chroma(c);
|
|
60
|
+
if (ch < 0.16 || L > 0.86 || L < 0.012) continue;
|
|
61
|
+
// Keep the designer's order, but let muted tones follow the vivid ones.
|
|
62
|
+
(ch >= 0.35 ? vividOnes : dullOnes).push(c);
|
|
63
|
+
}
|
|
64
|
+
return [...vividOnes, ...dullOnes];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Distance between two hues on the colour wheel, 0..180 degrees. */
|
|
68
|
+
export function hueDistance(a: string, b: string): number {
|
|
69
|
+
const d = Math.abs(toHsl(a).h - toHsl(b).h) % 360;
|
|
70
|
+
return d > 180 ? 360 - d : d;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Moves a colour towards white or black until it reaches `target` contrast with `bg` (bounded). */
|
|
74
|
+
function ensureContrast(c: string, bg: string, target: number): string {
|
|
75
|
+
let out = c;
|
|
76
|
+
const towardsWhite = luminance(bg) < 0.3;
|
|
77
|
+
for (let i = 0; i < 10 && contrast(out, bg) < target; i++) out = towardsWhite ? lighten(out, 0.12) : darken(out, 0.1);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Keeps saturation alive after contrast fixes so accents never turn grey-brown. */
|
|
82
|
+
function vivid(c: string): string {
|
|
83
|
+
const hsl = toHsl(c);
|
|
84
|
+
return hsl.s < 0.45 && chroma(c) > 0.12 ? fromHsl({ ...hsl, s: Math.min(1, hsl.s + 0.25) }) : c;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function mockTheme(palette: readonly Color[], dark: boolean): MockTheme {
|
|
88
|
+
const picked = usableAccents(palette);
|
|
89
|
+
const src = picked.length > 0 ? picked : [...FALLBACK_PALETTE];
|
|
90
|
+
const first = src[0];
|
|
91
|
+
// Fill missing accents with hue rotations of the brand colour: always harmonious.
|
|
92
|
+
const rotations = [38, -48, 152];
|
|
93
|
+
const raw: string[] = [first];
|
|
94
|
+
for (let i = 1; i < 4; i++) raw.push(src[i] ?? rotateHue(first, rotations[i - 1]));
|
|
95
|
+
|
|
96
|
+
const bg = dark ? '#0b0b0f' : '#f3f4f7';
|
|
97
|
+
const surface = dark ? '#17181d' : '#ffffff';
|
|
98
|
+
const accents = raw.map((c) => vivid(ensureContrast(c, dark ? surface : '#ffffff', dark ? 3.2 : 2.4))) as MockTheme['accents'];
|
|
99
|
+
const accent = accents[0];
|
|
100
|
+
const text = dark ? '#f4f5f7' : '#0e1015';
|
|
101
|
+
const onAccent = contrast('#ffffff', accent) >= 2.3 ? '#ffffff' : '#101114';
|
|
102
|
+
// sRGB blends of distant hues (teal + amber) go olive; keep gradient partners within ~70 degrees.
|
|
103
|
+
const companion = hueDistance(accent, accents[1]) <= 70 ? accents[1] : rotateHue(accent, 34);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
dark,
|
|
107
|
+
bg,
|
|
108
|
+
surface,
|
|
109
|
+
surface2: dark ? '#22232a' : '#eef0f4',
|
|
110
|
+
sidebar: dark ? '#121217' : '#f7f7f9',
|
|
111
|
+
text,
|
|
112
|
+
text2: dark ? '#8f92a1' : '#7a808c',
|
|
113
|
+
bar: mix(text, surface, dark ? 0.16 : 0.2),
|
|
114
|
+
bar2: mix(text, surface, dark ? 0.66 : 0.74),
|
|
115
|
+
bar3: mix(text, surface, dark ? 0.82 : 0.88),
|
|
116
|
+
line: dark ? 'rgba(255, 255, 255, 0.07)' : 'rgba(16, 22, 34, 0.07)',
|
|
117
|
+
shadow: dark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(22, 30, 52, 0.1)',
|
|
118
|
+
accents,
|
|
119
|
+
accent,
|
|
120
|
+
onAccent,
|
|
121
|
+
soft: mix(accent, surface, dark ? 0.78 : 0.86),
|
|
122
|
+
companion,
|
|
123
|
+
brand: [lighten(accent, 0.05), darken(mix(accent, companion, 0.6), 0.16)],
|
|
124
|
+
good: dark ? '#34d399' : '#12a46b',
|
|
125
|
+
bad: dark ? '#fb7185' : '#e5484d',
|
|
126
|
+
warn: dark ? '#fbbf24' : '#f59e0b',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Accent tint over a surface, for icon tiles and chips in any accent. */
|
|
131
|
+
export function tint(t: MockTheme, color: string, amount = 0.86): string {
|
|
132
|
+
return mix(color, t.surface, t.dark ? Math.min(amount, 0.8) : amount);
|
|
133
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/** Reusable app widgets: buttons, chips, section headers, charts, list rows. */
|
|
2
|
+
import { mix, rgba } from '../../color';
|
|
3
|
+
import type { Area, Mk } from './kit';
|
|
4
|
+
import { bar, body, box, card, circle, linear, measure, num, smoothPath, title } from './kit';
|
|
5
|
+
import { icon, type IconName } from './icons';
|
|
6
|
+
import { tint } from './theme';
|
|
7
|
+
|
|
8
|
+
/** The app types every mock screen can show. */
|
|
9
|
+
export type MockArchetypeId = 'finance' | 'social' | 'tasks' | 'fitness' | 'chat' | 'music' | 'travel' | 'shop' | 'calendar' | 'meditation';
|
|
10
|
+
|
|
11
|
+
export interface MockApp {
|
|
12
|
+
id: MockArchetypeId;
|
|
13
|
+
/** Sidebar navigation on tablets and desktops. */
|
|
14
|
+
nav: readonly IconName[];
|
|
15
|
+
/** Logo glyph. */
|
|
16
|
+
glyph: IconName;
|
|
17
|
+
/** Phone tab bar icons, or null when the app has its own bottom controls. */
|
|
18
|
+
tabs: readonly IconName[] | null;
|
|
19
|
+
tabSelected: number;
|
|
20
|
+
/** Full-screen backdrop; defaults to the theme background. */
|
|
21
|
+
backdrop?(m: Mk, W: number, H: number): void;
|
|
22
|
+
/** Status bar ink when the app's top is not the theme background. */
|
|
23
|
+
ink?(m: Mk): string;
|
|
24
|
+
/**
|
|
25
|
+
* Phone / tablet content. `a` runs from below the status bar to above the
|
|
26
|
+
* tab bar; `full` is the whole screen (for full-bleed apps).
|
|
27
|
+
*/
|
|
28
|
+
narrow(m: Mk, a: Area, full: Area): void;
|
|
29
|
+
/** Desktop content inside the app shell. */
|
|
30
|
+
wide(m: Mk, a: Area): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Round icon button; `badge` adds a notification dot. */
|
|
34
|
+
export function iconButton(m: Mk, cx: number, cy: number, d: number, name: IconName, opts: { badge?: boolean; fill?: string; ink?: string; lift?: boolean } = {}): void {
|
|
35
|
+
const t = m.t;
|
|
36
|
+
const fill = opts.fill ?? t.surface;
|
|
37
|
+
if (opts.lift !== false && !t.dark && fill === t.surface) {
|
|
38
|
+
card(m, cx - d / 2, cy - d / 2, d, d, d / 2, fill, 0.5);
|
|
39
|
+
} else {
|
|
40
|
+
circle(m, cx, cy, d / 2, fill);
|
|
41
|
+
}
|
|
42
|
+
icon(m.ctx, name, cx, cy, d * 0.46, opts.ink ?? t.text, false, 2);
|
|
43
|
+
if (opts.badge) {
|
|
44
|
+
circle(m, cx + d * 0.22, cy - d * 0.22, d * 0.12, fill);
|
|
45
|
+
circle(m, cx + d * 0.22, cy - d * 0.22, d * 0.085, t.bad);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Section title with a "see all" link on the right. */
|
|
50
|
+
export function sectionHeader(m: Mk, x: number, y: number, w: number, titleW = 118): void {
|
|
51
|
+
title(m, x, y, titleW, 18);
|
|
52
|
+
bar(m, x + w - 46, y + 7, 46, 6, m.t.accent);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A pill chip with an optional icon and number. Returns its width. */
|
|
56
|
+
export function chip(m: Mk, x: number, y: number, h: number, opts: { text?: string; icon?: IconName; fill: string; ink: string; barW?: number }): number {
|
|
57
|
+
const pad = h * 0.42;
|
|
58
|
+
const iconW = opts.icon ? h * 0.52 : 0;
|
|
59
|
+
const textW = opts.text ? measure(m, opts.text, h * 0.46, 600) : 0;
|
|
60
|
+
const barW = opts.barW ?? 0;
|
|
61
|
+
const gap = h * 0.22;
|
|
62
|
+
const parts = [iconW, textW, barW].filter((v) => v > 0);
|
|
63
|
+
const w = pad * 2 + parts.reduce((s, v) => s + v, 0) + gap * Math.max(0, parts.length - 1);
|
|
64
|
+
box(m, x, y, w, h, h / 2, opts.fill);
|
|
65
|
+
let cx = x + pad;
|
|
66
|
+
if (opts.icon) {
|
|
67
|
+
icon(m.ctx, opts.icon, cx + iconW / 2, y + h / 2, iconW, opts.ink, false, 2.6);
|
|
68
|
+
cx += iconW + gap;
|
|
69
|
+
}
|
|
70
|
+
if (opts.text) {
|
|
71
|
+
num(m, opts.text, cx, y + h * 0.66, h * 0.46, 600, opts.ink);
|
|
72
|
+
cx += textW + gap;
|
|
73
|
+
}
|
|
74
|
+
if (barW > 0) bar(m, cx, y + h / 2 - h * 0.1, barW, h * 0.2, opts.ink);
|
|
75
|
+
return w;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Segmented control of `n` skeleton labels with one selected. */
|
|
79
|
+
export function segmented(m: Mk, x: number, y: number, w: number, n: number, selected: number, h = 30): void {
|
|
80
|
+
const t = m.t;
|
|
81
|
+
box(m, x, y, w, h, h / 2, t.surface2);
|
|
82
|
+
const sw = w / n;
|
|
83
|
+
for (let i = 0; i < n; i++) {
|
|
84
|
+
const on = i === selected;
|
|
85
|
+
if (on) card(m, x + i * sw + 3, y + 3, sw - 6, h - 6, (h - 6) / 2, t.dark ? mix(t.surface2, '#ffffff', 0.12) : '#ffffff', 0.25);
|
|
86
|
+
bar(m, x + i * sw + sw * 0.32, y + h / 2 - 2.6, sw * 0.36, 5.2, on ? t.bar : t.bar2);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ChartOptions {
|
|
91
|
+
color: string;
|
|
92
|
+
lw?: number;
|
|
93
|
+
/** Index of a highlighted point with marker and tooltip. */
|
|
94
|
+
highlight?: number;
|
|
95
|
+
tooltip?: string;
|
|
96
|
+
grid?: boolean;
|
|
97
|
+
fillAlpha?: number;
|
|
98
|
+
/** Stretch values to the chart height (default true). */
|
|
99
|
+
normalize?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Smooth area chart of values in 0..1 over the rect. */
|
|
103
|
+
export function areaChart(m: Mk, r: Area, values: readonly number[], o: ChartOptions): void {
|
|
104
|
+
if (values.length < 2 || !(r.w > 0) || !(r.h > 0)) return;
|
|
105
|
+
const ctx = m.ctx;
|
|
106
|
+
const t = m.t;
|
|
107
|
+
// Stretch the series over the chart height so every chart looks lively.
|
|
108
|
+
let lo = Infinity;
|
|
109
|
+
let hi = -Infinity;
|
|
110
|
+
for (const v of values) {
|
|
111
|
+
lo = Math.min(lo, v);
|
|
112
|
+
hi = Math.max(hi, v);
|
|
113
|
+
}
|
|
114
|
+
const span = hi - lo > 1e-6 ? hi - lo : 1;
|
|
115
|
+
const norm = (v: number) => (o.normalize === false ? v : 0.1 + ((v - lo) / span) * 0.78);
|
|
116
|
+
const pts = values.map((v, i) => [r.x + (r.w * i) / (values.length - 1), r.y + r.h - norm(v) * r.h] as [number, number]);
|
|
117
|
+
if (o.grid) {
|
|
118
|
+
ctx.save();
|
|
119
|
+
ctx.setLineDash([3, 5]);
|
|
120
|
+
ctx.lineWidth = 1;
|
|
121
|
+
ctx.strokeStyle = t.line;
|
|
122
|
+
for (let i = 0; i <= 3; i++) {
|
|
123
|
+
const gy = r.y + (r.h * i) / 3;
|
|
124
|
+
ctx.beginPath();
|
|
125
|
+
ctx.moveTo(r.x, gy);
|
|
126
|
+
ctx.lineTo(r.x + r.w, gy);
|
|
127
|
+
ctx.stroke();
|
|
128
|
+
}
|
|
129
|
+
ctx.restore();
|
|
130
|
+
}
|
|
131
|
+
ctx.beginPath();
|
|
132
|
+
smoothPath(ctx, pts);
|
|
133
|
+
ctx.lineTo(r.x + r.w, r.y + r.h);
|
|
134
|
+
ctx.lineTo(r.x, r.y + r.h);
|
|
135
|
+
ctx.closePath();
|
|
136
|
+
ctx.fillStyle = linear(m, 0, r.y, 0, r.y + r.h, [
|
|
137
|
+
[0, rgba(o.color, o.fillAlpha ?? (m.t.dark ? 0.32 : 0.26))],
|
|
138
|
+
[1, rgba(o.color, 0)],
|
|
139
|
+
]);
|
|
140
|
+
ctx.fill();
|
|
141
|
+
ctx.beginPath();
|
|
142
|
+
smoothPath(ctx, pts);
|
|
143
|
+
ctx.lineWidth = o.lw ?? 2.6;
|
|
144
|
+
ctx.lineJoin = 'round';
|
|
145
|
+
ctx.lineCap = 'round';
|
|
146
|
+
ctx.strokeStyle = o.color;
|
|
147
|
+
ctx.stroke();
|
|
148
|
+
if (o.highlight !== undefined) {
|
|
149
|
+
const [hx, hy] = pts[Math.max(0, Math.min(pts.length - 1, o.highlight))];
|
|
150
|
+
ctx.save();
|
|
151
|
+
ctx.setLineDash([3, 4]);
|
|
152
|
+
ctx.lineWidth = 1.4;
|
|
153
|
+
ctx.strokeStyle = rgba(o.color, 0.5);
|
|
154
|
+
ctx.beginPath();
|
|
155
|
+
ctx.moveTo(hx, hy);
|
|
156
|
+
ctx.lineTo(hx, r.y + r.h);
|
|
157
|
+
ctx.stroke();
|
|
158
|
+
ctx.restore();
|
|
159
|
+
circle(m, hx, hy, 9, rgba(o.color, 0.18));
|
|
160
|
+
circle(m, hx, hy, 5.2, t.surface);
|
|
161
|
+
ctx.beginPath();
|
|
162
|
+
ctx.arc(hx, hy, 5.2, 0, Math.PI * 2);
|
|
163
|
+
ctx.lineWidth = 2.6;
|
|
164
|
+
ctx.strokeStyle = o.color;
|
|
165
|
+
ctx.stroke();
|
|
166
|
+
if (o.tooltip) {
|
|
167
|
+
const tw = measure(m, o.tooltip, 12.5, 600) + 20;
|
|
168
|
+
const th = 28;
|
|
169
|
+
const tx = Math.min(Math.max(hx - tw / 2, r.x), r.x + r.w - tw);
|
|
170
|
+
const ty = hy - th - 14;
|
|
171
|
+
const ink = t.dark ? '#ffffff' : '#111318';
|
|
172
|
+
box(m, tx, ty, tw, th, 9, ink);
|
|
173
|
+
ctx.beginPath();
|
|
174
|
+
ctx.moveTo(hx - 5, ty + th - 0.5);
|
|
175
|
+
ctx.lineTo(hx + 5, ty + th - 0.5);
|
|
176
|
+
ctx.lineTo(hx, ty + th + 5);
|
|
177
|
+
ctx.closePath();
|
|
178
|
+
ctx.fillStyle = ink;
|
|
179
|
+
ctx.fill();
|
|
180
|
+
num(m, o.tooltip, tx + tw / 2, ty + 18.5, 12.5, 600, t.dark ? '#111318' : '#ffffff', 'center');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
ctx.lineCap = 'butt';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Vertical bar chart with rounded bars; `hi` is the highlighted bar. */
|
|
187
|
+
export function barChart(m: Mk, r: Area, values: readonly number[], color: string, hi: number, track: string | null = null): void {
|
|
188
|
+
const n = values.length;
|
|
189
|
+
if (n === 0) return;
|
|
190
|
+
const gap = r.w / n;
|
|
191
|
+
const bw = Math.min(gap * 0.5, 26);
|
|
192
|
+
values.forEach((v, i) => {
|
|
193
|
+
const bx = r.x + gap * (i + 0.5) - bw / 2;
|
|
194
|
+
if (track) box(m, bx, r.y, bw, r.h, bw / 2, track);
|
|
195
|
+
const bh = Math.max(bw, r.h * v);
|
|
196
|
+
box(m, bx, r.y + r.h - bh, bw, bh, bw / 2, i === hi ? color : rgba(color, m.t.dark ? 0.35 : 0.25));
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A list row: icon tile, title and subtitle bars, trailing value. */
|
|
201
|
+
export function listRow(m: Mk, x: number, y: number, w: number, opts: { icon: IconName; color: string; titleW: number; subW: number; value?: string; valueColor?: string; sub?: string }): void {
|
|
202
|
+
const t = m.t;
|
|
203
|
+
box(m, x, y, 46, 46, 15, tint(t, opts.color));
|
|
204
|
+
icon(m.ctx, opts.icon, x + 23, y + 23, 21, opts.color, false, 2.1);
|
|
205
|
+
title(m, x + 60, y + 4, opts.titleW, 15);
|
|
206
|
+
if (opts.sub) num(m, opts.sub, x + 60, y + 39, 12.5, 500, t.text2);
|
|
207
|
+
else body(m, x + 60, y + 25, opts.subW, 13);
|
|
208
|
+
if (opts.value) num(m, opts.value, x + w, y + 29, 15.5, 650, opts.valueColor ?? t.text, 'right');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Rating stars, `value` of 5. */
|
|
212
|
+
export function stars(m: Mk, x: number, cy: number, size: number, value: number): void {
|
|
213
|
+
for (let i = 0; i < 5; i++) {
|
|
214
|
+
const on = i < Math.round(value);
|
|
215
|
+
icon(m.ctx, 'star', x + size / 2 + i * size * 1.1, cy, size, on ? '#f5b014' : m.t.bar3, on, 1.6);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A primary call-to-action button with a skeleton label. */
|
|
220
|
+
export function primaryButton(m: Mk, x: number, y: number, w: number, h: number, color: string = m.t.accent, glyph?: IconName): void {
|
|
221
|
+
const t = m.t;
|
|
222
|
+
box(m, x, y, w, h, h / 2, color);
|
|
223
|
+
const labelW = Math.min(w * 0.36, 110);
|
|
224
|
+
const ink = color === t.accent ? t.onAccent : '#ffffff';
|
|
225
|
+
if (glyph) {
|
|
226
|
+
icon(m.ctx, glyph, x + w / 2 - labelW / 2 - 6, y + h / 2, h * 0.4, ink, false, 2.4);
|
|
227
|
+
bar(m, x + w / 2 - labelW / 2 + 12, y + h / 2 - 3, labelW, 6, rgba(ink, 0.92));
|
|
228
|
+
} else {
|
|
229
|
+
bar(m, x + (w - labelW) / 2, y + h / 2 - 3, labelW, 6, rgba(ink, 0.92));
|
|
230
|
+
}
|
|
231
|
+
}
|