codebay 0.5.0 → 0.7.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 (114) hide show
  1. package/.mochi/manifest.json +286 -227
  2. package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-fkna7bj2.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +13 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +1 -0
  6. package/.mochi/svelte-client/{_hydrate-SettingsView_8hmarwmf8onq-r9vvc7rx.js → _hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js} +3 -3
  7. package/.mochi/svelte-client/chunk-330mrspw.js +2 -0
  8. package/.mochi/svelte-client/{chunk-dz1019cy.js → chunk-4jgkak13.js} +1 -1
  9. package/.mochi/svelte-client/chunk-fqg2qzj8.js +2 -0
  10. package/.mochi/svelte-client/chunk-k5m4kt20.js +2 -0
  11. package/.mochi/svelte-client/{chunk-nmf4dq86.js → chunk-qj7zy2d9.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-ssa0ad2f.js → chunk-rpdphch7.js} +1 -1
  13. package/.mochi/svelte-compile/{App-9qjy14bc.server.js → App-6rcf0d74.server.js} +113 -46
  14. package/.mochi/svelte-compile/{Avatars-sq8mjn6k.server.js → Avatars-pzyz0tv3.server.js} +75 -15
  15. package/.mochi/svelte-compile/{Instance-13j59daw.server.js → Instance-49ksrp11.server.js} +15 -3
  16. package/.mochi/svelte-compile/{Settings-p0s2awg1.server.js → Settings-sz5d6qj7.server.js} +3 -3
  17. package/.mochi/svelte-compile/{chunk-dfe2km6a.js → chunk-q8rbesaw.js} +324 -123
  18. package/.mochi/svelte-css/Avatar-3pi30db6ozvf0.css +1 -0
  19. package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +1 -0
  20. package/.mochi/svelte-css/{AvatarsView-3hq37u4dgtljn.css → AvatarsView-2ughbzc26luyb.css} +1 -1
  21. package/.mochi/svelte-css/InstanceCard-3vnltxnbxkvay.css +1 -0
  22. package/.mochi/svelte-css/InstanceFilter-3q6x0jt0hcgpz.css +1 -0
  23. package/.mochi/svelte-css/{InstanceView-wq85bxtusont.css → InstanceView-3vzow3ttxzlh3.css} +1 -1
  24. package/README.md +2 -0
  25. package/package.json +2 -2
  26. package/src/avatars/anchor.ts +1 -0
  27. package/src/avatars/ant-head.ts +17 -0
  28. package/src/avatars/apple.ts +17 -0
  29. package/src/avatars/bear.ts +1 -0
  30. package/src/avatars/bee.ts +1 -0
  31. package/src/avatars/bodybuilder.ts +17 -0
  32. package/src/avatars/cat.ts +1 -0
  33. package/src/avatars/cherry.ts +1 -0
  34. package/src/avatars/crab.ts +1 -0
  35. package/src/avatars/crown.ts +1 -0
  36. package/src/avatars/diamond.ts +1 -0
  37. package/src/avatars/dog.ts +1 -0
  38. package/src/avatars/editor.ts +17 -0
  39. package/src/avatars/fish.ts +1 -0
  40. package/src/avatars/flower.ts +1 -0
  41. package/src/avatars/fox.ts +1 -0
  42. package/src/avatars/frog.ts +1 -0
  43. package/src/avatars/ghost.ts +1 -0
  44. package/src/avatars/heart.ts +8 -8
  45. package/src/avatars/horse.ts +17 -0
  46. package/src/avatars/index.ts +20 -0
  47. package/src/avatars/invader.ts +1 -0
  48. package/src/avatars/key.ts +1 -0
  49. package/src/avatars/lightning.ts +1 -0
  50. package/src/avatars/mushroom.ts +1 -0
  51. package/src/avatars/octopus.ts +1 -0
  52. package/src/avatars/owl.ts +1 -0
  53. package/src/avatars/penguin.ts +1 -0
  54. package/src/avatars/planet.ts +1 -0
  55. package/src/avatars/praying-mantis.ts +17 -0
  56. package/src/avatars/rabbit.ts +1 -0
  57. package/src/avatars/robot.ts +1 -0
  58. package/src/avatars/rocket.ts +1 -0
  59. package/src/avatars/serialize.ts +4 -3
  60. package/src/avatars/skull.ts +6 -6
  61. package/src/avatars/snail.ts +1 -0
  62. package/src/avatars/snowflake.ts +17 -0
  63. package/src/avatars/star.ts +1 -0
  64. package/src/avatars/strawberry.ts +17 -0
  65. package/src/avatars/target.ts +1 -0
  66. package/src/avatars/tree.ts +1 -0
  67. package/src/avatars/types.ts +6 -3
  68. package/src/avatars/vangogh-almond-blossoms.ts +17 -0
  69. package/src/avatars/wasp.ts +17 -0
  70. package/src/avatars/whale.ts +1 -0
  71. package/src/avatars/windmill.ts +17 -0
  72. package/src/components/AppShell.svelte +13 -0
  73. package/src/components/Avatar.svelte +4 -1
  74. package/src/components/AvatarEditor.svelte +83 -5
  75. package/src/components/AvatarsView.svelte +14 -1
  76. package/src/components/DashboardView.svelte +39 -4
  77. package/src/components/InstanceCard.svelte +55 -6
  78. package/src/components/InstanceFilter.svelte +68 -0
  79. package/src/components/InstanceView.svelte +41 -8
  80. package/src/components/SettingsView.svelte +2 -2
  81. package/src/components/TopBar.svelte +9 -1
  82. package/src/container-injections/attention-hooks.ts +19 -38
  83. package/src/container-injections/claude-aliases.ts +7 -23
  84. package/src/container-injections/claude-code-credentials.ts +12 -8
  85. package/src/container-injections/claude-code-custom.ts +35 -48
  86. package/src/container-injections/claude-code-ide-extension.ts +39 -0
  87. package/src/container-injections/claude-code-models.ts +8 -24
  88. package/src/container-injections/claude-model.ts +5 -28
  89. package/src/container-injections/claude-no-coauthor.ts +5 -17
  90. package/src/container-injections/claude-skip-permissions.ts +7 -17
  91. package/src/container-injections/claude-statusline.ts +16 -18
  92. package/src/container-injections/claude-trust.ts +73 -0
  93. package/src/container-injections/host-env-vars.ts +13 -24
  94. package/src/container-injections/tmux.ts +2 -11
  95. package/src/index.ts +2 -0
  96. package/src/lib/claude-settings.server.ts +35 -0
  97. package/src/lib/container-files.server.ts +219 -0
  98. package/src/lib/devcontainer.server.ts +38 -1
  99. package/src/lib/exec.server.ts +2 -7
  100. package/src/lib/injections.server.ts +4 -0
  101. package/src/lib/instances.server.ts +14 -0
  102. package/src/lib/theme.server.ts +7 -3
  103. package/src/shell.html +2 -0
  104. package/src/types.ts +3 -0
  105. package/.mochi/svelte-client/HydratableIsland-79z9xq4b.js +0 -1
  106. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-f5rzyjm8.js +0 -2
  107. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-fxyd5qx4.js +0 -13
  108. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-nv31ptdp.js +0 -1
  109. package/.mochi/svelte-client/chunk-6eexkr6p.js +0 -2
  110. package/.mochi/svelte-client/chunk-7znwaf7t.js +0 -2
  111. package/.mochi/svelte-client/chunk-zmmb1c4b.js +0 -2
  112. package/.mochi/svelte-css/Avatar-24th63awm9mna.css +0 -1
  113. package/.mochi/svelte-css/AvatarEditor-1ryrs1md5g36y.css +0 -1
  114. package/.mochi/svelte-css/InstanceCard-16f77aysm7lgp.css +0 -1
@@ -0,0 +1,17 @@
1
+ import type { AvatarArt } from './types.ts';
2
+
3
+ const art: AvatarArt = {
4
+ name: 'strawberry',
5
+ pixels: [
6
+ '...#....',
7
+ '..#...#.',
8
+ '..#..#.#',
9
+ '.###..#.',
10
+ '#####...',
11
+ '#####...',
12
+ '.###....',
13
+ '..#.....'
14
+ ]
15
+ };
16
+
17
+ export default art;
@@ -2,6 +2,7 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'target',
5
+ robot: true,
5
6
  pixels: [
6
7
  '..####..',
7
8
  '.#....#.',
@@ -2,6 +2,7 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'tree',
5
+ robot: true,
5
6
  pixels: [
6
7
  '...##...',
7
8
  '..####..',
@@ -1,14 +1,17 @@
1
1
  // The string legend keeps the art human-editable — you can eyeball a creature in the source.
2
2
  //
3
- // '#' → on (lit pixel)
4
- // ' ' or '.' → off (unlit pixel)
3
+ // '#' → on (lit pixel)
4
+ // '+' → gray (half-lit pixel)
5
+ // ' ' or '.' → off (unlit pixel)
5
6
  export type AvatarArt = {
6
7
  name: string; // also the uniqueness key
7
8
  pixels: string[];
9
+ robot?: boolean; // true for the original AI-generated sprites, slated to be redrawn by hand
8
10
  };
9
11
 
10
12
  export const OFF = 0;
11
13
  export const ON = 1;
14
+ export const GRAY = 2;
12
15
 
13
16
  export const ROWS = 8;
14
17
  export const COLS = 8;
@@ -17,7 +20,7 @@ export function decode(art: AvatarArt): number[] {
17
20
  const cells: number[] = [];
18
21
  for (const row of art.pixels) {
19
22
  for (const ch of row) {
20
- cells.push(ch === '#' ? ON : OFF);
23
+ cells.push(ch === '#' ? ON : ch === '+' ? GRAY : OFF);
21
24
  }
22
25
  }
23
26
  return cells;
@@ -0,0 +1,17 @@
1
+ import type { AvatarArt } from './types.ts';
2
+
3
+ const art: AvatarArt = {
4
+ name: 'vangogh-almond-blossoms',
5
+ pixels: [
6
+ '....#.#.',
7
+ '.....#..',
8
+ '####....',
9
+ '#...#..#',
10
+ '.#...##.',
11
+ '..#....#',
12
+ '.#.#....',
13
+ '..#.....'
14
+ ]
15
+ };
16
+
17
+ export default art;
@@ -0,0 +1,17 @@
1
+ import type { AvatarArt } from './types.ts';
2
+
3
+ const art: AvatarArt = {
4
+ name: 'wasp',
5
+ pixels: [
6
+ '.....##.',
7
+ '.##.###.',
8
+ '.###....',
9
+ '..#.#...',
10
+ '.#.#.#..',
11
+ '##..##..',
12
+ '##......',
13
+ '........'
14
+ ]
15
+ };
16
+
17
+ export default art;
@@ -2,6 +2,7 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'whale',
5
+ robot: true,
5
6
  pixels: [
6
7
  '....#...',
7
8
  '...###..',
@@ -0,0 +1,17 @@
1
+ import type { AvatarArt } from './types.ts';
2
+
3
+ const art: AvatarArt = {
4
+ name: 'windmill',
5
+ pixels: [
6
+ '........',
7
+ '.#...#..',
8
+ '..#.#...',
9
+ '...#....',
10
+ '..#.#...',
11
+ '.#.#.#..',
12
+ '..###...',
13
+ '..###...'
14
+ ]
15
+ };
16
+
17
+ export default art;
@@ -114,6 +114,19 @@
114
114
  return () => window.removeEventListener('popstate', onPop);
115
115
  });
116
116
 
117
+ // Native unsaved-changes guard: a reload/close/external-nav tears down the code-server
118
+ // iframes and loses in-editor state. In-app /↔/ide transitions are pushState (no unload)
119
+ // and Settings/Details links open in new tabs, so this only fires on a true teardown.
120
+ $effect(() => {
121
+ if (running.length === 0) return;
122
+ function onBeforeUnload(e: BeforeUnloadEvent) {
123
+ e.preventDefault();
124
+ e.returnValue = '';
125
+ }
126
+ window.addEventListener('beforeunload', onBeforeUnload);
127
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
128
+ });
129
+
117
130
  // Mounted lazily, then kept mounted (hidden via CSS) so the editor survives tab switches.
118
131
  const visited = new SvelteSet<string>();
119
132
  $effect(() => {
@@ -96,7 +96,7 @@
96
96
  onlostpointercapture={release}
97
97
  >
98
98
  {#each grid as cell, i (i)}
99
- <span class="px" class:on={cell === 1}></span>
99
+ <span class="px" class:on={cell === 1} class:gray={cell === 2}></span>
100
100
  {/each}
101
101
  {#if interactive}
102
102
  <span class="invert" aria-hidden="true"></span>
@@ -121,6 +121,9 @@
121
121
  background-clip: content-box;
122
122
  background-color: var(--rule-soft); /* unlit LED — faint */
123
123
  }
124
+ .px.gray {
125
+ background-color: var(--led-gray); /* half-lit LED */
126
+ }
124
127
  .px.on {
125
128
  background-color: var(--ink);
126
129
  }
@@ -4,7 +4,8 @@
4
4
  import Contrast from '@lucide/svelte/icons/contrast';
5
5
  import ExternalLink from '@lucide/svelte/icons/external-link';
6
6
  import toast from 'svelte-french-toast';
7
- import { ROWS, COLS, ON, OFF, type AvatarArt } from '../avatars/types.ts';
7
+ import { ROWS, COLS, ON, OFF, GRAY, type AvatarArt } from '../avatars/types.ts';
8
+ import { MODES, nextValue, type Mode } from '../avatars/editor.ts';
8
9
  import {
9
10
  cellsToPixels,
10
11
  normalizeName,
@@ -24,7 +25,26 @@
24
25
  const slug = $derived(normalizeName(avatarName));
25
26
  const art = $derived<AvatarArt>({ name: slug || 'my-avatar', pixels: cellsToPixels(cells) });
26
27
  // Contributing needs a name and at least one lit pixel — a blank sprite is no sprite.
27
- const canContribute = $derived(slug.length > 0 && cells.some((c) => c === ON));
28
+ const canContribute = $derived(slug.length > 0 && cells.some((c) => c !== OFF));
29
+
30
+ // Cycle is the default so both shades are reachable without switching mode.
31
+ let mode = $state<Mode>('cycle');
32
+ let modeButtons = $state<HTMLButtonElement[]>([]);
33
+
34
+ // Roving-tabindex arrow navigation, as a radiogroup is expected to behave.
35
+ function onModeKeydown(e: KeyboardEvent, i: number) {
36
+ const dir =
37
+ e.key === 'ArrowRight' || e.key === 'ArrowDown'
38
+ ? 1
39
+ : e.key === 'ArrowLeft' || e.key === 'ArrowUp'
40
+ ? -1
41
+ : 0;
42
+ if (dir === 0) return;
43
+ e.preventDefault();
44
+ const next = (i + dir + MODES.length) % MODES.length;
45
+ mode = MODES[next]!.id;
46
+ modeButtons[next]?.focus();
47
+ }
28
48
 
29
49
  // Capturing the pointer on the grid is what lets one stroke paint every cell it crosses.
30
50
  let gridEl = $state<HTMLDivElement | null>(null);
@@ -51,7 +71,7 @@
51
71
  if (i == null) return;
52
72
  (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
53
73
  painting = true;
54
- paintValue = cells[i] === ON ? OFF : ON;
74
+ paintValue = nextValue(mode, cells[i]!);
55
75
  cells[i] = paintValue;
56
76
  }
57
77
 
@@ -69,8 +89,9 @@
69
89
  cells = Array(ROWS * COLS).fill(OFF);
70
90
  }
71
91
 
92
+ // Only swaps off↔on; gray sits in the middle and is left untouched.
72
93
  function invert() {
73
- cells = cells.map((c) => (c === ON ? OFF : ON));
94
+ cells = cells.map((c) => (c === ON ? OFF : c === OFF ? ON : c));
74
95
  }
75
96
 
76
97
  async function copyModule() {
@@ -106,7 +127,7 @@
106
127
  onlostpointercapture={stopPainting}
107
128
  >
108
129
  {#each cells as cell, i (i)}
109
- <span class="cell" class:on={cell === ON}></span>
130
+ <span class="cell" class:on={cell === ON} class:gray={cell === GRAY}></span>
110
131
  {/each}
111
132
  </div>
112
133
  </div>
@@ -134,6 +155,27 @@
134
155
  />
135
156
  </label>
136
157
 
158
+ <div class="mode-field">
159
+ <span class="preview-label">Mode</span>
160
+ <div class="modes" role="radiogroup" aria-label="Drawing mode">
161
+ {#each MODES as m, i (m.id)}
162
+ <button
163
+ type="button"
164
+ class="mode"
165
+ class:active={mode === m.id}
166
+ role="radio"
167
+ aria-checked={mode === m.id}
168
+ tabindex={mode === m.id ? 0 : -1}
169
+ bind:this={modeButtons[i]}
170
+ onclick={() => (mode = m.id)}
171
+ onkeydown={(e) => onModeKeydown(e, i)}
172
+ >
173
+ {m.label}
174
+ </button>
175
+ {/each}
176
+ </div>
177
+ </div>
178
+
137
179
  <div class="tools">
138
180
  <Button size="sm" icon={Eraser} onclick={clear}>Clear</Button>
139
181
  <Button size="sm" icon={Contrast} onclick={invert}>Invert</Button>
@@ -224,6 +266,9 @@
224
266
  background-clip: content-box;
225
267
  background-color: var(--rule-soft); /* unlit LED — faint */
226
268
  }
269
+ .cell.gray {
270
+ background-color: var(--led-gray); /* half-lit LED */
271
+ }
227
272
  .cell.on {
228
273
  background-color: var(--ink);
229
274
  }
@@ -267,6 +312,39 @@
267
312
  outline: none;
268
313
  box-shadow: inset 3px 3px 0 var(--rule-soft);
269
314
  }
315
+ .mode-field {
316
+ display: flex;
317
+ flex-direction: column;
318
+ gap: 6px;
319
+ }
320
+ .modes {
321
+ display: flex;
322
+ border: 1px solid var(--ink);
323
+ }
324
+ .mode {
325
+ flex: 1;
326
+ padding: 7px 6px;
327
+ border: none;
328
+ border-right: 1px solid var(--ink);
329
+ background: var(--bg);
330
+ color: var(--ink);
331
+ font-family: var(--font-mono);
332
+ font-size: 11px;
333
+ text-transform: uppercase;
334
+ letter-spacing: 0.06em;
335
+ cursor: pointer;
336
+ }
337
+ .mode:last-child {
338
+ border-right: none;
339
+ }
340
+ .mode.active {
341
+ background: var(--ink);
342
+ color: var(--bg);
343
+ }
344
+ .mode:focus-visible {
345
+ outline: none;
346
+ box-shadow: inset 0 0 0 2px var(--rule-soft);
347
+ }
270
348
  .tools {
271
349
  display: flex;
272
350
  gap: 8px;
@@ -3,6 +3,7 @@
3
3
  import AppBar from './AppBar.svelte';
4
4
  import Avatar from './Avatar.svelte';
5
5
  import AvatarEditor from './AvatarEditor.svelte';
6
+ import Bot from '@lucide/svelte/icons/bot';
6
7
  import { avatars } from '../avatars/index.ts';
7
8
 
8
9
  let { namePlaceholder }: { namePlaceholder: string } = $props();
@@ -25,7 +26,12 @@
25
26
  {#each avatars as art (art.name)}
26
27
  <li>
27
28
  <Avatar {art} name={art.name} scale={8} interactive />
28
- <span class="label">{art.name}</span>
29
+ <span class="label">
30
+ {#if art.robot}
31
+ <span class="robot-mark" title="AI-generated"><Bot size={11} /></span>
32
+ {/if}
33
+ {art.name}
34
+ </span>
29
35
  </li>
30
36
  {/each}
31
37
  </ul>
@@ -106,10 +112,17 @@
106
112
  gap: 8px;
107
113
  }
108
114
  .label {
115
+ display: inline-flex;
116
+ align-items: center;
117
+ gap: 4px;
109
118
  font-family: var(--font-mono);
110
119
  font-size: 11px;
111
120
  text-transform: uppercase;
112
121
  letter-spacing: 0.08em;
113
122
  color: var(--ink-soft);
114
123
  }
124
+ .robot-mark {
125
+ display: inline-flex;
126
+ color: var(--ink-faint);
127
+ }
115
128
  </style>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { type Instance, type Preflight } from '../types.ts';
2
+ import { type Instance, type InstanceFilter, type Preflight } from '../types.ts';
3
3
  import type { AvatarArt } from '../avatars/index.ts';
4
4
  import FolderBrowser from './FolderBrowser.svelte';
5
5
  import InstanceCard from './InstanceCard.svelte';
@@ -16,14 +16,41 @@
16
16
  pet
17
17
  }: { preflight: Preflight; instances: Instance[]; loaded: boolean; pet?: AvatarArt } = $props();
18
18
 
19
+ type Action = 'start' | 'stop' | 'delete' | 'rebuild';
20
+
19
21
  let browserOpen = $state(false);
22
+ let filter = $state<InstanceFilter>('all');
20
23
  let creating = $state(false);
21
24
  let actionError = $state<string | null>(null);
22
25
  let editingId = $state<string | null>(null);
23
26
  let editingName = $state('');
27
+ // Per-instance in-flight action + the status it started from, so the busy overlay clears
28
+ // only once the live stream reports the resulting change (avoids flicker on the round-trip).
29
+ let pending = $state<Record<string, { action: Action; since: Instance['status'] }>>({});
24
30
 
25
31
  const ready = $derived(preflight.docker && preflight.cli);
26
32
 
33
+ const visible = $derived(
34
+ instances.filter((i) => {
35
+ if (filter === 'active') return i.status === 'running' || i.status === 'creating';
36
+ if (filter === 'stopped') return i.status === 'stopped' || i.status === 'error';
37
+ return true;
38
+ })
39
+ );
40
+
41
+ // Drop a pending entry once its instance is gone (deleted) or its status has moved on.
42
+ $effect(() => {
43
+ for (const [id, { since }] of Object.entries(pending)) {
44
+ const instance = instances.find((i) => i.id === id);
45
+ if (!instance || instance.status !== since) clearPending(id);
46
+ }
47
+ });
48
+
49
+ function clearPending(id: string) {
50
+ const { [id]: _, ...rest } = pending;
51
+ pending = rest;
52
+ }
53
+
27
54
  async function createFrom(sourcePath: string, opts?: { branch?: string }) {
28
55
  browserOpen = false;
29
56
  creating = true;
@@ -42,7 +69,7 @@
42
69
  }
43
70
  }
44
71
 
45
- async function act(id: string, action: 'start' | 'stop' | 'delete' | 'rebuild') {
72
+ async function act(id: string, action: Action) {
46
73
  actionError = null;
47
74
  if (action === 'delete' && !confirm('Delete this instance and its copied files?')) return;
48
75
  // Rebuild discards the container; the workspace copy lives on the host and survives.
@@ -51,11 +78,14 @@
51
78
  !confirm('Recreate this container and re-run setup? The workspace is kept.')
52
79
  )
53
80
  return;
81
+ const since = instances.find((i) => i.id === id)?.status;
82
+ if (since) pending = { ...pending, [id]: { action, since } };
54
83
  try {
55
84
  await apiPost(`/api/instances/${id}/${action}`, undefined, `Failed to ${action}`);
56
- // The live stream reflects the resulting state.
85
+ // The live stream reflects the resulting state; the $effect clears the busy overlay.
57
86
  } catch (err) {
58
87
  actionError = (err as Error).message;
88
+ clearPending(id);
59
89
  }
60
90
  }
61
91
 
@@ -105,6 +135,8 @@
105
135
  canDelete={instances.length > 0}
106
136
  {ready}
107
137
  {creating}
138
+ {filter}
139
+ onFilter={(v) => (filter = v)}
108
140
  onNew={() => (browserOpen = true)}
109
141
  onDeleteAll={deleteAll}
110
142
  />
@@ -132,12 +164,15 @@
132
164
  New instance
133
165
  </Button>
134
166
  </div>
167
+ {:else if visible.length === 0}
168
+ <div class="empty"><p class="empty-sub">No {filter} instances.</p></div>
135
169
  {:else}
136
170
  <ul class="grid">
137
- {#each instances as instance (instance.id)}
171
+ {#each visible as instance (instance.id)}
138
172
  <InstanceCard
139
173
  {instance}
140
174
  editing={editingId === instance.id}
175
+ pending={pending[instance.id]?.action ?? null}
141
176
  bind:editingName
142
177
  onact={(action) => act(instance.id, action)}
143
178
  onstartrename={() => startRename(instance)}
@@ -7,10 +7,13 @@
7
7
  import Button from './Button.svelte';
8
8
  import { withPopupMarker } from '../lib/popup-nav.ts';
9
9
 
10
+ type Action = 'start' | 'stop' | 'delete' | 'rebuild';
11
+
10
12
  let {
11
13
  instance,
12
14
  editing,
13
15
  editingName = $bindable(),
16
+ pending = null,
14
17
  onact,
15
18
  onstartrename,
16
19
  oncommitrename,
@@ -19,7 +22,9 @@
19
22
  instance: Instance;
20
23
  editing: boolean;
21
24
  editingName: string;
22
- onact: (action: 'start' | 'stop' | 'delete' | 'rebuild') => void;
25
+ /** The user-triggered action currently in flight, if any — drives the busy overlay. */
26
+ pending?: Action | null;
27
+ onact: (action: Action) => void;
23
28
  onstartrename: () => void;
24
29
  oncommitrename: () => void;
25
30
  oncancelrename: () => void;
@@ -31,9 +36,21 @@
31
36
  instance.status === 'stopped' ||
32
37
  (instance.status === 'error' && !!instance.container_id)
33
38
  );
39
+
40
+ const BUSY_LABEL: Record<Action, string> = {
41
+ start: 'Starting…',
42
+ stop: 'Stopping…',
43
+ delete: 'Deleting…',
44
+ rebuild: 'Rebuilding…'
45
+ };
34
46
  </script>
35
47
 
36
- <li class="card panel">
48
+ <li class="card panel" class:busy={!!pending} aria-busy={!!pending || undefined}>
49
+ {#if pending}
50
+ <div class="busy-overlay" aria-live="polite">
51
+ <span class="busy-label">{BUSY_LABEL[pending]}</span>
52
+ </div>
53
+ {/if}
37
54
  <div class="card-head">
38
55
  <Avatar id={instance.id} name={instance.name} interactive />
39
56
  {#if editing}
@@ -109,16 +126,50 @@
109
126
 
110
127
  <style>
111
128
  .card {
129
+ position: relative;
112
130
  background: var(--bg-card);
113
131
  padding: 16px 16px 14px;
114
132
  transition:
115
133
  transform 0.08s steps(2),
116
134
  box-shadow 0.08s steps(2);
117
135
  }
118
- .card:hover {
136
+ .card:hover:not(.busy) {
119
137
  transform: translate(-1px, -1px);
120
138
  box-shadow: 6px 6px 0 var(--ink);
121
139
  }
140
+ /* Translucent scrim dims the card body and blocks clicks on the buttons beneath. */
141
+ .busy-overlay {
142
+ position: absolute;
143
+ inset: 0;
144
+ z-index: 1;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ background: color-mix(in srgb, var(--bg-card) 78%, transparent);
149
+ }
150
+ .busy-label {
151
+ font-family: var(--font-mono);
152
+ font-weight: 600;
153
+ font-size: 12px;
154
+ text-transform: uppercase;
155
+ letter-spacing: 0.1em;
156
+ padding: 4px 9px;
157
+ border: 1px solid var(--ink);
158
+ background: var(--ink);
159
+ color: var(--bg);
160
+ animation: lcd-blink 1.1s steps(1) infinite;
161
+ }
162
+ @keyframes lcd-blink {
163
+ 50% {
164
+ background: transparent;
165
+ color: var(--ink);
166
+ }
167
+ }
168
+ @media (prefers-reduced-motion: reduce) {
169
+ .busy-label {
170
+ animation: none;
171
+ }
172
+ }
122
173
  .card-head {
123
174
  display: flex;
124
175
  align-items: center;
@@ -167,9 +218,7 @@
167
218
  font-family: var(--font-mono);
168
219
  font-weight: 600;
169
220
  font-size: 15px;
170
- overflow: hidden;
171
- text-overflow: ellipsis;
172
- white-space: nowrap;
221
+ overflow-wrap: anywhere;
173
222
  /* Reset button chrome so it reads as plain text until hovered. */
174
223
  margin: 0;
175
224
  padding: 2px 4px;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import type { InstanceFilter } from '../types.ts';
3
+
4
+ let { value, onchange }: { value: InstanceFilter; onchange: (v: InstanceFilter) => void } =
5
+ $props();
6
+
7
+ const options: { value: InstanceFilter; label: string }[] = [
8
+ { value: 'all', label: 'All' },
9
+ { value: 'active', label: 'Active' },
10
+ { value: 'stopped', label: 'Stopped' }
11
+ ];
12
+ </script>
13
+
14
+ <div class="picker" role="radiogroup" aria-label="Filter instances">
15
+ {#each options as opt (opt.value)}
16
+ <button
17
+ type="button"
18
+ class="segment"
19
+ class:active={value === opt.value}
20
+ role="radio"
21
+ aria-checked={value === opt.value}
22
+ onclick={() => onchange(opt.value)}
23
+ >
24
+ {opt.label}
25
+ </button>
26
+ {/each}
27
+ </div>
28
+
29
+ <style>
30
+ .picker {
31
+ display: inline-flex;
32
+ flex: none;
33
+ border: 1px solid var(--ink);
34
+ background: var(--bg-card);
35
+ }
36
+ .segment {
37
+ display: inline-flex;
38
+ align-items: center;
39
+ gap: 6px;
40
+ padding: 7px 12px;
41
+ font-family: var(--font-mono);
42
+ font-weight: 600;
43
+ font-size: 12px;
44
+ text-transform: uppercase;
45
+ letter-spacing: 0.06em;
46
+ border: none;
47
+ background: transparent;
48
+ color: var(--ink);
49
+ cursor: pointer;
50
+ }
51
+ .segment + .segment {
52
+ border-left: 1px solid var(--ink);
53
+ }
54
+ .segment:hover:not(.active) {
55
+ background: var(--ink);
56
+ color: var(--bg);
57
+ }
58
+ .segment.active {
59
+ font-weight: 700;
60
+ background: var(--ink);
61
+ color: var(--bg);
62
+ cursor: default;
63
+ }
64
+ .segment:focus-visible {
65
+ outline: 2px solid var(--ink);
66
+ outline-offset: -2px;
67
+ }
68
+ </style>