getbotons 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.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/installer/admin.mjs +6 -0
  4. package/installer/botons.mjs +430 -0
  5. package/installer/brain-clis.mjs +28 -0
  6. package/installer/linger.mjs +39 -0
  7. package/installer/output.mjs +27 -0
  8. package/installer/revision.mjs +30 -0
  9. package/installer/shell-path.mjs +66 -0
  10. package/installer/source-lock.json +2891 -0
  11. package/package.json +26 -4
  12. package/payload/LICENSE +21 -0
  13. package/payload/SECURITY.md +33 -0
  14. package/payload/installer/admin.mjs +6 -0
  15. package/payload/installer/botons.mjs +430 -0
  16. package/payload/installer/brain-clis.mjs +28 -0
  17. package/payload/installer/linger.mjs +39 -0
  18. package/payload/installer/output.mjs +27 -0
  19. package/payload/installer/revision.mjs +30 -0
  20. package/payload/installer/shell-path.mjs +66 -0
  21. package/payload/installer/source-lock.json +2891 -0
  22. package/payload/npm-shrinkwrap.json +51 -0
  23. package/payload/package.json +13 -0
  24. package/payload/scripts/backup.ts +47 -0
  25. package/payload/scripts/botons-backup.service +15 -0
  26. package/payload/scripts/botons-backup.timer +11 -0
  27. package/payload/scripts/botons.service +36 -0
  28. package/payload/scripts/check-browser.ts +90 -0
  29. package/payload/scripts/doctor.ts +790 -0
  30. package/payload/scripts/install-backup-timer.ts +52 -0
  31. package/payload/scripts/install-service.ts +206 -0
  32. package/payload/scripts/login.ts +17 -0
  33. package/payload/scripts/migrate-names.ts +47 -0
  34. package/payload/scripts/pair.ts +163 -0
  35. package/payload/scripts/restore.ts +108 -0
  36. package/payload/server/bin/botons +4 -0
  37. package/payload/server/bin/legacy/minion +4 -0
  38. package/payload/server/bin/legacy/tots +4 -0
  39. package/payload/server/src/acting.ts +16 -0
  40. package/payload/server/src/api-keys.ts +544 -0
  41. package/payload/server/src/api.ts +1484 -0
  42. package/payload/server/src/app.ts +546 -0
  43. package/payload/server/src/approvals.ts +873 -0
  44. package/payload/server/src/ask-rules.ts +181 -0
  45. package/payload/server/src/audit.ts +158 -0
  46. package/payload/server/src/auth.ts +497 -0
  47. package/payload/server/src/backup.ts +382 -0
  48. package/payload/server/src/brain-login.ts +75 -0
  49. package/payload/server/src/browser-cert.ts +67 -0
  50. package/payload/server/src/browser-handoff.ts +119 -0
  51. package/payload/server/src/browser-handover.ts +175 -0
  52. package/payload/server/src/browser-policy.ts +210 -0
  53. package/payload/server/src/browser-proxy.ts +580 -0
  54. package/payload/server/src/browser-request.ts +335 -0
  55. package/payload/server/src/browser-screenshot.ts +56 -0
  56. package/payload/server/src/browser-send.ts +155 -0
  57. package/payload/server/src/browser.ts +2164 -0
  58. package/payload/server/src/callback.ts +218 -0
  59. package/payload/server/src/capabilities.ts +90 -0
  60. package/payload/server/src/cards.ts +243 -0
  61. package/payload/server/src/cli/approve-mcp.ts +119 -0
  62. package/payload/server/src/cli/botons.ts +629 -0
  63. package/payload/server/src/cli/browser-mcp.ts +168 -0
  64. package/payload/server/src/cli/workspace-file.ts +78 -0
  65. package/payload/server/src/cli-homes.ts +38 -0
  66. package/payload/server/src/cli-install.ts +104 -0
  67. package/payload/server/src/cli-updates.ts +112 -0
  68. package/payload/server/src/codex-config-repair.ts +80 -0
  69. package/payload/server/src/config.ts +473 -0
  70. package/payload/server/src/connector-access.ts +158 -0
  71. package/payload/server/src/connector-launch.ts +203 -0
  72. package/payload/server/src/connectors.ts +947 -0
  73. package/payload/server/src/disk-usage.ts +62 -0
  74. package/payload/server/src/engine.ts +2328 -0
  75. package/payload/server/src/env-init.ts +5 -0
  76. package/payload/server/src/events.ts +46 -0
  77. package/payload/server/src/files.ts +528 -0
  78. package/payload/server/src/github-access.ts +52 -0
  79. package/payload/server/src/health.ts +117 -0
  80. package/payload/server/src/held-dir.ts +96 -0
  81. package/payload/server/src/hooks.ts +141 -0
  82. package/payload/server/src/http.ts +207 -0
  83. package/payload/server/src/index.ts +139 -0
  84. package/payload/server/src/install-type.ts +11 -0
  85. package/payload/server/src/instructions.ts +204 -0
  86. package/payload/server/src/isolation.ts +234 -0
  87. package/payload/server/src/legacy-upgrade.ts +668 -0
  88. package/payload/server/src/limits.ts +44 -0
  89. package/payload/server/src/lock.ts +75 -0
  90. package/payload/server/src/log.ts +137 -0
  91. package/payload/server/src/managed-clis.ts +65 -0
  92. package/payload/server/src/mcp-client.ts +229 -0
  93. package/payload/server/src/notify.ts +193 -0
  94. package/payload/server/src/oauth-connections.ts +650 -0
  95. package/payload/server/src/oauth.ts +311 -0
  96. package/payload/server/src/outbound.ts +142 -0
  97. package/payload/server/src/portable.ts +1669 -0
  98. package/payload/server/src/prompt.ts +676 -0
  99. package/payload/server/src/providers/api-brain.ts +612 -0
  100. package/payload/server/src/providers/api-history.ts +131 -0
  101. package/payload/server/src/providers/api-tools.ts +481 -0
  102. package/payload/server/src/providers/api-wire.ts +690 -0
  103. package/payload/server/src/providers/claude-cli.ts +620 -0
  104. package/payload/server/src/providers/cli-check.ts +265 -0
  105. package/payload/server/src/providers/cli-turn.ts +20 -0
  106. package/payload/server/src/providers/codex-cli.ts +552 -0
  107. package/payload/server/src/providers/codex-workspace.ts +61 -0
  108. package/payload/server/src/providers/failures.ts +143 -0
  109. package/payload/server/src/providers/index.ts +131 -0
  110. package/payload/server/src/providers/mcp-stdio.ts +147 -0
  111. package/payload/server/src/providers/mock.ts +94 -0
  112. package/payload/server/src/providers/process.ts +268 -0
  113. package/payload/server/src/providers/readiness.ts +43 -0
  114. package/payload/server/src/providers/sse.ts +73 -0
  115. package/payload/server/src/providers/types.ts +173 -0
  116. package/payload/server/src/rates.ts +128 -0
  117. package/payload/server/src/reactions.ts +266 -0
  118. package/payload/server/src/recent-logs.ts +489 -0
  119. package/payload/server/src/rehearsal.ts +593 -0
  120. package/payload/server/src/rehearsals.ts +303 -0
  121. package/payload/server/src/replay.ts +165 -0
  122. package/payload/server/src/sandbox.ts +767 -0
  123. package/payload/server/src/schedule.ts +214 -0
  124. package/payload/server/src/scheduler.ts +24 -0
  125. package/payload/server/src/schema.ts +882 -0
  126. package/payload/server/src/secrets.ts +46 -0
  127. package/payload/server/src/setup.ts +356 -0
  128. package/payload/server/src/skill-packages.ts +363 -0
  129. package/payload/server/src/skills.ts +527 -0
  130. package/payload/server/src/spend.ts +255 -0
  131. package/payload/server/src/ssrf.ts +126 -0
  132. package/payload/server/src/starter-gate.ts +168 -0
  133. package/payload/server/src/starters.ts +610 -0
  134. package/payload/server/src/storage.ts +32 -0
  135. package/payload/server/src/store.ts +3087 -0
  136. package/payload/server/src/tailnet.ts +132 -0
  137. package/payload/server/src/templates.ts +454 -0
  138. package/payload/server/src/trash.ts +772 -0
  139. package/payload/server/src/util.ts +97 -0
  140. package/payload/server/src/webhooks.ts +216 -0
  141. package/payload/server/src/work-feed.ts +473 -0
  142. package/payload/shared/app-version.d.mts +1 -0
  143. package/payload/shared/app-version.mjs +3 -0
  144. package/payload/shared/boton.ts +323 -0
  145. package/payload/shared/brand.ts +107 -0
  146. package/payload/shared/cards.ts +427 -0
  147. package/payload/shared/effort.ts +91 -0
  148. package/payload/shared/files.ts +104 -0
  149. package/payload/shared/handoff.ts +97 -0
  150. package/payload/shared/install-commands.ts +7 -0
  151. package/payload/shared/legacy-names.d.mts +35 -0
  152. package/payload/shared/legacy-names.mjs +244 -0
  153. package/payload/shared/live-view.ts +94 -0
  154. package/payload/shared/look-critter.ts +189 -0
  155. package/payload/shared/look-tater.ts +127 -0
  156. package/payload/shared/look.ts +200 -0
  157. package/payload/shared/reactions.ts +43 -0
  158. package/payload/shared/seeded.ts +34 -0
  159. package/payload/shared/services.ts +90 -0
  160. package/payload/shared/types.ts +3016 -0
  161. package/payload/shared/util.ts +68 -0
  162. package/payload/shared/versions.ts +44 -0
  163. package/payload/shared/wake.ts +164 -0
  164. package/payload/web/dist/apple-touch-icon.png +0 -0
  165. package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
  166. package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
  167. package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
  168. package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
  169. package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
  170. package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
  171. package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
  172. package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
  173. package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
  174. package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
  175. package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
  176. package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
  177. package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
  178. package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
  179. package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
  180. package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
  181. package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
  183. package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
  184. package/payload/web/dist/favicon.svg +1 -0
  185. package/payload/web/dist/icon-192.png +0 -0
  186. package/payload/web/dist/icon-512.png +0 -0
  187. package/payload/web/dist/icon.svg +1 -0
  188. package/payload/web/dist/index.html +67 -0
  189. package/payload/web/dist/manifest.webmanifest +12 -0
  190. package/shared/legacy-names.d.mts +35 -0
  191. package/shared/legacy-names.mjs +244 -0
@@ -0,0 +1,323 @@
1
+ import type { AvatarState, BotonLook } from './types.ts';
2
+ import { BACKDROPS, DARK_BACKDROPS, GLOWS, SHELLS } from './look.ts';
3
+
4
+ // The Boton renderer: a pure function from (look, state, size) to SVG markup in a 100 × 100 frame. The web avatar,
5
+ // the builder and the snapshot tests all draw through it. Every value it writes comes from the look's fixed names
6
+ // or from numbers computed here, never from free text. A Boton is a button seen from the front with a little depth
7
+ // (a cap over a darker skirt), a rim of light, a dark glass display with its face lit on it, and layered parts:
8
+ // arms, top, ears, the button, the display and face, visor, panel, then the state's cues. Glow is drawn with a soft
9
+ // wide stroke under the crisp one, never an SVG filter, so a sidebar of Botons stays cheap.
10
+
11
+ /** Detail by size, like optical sizes in type: 0 up to 28 px (silhouette, rim, face, top, state), 1 up to 72 px, 2 above. */
12
+ export type Lod = 0 | 1 | 2;
13
+ export const lodFor = (px: number): Lod => (px <= 28 ? 0 : px <= 72 ? 1 : 2);
14
+
15
+ export const STATES: readonly AvatarState[] = ['idle', 'working', 'approval', 'error', 'asleep'];
16
+
17
+ /** The display glass: every glow keeps 7:1 on it, in both themes. */
18
+ export const GLASS = '#0a0f1c';
19
+ /** How far the skirt (the button's side) darkens the shell: with the rim light, it keeps every shell at 3:1 on every backdrop. */
20
+ export const SKIRT = 0.5;
21
+ const ERR = '#ff6b6b';
22
+ const WARN = '#ffbe45';
23
+ export const OFF = '#56607a';
24
+ /** The rim with no brain: unlit, but still an edge at 3:1 on the dark backdrops. */
25
+ export const OFF_RIM = '#8e98ab';
26
+ const n = (v: number) => Math.round(v * 100) / 100;
27
+ const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
28
+ const hex = (c: number[]) => `#${c.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('')}`;
29
+ export const mix = (a: string, b: string, t: number) => {
30
+ const A = rgb(a);
31
+ const B = rgb(b);
32
+ return hex(A.map((v, i) => v + (B[i] - v) * t));
33
+ };
34
+ export const skirtOf = (shell: string) => mix(shell, '#05070d', SKIRT);
35
+
36
+ interface Ctx {
37
+ state: AvatarState;
38
+ lod: Lod;
39
+ /** A prefix for ids, unique in the page. */
40
+ uid: string;
41
+ /** Animation classes (off for still images). */
42
+ motion: boolean;
43
+ shell: string;
44
+ skirt: string;
45
+ glow: string;
46
+ /** Lit: its light is on (it has a brain). */
47
+ lit: boolean;
48
+ /** Soft halos under lit strokes (from detail 1). */
49
+ halo: boolean;
50
+ }
51
+ const cls = (c: Ctx, name: string) => (c.motion && name ? ` class="${name}"` : '');
52
+
53
+ type Cap = { kind: 'c'; cx: number; cy: number; r: number } | { kind: 'r' | 'k'; x: number; y: number; w: number; h: number; rx: number };
54
+ interface Shape {
55
+ cap: Cap;
56
+ /** The top of the cap, where the top part sits. */
57
+ top: number;
58
+ /** The cap's outer edge at eye height, where the ears sit. */
59
+ side: number;
60
+ /** Eye line. */
61
+ ey: number;
62
+ /** The display. */
63
+ d: { x: number; y: number; w: number; h: number; rx: number };
64
+ /** The panel's center line. */
65
+ p: number;
66
+ /** Arm anchors. */
67
+ ax: number;
68
+ ay: number;
69
+ /** Half the distance between the eyes. */
70
+ dx: number;
71
+ }
72
+ const SHAPES: Record<BotonLook['shape'], Shape> = {
73
+ round: { cap: { kind: 'c', cx: 50, cy: 55, r: 29 }, top: 26, side: 21, ey: 53, d: { x: 31, y: 40, w: 38, h: 25, rx: 11 }, p: 74, ax: 25, ay: 68, dx: 8.36 },
74
+ pill: { cap: { kind: 'r', x: 12, y: 38, w: 76, h: 42, rx: 21 }, top: 38, side: 12, ey: 59, d: { x: 27, y: 45, w: 46, h: 24, rx: 11 }, p: 74.5, ax: 16, ay: 70, dx: 10 },
75
+ key: { cap: { kind: 'k', x: 20, y: 27, w: 60, h: 54, rx: 12 }, top: 27, side: 17, ey: 49, d: { x: 29, y: 35, w: 42, h: 26, rx: 8 }, p: 71, ax: 19, ay: 72, dx: 9.24 },
76
+ };
77
+ const my = (s: Shape) => n(s.d.y + s.d.h * 0.74);
78
+
79
+ /** The cap, or (dy, grow) the skirt under it. */
80
+ function capEl(c: Cap, attrs: string, dy = 0, grow = 0): string {
81
+ if (c.kind === 'c') return `<circle cx="${c.cx}" cy="${n(c.cy + dy)}" r="${n(c.r + grow)}" ${attrs}/>`;
82
+ const g = c.kind === 'k' && grow ? grow + 2 : grow;
83
+ return `<rect x="${n(c.x - g)}" y="${n(c.y + dy)}" width="${n(c.w + g * 2)}" height="${n(c.h + (c.kind === 'k' ? grow * 2 : 0))}" rx="${n(c.rx + g / 2)}" ${attrs}/>`;
84
+ }
85
+
86
+ /** A lit stroke: a soft wide halo under the crisp line. */
87
+ function lit(d: string, col: string, w: number, halo: boolean): string {
88
+ const base = `stroke="${col}" fill="none" stroke-linecap="round" stroke-linejoin="round"`;
89
+ return `${halo ? `<path d="${d}" ${base} stroke-width="${n(w + 3.4)}" stroke-opacity=".22"/>` : ''}<path d="${d}" ${base} stroke-width="${w}"/>`;
90
+ }
91
+ const dot = (x: number, y: number, r: number, col: string, halo: boolean) => `${halo ? `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r + 2)}" fill="${col}" opacity=".22"/>` : ''}<circle cx="${n(x)}" cy="${n(y)}" r="${r}" fill="${col}"/>`;
92
+
93
+ type EyeKind = BotonLook['eyes'] | 'closed' | 'wide' | 'down' | 'worried';
94
+ function eyes(kind: EyeKind, s: Shape, c: Ctx, col: string): string {
95
+ const { ey, dx } = s;
96
+ const h = c.halo;
97
+ const one = (x: number, k: EyeKind): string => {
98
+ switch (k) {
99
+ case 'rings': return lit(`M${n(x - 3.6)} ${ey}a3.6 3.6 0 1 0 7.2 0a3.6 3.6 0 1 0 -7.2 0`, col, 2.2, h);
100
+ case 'bars': return lit(`M${n(x)} ${n(ey - 3.4)}V${n(ey + 3.4)}`, col, 4.2, h);
101
+ case 'happy': return lit(`M${n(x - 4)} ${n(ey + 1.6)}Q${n(x)} ${n(ey - 4.4)} ${n(x + 4)} ${n(ey + 1.6)}`, col, 2.6, h);
102
+ case 'lidded': return `<path d="M${n(x - 4)} ${n(ey - 0.6)}H${n(x + 4)}a4 4 0 0 1 -8 0Z" fill="${col}"/>`;
103
+ case 'closed': return lit(`M${n(x - 3.6)} ${n(ey + 0.6)}Q${n(x)} ${n(ey + 3)} ${n(x + 3.6)} ${n(ey + 0.6)}`, col, 2, false);
104
+ case 'wide': return `${dot(x, ey, 4.4, col, h)}<circle cx="${n(x + 1.2)}" cy="${n(ey - 1.4)}" r="1.4" fill="${GLASS}"/>`;
105
+ case 'down': return dot(x, ey + 1.6, 3.2, col, h);
106
+ case 'worried': return dot(x, ey + 0.6, 3.2, col, h);
107
+ default: return dot(x, ey, 3.6, col, h);
108
+ }
109
+ };
110
+ if (kind === 'wink') return one(50 - dx, 'dots') + lit(`M${n(50 + dx - 4)} ${ey}Q${n(50 + dx)} ${n(ey + 2.4)} ${n(50 + dx + 4)} ${ey}`, col, 2.6, h);
111
+ return `<g${cls(c, c.state === 'idle' ? 'av-blink' : '')}>${one(50 - dx, kind)}${one(50 + dx, kind)}</g>`;
112
+ }
113
+
114
+ type BrowKind = BotonLook['brows'] | 'worried' | 'focused';
115
+ function brows(kind: BrowKind, s: Shape, c: Ctx, col: string): string {
116
+ const y = s.ey - 7.4;
117
+ const L = 50 - s.dx;
118
+ const R = 50 + s.dx;
119
+ const p: Record<Exclude<BrowKind, 'none'>, [string, string, number]> = {
120
+ soft: [`M${n(L - 3.6)} ${n(y + 0.6)}Q${n(L)} ${n(y - 1.4)} ${n(L + 3.6)} ${n(y + 0.6)}`, `M${n(R - 3.6)} ${n(y + 0.6)}Q${n(R)} ${n(y - 1.4)} ${n(R + 3.6)} ${n(y + 0.6)}`, 1.8],
121
+ straight: [`M${n(L - 3.6)} ${n(y)}H${n(L + 3.6)}`, `M${n(R - 3.6)} ${n(y)}H${n(R + 3.6)}`, 2],
122
+ arched: [`M${n(L - 4)} ${n(y + 1.4)}Q${n(L)} ${n(y - 3)} ${n(L + 4)} ${n(y + 1.4)}`, `M${n(R - 4)} ${n(y + 1.4)}Q${n(R)} ${n(y - 3)} ${n(R + 4)} ${n(y + 1.4)}`, 1.9],
123
+ bold: [`M${n(L - 4)} ${n(y - 0.4)}L${n(L + 4)} ${n(y + 1)}`, `M${n(R - 4)} ${n(y + 1)}L${n(R + 4)} ${n(y - 0.4)}`, 3],
124
+ worried: [`M${n(L - 4)} ${n(y + 1.6)}L${n(L + 3.4)} ${n(y - 0.8)}`, `M${n(R - 3.4)} ${n(y - 0.8)}L${n(R + 4)} ${n(y + 1.6)}`, 2],
125
+ focused: [`M${n(L - 4)} ${n(y - 0.2)}L${n(L + 4)} ${n(y + 1.4)}`, `M${n(R - 4)} ${n(y + 1.4)}L${n(R + 4)} ${n(y - 0.2)}`, 2],
126
+ };
127
+ if (kind === 'none') return '';
128
+ const [a, b, w] = p[kind];
129
+ return lit(`${a}${b}`, col, w, c.halo);
130
+ }
131
+
132
+ type MouthKind = BotonLook['mouth'] | 'dots' | 'wobbly' | 'flat';
133
+ function mouth(kind: MouthKind, s: Shape, c: Ctx, col: string): string {
134
+ const y = my(s);
135
+ const h = c.halo;
136
+ switch (kind) {
137
+ case 'grin': return `<path d="M44.4 ${n(y - 1.2)}H55.6a5.6 5 0 0 1 -11.2 0Z" fill="${col}"/>`;
138
+ case 'smirk': return lit(`M45 ${n(y + 0.4)}Q51 ${n(y + 2.6)} 55.6 ${n(y - 1.6)}`, col, 2.4, h);
139
+ case 'o': return lit(`M47.4 ${y}a2.6 2.6 0 1 0 5.2 0a2.6 2.6 0 1 0 -5.2 0`, col, 2.2, h);
140
+ case 'calm': return lit(`M46 ${y}H54`, col, 2.4, h);
141
+ case 'wave': return lit(`M43.4 ${y}q1.65 -3 3.3 0t3.3 0t3.3 0t3.3 0`, col, 2, h);
142
+ case 'dots': return `<g fill="${col}"><circle cx="45.6" cy="${y}" r="1.6"/><circle cx="50" cy="${y}" r="1.6"/><circle cx="54.4" cy="${y}" r="1.6"/></g>`;
143
+ case 'wobbly': return lit(`M44.6 ${n(y + 1.2)}q2.7 -2.6 5.4 0t5.4 0`, col, 2.2, h);
144
+ case 'flat': return lit(`M46.6 ${n(y + 0.6)}H53.4`, col, 2, false);
145
+ default: return lit(`M44.6 ${n(y - 1)}Q50 ${n(y + 4)} 55.4 ${n(y - 1)}`, col, 2.6, h);
146
+ }
147
+ }
148
+
149
+ function top(kind: BotonLook['top'], s: Shape, c: Ctx): string {
150
+ const t = s.top;
151
+ const { shell, skirt: sk, glow, lit: on, halo } = c;
152
+ const ball = (x: number, y: number, r = 3.4) => `${on && halo ? `<circle cx="${x}" cy="${n(y)}" r="${n(r + 2.6)}" fill="${glow}" opacity=".25"/>` : ''}<circle cx="${x}" cy="${n(y)}" r="${r}" fill="${on ? glow : sk}" stroke="${sk}" stroke-width="1.2"/>`;
153
+ const stem = (d: string, w = 2.6) => `<path d="${d}" stroke="${sk}" stroke-width="${w}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`;
154
+ switch (kind) {
155
+ case 'antenna': return stem(`M50 ${t + 1}V${t - 13}`) + ball(50, t - 15);
156
+ case 'twin': return stem(`M44 ${t + 2}L38 ${t - 11}M56 ${t + 2}L62 ${t - 11}`) + ball(37, t - 13, 3) + ball(63, t - 13, 3);
157
+ case 'dish': return stem(`M50 ${t + 1}V${t - 7}`) + `<path d="M39 ${t - 15}Q50 ${t - 2} 61 ${t - 15}Z" fill="${shell}" stroke="${sk}" stroke-width="1.6" stroke-linejoin="round" transform="rotate(-16 50 ${t - 9})"/>` + ball(50, t - 12, 1.8);
158
+ case 'coil': return stem(`M50 ${t + 1}V${t - 2}l-4 -2.2 8 -2.6 -8 -2.6 8 -2.6 -4 -2.2`, 2.2) + ball(50, t - 16, 3.2);
159
+ case 'fin': return `<path d="M40 ${t + 3}Q42 ${t - 14} 58 ${t - 15}Q55 ${t - 6} 60 ${t + 3}Z" fill="${shell}" stroke="${sk}" stroke-width="1.6" stroke-linejoin="round"/>${on && c.lod ? lit(`M44 ${t - 1}Q46 ${t - 10} 55 ${n(t - 11.4)}`, glow, 1.6, halo) : ''}`;
160
+ case 'beacon': return `<rect x="42" y="${t - 3}" width="16" height="5" rx="2" fill="${sk}"/><path d="M43.5 ${t - 3}a6.5 6.5 0 0 1 13 0Z" fill="${on ? glow : mix(sk, '#ffffff', 0.3)}" stroke="${sk}" stroke-width="1.2"/>`;
161
+ case 'halo': return on ? lit(`M33 ${t - 9}a17 5 0 1 0 34 0a17 5 0 1 0 -34 0`, glow, 2.2, halo) : `<ellipse cx="50" cy="${t - 9}" rx="17" ry="5" fill="none" stroke="${sk}" stroke-width="2.2"/>`;
162
+ default: return '';
163
+ }
164
+ }
165
+
166
+ function ears(kind: BotonLook['ears'], s: Shape, c: Ctx): string {
167
+ const { ey } = s;
168
+ const { shell, skirt: sk, glow, lit: on } = c;
169
+ const L = s.side + 1;
170
+ const R = 100 - s.side - 1;
171
+ switch (kind) {
172
+ case 'bolts': return [L, R].map((x) => `<rect x="${x - 4}" y="${ey - 4}" width="8" height="8" rx="2" fill="${sk}"/><circle cx="${x + (x < 50 ? -1 : 1)}" cy="${ey}" r="1.6" fill="${shell}"/>`).join('');
173
+ case 'pods': return [L - 2, R + 2].map((x) => `<rect x="${n(x - 4.6)}" y="${ey - 9}" width="9.2" height="18" rx="4.6" fill="${shell}" stroke="${sk}" stroke-width="1.8"/>${on ? `<rect x="${x - 1}" y="${ey - 5}" width="2" height="10" rx="1" fill="${glow}"/>` : ''}`).join('');
174
+ case 'fins': return `<path d="M${L + 2} ${ey - 7}L${L - 9} ${ey - 13}L${L - 5} ${ey + 5}Z" fill="${shell}" stroke="${sk}" stroke-width="1.6" stroke-linejoin="round"/><path d="M${R - 2} ${ey - 7}L${R + 9} ${ey - 13}L${R + 5} ${ey + 5}Z" fill="${shell}" stroke="${sk}" stroke-width="1.6" stroke-linejoin="round"/>`;
175
+ case 'knobs': return ([[L, -1], [R, 1]] as const).map(([x, side]) => `<rect x="${side < 0 ? x - 5 : x}" y="${ey - 2}" width="5" height="4" fill="${sk}"/><circle cx="${x + side * 7}" cy="${ey}" r="3.8" fill="${on ? glow : shell}" stroke="${sk}" stroke-width="1.6"/>`).join('');
176
+ default: return '';
177
+ }
178
+ }
179
+
180
+ function visor(kind: BotonLook['visor'], s: Shape, c: Ctx): string {
181
+ const { ey, dx, d } = s;
182
+ const L = n(50 - dx);
183
+ const R = n(50 + dx);
184
+ const { skirt: sk, glow } = c;
185
+ switch (kind) {
186
+ case 'goggles': return `<g fill="${glow}" fill-opacity=".12" stroke="${sk}" stroke-width="2.4"><circle cx="${L}" cy="${ey}" r="6.6"/><circle cx="${R}" cy="${ey}" r="6.6"/></g><path d="M${n(L + 6.6)} ${ey}H${n(R - 6.6)}M${d.x - 1} ${ey}H${n(L - 6.6)}M${n(R + 6.6)} ${ey}H${d.x + d.w + 1}" stroke="${sk}" stroke-width="2.4"/>`;
187
+ case 'monocle': return `<circle cx="${R}" cy="${ey}" r="7" fill="${glow}" fill-opacity=".1" stroke="${sk}" stroke-width="2.4"/><path d="M${n(R + 5)} ${ey + 5}Q${n(R + 9)} ${ey + 12} ${n(R + 6)} ${d.y + d.h + 3}" stroke="${sk}" stroke-width="1.2" fill="none"/>`;
188
+ case 'band': return `<rect x="${d.x - 2}" y="${n(ey - 5.6)}" width="${d.w + 4}" height="11.2" rx="5.6" fill="${glow}" fill-opacity=".16" stroke="${glow}" stroke-opacity=".7" stroke-width="1.4"/>`;
189
+ default: return '';
190
+ }
191
+ }
192
+
193
+ function panel(kind: BotonLook['panel'], s: Shape, c: Ctx): string {
194
+ const y = s.p;
195
+ const { skirt: sk, glow, lit: on, shell } = c;
196
+ switch (kind) {
197
+ case 'holes': return `<g fill="${sk}"><circle cx="46.6" cy="${n(y - 2.2)}" r="1.5"/><circle cx="53.4" cy="${n(y - 2.2)}" r="1.5"/><circle cx="46.6" cy="${n(y + 2.2)}" r="1.5"/><circle cx="53.4" cy="${n(y + 2.2)}" r="1.5"/></g>`;
198
+ case 'grill': return `<path d="M44 ${n(y - 2.6)}H56M43 ${y}H57M44 ${n(y + 2.6)}H56" stroke="${sk}" stroke-width="1.8" stroke-linecap="round"/>`;
199
+ case 'leds': return [44, 50, 56].map((x, i) => `<circle cx="${x}" cy="${y}" r="2" fill="${on ? (i === 1 ? glow : mix(glow, sk, 0.55)) : sk}"/>`).join('');
200
+ case 'dial': return `<circle cx="50" cy="${y}" r="4.2" fill="${mix(shell, '#ffffff', 0.25)}" stroke="${sk}" stroke-width="1.6"/><path d="M50 ${y}L52.4 ${n(y - 2.4)}" stroke="${sk}" stroke-width="1.6" stroke-linecap="round"/>`;
201
+ case 'switch': return `<rect x="44" y="${n(y - 2.6)}" width="12" height="5.2" rx="2.6" fill="${sk}"/><circle cx="${on ? 53.4 : 46.6}" cy="${y}" r="2.1" fill="${on ? glow : shell}"/>`;
202
+ default: return '';
203
+ }
204
+ }
205
+
206
+ /** Arms behind the body. Needs you raises the right one; the left keeps its pose. */
207
+ function arms(kind: BotonLook['arms'], s: Shape, c: Ctx): string {
208
+ const k = c.state === 'approval' ? 'raise' : kind;
209
+ if (k === 'none') return '';
210
+ const { ax, ay } = s;
211
+ const L = ax;
212
+ const R = 100 - ax;
213
+ const arm = (d: string) => `<path d="${d}" stroke="${c.skirt}" stroke-width="5.4" fill="none" stroke-linecap="round"/>`;
214
+ const down = (x: number, side: number) => arm(`M${x} ${ay}l${side * 6} 7`);
215
+ const up = (x: number, side: number) => arm(`M${x + side * 2} ${ay - 3}l${side * 9} -13`) + `<circle cx="${x + side * 11}" cy="${ay - 17}" r="3.6" fill="${c.skirt}"/>`;
216
+ switch (k) {
217
+ case 'wave': return down(L, -1) + up(R, 1);
218
+ case 'raise': return (kind === 'none' ? '' : down(L, -1)) + `<g${cls(c, 'av-wave')} style="transform-origin:${R + 2}px ${ay - 3}px">${up(R, 1)}</g>`;
219
+ case 'cheer': return up(L, -1) + up(R, 1);
220
+ default: return down(L, -1) + down(R, 1);
221
+ }
222
+ }
223
+
224
+ export interface BotonOptions {
225
+ state?: AvatarState;
226
+ /** Pixel size it's drawn at, which picks the detail level. */
227
+ size?: number;
228
+ lod?: Lod;
229
+ uid?: string;
230
+ /** No animation classes. */
231
+ still?: boolean;
232
+ /** The frame: a circle on the backdrop (avatars), a square on it, or none (the figure alone). */
233
+ frame?: 'circle' | 'square' | 'none';
234
+ }
235
+
236
+ /** What goes inside <svg viewBox="0 0 100 100">. */
237
+ export function botonInner(look: BotonLook, o: BotonOptions = {}): string {
238
+ const state = o.state ?? 'idle';
239
+ const lod = o.lod ?? lodFor(o.size ?? 48);
240
+ const uid = (o.uid ?? 'tt').replace(/[^A-Za-z0-9_-]/g, '');
241
+ const s = SHAPES[look.shape];
242
+ const off = state === 'asleep';
243
+ // No brain: the shell a little greyer and the display dark, as if the lights are off.
244
+ const shell = off ? mix(SHELLS[look.shell], '#8a92a3', 0.35) : SHELLS[look.shell];
245
+ const glow = state === 'error' ? ERR : GLOWS[look.glow];
246
+ const c: Ctx = { state, lod, uid, motion: !o.still, shell, skirt: skirtOf(shell), glow, lit: !off, halo: lod >= 1 && !off };
247
+ const face = off ? OFF : glow;
248
+ const onDark = DARK_BACKDROPS.includes(look.backdrop);
249
+ const { cap, d } = s;
250
+
251
+ let fig = arms(look.arms, s, c);
252
+ fig += top(look.top, s, c);
253
+ if (lod >= 1) fig += ears(look.ears, s, c);
254
+ // The button: its skirt a little lower, then the cap, then a rim of light (off without a brain).
255
+ let defs = '';
256
+ if (lod >= 1) defs += `<linearGradient id="${uid}g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(shell, '#ffffff', 0.22)}"/><stop offset=".55" stop-color="${shell}"/><stop offset="1" stop-color="${mix(shell, '#05070d', 0.12)}"/></linearGradient>`;
257
+ fig += capEl(cap, `fill="${c.skirt}"`, 4.5, cap.kind === 'k' ? 1 : 0.5);
258
+ fig += capEl(cap, `fill="${lod >= 1 ? `url(#${uid}g)` : shell}"`);
259
+ fig += capEl(cap, `fill="none" stroke="${off ? OFF_RIM : glow}" stroke-width="${lod ? 1.3 : 1.8}" stroke-opacity=".9"`);
260
+ if (lod >= 1) {
261
+ // A soft highlight on the cap's upper left: it reads as a physical button.
262
+ const hl = cap.kind === 'c' ? `M${n(cap.cx - cap.r * 0.62)} ${n(cap.cy - cap.r * 0.5)}Q${n(cap.cx - cap.r * 0.25)} ${n(cap.cy - cap.r * 0.86)} ${n(cap.cx + cap.r * 0.1)} ${n(cap.cy - cap.r * 0.9)}` : `M${n(cap.x + cap.rx * 0.6)} ${cap.y + 4}H${n(cap.x + cap.w * 0.45)}`;
263
+ fig += `<path d="${hl}" stroke="#fff" stroke-opacity=".45" stroke-width="2.2" fill="none" stroke-linecap="round"/>`;
264
+ }
265
+ // The display: dark glass, the face lit on it.
266
+ fig += `<rect x="${d.x}" y="${d.y}" width="${d.w}" height="${d.h}" rx="${d.rx}" fill="${off ? '#141927' : GLASS}" stroke="${off ? OFF : glow}" stroke-opacity=".5" stroke-width="1.1"/>`;
267
+ if (lod >= 1 && !off) fig += `<path d="M${d.x + 4} ${n(d.y + 3.5)}H${n(d.x + d.w * 0.42)}" stroke="#fff" stroke-opacity=".14" stroke-width="1.6" stroke-linecap="round"/>`;
268
+ if (state === 'error' && lod >= 1) fig += `<path d="M${d.x + 3} ${d.y + 6}h9M${d.x + d.w - 14} ${d.y + d.h - 5}h10" stroke="${ERR}" stroke-opacity=".45" stroke-width="1.2"/>`;
269
+ // The face, by state. A state replaces the resting eyes, brows and mouth, so the mood always shows, visor or not.
270
+ const [e, b, m]: [EyeKind, BrowKind, MouthKind] = {
271
+ idle: [look.eyes, look.brows, look.mouth] as [EyeKind, BrowKind, MouthKind],
272
+ working: ['down', 'focused', 'dots'] as [EyeKind, BrowKind, MouthKind],
273
+ approval: ['wide', 'arched', 'o'] as [EyeKind, BrowKind, MouthKind],
274
+ error: ['worried', 'worried', 'wobbly'] as [EyeKind, BrowKind, MouthKind],
275
+ asleep: ['closed', 'none', 'flat'] as [EyeKind, BrowKind, MouthKind],
276
+ }[state];
277
+ if (look.cheeks && lod >= 1 && state === 'idle') fig += `<g fill="#ff7eb6" opacity=".55"><rect x="${n(50 - s.dx - 8)}" y="${s.ey + 5}" width="4.6" height="2.2" rx="1.1"/><rect x="${n(50 + s.dx + 3.4)}" y="${s.ey + 5}" width="4.6" height="2.2" rx="1.1"/></g>`;
278
+ fig += brows(b, s, c, face) + eyes(e, s, c, face) + mouth(m, s, c, face);
279
+ if (lod >= 1 && state === 'idle') fig += visor(look.visor, s, c);
280
+ if (lod >= 1) fig += panel(look.panel, s, c);
281
+
282
+ // State cues beyond the face. Each one also has words wherever it's shown.
283
+ if (state === 'working') {
284
+ // A lit arc around the button, like a button that's busy.
285
+ const arc = cap.kind === 'c'
286
+ ? `M50 ${n(cap.cy - cap.r - 6.5)}a${n(cap.r + 6.5)} ${n(cap.r + 6.5)} 0 0 1 ${n((cap.r + 6.5) * 0.87)} ${n((cap.r + 6.5) * 1.5)}`
287
+ : `M${cap.x + cap.w / 2} ${cap.y - 5}H${cap.x + cap.w - cap.rx}q${cap.rx + 5} 0 ${cap.rx + 5} ${cap.rx + 5}V${n(cap.y + cap.h * 0.55)}`;
288
+ const cy = cap.kind === 'c' ? cap.cy : cap.y + cap.h / 2;
289
+ fig += `<g${cls(c, 'av-spin')} style="transform-origin:50px ${cy}px">${lit(arc, glow, lod ? 2.4 : 2.8, c.halo)}</g>`;
290
+ }
291
+ if (state === 'approval' || state === 'error') {
292
+ const x = cap.kind === 'c' ? 72 : cap.x + cap.w - 2;
293
+ const y = cap.kind === 'c' ? 32 : cap.y + 1;
294
+ const r = lod ? 6.4 : 8;
295
+ const ring = `stroke="${onDark ? GLASS : '#ffffff'}" stroke-width="1.6"`;
296
+ fig += state === 'approval'
297
+ ? `<circle cx="${x}" cy="${y}" r="${r}" fill="${WARN}" ${ring}/><path d="M${x} ${n(y - 3.2)}V${n(y + 0.6)}" stroke="${GLASS}" stroke-width="2" stroke-linecap="round"/><circle cx="${x}" cy="${y + 3}" r="1.1" fill="${GLASS}"/>`
298
+ : `<circle cx="${x}" cy="${y}" r="${r}" fill="#e5484d" ${ring}/><path d="M${n(x - 2.4)} ${n(y - 2.4)}l4.8 4.8m0 -4.8l-4.8 4.8" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>`;
299
+ }
300
+ if (off) {
301
+ const zc = onDark ? '#c7cfdd' : OFF;
302
+ fig += `<g${cls(c, 'av-zzz')} fill="none" stroke="${zc}" stroke-width="${lod ? 1.8 : 2.4}" stroke-linecap="round" stroke-linejoin="round"><path d="M70 ${s.top - 4}h6l-6 7h6"/>${lod ? `<path d="M80 ${s.top - 12}h4.4l-4.4 5h4.4"/>` : ''}</g>`;
303
+ // A standby light: the body has power, but nothing is thinking.
304
+ if (lod >= 1) fig += `<circle cx="${d.x + d.w - 5}" cy="${n(d.y + d.h - 4.5)}" r="1.5" fill="#ff8a5c"/>`;
305
+ }
306
+
307
+ const frame = o.frame ?? 'circle';
308
+ const body = `<g>${fig}</g>`;
309
+ if (frame === 'none') return `${defs ? `<defs>${defs}</defs>` : ''}${body}`;
310
+ const bd = BACKDROPS[look.backdrop];
311
+ const shape = frame === 'square' ? '<rect width="100" height="100"/>' : '<circle cx="50" cy="50" r="50"/>';
312
+ const fill = frame === 'square' ? `<rect width="100" height="100" fill="${bd}"/>` : `<circle cx="50" cy="50" r="50" fill="${bd}"/>`;
313
+ // A light-cycle floor on the grid backdrop, faint enough to stay behind the Boton.
314
+ const floor = look.backdrop === 'grid' && lod >= 1
315
+ ? `<path d="M0 62H100M0 70H100M0 81H100M0 96H100${[10, 26, 42, 58, 74, 90].map((x) => `M${n(50 + (x - 50) * 0.25)} 62L${n(x * 1.6 - 30)} 100`).join('')}" stroke="${GLOWS[look.glow]}" stroke-opacity=".28" stroke-width=".8" fill="none"/>`
316
+ : '';
317
+ return `<defs><clipPath id="${uid}c">${shape}</clipPath>${defs}</defs><g clip-path="url(#${uid}c)">${fill}${floor}${body}</g>`;
318
+ }
319
+
320
+ /** A whole, still SVG document, for snapshots and files. */
321
+ export function botonSvg(look: BotonLook, o: BotonOptions = {}): string {
322
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${botonInner(look, { still: true, ...o })}</svg>`;
323
+ }
@@ -0,0 +1,107 @@
1
+ // The Botons marks, drawn as SVG strings: the brand Boton, the "botons" wordmark, the app icon and the favicon. They
2
+ // are fixed art (no user data reaches them), shared by the web app and scripts/icons.ts, and match the brand board in
3
+ // docs/brand. The wordmark is drawn with strokes, so it needs no font and looks the same everywhere.
4
+
5
+ /** The brand scale (docs/brand/board/tokens.css). */
6
+ export const BRAND = {
7
+ cyan: '#3ee6ff',
8
+ glass: '#0a0f1c',
9
+ shell: '#eef2f7',
10
+ shellLight: '#f2f5f9',
11
+ shellDeep: '#d2d6db',
12
+ skirt: '#7a7d82',
13
+ cheek: '#ff7eb6',
14
+ nightTop: '#16214a',
15
+ nightMid: '#0a0f1f',
16
+ void: '#05070d',
17
+ } as const;
18
+
19
+ const safeId = (uid: string) => uid.replace(/[^A-Za-z0-9_-]/g, '');
20
+
21
+ /** The view box of the brand Boton: a round button with a lit antenna (its glow included), a little taller than wide. */
22
+ export const MARK_VIEWBOX = '20 4 60 86';
23
+
24
+ /**
25
+ * The brand Boton's body, in a 100 × 100 frame: a snow-white round button with a little depth, a rim of light, a
26
+ * dark glass display with a lit face, and a lit antenna. `uid` keeps its gradient id unique on a page.
27
+ */
28
+ function botonBody(id: string): string {
29
+ const { cyan, glass, skirt } = BRAND;
30
+ return `<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${BRAND.shellLight}"/><stop offset=".55" stop-color="${BRAND.shell}"/><stop offset="1" stop-color="${BRAND.shellDeep}"/></linearGradient></defs>`
31
+ + `<path d="M50 27V13" stroke="${skirt}" stroke-width="2.6" stroke-linecap="round"/>`
32
+ + `<circle cx="50" cy="11" r="6" fill="${cyan}" opacity=".25"/><circle cx="50" cy="11" r="3.4" fill="${cyan}" stroke="${skirt}" stroke-width="1.2"/>`
33
+ + `<circle cx="50" cy="59.5" r="29.5" fill="${skirt}"/>`
34
+ + `<circle cx="50" cy="55" r="29" fill="url(#${id})"/>`
35
+ + `<circle cx="50" cy="55" r="29" fill="none" stroke="${cyan}" stroke-width="1.3" stroke-opacity=".75"/>`
36
+ + '<path d="M32 40.5Q42.8 30.1 52.9 28.9" stroke="#fff" stroke-opacity=".45" stroke-width="2.2" fill="none" stroke-linecap="round"/>'
37
+ + `<rect x="31" y="40" width="38" height="25" rx="11" fill="${glass}" stroke="${cyan}" stroke-opacity=".5" stroke-width="1.1"/>`
38
+ + '<path d="M35 43.5H47" stroke="#fff" stroke-opacity=".14" stroke-width="1.6" stroke-linecap="round"/>'
39
+ + `<g fill="${BRAND.cheek}" opacity=".55"><rect x="33.6" y="58" width="4.6" height="2.2" rx="1.1"/><rect x="61.8" y="58" width="4.6" height="2.2" rx="1.1"/></g>`
40
+ + `<g fill="${cyan}"><circle cx="41.6" cy="53" r="5.6" opacity=".22"/><circle cx="41.6" cy="53" r="3.6"/><circle cx="58.4" cy="53" r="5.6" opacity=".22"/><circle cx="58.4" cy="53" r="3.6"/></g>`
41
+ + `<g stroke="${cyan}" fill="none" stroke-linecap="round"><path d="M44.6 57.5Q50 62.5 55.4 57.5" stroke-width="6" stroke-opacity=".22"/><path d="M44.6 57.5Q50 62.5 55.4 57.5" stroke-width="2.6"/></g>`;
42
+ }
43
+
44
+ /** The brand Boton's inner markup, in MARK_VIEWBOX. */
45
+ export function markInner(uid = 'bm'): string {
46
+ return botonBody(`${safeId(uid)}g`);
47
+ }
48
+
49
+ /** The view box of the wordmark's letters. */
50
+ export const WORDMARK_VIEWBOX = '0 0 246 52';
51
+
52
+ /** "botons" in a rounded monoline: round caps, a geometric b, o and n, a hooked t, a single-stroke s. */
53
+ export function wordmarkInner(color = 'currentColor', weight = 9): string {
54
+ return `<g stroke="${color}" stroke-width="${weight}" fill="none" stroke-linecap="round" stroke-linejoin="round">`
55
+ + '<path d="M8 6V46"/><circle cx="21.5" cy="32" r="13.5"/>'
56
+ + '<circle cx="64.5" cy="32" r="13.5"/>'
57
+ + '<path d="M102 6V36Q102 46 112 46"/><path d="M94 19H112"/>'
58
+ + '<circle cx="141.5" cy="32" r="13.5"/>'
59
+ + '<path d="M171 46V18.5"/><path d="M171 32A13.5 13.5 0 0 1 198 32V46"/>'
60
+ + '<path d="M236.5 21Q231.5 17.5 224.5 18Q216.5 19 217 25Q217.5 30 225.5 31.5Q235.5 33 235.5 39Q235 46 224.5 46Q217.5 46 214 42"/>'
61
+ + '</g>';
62
+ }
63
+
64
+ /** The logo: the brand Boton beside the wordmark. The wordmark takes `ink` (currentColor follows the theme). */
65
+ export function logoSvg({ ink = 'currentColor', uid = 'logo', height }: { ink?: string; uid?: string; height?: number } = {}): string {
66
+ const size = height ? ` height="${height}" width="${Math.round((height * 318) / 60)}"` : '';
67
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 318 60"${size} role="img" aria-label="Botons">`
68
+ + `<svg x="0" y="0" width="62" height="60" viewBox="${MARK_VIEWBOX}">${markInner(uid)}</svg>`
69
+ + `<g transform="translate(70 6)">${wordmarkInner(ink)}</g></svg>`;
70
+ }
71
+
72
+ /**
73
+ * The app icon in a 100 × 100 square: the brand Boton lit up over a light-cycle floor. The Boton stays inside the
74
+ * central circle a maskable icon keeps (radius 40), so any mask crops it well. `rounded` adds the corner radius for
75
+ * the SVG icon; the PNGs for home screens are full-bleed and the system masks them.
76
+ */
77
+ export function appIconInner(uid = 'ai', { rounded = true } = {}): string {
78
+ const id = safeId(uid);
79
+ const square = rounded ? '<rect width="100" height="100" rx="22"/>' : '<rect width="100" height="100"/>';
80
+ const rays = [-30, -6, 18, 42, 66, 90, 114, 138].map((x) => `M${Math.round((50 + (x - 50) * 0.18) * 100) / 100} 64L${x} 100`).join('');
81
+ return `<defs><linearGradient id="${id}bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${BRAND.nightTop}"/><stop offset=".62" stop-color="${BRAND.nightMid}"/><stop offset="1" stop-color="${BRAND.void}"/></linearGradient>`
82
+ + `<radialGradient id="${id}gl" cx=".5" cy=".64" r=".5"><stop offset="0" stop-color="${BRAND.cyan}" stop-opacity=".35"/><stop offset="1" stop-color="${BRAND.cyan}" stop-opacity="0"/></radialGradient>`
83
+ + `<clipPath id="${id}sq">${square}</clipPath></defs>`
84
+ + `<g clip-path="url(#${id}sq)"><rect width="100" height="100" fill="url(#${id}bg)"/>`
85
+ + `<path d="M0 64H100M0 69H100M0 76H100M0 86H100M0 99H100${rays}" stroke="${BRAND.cyan}" stroke-opacity=".32" stroke-width=".7" fill="none"/>`
86
+ + `<ellipse cx="50" cy="86" rx="36" ry="9" fill="url(#${id}gl)"/>`
87
+ + `<g transform="translate(14 13) scale(.72)">${botonBody(`${id}b`)}</g></g>`;
88
+ }
89
+
90
+ /** The favicon in a 32 × 32 square: a button, a dark display, two lit eyes and the antenna's light, so it holds at 16 px. */
91
+ export function faviconInner(): string {
92
+ const { cyan, glass } = BRAND;
93
+ return `<rect width="32" height="32" rx="8" fill="${glass}"/>`
94
+ + '<circle cx="16" cy="18.4" r="11.2" fill="#9aa6b8"/>'
95
+ + `<circle cx="16" cy="17" r="11.2" fill="${BRAND.shell}" stroke="${cyan}" stroke-width="1"/>`
96
+ + `<rect x="8.4" y="11.6" width="15.2" height="10" rx="5" fill="${glass}"/>`
97
+ + `<circle cx="12.6" cy="16.2" r="1.9" fill="${cyan}"/><circle cx="19.4" cy="16.2" r="1.9" fill="${cyan}"/>`
98
+ + `<circle cx="16" cy="3.4" r="2" fill="${cyan}"/>`;
99
+ }
100
+
101
+ /** Whole SVG documents for the files in web/public (scripts/icons.ts writes them). */
102
+ export function faviconSvg(): string {
103
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">${faviconInner()}</svg>\n`;
104
+ }
105
+ export function appIconSvg({ rounded = true } = {}): string {
106
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${appIconInner('i', { rounded })}</svg>\n`;
107
+ }