Skip to content

Commit bfeeaa7

Browse files
committed
Accessibility, UX, and performance QA pass (passes 1-10)
Broad repair pass across the shared shell, NukeMap, VIPTrack, Interoperability, and Training-Knowledge workspaces. Summary of changes: - Extracted ~2MB shared runtime into /app-runtime.js (/ payload ~2MB -> ~94KB) - Fixed LAN heartbeat bootstrap bug (inline Jinja dependency after split) - Removed transition:all from tab partials; replaced with explicit property lists - Stripped first-party outline:none patterns; restored :focus-visible across tabs - Added MutationObserver-based accessibility defaults to shared shell (type=button, aria-label inference for icon buttons) - Added role=switch + aria-checked + keyboard semantics to VIPTrack settings toggles - Fixed VIPTrack startup bug: trailRenderer.init() never called on boot - Added NM.enhanceToggleRows() for NukeMap toggle keyboard/switch semantics - Improved ARIA labeling on NukeMap warhead/yield/wind controls and VIPTrack selects - Exposed LAN chat stream as aria-live log; labeled LAN encryption toggle - Added intrinsic image dimensions to shell, wizard, VIPTrack logo/banner assets - Converted IO import drop-zone and TK flashcard launchers to keyboard-targetable controls - Added alt/intrinsic sizing to runtime-generated media thumbnails, map export images, AI chat preview, and supply image previews - Added role=tablist/tab/tabpanel + aria-selected semantics to Interoperability and Training-Knowledge sub-tab navigation - Added role=dialog + aria-modal + focus-on-open to cross-training matrix modal - Added /interoperability and /training-knowledge page routes (navigation was broken: these tabs had no routes and were stuck behind an is-hidden class) - Added CODEX_CHANGELOG.md to .gitignore - Tests: 906 pytest passed, 22 Playwright passed (was 905/19 before this pass)
1 parent 08eb9b1 commit bfeeaa7

43 files changed

Lines changed: 957 additions & 441 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ tmp_pytest_full.out
3535

3636
# Internal docs (keep locally, not in repo)
3737
CLAUDE.md
38+
CODEX_CHANGELOG.md
3839
docs/schema.md

tests/test_core.py

Lines changed: 173 additions & 11 deletions
Large diffs are not rendered by default.

tests/test_runtime_asset.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
def test_workspace_page_uses_shared_runtime_script(client):
2+
response = client.get('/preparedness')
3+
4+
assert response.status_code == 200
5+
6+
html = response.get_data(as_text=True)
7+
assert '<script src="/app-runtime.js?v=' in html
8+
assert 'window.NOMAD_VERSION =' in html
9+
assert "const VERSION = '" not in html
10+
assert len(response.get_data()) < 1_000_000
11+
12+
13+
def test_shared_runtime_script_renders_javascript(client):
14+
response = client.get('/app-runtime.js?v=test')
15+
16+
assert response.status_code == 200
17+
assert response.mimetype == 'application/javascript'
18+
assert 'public, max-age=86400' in response.headers.get('Cache-Control', '')
19+
assert response.headers.get('X-Content-Type-Options') == 'nosniff'
20+
21+
body = response.get_data(as_text=True)
22+
assert '{{ version }}' not in body
23+
assert "const VERSION = window.NOMAD_VERSION || '0.0.0';" in body
24+
assert 'function inferButtonAriaLabel(button, text)' in body
25+
assert "if (!button.hasAttribute('type')) button.type = 'button';" in body
26+
assert "return 'Delete item';" in body
27+
assert 'function observeShellAccessibilityDefaults()' in body
28+
assert "observer.observe(document.body, { childList: true, subtree: true });" in body

tests/ui/shell-workflows.spec.mjs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,29 @@ test('nukemap fills the wide workspace frame and follows the active shell theme'
618618
await page.locator('#welcome-dismiss').click();
619619
await page.waitForTimeout(2400);
620620

621+
const multiToggle = page.locator('#tab-nukemap .toggle-row').first();
622+
const toggleFocusState = await multiToggle.evaluate((toggleRow) => ({
623+
toggleInput: toggleRow?.dataset?.toggleInput || '',
624+
activeRole: toggleRow?.getAttribute?.('role') || '',
625+
activeAriaChecked: toggleRow?.getAttribute?.('aria-checked') || '',
626+
tabIndex: toggleRow?.tabIndex ?? -1,
627+
}));
628+
629+
expect(toggleFocusState.toggleInput).toBe('multi-check');
630+
expect(toggleFocusState.activeRole).toBe('switch');
631+
expect(toggleFocusState.activeAriaChecked).toBe('false');
632+
expect(toggleFocusState.tabIndex).toBe(0);
633+
634+
await multiToggle.evaluate((toggleRow) => {
635+
toggleRow.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }));
636+
});
637+
const multiCheckEnabled = await page.evaluate(() => ({
638+
checked: document.getElementById('multi-check')?.checked ?? false,
639+
ariaChecked: document.querySelector('#tab-nukemap .toggle-row')?.getAttribute?.('aria-checked') || '',
640+
}));
641+
expect(multiCheckEnabled.checked).toBeTruthy();
642+
expect(multiCheckEnabled.ariaChecked).toBe('true');
643+
621644
const postDemoState = await page.evaluate(() => ({
622645
shellStillVisible: document.getElementById('tab-nukemap')?.classList.contains('active') ?? false,
623646
stageVisible: !!document.getElementById('nukemap-stage')?.getBoundingClientRect().width,
@@ -700,6 +723,11 @@ test('viptrack fills the wide workspace frame and keeps embedded controls statef
700723
await embedded.locator('#settingsBtn').evaluate((button) => button.click());
701724
await expect(embedded.locator('#settingsBtn')).toHaveAttribute('aria-expanded', 'true');
702725
await expect(embedded.locator('#settingsPanel')).toHaveAttribute('aria-hidden', 'false');
726+
await expect(embedded.locator('#toggleTrailArrows')).toHaveAttribute('role', 'switch');
727+
await expect(embedded.locator('#toggleTrailArrows')).toHaveAttribute('aria-checked', 'true');
728+
await embedded.locator('#toggleTrailArrows').evaluate((button) => button.click());
729+
await expect(embedded.locator('#toggleTrailArrows')).toHaveAttribute('aria-checked', 'false');
730+
await expect(embedded.locator('#toggleAlerts')).toHaveAttribute('role', 'switch');
703731

704732
await embedded.locator('#panelsBtn').evaluate((button) => button.click());
705733
await expect(embedded.locator('#panelsBtn')).toHaveAttribute('aria-expanded', 'true');
@@ -765,6 +793,80 @@ test('shared shell pauses VIPTrack activity after switching away from the tab',
765793
expect(hiddenState?.reason).toBe('tab-hidden');
766794
});
767795

796+
test('interoperability sub-tabs expose correct ARIA tablist semantics and toggle aria-selected', async ({ page }) => {
797+
await bootWorkspace(page, 'nightops', '/interoperability');
798+
const tablist = page.locator('#tab-interoperability [role="tablist"]');
799+
await expect(tablist).toBeVisible();
800+
await expect(tablist).toHaveAttribute('aria-label', 'Data exchange sections');
801+
802+
// Default: export tab is selected
803+
const exportTab = page.locator('#io-tab-export');
804+
await expect(exportTab).toHaveAttribute('aria-selected', 'true');
805+
await expect(page.locator('#io-panel-export')).toBeVisible();
806+
807+
// Click import tab — aria-selected should shift
808+
await page.locator('#io-tab-import').click();
809+
await expect(page.locator('#io-tab-export')).toHaveAttribute('aria-selected', 'false');
810+
await expect(page.locator('#io-tab-import')).toHaveAttribute('aria-selected', 'true');
811+
await expect(page.locator('#io-panel-import')).toBeVisible();
812+
await expect(page.locator('#io-panel-export')).not.toBeVisible();
813+
814+
// Click history tab
815+
await page.locator('#io-tab-history').click();
816+
await expect(page.locator('#io-tab-import')).toHaveAttribute('aria-selected', 'false');
817+
await expect(page.locator('#io-tab-history')).toHaveAttribute('aria-selected', 'true');
818+
await expect(page.locator('#io-panel-history')).toBeVisible();
819+
});
820+
821+
test('training-knowledge sub-tabs expose correct ARIA tablist semantics and toggle aria-selected', async ({ page }) => {
822+
await bootWorkspace(page, 'nightops', '/training-knowledge');
823+
const tablist = page.locator('#tab-training-knowledge [role="tablist"]');
824+
await expect(tablist).toBeVisible();
825+
await expect(tablist).toHaveAttribute('aria-label', 'Training and knowledge sections');
826+
827+
// Default: skills tab is selected
828+
const skillsTab = page.locator('#tk-tab-skills');
829+
await expect(skillsTab).toHaveAttribute('aria-selected', 'true');
830+
await expect(page.locator('#tk-panel-skills')).toBeVisible();
831+
832+
// Click courses tab — aria-selected should shift
833+
await page.locator('#tk-tab-courses').click();
834+
await expect(page.locator('#tk-tab-skills')).toHaveAttribute('aria-selected', 'false');
835+
await expect(page.locator('#tk-tab-courses')).toHaveAttribute('aria-selected', 'true');
836+
await expect(page.locator('#tk-panel-courses')).toBeVisible();
837+
await expect(page.locator('#tk-panel-skills')).not.toBeVisible();
838+
839+
// Click flashcards tab
840+
await page.locator('#tk-tab-flashcards').click();
841+
await expect(page.locator('#tk-tab-courses')).toHaveAttribute('aria-selected', 'false');
842+
await expect(page.locator('#tk-tab-flashcards')).toHaveAttribute('aria-selected', 'true');
843+
await expect(page.locator('#tk-panel-flashcards')).toBeVisible();
844+
});
845+
846+
test('training-knowledge cross-training matrix modal has dialog semantics and receives focus on open', async ({ page }) => {
847+
await bootWorkspace(page, 'nightops', '/training-knowledge');
848+
849+
// Modal should be hidden initially
850+
const modal = page.locator('#tk-matrix-modal');
851+
await expect(modal).toHaveClass(/is-hidden/);
852+
await expect(modal).toHaveAttribute('role', 'dialog');
853+
await expect(modal).toHaveAttribute('aria-modal', 'true');
854+
await expect(modal).toHaveAttribute('aria-labelledby', 'tk-matrix-title');
855+
856+
// Open the modal
857+
await page.locator('button', { hasText: 'Cross-Training Matrix' }).click();
858+
await expect(modal).not.toHaveClass(/is-hidden/);
859+
860+
// Close button should have focus after open
861+
const closeBtn = page.locator('#tk-matrix-close');
862+
await expect(closeBtn).toBeFocused();
863+
await expect(closeBtn).toHaveAttribute('aria-label', 'Close cross-training matrix');
864+
865+
// Close via the close button
866+
await closeBtn.click();
867+
await expect(modal).toHaveClass(/is-hidden/);
868+
});
869+
768870
[
769871
{ name: 'notes', path: '/notes?tab=notes', visibleSelector: '#tab-notes', allowedEndpoints: [] },
770872
{ name: 'settings', path: '/settings?tab=settings', visibleSelector: '#tab-settings', allowedEndpoints: ['/api/content-summary'] },

web/app.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,18 @@ def no_cache(response):
428428
'title': 'VIPTrack',
429429
'partial': 'index_partials/_tab_viptrack.html',
430430
},
431+
'training-knowledge': {
432+
'route': '/training-knowledge',
433+
'aliases': ['/training'],
434+
'title': 'Training & Knowledge',
435+
'partial': 'index_partials/_tab_training_knowledge.html',
436+
},
437+
'interoperability': {
438+
'route': '/interoperability',
439+
'aliases': ['/data-exchange'],
440+
'title': 'Interoperability',
441+
'partial': 'index_partials/_tab_interoperability.html',
442+
},
431443
}
432444

433445
workspace_routes = {tab: meta['route'] for tab, meta in workspace_pages.items()}
@@ -459,6 +471,18 @@ def _render_workspace_page(tab_id, allow_launch_restore=False):
459471
wizard_should_launch=(tab_id == 'services' and not first_run_complete),
460472
)
461473

474+
@app.route('/app-runtime.js')
475+
def app_runtime_js():
476+
response = Response(
477+
render_template('index_partials/_app_inline.js', version=VERSION),
478+
mimetype='application/javascript',
479+
)
480+
response.headers['Cache-Control'] = (
481+
'no-cache' if app.config.get('DEBUG') else 'public, max-age=86400'
482+
)
483+
response.headers['X-Content-Type-Options'] = 'nosniff'
484+
return response
485+
462486
@app.route('/')
463487
def dashboard():
464488
return _render_workspace_page('services', allow_launch_restore=True)
@@ -528,6 +552,16 @@ def nukemap_tab_page():
528552
def viptrack_tab_page():
529553
return _render_workspace_page('viptrack')
530554

555+
@app.route('/training-knowledge')
556+
@app.route('/training')
557+
def training_knowledge_page():
558+
return _render_workspace_page('training-knowledge')
559+
560+
@app.route('/interoperability')
561+
@app.route('/data-exchange')
562+
def interoperability_page():
563+
return _render_workspace_page('interoperability')
564+
531565
# ─── Cross-Module Intelligence (Needs System) ─────────────────────
532566

533567
SURVIVAL_NEEDS = {

web/nukemap/css/styles.css

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ html,body{height:100%;overflow:hidden;font-family:'Segoe UI',system-ui,-apple-sy
4747

4848
/* Search */
4949
.search-wrap{position:relative}
50-
#search{width:100%;padding:8px 14px 8px 34px;border-radius:var(--radius-sm);background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:13px;outline:none;transition:border-color .2s,box-shadow .2s}
50+
#search{width:100%;padding:8px 14px 8px 34px;border-radius:var(--radius-sm);background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:13px;transition:border-color .2s,box-shadow .2s}
5151
#search:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(137,180,250,0.12)}
5252
#search::placeholder{color:var(--overlay0)}
5353
.search-icon{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--overlay0);pointer-events:none}
@@ -82,34 +82,36 @@ html,body{height:100%;overflow:hidden;font-family:'Segoe UI',system-ui,-apple-sy
8282
.tab-content{display:none}.tab-content.active{display:block}
8383

8484
/* Controls */
85-
.weapon-filter{width:100%;padding:5px 10px;margin-bottom:4px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:10px;outline:none}
86-
.weapon-filter:focus{border-color:var(--blue)}
85+
.weapon-filter{width:100%;padding:5px 10px;margin-bottom:4px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:10px}
86+
.weapon-filter:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(137,180,250,0.12)}
8787
.weapon-filter::placeholder{color:var(--overlay0)}
8888
.select-wrap{position:relative;margin-bottom:8px}
89-
.custom-select{width:100%;padding:8px 36px 8px 12px;border-radius:var(--radius-sm);background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:12px;cursor:pointer;outline:none;appearance:none;-webkit-appearance:none}
90-
.custom-select:focus{border-color:var(--blue)}
89+
.custom-select{width:100%;padding:8px 36px 8px 12px;border-radius:var(--radius-sm);background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:12px;cursor:pointer;appearance:none;-webkit-appearance:none}
90+
.custom-select:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(137,180,250,0.12)}
9191
.select-arrow{position:absolute;right:11px;top:50%;transform:translateY(-50%);color:var(--overlay0);pointer-events:none}
9292
.select-arrow svg{width:10px;height:10px;fill:currentColor}
9393
.yield-row{display:flex;align-items:center;gap:10px;margin-bottom:4px}
9494
.yield-display{min-width:80px;text-align:right;font-size:22px;font-weight:800;font-variant-numeric:tabular-nums;color:var(--peach);line-height:1}
9595
.yield-unit{font-size:11px;font-weight:600;color:var(--overlay1)}
96-
#yield-slider{flex:1;-webkit-appearance:none;appearance:none;height:5px;border-radius:3px;background:linear-gradient(90deg,var(--green) 0%,var(--yellow) 40%,var(--peach) 70%,var(--red) 100%);outline:none;cursor:pointer}
96+
#yield-slider{flex:1;-webkit-appearance:none;appearance:none;height:5px;border-radius:3px;background:linear-gradient(90deg,var(--green) 0%,var(--yellow) 40%,var(--peach) 70%,var(--red) 100%);cursor:pointer}
97+
#yield-slider:focus-visible{box-shadow:0 0 0 3px rgba(137,180,250,0.12)}
9798
#yield-slider::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:var(--text);border:2px solid var(--surface0);cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.4);transition:transform .15s}
9899
#yield-slider::-webkit-slider-thumb:hover{transform:scale(1.2)}
99100
#yield-slider::-moz-range-thumb{width:18px;height:18px;border-radius:50%;background:var(--text);border:2px solid var(--surface0);cursor:pointer}
100101
.yield-labels{display:flex;justify-content:space-between;font-size:9px;color:var(--overlay0);margin-bottom:8px;padding:0 2px}
101102
.yield-input-row{display:flex;gap:6px;margin-bottom:8px}
102-
#yield-input{width:80px;padding:5px 8px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--peach);font-size:12px;font-weight:600;outline:none;text-align:right}
103-
#yield-input:focus{border-color:var(--blue)}
104-
#yield-unit-select{padding:5px 8px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:12px;outline:none;cursor:pointer}
103+
#yield-input{width:80px;padding:5px 8px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--peach);font-size:12px;font-weight:600;text-align:right}
104+
#yield-input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(137,180,250,0.12)}
105+
#yield-unit-select{padding:5px 8px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:12px;cursor:pointer}
106+
#yield-unit-select:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(137,180,250,0.12)}
105107
.burst-options{display:flex;gap:5px;margin-bottom:8px}
106108
.burst-btn{flex:1;padding:6px 4px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--subtext0);font-size:11px;font-weight:600;cursor:pointer;text-align:center;transition:background-color .2s,color .2s,border-color .2s,box-shadow .2s}
107109
.burst-btn.active{background:var(--surface1);color:var(--text);border-color:var(--blue);box-shadow:0 0 0 1px rgba(137,180,250,0.2)}
108110
.burst-btn:hover:not(.active){background:rgba(69,71,90,0.5);color:var(--text)}
109111
.compact-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
110112
.compact-row label{font-size:11px;color:var(--subtext0);white-space:nowrap;min-width:50px}
111-
.compact-input{flex:1;max-width:80px;padding:5px 8px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:12px;outline:none;text-align:right}
112-
.compact-input:focus{border-color:var(--blue)}
113+
.compact-input{flex:1;max-width:80px;padding:5px 8px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:12px;text-align:right}
114+
.compact-input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(137,180,250,0.12)}
113115
.compact-row .unit{font-size:10px;color:var(--overlay0)}
114116

115117
/* Detonate */
@@ -122,10 +124,12 @@ html,body{height:100%;overflow:hidden;font-family:'Segoe UI',system-ui,-apple-sy
122124
.btn-secondary:hover{background:var(--surface0);color:var(--text)}
123125

124126
/* Toggles */
125-
.toggle-row{display:flex;align-items:center;gap:8px;margin-top:6px;cursor:pointer;user-select:none}
126-
.toggle-row input{display:none}
127+
.toggle-row{display:flex;align-items:center;gap:8px;margin-top:6px;cursor:pointer;user-select:none;position:relative}
128+
.toggle-row input{position:absolute;inset:0;margin:0;opacity:0;cursor:pointer}
127129
.tg-slider{width:30px;height:16px;border-radius:8px;background:var(--surface1);position:relative;transition:background .2s;flex-shrink:0}
128130
.tg-slider::after{content:'';position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:var(--overlay0);transition:left .2s,background-color .2s}
131+
.toggle-row:focus-visible .tg-slider,
132+
.toggle-row input:focus-visible+.tg-slider{box-shadow:0 0 0 3px rgba(137,180,250,0.32)}
129133
.toggle-row input:checked+.tg-slider{background:var(--blue)}
130134
.toggle-row input:checked+.tg-slider::after{left:16px;background:var(--text)}
131135
.tg-label{font-size:10px;color:var(--subtext0);font-weight:600}
@@ -139,7 +143,8 @@ html,body{height:100%;overflow:hidden;font-family:'Segoe UI',system-ui,-apple-sy
139143
.wind-info{flex:1;font-size:10px}
140144
.wind-info label{color:var(--subtext0);display:block;margin-bottom:3px;font-weight:600}
141145
.wind-speed-row{display:flex;gap:5px;align-items:center}
142-
#wind-speed{width:55px;padding:4px 6px;border-radius:5px;background:var(--mantle);border:1px solid var(--surface1);color:var(--text);font-size:11px;outline:none;text-align:right}
146+
#wind-speed{width:55px;padding:4px 6px;border-radius:5px;background:var(--mantle);border:1px solid var(--surface1);color:var(--text);font-size:11px;text-align:right}
147+
#wind-speed:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(137,180,250,0.12)}
143148
.wind-dir-label{color:var(--overlay0);font-size:9px;margin-top:2px}
144149

145150
/* Legend */
@@ -213,11 +218,13 @@ html,body{height:100%;overflow:hidden;font-family:'Segoe UI',system-ui,-apple-sy
213218
.compare-table td{padding:3px 6px;border-bottom:1px solid rgba(69,71,90,0.2)}
214219
.ct-label{color:var(--overlay1);font-weight:600}.ct-a{color:var(--blue);font-weight:600;font-variant-numeric:tabular-nums}.ct-b{color:var(--peach);font-weight:600;font-variant-numeric:tabular-nums}
215220
.compare-selects{display:flex;gap:6px;margin-bottom:6px}
216-
.compare-selects select{flex:1;padding:5px 8px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:10px;outline:none}
221+
.compare-selects select{flex:1;padding:5px 8px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:10px}
222+
.compare-selects select:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(137,180,250,0.12)}
217223

218224
/* Share */
219225
.share-row{display:flex;gap:5px;align-items:center}
220-
#share-input{flex:1;padding:5px 8px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:10px;outline:none;font-family:monospace}
226+
#share-input{flex:1;padding:5px 8px;border-radius:6px;background:var(--surface0);border:1px solid var(--surface1);color:var(--text);font-size:10px;font-family:monospace}
227+
#share-input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(137,180,250,0.12)}
221228
.share-copy{padding:5px 10px;border-radius:6px;background:var(--blue);border:none;color:var(--crust);font-size:10px;font-weight:700;cursor:pointer}
222229

223230
/* MIRV status */

0 commit comments

Comments
 (0)