codebay 0.11.0 → 0.12.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 (37) hide show
  1. package/.mochi/manifest.json +114 -106
  2. package/.mochi/svelte-client/HydratableIsland-4j3bazeq.js +1 -0
  3. package/.mochi/svelte-client/{_hydrate-AppShell_2rtnxn6pgp2rh-v65ctd99.js → _hydrate-AppShell_2rtnxn6pgp2rh-aqm4k957.js} +2 -2
  4. package/.mochi/svelte-client/{_hydrate-AvatarsView_257s9yq1wwr4f-htjfv8q9.js → _hydrate-AvatarsView_257s9yq1wwr4f-m7h7xdxn.js} +1 -1
  5. package/.mochi/svelte-client/{_hydrate-InstanceView_2ma45l9bybfhl-v07seqge.js → _hydrate-InstanceView_2ma45l9bybfhl-d256bw54.js} +1 -1
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-pwbtv8ma.js +39 -0
  7. package/.mochi/svelte-client/{chunk-wjagstre.js → chunk-1ttqgrz7.js} +1 -1
  8. package/.mochi/svelte-client/{chunk-czkxd74n.js → chunk-2eaagsfc.js} +1 -1
  9. package/.mochi/svelte-client/chunk-6v5d7dex.js +2 -0
  10. package/.mochi/svelte-client/{chunk-qx1defbm.js → chunk-a5e8vytv.js} +2 -2
  11. package/.mochi/svelte-client/{chunk-hxydjtda.js → chunk-d8rdrqsh.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-566rqq4c.js → chunk-d9bszcp8.js} +1 -1
  13. package/.mochi/svelte-client/chunk-krevp8yb.js +2 -0
  14. package/.mochi/svelte-client/{chunk-0zkdsrth.js → chunk-s70rry6z.js} +1 -1
  15. package/.mochi/svelte-client/{chunk-79aj59e0.js → chunk-wcfbjj3x.js} +2 -2
  16. package/.mochi/svelte-client/{chunk-y9yy1004.js → chunk-y1xjps59.js} +1 -1
  17. package/.mochi/svelte-compile/{App-mzn3dqjm.server.js → App-bhak71kt.server.js} +4 -3
  18. package/.mochi/svelte-compile/{Settings-tvc5yp08.server.js → Settings-j3aygrjb.server.js} +178 -147
  19. package/.mochi/svelte-compile/{chunk-bpqwf1nw.js → chunk-pqb403fh.js} +12 -1
  20. package/.mochi/svelte-css/AppShell-22m1mjm8nq2hn.css +1 -0
  21. package/.mochi/svelte-css/{InstanceView-oozmmctp8m3p.css → InstanceView-32j12hyg8v9u0.css} +1 -1
  22. package/package.json +1 -1
  23. package/src/components/AppShell.svelte +5 -1
  24. package/src/components/InstanceView.svelte +1 -1
  25. package/src/components/SettingsView.svelte +36 -1
  26. package/src/components/UiShowcase.svelte +4 -1
  27. package/src/container-injections/code-server-dark.ts +216 -0
  28. package/src/lib/devcontainer.server.ts +37 -16
  29. package/src/lib/injections.server.ts +4 -2
  30. package/src/lib/instances.server.ts +6 -4
  31. package/src/settings.ts +13 -0
  32. package/src/shell.html +21 -6
  33. package/.mochi/svelte-client/HydratableIsland-dammyce5.js +0 -1
  34. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-hj9yvqc4.js +0 -38
  35. package/.mochi/svelte-client/chunk-r3wxmmc0.js +0 -2
  36. package/.mochi/svelte-client/chunk-vs6pd2b4.js +0 -2
  37. package/.mochi/svelte-css/AppShell-36mpg5smnbmuw.css +0 -1
@@ -55,6 +55,17 @@ function setSoundEnabled(on) {
55
55
  return;
56
56
  localStorage.setItem(SOUND_KEY, on ? "on" : "off");
57
57
  }
58
+ var TAB_SHORTCUTS_KEY = "codebay.tabShortcuts";
59
+ function tabShortcutsEnabled() {
60
+ if (typeof localStorage === "undefined")
61
+ return true;
62
+ return localStorage.getItem(TAB_SHORTCUTS_KEY) !== "off";
63
+ }
64
+ function setTabShortcutsEnabled(on) {
65
+ if (typeof localStorage === "undefined")
66
+ return;
67
+ localStorage.setItem(TAB_SHORTCUTS_KEY, on ? "on" : "off");
68
+ }
58
69
 
59
70
  // src/sound.ts
60
71
  var SOUNDS = {
@@ -110,4 +121,4 @@ function playChime(state) {
110
121
  el.play().then(() => console.log(`[chime] playChime(${state}) \u2014 playing`)).catch((err) => console.warn(`[chime] playChime(${state}) \u2014 play rejected:`, err?.message ?? err));
111
122
  }
112
123
 
113
- export { Terminal, Layout_template, Plus, soundEnabled, setSoundEnabled, unlockAudio, playChime };
124
+ export { Terminal, Layout_template, Plus, soundEnabled, setSoundEnabled, tabShortcutsEnabled, setTabShortcutsEnabled, unlockAudio, playChime };
@@ -0,0 +1 @@
1
+ .app.ide.svelte-1duc7gd{display:flex;flex-direction:column;height:100vh}.panes.svelte-1duc7gd{position:relative;flex:1;min-height:0}.panes.hidden.svelte-1duc7gd{display:none}.pane.svelte-1duc7gd{position:absolute;inset:0}.pane.svelte-1duc7gd:not(.active){display:none}iframe.svelte-1duc7gd{display:block;--buncss-light: ;--buncss-dark:initial;color-scheme:dark;border:0;width:100%;height:100%}.empty.svelte-1duc7gd{position:absolute;display:flex;color:var(--ink-faint);font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em;justify-content:center;align-items: center;font-size:13px;inset:0}
@@ -1 +1 @@
1
- .topbar.svelte-15fxkvm{display:flex;border-bottom:1px solid var(--rule);align-items: center;gap:18px;padding:16px 24px}.back.svelte-15fxkvm{display:inline-flex;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em;color:var(--ink-soft);text-decoration:none;align-items: center;gap:5px;font-size:12px}.back.svelte-15fxkvm:hover{color:var(--ink)}.title.svelte-15fxkvm{display:flex;flex:1;align-items: center;gap:12px}.name.svelte-15fxkvm{font-family:var(--font-display);text-transform:uppercase;letter-spacing:.03em;font-size:20px;font-weight:700}.open.svelte-15fxkvm{display:inline-flex;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em;color:var(--ink);text-decoration:none;border:1px solid var(--edge);align-items: center;gap:6px;padding:7px 12px;font-size:12px;font-weight:600}.open.svelte-15fxkvm:hover{background:var(--fill);border-color:var(--fill);color:var(--fill-ink)}.restart.svelte-15fxkvm{display:inline-flex;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em;color:var(--ink);background:var(--bg);border:1px solid var(--edge);cursor:pointer;align-items: center;gap:6px;padding:7px 12px;font-size:12px;font-weight:600}.restart.svelte-15fxkvm:hover:not(:disabled){background:var(--fill);border-color:var(--fill);color:var(--fill-ink)}.restart.svelte-15fxkvm:disabled{opacity:var(--dim-panel);cursor:not-allowed}.restart-err.svelte-15fxkvm{font-family:var(--font-mono);color:var(--danger);margin:0;padding:8px 24px 0;font-size:12px}.stage.svelte-15fxkvm{max-width:1200px;margin:0 auto;padding:20px 24px 40px}.meta.svelte-15fxkvm{display:grid;grid-template-columns:max-content 1fr;align-items: center;gap:6px 14px;margin-bottom:18px}.meta.svelte-15fxkvm .k:where(.svelte-15fxkvm){font-family:var(--font-display);letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint);font-size:12px;font-weight:700}.meta.svelte-15fxkvm code:where(.svelte-15fxkvm){font-family:var(--font-mono);color:var(--ink-soft);font-size:13px}.meta.svelte-15fxkvm code.muted:where(.svelte-15fxkvm){color:var(--ink-faint)}.healthslot.svelte-15fxkvm,.ports.svelte-15fxkvm{margin-bottom:18px}.ports-bar.svelte-15fxkvm{display:flex;justify-content:space-between;align-items: center}.rebuild.svelte-15fxkvm{font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.08em;background:var(--fill-ink);color:var(--fill);border:1px solid var(--fill-ink);cursor:pointer;padding:4px 9px;font-size:10px;font-weight:600}.rebuild.svelte-15fxkvm:hover:not(:disabled){color:var(--fill-ink);background:0 0}.rebuild.svelte-15fxkvm:disabled{opacity:var(--dim-panel);cursor:not-allowed}.ports-body.svelte-15fxkvm{background:var(--bg-card);padding:12px 14px 14px}.port-list.svelte-15fxkvm{list-style:none;display:flex;flex-direction:column;gap:6px;margin:0 0 12px;padding:0}.port-list.svelte-15fxkvm li:where(.svelte-15fxkvm){display:flex;font-family:var(--font-mono);align-items: center;gap:8px;font-size:13px}.port-list.svelte-15fxkvm .cp:where(.svelte-15fxkvm){color:var(--ink);font-weight:600}.port-list.svelte-15fxkvm .arr:where(.svelte-15fxkvm){color:var(--ink-faint)}.port-list.svelte-15fxkvm a:where(.svelte-15fxkvm){display:inline-flex;color:var(--ink);text-decoration:none;border-bottom:1px solid var(--ink-faint);align-items: center;gap:4px}.port-list.svelte-15fxkvm a:where(.svelte-15fxkvm):hover{border-bottom-color:var(--ink)}.rm.svelte-15fxkvm{border:1px solid var(--ink-faint);color:var(--ink-soft);cursor:pointer;background:0 0;width:20px;height:20px;margin-left:auto;font-size:15px;line-height:1}.rm.svelte-15fxkvm:hover{border-color:var(--danger);color:var(--danger)}.empty.svelte-15fxkvm{font-family:var(--font-mono);color:var(--ink-faint);margin:0 0 12px;font-size:12px}.add.svelte-15fxkvm{display:flex;gap:8px}.add.svelte-15fxkvm input:where(.svelte-15fxkvm){font-family:var(--font-mono);border:1px solid var(--ink);background:var(--bg);color:var(--ink);outline:none;flex:1;min-width:0;padding:6px 9px;font-size:13px}.add.svelte-15fxkvm button:where(.svelte-15fxkvm){font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em;border:1px solid var(--edge);background:var(--bg-card);color:var(--ink);cursor:pointer;padding:6px 14px;font-size:12px;font-weight:600}.add.svelte-15fxkvm button:where(.svelte-15fxkvm):hover{background:var(--fill);border-color:var(--fill);color:var(--fill-ink)}.port-err.svelte-15fxkvm{font-family:var(--font-mono);color:var(--danger);margin:10px 0 0;font-size:12px}.hint.svelte-15fxkvm{font-family:var(--font-mono);color:var(--ink-faint);margin:10px 0 0;font-size:11px}.hint.pending.svelte-15fxkvm{color:var(--ink-soft)}.hint.svelte-15fxkvm code:where(.svelte-15fxkvm){color:var(--ink)}.logwrap.svelte-15fxkvm{overflow:hidden}.log-bar.svelte-15fxkvm{display:flex;justify-content:space-between;align-items: center}.log-actions.svelte-15fxkvm{display:flex;align-items: center;gap:6px}.copy.svelte-15fxkvm{display:inline-flex;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.08em;background:var(--fill-ink);color:var(--fill);border:1px solid var(--fill-ink);cursor:pointer;align-items: center;gap:5px;padding:4px 9px;font-size:10px;font-weight:600}.copy.svelte-15fxkvm:hover:not(:disabled),.copy.active.svelte-15fxkvm{color:var(--fill-ink);background:0 0}.dot.svelte-15fxkvm{background:var(--attn-done);border-radius:50%;flex:none;width:6px;height:6px}.copy.svelte-15fxkvm:disabled{opacity:var(--dim-panel);cursor:not-allowed}.logs.svelte-15fxkvm{position:relative;background:repeating-linear-gradient(0deg,var(--screen-line)0 1px,transparent 1px 3px),var(--screen-bg);color:var(--screen-ink);overflow:auto;overflow-anchor:none;height:calc(100vh - 320px);min-height:360px;padding:14px}.logs.svelte-15fxkvm pre:where(.svelte-15fxkvm){font-family:var(--font-mono);white-space:pre-wrap;word-break:break-word;margin:0;font-size:12.5px;line-height:1.5}@keyframes svelte-15fxkvm-lcd-blink{50%{background:0 0}}.caret.svelte-15fxkvm{display:inline-block;background:var(--screen-ink);vertical-align:text-bottom;animation:svelte-15fxkvm-lcd-blink 1.05s steps(1)infinite;width:.6em;height:1.05em;margin-left:2px}.err.svelte-15fxkvm{background:var(--bg-card);color:var(--ink);border-top:2px solid var(--ink);font-family:var(--font-mono);padding:12px 14px;font-size:13px}
1
+ .topbar.svelte-15fxkvm{display:flex;border-bottom:1px solid var(--rule);align-items: center;gap:18px;padding:16px 24px}.back.svelte-15fxkvm{display:inline-flex;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em;color:var(--ink-soft);text-decoration:none;align-items: center;gap:5px;font-size:12px}.back.svelte-15fxkvm:hover{color:var(--ink)}.title.svelte-15fxkvm{display:flex;flex:1;align-items: center;gap:12px}.name.svelte-15fxkvm{font-family:var(--font-display);text-transform:uppercase;letter-spacing:.03em;font-size:20px;font-weight:700}.open.svelte-15fxkvm{display:inline-flex;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em;color:var(--ink);text-decoration:none;border:1px solid var(--edge);align-items: center;gap:6px;padding:7px 12px;font-size:12px;font-weight:600}.open.svelte-15fxkvm:hover{background:var(--fill);border-color:var(--fill);color:var(--fill-ink)}.restart.svelte-15fxkvm{display:inline-flex;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em;color:var(--ink);background:var(--bg);border:1px solid var(--edge);cursor:pointer;align-items: center;gap:6px;padding:7px 12px;font-size:12px;font-weight:600}.restart.svelte-15fxkvm:hover:not(:disabled){background:var(--fill);border-color:var(--fill);color:var(--fill-ink)}.restart.svelte-15fxkvm:disabled{opacity:var(--dim-panel);cursor:not-allowed}.restart-err.svelte-15fxkvm{font-family:var(--font-mono);color:var(--danger);margin:0;padding:8px 24px 0;font-size:12px}.stage.svelte-15fxkvm{max-width:1200px;margin:0 auto;padding:20px 24px 40px}.meta.svelte-15fxkvm{display:grid;grid-template-columns:max-content 1fr;align-items: center;gap:6px 14px;margin-bottom:18px}.meta.svelte-15fxkvm .k:where(.svelte-15fxkvm){font-family:var(--font-display);letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint);font-size:12px;font-weight:700}.meta.svelte-15fxkvm code:where(.svelte-15fxkvm){font-family:var(--font-mono);color:var(--ink-soft);font-size:13px}.meta.svelte-15fxkvm code.muted:where(.svelte-15fxkvm){color:var(--ink-faint)}.healthslot.svelte-15fxkvm,.ports.svelte-15fxkvm{margin-bottom:18px}.ports-bar.svelte-15fxkvm{display:flex;justify-content:space-between;align-items: center}.rebuild.svelte-15fxkvm{font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.08em;background:var(--fill-ink);color:var(--fill);border:1px solid var(--fill-ink);cursor:pointer;padding:4px 9px;font-size:10px;font-weight:600}.rebuild.svelte-15fxkvm:hover:not(:disabled){color:var(--fill-ink);background:0 0}.rebuild.svelte-15fxkvm:disabled{opacity:var(--dim-panel);cursor:not-allowed}.ports-body.svelte-15fxkvm{background:var(--bg-card);padding:12px 14px 14px}.port-list.svelte-15fxkvm{list-style:none;display:flex;flex-direction:column;gap:6px;margin:0 0 12px;padding:0}.port-list.svelte-15fxkvm li:where(.svelte-15fxkvm){display:flex;font-family:var(--font-mono);align-items: center;gap:8px;font-size:13px}.port-list.svelte-15fxkvm .cp:where(.svelte-15fxkvm){color:var(--ink);font-weight:600}.port-list.svelte-15fxkvm .arr:where(.svelte-15fxkvm){color:var(--ink-faint)}.port-list.svelte-15fxkvm a:where(.svelte-15fxkvm){display:inline-flex;color:var(--ink);text-decoration:none;border-bottom:1px solid var(--ink-faint);align-items: center;gap:4px}.port-list.svelte-15fxkvm a:where(.svelte-15fxkvm):hover{border-bottom-color:var(--ink)}.rm.svelte-15fxkvm{border:1px solid var(--ink-faint);color:var(--ink-soft);cursor:pointer;background:0 0;width:20px;height:20px;margin-left:auto;font-size:15px;line-height:1}.rm.svelte-15fxkvm:hover{border-color:var(--danger);color:var(--danger)}.empty.svelte-15fxkvm{font-family:var(--font-mono);color:var(--ink-faint);margin:0 0 12px;font-size:12px}.add.svelte-15fxkvm{display:flex;gap:8px}.add.svelte-15fxkvm input:where(.svelte-15fxkvm){font-family:var(--font-mono);border:1px solid var(--ink);background:var(--bg);color:var(--ink);outline:none;flex:1;min-width:0;padding:6px 9px;font-size:13px}.add.svelte-15fxkvm button:where(.svelte-15fxkvm){font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em;border:1px solid var(--edge);background:var(--bg-card);color:var(--ink);cursor:pointer;padding:6px 14px;font-size:12px;font-weight:600}.add.svelte-15fxkvm button:where(.svelte-15fxkvm):hover{background:var(--fill);border-color:var(--fill);color:var(--fill-ink)}.port-err.svelte-15fxkvm{font-family:var(--font-mono);color:var(--danger);margin:10px 0 0;font-size:12px}.hint.svelte-15fxkvm{font-family:var(--font-mono);color:var(--ink-faint);margin:10px 0 0;font-size:11px}.hint.pending.svelte-15fxkvm{color:var(--ink-soft)}.hint.svelte-15fxkvm code:where(.svelte-15fxkvm){color:var(--ink)}.logwrap.svelte-15fxkvm{overflow:hidden}.log-bar.svelte-15fxkvm{display:flex;justify-content:space-between;align-items: center}.log-actions.svelte-15fxkvm{display:flex;align-items: center;gap:6px}.copy.svelte-15fxkvm{display:inline-flex;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.08em;background:var(--fill-ink);color:var(--fill);border:1px solid var(--fill-ink);cursor:pointer;align-items: center;gap:5px;padding:4px 9px;font-size:10px;font-weight:600}.copy.svelte-15fxkvm:hover:not(:disabled),.copy.active.svelte-15fxkvm{color:var(--fill-ink);background:0 0}.dot.svelte-15fxkvm{background:var(--live-dot);border-radius:50%;flex:none;width:6px;height:6px}.copy.svelte-15fxkvm:disabled{opacity:var(--dim-panel);cursor:not-allowed}.logs.svelte-15fxkvm{position:relative;background:repeating-linear-gradient(0deg,var(--screen-line)0 1px,transparent 1px 3px),var(--screen-bg);color:var(--screen-ink);overflow:auto;overflow-anchor:none;height:calc(100vh - 320px);min-height:360px;padding:14px}.logs.svelte-15fxkvm pre:where(.svelte-15fxkvm){font-family:var(--font-mono);white-space:pre-wrap;word-break:break-word;margin:0;font-size:12.5px;line-height:1.5}@keyframes svelte-15fxkvm-lcd-blink{50%{background:0 0}}.caret.svelte-15fxkvm{display:inline-block;background:var(--screen-ink);vertical-align:text-bottom;animation:svelte-15fxkvm-lcd-blink 1.05s steps(1)infinite;width:.6em;height:1.05em;margin-left:2px}.err.svelte-15fxkvm{background:var(--bg-card);color:var(--ink);border-top:2px solid var(--ink);font-family:var(--font-mono);padding:12px 14px;font-size:13px}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebay",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Web UI for spinning up isolated devcontainer instances from any local folder, each with a browser-based VS Code (code-server) — built for parallel Claude Code workflows.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,6 +9,7 @@
9
9
  import IdeLoader from './IdeLoader.svelte';
10
10
  import TerminalSplit from './TerminalSplit.svelte';
11
11
  import { playChime, unlockAudio } from '../sound.ts';
12
+ import { tabShortcutsEnabled } from '../settings.ts';
12
13
  import { liveStream } from '../live.ts';
13
14
  import { apiPost } from '../api.ts';
14
15
  import { syncTheme } from '../theme.ts';
@@ -189,7 +190,7 @@
189
190
  // for its own tabs and can't be intercepted, and Ctrl+Alt is AltGr on many European
190
191
  // layouts, so it would collide with typing punctuation.
191
192
  function jumpToTab(e: KeyboardEvent, fromFrame: boolean) {
192
- if (!e.altKey || e.ctrlKey || e.metaKey || editingId) return;
193
+ if (!e.altKey || e.ctrlKey || e.metaKey || editingId || !tabShortcutsEnabled()) return;
193
194
  // `code`, not `key`: Alt+digit emits punctuation on macOS layouts.
194
195
  const digit = /^Digit([1-9])$/.exec(e.code)?.[1];
195
196
  if (!digit) return;
@@ -440,6 +441,9 @@
440
441
  height: 100%;
441
442
  border: 0;
442
443
  display: block;
444
+ /* An embedded document takes its prefers-color-scheme from the embedder, so without this
445
+ Codebay's own light theme makes code-server's startup theme resolve light. */
446
+ color-scheme: dark;
443
447
  }
444
448
  .empty {
445
449
  position: absolute;
@@ -574,7 +574,7 @@
574
574
  width: 6px;
575
575
  height: 6px;
576
576
  border-radius: 50%;
577
- background: var(--attn-done);
577
+ background: var(--live-dot);
578
578
  }
579
579
  .copy:disabled {
580
580
  opacity: var(--dim-panel);
@@ -4,6 +4,7 @@
4
4
  import Power from '@lucide/svelte/icons/power';
5
5
  import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
6
6
  import Volume2 from '@lucide/svelte/icons/volume-2';
7
+ import Keyboard from '@lucide/svelte/icons/keyboard';
7
8
  import PawPrint from '@lucide/svelte/icons/paw-print';
8
9
  import SunMoon from '@lucide/svelte/icons/sun-moon';
9
10
  import ThemePicker from './ThemePicker.svelte';
@@ -27,7 +28,12 @@
27
28
  import { flushSync } from 'svelte';
28
29
  import { enhance } from 'mochi-framework';
29
30
  import type { MochiEnhanceOptions } from 'mochi-framework';
30
- import { soundEnabled, setSoundEnabled } from '../settings.ts';
31
+ import {
32
+ soundEnabled,
33
+ setSoundEnabled,
34
+ tabShortcutsEnabled,
35
+ setTabShortcutsEnabled
36
+ } from '../settings.ts';
31
37
  import { playChime, unlockAudio } from '../sound.ts';
32
38
  import Button from './Button.svelte';
33
39
  import CoinButton from './CoinButton.svelte';
@@ -133,6 +139,7 @@
133
139
 
134
140
  // Defaults to on during SSR, where localStorage doesn't exist.
135
141
  let sound = $state(soundEnabled());
142
+ let tabShortcuts = $state(tabShortcutsEnabled());
136
143
 
137
144
  // DB-backed, so it initializes from the prop. Undefined is the off state — the header keeps its box logo.
138
145
  // svelte-ignore state_referenced_locally
@@ -780,6 +787,11 @@
780
787
  if (on) playChime('done');
781
788
  }
782
789
 
790
+ function toggleTabShortcuts(on: boolean) {
791
+ tabShortcuts = on;
792
+ setTabShortcutsEnabled(on);
793
+ }
794
+
783
795
  // State is object-shaped (the chosen sprite), so this can't reuse the boolean `toggleOpts`.
784
796
  const petToggleOpts: MochiEnhanceOptions<{ enabled: boolean; name?: string }, ActionFailure> = {
785
797
  onPending: (v) => (savingPet = v),
@@ -1980,6 +1992,29 @@
1980
1992
  </div>
1981
1993
  </section>
1982
1994
 
1995
+ <section class="card">
1996
+ <div class="row">
1997
+ <div class="label">
1998
+ <Keyboard size={20} />
1999
+ <div class="text">
2000
+ <div class="name">Tab keyboard shortcuts</div>
2001
+ <div class="desc">
2002
+ Jump between open instance tabs with <code>Alt</code>+<code>1</code>–<code>9</code> (9 is
2003
+ always the last tab). Turn off to leave those keystrokes to the editor.
2004
+ </div>
2005
+ </div>
2006
+ </div>
2007
+ <label class="switch">
2008
+ <input
2009
+ type="checkbox"
2010
+ checked={tabShortcuts}
2011
+ onchange={(e) => toggleTabShortcuts(e.currentTarget.checked)}
2012
+ />
2013
+ <span class="track"><span class="thumb"></span></span>
2014
+ </label>
2015
+ </div>
2016
+ </section>
2017
+
1983
2018
  <section class="card">
1984
2019
  <form class="row" method="POST" action="?/petToggle" {@attach enhance(petToggleOpts)}>
1985
2020
  <div class="label">
@@ -45,7 +45,10 @@
45
45
  '--info'
46
46
  ]
47
47
  },
48
- { group: 'Attention', tokens: ['--attn-done', '--attn-waiting', '--switch-on-bg'] }
48
+ {
49
+ group: 'Attention',
50
+ tokens: ['--attn-done', '--attn-waiting', '--live-dot', '--switch-on-bg']
51
+ }
49
52
  ];
50
53
 
51
54
  const chips: Record<string, HTMLElement> = {};
@@ -0,0 +1,216 @@
1
+ import { checkPresence, execInContainer, type ExecTarget } from '../lib/exec.server.ts';
2
+ import { editJsonFile, readJsonFile } from '../lib/container-files.server.ts';
3
+ import { CODE_SERVER_SETTINGS } from '../lib/devcontainer.server.ts';
4
+ import type { Injection } from '../lib/injections.server.ts';
5
+
6
+ /** Locates the bundled VS Code from the binary; shared prelude for the resolve and the check. */
7
+ const WALK_TO_BUNDLE =
8
+ `p=$(command -v code-server) || exit 1; p=$(readlink -f "$p"); ` +
9
+ `while [ "$p" != "/" ]; do for c in "$p" "$p/lib/vscode"; do `;
10
+
11
+ /**
12
+ * Walks up from the resolved binary rather than assuming /usr/lib/code-server: the feature and the
13
+ * standalone tarball put the wrapper at different depths, and only one of the two layouts keeps the
14
+ * bundled VS Code directly above `bin/`. Prints the root on the last line — `bash -lc` runs profile
15
+ * scripts whose stdout comes first, so callers must read the last line, never the whole capture.
16
+ */
17
+ export const RESOLVE_ROOT_SCRIPT =
18
+ WALK_TO_BUNDLE +
19
+ `[ -d "$c/extensions/theme-defaults" ] && { printf '\\n%s\\n' "$c"; exit 0; }; done; ` +
20
+ `p=$(dirname "$p"); done; exit 1`;
21
+
22
+ /**
23
+ * Whitespace-tolerant because the shipped manifest is minified (`"uiTheme":"vs"`) while our own
24
+ * rewrite pretty-prints it — a fixed-spacing pattern would miss the unpatched file and report green.
25
+ * The closing quote is what keeps `"vs"` from also matching `"vs-dark"`.
26
+ */
27
+ const LIGHT_UI_THEME_RE = '"uiTheme"[[:space:]]*:[[:space:]]*"(vs|hc-light)"';
28
+
29
+ /**
30
+ * Resolve and probe in one exec: `check()` runs on every health tick, in the same `Promise.all` as
31
+ * the `codeServerAccessible` probe that gates mounting the IDE iframe, so a second round trip here
32
+ * would slow the boot-time fast cadence for no gain — the root can't move for a container's life.
33
+ */
34
+ export const CHECK_SCRIPT =
35
+ WALK_TO_BUNDLE +
36
+ `m="$c/extensions/theme-defaults/package.json"; ` +
37
+ `[ -f "$m" ] && { grep -qE '${LIGHT_UI_THEME_RE}' "$m" && echo 0 || echo 1; exit 0; }; done; ` +
38
+ `p=$(dirname "$p"); done; echo 0`;
39
+
40
+ /**
41
+ * The builtin-extension scan is cached against the mtime of the `extensions/` **directory**, which
42
+ * rewriting a manifest inside it does not touch — so without invalidating both the cache file and
43
+ * that mtime, the running server and the next window keep serving the pre-rewrite themes.
44
+ */
45
+ const CLEAR_BUILTIN_CACHE =
46
+ `rm -f ~/.local/share/code-server/CachedProfilesData/*/extensions.builtin.cache 2>/dev/null; ` +
47
+ `exit 0`;
48
+
49
+ const SETTINGS_DIR = '$h/.local/share/code-server/User';
50
+
51
+ /**
52
+ * VS Code matches `workbench.colorTheme` against a theme's *settingsId*, which is its manifest `id`
53
+ * (the label is only a fallback for entries without one) — so this reads ids, never the nls labels.
54
+ * The order tracks the build default (`ThemeSettingDefaults.COLOR_THEME_DARK`) across versions,
55
+ * because pinning exactly that id is what lets the workbench paint its real dark colors before any
56
+ * extension loads. Anything unrecognised falls back to whatever dark theme the build does ship.
57
+ */
58
+ const PREFERRED_DARK_IDS = ['Dark 2026', 'Default Dark Modern', 'Dark Modern', 'Dark+'];
59
+
60
+ const THEME_KEYS = [
61
+ 'workbench.colorTheme',
62
+ 'workbench.preferredDarkColorTheme',
63
+ 'workbench.preferredLightColorTheme'
64
+ ];
65
+
66
+ /** A root path we are about to interpolate into a shell script, so nothing exotic may pass. */
67
+ const SAFE_PATH = /^\/[A-Za-z0-9._+\-/]+$/;
68
+
69
+ /** Mirrors `checkPresence`: only the last line is ours, everything before it is login-shell noise. */
70
+ function lastLine(stdout: string): string {
71
+ return stdout.trimEnd().split('\n').pop()?.trim() ?? '';
72
+ }
73
+
74
+ interface ThemeContribution {
75
+ id?: string;
76
+ uiTheme?: string;
77
+ path?: string;
78
+ }
79
+
80
+ function themesOf(manifest: Record<string, unknown>): ThemeContribution[] | null {
81
+ const contributes = manifest.contributes;
82
+ if (typeof contributes !== 'object' || contributes === null) return null;
83
+ const themes = (contributes as Record<string, unknown>).themes;
84
+ return Array.isArray(themes) ? (themes as ThemeContribution[]) : null;
85
+ }
86
+
87
+ /** Null when the build ships no identifiable dark theme — better to leave the staged settings alone than pin an id nothing resolves. */
88
+ export function pickDarkThemeId(manifest: Record<string, unknown>): string | null {
89
+ const ids = (themesOf(manifest) ?? [])
90
+ .filter((t) => t?.uiTheme === 'vs-dark' && typeof t.id === 'string' && t.id.trim())
91
+ .map((t) => t.id!.trim());
92
+ if (!ids.length) return null;
93
+ return PREFERRED_DARK_IDS.find((id) => ids.includes(id)) ?? ids[0]!;
94
+ }
95
+
96
+ /**
97
+ * Selecting by `uiTheme` rather than by filename is load-bearing: the light set is
98
+ * 2026-light/light_modern/light_plus/light_vs *and* hc_light, so a `light*.json` glob silently
99
+ * misses two of them. Repointing `path` too, since the light JSONs stay light on disk.
100
+ */
101
+ export function darkenThemeManifest(manifest: Record<string, unknown>): {
102
+ next: Record<string, unknown>;
103
+ changed: number;
104
+ } {
105
+ const themes = themesOf(manifest);
106
+ if (!themes) return { next: manifest, changed: 0 };
107
+
108
+ const pathFor = (ui: string) =>
109
+ themes.find((t) => t?.uiTheme === ui && typeof t.path === 'string')?.path;
110
+ const darkPath = pathFor('vs-dark');
111
+ const hcDarkPath = pathFor('hc-black');
112
+
113
+ let changed = 0;
114
+ const nextThemes = themes.map((theme) => {
115
+ if (theme?.uiTheme === 'vs' && darkPath) {
116
+ changed++;
117
+ return { ...theme, uiTheme: 'vs-dark', path: darkPath };
118
+ }
119
+ if (theme?.uiTheme === 'hc-light' && hcDarkPath) {
120
+ changed++;
121
+ return { ...theme, uiTheme: 'hc-black', path: hcDarkPath };
122
+ }
123
+ return theme;
124
+ });
125
+
126
+ const contributes = manifest.contributes as Record<string, unknown>;
127
+ return {
128
+ next: { ...manifest, contributes: { ...contributes, themes: nextThemes } },
129
+ changed
130
+ };
131
+ }
132
+
133
+ /** Only the writes need root; resolution runs as the remote user, whose PATH also carries a per-user standalone install. */
134
+ const rootTarget = (target: ExecTarget): ExecTarget => ({ containerId: target.containerId });
135
+
136
+ async function resolveRoot(target: ExecTarget): Promise<string | null> {
137
+ const res = await execInContainer(target, { script: RESOLVE_ROOT_SCRIPT, capture: true });
138
+ if (!res.ok) return null;
139
+ const root = lastLine(res.stdout);
140
+ return SAFE_PATH.test(root) ? root : null;
141
+ }
142
+
143
+ /**
144
+ * VS Code Web's pre-extension paint falls back to `isWeb ? LIGHT : DARK` whenever it has no usable
145
+ * cached theme, so pinning the theme in settings alone can never keep the first frame dark. This
146
+ * closes both halves: it pins the theme id *this* build uses (so the workbench stops discarding its
147
+ * own cache), and rewrites the bundled manifest so the light themes render dark if one is selected.
148
+ */
149
+ export const codeServerDark: Injection = {
150
+ id: 'code-server-dark',
151
+ label: 'code-server dark theme',
152
+ // code-server-only; terminal-mode instances never run a workbench.
153
+ modes: ['ide'],
154
+
155
+ async apply(target, log) {
156
+ log('Forcing code-server dark theme…\n');
157
+ const root = await resolveRoot(target);
158
+ if (!root) {
159
+ log('⚠ code-server install not found — dark theme left to the staged settings\n');
160
+ return;
161
+ }
162
+
163
+ const themeDir = `${root}/extensions/theme-defaults`;
164
+ const manifest = await readJsonFile(target, { dir: themeDir, name: 'package.json' });
165
+ if (!manifest) {
166
+ log('⚠ Could not read the bundled theme manifest — dark theme left to the staged settings\n');
167
+ return;
168
+ }
169
+
170
+ const darkTheme = pickDarkThemeId(manifest);
171
+ if (!darkTheme) {
172
+ log('⚠ No dark theme found in the bundled manifest — staged settings left as they are\n');
173
+ } else {
174
+ // Seeded from the staged defaults so this still writes a complete file on the path where
175
+ // the launcher's own copy silently failed, rather than a settings.json of three keys.
176
+ const settings = await editJsonFile(
177
+ target,
178
+ { dir: SETTINGS_DIR, name: 'settings.json' },
179
+ (current) => ({
180
+ ...CODE_SERVER_SETTINGS,
181
+ ...current,
182
+ ...Object.fromEntries(THEME_KEYS.map((k) => [k, darkTheme]))
183
+ })
184
+ );
185
+ log(
186
+ settings.ok
187
+ ? `✓ Pinned code-server theme to "${darkTheme}"\n`
188
+ : `⚠ Could not pin the code-server theme: ${settings.error}\n`
189
+ );
190
+ }
191
+
192
+ if (darkenThemeManifest(manifest).changed === 0) {
193
+ log('· No light themes to darken in this build\n');
194
+ return;
195
+ }
196
+ const written = await editJsonFile(
197
+ rootTarget(target),
198
+ { dir: themeDir, name: 'package.json' },
199
+ (current) => darkenThemeManifest(current).next
200
+ );
201
+ if (!written.ok) {
202
+ log(`⚠ Could not darken the built-in light themes: ${written.error}\n`);
203
+ return;
204
+ }
205
+ await execInContainer(target, { script: CLEAR_BUILTIN_CACHE });
206
+ // Bumping the scan's cache key; root because the bundled tree isn't the remote user's.
207
+ await execInContainer(rootTarget(target), {
208
+ script: `touch "${root}/extensions" 2>/dev/null; exit 0`
209
+ });
210
+ log('✓ Built-in light themes now render dark\n');
211
+ },
212
+
213
+ check(target) {
214
+ return checkPresence(target, CHECK_SCRIPT);
215
+ }
216
+ };
@@ -124,12 +124,17 @@ const MANAGER_GIT_EXCLUDES = [
124
124
  const EXCLUDE_MARKER_START = '# >>> codebay (auto-generated) >>>';
125
125
  const EXCLUDE_MARKER_END = '# <<< codebay <<<';
126
126
 
127
- const CODE_SERVER_SETTINGS = {
127
+ export const CODE_SERVER_SETTINGS = {
128
+ // Overwritten at boot by the code-server-dark injection with the id this build actually
129
+ // ships; the legacy label only has to carry a code-server old enough to still use it.
128
130
  'workbench.colorTheme': 'Default Dark Modern',
129
- // Stop VS Code re-resolving the theme from the OS/browser color scheme at runtime —
130
- // that re-resolution (fired on focus/tab-switch) is what randomly flips the editor to
131
- // light. Pinning both preferred themes to dark keeps either branch dark even if it does.
132
- 'window.autoDetectColorScheme': false,
131
+ // Counter-intuitive, and the opposite of what this file used to say: VS Code Web's
132
+ // pre-extension paint is `getPreferredColorScheme() ?? (isWeb ? LIGHT : DARK)`, and
133
+ // getPreferredColorScheme returns undefined unless detection is on — so turning detection
134
+ // OFF is what forced that first paint to light. On is only safe because both preferred
135
+ // themes below are the same dark theme, making either branch dark.
136
+ 'window.autoDetectColorScheme': true,
137
+ // Left off so the high-contrast branch (which could resolve hcLight) stays unreachable.
133
138
  'window.autoDetectHighContrast': false,
134
139
  'workbench.preferredDarkColorTheme': 'Default Dark Modern',
135
140
  'workbench.preferredLightColorTheme': 'Default Dark Modern',
@@ -241,10 +246,20 @@ const terminalTask = (permissionMode: ClaudePermissionMode) => ({
241
246
  problemMatcher: []
242
247
  });
243
248
 
249
+ const CODE_SERVER_USER_DIR = '~/.local/share/code-server/User';
250
+
251
+ /**
252
+ * Seeds the file only when it's missing. `relaunchSurface` re-runs this launcher *after* the
253
+ * injections have rewritten the theme id to the one the installed build uses, so an unconditional
254
+ * copy would put the stale staged id back and re-arm the very cache mismatch this all exists to
255
+ * avoid. A rebuild always gets a fresh container, so the staged defaults still land on every build.
256
+ * `[ -f ]` rather than `cp -n`, which busybox coreutils don't reliably carry.
257
+ */
244
258
  const CODE_SERVER_APPLY_SETTINGS =
245
- `mkdir -p ~/.local/share/code-server/User && ` +
259
+ `mkdir -p ${CODE_SERVER_USER_DIR}; ` +
260
+ `[ -f ${CODE_SERVER_USER_DIR}/settings.json ] || ` +
246
261
  `cp -f \\"$PWD/.devcontainer/${CODE_SERVER_SETTINGS_FILE}\\" ` +
247
- `~/.local/share/code-server/User/settings.json 2>/dev/null;`;
262
+ `${CODE_SERVER_USER_DIR}/settings.json 2>/dev/null;`;
248
263
 
249
264
  // Fully detached (redirects + </dev/null + &) because `devcontainer up` waits for postStart's
250
265
  // pipes to close — an attached download would keep the whole boot on the Open VSX critical path.
@@ -456,7 +471,10 @@ export function findDevcontainerConfig(dir: string): string | null {
456
471
  const CODEBAY_CONFIG_NESTED = 'codebay.devcontainer.json';
457
472
  const CODEBAY_CONFIG_FLAT = '.codebay.devcontainer.json';
458
473
 
459
- /** Sits in the same directory as the project's own config so relative paths resolve identically. */
474
+ /**
475
+ * Sits in the same directory as the project's own config, which is what `--config` points the CLI at:
476
+ * relative dockerfile/compose/feature paths resolve against that directory either way.
477
+ */
460
478
  export function overrideConfigPath(
461
479
  workspaceDir: string,
462
480
  canonical: string | null = findDevcontainerConfig(workspaceDir)
@@ -524,7 +542,7 @@ const HOST_GATEWAY_ARG = '--add-host=host.docker.internal:host-gateway';
524
542
 
525
543
  /**
526
544
  * Merges the project's config (read-only) into a Codebay-owned sibling file that `devcontainer up
527
- * --config` boots from, so the project's own devcontainer.json never shows as modified in git.
545
+ * --override-config` boots from, so the project's own devcontainer.json never shows as modified in git.
528
546
  */
529
547
  export async function writeOverrideConfig(
530
548
  workspaceDir: string,
@@ -534,7 +552,7 @@ export async function writeOverrideConfig(
534
552
  mode: InstanceMode = 'ide',
535
553
  permissionMode: ClaudePermissionMode = 'default',
536
554
  envVars: { name: string; value: string }[] = []
537
- ): Promise<{ imageSource: string; configPath: string }> {
555
+ ): Promise<{ imageSource: string; configPath: string | null; overrideConfigPath: string }> {
538
556
  const isTerminal = mode === 'terminal';
539
557
  const canonical = findDevcontainerConfig(workspaceDir);
540
558
  const target = overrideConfigPath(workspaceDir, canonical);
@@ -640,7 +658,7 @@ export async function writeOverrideConfig(
640
658
 
641
659
  await writeLocalGitExclude(workspaceDir);
642
660
 
643
- return { imageSource, configPath: target };
661
+ return { imageSource, configPath: canonical, overrideConfigPath: target };
644
662
  }
645
663
 
646
664
  /** `codebay-tmux` has been injected into every config this manager has ever written. */
@@ -902,10 +920,10 @@ export interface UpResult {
902
920
  description?: string;
903
921
  }
904
922
 
905
- export function devcontainerUpArgs(
906
- workspaceDir: string,
907
- opts: { noCache?: boolean; configPath?: string } = {}
908
- ) {
923
+ /** `configPath` is the project's own config (`--config`), `overrideConfigPath` the Codebay-owned one. */
924
+ type UpOptions = { noCache?: boolean; configPath?: string; overrideConfigPath?: string };
925
+
926
+ export function devcontainerUpArgs(workspaceDir: string, opts: UpOptions = {}) {
909
927
  const args = [
910
928
  devcontainerBin(),
911
929
  'up',
@@ -913,7 +931,10 @@ export function devcontainerUpArgs(
913
931
  workspaceDir,
914
932
  '--remove-existing-container'
915
933
  ];
934
+ // --config rejects any basename but devcontainer.json/.devcontainer.json, so the Codebay-owned
935
+ // file rides --override-config; --config only pins which path relative resolution is based on.
916
936
  if (opts.configPath) args.push('--config', opts.configPath);
937
+ if (opts.overrideConfigPath) args.push('--override-config', opts.overrideConfigPath);
917
938
  // Only takes effect because --remove-existing-container drops the container before the build.
918
939
  if (opts.noCache) args.push('--build-no-cache');
919
940
  return args;
@@ -931,7 +952,7 @@ export function devcontainerUpEnv(): Record<string, string | undefined> {
931
952
  export async function devcontainerUp(
932
953
  workspaceDir: string,
933
954
  onLog: (chunk: string) => void,
934
- opts: { noCache?: boolean; configPath?: string } = {}
955
+ opts: UpOptions = {}
935
956
  ): Promise<UpResult> {
936
957
  const proc = Bun.spawn(devcontainerUpArgs(workspaceDir, opts), {
937
958
  cwd: workspaceDir,
@@ -8,6 +8,7 @@ import { gitIdentity } from '../container-injections/git-identity.ts';
8
8
  import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
9
9
  import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
10
10
  import { claudeCodeIdeExtension } from '../container-injections/claude-code-ide-extension.ts';
11
+ import { codeServerDark } from '../container-injections/code-server-dark.ts';
11
12
  import { claudeCodeInstall } from '../container-injections/claude-code-install.ts';
12
13
  import { claudeCodeUpdate } from '../container-injections/claude-code-update.ts';
13
14
  import { claudeCodeModels } from '../container-injections/claude-code-models.ts';
@@ -69,8 +70,9 @@ function buildStages(claudeInjection: Injection): Injection[][] {
69
70
  claudeEffortLevel
70
71
  ],
71
72
  // git-identity needs stage 1's safe.directory; ttyd shares the apt/dpkg lock with tmux and
72
- // the /usr/local/bin symlink with claude-code-install, so it trails both.
73
- [gitIdentity, attentionHooks, claudeCodeModels, ttyd, claudeCodeUpdate],
73
+ // the /usr/local/bin symlink with claude-code-install, so it trails both. code-server-dark
74
+ // drops the extension cache, so it must not run while stage 1 is still installing into it.
75
+ [gitIdentity, attentionHooks, claudeCodeModels, ttyd, claudeCodeUpdate, codeServerDark],
74
76
  [githubCredentials, claudeStatusline, claudePermissionMode],
75
77
  [claudeModel, claudeAliases],
76
78
  // claude-trust edits ~/.claude.json, which the stage-1 Claude slot also writes.
@@ -467,7 +467,7 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
467
467
  }));
468
468
  appendLog(row.id, `Injecting ${surfaceLabel(row.mode)} (host port ${row.host_port})\n`);
469
469
  const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
470
- const { imageSource, configPath } = await writeOverrideConfig(
470
+ const { imageSource, configPath, overrideConfigPath } = await writeOverrideConfig(
471
471
  row.workspace_path,
472
472
  row.host_port,
473
473
  forwards,
@@ -481,8 +481,9 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
481
481
  const noCache = opts.noCache || getOption('disable_build_cache') === '1';
482
482
  if (noCache) appendLog(row.id, `Building without cache (--build-no-cache)\n`);
483
483
 
484
- // The CLI's --remove-existing-container matches by the devcontainer.config_file label, which
485
- // no longer matches containers built from the pre-separate-config path — drop them ourselves.
484
+ // The CLI's --remove-existing-container matches by the devcontainer.config_file label, and
485
+ // deliberately ignores a workspace match whose label differs — so a container left by a build
486
+ // that labelled itself differently would survive and keep the host port. Drop it ourselves.
486
487
  if (row.container_id && !(await removeContainerOnly(row.container_id))) {
487
488
  appendLog(
488
489
  row.id,
@@ -494,7 +495,8 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
494
495
  const upStart = Date.now();
495
496
  const result = await devcontainerUp(row.workspace_path, (chunk) => appendLog(row.id, chunk), {
496
497
  noCache,
497
- configPath
498
+ configPath: configPath ?? undefined,
499
+ overrideConfigPath
498
500
  });
499
501
  appendLog(row.id, `⏱ devcontainer up: ${elapsed(upStart)}\n`);
500
502
 
package/src/settings.ts CHANGED
@@ -12,3 +12,16 @@ export function setSoundEnabled(on: boolean): void {
12
12
  if (typeof localStorage === 'undefined') return;
13
13
  localStorage.setItem(SOUND_KEY, on ? 'on' : 'off');
14
14
  }
15
+
16
+ const TAB_SHORTCUTS_KEY = 'codebay.tabShortcuts';
17
+
18
+ /** Defaults to on — only an explicit 'off' disables the Alt+1-9 tab jumps. */
19
+ export function tabShortcutsEnabled(): boolean {
20
+ if (typeof localStorage === 'undefined') return true;
21
+ return localStorage.getItem(TAB_SHORTCUTS_KEY) !== 'off';
22
+ }
23
+
24
+ export function setTabShortcutsEnabled(on: boolean): void {
25
+ if (typeof localStorage === 'undefined') return;
26
+ localStorage.setItem(TAB_SHORTCUTS_KEY, on ? 'on' : 'off');
27
+ }
package/src/shell.html CHANGED
@@ -57,12 +57,16 @@
57
57
  --screen-ink: light-dark(#d8d9cf, #e8b055);
58
58
  --screen-line: light-dark(rgba(255, 255, 255, 0.04), rgba(232, 176, 85, 0.05));
59
59
  --attn-done: #22c55e;
60
+ /* The "live" LED on an inverted chip. Green reads as a lit indicator on
61
+ light's near-black chip; on dark's amber one it clashes, so there it
62
+ drops to the chip's own ink (matches --fill-ink). */
63
+ --live-dot: light-dark(#22c55e, #17130a);
60
64
  --attn-waiting: light-dark(#f59e0b, #ff7a2f);
61
65
  /* Softened in dark — a saturated blue is the one hue that fights the warm ground. */
62
66
  --info: light-dark(#388bfd, #6c9fd8);
63
- /* The pixel matrix. 3.5% white over a near-black ground is invisible, so
67
+ /* The pixel matrix. 2.5% white over a near-black ground is invisible, so
64
68
  dark gets amber at double the alpha to read as a grid rather than noise. */
65
- --grid-line: light-dark(rgba(0, 0, 0, 0.035), rgba(217, 150, 58, 0.07));
69
+ --grid-line: light-dark(rgba(0, 0, 0, 0.025), rgba(217, 150, 58, 0.05));
66
70
  /* --attn-done at reduced opacity — reads correctly on both themes' track color. */
67
71
  --switch-on-bg: rgba(34, 197, 94, 0.32);
68
72
  /* The two dim levels, named so they stay consistent. Deliberately not
@@ -116,16 +120,27 @@
116
120
  body {
117
121
  margin: 0;
118
122
  font-family: var(--font-sans);
119
- /* Pale LCD panel with a faint pixel-grid matrix overlay. */
120
- background:
121
- repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 3px),
122
- repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 3px), var(--bg);
123
+ background: var(--bg);
123
124
  color: var(--ink);
124
125
  font-size: 14px;
125
126
  line-height: 1.55;
126
127
  min-height: 100vh;
127
128
  -webkit-font-smoothing: antialiased;
128
129
  }
130
+ /* The faint pixel-grid matrix overlay lives on a viewport-fixed layer
131
+ so it rasters once and is never re-tiled as the page scrolls. The 4px
132
+ cell keeps the 1px lines sparse enough to avoid moiré against the
133
+ physical pixel grid under fractional display scaling. */
134
+ body::before {
135
+ content: '';
136
+ position: fixed;
137
+ inset: 0;
138
+ z-index: -1;
139
+ pointer-events: none;
140
+ background:
141
+ repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 4px),
142
+ repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 4px);
143
+ }
129
144
  </style>
130
145
  {{mochi.head}} {{mochi.css}}
131
146
  </head>
@@ -1 +0,0 @@
1
- import{jb as a}from"/_mochi/client/chunk-566rqq4c.js";import"/_mochi/client/chunk-czkxd74n.js";export{a as registerComponent};