@oddessentials/agent-guild 0.2.0 → 0.3.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.
package/web/app.js CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  const TOKEN_KEY = 'agentGuild.token';
5
5
  const CWD_KEY = 'agentGuild.cwd';
6
+ const ACCOUNTS_KEY = 'agentGuild.accounts';
7
+ const THEME_KEY = 'agentGuild.theme';
6
8
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
7
9
 
8
10
  const $ = (id) => document.getElementById(id);
@@ -10,6 +12,7 @@ const state = {
10
12
  token: null,
11
13
  providers: [],
12
14
  usage: new Map(),
15
+ accounts: {},
13
16
  stats: null,
14
17
  statsFor: new Map(),
15
18
  sessions: new Map(),
@@ -17,6 +20,10 @@ const state = {
17
20
  activeId: null,
18
21
  eventsSocket: null,
19
22
  eventsRetry: 0,
23
+ /** True while the events socket is open. */
24
+ connected: false,
25
+ /** The manager's own version check, from `hello` and `manager.upgrade`. */
26
+ upgrade: null,
20
27
  /** True from a shutdown request until the manager is reachable again. */
21
28
  stopping: false,
22
29
  /** After a stop: how many session processes did not confirm exiting, or null if the manager never said. */
@@ -43,8 +50,93 @@ function setConnection(kind, label) {
43
50
  const el = $('connection');
44
51
  el.className = `connection ${kind}`;
45
52
  el.querySelector('.label').textContent = label;
46
- // The manager can only be stopped while the page can reach it.
47
- $('stop-manager').hidden = kind !== 'ok';
53
+ // The manager can only be stopped or upgraded while the page can reach it.
54
+ state.connected = kind === 'ok';
55
+ $('stop-manager').hidden = !state.connected;
56
+ renderUpgrade();
57
+ }
58
+
59
+ // ---- theme ----------------------------------------------------------------
60
+
61
+ /** theme.js applied the saved or system theme before the first paint; this keeps the button in step. */
62
+ function applyTheme(theme) {
63
+ document.documentElement.dataset.theme = theme;
64
+ const button = $('theme-toggle');
65
+ const other = theme === 'dark' ? 'light' : 'dark';
66
+ button.textContent = theme === 'dark' ? '☀ Light' : '☾ Dark';
67
+ button.title = `Switch to the ${other} theme`;
68
+ button.setAttribute('aria-label', `Switch to the ${other} theme`);
69
+ }
70
+
71
+ function currentTheme() {
72
+ return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
73
+ }
74
+
75
+ function toggleTheme() {
76
+ const theme = currentTheme() === 'dark' ? 'light' : 'dark';
77
+ save(THEME_KEY, theme);
78
+ applyTheme(theme);
79
+ }
80
+
81
+ // ---- upgrading the manager ------------------------------------------------
82
+
83
+ function renderUpgrade() {
84
+ const u = state.upgrade;
85
+ const button = $('upgrade');
86
+ const note = $('upgrade-note');
87
+ const offer = Boolean(state.connected && u?.available && u.command);
88
+ button.hidden = !offer;
89
+ if (offer) {
90
+ button.textContent = `Upgrade to ${u.latestVersion}`;
91
+ button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.`;
92
+ }
93
+ let text = '';
94
+ let title = '';
95
+ const last = u?.lastInstall;
96
+ if (u?.installing) {
97
+ text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
98
+ title = 'npm is running in a session. Keep the manager running until it finishes.';
99
+ } else if (u?.pendingVersion) {
100
+ text = `v${u.pendingVersion} installed · restart to use it`;
101
+ title = `Agent Guild ${u.pendingVersion} is installed, but this manager is still ${u.version}. Stop the manager and run "agent-guild open" to use it.`;
102
+ } else if (last?.outcome === 'failed') {
103
+ text = last.exitCode === null ? 'Upgrade failed' : `Upgrade failed (exit ${last.exitCode})`;
104
+ title = 'See the upgrade session for npm\'s output, then run the upgrade again: the files on disk may be incomplete. On Windows, files in use cannot be replaced: stop the manager first and run the command yourself.';
105
+ } else if (last?.outcome === 'unchanged') {
106
+ text = 'Upgrade finished, but this copy was not replaced';
107
+ title = `npm did not replace the files this manager runs from. Run${u.command ? ` "${u.command}"` : ' the npm install'} where Agent Guild is installed.`;
108
+ } else if (u?.available && !u.command) {
109
+ text = `v${u.latestVersion} available`;
110
+ title = u.guidance || '';
111
+ }
112
+ note.hidden = !state.connected || !text;
113
+ note.textContent = text;
114
+ note.title = title;
115
+ }
116
+
117
+ function setUpgrade(upgrade) {
118
+ const before = state.upgrade;
119
+ state.upgrade = upgrade || null;
120
+ renderUpgrade();
121
+ const pending = state.upgrade?.pendingVersion;
122
+ if (pending && pending !== before?.pendingVersion) {
123
+ toast(`Agent Guild ${pending} is installed. Stop the manager and run "agent-guild open" to use it.`, 10000);
124
+ }
125
+ }
126
+
127
+ async function upgradeManager() {
128
+ const button = $('upgrade');
129
+ button.disabled = true;
130
+ try {
131
+ const { session } = await api('POST', '/upgrade');
132
+ upsertSession(session);
133
+ openPanel(session.id);
134
+ } catch (err) {
135
+ if (err instanceof AuthError) return showAuth(err.message);
136
+ toast(err.message, 8000);
137
+ } finally {
138
+ button.disabled = false;
139
+ }
48
140
  }
49
141
 
50
142
  function relativeTime(iso) {
@@ -148,6 +240,51 @@ function wsUrl(path) {
148
240
 
149
241
  // ---- providers ------------------------------------------------------------
150
242
 
243
+ function selectedAccount(provider) {
244
+ const accounts = provider.accounts || [];
245
+ return accounts.find((a) => a.id === state.accounts[provider.id]) || accounts[0] || { id: 'default', label: 'Default' };
246
+ }
247
+
248
+ function selectAccount(provider, id) {
249
+ state.accounts[provider.id] = id;
250
+ save(ACCOUNTS_KEY, JSON.stringify(state.accounts));
251
+ }
252
+
253
+ function usageFor(provider, account = selectedAccount(provider)) {
254
+ return state.usage.get(`${provider.id}/${account.id}`);
255
+ }
256
+
257
+ function renderAccounts(card, provider) {
258
+ const host = card.querySelector('.accounts');
259
+ const accounts = provider.accounts || [];
260
+ host.hidden = accounts.length < 2;
261
+ if (host.hidden) return host.replaceChildren();
262
+ const selected = selectedAccount(provider).id;
263
+ const same = host.children.length === accounts.length && accounts.every((a, i) => host.children[i].dataset.account === a.id);
264
+ if (!same) {
265
+ host.replaceChildren(...accounts.map((account) => {
266
+ const chip = document.createElement('button');
267
+ chip.type = 'button';
268
+ chip.className = 'account-chip';
269
+ chip.setAttribute('role', 'tab');
270
+ chip.dataset.account = account.id;
271
+ chip.addEventListener('click', () => {
272
+ selectAccount(provider, account.id);
273
+ renderAccounts(card, provider);
274
+ renderUsage(card, provider);
275
+ });
276
+ return chip;
277
+ }));
278
+ }
279
+ accounts.forEach((account, i) => {
280
+ const chip = host.children[i];
281
+ chip.classList.toggle('unsigned', usageFor(provider, account)?.signedIn === false);
282
+ chip.setAttribute('aria-selected', String(account.id === selected));
283
+ chip.textContent = account.label;
284
+ chip.title = `Start new ${provider.tool} sessions as the ${account.label} account`;
285
+ });
286
+ }
287
+
151
288
  function renderProviders() {
152
289
  const list = $('providers');
153
290
  const tpl = $('provider-template');
@@ -168,7 +305,6 @@ function renderProviders() {
168
305
  const existing = node.querySelector('.existing');
169
306
  const hint = node.querySelector('.hint');
170
307
  start.hidden = !provider.available;
171
- start.title = `Start a new ${provider.tool} session`;
172
308
  start.addEventListener('click', () => startSession(provider, node));
173
309
  existing.hidden = !provider.available || !provider.resumable;
174
310
  existing.title = `Resume one of ${provider.tool}'s own sessions by its id`;
@@ -186,6 +322,7 @@ function renderProviders() {
186
322
  renderHint(hint, provider);
187
323
  renderCopies(node.querySelector('.copies'), provider);
188
324
  renderConsoleLinks(node, provider);
325
+ renderAccounts(node, provider);
189
326
  renderUsage(node, provider);
190
327
  renderModelStats(node, provider);
191
328
  return node;
@@ -284,14 +421,21 @@ function creditsNote(usage) {
284
421
 
285
422
  function renderUsage(card, provider) {
286
423
  const host = card.querySelector('.usage');
287
- const usage = state.usage.get(provider.id);
424
+ const account = selectedAccount(provider);
425
+ const usage = usageFor(provider, account);
288
426
  renderTier(card, provider, provider.usageSource ? usage : null);
427
+ const start = card.querySelector('.new');
428
+ const unsigned = Boolean(provider.available && usage?.signedIn === false);
429
+ start.textContent = unsigned ? 'Sign in' : 'New';
430
+ start.title = unsigned
431
+ ? `Start a ${provider.tool} session and sign in as the ${account.label} account`
432
+ : `Start a new ${provider.tool} session${provider.accounts?.length > 1 ? ` as the ${account.label} account` : ''}`;
289
433
  if (!provider.available || !provider.usageSource || !usage) return host.replaceChildren();
290
434
  if (usage.error || usage.windows.length === 0) {
291
435
  const note = document.createElement('div');
292
436
  note.className = 'usage-note';
293
- note.textContent = `Usage: ${usage.error || 'no limits reported'}`;
294
- note.title = note.textContent;
437
+ note.textContent = unsigned ? 'Not signed in yet' : `Usage: ${usage.error || 'no limits reported'}`;
438
+ note.title = unsigned ? usage.error : note.textContent;
295
439
  return host.replaceChildren(note, ...creditsNote(usage));
296
440
  }
297
441
  host.replaceChildren(...usage.windows.map((w) => {
@@ -313,10 +457,12 @@ function renderUsage(card, provider) {
313
457
  async function loadUsage() {
314
458
  let usage;
315
459
  try { ({ usage } = await api('GET', '/usage')); } catch { return; }
316
- state.usage = new Map(usage.map((u) => [u.providerId, u]));
460
+ state.usage = new Map(usage.map((u) => [`${u.providerId}/${u.accountId ?? 'default'}`, u]));
317
461
  for (const card of $('providers').children) {
318
462
  const provider = state.providers.find((p) => p.id === card.dataset.id);
319
- if (provider) renderUsage(card, provider);
463
+ if (!provider) continue;
464
+ renderAccounts(card, provider);
465
+ renderUsage(card, provider);
320
466
  }
321
467
  }
322
468
 
@@ -818,7 +964,7 @@ async function startSession(provider, card, { resume } = {}) {
818
964
  save(CWD_KEY, cwd);
819
965
  card.classList.add('busy');
820
966
  try {
821
- const body = { providerId: provider.id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
967
+ const body = { providerId: provider.id, account: selectedAccount(provider).id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
822
968
  const { session } = await api('POST', '/sessions', body);
823
969
  upsertSession(session);
824
970
  openPanel(session.id);
@@ -852,6 +998,12 @@ function modelTitle(s) {
852
998
  return `Model ${modelText(s)}${id}, ${MODEL_SOURCES[s.model.source] || s.model.source}`;
853
999
  }
854
1000
 
1001
+ function accountLabel(s) {
1002
+ if (!s.account) return '';
1003
+ const provider = state.providers.find((p) => p.id === s.provider.id);
1004
+ return (provider?.accounts?.length ?? 0) > 1 || s.account.id !== 'default' ? s.account.label : '';
1005
+ }
1006
+
855
1007
  function statusText(s) {
856
1008
  if (s.status === 'exited') {
857
1009
  if (s.signal) return `Exited (${s.signal})`;
@@ -891,7 +1043,7 @@ function updateCard(node, s) {
891
1043
  badge.title = `Level ${level}`;
892
1044
  node.querySelector('.name').textContent = s.name;
893
1045
  const resumed = s.resume ? ` · resumed ${s.resume}` : '';
894
- node.querySelector('.meta').textContent = `${s.provider.vendor} · ${s.provider.tool} · started ${relativeTime(s.createdAt)}${resumed}`;
1046
+ node.querySelector('.meta').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
895
1047
  const pill = node.querySelector('.status-pill');
896
1048
  pill.textContent = statusText(s);
897
1049
  pill.className = `status-pill ${s.status === 'exited' ? 'exited' : s.activity}`;
@@ -910,7 +1062,8 @@ function updateCard(node, s) {
910
1062
  node.querySelector('.stop').hidden = s.status !== 'running';
911
1063
  node.querySelector('.remove').hidden = s.status === 'running';
912
1064
  const modelLabel = s.model ? `, model ${modelText(s)}` : '';
913
- node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
1065
+ const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
1066
+ node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${accountName}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
914
1067
  }
915
1068
 
916
1069
  function renderSessions() {
@@ -1146,7 +1299,7 @@ function updatePanel() {
1146
1299
  if (!s) return;
1147
1300
  paintProviderIcon($('panel-icon'), s.provider);
1148
1301
  $('panel-title').textContent = s.name;
1149
- $('panel-sub').textContent = [s.provider.tool, modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
1302
+ $('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
1150
1303
  $('panel-sub').title = modelTitle(s);
1151
1304
  renderAgents($('panel-agents'), s.agents);
1152
1305
  const stop = $('panel-stop');
@@ -1246,6 +1399,9 @@ function connectEvents() {
1246
1399
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
1247
1400
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
1248
1401
  renderSessions();
1402
+ setUpgrade(msg.upgrade);
1403
+ } else if (msg.type === 'manager.upgrade') {
1404
+ setUpgrade(msg.upgrade);
1249
1405
  } else if (msg.type === 'manager.stopping') {
1250
1406
  enterStopping(msg.running);
1251
1407
  } else if (msg.type === 'manager.stopped') {
@@ -1381,6 +1537,13 @@ $('models-more').addEventListener('click', () => {
1381
1537
  $('models-list').children[before]?.querySelector('.model-toggle')?.focus();
1382
1538
  });
1383
1539
  $('stop-manager').addEventListener('click', () => stopManager());
1540
+ $('upgrade').addEventListener('click', upgradeManager);
1541
+ $('theme-toggle').addEventListener('click', toggleTheme);
1542
+ applyTheme(currentTheme());
1543
+ // Follow the system setting until the user picks a theme.
1544
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
1545
+ if (!load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
1546
+ });
1384
1547
  $('panel-stop').addEventListener('click', () => {
1385
1548
  const s = state.sessions.get(state.activeId);
1386
1549
  if (!s) return;
@@ -1388,6 +1551,8 @@ $('panel-stop').addEventListener('click', () => {
1388
1551
  else removeSession(s.id);
1389
1552
  });
1390
1553
  $('cwd').value = load(CWD_KEY) || '';
1554
+ try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
1555
+ if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
1391
1556
  setInterval(renderSessions, 30000);
1392
1557
 
1393
1558
  // The terminal panel sits below the top bar, which wraps onto two rows on
package/web/art/art.css CHANGED
@@ -1,11 +1,13 @@
1
1
  .level-badge { display: none; }
2
2
 
3
- @media (prefers-color-scheme: dark) {
4
- :root {
5
- --gold: #e8c47c;
6
- --gold-dim: #8a6d3b;
7
- --card-bg: rgba(18, 20, 26, 0.9);
8
- }
3
+ /* The guild look is the dark theme. The rules below nest under the theme
4
+ attribute that theme.js sets on <html>, so they apply whether the user
5
+ chose dark or the system did. :where() keeps their specificity as it was
6
+ inside the media query, so styles.css still loses to them only on order. */
7
+ :where(:root[data-theme="dark"]) {
8
+ --gold: #e8c47c;
9
+ --gold-dim: #8a6d3b;
10
+ --card-bg: rgba(18, 20, 26, 0.9);
9
11
 
10
12
  .provider, .session-card {
11
13
  --pc: #a67cf6;
package/web/index.html CHANGED
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Agent Guild</title>
7
7
  <meta name="color-scheme" content="light dark">
8
+ <script src="/theme.js"></script>
8
9
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%235B5BD6'/%3E%3Cpath d='M9 22l7-12 7 12' stroke='white' stroke-width='3' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
9
10
  <link rel="stylesheet" href="/vendor/xterm/xterm.css">
10
11
  <link rel="stylesheet" href="/styles.css">
@@ -20,6 +21,9 @@
20
21
  <div id="connection" class="connection" role="status" aria-live="polite">
21
22
  <span class="dot"></span><span class="label">Connecting…</span>
22
23
  </div>
24
+ <span id="upgrade-note" class="upgrade-note" hidden></span>
25
+ <button id="upgrade" class="btn upgrade" type="button" hidden></button>
26
+ <button id="theme-toggle" class="btn theme-toggle" type="button"></button>
23
27
  <button id="stop-manager" class="btn danger stop-manager" type="button" hidden title="Stop the session manager. This ends every session.">Stop manager</button>
24
28
  </div>
25
29
  </header>
@@ -113,6 +117,7 @@
113
117
  <span class="tool"></span>
114
118
  <span class="state"></span>
115
119
  </span>
120
+ <div class="accounts" role="tablist" aria-label="Accounts" hidden></div>
116
121
  <div class="usage"></div>
117
122
  <div class="model-stats"></div>
118
123
  <div class="provider-links">
package/web/styles.css CHANGED
@@ -18,21 +18,20 @@
18
18
  color-scheme: light;
19
19
  }
20
20
 
21
- @media (prefers-color-scheme: dark) {
22
- :root {
23
- --bg: #0f1115;
24
- --surface: #171a21;
25
- --surface-2: #1e222b;
26
- --border: #2a303b;
27
- --text: #e6e9ef;
28
- --muted: #9aa4b2;
29
- --accent: #7c7cf0;
30
- --danger: #ef6b66;
31
- --ok: #4cc38a;
32
- --warn: #e0a84a;
33
- --shadow: none;
34
- color-scheme: dark;
35
- }
21
+ /* theme.js sets data-theme from the saved choice or the system setting before the first paint. */
22
+ :root[data-theme="dark"] {
23
+ --bg: #0f1115;
24
+ --surface: #171a21;
25
+ --surface-2: #1e222b;
26
+ --border: #2a303b;
27
+ --text: #e6e9ef;
28
+ --muted: #9aa4b2;
29
+ --accent: #7c7cf0;
30
+ --danger: #ef6b66;
31
+ --ok: #4cc38a;
32
+ --warn: #e0a84a;
33
+ --shadow: none;
34
+ color-scheme: dark;
36
35
  }
37
36
 
38
37
  * { box-sizing: border-box; }
@@ -86,6 +85,16 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
86
85
  .provider .state.update-available { color: var(--warn); }
87
86
  .provider.unavailable .state { color: var(--muted); }
88
87
  .provider .update { color: var(--warn); border-color: var(--warn); }
88
+ .provider .accounts { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; }
89
+ .account-chip {
90
+ font: inherit; font-size: 0.75rem; font-weight: 600; padding: 2px 10px; border-radius: 999px; cursor: pointer;
91
+ border: 1px solid var(--border); background: var(--surface-2); color: var(--muted); max-width: 100%;
92
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
93
+ }
94
+ .account-chip:hover { border-color: var(--muted); }
95
+ .account-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
96
+ .account-chip[aria-selected="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
97
+ .account-chip.unsigned { border-style: dashed; }
89
98
  .provider .usage { grid-column: 1 / -1; display: grid; gap: 4px; }
90
99
  .provider .usage:empty { display: none; }
91
100
  .meter { display: grid; grid-template-columns: 5.5em 1fr auto; align-items: center; gap: 8px; font-size: 0.75rem; color: var(--muted); }
@@ -152,7 +161,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
152
161
  .tier-b { --tier: #3b82f6; --tier-ink: #1d4ed8; }
153
162
  .tier-c { --tier: #22a35a; --tier-ink: #166534; }
154
163
  .tier-d { --tier: #94a3b8; --tier-ink: #475569; }
155
- @media (prefers-color-scheme: dark) {
164
+ :where(:root[data-theme="dark"]) {
156
165
  .tier-s { --tier: #e0b45c; --tier-ink: #f6dea6; }
157
166
  .tier-a { --tier: #a78bfa; --tier-ink: #ddd3ff; }
158
167
  .tier-b { --tier: #60a5fa; --tier-ink: #cfe3ff; }
@@ -240,7 +249,9 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
240
249
  .btn.danger { color: var(--danger); }
241
250
  .btn.danger:hover { border-color: var(--danger); }
242
251
  .btn:disabled { opacity: 0.6; cursor: progress; }
243
- .stop-manager { white-space: nowrap; }
252
+ .stop-manager, .theme-toggle, .upgrade { white-space: nowrap; }
253
+ .btn.upgrade { color: var(--warn); border-color: var(--warn); }
254
+ .upgrade-note { font-size: 0.85rem; color: var(--warn); }
244
255
 
245
256
  .empty { color: var(--muted); margin: 8px 0 0; }
246
257
 
@@ -308,7 +319,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
308
319
  .badge.new { --badge: var(--ok); color: color-mix(in srgb, var(--badge) 72%, #000); }
309
320
  .badge.expires { --badge: var(--warn); color: color-mix(in srgb, var(--badge) 62%, #000); }
310
321
  .badge.in-use, .badge.new, .badge.expires { border-color: color-mix(in srgb, var(--badge) 45%, transparent); }
311
- @media (prefers-color-scheme: dark) {
322
+ :where(:root[data-theme="dark"]) {
312
323
  .badge.in-use, .badge.new, .badge.expires { color: var(--badge); }
313
324
  }
314
325
  .model-detail {
package/web/theme.js ADDED
@@ -0,0 +1,10 @@
1
+ // Applies the chosen theme before the first paint, so the page never flashes
2
+ // the other one. Loaded in <head> as a plain script; app.js owns the toggle.
3
+ (function () {
4
+ var theme = null;
5
+ try { theme = localStorage.getItem('agentGuild.theme'); } catch (e) { /* storage unavailable */ }
6
+ if (theme !== 'light' && theme !== 'dark') {
7
+ theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
8
+ }
9
+ document.documentElement.dataset.theme = theme;
10
+ })();