@oddessentials/agent-guild 0.25.0 → 0.27.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 (56) hide show
  1. package/README.md +44 -10
  2. package/bin/agent-guild.mjs +2 -0
  3. package/node_modules/qrcode-generator/README.md +101 -0
  4. package/node_modules/qrcode-generator/dist/qrcode.d.ts +57 -0
  5. package/node_modules/qrcode-generator/dist/qrcode.js +2297 -0
  6. package/node_modules/qrcode-generator/dist/qrcode.mjs +2237 -0
  7. package/node_modules/qrcode-generator/dist/qrcode_SJIS.js +30 -0
  8. package/node_modules/qrcode-generator/dist/qrcode_SJIS.mjs +23 -0
  9. package/node_modules/qrcode-generator/dist/qrcode_UTF8.js +26 -0
  10. package/node_modules/qrcode-generator/dist/qrcode_UTF8.mjs +50 -0
  11. package/node_modules/qrcode-generator/experiment/vt/package-lock.json +6184 -0
  12. package/node_modules/qrcode-generator/experiment/vt/package.json +19 -0
  13. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode.ts +2311 -0
  14. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_SJIS.ts +25 -0
  15. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_UTF8.ts +50 -0
  16. package/node_modules/qrcode-generator/experiment/vt/src/main/types.ts +80 -0
  17. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode-test-impl.js +353 -0
  18. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode.test.mjs +5 -0
  19. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_SJIS.test.mjs +10 -0
  20. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_UTF8.test.mjs +9 -0
  21. package/node_modules/qrcode-generator/experiment/vt/tsconfig.json +25 -0
  22. package/node_modules/qrcode-generator/experiment/vt/vite.config.js +31 -0
  23. package/node_modules/qrcode-generator/experiment/vt/web-test-runner.config.mjs +7 -0
  24. package/node_modules/qrcode-generator/package.json +37 -0
  25. package/node_modules/qrcode-generator/test/qrcode-test-impl.js +353 -0
  26. package/node_modules/qrcode-generator/test/qrcode.test.html +18 -0
  27. package/node_modules/qrcode-generator/test/qrcode.test.mjs +5 -0
  28. package/node_modules/qrcode-generator/test/qrcode_SJIS.test.html +21 -0
  29. package/node_modules/qrcode-generator/test/qrcode_SJIS.test.mjs +10 -0
  30. package/node_modules/qrcode-generator/test/qrcode_UTF8.test.html +19 -0
  31. package/node_modules/qrcode-generator/test/qrcode_UTF8.test.mjs +9 -0
  32. package/node_modules/qrcode-generator/web-test-runner.config.mjs +8 -0
  33. package/package.json +3 -2
  34. package/src/manager/access-policy.mjs +39 -0
  35. package/src/manager/config.mjs +1 -0
  36. package/src/manager/folder-opener.mjs +96 -0
  37. package/src/manager/folder-picker.mjs +116 -0
  38. package/src/manager/folder-picker.ps1 +84 -0
  39. package/src/manager/github-views.mjs +171 -0
  40. package/src/manager/github.mjs +56 -1
  41. package/src/manager/main.mjs +11 -6
  42. package/src/manager/remote-access.mjs +286 -0
  43. package/src/manager/server.mjs +105 -18
  44. package/src/manager/tailscale.mjs +201 -0
  45. package/web/activity-favicon.js +71 -0
  46. package/web/app.js +1154 -98
  47. package/web/index.html +205 -92
  48. package/web/layout.js +75 -0
  49. package/web/remote-access.js +236 -0
  50. package/web/repo-search.js +91 -0
  51. package/web/skins/gnomeland/skin.css +1 -1
  52. package/web/skins/goblinville/skin.css +2 -2
  53. package/web/skins/grove/skin.css +1 -1
  54. package/web/skins/guild/skin.css +3 -3
  55. package/web/skins/orbital/skin.css +1 -1
  56. package/web/styles.css +269 -38
@@ -0,0 +1,236 @@
1
+ export function remotePresentation(data) {
2
+ if (!data) return { title: 'Checking connection…', text: 'Looking for Tailscale on this computer.', label: 'Enable remote access' };
3
+ const problem = data.problem;
4
+ if (data.busy && problem?.code !== 'approval_required') return {
5
+ title: data.busy === 'checking' ? 'Checking connection…' : data.busy === 'disabling' ? 'Disabling remote access…' : 'Preparing remote access…',
6
+ text: 'Your terminal sessions keep running.', label: 'Please wait…',
7
+ };
8
+ if (data.pending === 'disable') return { title: 'Tailscale cleanup needed', text: problem?.code === 'permission_required'
9
+ ? 'Remote access is blocked. Run the cleanup command in Connection details from a terminal with permission to manage Tailscale, then retry cleanup.'
10
+ : problem?.message || 'Access through the saved address is blocked. Retry to remove its Serve route.', label: 'Retry cleanup', action: 'disable',
11
+ ...(['not_installed', 'update_required'].includes(problem?.code) ? { help: 'https://tailscale.com/download', helpLabel: 'Get Tailscale' } : {}) };
12
+ if (problem?.approvalUrl) return { title: 'One step needed in Tailscale', text: problem.message, help: problem.approvalUrl, helpLabel: 'Open Tailscale approval', label: 'Continue setup' };
13
+ if (problem?.code === 'not_installed') return { title: 'Connect this computer to Tailscale', text: problem.message, help: 'https://tailscale.com/download', helpLabel: 'Install Tailscale', label: 'Continue setup' };
14
+ if (problem?.code === 'update_required') return { title: 'Update Tailscale', text: problem.message, help: 'https://tailscale.com/download', helpLabel: 'Get Tailscale', label: 'Continue setup' };
15
+ if (problem?.code === 'not_connected' || problem?.code === 'address_unavailable') return { title: 'Connect Tailscale', text: problem.message, label: 'Continue setup' };
16
+ if (problem?.code === 'permission_required') return { title: 'Tailscale needs permission', text: 'Open a terminal on this computer with permission to manage Tailscale. The connection details below show the command to run, then choose Continue setup.', label: 'Continue setup' };
17
+ if (problem) return { title: data.pending === 'enable' ? 'Finish setting up remote access' : 'Connection needs attention', text: problem.message, label: data.mode === 'tailscale' ? 'Repair connection' : data.candidate?.existing ? 'Use existing route' : 'Continue setup' };
18
+ if (data.mode === 'tailscale') return {
19
+ title: 'Remote access enabled', text: data.connection?.ok ? 'The secure address reaches this manager. Connect another device to start using it.'
20
+ : data.connection ? 'The route is configured, but this computer could not verify the secure connection. Check Tailscale, then try the address on your other device.' : 'Your saved Tailscale address is ready to check.',
21
+ label: 'Repair connection', hidePrimary: true,
22
+ };
23
+ if (data.candidate?.existing) return { title: 'Your Tailscale route is ready', text: 'A private route already points to this manager. Use it to let Agent Guild save and manage this connection.', label: 'Use existing route' };
24
+ if (data.mode === 'custom') return { title: 'Custom proxy access configured', text: data.source === 'environment' ? 'These settings came from the launch environment. Save them below to keep them across launches.' : 'Your proxy settings are saved and active.', label: 'Set up Tailscale' };
25
+ if (!data.tailscale) return { title: 'Set up remote access', text: 'Check this computer’s Tailscale connection to get started.', label: 'Enable remote access' };
26
+ return { title: 'Tailscale connected', text: 'Enable a private address for your running terminals. Your settings will be saved for future launches.', label: 'Enable remote access' };
27
+ }
28
+
29
+ export function createRemoteAccessUI({ api, getToken, isConnected, onAuthError }) {
30
+ const $ = (id) => document.getElementById(id);
31
+ const dialog = $('remote-access');
32
+ let data = null, request = 0, loading = false, posting = false, timer = null, sharing = false, shareGeneration = 0, editRevision = null, dirty = false;
33
+ let available = false, waitingForApproval = false;
34
+
35
+ function clearShare() {
36
+ sharing = false;
37
+ shareGeneration++;
38
+ $('remote-share').hidden = true;
39
+ $('remote-connect').setAttribute('aria-expanded', 'false');
40
+ $('remote-signin').value = '';
41
+ const canvas = $('remote-qr');
42
+ canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height);
43
+ canvas.hidden = true;
44
+ }
45
+
46
+ function render() {
47
+ const presentation = remotePresentation(data);
48
+ const busy = loading || posting || Boolean(data?.busy) || !isConnected();
49
+ $('remote-status-title').textContent = presentation.title;
50
+ $('remote-status-text').textContent = isConnected() ? presentation.text : 'Reconnect to the manager to change remote access.';
51
+ $('remote-status-title').parentElement.dataset.ready = String(data?.mode === 'tailscale' && !data.problem && !data.busy && Boolean(data.connection?.ok));
52
+ $('remote-primary').textContent = presentation.label;
53
+ $('remote-primary').hidden = Boolean(presentation.hidePrimary || (presentation.help && !presentation.action));
54
+ $('remote-primary').disabled = busy || !data;
55
+ $('remote-check').disabled = busy || !data;
56
+ $('remote-disable').disabled = busy || !data;
57
+ $('remote-disable').hidden = !data || (data.mode === 'off' && !data.pending);
58
+ $('remote-disable-yes').disabled = busy;
59
+ $('remote-custom-save').disabled = busy || !data || data.mode === 'tailscale' || Boolean(data.pending);
60
+ for (const id of ['remote-hosts', 'remote-origins']) $(id).disabled = !data || data.mode === 'tailscale' || Boolean(data.pending);
61
+ $('remote-custom-note').textContent = data?.mode === 'tailscale' || data?.pending
62
+ ? 'Disable Tailscale remote access and finish any cleanup before switching to another proxy.'
63
+ : 'Configure your proxy separately, then enter the hosts and browser origins it uses. Saving applies immediately and replaces environment settings.';
64
+ $('remote-help').hidden = !presentation.help;
65
+ if (presentation.help) { $('remote-help').href = presentation.help; $('remote-help').textContent = presentation.helpLabel; }
66
+ $('remote-address-row').hidden = !data?.url;
67
+ $('remote-address').value = data?.url || '';
68
+ $('remote-open-address').href = data?.url || '#';
69
+ $('remote-connect').disabled = busy;
70
+ $('remote-copy-address').disabled = busy;
71
+ $('remote-copy-signin').disabled = busy;
72
+ if (!data?.url || !isConnected() || data.busy === 'disabling') clearShare();
73
+ $('remote-forget').hidden = data?.pending !== 'disable' || !['route_changed', 'network_changed'].includes(data.problem?.code);
74
+ $('remote-forget').disabled = busy;
75
+ const diagnostics = [data?.tailscale ? `Tailscale ${data.tailscale.version} · ${data.tailscale.hostname}` : '',
76
+ data?.source === 'saved' ? 'Settings are saved on the manager computer.' : data?.source === 'environment' ? 'Settings currently come from the launch environment.' : '',
77
+ data?.connection?.checkedAt ? `Last connection check: ${new Date(data.connection.checkedAt).toLocaleString()}` : '',
78
+ data?.command ? `On the manager computer: ${data.command}` : '',
79
+ ].filter(Boolean);
80
+ $('remote-diagnostics').textContent = diagnostics.join('\n');
81
+ if (data && !dirty) {
82
+ $('remote-hosts').value = data.hosts.join(', ');
83
+ $('remote-origins').value = data.origins.join(', ');
84
+ editRevision = data.revision;
85
+ }
86
+ if (data?.problem?.approvalUrl) waitingForApproval = true;
87
+ }
88
+
89
+ function showError(error) {
90
+ if (error.name === 'AuthError' || error.message === 'The access token was rejected.') { close(); onAuthError(error.message); return; }
91
+ $('remote-error').textContent = error.message || 'Could not change remote access. Try again.';
92
+ $('remote-error').hidden = false;
93
+ }
94
+
95
+ function schedule() {
96
+ clearTimeout(timer);
97
+ if (dialog.open && available && isConnected()) timer = setTimeout(() => refresh(!data?.busy), data?.busy ? 800 : 30000);
98
+ }
99
+
100
+ async function refresh(check = false) {
101
+ if (!dialog.open || !available || !isConnected() || loading || posting) return;
102
+ const generation = ++request;
103
+ loading = true;
104
+ if (check) $('remote-error').hidden = true;
105
+ render();
106
+ try {
107
+ const result = await api(check ? 'POST' : 'GET', check ? '/remote-access/check' : '/remote-access');
108
+ if (generation !== request || !dialog.open) return;
109
+ if (data?.url !== result.remoteAccess.url) clearShare();
110
+ data = result.remoteAccess;
111
+ } catch (error) { if (generation === request && dialog.open) showError(error); }
112
+ finally { if (generation === request) { loading = false; render(); schedule(); } }
113
+ if (waitingForApproval && !data?.busy && data?.pending === 'enable' && data?.tailscale?.httpsReady && dialog.open) {
114
+ waitingForApproval = false;
115
+ await change('enable');
116
+ }
117
+ }
118
+
119
+ async function change(action, extra = {}) {
120
+ if (!data || posting || data.busy || !isConnected()) return;
121
+ posting = true;
122
+ const generation = ++request;
123
+ $('remote-error').hidden = true;
124
+ $('remote-disable-confirm').hidden = true;
125
+ if (action !== 'enable') clearShare();
126
+ render();
127
+ try {
128
+ const result = await api('PUT', '/remote-access', { revision: data.revision, action, adopt: Boolean(data.candidate?.existing), ...extra });
129
+ if (generation !== request || !dialog.open) return;
130
+ data = result.remoteAccess;
131
+ if (action === 'custom') dirty = false;
132
+ } catch (error) {
133
+ if (generation === request && dialog.open) {
134
+ showError(error);
135
+ if (error.code === 'stale_settings') {
136
+ dirty = false;
137
+ try { data = (await api('GET', '/remote-access')).remoteAccess; } catch (refreshError) { showError(refreshError); }
138
+ }
139
+ }
140
+ } finally { if (generation === request) { posting = false; render(); schedule(); } }
141
+ }
142
+
143
+ async function copy(input, button) {
144
+ try {
145
+ if (!navigator.clipboard) throw new Error('Clipboard unavailable');
146
+ await navigator.clipboard.writeText(input.value);
147
+ const label = button.textContent;
148
+ button.textContent = 'Copied';
149
+ setTimeout(() => { button.textContent = label; }, 1600);
150
+ } catch {
151
+ input.focus(); input.select();
152
+ $('remote-error').textContent = 'The link is selected. Use your browser’s Copy command.';
153
+ $('remote-error').hidden = false;
154
+ }
155
+ }
156
+
157
+ async function toggleShare() {
158
+ if (sharing) { clearShare(); return; }
159
+ if (!data?.url || !getToken() || !isConnected()) return;
160
+ sharing = true;
161
+ const generation = ++shareGeneration;
162
+ const url = new URL(data.url);
163
+ url.hash = new URLSearchParams({ token: getToken() }).toString();
164
+ $('remote-signin').value = url.href;
165
+ $('remote-share').hidden = false;
166
+ $('remote-connect').setAttribute('aria-expanded', 'true');
167
+ $('remote-qr-note').textContent = 'Preparing QR code…';
168
+ try {
169
+ const { default: qrcode } = await import('./vendor/qrcode.mjs');
170
+ if (!sharing || generation !== shareGeneration || !dialog.open) return;
171
+ const code = qrcode(0, 'M');
172
+ code.addData(url.href, 'Byte');
173
+ code.make();
174
+ const count = code.getModuleCount(), scale = 5, border = 4;
175
+ const canvas = $('remote-qr');
176
+ canvas.width = canvas.height = (count + border * 2) * scale;
177
+ const context = canvas.getContext('2d');
178
+ context.fillStyle = '#ffffff'; context.fillRect(0, 0, canvas.width, canvas.height);
179
+ context.fillStyle = '#000000';
180
+ for (let row = 0; row < count; row++) for (let col = 0; col < count; col++) {
181
+ if (code.isDark(row, col)) context.fillRect((col + border) * scale, (row + border) * scale, scale, scale);
182
+ }
183
+ canvas.hidden = false;
184
+ $('remote-qr-note').textContent = '';
185
+ } catch { if (generation === shareGeneration) $('remote-qr-note').textContent = 'The QR code could not load. Use the sign-in link below.'; }
186
+ }
187
+
188
+ function close() {
189
+ request++; loading = false; posting = false;
190
+ clearTimeout(timer); clearShare();
191
+ $('remote-disable-confirm').hidden = true;
192
+ if (dialog.open) dialog.close();
193
+ }
194
+
195
+ $('remote-access-open').addEventListener('click', async () => {
196
+ $('settings-menu').hidePopover?.();
197
+ $('topbar-menu').hidePopover?.();
198
+ if (!dialog.open) dialog.showModal();
199
+ $('remote-error').hidden = true;
200
+ dirty = false;
201
+ await refresh(false);
202
+ await refresh(true);
203
+ });
204
+ $('remote-close').addEventListener('click', close);
205
+ dialog.addEventListener('click', (event) => { if (event.target === dialog && event.clientX !== 0) {
206
+ const rect = dialog.getBoundingClientRect();
207
+ if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) close();
208
+ } });
209
+ dialog.addEventListener('close', () => { close(); ($('settings').checkVisibility() ? $('settings') : $('menu-toggle')).focus(); });
210
+ $('remote-primary').addEventListener('click', () => change(remotePresentation(data).action || 'enable'));
211
+ $('remote-check').addEventListener('click', () => refresh(true));
212
+ $('remote-disable').addEventListener('click', () => { $('remote-disable-confirm').hidden = false; $('remote-disable-yes').focus(); });
213
+ $('remote-disable-yes').addEventListener('click', () => change('disable'));
214
+ $('remote-disable-no').addEventListener('click', () => { $('remote-disable-confirm').hidden = true; $('remote-disable').focus(); });
215
+ $('remote-forget').addEventListener('click', () => change('forget'));
216
+ $('remote-connect').addEventListener('click', toggleShare);
217
+ $('remote-copy-address').addEventListener('click', () => copy($('remote-address'), $('remote-copy-address')));
218
+ $('remote-copy-signin').addEventListener('click', () => copy($('remote-signin'), $('remote-copy-signin')));
219
+ for (const id of ['remote-hosts', 'remote-origins']) $(id).addEventListener('input', () => { dirty = true; });
220
+ $('remote-custom-form').addEventListener('submit', (event) => {
221
+ event.preventDefault();
222
+ const list = (id) => $(id).value.split(',').map((value) => value.trim()).filter(Boolean);
223
+ change('custom', { revision: editRevision, hosts: list('remote-hosts'), origins: list('remote-origins') });
224
+ });
225
+ addEventListener('focus', () => { if (dialog.open && !data?.busy) refresh(true); });
226
+ document.addEventListener('visibilitychange', () => {
227
+ if (document.visibilityState === 'hidden') clearShare();
228
+ else if (dialog.open && !data?.busy) refresh(true);
229
+ });
230
+ return {
231
+ setAvailable(capability) { available = Boolean(capability?.available); $('remote-access-open').hidden = !available; if (!available) close(); },
232
+ updated() { if (dialog.open) refresh(false); },
233
+ connectionChanged() { render(); if (!isConnected()) { clearTimeout(timer); clearShare(); } else schedule(); },
234
+ close,
235
+ };
236
+ }
@@ -0,0 +1,91 @@
1
+ // Ranking and highlighting for the GitHub panel's repository picker, kept free of the page so tests can run it.
2
+
3
+ const RECENT_MAX = 8;
4
+
5
+ function haystack(repo) {
6
+ return [repo.fullName, repo.name, repo.description, repo.language, repo.login].filter(Boolean).join(' ').toLowerCase();
7
+ }
8
+
9
+ /**
10
+ * Every whitespace-separated word must appear in the owner/name, description,
11
+ * language or account login. A name that starts with the whole query ranks
12
+ * first, then owner/name containing it, then the rest; ties go to the latest push.
13
+ * An empty query keeps every repository, latest push first.
14
+ */
15
+ export function rankRepos(repos, query) {
16
+ const q = String(query ?? '').trim().toLowerCase();
17
+ const words = q.split(/\s+/).filter(Boolean);
18
+ const ranked = [];
19
+ for (const repo of repos) {
20
+ if (!words.length) {
21
+ ranked.push({ repo, rank: 3 });
22
+ continue;
23
+ }
24
+ if (!words.every((word) => haystack(repo).includes(word))) continue;
25
+ const rank = String(repo.name ?? '').toLowerCase().startsWith(q) ? 0 : String(repo.fullName ?? '').toLowerCase().includes(q) ? 1 : 2;
26
+ ranked.push({ repo, rank });
27
+ }
28
+ ranked.sort((a, b) => a.rank - b.rank
29
+ || (Date.parse(b.repo.pushedAt) || 0) - (Date.parse(a.repo.pushedAt) || 0)
30
+ || String(a.repo.fullName).localeCompare(String(b.repo.fullName))
31
+ || String(a.repo.accountId).localeCompare(String(b.repo.accountId)));
32
+ return ranked.map((item) => item.repo);
33
+ }
34
+
35
+ /** One repository as seen by one account: the same repository through two accounts is two picks. */
36
+ export function repoKey(repo) {
37
+ return `${repo.accountId}:${repo.fullName}`;
38
+ }
39
+
40
+ /** Up to eight recent picks first, then the rest in their order, each once. */
41
+ export function recentFirst(repos, keys) {
42
+ const byKey = new Map(repos.map((repo) => [repoKey(repo), repo]));
43
+ const pinned = [];
44
+ const seen = new Set();
45
+ for (const key of Array.isArray(keys) ? keys : []) {
46
+ const repo = byKey.get(key);
47
+ if (!repo || seen.has(key)) continue;
48
+ pinned.push(repo);
49
+ seen.add(key);
50
+ if (pinned.length === RECENT_MAX) break;
51
+ }
52
+ return [...pinned, ...repos.filter((repo) => !seen.has(repoKey(repo)))];
53
+ }
54
+
55
+ /** The keys with `repo` moved to the front, at most eight. */
56
+ export function remember(keys, repo) {
57
+ const key = repoKey(repo);
58
+ return [key, ...(Array.isArray(keys) ? keys : []).filter((k) => k !== key)].slice(0, RECENT_MAX);
59
+ }
60
+
61
+ /** The pick for a folder's origin (lowercase owner/name): the account picked most recently, else the first that has it. */
62
+ export function repoForOrigin(repos, origin, keys = []) {
63
+ if (!origin) return null;
64
+ const matches = repos.filter((repo) => String(repo.fullName).toLowerCase() === origin);
65
+ if (!matches.length) return null;
66
+ const order = Array.isArray(keys) ? keys : [];
67
+ return recentFirst(matches, order)[0];
68
+ }
69
+
70
+ /** Splits text into matched and unmatched runs for the query's words, matched literally. */
71
+ export function highlightParts(text, query) {
72
+ const source = String(text ?? '');
73
+ const words = String(query ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean);
74
+ if (!source || !words.length) return [{ match: false, text: source }];
75
+ const lower = source.toLowerCase();
76
+ const marks = new Array(source.length).fill(false);
77
+ for (const word of words) {
78
+ for (let at = lower.indexOf(word); at !== -1; at = lower.indexOf(word, at + word.length)) {
79
+ marks.fill(true, at, at + word.length);
80
+ }
81
+ }
82
+ const parts = [];
83
+ let start = 0;
84
+ for (let i = 1; i <= source.length; i++) {
85
+ if (i === source.length || marks[i] !== marks[start]) {
86
+ parts.push({ match: marks[start], text: source.slice(start, i) });
87
+ start = i;
88
+ }
89
+ }
90
+ return parts;
91
+ }
@@ -245,7 +245,7 @@
245
245
  .btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
246
246
  .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
247
247
 
248
- .models, .news-panel, .auth, .news-latest, .appearance-menu { background: var(--panel-strong); backdrop-filter: blur(16px); }
248
+ .models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--panel-strong); backdrop-filter: blur(16px); }
249
249
  .models, .news-panel { box-shadow: var(--dialog-shadow); }
250
250
  .model-columns, .news-day { background: transparent; backdrop-filter: blur(16px); }
251
251
  .models::backdrop, .news-panel::backdrop { background: rgba(40, 24, 8, 0.4); backdrop-filter: blur(2px); }
@@ -50,7 +50,7 @@
50
50
  background: var(--page-veil), var(--page-art) center bottom / cover no-repeat;
51
51
  }
52
52
  /* Text that sits on the town itself gets a halo of the page colour. */
53
- .section-head h2, .cwd > span, .empty { text-shadow: 0 0 2px var(--bg), 0 0 8px var(--bg), 0 0 16px var(--bg); }
53
+ .section-head h2, .cwd > label, .empty { text-shadow: 0 0 2px var(--bg), 0 0 8px var(--bg), 0 0 16px var(--bg); }
54
54
  h2 { font-weight: 400; letter-spacing: 0.03em; font-size: 1.22rem; }
55
55
 
56
56
  .topbar { background: var(--panel); backdrop-filter: blur(12px) saturate(1.1); border-bottom: 1px solid transparent; border-image: var(--rule) 1; }
@@ -251,7 +251,7 @@
251
251
  .btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
252
252
  .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
253
253
 
254
- .models, .news-panel, .auth, .news-latest, .appearance-menu { background: var(--panel-strong); backdrop-filter: blur(16px); }
254
+ .models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--panel-strong); backdrop-filter: blur(16px); }
255
255
  .models, .news-panel { box-shadow: var(--dialog-shadow); }
256
256
  .model-columns, .news-day { background: transparent; backdrop-filter: blur(16px); }
257
257
  .models::backdrop, .news-panel::backdrop { background: rgba(18, 26, 20, 0.42); backdrop-filter: blur(2px); }
@@ -246,7 +246,7 @@
246
246
  .btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
247
247
  .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--paper-strong); }
248
248
 
249
- .models, .news-panel, .auth, .news-latest, .appearance-menu { background: var(--paper-strong); backdrop-filter: blur(16px); }
249
+ .models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--paper-strong); backdrop-filter: blur(16px); }
250
250
  .models, .news-panel { box-shadow: var(--dialog-shadow); }
251
251
  .model-columns, .news-day { background: transparent; backdrop-filter: blur(16px); }
252
252
  .models::backdrop, .news-panel::backdrop { background: rgba(20, 36, 26, 0.34); backdrop-filter: blur(3px); }
@@ -343,10 +343,10 @@
343
343
  border-radius: 6px; border-color: #4a3d25; background: var(--card-bg);
344
344
  box-shadow: inset 0 0 0 1px rgba(232, 196, 124, 0.06), 0 8px 24px rgba(0, 0, 0, 0.35);
345
345
  }
346
- .news-row + .news-row, .news-bar, .news-filters, .news-item, .news-status, .changelog-release + .changelog-release { border-color: #2a2418; }
346
+ .news-row + .news-row, .news-bar, .dock-bar, .news-filters, .news-item, .news-status, .changelog-release + .changelog-release { border-color: #2a2418; }
347
347
  .news-link { color: #f3ead8; }
348
348
  a.news-link:visited { color: var(--muted); }
349
- .news-panel {
349
+ .news-panel, .dock {
350
350
  background: #12141a; border-left-color: #4a3d25;
351
351
  box-shadow: inset 1px 0 0 rgba(232, 196, 124, 0.08), -24px 0 60px rgba(0, 0, 0, 0.6);
352
352
  }
@@ -458,7 +458,7 @@
458
458
  .model-toggle:hover { background: #f6eedd; }
459
459
 
460
460
  .news-latest { border-color: #e4d6b6; box-shadow: 0 1px 2px rgba(70, 48, 12, 0.08), 0 10px 26px rgba(70, 48, 12, 0.08); }
461
- .news-panel { border-left-color: #e2d3b2; box-shadow: -24px 0 60px rgba(40, 28, 8, 0.22); }
461
+ .news-panel, .dock { border-left-color: #e2d3b2; box-shadow: -24px 0 60px rgba(40, 28, 8, 0.22); }
462
462
  .news-panel::backdrop { background: rgba(40, 30, 14, 0.3); }
463
463
 
464
464
  .status-pill { background: #f6efe0; border: 1px solid #e2d3b2; }
@@ -248,7 +248,7 @@
248
248
  .btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
249
249
  .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--glass-strong); }
250
250
 
251
- .models, .news-panel, .auth, .news-latest, .appearance-menu { background: var(--glass-strong); backdrop-filter: blur(16px); }
251
+ .models, .news-panel, .dock, .auth, .news-latest, .menu:popover-open { background: var(--glass-strong); backdrop-filter: blur(16px); }
252
252
  .models, .news-panel { box-shadow: var(--dialog-shadow); }
253
253
  .model-columns, .news-day { background: transparent; backdrop-filter: blur(16px); }
254
254
  .models::backdrop, .news-panel::backdrop { background: rgba(10, 16, 40, 0.38); backdrop-filter: blur(2px); }