diff --git a/CHANGELOG.md b/CHANGELOG.md index ea29cc24..c8ec9465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## [Unreleased] +## [v0.51.171] — 2026-05-30 — Release EQ (stage-batch53 — tool-output card badge + Neon opt-in skin) + +### Added + +- Tool-call output cards now carry a persistent "Tool output" badge and accent rail so tool output stays visually distinct from final assistant responses without requiring hover (#2867). +- New opt-in "Neon" cyberpunk skin (dark-first, purple/cyan accents). Default-off; select it from the skin list like Catppuccin or Nous. + ## [v0.51.170] — 2026-05-30 — Release EP (stage-batch52 — run-aware SSE replay cursors) ### Fixed diff --git a/static/boot.js b/static/boot.js index 3835e261..9136a028 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1343,6 +1343,7 @@ const _SKINS=[ {name:'Catppuccin',colors:['#CBA6F7','#B4BEFE','#8839EF']}, {name:'Hepburn', colors:['#c6246a','#ec5597','#f2abca']}, {name:'Nous', colors:['#4682B4','#3A6E9A','#2C5F88']}, + {name:'Neon', colors:['#B347FF','#C76BFF','#00DDFF']}, {name:'Geist Contrast', value:'geist-contrast', colors:['#000000','#ffffff','#FFF175']}, ]; const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value)); diff --git a/static/index.html b/static/index.html index ccd2a344..5969bde2 100644 --- a/static/index.html +++ b/static/index.html @@ -17,7 +17,7 @@ - + diff --git a/static/style.css b/static/style.css index 90b36834..f1a97f79 100644 --- a/static/style.css +++ b/static/style.css @@ -345,6 +345,86 @@ :root.dark[data-skin="nous"] .mcp-http{color:#7EB6E0;} :root.dark[data-skin="nous"] .mcp-status-active{color:#7EB6E0;} + /* ═══ NEON — cyberpunk theme (dark-first) ═══ */ + :root[data-skin="neon"]{ + --bg:#F8F7FC;--sidebar:#F0EEF8;--border:#D8D4EC;--border2:rgba(0,0,0,0.12); + --text:#1E1B2E;--muted:#6E6888;--accent:#9333EA;--blue:#7C3AED;--gold:#A855F7;--code-bg:#F0EDF8; + --surface:#FFFFFF;--topbar-bg:rgba(248,247,252,.98);--main-bg:rgba(248,247,252,0.5); + --focus-ring:rgba(147,51,234,.3);--focus-glow:rgba(147,51,234,.08); + --input-bg:rgba(147,51,234,.03);--hover-bg:rgba(147,51,234,.05); + --strong:#0D0B14;--em:#7C3AED;--code-text:#6D28D9;--code-inline-bg:rgba(147,51,234,.08);--pre-text:#1E1B2E; + --accent-hover:#7E22CE;--accent-bg:rgba(147,51,234,0.06);--accent-bg-strong:rgba(147,51,234,0.12);--accent-text:#6D28D9; + --error:#DC2626;--success:#059669;--warning:#D97706;--info:#7C3AED; + --radius-sm:2px;--radius-md:4px;--radius-card:4px;--radius-lg:6px; + --font-ui:system-ui,-apple-system,sans-serif; + --surface-subtle:rgba(147,51,234,.025);--surface-subtle-hover:rgba(147,51,234,.05); + --border-subtle:rgba(147,51,234,.08);--border-muted:rgba(147,51,234,.12); + --purple:#9333EA; + } + :root.dark[data-skin="neon"]{ + --bg:#08060d;--sidebar:#06040a;--border:#1c1633;--border2:rgba(179,71,255,0.15); + --text:#d8d6f0;--muted:#6a6588;--accent:#b347ff;--blue:#a78bfa;--gold:#c084fc;--code-bg:#050308; + --surface:#0f0d1a;--topbar-bg:rgba(8,6,13,.98);--main-bg:rgba(8,6,13,0.5); + --focus-ring:rgba(179,71,255,.35);--focus-glow:rgba(179,71,255,.10); + --input-bg:rgba(179,71,255,.04);--hover-bg:rgba(179,71,255,.06); + --strong:#f0edff;--em:#00ddff;--code-text:#c8c0f0;--code-inline-bg:rgba(179,71,255,.10);--pre-text:#d8d6f0; + --accent-hover:#c76bff;--accent-bg:rgba(179,71,255,0.10);--accent-bg-strong:rgba(179,71,255,0.20);--accent-text:#c8a0ff; + --error:#ff4466;--success:#00dd88;--warning:#ffaa33;--info:#00ddff; + --surface-subtle:rgba(179,71,255,.03);--surface-subtle-hover:rgba(179,71,255,.06); + --border-subtle:rgba(179,71,255,.08);--border-muted:rgba(179,71,255,.12); + --purple:#b347ff; + } + /* ── Neon skin: component-level overrides ── */ + /* Selection glow */ + :root.dark[data-skin="neon"] ::selection{background:rgba(179,71,255,0.35);color:#fff;} + /* Topbar: purple bottom border with glow */ + :root.dark[data-skin="neon"] .app-titlebar{border-bottom-color:rgba(179,71,255,0.2);} + /* Sidebar: subtle purple inset shadow */ + :root.dark[data-skin="neon"] .sidebar{box-shadow:inset -1px 0 30px rgba(179,71,255,0.03);} + /* Inputs: purple glow on focus */ + :root.dark[data-skin="neon"] textarea:focus, + :root.dark[data-skin="neon"] input[type="text"]:focus, + :root.dark[data-skin="neon"] input[type="password"]:focus, + :root.dark[data-skin="neon"] select:focus{box-shadow:0 0 16px rgba(179,71,255,0.2);} + /* Send button: neon glow */ + :root.dark[data-skin="neon"] button.send-btn{box-shadow:0 0 14px rgba(179,71,255,0.25);} + :root.dark[data-skin="neon"] button.send-btn:hover{box-shadow:0 0 24px rgba(179,71,255,0.45);} + /* New chat button: purple gradient */ + :root.dark[data-skin="neon"] .new-chat-btn{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;box-shadow:0 0 12px rgba(179,71,255,0.2);} + :root.dark[data-skin="neon"] .new-chat-btn:hover{background:var(--accent-hover);border-color:var(--accent-hover);color:#fff;box-shadow:0 0 20px rgba(179,71,255,0.35);} + /* Scrollbar: purple */ + :root.dark[data-skin="neon"]::-webkit-scrollbar-thumb{background:rgba(179,71,255,.18);border-radius:3px;} + :root.dark[data-skin="neon"]::-webkit-scrollbar-thumb:hover{background:rgba(179,71,255,.30);} + :root.dark[data-skin="neon"]{scrollbar-width:thin;scrollbar-color:rgba(179,71,255,.18) transparent;} + /* Tool cards */ + :root.dark[data-skin="neon"] .tool-card{background:rgba(179,71,255,0.03);border-color:rgba(179,71,255,0.08);} + :root.dark[data-skin="neon"] .tool-card:hover{border-color:rgba(179,71,255,0.2);} + :root.dark[data-skin="neon"] .tool-card-running{background:var(--accent-bg);border-color:var(--accent-bg-strong);} + :root.dark[data-skin="neon"] .tool-card-name{color:var(--muted);font-weight:500;} + :root.dark[data-skin="neon"] .tool-arg-key, + :root.dark[data-skin="neon"] .tool-card-more{color:var(--accent-text);} + :root.dark[data-skin="neon"] .tool-card-running-dot{background:var(--accent);} + /* Tree viewer: purple family */ + :root.dark[data-skin="neon"] .tree-key{color:#b347ff;} + :root.dark[data-skin="neon"] .tree-str{color:#c8a0ff;} + :root.dark[data-skin="neon"] .tree-num{color:#00ddff;} + :root.dark[data-skin="neon"] .tree-bool{color:#b347ff;} + /* Session badges/tags */ + :root.dark[data-skin="neon"] .session-child-count{background:rgba(179,71,255,.16);color:#c8a0ff;} + :root.dark[data-skin="neon"] .session-child-count:hover{background:rgba(179,71,255,.26);color:#d8c0ff;} + :root.dark[data-skin="neon"] .session-tree-badge{background:rgba(179,71,255,.2);color:#c8a0ff;} + :root.dark[data-skin="neon"] .session-tag{background:rgba(179,71,255,.2);color:#c8a0ff;} + /* Diff blocks */ + :root.dark[data-skin="neon"] .diff-block .diff-plus{color:#b347ff;} + /* MCP status */ + :root.dark[data-skin="neon"] .mcp-http{color:#c8a0ff;} + :root.dark[data-skin="neon"] .mcp-status-active{color:#00dd88;} + /* Image lightbox */ + :root.dark[data-skin="neon"] .img-lightbox-close:hover{background:rgba(179,71,255,.35);} + /* Links: cyan with hover glow */ + :root.dark[data-skin="neon"] a{color:var(--info);transition:text-shadow 0.2s;} + :root.dark[data-skin="neon"] a:hover{text-shadow:0 0 8px rgba(0,221,255,0.4);} + /* ── Skin: Geist Contrast (Geist-inspired neutral precision) ── Dark mode uses a #FFF175 accent; solid accent controls use black foreground, while selected/navigation states stay neutral and restrained. */ @@ -2544,11 +2624,12 @@ body.resizing .sidebar{transition:none!important;} display:none; } } -.tool-card{background:var(--surface-subtle);border:1px solid var(--border-muted);border-left:2px solid var(--border-muted);border-radius:var(--radius-card);margin:2px 0;overflow:hidden;transition:border-color .15s,background-color .15s;} +.tool-card{background:var(--surface-subtle);border:1px solid var(--border-muted);border-left:3px solid var(--accent-bg-strong);border-radius:var(--radius-card);margin:2px 0;overflow:hidden;transition:border-color .15s,background-color .15s;} .tool-card:hover{border-color:var(--border2);background:var(--surface-subtle-hover);} .tool-card-running{border-color:var(--accent-bg-strong);background:var(--accent-bg);} .tool-card-header{display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) var(--space-3);cursor:pointer;user-select:none;} .tool-card-icon{font-size:13px;flex-shrink:0;opacity:.65;} +.tool-card-badge{flex-shrink:0;font-size:10px;line-height:1.2;text-transform:uppercase;letter-spacing:.045em;font-weight:700;color:var(--accent-text);background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:999px;padding:1px 6px;} .tool-card-name{font-size:var(--font-size-xs);font-weight:600;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;flex-shrink:0;} .tool-card-preview{font-size:var(--font-size-xs);color:var(--muted);opacity:.62;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .tool-card-toggle{font-size:10px;color:var(--muted);opacity:.45;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;} diff --git a/static/ui.js b/static/ui.js index 50ffce78..5f2a9360 100644 --- a/static/ui.js +++ b/static/ui.js @@ -6987,6 +6987,7 @@ function buildToolCard(tc){
${runIndicator} ${icon} + Tool output ${esc(displayName)} ${esc(previewText)} ${hasDetail?`${li('chevron-right',12)}`:''} diff --git a/tests/test_issue2867_tool_output_visual_distinction.py b/tests/test_issue2867_tool_output_visual_distinction.py new file mode 100644 index 00000000..f8daca29 --- /dev/null +++ b/tests/test_issue2867_tool_output_visual_distinction.py @@ -0,0 +1,33 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") + + +def test_tool_cards_render_persistent_tool_output_badge(): + """Tool output must remain distinguishable without relying on hover state.""" + build_start = UI_JS.index('function buildToolCard(tc){') + build_end = UI_JS.index('function _syncToolCallGroupSummary', build_start) + build_tool_card = UI_JS[build_start:build_end] + + assert 'Tool output' in build_tool_card + assert '${esc(displayName)}' in build_tool_card + assert build_tool_card.index('class="tool-card-badge"') < build_tool_card.index('class="tool-card-name"') + + +def test_tool_card_badge_style_is_not_hover_only(): + badge_rule_start = STYLE_CSS.index('.tool-card-badge{') + badge_rule_end = STYLE_CSS.index('}', badge_rule_start) + badge_rule = STYLE_CSS[badge_rule_start:badge_rule_end] + + assert '.tool-card:hover .tool-card-badge' not in STYLE_CSS + assert 'text-transform:uppercase' in badge_rule + assert 'background:var(--accent-bg)' in badge_rule + assert 'border:1px solid var(--accent-bg-strong)' in badge_rule + assert 'color:var(--accent-text)' in badge_rule + + +def test_tool_cards_have_persistent_accent_rail(): + assert '.tool-card{background:var(--surface-subtle);' in STYLE_CSS + assert 'border-left:3px solid var(--accent-bg-strong)' in STYLE_CSS diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index 21a0656e..49d68630 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -681,7 +681,11 @@ def test_pwa_safe_area_top_stays_scoped_to_installed_modes(): def test_titlebar_safe_area_top_uses_scoped_variable(): """The titlebar must use the safe-area variable instead of direct env().""" - m = re.search(r'\.app-titlebar\{(?P[^}]*)\}', CSS) + # Match the GLOBAL `.app-titlebar{...}` rule, not skin-scoped variants like + # `:root.dark[data-skin="neon"] .app-titlebar{...}` (#3164) which can appear + # earlier in the file. Require the selector to start the line (optionally + # indented) with no `[data-skin=` scope prefix. + m = re.search(r'(?m)^\s*\.app-titlebar\{(?P[^}]*)\}', CSS) assert m, ".app-titlebar rule missing from style.css" rule = m.group("body") assert "padding-top:var(--app-titlebar-safe-top)" in rule, ( diff --git a/tests/test_sprint20b.py b/tests/test_sprint20b.py index 13aad7b7..ee41488a 100644 --- a/tests/test_sprint20b.py +++ b/tests/test_sprint20b.py @@ -31,7 +31,10 @@ def _find_global_selector(css, selector): return -1 line_start = css.rfind('\n', 0, idx) + 1 line_prefix = css[line_start:idx] - if ':root[data-skin=' not in line_prefix: + # Skip skin-scoped rules. Skins use both `:root[data-skin="x"]` and the + # dark-variant `:root.dark[data-skin="x"]` (#3164 Neon), so match the + # `[data-skin=` marker generically rather than the bare `:root[` form. + if '[data-skin=' not in line_prefix: return idx pos = idx + 1