codebay 0.0.1 → 0.2.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 (204) hide show
  1. package/.mochi/import-css/index-0nwv3654.css +18 -0
  2. package/.mochi/import-css/index-xndx4980.css +54 -0
  3. package/.mochi/manifest.json +1612 -0
  4. package/.mochi/server-island.js +1 -0
  5. package/.mochi/svelte-client/HydratableIsland-wrq79y0w.js +1 -0
  6. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-7xgbmxxg.js +2 -0
  7. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-3cnbjpz2.js +13 -0
  8. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-2ntmdm3e.js +1 -0
  9. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t08spym1.js +23 -0
  10. package/.mochi/svelte-client/chunk-04qv4w6w.js +2 -0
  11. package/.mochi/svelte-client/chunk-5jnepvzx.js +2 -0
  12. package/.mochi/svelte-client/chunk-hcztrd75.js +1 -0
  13. package/.mochi/svelte-client/chunk-qxwt25hn.js +4 -0
  14. package/.mochi/svelte-client/chunk-sjmcgh7f.js +2 -0
  15. package/.mochi/svelte-client/chunk-tr67wtwn.js +2 -0
  16. package/.mochi/svelte-compile/App-f0q5n4rk.server.js +1168 -0
  17. package/.mochi/svelte-compile/Avatars-p8gfef7x.server.js +332 -0
  18. package/.mochi/svelte-compile/ClientStats-z3tn9ad6.server.js +99 -0
  19. package/.mochi/svelte-compile/DefaultError-wh77vebf.server.js +52 -0
  20. package/.mochi/svelte-compile/Instance-atjwsczx.server.js +356 -0
  21. package/.mochi/svelte-compile/Settings-q88xw6h8.server.js +1173 -0
  22. package/.mochi/svelte-compile/chunk-4kwy539a.js +14 -0
  23. package/.mochi/svelte-compile/chunk-9bjk7yx6.js +81 -0
  24. package/.mochi/svelte-compile/chunk-btmcxhqy.js +58 -0
  25. package/.mochi/svelte-compile/chunk-sxqys3nw.js +1668 -0
  26. package/.mochi/svelte-compile/chunk-v6796ga4.js +3295 -0
  27. package/.mochi/svelte-compile/chunk-we0ggtws.js +34 -0
  28. package/.mochi/svelte-css/AppBar-3bxinv4pabv8l.css +1 -0
  29. package/.mochi/svelte-css/AppShell-36mpg5smnbmuw.css +1 -0
  30. package/.mochi/svelte-css/Avatar-24th63awm9mna.css +1 -0
  31. package/.mochi/svelte-css/AvatarEditor-1ryrs1md5g36y.css +1 -0
  32. package/.mochi/svelte-css/AvatarsView-3hq37u4dgtljn.css +1 -0
  33. package/.mochi/svelte-css/BackLink-2wlu0eovz9j86.css +1 -0
  34. package/.mochi/svelte-css/BranchBox-1yqu1q2mgnjwz.css +1 -0
  35. package/.mochi/svelte-css/Brand-1la7zw5uhcbl4.css +1 -0
  36. package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +1 -0
  37. package/.mochi/svelte-css/CheckmarkIcon-3qlazj2oc50jc.css +1 -0
  38. package/.mochi/svelte-css/ClientStats-19qzckq40p2q9.css +1 -0
  39. package/.mochi/svelte-css/CoinButton-1rej3sh3tkjud.css +1 -0
  40. package/.mochi/svelte-css/CredMenu-9hwgeptp7crb.css +1 -0
  41. package/.mochi/svelte-css/DashboardView-1cy4xpqhja9sx.css +1 -0
  42. package/.mochi/svelte-css/DefaultError-2b0daamydxgb2.css +1 -0
  43. package/.mochi/svelte-css/ErrorIcon-2x0tzsqxskw28.css +1 -0
  44. package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +1 -0
  45. package/.mochi/svelte-css/HealthBox-er2her0jukwl.css +1 -0
  46. package/.mochi/svelte-css/IdeBar-2qoqi2ya9zdn3.css +1 -0
  47. package/.mochi/svelte-css/IdeLoader-26rlko8e00j3g.css +1 -0
  48. package/.mochi/svelte-css/InstanceCard-16f77aysm7lgp.css +1 -0
  49. package/.mochi/svelte-css/InstanceView-wq85bxtusont.css +1 -0
  50. package/.mochi/svelte-css/LoaderIcon-soq5wt3p05ye.css +1 -0
  51. package/.mochi/svelte-css/PortsBox-1t25b5yg7jva3.css +1 -0
  52. package/.mochi/svelte-css/SettingsCog-jftyadvo64qo.css +1 -0
  53. package/.mochi/svelte-css/SettingsView-1nfdwnm16cpp2.css +1 -0
  54. package/.mochi/svelte-css/Skeleton-8k69usiju43h.css +1 -0
  55. package/.mochi/svelte-css/StatusBadge-3mq0j7z6aq4j.css +1 -0
  56. package/.mochi/svelte-css/ThemePicker-1ir8jyocm8mvi.css +1 -0
  57. package/.mochi/svelte-css/ToastBar-3havgkmtofntq.css +1 -0
  58. package/.mochi/svelte-css/ToastIcon-2z5wrkc95khg0.css +1 -0
  59. package/.mochi/svelte-css/ToastMessage-2jfwb2og2lfa8.css +1 -0
  60. package/.mochi/svelte-css/ToastWrapper-1fbnym10rm2nv.css +1 -0
  61. package/.mochi/svelte-css/Toaster-kk60j193854n.css +1 -0
  62. package/.mochi/svelte-css/TopBar-2q87li6yt8ids.css +1 -0
  63. package/LICENSE +21 -0
  64. package/README.md +34 -3
  65. package/bin/codebay.ts +22 -0
  66. package/migrations/0001_init.sql +20 -0
  67. package/migrations/0002_port_forwards.sql +9 -0
  68. package/migrations/0003_options.sql +4 -0
  69. package/migrations/0004_instance_image.sql +1 -0
  70. package/package.json +87 -9
  71. package/public/sounds/done.wav +0 -0
  72. package/public/sounds/waiting.wav +0 -0
  73. package/src/__fixtures__/HelloWorld.svelte +4 -0
  74. package/src/additional-svelte-typings.d.ts +1 -0
  75. package/src/api.ts +61 -0
  76. package/src/avatars/anchor.ts +17 -0
  77. package/src/avatars/bear.ts +17 -0
  78. package/src/avatars/bee.ts +17 -0
  79. package/src/avatars/bob-marley.ts +17 -0
  80. package/src/avatars/car.ts +17 -0
  81. package/src/avatars/carpathians.ts +17 -0
  82. package/src/avatars/cat-body.ts +17 -0
  83. package/src/avatars/cat-sits.ts +17 -0
  84. package/src/avatars/cat.ts +17 -0
  85. package/src/avatars/celtic-cross.ts +17 -0
  86. package/src/avatars/cherry.ts +17 -0
  87. package/src/avatars/cotton.ts +17 -0
  88. package/src/avatars/crab.ts +17 -0
  89. package/src/avatars/crown.ts +17 -0
  90. package/src/avatars/diamond.ts +17 -0
  91. package/src/avatars/dog.ts +17 -0
  92. package/src/avatars/elephant.ts +17 -0
  93. package/src/avatars/fish.ts +17 -0
  94. package/src/avatars/fleur-de-lis.ts +17 -0
  95. package/src/avatars/flower.ts +17 -0
  96. package/src/avatars/fox.ts +17 -0
  97. package/src/avatars/frog.ts +17 -0
  98. package/src/avatars/ghost.ts +17 -0
  99. package/src/avatars/giraffe.ts +17 -0
  100. package/src/avatars/glasses.ts +17 -0
  101. package/src/avatars/heart.ts +17 -0
  102. package/src/avatars/index.ts +128 -0
  103. package/src/avatars/invader.ts +17 -0
  104. package/src/avatars/kangaroo.ts +17 -0
  105. package/src/avatars/key.ts +17 -0
  106. package/src/avatars/lightning.ts +17 -0
  107. package/src/avatars/mushroom.ts +17 -0
  108. package/src/avatars/name-prompts.ts +30 -0
  109. package/src/avatars/octopus.ts +17 -0
  110. package/src/avatars/owl.ts +17 -0
  111. package/src/avatars/penguin.ts +17 -0
  112. package/src/avatars/planet.ts +17 -0
  113. package/src/avatars/rabbit.ts +17 -0
  114. package/src/avatars/rat.ts +17 -0
  115. package/src/avatars/robot.ts +17 -0
  116. package/src/avatars/rocket.ts +17 -0
  117. package/src/avatars/sea.ts +17 -0
  118. package/src/avatars/serialize.ts +65 -0
  119. package/src/avatars/skull.ts +17 -0
  120. package/src/avatars/snail.ts +17 -0
  121. package/src/avatars/snake.ts +17 -0
  122. package/src/avatars/snakegame.ts +17 -0
  123. package/src/avatars/star.ts +17 -0
  124. package/src/avatars/svelte.ts +17 -0
  125. package/src/avatars/target.ts +17 -0
  126. package/src/avatars/tree.ts +17 -0
  127. package/src/avatars/types.ts +24 -0
  128. package/src/avatars/ukraine.ts +17 -0
  129. package/src/avatars/whale.ts +17 -0
  130. package/src/avatars/wheat.ts +17 -0
  131. package/src/avatars/yinyan.ts +17 -0
  132. package/src/components/AppBar.svelte +35 -0
  133. package/src/components/AppShell.svelte +302 -0
  134. package/src/components/Avatar.svelte +235 -0
  135. package/src/components/AvatarEditor.svelte +297 -0
  136. package/src/components/AvatarsView.svelte +115 -0
  137. package/src/components/BranchBox.svelte +32 -0
  138. package/src/components/Brand.svelte +41 -0
  139. package/src/components/Button.svelte +161 -0
  140. package/src/components/CoinButton.svelte +121 -0
  141. package/src/components/CredMenu.svelte +171 -0
  142. package/src/components/DashboardView.svelte +198 -0
  143. package/src/components/FolderBrowser.svelte +635 -0
  144. package/src/components/HealthBox.svelte +161 -0
  145. package/src/components/IdeBar.svelte +186 -0
  146. package/src/components/IdeLoader.svelte +135 -0
  147. package/src/components/InstanceCard.svelte +220 -0
  148. package/src/components/InstanceView.svelte +567 -0
  149. package/src/components/PortsBox.svelte +74 -0
  150. package/src/components/SettingsCog.svelte +22 -0
  151. package/src/components/SettingsView.svelte +1494 -0
  152. package/src/components/Skeleton.svelte +43 -0
  153. package/src/components/StatusBadge.svelte +54 -0
  154. package/src/components/ThemePicker.svelte +77 -0
  155. package/src/components/TopBar.svelte +64 -0
  156. package/src/components/UiShowcase.svelte +578 -0
  157. package/src/components/ui-showcase/ComponentDemo.svelte +85 -0
  158. package/src/container-injections/attention-hooks.ts +117 -0
  159. package/src/container-injections/claude-aliases.ts +43 -0
  160. package/src/container-injections/claude-code-credentials.ts +145 -0
  161. package/src/container-injections/claude-code-custom.ts +125 -0
  162. package/src/container-injections/claude-no-coauthor.ts +35 -0
  163. package/src/container-injections/claude-skip-permissions.ts +38 -0
  164. package/src/container-injections/claude-statusline.ts +109 -0
  165. package/src/container-injections/git-identity.ts +63 -0
  166. package/src/container-injections/git-safe-directory.ts +18 -0
  167. package/src/container-injections/github-credentials.ts +162 -0
  168. package/src/container-injections/host-env-vars.ts +108 -0
  169. package/src/container-injections/tmux.ts +67 -0
  170. package/src/fontsource.d.ts +3 -0
  171. package/src/index.ts +81 -0
  172. package/src/lib/auth.server.ts +95 -0
  173. package/src/lib/bridge.server.ts +34 -0
  174. package/src/lib/config.server.ts +140 -0
  175. package/src/lib/crypto.server.ts +12 -0
  176. package/src/lib/db.server.ts +196 -0
  177. package/src/lib/devcontainer.server.ts +511 -0
  178. package/src/lib/docker-client.server.ts +61 -0
  179. package/src/lib/docker.server.ts +139 -0
  180. package/src/lib/exec.server.ts +92 -0
  181. package/src/lib/git.server.ts +68 -0
  182. package/src/lib/health.server.ts +105 -0
  183. package/src/lib/injections.server.ts +72 -0
  184. package/src/lib/instances.server.ts +552 -0
  185. package/src/lib/links.ts +9 -0
  186. package/src/lib/picker.server.ts +46 -0
  187. package/src/lib/ports.server.ts +9 -0
  188. package/src/lib/proxy.server.ts +170 -0
  189. package/src/lib/repo-url.ts +60 -0
  190. package/src/lib/spawn.server.ts +14 -0
  191. package/src/lib/theme.server.ts +22 -0
  192. package/src/live.ts +50 -0
  193. package/src/pages/App.svelte +14 -0
  194. package/src/pages/Avatars.svelte +9 -0
  195. package/src/pages/Instance.svelte +9 -0
  196. package/src/pages/Settings.svelte +76 -0
  197. package/src/pages/UI.svelte +10 -0
  198. package/src/routes.ts +444 -0
  199. package/src/settings.ts +14 -0
  200. package/src/shell.html +100 -0
  201. package/src/sound.ts +69 -0
  202. package/src/theme.ts +24 -0
  203. package/src/types.ts +79 -0
  204. package/svelte.config.js +5 -0
@@ -0,0 +1,302 @@
1
+ <script lang="ts">
2
+ import { ideUrl, type Instance, type Preflight } from '../types.ts';
3
+ import { SvelteSet } from 'svelte/reactivity';
4
+ import DashboardView from './DashboardView.svelte';
5
+ import IdeBar from './IdeBar.svelte';
6
+ import IdeLoader from './IdeLoader.svelte';
7
+ import { playChime, unlockAudio } from '../sound.ts';
8
+ import { liveStream } from '../live.ts';
9
+ import { apiPost } from '../api.ts';
10
+ import toast, { Toaster } from 'svelte-french-toast';
11
+
12
+ // `snapshot` seeds the live state so neither view renders a loading flash first.
13
+ let {
14
+ preflight,
15
+ initialPath,
16
+ snapshot
17
+ }: { preflight: Preflight; initialPath: string; snapshot: Instance[] } = $props();
18
+
19
+ // Seeding from the SSR snapshot is intentional — the live stream overwrites it.
20
+ // svelte-ignore state_referenced_locally
21
+ let instances = $state<Instance[]>(snapshot);
22
+ // Auth is preserved across stream updates because it's only ever probed at SSR.
23
+ // svelte-ignore state_referenced_locally
24
+ let livePreflight = $state<Preflight>(preflight);
25
+ // svelte-ignore state_referenced_locally
26
+ let loaded = $state(snapshot.length > 0);
27
+ const running = $derived(instances.filter((i) => i.status === 'running'));
28
+
29
+ let attention = $state<Record<string, 'done' | 'waiting' | null>>(
30
+ Object.fromEntries(snapshot.map((i) => [i.id, i.attention]))
31
+ );
32
+
33
+ let editingId = $state<string | null>(null);
34
+ let editingName = $state('');
35
+
36
+ function startRename(instance: Instance) {
37
+ editingId = instance.id;
38
+ editingName = instance.name;
39
+ }
40
+
41
+ function cancelRename() {
42
+ editingId = null;
43
+ editingName = '';
44
+ }
45
+
46
+ async function commitRename(id: string) {
47
+ const name = editingName.trim();
48
+ const original = instances.find((i) => i.id === id)?.name;
49
+ // Nothing to do on an empty or unchanged name — just close the editor.
50
+ if (!name || name === original) {
51
+ cancelRename();
52
+ return;
53
+ }
54
+ cancelRename();
55
+ try {
56
+ await apiPost(`/api/instances/${id}/rename`, { name }, 'Failed to rename');
57
+ // The live stream reflects the new name.
58
+ } catch (err) {
59
+ toast.error((err as Error).message);
60
+ }
61
+ }
62
+
63
+ // Mochi has no client router, and a document reload would tear down the code-server iframes.
64
+ // svelte-ignore state_referenced_locally
65
+ let path = $state(initialPath);
66
+ const onIde = $derived(path.startsWith('/ide'));
67
+ const requestedId = $derived(path.startsWith('/ide/') ? path.slice('/ide/'.length) : '');
68
+ // Falls back to the first running instance, for arriving at one that has since stopped.
69
+ const active = $derived(
70
+ onIde ? (running.some((i) => i.id === requestedId) ? requestedId : (running[0]?.id ?? '')) : ''
71
+ );
72
+
73
+ function navigate(to: string) {
74
+ if (to === path) return;
75
+ history.pushState({}, '', to);
76
+ path = to;
77
+ }
78
+
79
+ // Modifier clicks, new-tab targets, and other routes must fall through to a full navigation.
80
+ $effect(() => {
81
+ function onClick(e: MouseEvent) {
82
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
83
+ return;
84
+ const anchor = (e.target as Element | null)?.closest?.('a');
85
+ if (!anchor) return;
86
+ const href = anchor.getAttribute('href');
87
+ if (!href || anchor.target === '_blank' || anchor.hasAttribute('download')) return;
88
+ const url = new URL(href, location.href);
89
+ if (url.origin !== location.origin) return;
90
+ if (url.pathname === '/' || url.pathname.startsWith('/ide/')) {
91
+ e.preventDefault();
92
+ navigate(url.pathname);
93
+ }
94
+ }
95
+ document.addEventListener('click', onClick);
96
+ return () => document.removeEventListener('click', onClick);
97
+ });
98
+
99
+ $effect(() => {
100
+ function onPop() {
101
+ path = location.pathname;
102
+ }
103
+ window.addEventListener('popstate', onPop);
104
+ return () => window.removeEventListener('popstate', onPop);
105
+ });
106
+
107
+ // Mounted lazily, then kept mounted (hidden via CSS) so the editor survives tab switches.
108
+ const visited = new SvelteSet<string>();
109
+ $effect(() => {
110
+ if (active) visited.add(active);
111
+ });
112
+
113
+ // Until `load` fires, a loader overlays the pane so a fresh IDE isn't blank white.
114
+ const loadedFrames = new SvelteSet<string>();
115
+
116
+ // `running` only means the container is up; mounting before code-server binds its
117
+ // port renders the proxy's 503. Sticky, so a later probe blip can't tear down a live editor.
118
+ const everReady = new SvelteSet<string>();
119
+
120
+ // Kept separate from `everReady` so that stays an honest record of what the probe reported.
121
+ const forced = new SvelteSet<string>();
122
+ const mountable = (id: string) => everReady.has(id) || forced.has(id);
123
+
124
+ const STALLED_AFTER_MS = 10_000;
125
+
126
+ $effect(() => {
127
+ const inst = running.find((i) => i.id === active);
128
+ document.title = onIde && inst ? `${inst.name} — Codebay` : 'Codebay';
129
+ });
130
+
131
+ // Browsers block audio until the page has been interacted with.
132
+ $effect(() => {
133
+ window.addEventListener('pointerdown', unlockAudio);
134
+ window.addEventListener('keydown', unlockAudio);
135
+ return () => {
136
+ window.removeEventListener('pointerdown', unlockAudio);
137
+ window.removeEventListener('keydown', unlockAudio);
138
+ };
139
+ });
140
+
141
+ $effect(() => {
142
+ // The re-seed after a reconnect is a baseline, not a change — otherwise it replays chimes.
143
+ let primed = false;
144
+ return liveStream(
145
+ (msg) => {
146
+ if (msg.type === 'preflight') {
147
+ livePreflight = { ...livePreflight, docker: msg.data.docker, cli: msg.data.cli };
148
+ return;
149
+ }
150
+ if (msg.type === 'health') {
151
+ // A tick in flight when a rebuild starts probes the *old* container and
152
+ // reports it accessible, which would mount the iframe against the replacement too early.
153
+ const inst = instances.find((i) => i.id === msg.data.id);
154
+ if (msg.data.health.codeServerAccessible && inst?.status === 'running')
155
+ everReady.add(msg.data.id);
156
+ return;
157
+ }
158
+ if (msg.type !== 'instances') return;
159
+ const next = msg.data;
160
+ // Re-arm the gate: a replaced container must prove code-server is up again.
161
+ const live = new Set(next.filter((i) => i.status === 'running').map((i) => i.id));
162
+ for (const id of [...everReady]) if (!live.has(id)) everReady.delete(id);
163
+ for (const id of [...forced]) if (!live.has(id)) forced.delete(id);
164
+ for (const id of [...loadedFrames]) if (!live.has(id)) loadedFrames.delete(id);
165
+ const nextAttention: Record<string, 'done' | 'waiting' | null> = {};
166
+ for (const inst of next) nextAttention[inst.id] = inst.attention;
167
+ if (primed) {
168
+ for (const id in nextAttention) {
169
+ const state = nextAttention[id];
170
+ if (state && state !== attention[id]) {
171
+ if (id === active) {
172
+ console.log(
173
+ `[chime] attention id=${id} state=${state} — skipped (tab is active/focused)`
174
+ );
175
+ } else {
176
+ console.log(`[chime] attention id=${id} state=${state} → playChime`);
177
+ playChime(state);
178
+ }
179
+ }
180
+ }
181
+ } else {
182
+ console.log('[chime] first frame after (re)connect — baselining, no chimes');
183
+ }
184
+ primed = true;
185
+ instances = next;
186
+ attention = nextAttention;
187
+ loaded = true;
188
+ },
189
+ () => {
190
+ primed = false;
191
+ }
192
+ );
193
+ });
194
+
195
+ // Gated on document visibility, or a background browser tab would wipe the
196
+ // signal out from under the dashboard card.
197
+ $effect(() => {
198
+ if (!active || !attention[active]) return;
199
+ const dismiss = () => {
200
+ if (document.visibilityState === 'visible') {
201
+ void apiPost(`/api/instances/${active}/attention/clear`).catch(() => {
202
+ /* best-effort — the next visibility change retries */
203
+ });
204
+ }
205
+ };
206
+ dismiss();
207
+ document.addEventListener('visibilitychange', dismiss);
208
+ return () => document.removeEventListener('visibilitychange', dismiss);
209
+ });
210
+ </script>
211
+
212
+ <div class="app" class:ide={onIde}>
213
+ {#if onIde}
214
+ <IdeBar
215
+ {running}
216
+ {active}
217
+ {attention}
218
+ {editingId}
219
+ bind:editingName
220
+ onselect={(id) => navigate(`/ide/${id}`)}
221
+ onstartrename={startRename}
222
+ oncommitrename={commitRename}
223
+ oncancelrename={cancelRename}
224
+ />
225
+ {:else}
226
+ <DashboardView preflight={livePreflight} {instances} {loaded} />
227
+ {/if}
228
+
229
+ <!-- Always mounted, only hidden, so the iframes survive navigation. -->
230
+ <div class="panes" class:hidden={!onIde}>
231
+ {#each running as inst (inst.id)}
232
+ {#if visited.has(inst.id)}
233
+ <div class="pane" class:active={inst.id === active}>
234
+ {#if mountable(inst.id)}
235
+ <iframe src={ideUrl(inst)} title={inst.name} onload={() => loadedFrames.add(inst.id)}
236
+ ></iframe>
237
+ {/if}
238
+ <!-- Two distinct waits: the health probe is unbounded and gets the override,
239
+ the iframe's own `load` is bounded and gets a plain loader. -->
240
+ {#if !mountable(inst.id)}
241
+ <IdeLoader stalledAfterMs={STALLED_AFTER_MS} onoverride={() => forced.add(inst.id)} />
242
+ {:else if !loadedFrames.has(inst.id)}
243
+ <IdeLoader />
244
+ {/if}
245
+ </div>
246
+ {/if}
247
+ {/each}
248
+ {#if onIde && running.length === 0}
249
+ <div class="empty">No running instances.</div>
250
+ {/if}
251
+ </div>
252
+ </div>
253
+
254
+ <!-- Hoisted out of DashboardView so IDE-route toasts have somewhere to render too. -->
255
+ <Toaster
256
+ toastOptions={{
257
+ style:
258
+ 'border:1px solid var(--ink); background:var(--bg-card); color:var(--ink); box-shadow:4px 4px 0 var(--ink); font-family:var(--font-mono); font-size:13px;'
259
+ }}
260
+ />
261
+
262
+ <style>
263
+ /* The IDE route fills the viewport; the dashboard stays in normal scrolling flow. */
264
+ .app.ide {
265
+ display: flex;
266
+ flex-direction: column;
267
+ height: 100vh;
268
+ }
269
+ .panes {
270
+ position: relative;
271
+ flex: 1;
272
+ min-height: 0;
273
+ }
274
+ .panes.hidden {
275
+ display: none;
276
+ }
277
+ .pane {
278
+ position: absolute;
279
+ inset: 0;
280
+ }
281
+ .pane:not(.active) {
282
+ display: none;
283
+ }
284
+ iframe {
285
+ width: 100%;
286
+ height: 100%;
287
+ border: 0;
288
+ display: block;
289
+ }
290
+ .empty {
291
+ position: absolute;
292
+ inset: 0;
293
+ display: flex;
294
+ align-items: center;
295
+ justify-content: center;
296
+ color: var(--ink-faint);
297
+ font-family: var(--font-mono);
298
+ text-transform: uppercase;
299
+ letter-spacing: 0.06em;
300
+ font-size: 13px;
301
+ }
302
+ </style>
@@ -0,0 +1,235 @@
1
+ <script lang="ts">
2
+ import { pickAvatar, decode, type AvatarArt } from '../avatars/index.ts';
3
+
4
+ // `scale` is device-pixels per LED cell; the panel is always an integer multiple
5
+ // of it (10 × scale px) so every pixel lands on a crisp boundary.
6
+ let {
7
+ id = '',
8
+ name,
9
+ scale = 6,
10
+ art,
11
+ interactive = false
12
+ }: {
13
+ id?: string;
14
+ name: string;
15
+ scale?: number;
16
+ art?: AvatarArt;
17
+ interactive?: boolean;
18
+ } = $props();
19
+
20
+ // The "LCD pressure" gag: pressing the panel squishes it like a real LCD.
21
+ let pressed = $state(false);
22
+ let ghosting = $state(false);
23
+ let bx = $state('50%');
24
+ let by = $state('50%');
25
+ // Driven by press position so the sheen shifts as you drag; a CSS transition eases the trail.
26
+ let spin = $state(0);
27
+ let hue = $state(0);
28
+ let ghostTimer: ReturnType<typeof setTimeout> | undefined;
29
+
30
+ // Deriving the palette from position is what makes every pixel read a bit different.
31
+ function aim(e: PointerEvent) {
32
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
33
+ const px = (e.clientX - rect.left) / rect.width;
34
+ const py = (e.clientY - rect.top) / rect.height;
35
+ bx = px * 100 + '%';
36
+ by = py * 100 + '%';
37
+ // Non-linear mix of x and y so dragging sweeps the palette in interesting ways.
38
+ hue = (px * 360 + py * 140) % 360;
39
+ spin = (py * 300 - px * 120 + 360) % 360;
40
+ }
41
+
42
+ function onpointerdown(e: PointerEvent) {
43
+ if (!interactive) return;
44
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
45
+ aim(e);
46
+ clearTimeout(ghostTimer);
47
+ ghosting = false;
48
+ pressed = true;
49
+ }
50
+
51
+ function onpointermove(e: PointerEvent) {
52
+ if (pressed) aim(e);
53
+ }
54
+
55
+ function release() {
56
+ if (!pressed) return;
57
+ pressed = false;
58
+ ghosting = true;
59
+ clearTimeout(ghostTimer);
60
+ ghostTimer = setTimeout(() => (ghosting = false), 250);
61
+ }
62
+
63
+ $effect(() => () => clearTimeout(ghostTimer));
64
+
65
+ // Clamp to a whole number ≥ 1 — fractional scales would blur the grid.
66
+ const s = $derived(Math.max(1, Math.round(scale)));
67
+ // 0.5px is a crisp single device-pixel at 2×; at 1× the cells can't spare any gutter.
68
+ const gap = $derived(s >= 2 ? 0.5 : 0);
69
+
70
+ const cells = $derived(decode(art ?? pickAvatar(id)));
71
+ // The 10×10 outer ring is unlit LED cells — a bezel of real dots, not blank padding.
72
+ const grid = $derived(
73
+ Array.from({ length: 100 }, (_, i) => {
74
+ const r = Math.floor(i / 10);
75
+ const c = i % 10;
76
+ const inner = r >= 1 && r <= 8 && c >= 1 && c <= 8;
77
+ return inner ? cells[(r - 1) * 8 + (c - 1)] : 0;
78
+ })
79
+ );
80
+ </script>
81
+
82
+ <span
83
+ class="avatar"
84
+ class:interactive
85
+ class:pressed
86
+ class:ghosting
87
+ role="img"
88
+ aria-label={name}
89
+ title={name}
90
+ style="width:{10 * s}px;height:{10 *
91
+ s}px;--gap:{gap}px;--bx:{bx};--by:{by};--spin:{spin}deg;--hue:{hue}deg"
92
+ {onpointerdown}
93
+ {onpointermove}
94
+ onpointerup={release}
95
+ onpointercancel={release}
96
+ onlostpointercapture={release}
97
+ >
98
+ {#each grid as cell, i (i)}
99
+ <span class="px" class:on={cell === 1}></span>
100
+ {/each}
101
+ {#if interactive}
102
+ <span class="invert" aria-hidden="true"></span>
103
+ <span class="bloom" aria-hidden="true"></span>
104
+ {/if}
105
+ </span>
106
+
107
+ <style>
108
+ /* Sized to an exact 10×scale so each cell is a whole number of device-pixels.
109
+ The LED gap is a per-cell gutter, which keeps that integer cell size intact. */
110
+ .avatar {
111
+ display: grid;
112
+ grid-template-columns: repeat(10, 1fr);
113
+ grid-template-rows: repeat(10, 1fr);
114
+ flex: none;
115
+ border: 1px solid var(--ink);
116
+ background: var(--bg);
117
+ }
118
+ .px {
119
+ box-sizing: border-box;
120
+ padding: 0 var(--gap) var(--gap) 0;
121
+ background-clip: content-box;
122
+ background-color: var(--rule-soft); /* unlit LED — faint */
123
+ }
124
+ .px.on {
125
+ background-color: var(--ink);
126
+ }
127
+
128
+ /* Registered as <angle> because plain custom properties don't interpolate. */
129
+ @property --hue {
130
+ syntax: '<angle>';
131
+ inherits: false;
132
+ initial-value: 0deg;
133
+ }
134
+ @property --spin {
135
+ syntax: '<angle>';
136
+ inherits: false;
137
+ initial-value: 0deg;
138
+ }
139
+ .avatar.interactive {
140
+ position: relative;
141
+ cursor: pointer;
142
+ touch-action: none; /* a touch-press shouldn't scroll the page */
143
+ }
144
+ /* A white `difference` layer inverts what's behind it; the radial mask is what
145
+ confines the inversion to a disc around the press point. */
146
+ .invert {
147
+ position: absolute;
148
+ inset: 0;
149
+ pointer-events: none;
150
+ opacity: 0;
151
+ background: #fff;
152
+ mix-blend-mode: difference;
153
+ -webkit-mask: radial-gradient(
154
+ circle at var(--bx) var(--by),
155
+ rgba(0, 0, 0, 1) 0%,
156
+ rgba(0, 0, 0, 0.92) 24%,
157
+ rgba(0, 0, 0, 0) 58%
158
+ );
159
+ mask: radial-gradient(
160
+ circle at var(--bx) var(--by),
161
+ rgba(0, 0, 0, 1) 0%,
162
+ rgba(0, 0, 0, 0.92) 24%,
163
+ rgba(0, 0, 0, 0) 58%
164
+ );
165
+ }
166
+ .avatar.pressed .invert {
167
+ opacity: 1;
168
+ }
169
+ .avatar.ghosting .invert {
170
+ opacity: 0;
171
+ transition: opacity 250ms ease-out;
172
+ }
173
+
174
+ /* The squished-crystal splotch, centered on --bx/--by over the inverted pixels. */
175
+ .bloom {
176
+ position: absolute;
177
+ inset: 0;
178
+ pointer-events: none;
179
+ opacity: 0;
180
+ mix-blend-mode: screen;
181
+ /* Palette derived from press position; ease between states for a liquid trail. */
182
+ filter: hue-rotate(var(--hue, 0deg));
183
+ transition:
184
+ --hue 350ms ease-out,
185
+ --spin 350ms ease-out;
186
+ background:
187
+ conic-gradient(
188
+ from var(--spin, 0deg) at var(--bx) var(--by),
189
+ #ff0080,
190
+ #ffae00,
191
+ #00ff6a,
192
+ #00b3ff,
193
+ #7a00ff,
194
+ #ff0080
195
+ ),
196
+ radial-gradient(
197
+ circle at var(--bx) var(--by),
198
+ rgba(13, 14, 10, 0.85) 0%,
199
+ rgba(13, 14, 10, 0.4) 18%,
200
+ transparent 45%
201
+ );
202
+ /* Mask the rainbow conic into a ring around the press point. */
203
+ -webkit-mask: radial-gradient(
204
+ circle at var(--bx) var(--by),
205
+ transparent 8%,
206
+ #000 26%,
207
+ #000 38%,
208
+ transparent 60%
209
+ );
210
+ mask: radial-gradient(
211
+ circle at var(--bx) var(--by),
212
+ transparent 8%,
213
+ #000 26%,
214
+ #000 38%,
215
+ transparent 60%
216
+ );
217
+ }
218
+ .avatar.pressed .bloom {
219
+ opacity: 0.45;
220
+ }
221
+ .avatar.ghosting .bloom {
222
+ opacity: 0;
223
+ transition: opacity 250ms ease-out;
224
+ }
225
+
226
+ @media (prefers-reduced-motion: no-preference) {
227
+ .avatar.interactive {
228
+ transition: transform 250ms ease-out;
229
+ }
230
+ .avatar.pressed {
231
+ transform: scale(0.97);
232
+ transition: transform 60ms ease-out;
233
+ }
234
+ }
235
+ </style>