capture-flow 0.0.0-stage → 0.1.1

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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -2
  3. package/bin/capture-flow.mjs +12 -0
  4. package/package.json +56 -4
  5. package/skills/capture-flow/SKILL.md +248 -0
  6. package/skills/capture-flow/references/capture-recipes.md +155 -0
  7. package/skills/capture-flow/references/store-sizes.md +91 -0
  8. package/src/build.ts +186 -0
  9. package/src/capture.ts +77 -0
  10. package/src/cli.ts +139 -0
  11. package/src/index.ts +11 -0
  12. package/src/manifest.ts +98 -0
  13. package/src/render.ts +75 -0
  14. package/src/verify.ts +90 -0
  15. package/vendor/wajha/LICENSE +21 -0
  16. package/vendor/wajha/NOTICE +21 -0
  17. package/vendor/wajha/engine/background.ts +55 -0
  18. package/vendor/wajha/engine/bidi.ts +589 -0
  19. package/vendor/wajha/engine/cache.ts +96 -0
  20. package/vendor/wajha/engine/color.ts +125 -0
  21. package/vendor/wajha/engine/devices/android.ts +53 -0
  22. package/vendor/wajha/engine/devices/browser.ts +170 -0
  23. package/vendor/wajha/engine/devices/finish.ts +154 -0
  24. package/vendor/wajha/engine/devices/geometry.ts +326 -0
  25. package/vendor/wajha/engine/devices/index.ts +143 -0
  26. package/vendor/wajha/engine/devices/ipad.ts +21 -0
  27. package/vendor/wajha/engine/devices/iphone.ts +55 -0
  28. package/vendor/wajha/engine/devices/macbook.ts +187 -0
  29. package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
  30. package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
  31. package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
  32. package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
  33. package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
  34. package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
  35. package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
  36. package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
  37. package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
  38. package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
  39. package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
  40. package/vendor/wajha/engine/devices/mock/art.ts +557 -0
  41. package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
  42. package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
  43. package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
  44. package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
  45. package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
  46. package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
  47. package/vendor/wajha/engine/devices/paths.ts +113 -0
  48. package/vendor/wajha/engine/devices/screen.ts +75 -0
  49. package/vendor/wajha/engine/devices/shadow.ts +124 -0
  50. package/vendor/wajha/engine/devices/slab.ts +200 -0
  51. package/vendor/wajha/engine/devices/vision.ts +43 -0
  52. package/vendor/wajha/engine/devices/watch.ts +14 -0
  53. package/vendor/wajha/engine/geometry.ts +152 -0
  54. package/vendor/wajha/engine/images.ts +96 -0
  55. package/vendor/wajha/engine/index.ts +20 -0
  56. package/vendor/wajha/engine/linebreak.ts +226 -0
  57. package/vendor/wajha/engine/markup.ts +84 -0
  58. package/vendor/wajha/engine/mock.ts +158 -0
  59. package/vendor/wajha/engine/paint.ts +474 -0
  60. package/vendor/wajha/engine/patterns.ts +263 -0
  61. package/vendor/wajha/engine/render.ts +364 -0
  62. package/vendor/wajha/engine/shapes.ts +260 -0
  63. package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
  64. package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
  65. package/vendor/wajha/engine/text.ts +694 -0
  66. package/vendor/wajha/engine/types.ts +190 -0
  67. package/vendor/wajha/export/paths.ts +197 -0
  68. package/vendor/wajha/export/png.ts +223 -0
  69. package/vendor/wajha/export/types.ts +60 -0
  70. package/vendor/wajha/model/adapt.ts +889 -0
  71. package/vendor/wajha/model/catalog.ts +369 -0
  72. package/vendor/wajha/model/clone.ts +58 -0
  73. package/vendor/wajha/model/colorMath.ts +128 -0
  74. package/vendor/wajha/model/defaults.ts +386 -0
  75. package/vendor/wajha/model/geometry.ts +95 -0
  76. package/vendor/wajha/model/ids.ts +13 -0
  77. package/vendor/wajha/model/instantiate.ts +280 -0
  78. package/vendor/wajha/model/mirror.ts +43 -0
  79. package/vendor/wajha/model/resolve.ts +87 -0
  80. package/vendor/wajha/model/rules.ts +249 -0
  81. package/vendor/wajha/model/stub.ts +4 -0
  82. package/vendor/wajha/model/types.ts +456 -0
  83. package/vendor/wajha/model/typography.ts +28 -0
  84. package/vendor/wajha/model/validate.ts +917 -0
  85. package/vendor/wajha/templates/builtin/aurora.ts +163 -0
  86. package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
  87. package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
  88. package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
  89. package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
  90. package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
  91. package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
  92. package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
  93. package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
  94. package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
  95. package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
  96. package/vendor/wajha/templates/builtin/noor.ts +107 -0
  97. package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
  98. package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
  99. package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
  100. package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
  101. package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
  102. package/vendor/wajha/templates/builtin/sage.ts +94 -0
  103. package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
  104. package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
  105. package/vendor/wajha/templates/index.ts +55 -0
  106. package/vendor/wajha/templates/kit.ts +761 -0
  107. package/vendor/wajha/ui/sortShots.ts +239 -0
@@ -0,0 +1,187 @@
1
+ /**
2
+ * MacBook Pro seen straight on: aluminium-rimmed glass lid with a notch,
3
+ * a glimpse of the hinge, and the thin front slab of the base with its
4
+ * thumb scoop. The base is the widest part and spans the whole geometry.
5
+ */
6
+ import type { Ctx2D } from '../types';
7
+ import { darken, lighten, mix } from '../color';
8
+ import type { MacLayout } 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
+ import { paintBand, paintInnerShadow, paintLens } from './slab';
14
+
15
+ function lidPath(ctx: Ctx2D, L: MacLayout, inset: number): void {
16
+ const { lid } = L;
17
+ const t = Math.max(0, lid.rTop - inset);
18
+ const b = Math.max(0, lid.rBottom - inset);
19
+ roundRectPath(ctx, lid.x + inset, lid.y + inset, lid.w - 2 * inset, lid.h - 2 * inset, [t, t, b, b]);
20
+ }
21
+
22
+ /** Front slab of the base: tiny top corners, soft elliptical bottom corners where the case curves under. */
23
+ export function basePath(ctx: Ctx2D, L: MacLayout): void {
24
+ const { x, y, w, h } = L.base;
25
+ if (!(w > 0) || !(h > 0)) return;
26
+ const rt = Math.min(h * 0.22, w / 4);
27
+ const rx = Math.min(w * 0.034, w / 4);
28
+ const ry = Math.min(h * 0.78, h - rt);
29
+ const k = 0.5523; // cubic circle constant
30
+ ctx.moveTo(x + rt, y);
31
+ ctx.lineTo(x + w - rt, y);
32
+ ctx.quadraticCurveTo(x + w, y, x + w, y + rt);
33
+ ctx.lineTo(x + w, y + h - ry);
34
+ ctx.bezierCurveTo(x + w, y + h - ry * (1 - k), x + w - rx * (1 - k), y + h, x + w - rx, y + h);
35
+ ctx.lineTo(x + rx, y + h);
36
+ ctx.bezierCurveTo(x + rx * (1 - k), y + h, x, y + h - ry * (1 - k), x, y + h - ry);
37
+ ctx.lineTo(x, y + rt);
38
+ ctx.quadraticCurveTo(x, y, x + rt, y);
39
+ ctx.closePath();
40
+ }
41
+
42
+ /** Lid and base outlines, for the drop shadow. */
43
+ export function macBodyPath(ctx: Ctx2D, L: MacLayout): void {
44
+ lidPath(ctx, L, 0);
45
+ basePath(ctx, L);
46
+ }
47
+
48
+ function paintBase(ctx: Ctx2D, L: MacLayout, paint: FramePaint, px: number): void {
49
+ const { x, y, w, h } = L.base;
50
+ const c = paint.base;
51
+ const g = ctx.createLinearGradient(0, y, 0, y + h);
52
+ if (paint.finish === 'clay') {
53
+ g.addColorStop(0, lighten(c, 0.05));
54
+ g.addColorStop(1, darken(c, 0.12));
55
+ } else if (paint.finish === 'matte') {
56
+ g.addColorStop(0, lighten(c, 0.16));
57
+ g.addColorStop(0.5, c);
58
+ g.addColorStop(1, darken(c, 0.28));
59
+ } else {
60
+ g.addColorStop(0, lighten(c, 0.5));
61
+ g.addColorStop(0.1, lighten(c, 0.2));
62
+ g.addColorStop(0.45, c);
63
+ g.addColorStop(0.82, darken(c, 0.2));
64
+ g.addColorStop(1, darken(c, 0.45));
65
+ }
66
+ ctx.beginPath();
67
+ basePath(ctx, L);
68
+ ctx.fillStyle = g;
69
+ ctx.fill();
70
+
71
+ if (paint.finish !== 'clay') {
72
+ // Brushed aluminium falls off towards the ends and brightens in the middle.
73
+ const hg = ctx.createLinearGradient(x, 0, x + w, 0);
74
+ hg.addColorStop(0, 'rgba(0, 0, 0, 0.2)');
75
+ hg.addColorStop(0.07, 'rgba(0, 0, 0, 0)');
76
+ hg.addColorStop(0.5, `rgba(255, 255, 255, ${paint.finish === 'metal' ? 0.1 : 0.05})`);
77
+ hg.addColorStop(0.93, 'rgba(0, 0, 0, 0)');
78
+ hg.addColorStop(1, 'rgba(0, 0, 0, 0.2)');
79
+ ctx.beginPath();
80
+ basePath(ctx, L);
81
+ ctx.fillStyle = hg;
82
+ ctx.fill();
83
+ }
84
+
85
+ // Thumb scoop in the top edge.
86
+ const nw = w * 0.13;
87
+ const nh = h * 0.42;
88
+ const nx = x + (w - nw) / 2;
89
+ const sg = ctx.createLinearGradient(0, y, 0, y + nh);
90
+ sg.addColorStop(0, darken(c, paint.finish === 'clay' ? 0.16 : 0.38));
91
+ sg.addColorStop(1, darken(c, paint.finish === 'clay' ? 0.05 : 0.1));
92
+ ctx.beginPath();
93
+ ctx.moveTo(nx, y);
94
+ ctx.bezierCurveTo(nx + nh * 0.9, y, nx + nh * 0.55, y + nh, nx + nh * 1.9, y + nh);
95
+ ctx.lineTo(nx + nw - nh * 1.9, y + nh);
96
+ ctx.bezierCurveTo(nx + nw - nh * 0.55, y + nh, nx + nw - nh * 0.9, y, nx + nw, y);
97
+ ctx.closePath();
98
+ ctx.fillStyle = sg;
99
+ ctx.fill();
100
+
101
+ if (paint.finish === 'metal') {
102
+ // Chamfered top edge catching the light.
103
+ const hair = Math.min(1 / px, h * 0.08);
104
+ ctx.fillStyle = lighten(c, 0.72);
105
+ ctx.fillRect(x + w * 0.004, y, w * 0.992, hair);
106
+ }
107
+ // Underside in shadow, with the case curving away from view.
108
+ const ug = ctx.createLinearGradient(0, y + h * 0.82, 0, y + h);
109
+ ug.addColorStop(0, 'rgba(0, 0, 0, 0)');
110
+ ug.addColorStop(1, `rgba(0, 0, 0, ${paint.finish === 'clay' ? 0.12 : 0.28})`);
111
+ ctx.beginPath();
112
+ basePath(ctx, L);
113
+ ctx.fillStyle = ug;
114
+ ctx.fill();
115
+ }
116
+
117
+ function paintHinge(ctx: Ctx2D, L: MacLayout, paint: FramePaint): void {
118
+ const { x, y, w, h } = L.hinge;
119
+ const g = ctx.createLinearGradient(0, y, 0, y + h);
120
+ if (paint.finish === 'clay') {
121
+ g.addColorStop(0, darken(paint.base, 0.35));
122
+ g.addColorStop(1, darken(paint.base, 0.2));
123
+ } else {
124
+ g.addColorStop(0, '#0b0b0d');
125
+ g.addColorStop(0.55, mix('#26262a', paint.base, 0.15));
126
+ g.addColorStop(1, '#0e0e10');
127
+ }
128
+ ctx.beginPath();
129
+ roundRectPath(ctx, x, y, w, h, [0, 0, h * 0.5, h * 0.5]);
130
+ ctx.fillStyle = g;
131
+ ctx.fill();
132
+ }
133
+
134
+ function paintNotch(ctx: Ctx2D, L: MacLayout, paint: FramePaint, px: number): void {
135
+ const s = L.screen;
136
+ const nw = L.notch.w;
137
+ const nh = L.notch.h;
138
+ if (!(nw > 0) || !(nh > 0)) return;
139
+ const x = s.x + (s.w - nw) / 2;
140
+ const y = s.y - Math.min(1 / px, (s.y - L.rim) * 0.5); // overlap the bezel so no seam shows
141
+ const r = nh * 0.36;
142
+ const f = Math.min(nh * 0.24, nw * 0.1);
143
+ const bottom = s.y + nh;
144
+ ctx.beginPath();
145
+ ctx.moveTo(x - f, y);
146
+ ctx.quadraticCurveTo(x, y, x, y + f);
147
+ ctx.lineTo(x, bottom - r);
148
+ ctx.quadraticCurveTo(x, bottom, x + r, bottom);
149
+ ctx.lineTo(x + nw - r, bottom);
150
+ ctx.quadraticCurveTo(x + nw, bottom, x + nw, bottom - r);
151
+ ctx.lineTo(x + nw, y + f);
152
+ ctx.quadraticCurveTo(x + nw, y, x + nw + f, y);
153
+ ctx.closePath();
154
+ ctx.fillStyle = paint.bezel;
155
+ ctx.fill();
156
+ paintLens(ctx, s.x + s.w / 2, s.y + nh * 0.46, nh * 0.17);
157
+ }
158
+
159
+ /** Paints the whole laptop with its top-left at the current origin. */
160
+ export function paintMacbook(ctx: Ctx2D, L: MacLayout, paint: FramePaint, px: number, content: ScreenContent): void {
161
+ paintHinge(ctx, L, paint);
162
+ paintBase(ctx, L, paint, px);
163
+
164
+ const lidBox = { x: L.lid.x, y: L.lid.y, w: L.lid.w, h: L.lid.h };
165
+ paintBand(ctx, (inset) => lidPath(ctx, L, inset), L.rim, paint, px, lidBox);
166
+ // Black glass around the display, as a ring that tucks one pixel under the screen.
167
+ const s = L.screen;
168
+ const o = Math.min(1 / px, s.w * 0.01, s.h * 0.01);
169
+ ctx.beginPath();
170
+ lidPath(ctx, L, L.rim);
171
+ roundRectPath(ctx, s.x + o, s.y + o, s.w - 2 * o, s.h - 2 * o, [s.radius, s.radius, 0, 0]);
172
+ ctx.fillStyle = paint.bezel;
173
+ ctx.fill('evenodd');
174
+ if (!paint.bezelMatchesBody) {
175
+ const hair = Math.min(1 / px, L.rim);
176
+ ctx.lineWidth = hair;
177
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
178
+ ctx.beginPath();
179
+ lidPath(ctx, L, L.rim + hair * 0.5);
180
+ ctx.stroke();
181
+ }
182
+
183
+ const screenPath = (c: Ctx2D) => roundRectPath(c, s.x, s.y, s.w, s.h, [s.radius, s.radius, 0, 0]);
184
+ paintScreen(ctx, s, screenPath, content);
185
+ if (paint.bezelMatchesBody) paintInnerShadow(ctx, s, screenPath, L.lid.w * 0.008 * px, L.lid.w * 0.0015 * px, 0.18);
186
+ paintNotch(ctx, L, paint, px);
187
+ }
@@ -0,0 +1,145 @@
1
+ /** Calendar / agenda: week strip, hourly timeline with coloured events, now indicator. */
2
+ import { darken, mix, rgba } from '../../../color';
3
+ import { avatarStack } from '../art';
4
+ import { icon } from '../icons';
5
+ import type { Area, Mk } from '../kit';
6
+ import { bar, box, card, circle, num, title } from '../kit';
7
+ import { tint } from '../theme';
8
+ import type { MockApp } from '../widgets';
9
+ import { iconButton } from '../widgets';
10
+
11
+ interface Event {
12
+ start: number;
13
+ end: number;
14
+ color: number;
15
+ people: number;
16
+ }
17
+
18
+ const EVENTS: readonly Event[] = [
19
+ { start: 9, end: 10, color: 0, people: 3 },
20
+ { start: 10.5, end: 12, color: 1, people: 0 },
21
+ { start: 13, end: 13.75, color: 2, people: 2 },
22
+ { start: 14.5, end: 16, color: 3, people: 4 },
23
+ { start: 16.5, end: 17.5, color: 0, people: 0 },
24
+ { start: 18.5, end: 20, color: 2, people: 2 },
25
+ ];
26
+
27
+ const hourLabel = (h: number) => {
28
+ const total = Math.round(h * 60);
29
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
30
+ };
31
+
32
+ function eventBlock(m: Mk, x: number, y: number, w: number, h: number, ev: Event, k: number): void {
33
+ const t = m.t;
34
+ const c = t.accents[ev.color % 4];
35
+ box(m, x, y, w, h, 14, tint(t, c, t.dark ? 0.76 : 0.85));
36
+ box(m, x + 6, y + 8, 4, Math.max(4, h - 16), 2, c);
37
+ const ink = t.dark ? mix(c, '#ffffff', 0.35) : darken(c, 0.35);
38
+ bar(m, x + 20, y + 13, Math.min(w - 40, 100 + ((k * 23) % 60)), 7.5, ink);
39
+ if (h > 46) num(m, `${hourLabel(ev.start)} - ${hourLabel(ev.end)}`, x + 20, y + 41, 12, 600, rgba(ink, 0.75));
40
+ if (ev.people > 0 && h > 60 && w > 160) avatarStack(m, x + w - 16 - 24 * (1 + (ev.people - 1) * 0.66), y + h - 24, 24, ev.people, m.seed + k * 5, tint(t, c, t.dark ? 0.76 : 0.85));
41
+ }
42
+
43
+ function weekStrip(m: Mk, x: number, y: number, w: number, first: number, selected: number): void {
44
+ const t = m.t;
45
+ const step = w / 7;
46
+ for (let i = 0; i < 7; i++) {
47
+ const cx = x + step * (i + 0.5);
48
+ const on = i === selected;
49
+ if (on) box(m, cx - Math.min(23, step * 0.42), y, Math.min(46, step * 0.84), 70, 18, t.accent);
50
+ bar(m, cx - 6, y + 14, 12, 5, on ? rgba(t.onAccent, 0.75) : t.bar3);
51
+ num(m, String(first + i), cx, y + 48, 18, 700, on ? t.onAccent : t.text, 'center');
52
+ if (i % 3 !== 2 && !on) circle(m, cx, y + 62, 2.6, t.accents[i % 4]);
53
+ }
54
+ }
55
+
56
+ function timeline(m: Mk, a: Area, top: number, startHour: number, hourH: number, labelW: number): void {
57
+ const t = m.t;
58
+ const ctx = m.ctx;
59
+ const hours = Math.ceil((a.y + a.h - top) / hourH) + 1;
60
+ for (let i = 0; i < hours; i++) {
61
+ const hy = top + i * hourH;
62
+ num(m, `${startHour + i}:00`, a.x + labelW - 10, hy + 4, 11.5, 500, t.text2, 'right');
63
+ ctx.fillStyle = t.line;
64
+ ctx.fillRect(a.x + labelW, hy, a.w - labelW, 1);
65
+ }
66
+ EVENTS.forEach((ev, k) => {
67
+ const y = top + (ev.start - startHour) * hourH + 2;
68
+ const h = (ev.end - ev.start) * hourH - 4;
69
+ if (y > a.y + a.h + 40) return;
70
+ eventBlock(m, a.x + labelW + 6, y, a.w - labelW - 6, h, ev, k);
71
+ });
72
+ const ny = top + (11.3 - startHour) * hourH;
73
+ ctx.fillStyle = t.bad;
74
+ ctx.fillRect(a.x + labelW, ny - 1, a.w - labelW, 2);
75
+ circle(m, a.x + labelW, ny, 5, t.bad);
76
+ }
77
+
78
+ export const calendar: MockApp = {
79
+ id: 'calendar',
80
+ nav: ['calendar', 'list', 'clock', 'users', 'inbox', 'settings'],
81
+ glyph: 'calendar',
82
+ tabs: ['calendar', 'list', 'users', 'settings'],
83
+ tabSelected: 0,
84
+ narrow(m, a) {
85
+ const t = m.t;
86
+ const M = 20;
87
+ const x = a.x + M;
88
+ const w = a.w - 2 * M;
89
+ let y = a.y + 8;
90
+ num(m, '2026', x, y + 14, 14, 600, t.accent);
91
+ title(m, x, y + 20, 136, 28);
92
+ iconButton(m, x + w - 73, y + 26, 42, 'search');
93
+ circle(m, x + w - 21, y + 26, 21, t.accent);
94
+ icon(m.ctx, 'plus', x + w - 21, y + 26, 20, t.onAccent, false, 2.6);
95
+ y += 72;
96
+ const first = 12 + (m.seed % 9);
97
+ weekStrip(m, x, y, w, first, 2);
98
+ y += 86;
99
+ card(m, x - 6, y, w + 12, a.y + a.h - y + 60, 26);
100
+ timeline(m, { x: x + 4, y: a.y, w: w - 8, h: a.h }, y + 24, 9, 74, 52);
101
+ },
102
+ wide(m, a) {
103
+ const t = m.t;
104
+ const ctx = m.ctx;
105
+ card(m, a.x, a.y, a.w, a.h + 40, 24);
106
+ const labelW = 64;
107
+ const top = a.y + 78;
108
+ const cols = 7;
109
+ const cw = (a.w - labelW - 16) / cols;
110
+ const first = 12 + (m.seed % 9);
111
+ for (let i = 0; i < cols; i++) {
112
+ const cx = a.x + labelW + cw * (i + 0.5);
113
+ const on = i === 2;
114
+ bar(m, cx - 10, a.y + 18, 20, 5, t.bar3);
115
+ if (on) circle(m, cx, a.y + 46, 18, t.accent);
116
+ num(m, String(first + i), cx, a.y + 52, 17, 700, on ? t.onAccent : t.text, 'center');
117
+ if (i > 0) {
118
+ ctx.fillStyle = t.line;
119
+ ctx.fillRect(a.x + labelW + cw * i, top, 1, a.h);
120
+ }
121
+ }
122
+ const hourH = 64;
123
+ const hours = Math.ceil((a.y + a.h - top) / hourH) + 1;
124
+ for (let i = 0; i < hours; i++) {
125
+ const hy = top + i * hourH;
126
+ num(m, `${8 + i}:00`, a.x + labelW - 12, hy + 4, 11.5, 500, t.text2, 'right');
127
+ ctx.fillStyle = t.line;
128
+ ctx.fillRect(a.x + labelW, hy, a.w - labelW - 16, 1);
129
+ }
130
+ for (let d = 0; d < cols; d++) {
131
+ for (let j = 0; j < 2 + ((d * 5 + m.seed) % 2); j++) {
132
+ const ev = EVENTS[(d * 2 + j + m.seed) % EVENTS.length];
133
+ const start = 8.5 + ((d * 3 + j * 4) % 9);
134
+ const len = ev.end - ev.start;
135
+ const y = top + (start - 8) * hourH + 2;
136
+ if (y > a.y + a.h) continue;
137
+ eventBlock(m, a.x + labelW + cw * d + 4, y, cw - 8, len * hourH - 4, { ...ev, start, end: start + len }, d * 3 + j);
138
+ }
139
+ }
140
+ const ny = top + 3.3 * hourH;
141
+ ctx.fillStyle = t.bad;
142
+ ctx.fillRect(a.x + labelW, ny - 1, a.w - labelW - 16, 2);
143
+ circle(m, a.x + labelW + cw * 2, ny, 5, t.bad);
144
+ },
145
+ };
@@ -0,0 +1,213 @@
1
+ /** Messaging: a conversation laid out bottom-up like a real chat, with photo and voice bubbles. */
2
+ import { mix, rgba } from '../../../color';
3
+ import { avatar, photo } from '../art';
4
+ import { icon } from '../icons';
5
+ import type { Area, Mk } from '../kit';
6
+ import { bar, body, box, card, circle, linear, num, title } from '../kit';
7
+ import type { MockApp } from '../widgets';
8
+
9
+ type Msg =
10
+ | { kind: 'text'; out: boolean; lines: number[]; time?: string }
11
+ | { kind: 'image'; out: boolean }
12
+ | { kind: 'voice'; out: boolean }
13
+ | { kind: 'typing' }
14
+ | { kind: 'date' };
15
+
16
+ const THREAD: readonly Msg[] = [
17
+ { kind: 'date' },
18
+ { kind: 'text', out: false, lines: [0.92, 0.58] },
19
+ { kind: 'text', out: true, lines: [0.52] },
20
+ { kind: 'text', out: true, lines: [0.96, 0.7], time: '9:38' },
21
+ { kind: 'image', out: false },
22
+ { kind: 'text', out: false, lines: [0.48] },
23
+ { kind: 'voice', out: true },
24
+ { kind: 'text', out: false, lines: [1, 0.86, 0.42], time: '9:40' },
25
+ { kind: 'text', out: true, lines: [0.62], time: '9:41' },
26
+ { kind: 'typing' },
27
+ ];
28
+
29
+ function msgHeight(msg: Msg, maxW: number): number {
30
+ switch (msg.kind) {
31
+ case 'text':
32
+ return 20 + msg.lines.length * 19 + (msg.time ? 16 : 0);
33
+ case 'image':
34
+ return Math.round(maxW * 0.66);
35
+ case 'voice':
36
+ return 54;
37
+ case 'typing':
38
+ return 42;
39
+ case 'date':
40
+ return 28;
41
+ }
42
+ }
43
+
44
+ function sender(msg: Msg): 'in' | 'out' | 'none' {
45
+ if (msg.kind === 'date') return 'none';
46
+ if (msg.kind === 'typing') return 'in';
47
+ return msg.out ? 'out' : 'in';
48
+ }
49
+
50
+ function bubbleFill(m: Mk, out: boolean, x: number, y: number, w: number, h: number): string | CanvasGradient {
51
+ const t = m.t;
52
+ if (out)
53
+ return linear(m, x, y, x + w, y + h, [
54
+ [0, t.accent],
55
+ [1, mix(t.accent, t.companion, 0.45)],
56
+ ]);
57
+ return t.dark ? t.surface2 : t.surface;
58
+ }
59
+
60
+ function drawMsg(m: Mk, msg: Msg, a: Area, y: number, h: number, maxW: number, k: number): void {
61
+ const t = m.t;
62
+ const M = 16;
63
+ if (msg.kind === 'date') {
64
+ box(m, a.x + a.w / 2 - 44, y, 88, 24, 12, t.dark ? t.surface2 : rgba(t.text, 0.06));
65
+ bar(m, a.x + a.w / 2 - 24, y + 9.5, 48, 5, t.bar2);
66
+ return;
67
+ }
68
+ const out = msg.kind !== 'typing' && msg.out;
69
+ const radii: [number, number, number, number] = out ? [20, 20, 6, 20] : [20, 20, 20, 6];
70
+ let w: number;
71
+ if (msg.kind === 'text') w = Math.max(...msg.lines) * (maxW - 28) + 28;
72
+ else if (msg.kind === 'image') w = maxW * 0.8;
73
+ else if (msg.kind === 'voice') w = Math.min(maxW, 236);
74
+ else w = 70;
75
+ const x = out ? a.x + a.w - M - w : a.x + M;
76
+ if (msg.kind === 'image') {
77
+ photo(m, x, y, w, h, radii, m.seed + k);
78
+ return;
79
+ }
80
+ const fill = bubbleFill(m, out, x, y, w, h);
81
+ if (!out && !t.dark) card(m, x, y, w, h, radii[0], fill, 0.3);
82
+ box(m, x, y, w, h, radii, fill);
83
+ const ink = out ? rgba(t.onAccent, 0.88) : t.bar2;
84
+ if (msg.kind === 'text') {
85
+ msg.lines.forEach((f, i) => bar(m, x + 14, y + 16 + i * 19, (maxW - 28) * f, 7, ink));
86
+ if (msg.time) {
87
+ num(m, msg.time, x + w - 14 - (out ? 18 : 0), y + h - 9, 11, 500, out ? rgba(t.onAccent, 0.7) : t.text2, 'right');
88
+ if (out) {
89
+ icon(m.ctx, 'check', x + w - 22, y + h - 13, 12, rgba(t.onAccent, 0.8), false, 2.6);
90
+ icon(m.ctx, 'check', x + w - 17, y + h - 13, 12, rgba(t.onAccent, 0.8), false, 2.6);
91
+ }
92
+ }
93
+ } else if (msg.kind === 'voice') {
94
+ circle(m, x + 28, y + h / 2, 16, out ? 'rgba(255, 255, 255, 0.22)' : t.accent);
95
+ icon(m.ctx, 'play', x + 29.5, y + h / 2, 14, t.onAccent, false, 1.6);
96
+ const bars = 22;
97
+ const bx0 = x + 54;
98
+ const bw = (w - 54 - 52) / bars;
99
+ for (let i = 0; i < bars; i++) {
100
+ const v = 0.25 + 0.75 * Math.abs(Math.sin(i * 1.7 + k) * Math.cos(i * 0.6));
101
+ const bh = 4 + v * 22;
102
+ box(m, bx0 + i * bw, y + h / 2 - bh / 2, Math.max(1.5, bw * 0.55), bh, 2, i < 9 ? (out ? t.onAccent : t.accent) : out ? 'rgba(255, 255, 255, 0.45)' : t.bar3);
103
+ }
104
+ num(m, '0:24', x + w - 14, y + h / 2 + 4.5, 12, 600, out ? rgba(t.onAccent, 0.85) : t.text2, 'right');
105
+ } else {
106
+ for (let i = 0; i < 3; i++) circle(m, x + 22 + i * 13, y + h / 2, 4.2, rgba(t.text2, 0.4 + i * 0.2));
107
+ }
108
+ }
109
+
110
+ /** Lays the thread out from `bottom` upwards until `top`, cycling when the screen is tall. */
111
+ function thread(m: Mk, a: Area, top: number, bottom: number): void {
112
+ const maxW = Math.min(a.w * 0.74, 360);
113
+ let y = bottom;
114
+ let i = THREAD.length - 1;
115
+ let prev: Msg | null = null;
116
+ for (let n = 0; n < 80 && y > top; n++) {
117
+ const msg = THREAD[((i % THREAD.length) + THREAD.length) % THREAD.length];
118
+ const h = msgHeight(msg, maxW);
119
+ const gap = prev && sender(prev) === sender(msg) ? 6 : 14;
120
+ y -= h + (prev ? gap : 0);
121
+ drawMsg(m, msg, a, y, h, maxW, n);
122
+ prev = msg;
123
+ i--;
124
+ }
125
+ }
126
+
127
+ function inputBar(m: Mk, x: number, y: number, w: number): void {
128
+ const t = m.t;
129
+ circle(m, x + 20, y + 22, 20, t.dark ? t.surface2 : t.surface);
130
+ icon(m.ctx, 'plus', x + 20, y + 22, 20, t.text2, false, 2.3);
131
+ const fx = x + 50;
132
+ const fw = w - 100;
133
+ if (!t.dark) card(m, fx, y, fw, 44, 22, t.surface, 0.35);
134
+ else box(m, fx, y, fw, 44, 22, t.surface2);
135
+ bar(m, fx + 18, y + 19, fw * 0.42, 6, t.bar3);
136
+ icon(m.ctx, 'camera', fx + fw - 22, y + 22, 20, t.text2, false, 2);
137
+ circle(m, x + w - 22, y + 22, 22, t.accent);
138
+ icon(m.ctx, 'mic', x + w - 22, y + 22, 20, t.onAccent, false, 2.3);
139
+ }
140
+
141
+ function header(m: Mk, x: number, y: number, w: number): void {
142
+ const t = m.t;
143
+ icon(m.ctx, 'chevronLeft', x + 8, y + 22, 24, t.accent, false, 2.6);
144
+ avatar(m, x + 52, y + 22, 42, m.seed + 21);
145
+ circle(m, x + 67, y + 37, 6.5, t.dark ? t.bg : t.surface);
146
+ circle(m, x + 67, y + 37, 4.5, t.good);
147
+ title(m, x + 82, y + 4, 112, 16);
148
+ bar(m, x + 82, y + 30, 46, 5.5, t.good);
149
+ icon(m.ctx, 'video', x + w - 52, y + 22, 24, t.accent, false, 2);
150
+ icon(m.ctx, 'phone', x + w - 10, y + 22, 22, t.accent, false, 2);
151
+ }
152
+
153
+ export const chat: MockApp = {
154
+ id: 'chat',
155
+ nav: ['chat', 'users', 'phone', 'bookmark', 'folder', 'settings'],
156
+ glyph: 'chat',
157
+ tabs: null,
158
+ tabSelected: 0,
159
+ narrow(m, a) {
160
+ const t = m.t;
161
+ const x = a.x + 12;
162
+ const w = a.w - 24;
163
+ const inputY = a.y + a.h - 52;
164
+ thread(m, a, a.y + 64, inputY - 14);
165
+ // Header and input sit on solid panels so the cut-off thread slides under them.
166
+ m.ctx.fillStyle = linear(m, 0, a.y - 60, 0, a.y + 76, [
167
+ [0.75, t.bg],
168
+ [1, rgba(t.bg, 0)],
169
+ ]);
170
+ m.ctx.fillRect(a.x, a.y - 60, a.w, 136);
171
+ header(m, x, a.y + 4, w);
172
+ m.ctx.fillStyle = linear(m, 0, inputY - 24, 0, inputY + 6, [
173
+ [0, rgba(t.bg, 0)],
174
+ [1, t.bg],
175
+ ]);
176
+ m.ctx.fillRect(a.x, inputY - 24, a.w, a.y + a.h - inputY + 60);
177
+ inputBar(m, x, inputY, w);
178
+ },
179
+ wide(m, a) {
180
+ const t = m.t;
181
+ const lw = Math.min(330, a.w * 0.34);
182
+ card(m, a.x, a.y, lw, a.h + 40, 22);
183
+ box(m, a.x + 16, a.y + 16, lw - 32, 38, 12, t.surface2);
184
+ icon(m.ctx, 'search', a.x + 36, a.y + 35, 16, t.text2, false, 2.2);
185
+ bar(m, a.x + 52, a.y + 32, 70, 5.5, t.bar3);
186
+ for (let i = 0, y = a.y + 70; y < a.y + a.h; i++, y += 72) {
187
+ const on = i === 1;
188
+ if (on) box(m, a.x + 8, y - 6, lw - 16, 68, 16, t.dark ? rgba(t.accent, 0.16) : rgba(t.accent, 0.08));
189
+ avatar(m, a.x + 40, y + 28, 48, m.seed + 20 + i);
190
+ title(m, a.x + 74, y + 8, 90 + (i % 3) * 18, 15);
191
+ body(m, a.x + 74, y + 30, lw - 140, 12);
192
+ num(m, `${9 - (i % 6)}:${String((i * 17) % 60).padStart(2, '0')}`, a.x + lw - 18, y + 22, 11.5, 500, t.text2, 'right');
193
+ if (i % 3 === 0) {
194
+ circle(m, a.x + lw - 30, y + 38, 10, t.accent);
195
+ num(m, String(1 + (i % 4)), a.x + lw - 30, y + 42, 11, 700, t.onAccent, 'center');
196
+ }
197
+ }
198
+ const tx = a.x + lw + 20;
199
+ const tw = a.w - lw - 20;
200
+ card(m, tx, a.y, tw, a.h + 40, 22);
201
+ header(m, tx + 16, a.y + 14, tw - 32);
202
+ m.ctx.fillStyle = t.line;
203
+ m.ctx.fillRect(tx, a.y + 72, tw, 1);
204
+ const ta: Area = { x: tx + 8, y: a.y, w: tw - 16, h: a.h };
205
+ m.ctx.save();
206
+ m.ctx.beginPath();
207
+ m.ctx.rect(tx, a.y + 73, tw, a.h - 73);
208
+ m.ctx.clip();
209
+ thread(m, ta, a.y + 73, a.y + a.h - 72);
210
+ m.ctx.restore();
211
+ inputBar(m, tx + 16, a.y + a.h - 58, tw - 32);
212
+ },
213
+ };