diff --git a/.gitignore b/.gitignore index f8d2c74..e48aa8b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ src-tauri/target/ *.ilk *.exp *.lib +.gstack/ + +# Test artifacts (the test scripts themselves are tracked) +scripts/ui-screenshots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ec16842..29452de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [v0.39.1](https://github.com/mandarwagh9/embedist/releases/tag/v0.39.1) — 2026-05-28 + +### Fixed +- **Focus ring no longer renders as 1px UA default.** Headless UI testing surfaced that the v0.39.0 `:focus-visible` rule used `outline: 2px solid var(--accent)` but Chromium has a long-standing quirk where the UA `outline-style: auto` 1px focus ring can't be fully replaced by an author `outline-style: solid` — the rendered width stays at 1px regardless. Switched the focus ring to `box-shadow` (the modern recommendation), which has no such issue. Verified: every focused interactive element now shows a clean `0 0 0 2px var(--accent)` shadow. +- **SetupWizard now follows the theme.** The wizard modal had a hardcoded Catppuccin palette (`#1e1e2e`, `#cdd6f4`, `#89b4fa`, etc), so flipping to light theme left the first-launch modal stuck on dark surfaces. Rewrote to use `var(--bg-*)`, `var(--text-*)`, `var(--accent)` everywhere. Verified in both themes. +- **Status toasts now follow the theme.** Toast success/error/warning/info backgrounds used hardcoded RGBA literals; replaced with `color-mix(in srgb, var(--success) 15%, transparent)` (and equivalents) so they tint correctly when `--success`/`--error`/etc change with theme. + +### Added +- **Headless UI smoke test.** `scripts/test-ui-headless.py` boots the Vite dev server in a Playwright Chromium with a Tauri shim, walks through the default view, focus ring, light theme, Serial sidebar, and Plot view toggle. Captures screenshots under `scripts/ui-screenshots/` (gitignored). Useful for catching CSS regressions before release. +- **Pure-logic smoke test.** `scripts/test-pure-logic.mjs` compiles `serial-parsing.ts` and `ai-pricing.ts` via esbuild and runs 30 assertions covering CSV/JSON/key-value parsing, pricing math, and formatter edge cases. + +### Documentation +- `docs/superpowers/specs/2026-05-28-theme-token-sweep.md` — the rest of the audit: 17 component files still have hardcoded hex values. Split into Category A (replace with tokens — 9 files), Category B (intentional palettes — leave), Category C (TSX inline styles). + +--- + ## [v0.39.0](https://github.com/mandarwagh9/embedist/releases/tag/v0.39.0) — 2026-05-28 ### Added diff --git a/docs/superpowers/specs/2026-05-28-theme-token-sweep.md b/docs/superpowers/specs/2026-05-28-theme-token-sweep.md new file mode 100644 index 0000000..e3e7d6b --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-theme-token-sweep.md @@ -0,0 +1,58 @@ +# Spec: Theme Token Sweep + +**Status:** proposed +**Owners:** TBD +**Tier:** 3 +**Sibling:** the v0.39.0 design-system commit landed the token *system* (focus ring, light theme, shadows). This spec is about the *consumers* still bypassing it. + +## Why + +A headless UI test against `localhost:1420` after the v0.39.0 token rebuild revealed that flipping `data-theme="light"` only restyled the outer chrome (titlebar, sidebar, statusbar, body). The SetupWizard modal stayed dark because it was hardcoded to a Catppuccin palette. After fixing SetupWizard.css and Toast.css inline as part of the post-v0.39.0 hotfix, the audit shows **17 more files** with hex literals that should be tokens but aren't. + +## Scope + +`grep -rn -E "#[0-9a-fA-F]{3,6}\b" src/components/` finds 147 hits across 19 files. Fixed in this session: SetupWizard.css, Toast.css. The remaining 17 break into three categories. + +### Category A — bug, fix to tokens + +These use a hardcoded color where a status token exists. Pure mechanical replace. + +| File | Hits | Replace with | +|---|---|---| +| `AgentActivityPanel.css:70` | error red `#e74c3c` | `var(--error)` | +| `AgentToolbar.css:69` | error red `#e74c3c` | `var(--error)` | +| `FeedbackPanel.css:35` | success green `#22c55e` | `var(--success)` | +| `FeedbackPanel.css:41,81` | error red `#ef4444` | `var(--error)` | +| `ContextMenu.css` | check on file | `var(--bg-surface)` etc | +| `FileExplorer.css` | check on file | mix of bg + status | +| `CodeEditor.css` (wrapper) | check on file | `var(--bg-primary)` | +| `TerminalPanel.css` | check on file | `var(--bg-primary)` | +| `ModeToggle.css:51` | agent purple `#A78BFA` | introduce `var(--mode-agent)` token | + +### Category B — intentional, leave alone + +These are domain-specific palettes that should NOT follow the app theme. + +- **`MarkdownRenderer.css`** — One Dark Pro syntax highlighting (`#C678DD`, `#98C379`, etc). Code blocks should remain dark-themed even in a light app theme; that's the convention every code IDE follows (VS Code, Cursor, Zed). Leave as-is. +- **`AgentActivityPanel.css:40-43`** — per-activity-type icon colors (shell `#9b59b6`, search `#3498db`, error `#e74c3c`). These are semantic icons, not theme surfaces. Could promote to `--icon-shell`, `--icon-search` tokens but they don't need to flip with theme. + +### Category C — TSX inline style colors + +Inline `style={{ color: '#...' }}` in TSX files (e.g. `AgentActivityPanel.tsx`, `PlanPhaseIndicator.tsx`, `ToolCallBlock.tsx`). Need case-by-case judgment. Roughly half are category A, half are category B per pattern above. + +## Verification + +After the sweep: + +1. Run the existing headless UI test: every panel screenshot should render with appropriate contrast in both `data-theme="dark"` and `data-theme="light"`. +2. The Monaco editor stays dark regardless of theme (its theme is independent — `settingsStore.editor.theme`). +3. `MarkdownRenderer` code-block syntax highlighting stays the same dark palette in both themes. + +## Risks + +- Color-mix isn't supported in older Chromium. Embedist's bundled Tauri WebView is recent enough (Chromium 120+), but if cross-platform support ever lands the macOS WebKit baseline (Safari 16.4) covers it; older WebKit fallback would need explicit rgba values. +- The agent-purple-as-mode-color (`#A78BFA`) appears in several places. Adding `--mode-agent` and similar tokens makes mode color theming a coherent system — worth doing once across all mode-tagged surfaces, not piecemeal. + +## Estimate + +~2 hours: one pass through 9 component CSS files in Category A + a smaller TSX sweep + a screenshot diff against this branch's baseline. diff --git a/package.json b/package.json index 9191657..77c7155 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "embedist", - "version": "0.39.0", + "version": "0.39.1", "description": "AI-native embedded development environment", "type": "module", "scripts": { diff --git a/scripts/test-pure-logic.mjs b/scripts/test-pure-logic.mjs new file mode 100644 index 0000000..5ce83fd --- /dev/null +++ b/scripts/test-pure-logic.mjs @@ -0,0 +1,80 @@ +// Quick sanity tests for the new pure-logic modules from v0.39.0. +// Not part of the production build — run with `node scripts/test-pure-logic.mjs`. +import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { transformSync } from 'esbuild'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '..'); + +function loadTs(rel) { + const src = readFileSync(resolve(root, rel), 'utf8'); + return transformSync(src, { loader: 'ts', format: 'esm', target: 'esnext' }).code; +} + +const tmp = mkdtempSync(join(tmpdir(), 'embedist-test-')); +const parserPath = join(tmp, 'parser.mjs'); +const pricingPath = join(tmp, 'pricing.mjs'); +writeFileSync(parserPath, loadTs('src/lib/serial-parsing.ts')); +writeFileSync(pricingPath, loadTs('src/lib/ai-pricing.ts')); + +const { parseTelemetryLine } = await import(pathToFileURL(parserPath).href); +const { estimateCostUSD, formatUSD, formatTokens } = await import(pathToFileURL(pricingPath).href); + +let pass = 0, fail = 0; +function eq(label, actual, expected) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (ok) { pass++; console.log(` PASS ${label}`); } + else { fail++; console.log(` FAIL ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`); } +} + +console.log('parseTelemetryLine — CSV shape:'); +eq('basic CSV', parseTelemetryLine('1.23,4.56,7.89'), { values: { ch0: 1.23, ch1: 4.56, ch2: 7.89 } }); +eq('integer CSV', parseTelemetryLine('10,20,30'), { values: { ch0: 10, ch1: 20, ch2: 30 } }); +eq('negative CSV', parseTelemetryLine('-1,-2.5,3'), { values: { ch0: -1, ch1: -2.5, ch2: 3 } }); +eq('TSV (tab separator)', parseTelemetryLine('1\t2\t3'), { values: { ch0: 1, ch1: 2, ch2: 3 } }); +eq('space separator', parseTelemetryLine('1 2 3'), { values: { ch0: 1, ch1: 2, ch2: 3 } }); +eq('single number', parseTelemetryLine('42'), { values: { ch0: 42 } }); + +console.log('\nparseTelemetryLine — key:value shape:'); +eq('colon style', parseTelemetryLine('temp:23.5 humidity:60'), { values: { temp: 23.5, humidity: 60 } }); +eq('equals style', parseTelemetryLine('temp=23.5 humidity=60'), { values: { temp: 23.5, humidity: 60 } }); +eq('mixed separators', parseTelemetryLine('x:1, y=2.5'), { values: { x: 1, y: 2.5 } }); +eq('embedded in text', parseTelemetryLine('INFO temp:42 humidity:55 packets received'), { values: { temp: 42, humidity: 55 } }); + +console.log('\nparseTelemetryLine — JSON shape:'); +eq('basic JSON', parseTelemetryLine('{"x":1,"y":2}'), { values: { x: 1, y: 2 } }); +eq('float JSON', parseTelemetryLine('{"temp":23.5}'), { values: { temp: 23.5 } }); +eq('mixed JSON drops non-numeric', parseTelemetryLine('{"x":1,"name":"sensor","y":2}'), { values: { x: 1, y: 2 } }); + +console.log('\nparseTelemetryLine — rejection cases:'); +eq('plain log line', parseTelemetryLine('hello world'), null); +eq('empty string', parseTelemetryLine(''), null); +eq('whitespace only', parseTelemetryLine(' '), null); +eq('non-numeric label', parseTelemetryLine('INFO: starting up'), null); + +console.log('\nestimateCostUSD — pricing math:'); +// gpt-4o: $2.50 in / $10.00 out per Mtok. 1000 in + 500 out = 0.0025 + 0.005 = 0.0075 +eq('gpt-4o', estimateCostUSD('gpt-4o', 1000, 500), 0.0075); +// claude-3-5-sonnet: $3 / $15. 10k in + 5k out = 0.03 + 0.075 = 0.105 +eq('claude-3-5-sonnet', estimateCostUSD('claude-3-5-sonnet-20241014', 10000, 5000), 0.105); +eq('ollama is free', estimateCostUSD('llama3.2', 1_000_000, 1_000_000), 0); +eq('unknown returns null',estimateCostUSD('mystery-model-99', 1000, 500), null); +eq('null model', estimateCostUSD(null, 1000, 500), null); + +console.log('\nformatUSD — display rounding:'); +eq('zero', formatUSD(0), '$0'); +eq('tiny', formatUSD(0.00005), '<$0.0001'); +eq('sub-cent', formatUSD(0.0035), '$0.0035'); +eq('sub-dollar', formatUSD(0.123), '$0.123'); +eq('dollars', formatUSD(12.5), '$12.50'); + +console.log('\nformatTokens — compact display:'); +eq('small', formatTokens(42), '42'); +eq('thousands', formatTokens(1234), '1.2K'); +eq('millions', formatTokens(2_500_000), '2.50M'); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail > 0 ? 1 : 0); diff --git a/scripts/test-ui-headless.py b/scripts/test-ui-headless.py new file mode 100644 index 0000000..f40979c --- /dev/null +++ b/scripts/test-ui-headless.py @@ -0,0 +1,241 @@ +"""Visual smoke test of the Embedist UI against the Vite dev server. + +We can drive the React frontend in headless Chromium, but Tauri's +`invoke()` IPC has no backend here, so any feature that calls into the +Rust commands (open folder, build, list providers, etc) will fail in the +console. We treat those as expected noise and report anything else. + +Outputs go to scripts/ui-screenshots/*.png; the script returns a small +JSON summary on stdout for easy parsing. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from playwright.sync_api import sync_playwright, ConsoleMessage + +OUT = Path(__file__).resolve().parent.parent / "scripts" / "ui-screenshots" +OUT.mkdir(parents=True, exist_ok=True) + +URL = "http://localhost:1420" + +# Console messages that are expected because the Tauri backend isn't here. +EXPECTED_NOISE = ( + "Tauri", # explicit tauri ipc errors + "invoke", + "IPC", + "rejected: window.__TAURI_IPC__", + "ResizeObserver", +) + + +def is_expected_noise(msg: ConsoleMessage) -> bool: + text = msg.text or "" + return any(needle in text for needle in EXPECTED_NOISE) + + +def main() -> int: + findings: list[str] = [] + console_errors: list[str] = [] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1280, "height": 800}) + + # The Tauri runtime exposes window.__TAURI_INTERNALS__ inside the + # native WebView. We're in plain Chromium, so we install a minimal + # shim before any script runs — just enough so getCurrentWindow() + # and invoke()/listen() don't throw. invoke() resolves with empty + # data so most fetches that hit the backend simply no-op. + context.add_init_script(""" + (() => { + // Many Embedist commands return arrays — providers list, board + // list, file list, etc. Returning [] is safer than {} so the + // .map() calls in the renderer don't blow up. + const empty = async () => []; + const noop = async () => {}; + window.__TAURI_INTERNALS__ = { + metadata: { + currentWebview: { label: 'main' }, + currentWindow: { label: 'main' }, + windows: [{ label: 'main' }], + webviews: [{ label: 'main' }], + }, + transformCallback: (cb) => (Math.random() * 1e9) | 0, + invoke: empty, + ipc: { postMessage: () => {} }, + runCallback: () => {}, + convertFileSrc: (p) => p, + // Tauri 2 event subsystem hangs off __TAURI_INTERNALS__. + // The unlisten chunk reads .unregisterListener(); make it a noop. + unregisterListener: () => {}, + registerListener: () => 0, + }; + window.__TAURI_IPC__ = noop; + window.__TAURI__ = { + event: { + emit: noop, + listen: async () => () => {}, + once: async () => () => {}, + }, + }; + })(); + """) + + page = context.new_page() + + all_console: list[str] = [] + page.on("console", lambda m: all_console.append(f"[{m.type}] {m.text}")) + # pageerror gives us the Error object; capture name + message + stack + def _on_pageerror(err): + try: + all_console.append(f"[pageerror] {err.name}: {err.message}\n{err.stack}") + except Exception: + all_console.append(f"[pageerror] {err}") + page.on("pageerror", _on_pageerror) + + # ---- (1) baseline dark theme --------------------------------------- + page.goto(URL, wait_until="domcontentloaded") + page.wait_for_load_state("networkidle", timeout=15000) + # App.tsx defers `appReady` 100 ms then shows the real UI. Give it some headroom. + page.wait_for_timeout(800) + page.screenshot(path=str(OUT / "01-dark-default.png"), full_page=False) + + # ---- DOM sanity: did the React app actually mount? ----------------- + root_html = page.evaluate("document.querySelector('#root')?.innerHTML?.length ?? 0") + if root_html < 200: + findings.append(f"React root barely rendered ({root_html} chars of innerHTML)") + else: + findings.append(f"OK React mounted ({root_html:,} chars in #root)") + + title = page.title() + findings.append(f"OK page title = {title!r}") + + # ---- (2) Tab cycling — does :focus-visible draw the new ring? ------ + # Tab a handful of times and capture which element has focus + its + # computed outline. The CSS rule sets `outline: 2px solid var(--accent)` + # so we expect a non-empty, non-"none" outline-style on each focused + # element after Tab. + focus_log: list[dict] = [] + # Make sure the document has actual focus so :focus-visible can fire + # (Chrome's heuristic for "keyboard navigation" needs the page to be + # the focused frame). + page.evaluate("window.focus(); document.body.focus();") + for i in range(8): + page.keyboard.press("Tab") + # Wait long enough for any `transition: all` on the focused + # element to complete; component CSS uses --transition-fast + # (100ms) and --transition-normal (150ms), so 400ms covers both. + page.wait_for_timeout(400) + info = page.evaluate("""() => { + const el = document.activeElement; + if (!el || el === document.body) return null; + const cs = getComputedStyle(el); + let matchesFocusVisible = false; + try { matchesFocusVisible = el.matches(':focus-visible'); } catch {} + return { + tag: el.tagName.toLowerCase(), + cls: (el.className || '').toString().slice(0, 60), + text: (el.innerText || el.value || '').toString().slice(0, 40), + outline: cs.outline, + outlineStyle: cs.outlineStyle, + outlineColor: cs.outlineColor, + outlineWidth: cs.outlineWidth, + boxShadow: cs.boxShadow, + matchesFocusVisible, + }; + }""") + if info is not None: + focus_log.append(info) + page.screenshot(path=str(OUT / "02-tab-focus.png"), full_page=False) + + ringed = [f for f in focus_log if f.get("outlineStyle") not in (None, "none", "")] + findings.append( + f"Tab visited {len(focus_log)} elements; {len(ringed)} show a focus outline" + ) + # Sample first 5 for evidence with explicit width/style/color + for f in focus_log[:5]: + findings.append( + f" focused <{f['tag']}> {f['cls'][:30]!r} " + f"outline-w={f['outlineWidth']} :focus-visible={f.get('matchesFocusVisible')} " + f"box-shadow={f.get('boxShadow', '')[:50]}" + ) + + # ---- (3) light theme contrast ------------------------------------- + # The settings store reads `theme` and the App applies it via + # data-theme. Setting the attribute directly tests the CSS payload. + page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") + page.wait_for_timeout(200) + page.screenshot(path=str(OUT / "03-light-theme.png"), full_page=False) + + contrast = page.evaluate("""() => { + const cs = getComputedStyle(document.body); + return { bg: cs.backgroundColor, fg: cs.color }; + }""") + findings.append(f"light theme body bg={contrast['bg']} fg={contrast['fg']}") + + # ---- (4) click Serial in sidebar, then Plot view toggle ------------ + # The Sidebar component lives at .app-sidebar (best guess) with icon + # buttons. We'll surface what's actually there before deciding how + # to click. + page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") + page.wait_for_timeout(150) + + # Try clicking a sidebar button with title or aria-label containing 'serial'. + clicked_serial = page.evaluate("""() => { + const candidates = [...document.querySelectorAll('button, [role="button"]')]; + const target = candidates.find(el => { + const t = (el.getAttribute('title') || el.getAttribute('aria-label') || el.innerText || '').toLowerCase(); + return t.includes('serial'); + }); + if (target) { target.click(); return true; } + return false; + }""") + findings.append(f"clicked sidebar Serial: {clicked_serial}") + page.wait_for_timeout(400) + page.screenshot(path=str(OUT / "04-serial-sidebar.png"), full_page=False) + + # Plot button is `.serial-view-btn` containing the text "Plot" + clicked_plot = page.evaluate("""() => { + const btn = [...document.querySelectorAll('button')] + .find(b => b.textContent?.trim() === 'Plot' && b.classList.contains('serial-view-btn')); + if (btn) { btn.click(); return true; } + return false; + }""") + findings.append(f"clicked Plot view toggle: {clicked_plot}") + page.wait_for_timeout(300) + page.screenshot(path=str(OUT / "05-plotter-empty-state.png"), full_page=False) + + # Verify the plotter empty-state SVG label rendered + empty_text = page.evaluate("""() => { + const el = document.querySelector('.serial-plotter-empty'); + return el ? el.textContent : null; + }""") + findings.append(f"plotter empty-state text: {empty_text!r}") + + # ---- (5) click Split mode to test the dual-view layout ------------- + page.evaluate("""() => { + const btn = [...document.querySelectorAll('button.serial-view-btn')] + .find(b => b.textContent?.trim() === 'Split'); + if (btn) btn.click(); + }""") + page.wait_for_timeout(300) + page.screenshot(path=str(OUT / "06-plotter-split-view.png"), full_page=False) + + # ---- Done ---------------------------------------------------------- + browser.close() + + summary = { + "screenshots_dir": str(OUT), + "findings": findings, + "console": all_console[:40], + "ok": True, + } + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7a449ca..70a060a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -813,7 +813,7 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] name = "embedist" -version = "0.38.0" +version = "0.39.1" dependencies = [ "dirs", "env_logger", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 89cec17..12544cd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "embedist" -version = "0.39.0" +version = "0.39.1" description = "AI-native embedded development environment" authors = ["Embedist Team"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 91c96e1..85dc5aa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Embedist", - "version": "0.39.0", + "version": "0.39.1", "identifier": "com.embedist.embedist", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/Common/Toast.css b/src/components/Common/Toast.css index 45aa70d..e8adae1 100644 --- a/src/components/Common/Toast.css +++ b/src/components/Common/Toast.css @@ -76,28 +76,31 @@ background: rgba(255, 255, 255, 0.1); } +/* Status toasts use color-mix so the tint follows the theme's --success + / --error / --warning / --info tokens. Falls back to a darker tint + when color-mix isn't supported (Edge legacy, Safari < 16.4). */ .toast-success { - background: rgba(52, 211, 153, 0.15); - border: 1px solid rgba(52, 211, 153, 0.4); - color: #34d399; + background: color-mix(in srgb, var(--success) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--success) 40%, transparent); + color: var(--success); } .toast-error { - background: rgba(239, 68, 68, 0.15); - border: 1px solid rgba(239, 68, 68, 0.4); - color: #ef4444; + background: color-mix(in srgb, var(--error) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--error) 40%, transparent); + color: var(--error); } .toast-warning { - background: rgba(251, 191, 36, 0.15); - border: 1px solid rgba(251, 191, 36, 0.4); - color: #fbbf24; + background: color-mix(in srgb, var(--warning) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent); + color: var(--warning); } .toast-info { - background: rgba(96, 165, 250, 0.15); - border: 1px solid rgba(96, 165, 250, 0.4); - color: #60a5fa; + background: color-mix(in srgb, var(--info) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--info) 40%, transparent); + color: var(--info); } @keyframes toastIn { diff --git a/src/components/Settings/SetupWizard.css b/src/components/Settings/SetupWizard.css index 9f053d2..da83f3f 100644 --- a/src/components/Settings/SetupWizard.css +++ b/src/components/Settings/SetupWizard.css @@ -4,7 +4,7 @@ left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.8); + background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; @@ -12,167 +12,177 @@ } .setup-wizard { - background: #1e1e2e; - border-radius: 12px; - padding: 32px; + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: var(--space-xxl); width: 500px; max-width: 90vw; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + box-shadow: var(--shadow-lg); } .setup-wizard-header { text-align: center; - margin-bottom: 24px; + margin-bottom: var(--space-xl); } .setup-wizard-header h1 { - margin: 0 0 8px 0; - font-size: 24px; - color: #cdd6f4; + margin: 0 0 var(--space-sm) 0; + font-size: var(--text-xl); + color: var(--text-primary); } .setup-wizard-header p { margin: 0; - color: #a6adc8; + color: var(--text-secondary); } .setup-wizard-steps { display: flex; justify-content: center; - gap: 16px; - margin-bottom: 32px; + gap: var(--space-lg); + margin-bottom: var(--space-xxl); } .setup-wizard-steps .step { - padding: 8px 16px; - border-radius: 20px; - background: #313244; - color: #6c7086; - font-size: 13px; - transition: all 0.2s ease; + padding: var(--space-sm) var(--space-lg); + border-radius: 999px; + background: var(--bg-tertiary); + color: var(--text-muted); + font-size: var(--text-sm); + transition: background var(--transition-fast), color var(--transition-fast); } .setup-wizard-steps .step.active { - background: #89b4fa; - color: #1e1e2e; + background: var(--accent); + color: var(--text-inverse); } .setup-wizard-content h2 { - margin: 0 0 12px 0; - font-size: 18px; - color: #cdd6f4; + margin: 0 0 var(--space-md) 0; + font-size: var(--text-md); + color: var(--text-primary); } .setup-wizard-content > p { - color: #a6adc8; - margin-bottom: 20px; + color: var(--text-secondary); + margin-bottom: var(--space-xl); } .platformio-status { - background: #313244; - padding: 12px 16px; - border-radius: 8px; - margin-bottom: 20px; + background: var(--bg-tertiary); + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + margin-bottom: var(--space-xl); + border: 1px solid var(--border-subtle); } .platformio-status .installed { - color: #a6e3a1; + color: var(--success); } .platformio-status .not-installed { - color: #f38ba8; + color: var(--error); } .install-progress { - margin-top: 12px; - padding: 8px 12px; - background: #45475a; - border-radius: 6px; - font-size: 13px; - color: #fab387; + margin-top: var(--space-md); + padding: var(--space-sm) var(--space-md); + background: var(--bg-surface-hover); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + color: var(--warning); } .platforms-list { display: flex; flex-direction: column; - gap: 12px; - margin-bottom: 24px; + gap: var(--space-md); + margin-bottom: var(--space-xl); } .platform-option { display: flex; align-items: center; - gap: 12px; - padding: 12px 16px; - background: #313244; - border-radius: 8px; + gap: var(--space-md); + padding: var(--space-md) var(--space-lg); + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); cursor: pointer; - transition: background 0.2s ease; + transition: background var(--transition-fast), border-color var(--transition-fast); } .platform-option:hover { - background: #45475a; + background: var(--bg-surface-hover); + border-color: var(--border-active); } .platform-option input { width: 18px; height: 18px; - accent-color: #89b4fa; + accent-color: var(--accent); } .platform-name { flex: 1; - color: #cdd6f4; + color: var(--text-primary); } .platform-size { - color: #6c7086; - font-size: 12px; + color: var(--text-muted); + font-size: var(--text-sm); } .setup-wizard-actions { display: flex; - gap: 12px; + gap: var(--space-md); justify-content: flex-end; } .setup-btn { - padding: 10px 20px; - border-radius: 8px; - border: none; - font-size: 14px; + padding: var(--space-sm) var(--space-xl); + border-radius: var(--radius-md); + border: 1px solid transparent; + font-size: var(--text-md); cursor: pointer; - transition: all 0.2s ease; + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); } .setup-btn.primary { - background: #89b4fa; - color: #1e1e2e; + background: var(--accent); + color: var(--text-inverse); } .setup-btn.primary:hover { - background: #b4befe; + background: var(--accent-hover); } .setup-btn.primary:disabled { - opacity: 0.6; + opacity: 0.5; cursor: not-allowed; } -.setup-btn.secondary { - background: #45475a; - color: #cdd6f4; +.setup-btn.secondary, +.setup-btn.skip { + background: var(--bg-surface-hover); + color: var(--text-primary); + border-color: var(--border-default); } -.setup-btn.secondary:hover { - background: #585b70; +.setup-btn.secondary:hover, +.setup-btn.skip:hover { + background: var(--bg-tertiary); + border-color: var(--border-active); } .setup-note { - background: #313244; - padding: 12px 16px; - border-radius: 8px; - font-size: 13px; - color: #a6adc8; - margin-bottom: 24px; + background: var(--bg-tertiary); + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + font-size: var(--text-sm); + color: var(--text-secondary); + margin-bottom: var(--space-xl); + border: 1px solid var(--border-subtle); } diff --git a/src/styles/global.css b/src/styles/global.css index 8b0a765..5a857e8 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -74,9 +74,13 @@ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.55); - /* Focus — apply with `outline: var(--focus-ring); outline-offset: 2px` - inside a `:focus-visible` selector. Used to standardize keyboard nav. */ - --focus-ring: 2px solid var(--accent); + /* Focus — atomic tokens (width / style / color) because some browsers + don't reliably substitute a compound shorthand stored in a single + custom property into the `outline:` shorthand. Apply with separate + properties inside a `:focus-visible` selector. */ + --focus-ring-width: 2px; + --focus-ring-style: solid; + --focus-ring-color: var(--accent); --focus-ring-offset: 2px; } @@ -150,28 +154,25 @@ button { :focus-visible (only fires when the browser thinks the user is using the keyboard). Individual components may override with a tighter ring but should not remove it entirely. */ +/* Global focus ring. We use `box-shadow` instead of `outline` because + Chromium has a long-standing quirk where the UA `:focus-visible` + `outline-style: auto` styling can't be fully overridden by an author + `outline-style: solid` — the rendered width stays at the UA's 1px + regardless of what the author specifies. `box-shadow` has no such + issue and is the modern recommendation for keyboard focus rings. + We also kill the UA outline explicitly so the two don't double up. */ :focus-visible { - outline: var(--focus-ring); - outline-offset: var(--focus-ring-offset); + outline: none; + box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring-color); border-radius: var(--radius-sm); } -button:focus-visible, -a:focus-visible, -[role="button"]:focus-visible, -[role="menuitem"]:focus-visible, -[role="tab"]:focus-visible, -[tabindex]:focus-visible { - outline: var(--focus-ring); - outline-offset: var(--focus-ring-offset); -} - input:focus-visible, textarea:focus-visible, select:focus-visible { - outline: var(--focus-ring); - outline-offset: 0; - box-shadow: 0 0 0 4px var(--accent-focus); + outline: none; + box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring-color), + 0 0 0 calc(var(--focus-ring-width) + 4px) var(--accent-focus); } /* Respect reduced-motion preferences across all components. */