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,125 @@
|
|
|
1
|
+
/** Colour helpers shared by the engine (and safe to use from the UI). */
|
|
2
|
+
|
|
3
|
+
export interface RGBA {
|
|
4
|
+
/** 0..255 */
|
|
5
|
+
r: number;
|
|
6
|
+
g: number;
|
|
7
|
+
b: number;
|
|
8
|
+
/** 0..1 */
|
|
9
|
+
a: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v);
|
|
13
|
+
|
|
14
|
+
/** Parses #rgb, #rgba, #rrggbb or #rrggbbaa. Anything else becomes opaque black. */
|
|
15
|
+
export function parseColor(input: string): RGBA {
|
|
16
|
+
const s = input.trim().replace(/^#/, '');
|
|
17
|
+
const ok = /^([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(s);
|
|
18
|
+
if (!ok) return { r: 0, g: 0, b: 0, a: 1 };
|
|
19
|
+
const full = s.length <= 4 ? [...s].map((ch) => ch + ch).join('') : s;
|
|
20
|
+
const n = (i: number) => parseInt(full.slice(i, i + 2), 16);
|
|
21
|
+
return { r: n(0), g: n(2), b: n(4), a: full.length === 8 ? n(6) / 255 : 1 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function toHex(c: RGBA, withAlpha = c.a < 1): string {
|
|
25
|
+
const h = (v: number) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0');
|
|
26
|
+
return `#${h(c.r)}${h(c.g)}${h(c.b)}${withAlpha ? h(c.a * 255) : ''}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** CSS rgba() string, optionally multiplying the alpha. */
|
|
30
|
+
export function rgba(color: string | RGBA, alphaMul = 1): string {
|
|
31
|
+
const c = typeof color === 'string' ? parseColor(color) : color;
|
|
32
|
+
const a = clamp(c.a * alphaMul, 0, 1);
|
|
33
|
+
return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${+a.toFixed(4)})`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Linear mix in sRGB space; t = 0 gives a, t = 1 gives b. */
|
|
37
|
+
export function mix(a: string, b: string, t: number): string {
|
|
38
|
+
const x = parseColor(a);
|
|
39
|
+
const y = parseColor(b);
|
|
40
|
+
const k = clamp(t, 0, 1);
|
|
41
|
+
return toHex({
|
|
42
|
+
r: x.r + (y.r - x.r) * k,
|
|
43
|
+
g: x.g + (y.g - x.g) * k,
|
|
44
|
+
b: x.b + (y.b - x.b) * k,
|
|
45
|
+
a: x.a + (y.a - x.a) * k,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const lighten = (hex: string, amount: number) => mix(hex, '#ffffff', amount);
|
|
50
|
+
export const darken = (hex: string, amount: number) => mix(hex, '#000000', amount);
|
|
51
|
+
|
|
52
|
+
/** WCAG relative luminance, 0 (black) .. 1 (white). */
|
|
53
|
+
export function luminance(hex: string): number {
|
|
54
|
+
const { r, g, b } = parseColor(hex);
|
|
55
|
+
const lin = (v: number) => {
|
|
56
|
+
const s = v / 255;
|
|
57
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
58
|
+
};
|
|
59
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isDark(hex: string): boolean {
|
|
63
|
+
return luminance(hex) < 0.4;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** WCAG contrast ratio between two colours (1..21). */
|
|
67
|
+
export function contrast(a: string, b: string): number {
|
|
68
|
+
const la = luminance(a);
|
|
69
|
+
const lb = luminance(b);
|
|
70
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface HSL {
|
|
74
|
+
/** Hue in degrees, 0..360 */
|
|
75
|
+
h: number;
|
|
76
|
+
/** 0..1 */
|
|
77
|
+
s: number;
|
|
78
|
+
/** 0..1 */
|
|
79
|
+
l: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** sRGB to HSL. Greys report hue 0. */
|
|
83
|
+
export function toHsl(hex: string): HSL {
|
|
84
|
+
const { r, g, b } = parseColor(hex);
|
|
85
|
+
const R = r / 255;
|
|
86
|
+
const G = g / 255;
|
|
87
|
+
const B = b / 255;
|
|
88
|
+
const max = Math.max(R, G, B);
|
|
89
|
+
const min = Math.min(R, G, B);
|
|
90
|
+
const l = (max + min) / 2;
|
|
91
|
+
const d = max - min;
|
|
92
|
+
if (d < 1e-6) return { h: 0, s: 0, l };
|
|
93
|
+
const s = d / (1 - Math.abs(2 * l - 1));
|
|
94
|
+
let h: number;
|
|
95
|
+
if (max === R) h = ((G - B) / d) % 6;
|
|
96
|
+
else if (max === G) h = (B - R) / d + 2;
|
|
97
|
+
else h = (R - G) / d + 4;
|
|
98
|
+
h *= 60;
|
|
99
|
+
return { h: h < 0 ? h + 360 : h, s: clamp(s, 0, 1), l };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** HSL to "#rrggbb". Hue wraps; saturation and lightness are clamped. */
|
|
103
|
+
export function fromHsl({ h, s, l }: HSL): string {
|
|
104
|
+
const hue = Number.isFinite(h) ? ((h % 360) + 360) % 360 : 0;
|
|
105
|
+
const S = Number.isFinite(s) ? clamp(s, 0, 1) : 0;
|
|
106
|
+
const L = Number.isFinite(l) ? clamp(l, 0, 1) : 0;
|
|
107
|
+
const c = (1 - Math.abs(2 * L - 1)) * S;
|
|
108
|
+
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
|
109
|
+
const m = L - c / 2;
|
|
110
|
+
const [r, g, b] =
|
|
111
|
+
hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x];
|
|
112
|
+
return toHex({ r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255, a: 1 });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Rotates the hue by `degrees`, keeping saturation and lightness (alpha is dropped). */
|
|
116
|
+
export function rotateHue(hex: string, degrees: number): string {
|
|
117
|
+
const c = toHsl(hex);
|
|
118
|
+
return fromHsl({ ...c, h: c.h + degrees });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Colourfulness 0 (grey) .. 1 (pure hue): max - min channel, like HSV chroma. */
|
|
122
|
+
export function chroma(hex: string): number {
|
|
123
|
+
const { r, g, b } = parseColor(hex);
|
|
124
|
+
return (Math.max(r, g, b) - Math.min(r, g, b)) / 255;
|
|
125
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android hardware: a Pixel-like phone (punch-hole camera, power above the
|
|
3
|
+
* volume rocker on the right) and tablet (camera on the top bezel).
|
|
4
|
+
*/
|
|
5
|
+
import type { ScreenRect } from '../types';
|
|
6
|
+
import type { SlabHardware } from './slab';
|
|
7
|
+
import { paintButton, paintLens } from './slab';
|
|
8
|
+
|
|
9
|
+
/** Punch-hole camera: diameter as a fraction of the device width, centre as a fraction of the screen width below its top. */
|
|
10
|
+
export const PUNCH_HOLE = { diameter: 0.034, centerY: 0.052 };
|
|
11
|
+
|
|
12
|
+
export function punchHole(screen: ScreenRect, deviceWidth: number): { cx: number; cy: number; r: number } {
|
|
13
|
+
const r = Math.min(deviceWidth * PUNCH_HOLE.diameter, screen.h * 0.2) / 2;
|
|
14
|
+
return { cx: screen.x + screen.w / 2, cy: screen.y + Math.max(screen.w * PUNCH_HOLE.centerY, r * 1.6), r };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const androidPhoneHardware: SlabHardware = {
|
|
18
|
+
buttons(ctx, L, paint) {
|
|
19
|
+
const out = L.w * 0.0058;
|
|
20
|
+
const limit = L.h - L.radius;
|
|
21
|
+
if (L.w * 0.55 < limit) paintButton(ctx, 'right', L.w, L.w * 0.42, L.w * 0.55, out, paint);
|
|
22
|
+
if (L.w * 0.92 < limit) paintButton(ctx, 'right', L.w, L.w * 0.64, L.w * 0.92, out, paint);
|
|
23
|
+
},
|
|
24
|
+
overlays(ctx, L) {
|
|
25
|
+
const { cx, cy, r } = punchHole(L.screen, L.w);
|
|
26
|
+
ctx.beginPath();
|
|
27
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
28
|
+
ctx.fillStyle = '#000000';
|
|
29
|
+
ctx.fill();
|
|
30
|
+
// Lens glass: a darker core with a faint blue reflection, readable only up close.
|
|
31
|
+
const g = ctx.createRadialGradient(cx - r * 0.25, cy - r * 0.3, 0, cx, cy, r * 0.62);
|
|
32
|
+
g.addColorStop(0, 'rgba(70, 86, 130, 0.5)');
|
|
33
|
+
g.addColorStop(0.5, 'rgba(14, 18, 32, 0.7)');
|
|
34
|
+
g.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
35
|
+
ctx.beginPath();
|
|
36
|
+
ctx.arc(cx, cy, r * 0.62, 0, Math.PI * 2);
|
|
37
|
+
ctx.fillStyle = g;
|
|
38
|
+
ctx.fill();
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const androidTabletHardware: SlabHardware = {
|
|
43
|
+
buttons(ctx, L, paint) {
|
|
44
|
+
const out = L.w * 0.0042;
|
|
45
|
+
paintButton(ctx, 'top', 0, L.w * 0.7, L.w * 0.8, out, paint);
|
|
46
|
+
const limit = L.h - L.radius;
|
|
47
|
+
if (L.w * 0.3 < limit) paintButton(ctx, 'right', L.w, L.w * 0.12, L.w * 0.3, out, paint);
|
|
48
|
+
},
|
|
49
|
+
bezel(ctx, L) {
|
|
50
|
+
const bezel = L.inset - L.band;
|
|
51
|
+
paintLens(ctx, L.w / 2, L.band + bezel / 2, Math.min(L.w * 0.0046, bezel * 0.3));
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop browser window: rounded window, toolbar with traffic lights,
|
|
3
|
+
* navigation chevrons, a URL field and a few actions. The chrome is light
|
|
4
|
+
* or dark following the luminance of the frame colour.
|
|
5
|
+
*/
|
|
6
|
+
import type { Ctx2D } from '../types';
|
|
7
|
+
import { darken, lighten, luminance, mix } from '../color';
|
|
8
|
+
import type { BrowserLayout } from './geometry';
|
|
9
|
+
import type { FramePaint } from './finish';
|
|
10
|
+
import { roundRectPath } from './paths';
|
|
11
|
+
import type { ScreenContent } from './screen';
|
|
12
|
+
import { paintScreen } from './screen';
|
|
13
|
+
|
|
14
|
+
export const TRAFFIC_LIGHTS = ['#ff5f57', '#febc2e', '#28c840'] as const;
|
|
15
|
+
|
|
16
|
+
/** Toolbar tones for a frame colour; exported for tests. */
|
|
17
|
+
export function browserChrome(paint: FramePaint): { dark: boolean; toolbar: string; top: string; bottom: string } {
|
|
18
|
+
const dark = luminance(paint.base) < 0.4;
|
|
19
|
+
const toolbar = paint.finish === 'clay' ? paint.base : dark ? mix('#232327', paint.base, 0.45) : mix('#f3f3f5', paint.base, 0.4);
|
|
20
|
+
if (paint.finish === 'metal') return { dark, toolbar, top: lighten(toolbar, dark ? 0.07 : 0.4), bottom: darken(toolbar, dark ? 0.12 : 0.035) };
|
|
21
|
+
if (paint.finish === 'matte') return { dark, toolbar, top: lighten(toolbar, dark ? 0.03 : 0.2), bottom: darken(toolbar, dark ? 0.05 : 0.015) };
|
|
22
|
+
return { dark, toolbar, top: toolbar, bottom: toolbar };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stroke(ctx: Ctx2D, color: string, width: number, draw: () => void): void {
|
|
26
|
+
ctx.beginPath();
|
|
27
|
+
draw();
|
|
28
|
+
ctx.lineWidth = width;
|
|
29
|
+
ctx.lineCap = 'round';
|
|
30
|
+
ctx.lineJoin = 'round';
|
|
31
|
+
ctx.strokeStyle = color;
|
|
32
|
+
ctx.stroke();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Paints window and toolbar with the top-left at the current origin, then the page. */
|
|
36
|
+
export function paintBrowser(ctx: Ctx2D, L: BrowserLayout, paint: FramePaint, px: number, content: ScreenContent): void {
|
|
37
|
+
const { w, h, radius: R, toolbar: T } = L;
|
|
38
|
+
const chrome = browserChrome(paint);
|
|
39
|
+
const ink = chrome.dark ? 'rgba(255, 255, 255, 0.55)' : 'rgba(0, 0, 0, 0.5)';
|
|
40
|
+
const faint = chrome.dark ? 'rgba(255, 255, 255, 0.24)' : 'rgba(0, 0, 0, 0.2)';
|
|
41
|
+
|
|
42
|
+
// Only the toolbar needs painting: the page covers the rest of the window.
|
|
43
|
+
const tg = ctx.createLinearGradient(0, 0, 0, T);
|
|
44
|
+
tg.addColorStop(0, chrome.top);
|
|
45
|
+
tg.addColorStop(1, chrome.bottom);
|
|
46
|
+
ctx.beginPath();
|
|
47
|
+
roundRectPath(ctx, 0, 0, w, Math.min(h, T + Math.min(1 / px, T * 0.1)), [R, R, 0, 0]);
|
|
48
|
+
ctx.fillStyle = tg;
|
|
49
|
+
ctx.fill();
|
|
50
|
+
|
|
51
|
+
// Traffic lights.
|
|
52
|
+
const r = T * 0.115;
|
|
53
|
+
const gap = T * 0.38;
|
|
54
|
+
const x0 = T * 0.42;
|
|
55
|
+
const cy = T / 2;
|
|
56
|
+
TRAFFIC_LIGHTS.forEach((color, i) => {
|
|
57
|
+
const cx = x0 + i * gap;
|
|
58
|
+
const g = ctx.createLinearGradient(0, cy - r, 0, cy + r);
|
|
59
|
+
g.addColorStop(0, lighten(color, 0.12));
|
|
60
|
+
g.addColorStop(1, darken(color, 0.06));
|
|
61
|
+
ctx.beginPath();
|
|
62
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
63
|
+
ctx.fillStyle = g;
|
|
64
|
+
ctx.fill();
|
|
65
|
+
ctx.lineWidth = Math.min(r * 0.18, 0.8 / px);
|
|
66
|
+
ctx.strokeStyle = 'rgba(0, 0, 0, 0.14)';
|
|
67
|
+
ctx.stroke();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Back / forward.
|
|
71
|
+
const icon = T * 0.13;
|
|
72
|
+
const lw = Math.max(T * 0.038, 0.5 / px);
|
|
73
|
+
const navX = x0 + 2 * gap + r + T * 0.62;
|
|
74
|
+
stroke(ctx, ink, lw, () => {
|
|
75
|
+
ctx.moveTo(navX + icon * 0.5, cy - icon);
|
|
76
|
+
ctx.lineTo(navX - icon * 0.5, cy);
|
|
77
|
+
ctx.lineTo(navX + icon * 0.5, cy + icon);
|
|
78
|
+
});
|
|
79
|
+
const fwdX = navX + T * 0.55;
|
|
80
|
+
stroke(ctx, faint, lw, () => {
|
|
81
|
+
ctx.moveTo(fwdX - icon * 0.5, cy - icon);
|
|
82
|
+
ctx.lineTo(fwdX + icon * 0.5, cy);
|
|
83
|
+
ctx.lineTo(fwdX - icon * 0.5, cy + icon);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// URL field, centred, with a padlock, the address as a skeleton bar and a reload arrow.
|
|
87
|
+
const leftEnd = fwdX + T * 0.5;
|
|
88
|
+
const rightStart = w - T * 1.75;
|
|
89
|
+
const pw = Math.min(w * 0.44, Math.max(0, 2 * Math.min(w / 2 - leftEnd, rightStart - w / 2)));
|
|
90
|
+
const ph = T * 0.58;
|
|
91
|
+
if (pw > T * 2) {
|
|
92
|
+
const px0 = (w - pw) / 2;
|
|
93
|
+
const py0 = cy - ph / 2;
|
|
94
|
+
ctx.beginPath();
|
|
95
|
+
roundRectPath(ctx, px0, py0, pw, ph, ph * 0.32);
|
|
96
|
+
ctx.fillStyle = chrome.dark ? 'rgba(255, 255, 255, 0.085)' : 'rgba(0, 0, 0, 0.06)';
|
|
97
|
+
ctx.fill();
|
|
98
|
+
const barW = pw * 0.34;
|
|
99
|
+
const barH = ph * 0.26;
|
|
100
|
+
const lockW = ph * 0.26;
|
|
101
|
+
const groupW = lockW + ph * 0.22 + barW;
|
|
102
|
+
const gx = w / 2 - groupW / 2;
|
|
103
|
+
// Padlock.
|
|
104
|
+
const lockH = lockW * 0.8;
|
|
105
|
+
const ly = cy - lockH * 0.28;
|
|
106
|
+
ctx.beginPath();
|
|
107
|
+
roundRectPath(ctx, gx, ly, lockW, lockH, lockW * 0.18);
|
|
108
|
+
ctx.fillStyle = ink;
|
|
109
|
+
ctx.fill();
|
|
110
|
+
stroke(ctx, ink, lockW * 0.17, () => {
|
|
111
|
+
ctx.moveTo(gx + lockW * 0.24, ly);
|
|
112
|
+
ctx.arc(gx + lockW / 2, ly - lockW * 0.08, lockW * 0.26, Math.PI, 0);
|
|
113
|
+
ctx.lineTo(gx + lockW * 0.76, ly);
|
|
114
|
+
});
|
|
115
|
+
ctx.beginPath();
|
|
116
|
+
roundRectPath(ctx, gx + lockW + ph * 0.22, cy - barH / 2, barW, barH, barH / 2);
|
|
117
|
+
ctx.fillStyle = chrome.dark ? 'rgba(255, 255, 255, 0.42)' : 'rgba(0, 0, 0, 0.34)';
|
|
118
|
+
ctx.fill();
|
|
119
|
+
// Reload.
|
|
120
|
+
const rx = px0 + pw - ph * 0.5;
|
|
121
|
+
const rr = ph * 0.16;
|
|
122
|
+
stroke(ctx, faint, Math.max(ph * 0.05, 0.5 / px), () => {
|
|
123
|
+
ctx.arc(rx, cy, rr, -Math.PI * 0.35, Math.PI * 1.45);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Share and new tab.
|
|
128
|
+
const sx = w - T * 1.15;
|
|
129
|
+
const s = T * 0.15;
|
|
130
|
+
stroke(ctx, ink, lw, () => {
|
|
131
|
+
ctx.moveTo(sx - s * 0.6, cy - s * 0.15);
|
|
132
|
+
ctx.lineTo(sx - s * 0.9, cy - s * 0.15);
|
|
133
|
+
ctx.lineTo(sx - s * 0.9, cy + s * 1.05);
|
|
134
|
+
ctx.lineTo(sx + s * 0.9, cy + s * 1.05);
|
|
135
|
+
ctx.lineTo(sx + s * 0.9, cy - s * 0.15);
|
|
136
|
+
ctx.lineTo(sx + s * 0.6, cy - s * 0.15);
|
|
137
|
+
ctx.moveTo(sx, cy + s * 0.35);
|
|
138
|
+
ctx.lineTo(sx, cy - s * 1.15);
|
|
139
|
+
ctx.moveTo(sx - s * 0.45, cy - s * 0.7);
|
|
140
|
+
ctx.lineTo(sx, cy - s * 1.15);
|
|
141
|
+
ctx.lineTo(sx + s * 0.45, cy - s * 0.7);
|
|
142
|
+
});
|
|
143
|
+
const nx = w - T * 0.5;
|
|
144
|
+
stroke(ctx, ink, lw, () => {
|
|
145
|
+
ctx.moveTo(nx - s, cy);
|
|
146
|
+
ctx.lineTo(nx + s, cy);
|
|
147
|
+
ctx.moveTo(nx, cy - s);
|
|
148
|
+
ctx.lineTo(nx, cy + s);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Toolbar separator.
|
|
152
|
+
const hair = Math.min(1 / px, T * 0.05);
|
|
153
|
+
ctx.fillStyle = chrome.dark ? 'rgba(0, 0, 0, 0.55)' : 'rgba(0, 0, 0, 0.1)';
|
|
154
|
+
ctx.fillRect(0, T - hair, w, hair);
|
|
155
|
+
|
|
156
|
+
const sc = L.screen;
|
|
157
|
+
paintScreen(ctx, sc, (c) => roundRectPath(c, sc.x, sc.y, sc.w, sc.h, [0, 0, sc.radius, sc.radius]), content);
|
|
158
|
+
|
|
159
|
+
// Window outline: a hairline that keeps the window crisp on any background.
|
|
160
|
+
const outline = paint.bezelMatchesBody
|
|
161
|
+
? darken(chrome.toolbar, chrome.dark ? 0.3 : 0.14)
|
|
162
|
+
: chrome.dark
|
|
163
|
+
? 'rgba(255, 255, 255, 0.16)'
|
|
164
|
+
: 'rgba(0, 0, 0, 0.16)';
|
|
165
|
+
ctx.lineWidth = hair;
|
|
166
|
+
ctx.strokeStyle = outline;
|
|
167
|
+
ctx.beginPath();
|
|
168
|
+
roundRectPath(ctx, hair / 2, hair / 2, w - hair, h - hair, Math.max(0, R - hair / 2));
|
|
169
|
+
ctx.stroke();
|
|
170
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour recipes for frame finishes. Every tone is derived from the single
|
|
3
|
+
* body colour the user picks, so graphite, silver, titanium, gold, white,
|
|
4
|
+
* black and pastel clays all get believable highlights and shades.
|
|
5
|
+
*/
|
|
6
|
+
import type { FrameFinish, FrameStyle } from '../../model/types';
|
|
7
|
+
import { darken, lighten, luminance, mix, parseColor, toHex } from '../color';
|
|
8
|
+
|
|
9
|
+
/** Colour at an offset 0..1. */
|
|
10
|
+
export type Stops = ReadonlyArray<readonly [number, string]>;
|
|
11
|
+
|
|
12
|
+
export interface FramePaint {
|
|
13
|
+
finish: FrameFinish;
|
|
14
|
+
/** Body colour as "#rrggbb". */
|
|
15
|
+
base: string;
|
|
16
|
+
bezel: string;
|
|
17
|
+
/** True when the bezel is drawn in the body colour (clay mockup look). */
|
|
18
|
+
bezelMatchesBody: boolean;
|
|
19
|
+
/** Profile across a band, 0 = outer edge .. 1 = inner edge next to the glass. */
|
|
20
|
+
band: Stops;
|
|
21
|
+
/** Semi-transparent overlay along the body (environment reflection), top-left to bottom-right. */
|
|
22
|
+
sheen: Stops | null;
|
|
23
|
+
/** Hardware buttons: outer (shadowed) edge and lit face. */
|
|
24
|
+
buttonEdge: string;
|
|
25
|
+
buttonFace: string;
|
|
26
|
+
/** Dark luminance class decides whether glints read as light-on-dark. */
|
|
27
|
+
isDarkBody: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const BLACK_BEZEL = '#050507';
|
|
31
|
+
|
|
32
|
+
const FINISHES: readonly FrameFinish[] = ['metal', 'matte', 'clay'];
|
|
33
|
+
|
|
34
|
+
/** Linear interpolation inside a stop list (stops must be sorted by offset). */
|
|
35
|
+
export function sampleStops(stops: Stops, t: number): string {
|
|
36
|
+
if (stops.length === 0) return '#000000';
|
|
37
|
+
const k = Number.isFinite(t) ? Math.min(Math.max(t, 0), 1) : 0;
|
|
38
|
+
if (k <= stops[0][0]) return stops[0][1];
|
|
39
|
+
for (let i = 1; i < stops.length; i++) {
|
|
40
|
+
const [o1, c1] = stops[i];
|
|
41
|
+
if (k <= o1) {
|
|
42
|
+
const [o0, c0] = stops[i - 1];
|
|
43
|
+
return mix(c0, c1, o1 > o0 ? (k - o0) / (o1 - o0) : 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return stops[stops.length - 1][1];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Opaque "#rrggbb" for any input, so alpha in the body colour never leaks into the artwork. */
|
|
50
|
+
function opaque(color: string): string {
|
|
51
|
+
const c = parseColor(color);
|
|
52
|
+
return toHex({ ...c, a: 1 }, false);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function metalBand(base: string, L: number): Stops {
|
|
56
|
+
// Light metals read through darker edges, dark metals through bright glints.
|
|
57
|
+
const light = L > 0.5;
|
|
58
|
+
const mid = L > 0.15;
|
|
59
|
+
const edge = darken(base, light ? 0.34 : mid ? 0.46 : 0.42);
|
|
60
|
+
const glint = lighten(base, light ? 0.85 : mid ? 0.62 : 0.4);
|
|
61
|
+
const shade = darken(base, light ? 0.14 : 0.3);
|
|
62
|
+
const inner = darken(base, light ? 0.5 : 0.62);
|
|
63
|
+
return [
|
|
64
|
+
[0, edge],
|
|
65
|
+
[0.12, mix(edge, base, 0.6)],
|
|
66
|
+
[0.27, glint],
|
|
67
|
+
[0.4, mix(glint, base, 0.6)],
|
|
68
|
+
[0.62, base],
|
|
69
|
+
[0.84, shade],
|
|
70
|
+
[1, inner],
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function metalSheen(L: number): Stops {
|
|
75
|
+
const k = L > 0.5 ? 0.8 : 1;
|
|
76
|
+
const w = (a: number) => `rgba(255, 255, 255, ${+(a * k).toFixed(3)})`;
|
|
77
|
+
const b = (a: number) => `rgba(0, 0, 0, ${+(a * (L > 0.5 ? 1.2 : 1)).toFixed(3)})`;
|
|
78
|
+
return [
|
|
79
|
+
[0, w(0.3)],
|
|
80
|
+
[0.1, w(0.06)],
|
|
81
|
+
[0.28, b(0.1)],
|
|
82
|
+
[0.47, w(0.16)],
|
|
83
|
+
[0.55, w(0.02)],
|
|
84
|
+
[0.72, b(0.08)],
|
|
85
|
+
[0.88, w(0.08)],
|
|
86
|
+
[1, b(0.22)],
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolves a frame style into concrete paint. Unknown finishes fall back to metal, unknown colours to black. */
|
|
91
|
+
export function framePaint(style: FrameStyle): FramePaint {
|
|
92
|
+
// Styles come from user files; tolerate a missing object or fields rather than throwing mid-render.
|
|
93
|
+
const s: Partial<FrameStyle> = style ?? {};
|
|
94
|
+
const finish: FrameFinish = s.finish && FINISHES.includes(s.finish) ? s.finish : 'metal';
|
|
95
|
+
const base = opaque(typeof s.color === 'string' ? s.color : '#000000');
|
|
96
|
+
const L = luminance(base);
|
|
97
|
+
const matchBezel = s.bezel === 'match';
|
|
98
|
+
const bezel = matchBezel ? base : BLACK_BEZEL;
|
|
99
|
+
const isDarkBody = L < 0.18;
|
|
100
|
+
|
|
101
|
+
if (finish === 'metal') {
|
|
102
|
+
const band = metalBand(base, L);
|
|
103
|
+
return {
|
|
104
|
+
finish,
|
|
105
|
+
base,
|
|
106
|
+
bezel,
|
|
107
|
+
bezelMatchesBody: matchBezel,
|
|
108
|
+
band,
|
|
109
|
+
sheen: metalSheen(L),
|
|
110
|
+
buttonEdge: darken(base, L > 0.5 ? 0.32 : 0.45),
|
|
111
|
+
buttonFace: sampleStops(band, 0.3),
|
|
112
|
+
isDarkBody,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (finish === 'matte') {
|
|
116
|
+
const edge = darken(base, L > 0.5 ? 0.16 : 0.3);
|
|
117
|
+
return {
|
|
118
|
+
finish,
|
|
119
|
+
base,
|
|
120
|
+
bezel,
|
|
121
|
+
bezelMatchesBody: matchBezel,
|
|
122
|
+
band: [
|
|
123
|
+
[0, edge],
|
|
124
|
+
[0.2, base],
|
|
125
|
+
[0.8, base],
|
|
126
|
+
[1, darken(base, L > 0.5 ? 0.2 : 0.38)],
|
|
127
|
+
],
|
|
128
|
+
sheen: [
|
|
129
|
+
[0, 'rgba(255, 255, 255, 0.10)'],
|
|
130
|
+
[0.5, 'rgba(255, 255, 255, 0)'],
|
|
131
|
+
[1, 'rgba(0, 0, 0, 0.10)'],
|
|
132
|
+
],
|
|
133
|
+
buttonEdge: edge,
|
|
134
|
+
buttonFace: base,
|
|
135
|
+
isDarkBody,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Clay: a soft solid object, defined only by a whisper of edge shading.
|
|
139
|
+
return {
|
|
140
|
+
finish,
|
|
141
|
+
base,
|
|
142
|
+
bezel,
|
|
143
|
+
bezelMatchesBody: matchBezel,
|
|
144
|
+
band: [
|
|
145
|
+
[0, darken(base, 0.08)],
|
|
146
|
+
[0.3, base],
|
|
147
|
+
[1, base],
|
|
148
|
+
],
|
|
149
|
+
sheen: null,
|
|
150
|
+
buttonEdge: darken(base, 0.1),
|
|
151
|
+
buttonFace: base,
|
|
152
|
+
isDarkBody,
|
|
153
|
+
};
|
|
154
|
+
}
|