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,189 @@
1
+ import { hash32, pick, seeded, weighted } from './seeded.ts';
2
+
3
+ export type CritterBody = 'bun' | 'loaf' | 'round' | 'pebble';
4
+ export type CritterEars = 'point' | 'round' | 'long' | 'floppy' | 'feelers';
5
+ export type CritterEyes = 'dot' | 'bean' | 'arc' | 'sleepy';
6
+ export type CritterMouth = 'smile' | 'open' | 'o' | 'w';
7
+ export type CritterMarks = 'none' | 'belly' | 'spots' | 'cap';
8
+ export type CritterColor = 'coral' | 'sage' | 'sky' | 'lilac' | 'peach' | 'mint' | 'rose' | 'periwinkle' | 'sand' | 'seafoam';
9
+ export type CritterBackdrop = 'navy' | 'plum' | 'forest' | 'wine' | 'slate' | 'cocoa' | 'indigo' | 'teal';
10
+ export interface CritterLook {
11
+ body: CritterBody;
12
+ ears: CritterEars;
13
+ eyes: CritterEyes;
14
+ mouth: CritterMouth;
15
+ marks: CritterMarks;
16
+ cheeks: boolean;
17
+ color: CritterColor;
18
+ backdrop: CritterBackdrop;
19
+ }
20
+
21
+ // The looks a database before schema version 32 holds: a Critter (version 25 on) and the capsule before it (24).
22
+ // They are read, never drawn: shared/look-tater.ts maps each one to its closest tater look, and shared/look.ts that to
23
+ // its closest Boton, in the schema upgrades and on import.
24
+ // This file imports only Node-free modules: the schema upgrade (server/src/schema.ts) and the CLI-free scripts load it.
25
+
26
+ export const BODIES: readonly CritterBody[] = ['bun', 'loaf', 'round', 'pebble'];
27
+ export const EARS: readonly CritterEars[] = ['point', 'round', 'long', 'floppy', 'feelers'];
28
+ export const EYES: readonly CritterEyes[] = ['dot', 'bean', 'arc', 'sleepy'];
29
+ export const MOUTHS: readonly CritterMouth[] = ['smile', 'open', 'o', 'w'];
30
+ export const MARKS: readonly CritterMarks[] = ['none', 'belly', 'spots', 'cap'];
31
+
32
+ /** Mid-light body colors, none in the saturated yellow band; each keeps 3:1 against every backdrop and 4.5:1 under the face ink. */
33
+ export const COLORS: Readonly<Record<CritterColor, string>> = {
34
+ coral: '#ff8c6e',
35
+ sage: '#a9d39f',
36
+ sky: '#8ec6ff',
37
+ lilac: '#c5a9ff',
38
+ peach: '#ffb48d',
39
+ mint: '#8fe0bf',
40
+ rose: '#ff9dbd',
41
+ periwinkle: '#a1aefc',
42
+ sand: '#e6c7a1',
43
+ seafoam: '#94dcd8',
44
+ };
45
+
46
+ /** Deep night backdrops behind the body. */
47
+ export const BACKDROPS: Readonly<Record<CritterBackdrop, string>> = {
48
+ navy: '#1d2b52',
49
+ plum: '#35214b',
50
+ forest: '#1b3b31',
51
+ wine: '#4b1f30',
52
+ slate: '#253241',
53
+ cocoa: '#3b2a20',
54
+ indigo: '#272a68',
55
+ teal: '#123a40',
56
+ };
57
+
58
+ export const COLOR_NAMES = Object.keys(COLORS) as CritterColor[];
59
+ export const BACKDROP_NAMES = Object.keys(BACKDROPS) as CritterBackdrop[];
60
+
61
+ /**
62
+ * Body colors that never get spots: a blue spotted creature reads as a well-known character. The guard turns
63
+ * spots on them into a belly patch, and every look goes through it: random, migrated, edited and sent to the API.
64
+ */
65
+ export const NO_SPOTS: readonly CritterColor[] = ['sky', 'periwinkle'];
66
+
67
+ /** The look with the guardrails applied (a new object; the input is left alone). */
68
+ export function guardCritter(look: CritterLook): CritterLook {
69
+ return NO_SPOTS.includes(look.color) && look.marks === 'spots' ? { ...look, marks: 'belly' } : { ...look };
70
+ }
71
+
72
+ const EYE_WEIGHTS = [['dot', 4], ['bean', 3], ['arc', 2], ['sleepy', 1]] as const;
73
+ const MARK_WEIGHTS = [['none', 3], ['belly', 2], ['spots', 2], ['cap', 2]] as const;
74
+
75
+ const oneOf = <T extends string>(xs: readonly T[], v: unknown): v is T => typeof v === 'string' && (xs as readonly string[]).includes(v);
76
+
77
+ /** A stored Critter, guarded; null for anything else. Unknown extra fields are dropped. */
78
+ export function parseCritter(v: unknown): CritterLook | null {
79
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
80
+ const o = v as Record<string, unknown>;
81
+ if (
82
+ !oneOf(BODIES, o.body) ||
83
+ !oneOf(EARS, o.ears) ||
84
+ !oneOf(EYES, o.eyes) ||
85
+ !oneOf(MOUTHS, o.mouth) ||
86
+ !oneOf(MARKS, o.marks) ||
87
+ typeof o.cheeks !== 'boolean' ||
88
+ !oneOf(COLOR_NAMES, o.color) ||
89
+ !oneOf(BACKDROP_NAMES, o.backdrop)
90
+ ) {
91
+ return null;
92
+ }
93
+ return guardCritter({ body: o.body, ears: o.ears, eyes: o.eyes, mouth: o.mouth, marks: o.marks, cheeks: o.cheeks, color: o.color, backdrop: o.backdrop });
94
+ }
95
+
96
+ // ------------------------------------------------------------------ the look a version-24 database holds
97
+ // { body: '#rrggbb', bg: '#rrggbb', eyes: 1 | 2, hair, mouth }: a colored capsule on a colored disc. It maps to
98
+ // the closest Critter, field by field, so a Boton keeps what made it recognizable: its color family, its
99
+ // backdrop's hue, what it wore on top (as ears) and its mouth. What has no counterpart (body shape, markings,
100
+ // cheeks) is seeded from the Boton's id, so the result is the same every time and on every machine.
101
+
102
+ /** Whether `v` has the version-24 shape: a capsule look. Fields need not be valid; each falls back on its own. */
103
+ export function isLook24(v: unknown): v is Record<string, unknown> {
104
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
105
+ const o = v as Record<string, unknown>;
106
+ return 'hair' in o || 'bg' in o || (typeof o.body === 'string' && o.body.startsWith('#'));
107
+ }
108
+
109
+ const HEX = /^#[0-9a-fA-F]{6}$/;
110
+ const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
111
+
112
+ /** [hue in degrees, saturation 0–1, lightness 0–1]. */
113
+ export function hsl(h: string): [number, number, number] {
114
+ const [r, g, b] = rgb(h).map((v) => v / 255);
115
+ const mx = Math.max(r, g, b);
116
+ const mn = Math.min(r, g, b);
117
+ const d = mx - mn;
118
+ const l = (mx + mn) / 2;
119
+ let hue = 0;
120
+ if (d) {
121
+ hue = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
122
+ hue *= 60;
123
+ if (hue < 0) hue += 360;
124
+ }
125
+ return [hue, d ? d / (1 - Math.abs(2 * l - 1)) : 0, l];
126
+ }
127
+
128
+ /** The saturated yellow band no body color may sit in. */
129
+ export function isYellow(h: string): boolean {
130
+ const [H, S, L] = hsl(h);
131
+ return H >= 40 && H <= 66 && S >= 0.45 && L >= 0.3 && L <= 0.92;
132
+ }
133
+
134
+ const hueDist = (a: number, b: number) => {
135
+ const d = Math.abs(a - b) % 360;
136
+ return d > 180 ? 360 - d : d;
137
+ };
138
+
139
+ /** The palette entry closest to `hex`: hue distance weighted by saturation, plus lightness. Ties go to the first. */
140
+ function nearest<K extends string>(hex: string, palette: Readonly<Record<K, string>>): K {
141
+ const [H, S, L] = hsl(hex);
142
+ let best = Object.keys(palette)[0] as K;
143
+ let score = Infinity;
144
+ for (const [k, v] of Object.entries(palette) as [K, string][]) {
145
+ const [h2, s2, l2] = hsl(v);
146
+ const sc = hueDist(H, h2) * Math.min(S, s2) + Math.abs(L - l2) * 90;
147
+ if (sc < score) {
148
+ score = sc;
149
+ best = k;
150
+ }
151
+ }
152
+ return best;
153
+ }
154
+
155
+ // The swatches the capsule editor offered, mapped by hand; any other color goes to the nearest one.
156
+ const BODY_24: Record<string, CritterColor> = { '#9b5de5': 'lilac', '#7bd389': 'sage', '#5fb3f9': 'sky', '#ff8fab': 'rose', '#ff9f43': 'peach' };
157
+ /** Yellow has no Critter counterpart: a seeded pick of warm colors instead. */
158
+ const FOR_YELLOW: readonly CritterColor[] = ['coral', 'peach', 'sand', 'mint'];
159
+ const BG_24: Record<string, CritterBackdrop> = {
160
+ '#2f6fed': 'navy',
161
+ '#1fb57a': 'forest',
162
+ '#ef6c2f': 'cocoa',
163
+ '#e0457b': 'wine',
164
+ '#8b5cf6': 'indigo',
165
+ '#14b8c4': 'teal',
166
+ '#f2b21b': 'cocoa',
167
+ '#5b6170': 'slate',
168
+ };
169
+ const HAIR_24: Record<string, CritterEars> = { none: 'round', sprout: 'feelers', spiky: 'point', combed: 'floppy', antenna: 'long' };
170
+ const MOUTH_24: Record<string, CritterMouth> = { smile: 'smile', grin: 'open', o: 'o', flat: 'w' };
171
+
172
+ /**
173
+ * The closest Critter to a version-24 look, seeded by `seed` (the Boton's id, or a template's). The draws are
174
+ * those of the approved design preview, in its order, so a look migrates to what that preview showed for it.
175
+ */
176
+ export function critterFromLook24(old: Record<string, unknown>, seed: string): CritterLook {
177
+ const r = seeded(hash32(seed));
178
+ const body = pick(r, BODIES);
179
+ const ears = (typeof old.hair === 'string' && Object.hasOwn(HAIR_24, old.hair) && HAIR_24[old.hair]) || 'round';
180
+ const eyes: CritterEyes = old.eyes === 1 ? 'arc' : pick(r, ['dot', 'bean'] as const);
181
+ const mouth = (typeof old.mouth === 'string' && Object.hasOwn(MOUTH_24, old.mouth) && MOUTH_24[old.mouth]) || 'smile';
182
+ const marks = weighted(r, MARK_WEIGHTS);
183
+ const cheeks = r() < 0.5;
184
+ const hexBody = typeof old.body === 'string' && HEX.test(old.body) ? old.body.toLowerCase() : null;
185
+ const color = hexBody ? BODY_24[hexBody] || (isYellow(hexBody) ? pick(r, FOR_YELLOW) : nearest(hexBody, COLORS)) : pick(r, COLOR_NAMES);
186
+ const hexBg = typeof old.bg === 'string' && HEX.test(old.bg) ? old.bg.toLowerCase() : null;
187
+ const backdrop = hexBg ? BG_24[hexBg] || nearest(hexBg, BACKDROPS) : pick(r, BACKDROP_NAMES);
188
+ return guardCritter({ body, ears, eyes, mouth, marks, cheeks, color, backdrop });
189
+ }
@@ -0,0 +1,127 @@
1
+ import { type CritterLook, critterFromLook24, isLook24, parseCritter } from './look-critter.ts';
2
+ import { hash32, pick, type Rand, seeded, weighted } from './seeded.ts';
3
+
4
+ // Tater looks: the looks a database at schema version 32 holds, a tater Boton built from fixed parts. They are read,
5
+ // never drawn: shared/look.ts maps each one to its closest Boton, in the schema upgrade and on import. The 31-to-32
6
+ // upgrade turns Critters and capsules into tater looks with taterLookFor, exactly as it always has, and the next
7
+ // upgrade takes them on to Botons. This file imports only Node-free modules: the schema upgrade
8
+ // (server/src/schema.ts) and the CLI-free scripts load it.
9
+
10
+ export type TaterShape = 'classic' | 'tall' | 'chunky';
11
+ export type TaterToast = 'raw' | 'golden' | 'toasty' | 'crispy' | 'sweet' | 'purple' | 'veggie';
12
+ export type TaterEyes = 'dots' | 'buttons' | 'ovals' | 'happy' | 'wink' | 'lidded';
13
+ export type TaterBrows = 'none' | 'soft' | 'straight' | 'arched' | 'bold';
14
+ export type TaterMouth = 'smile' | 'grin' | 'smirk' | 'o' | 'calm' | 'w';
15
+ export type TaterHat = 'none' | 'sprout' | 'beanie' | 'cap' | 'headphones' | 'bucket' | 'beret' | 'party';
16
+ export type TaterGlasses = 'none' | 'round' | 'square' | 'shades';
17
+ export type TaterExtra = 'none' | 'scarf' | 'bowtie' | 'pin' | 'lanyard';
18
+ export type TaterArms = 'none' | 'nubs' | 'wave' | 'cheer';
19
+ export type TaterWear = 'tomato' | 'sage' | 'sky' | 'plum' | 'ink' | 'cream';
20
+ export type TaterBackdrop = 'cream' | 'butter' | 'sage' | 'sky' | 'blush' | 'lilac' | 'cocoa' | 'night';
21
+
22
+ export interface TaterLook {
23
+ shape: TaterShape;
24
+ toast: TaterToast;
25
+ eyes: TaterEyes;
26
+ brows: TaterBrows;
27
+ mouth: TaterMouth;
28
+ hat: TaterHat;
29
+ glasses: TaterGlasses;
30
+ extra: TaterExtra;
31
+ arms: TaterArms;
32
+ wear: TaterWear;
33
+ backdrop: TaterBackdrop;
34
+ cheeks: boolean;
35
+ }
36
+
37
+ export const TATER_PARTS = {
38
+ shape: ['classic', 'tall', 'chunky'],
39
+ toast: ['raw', 'golden', 'toasty', 'crispy', 'sweet', 'purple', 'veggie'],
40
+ eyes: ['dots', 'buttons', 'ovals', 'happy', 'wink', 'lidded'],
41
+ brows: ['none', 'soft', 'straight', 'arched', 'bold'],
42
+ mouth: ['smile', 'grin', 'smirk', 'o', 'calm', 'w'],
43
+ hat: ['none', 'sprout', 'beanie', 'cap', 'headphones', 'bucket', 'beret', 'party'],
44
+ glasses: ['none', 'round', 'square', 'shades'],
45
+ extra: ['none', 'scarf', 'bowtie', 'pin', 'lanyard'],
46
+ arms: ['none', 'nubs', 'wave', 'cheer'],
47
+ wear: ['tomato', 'sage', 'sky', 'plum', 'ink', 'cream'],
48
+ backdrop: ['cream', 'butter', 'sage', 'sky', 'blush', 'lilac', 'cocoa', 'night'],
49
+ } as const satisfies { [K in keyof Omit<TaterLook, 'cheeks'>]: readonly TaterLook[K][] };
50
+
51
+ const oneOf = (xs: readonly string[], v: unknown): boolean => typeof v === 'string' && xs.includes(v);
52
+
53
+ /** A tater look as a database or a file holds it; null for anything else. Unknown extra fields are dropped. */
54
+ export function parseTater(v: unknown): TaterLook | null {
55
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
56
+ const o = v as Record<string, unknown>;
57
+ for (const [part, values] of Object.entries(TATER_PARTS)) if (!oneOf(values, o[part])) return null;
58
+ if (typeof o.cheeks !== 'boolean') return null;
59
+ return Object.fromEntries([...Object.keys(TATER_PARTS), 'cheeks'].map((k) => [k, o[k]])) as unknown as TaterLook;
60
+ }
61
+
62
+ // A random tater look, for a stored look that couldn't be read: the 31-to-32 upgrade seeds one from the id.
63
+ const TOAST_W = [['golden', 5], ['toasty', 3], ['raw', 2], ['crispy', 2], ['sweet', 2], ['purple', 1], ['veggie', 1]] as const;
64
+ const EYES_W = [['dots', 5], ['buttons', 3], ['ovals', 3], ['happy', 2], ['wink', 1], ['lidded', 1]] as const;
65
+ const BROWS_W = [['none', 5], ['soft', 2], ['straight', 1], ['arched', 1], ['bold', 1]] as const;
66
+ const MOUTH_W = [['smile', 5], ['grin', 2], ['calm', 2], ['smirk', 1], ['o', 1], ['w', 1]] as const;
67
+ const HAT_W = [['none', 6], ['sprout', 1], ['beanie', 1], ['cap', 1], ['headphones', 1], ['bucket', 1], ['beret', 1], ['party', 1]] as const;
68
+ const GLASSES_W = [['none', 8], ['round', 1], ['square', 1], ['shades', 1]] as const;
69
+ const EXTRA_W = [['none', 7], ['scarf', 1], ['bowtie', 1], ['pin', 1], ['lanyard', 1]] as const;
70
+ const ARMS_W = [['nubs', 6], ['none', 2], ['wave', 1], ['cheer', 1]] as const;
71
+
72
+ export function randomTater(r: Rand): TaterLook {
73
+ return {
74
+ shape: weighted(r, [['classic', 3], ['tall', 2], ['chunky', 2]] as const),
75
+ toast: weighted(r, TOAST_W),
76
+ eyes: weighted(r, EYES_W),
77
+ brows: weighted(r, BROWS_W),
78
+ mouth: weighted(r, MOUTH_W),
79
+ hat: weighted(r, HAT_W),
80
+ glasses: weighted(r, GLASSES_W),
81
+ extra: weighted(r, EXTRA_W),
82
+ arms: weighted(r, ARMS_W),
83
+ wear: pick(r, TATER_PARTS.wear),
84
+ backdrop: pick(r, TATER_PARTS.backdrop),
85
+ cheeks: r() < 0.6,
86
+ };
87
+ }
88
+
89
+ // A Critter maps to its closest tater look field by field, with no randomness.
90
+ const SHAPE_C: Record<CritterLook['body'], TaterShape> = { bun: 'classic', loaf: 'tall', round: 'chunky', pebble: 'classic' };
91
+ const TOAST_C: Record<CritterLook['color'], TaterToast> = { coral: 'sweet', peach: 'toasty', rose: 'crispy', sand: 'raw', sage: 'veggie', mint: 'veggie', seafoam: 'golden', sky: 'golden', lilac: 'purple', periwinkle: 'purple' };
92
+ const WEAR_C: Record<CritterLook['color'], TaterWear> = { coral: 'tomato', peach: 'tomato', rose: 'plum', sand: 'cream', sage: 'sage', mint: 'sage', seafoam: 'sky', sky: 'sky', lilac: 'plum', periwinkle: 'sky' };
93
+ const BACKDROP_C: Record<CritterLook['backdrop'], TaterBackdrop> = { navy: 'sky', plum: 'lilac', forest: 'sage', wine: 'blush', slate: 'night', cocoa: 'cocoa', indigo: 'lilac', teal: 'sage' };
94
+ const HAT_C: Record<CritterLook['ears'], TaterHat> = { point: 'cap', round: 'none', long: 'headphones', floppy: 'beanie', feelers: 'sprout' };
95
+ const EYES_C: Record<CritterLook['eyes'], TaterEyes> = { dot: 'dots', bean: 'ovals', arc: 'happy', sleepy: 'lidded' };
96
+ const MOUTH_C: Record<CritterLook['mouth'], TaterMouth> = { smile: 'smile', open: 'grin', o: 'o', w: 'w' };
97
+ const EXTRA_C: Record<CritterLook['marks'], TaterExtra> = { none: 'none', belly: 'scarf', spots: 'none', cap: 'pin' };
98
+
99
+ /** The closest tater look to a Critter. */
100
+ export function taterFromCritter(c: CritterLook): TaterLook {
101
+ const hat = HAT_C[c.ears] === 'none' && c.marks === 'cap' ? 'beret' : HAT_C[c.ears];
102
+ return {
103
+ shape: SHAPE_C[c.body],
104
+ toast: TOAST_C[c.color],
105
+ eyes: EYES_C[c.eyes],
106
+ brows: c.marks === 'spots' ? 'soft' : 'none',
107
+ mouth: MOUTH_C[c.mouth],
108
+ hat,
109
+ glasses: 'none',
110
+ extra: EXTRA_C[c.marks],
111
+ arms: 'nubs',
112
+ wear: WEAR_C[c.color],
113
+ backdrop: BACKDROP_C[c.backdrop],
114
+ cheeks: c.cheeks,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Any look a database at version 31 holds, as a tater look, never failing: a tater look as it is, a Critter mapped to
120
+ * its closest, a capsule through its closest Critter, and anything else one seeded from `seed`.
121
+ */
122
+ export function taterLookFor(v: unknown, seed: string): TaterLook {
123
+ const tater = parseTater(v);
124
+ if (tater) return tater;
125
+ const critter = parseCritter(v) ?? (isLook24(v) ? critterFromLook24(v, seed) : null);
126
+ return critter ? taterFromCritter(critter) : randomTater(seeded(hash32(`look:${seed}`)));
127
+ }
@@ -0,0 +1,200 @@
1
+ import type { BotonArms, BotonBackdrop, BotonBrows, BotonEars, BotonEyes, BotonGlow, BotonLook, BotonMouth, BotonPanel, BotonShape, BotonShell, BotonTop, BotonVisor } from './types.ts';
2
+ import { critterFromLook24, isLook24, parseCritter } from './look-critter.ts';
3
+ import { parseTater, type TaterLook, taterFromCritter } from './look-tater.ts';
4
+ import { hash32, pick, type Rand, seeded, weighted } from './seeded.ts';
5
+
6
+ // A Boton's look: a button-bot built from fixed parts. Every field is one of a fixed set of names, so a stored look
7
+ // can never carry markup or an arbitrary color into the renderer (shared/boton.ts).
8
+ // This file imports only Node-free modules: the schema upgrade (server/src/schema.ts) and the CLI-free scripts load it.
9
+
10
+ export { hash32, seeded };
11
+
12
+ export const SHAPES: readonly BotonShape[] = ['round', 'pill', 'key'];
13
+ export const EYES: readonly BotonEyes[] = ['dots', 'rings', 'bars', 'happy', 'wink', 'lidded'];
14
+ export const BROWS: readonly BotonBrows[] = ['none', 'soft', 'straight', 'arched', 'bold'];
15
+ export const MOUTHS: readonly BotonMouth[] = ['smile', 'grin', 'smirk', 'o', 'calm', 'wave'];
16
+ export const TOPS: readonly BotonTop[] = ['none', 'antenna', 'twin', 'dish', 'coil', 'fin', 'beacon', 'halo'];
17
+ export const EARS: readonly BotonEars[] = ['none', 'bolts', 'pods', 'fins', 'knobs'];
18
+ export const VISORS: readonly BotonVisor[] = ['none', 'goggles', 'monocle', 'band'];
19
+ export const PANELS: readonly BotonPanel[] = ['none', 'holes', 'grill', 'leds', 'dial', 'switch'];
20
+ export const ARMS: readonly BotonArms[] = ['none', 'nubs', 'wave', 'cheer'];
21
+
22
+ /** Shell colors. The display carries the face, so a shell only has to stand out from its backdrop. */
23
+ export const SHELLS: Readonly<Record<BotonShell, string>> = {
24
+ snow: '#eef2f7',
25
+ steel: '#a9b4c6',
26
+ graphite: '#323a4c',
27
+ sun: '#f4c53d',
28
+ signal: '#f0624d',
29
+ cobalt: '#3f63e0',
30
+ violet: '#8457e6',
31
+ teal: '#17a99d',
32
+ };
33
+ /** The light of the face and the rim. Every one is 7:1 or better on the display glass. */
34
+ export const GLOWS: Readonly<Record<BotonGlow, string>> = { cyan: '#3ee6ff', pink: '#ff6ad5', lime: '#a8ff5c', amber: '#ffbe45', violet: '#b49bff', ice: '#e8f6ff' };
35
+ /** The disc behind the Boton. Its skirt or its rim light keeps every shell at 3:1 or better against it. */
36
+ export const BACKDROPS: Readonly<Record<BotonBackdrop, string>> = {
37
+ mist: '#e7ecf3',
38
+ ice: '#d4f2fa',
39
+ mint: '#d3f4e2',
40
+ lilac: '#e6defa',
41
+ blush: '#fbdde8',
42
+ night: '#121b36',
43
+ plum: '#2a1842',
44
+ grid: '#090d18',
45
+ };
46
+ /** Backdrops dark enough that sleep marks are drawn light. */
47
+ export const DARK_BACKDROPS: readonly BotonBackdrop[] = ['night', 'plum', 'grid'];
48
+
49
+ export const SHELL_NAMES = Object.keys(SHELLS) as BotonShell[];
50
+ export const GLOW_NAMES = Object.keys(GLOWS) as BotonGlow[];
51
+ export const BACKDROP_NAMES = Object.keys(BACKDROPS) as BotonBackdrop[];
52
+
53
+ /** Every part and its choices, in the builder's order. */
54
+ export const PARTS = {
55
+ shape: SHAPES,
56
+ shell: SHELL_NAMES,
57
+ glow: GLOW_NAMES,
58
+ eyes: EYES,
59
+ brows: BROWS,
60
+ mouth: MOUTHS,
61
+ top: TOPS,
62
+ ears: EARS,
63
+ visor: VISORS,
64
+ panel: PANELS,
65
+ arms: ARMS,
66
+ backdrop: BACKDROP_NAMES,
67
+ } as const;
68
+ export type Part = keyof typeof PARTS;
69
+
70
+ /** The look a new Boton starts from before anything is chosen: the brand Boton. */
71
+ export const DEFAULT_LOOK: BotonLook = { shape: 'round', shell: 'snow', glow: 'cyan', eyes: 'dots', brows: 'none', mouth: 'smile', top: 'antenna', ears: 'none', visor: 'none', panel: 'none', arms: 'nubs', backdrop: 'night', cheeks: true };
72
+
73
+ // Weights for a random look: a friendly face on any shell, and only now and then ears, a visor or a panel, so a
74
+ // crowd of Botons reads as a family and each still has something of its own.
75
+ const EYES_W = [['dots', 5], ['rings', 3], ['bars', 3], ['happy', 2], ['wink', 1], ['lidded', 1]] as const;
76
+ const BROWS_W = [['none', 5], ['soft', 2], ['straight', 1], ['arched', 1], ['bold', 1]] as const;
77
+ const MOUTH_W = [['smile', 5], ['grin', 2], ['calm', 2], ['smirk', 1], ['o', 1], ['wave', 1]] as const;
78
+ const TOP_W = [['antenna', 4], ['none', 3], ['twin', 1], ['dish', 1], ['coil', 1], ['fin', 1], ['beacon', 1], ['halo', 1]] as const;
79
+ const EARS_W = [['none', 6], ['bolts', 1], ['pods', 1], ['fins', 1], ['knobs', 1]] as const;
80
+ const VISOR_W = [['none', 8], ['goggles', 1], ['monocle', 1], ['band', 1]] as const;
81
+ const PANEL_W = [['none', 6], ['holes', 2], ['grill', 1], ['leds', 1], ['dial', 1], ['switch', 1]] as const;
82
+ const ARMS_W = [['nubs', 6], ['none', 2], ['wave', 1], ['cheer', 1]] as const;
83
+
84
+ /** A random look; `r` may be seeded for a stable one. */
85
+ export function randomLook(r: Rand = Math.random): BotonLook {
86
+ return {
87
+ shape: weighted(r, [['round', 3], ['pill', 2], ['key', 2]] as const),
88
+ shell: pick(r, SHELL_NAMES),
89
+ glow: pick(r, GLOW_NAMES),
90
+ eyes: weighted(r, EYES_W),
91
+ brows: weighted(r, BROWS_W),
92
+ mouth: weighted(r, MOUTH_W),
93
+ top: weighted(r, TOP_W),
94
+ ears: weighted(r, EARS_W),
95
+ visor: weighted(r, VISOR_W),
96
+ panel: weighted(r, PANEL_W),
97
+ arms: weighted(r, ARMS_W),
98
+ backdrop: pick(r, BACKDROP_NAMES),
99
+ cheeks: r() < 0.6,
100
+ };
101
+ }
102
+
103
+ const oneOf = <T extends string>(xs: readonly T[], v: unknown): v is T => typeof v === 'string' && (xs as readonly string[]).includes(v);
104
+
105
+ /** A look as the API and the database hold it; null for anything else. Unknown extra fields are dropped. */
106
+ export function parseLook(v: unknown): BotonLook | null {
107
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
108
+ const o = v as Record<string, unknown>;
109
+ for (const [part, values] of Object.entries(PARTS)) if (!oneOf(values as readonly string[], o[part])) return null;
110
+ if (typeof o.cheeks !== 'boolean') return null;
111
+ const look = Object.fromEntries([...Object.keys(PARTS), 'cheeks'].map((k) => [k, o[k]]));
112
+ return look as unknown as BotonLook;
113
+ }
114
+
115
+ // ------------------------------------------------------------------ tater looks, which a database at 32 holds
116
+ // A tater look maps to its closest Boton field by field, with no randomness: its shape becomes a button, its toast a
117
+ // shell, its wear color the glow, what it wore on top an antenna or more (headphones become pod ears), its glasses a
118
+ // visor and its extras a panel; its face, arms and cheeks carry over, and the backdrop keeps its light or dark.
119
+
120
+ const SHAPE_T: Record<TaterLook['shape'], BotonShape> = { classic: 'round', tall: 'key', chunky: 'pill' };
121
+ const SHELL_T: Record<TaterLook['toast'], BotonShell> = { raw: 'snow', golden: 'sun', toasty: 'steel', crispy: 'graphite', sweet: 'signal', purple: 'violet', veggie: 'teal' };
122
+ const GLOW_T: Record<TaterLook['wear'], BotonGlow> = { tomato: 'pink', sage: 'lime', sky: 'cyan', plum: 'violet', ink: 'ice', cream: 'amber' };
123
+ const EYES_T: Record<TaterLook['eyes'], BotonEyes> = { dots: 'dots', buttons: 'rings', ovals: 'bars', happy: 'happy', wink: 'wink', lidded: 'lidded' };
124
+ const MOUTH_T: Record<TaterLook['mouth'], BotonMouth> = { smile: 'smile', grin: 'grin', smirk: 'smirk', o: 'o', calm: 'calm', w: 'wave' };
125
+ const TOP_T: Record<TaterLook['hat'], BotonTop> = { none: 'none', sprout: 'antenna', beanie: 'beacon', cap: 'fin', headphones: 'none', bucket: 'dish', beret: 'halo', party: 'twin' };
126
+ const VISOR_T: Record<TaterLook['glasses'], BotonVisor> = { none: 'none', round: 'goggles', square: 'monocle', shades: 'band' };
127
+ const PANEL_T: Record<TaterLook['extra'], BotonPanel> = { none: 'none', scarf: 'grill', bowtie: 'switch', pin: 'dial', lanyard: 'leds' };
128
+ const BACKDROP_T: Record<TaterLook['backdrop'], BotonBackdrop> = { cream: 'mist', butter: 'mist', sage: 'mint', sky: 'ice', blush: 'blush', lilac: 'lilac', cocoa: 'plum', night: 'night' };
129
+
130
+ /** The closest Boton to a tater look. */
131
+ export function lookFromTater(t: TaterLook): BotonLook {
132
+ return {
133
+ shape: SHAPE_T[t.shape],
134
+ shell: SHELL_T[t.toast],
135
+ glow: GLOW_T[t.wear],
136
+ eyes: EYES_T[t.eyes],
137
+ brows: t.brows,
138
+ mouth: MOUTH_T[t.mouth],
139
+ top: TOP_T[t.hat],
140
+ ears: t.hat === 'headphones' ? 'pods' : 'none',
141
+ visor: VISOR_T[t.glasses],
142
+ panel: PANEL_T[t.extra],
143
+ arms: t.arms,
144
+ backdrop: BACKDROP_T[t.backdrop],
145
+ cheeks: t.cheeks,
146
+ };
147
+ }
148
+
149
+ /** Whether a stored value is a look from before Botons (a tater look, a Critter or a capsule), which lookFor maps. */
150
+ export function isEarlierLook(v: unknown): boolean {
151
+ return parseLook(v) === null && (parseTater(v) !== null || parseCritter(v) !== null || isLook24(v));
152
+ }
153
+
154
+ /**
155
+ * Any stored look as a Boton, never failing: a Boton look as it is, a tater look mapped to its closest Boton, a
156
+ * Critter (or a capsule, through its closest Critter) through its closest tater look, and anything else (missing,
157
+ * unreadable) a Boton seeded from `seed`.
158
+ */
159
+ export function lookFor(v: unknown, seed: string): BotonLook {
160
+ const look = parseLook(v);
161
+ if (look) return look;
162
+ const tater = parseTater(v);
163
+ if (tater) return lookFromTater(tater);
164
+ const critter = parseCritter(v) ?? (isLook24(v) ? critterFromLook24(v, seed) : null);
165
+ return critter ? lookFromTater(taterFromCritter(critter)) : randomLook(seeded(hash32(`look:${seed}`)));
166
+ }
167
+
168
+ const LABELS = {
169
+ shape: { round: 'Round', pill: 'Pill', key: 'Key' },
170
+ shell: { snow: 'Snow', steel: 'Steel', graphite: 'Graphite', sun: 'Sun', signal: 'Signal', cobalt: 'Cobalt', violet: 'Violet', teal: 'Teal' },
171
+ glow: { cyan: 'Cyan', pink: 'Pink', lime: 'Lime', amber: 'Amber', violet: 'Violet', ice: 'Ice' },
172
+ eyes: { dots: 'Dots', rings: 'Rings', bars: 'Bars', happy: 'Happy', wink: 'Wink', lidded: 'Lidded' },
173
+ brows: { none: 'None', soft: 'Soft', straight: 'Straight', arched: 'Arched', bold: 'Bold' },
174
+ mouth: { smile: 'Smile', grin: 'Grin', smirk: 'Smirk', o: 'Oh', calm: 'Calm', wave: 'Wave' },
175
+ top: { none: 'None', antenna: 'Antenna', twin: 'Twin', dish: 'Dish', coil: 'Coil', fin: 'Fin', beacon: 'Beacon', halo: 'Halo' },
176
+ ears: { none: 'None', bolts: 'Bolts', pods: 'Pods', fins: 'Fins', knobs: 'Knobs' },
177
+ visor: { none: 'None', goggles: 'Goggles', monocle: 'Monocle', band: 'Band' },
178
+ panel: { none: 'None', holes: 'Button holes', grill: 'Grill', leds: 'Lights', dial: 'Dial', switch: 'Switch' },
179
+ arms: { none: 'None', nubs: 'Nubs', wave: 'Wave', cheer: 'Cheer' },
180
+ backdrop: { mist: 'Mist', ice: 'Ice', mint: 'Mint', lilac: 'Lilac', blush: 'Blush', night: 'Night', plum: 'Plum', grid: 'Grid' },
181
+ } as const satisfies { [K in Part]: Record<BotonLook[K], string> };
182
+
183
+ /** The builder's names for each choice. */
184
+ export const LOOK_LABELS: typeof LABELS = LABELS;
185
+
186
+ /** A short description of a look, for a screen reader: "Snow round Boton with a cyan glow, an antenna and goggles, on night". */
187
+ export function describeLook(l: BotonLook): string {
188
+ const lower = (s: string) => s.toLowerCase();
189
+ const article = (s: string) => (/^[aeiou]/i.test(s) ? 'an' : 'a');
190
+ const named = (s: string) => `${article(s)} ${lower(s)}`;
191
+ const wearing = [
192
+ `${named(LABELS.glow[l.glow])} glow`,
193
+ l.top !== 'none' ? named(LABELS.top[l.top] + (l.top === 'twin' ? ' antennas' : '')).replace(/^a twin/, 'twin') : '',
194
+ l.ears !== 'none' ? `${lower(LABELS.ears[l.ears])} for ears` : '',
195
+ l.visor === 'goggles' ? 'goggles' : l.visor !== 'none' ? named(LABELS.visor[l.visor]) : '',
196
+ l.panel !== 'none' ? (l.panel === 'holes' ? 'button holes' : l.panel === 'leds' ? 'a row of lights' : named(LABELS.panel[l.panel])) : '',
197
+ ].filter(Boolean);
198
+ const list = wearing.length > 1 ? `${wearing.slice(0, -1).join(', ')} and ${wearing.at(-1)}` : wearing[0];
199
+ return `${LABELS.shell[l.shell]} ${lower(LABELS.shape[l.shape])} Boton with ${list}, on ${lower(LABELS.backdrop[l.backdrop])}`;
200
+ }
@@ -0,0 +1,43 @@
1
+ // Emoji reactions on chat messages: what counts as one, and the defaults. The server checks every reaction with
2
+ // reactionEmoji; the web app uses the same lists for its picker.
3
+
4
+ /** The quick-pick row the app offers on a message. */
5
+ export const QUICK_REACTIONS: readonly string[] = ['👍', '❤️', '😂', '🎉', '👀', '🙏'];
6
+
7
+ /** The owner's reactions that start a short follow-up turn until they choose others: 👎 alone. */
8
+ export const DEFAULT_REACTION_FOLLOW_UPS: readonly string[] = ['👎'];
9
+
10
+ /** At most this many emoji start a follow-up turn. */
11
+ export const REACTION_FOLLOW_UP_MAX = 12;
12
+
13
+ /** At most this many different emoji of the owner's on one message. */
14
+ export const OWNER_REACTION_MAX = 10;
15
+
16
+ /** What a follow-up turn is asked to do for the reactions whose meaning is set; any other gets a general request. */
17
+ export const REACTION_MEANINGS: Readonly<Record<string, string>> = {
18
+ '👎': 'that was wrong or not what they wanted: reconsider it',
19
+ '😕': 'something about it is off or unclear: reconsider it, or ask what was off',
20
+ '❓': 'they want it explained',
21
+ };
22
+
23
+ const graphemes = new Intl.Segmenter('en', { granularity: 'grapheme' });
24
+ const PICTOGRAPHIC = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u;
25
+ const KEYCAP = /^[0-9#*]️?⃣$/u;
26
+ const PRESENTATION = /^\p{Emoji_Presentation}$/u;
27
+
28
+ /**
29
+ * The emoji as stored, or null when `input` isn't one: exactly one emoji (a single grapheme that is a pictograph, a
30
+ * flag or a keycap, with no text). A lone symbol that has a text form, such as ❤, is stored with its emoji
31
+ * presentation (❤️), so both spellings are one reaction.
32
+ */
33
+ export function reactionEmoji(input: unknown): string | null {
34
+ if (typeof input !== 'string') return null;
35
+ const e = input.trim().normalize('NFC');
36
+ if (!e || e.length > 32) return null;
37
+ if ([...graphemes.segment(e)].length !== 1) return null;
38
+ if (KEYCAP.test(e)) return e.replace(/️?⃣$/u, '️⃣');
39
+ if (!PICTOGRAPHIC.test(e) || /[\p{L}\p{N}\p{P}\p{Cc}\p{Zs}<>]/u.test(e)) return null;
40
+ const bare = e.replace(/️$/u, '');
41
+ if ([...bare].length === 1) return PRESENTATION.test(bare) ? bare : `${bare}️`;
42
+ return e;
43
+ }
@@ -0,0 +1,34 @@
1
+ // Seeded randomness for looks. This file imports nothing: the schema upgrade and the CLI-free scripts load it.
2
+
3
+ // FNV-1a for strings, mulberry32 for a stream. The same seed gives the same look on every
4
+ // machine, so a migrated Boton looks the same wherever it is drawn.
5
+ export function hash32(s: string): number {
6
+ let h = 0x811c9dc5;
7
+ for (let i = 0; i < s.length; i++) {
8
+ h ^= s.charCodeAt(i);
9
+ h = Math.imul(h, 0x01000193);
10
+ }
11
+ return h >>> 0;
12
+ }
13
+
14
+ export function seeded(seed: number): () => number {
15
+ let a = seed >>> 0;
16
+ return () => {
17
+ a = (a + 0x6d2b79f5) | 0;
18
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
19
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
20
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
21
+ };
22
+ }
23
+
24
+ export type Rand = () => number;
25
+ export const pick = <T>(r: Rand, xs: readonly T[]): T => xs[Math.min(xs.length - 1, Math.floor(r() * xs.length))];
26
+ export function weighted<T>(r: Rand, entries: readonly (readonly [T, number])[]): T {
27
+ let x = r() * entries.reduce((s, e) => s + e[1], 0);
28
+ for (const [v, w] of entries) {
29
+ x -= w;
30
+ if (x < 0) return v;
31
+ }
32
+ return entries[entries.length - 1][0];
33
+ }
34
+