Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions __dlg-main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const { app, BrowserWindow } = require('electron')
const fs = require('fs')
const path = require('path')
app.disableHardwareAcceleration()
const html = fs.readFileSync(path.join(__dirname, '__dlg.html'), 'utf8')
app.whenReady().then(async () => {
const results = []
for (const [w, h] of [[1440, 900], [1280, 700], [900, 500], [800, 380]]) {
const win = new BrowserWindow({ width: w, height: h, show: false })
await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))
const r = await win.webContents.executeJavaScript(`(() => {
const c = document.getElementById('content')
const b = document.getElementById('body')
const cr = c.getBoundingClientRect()
return {
viewport: innerHeight,
contentH: Math.round(cr.height),
contentTop: Math.round(cr.top),
contentBottom: Math.round(cr.bottom),
bodyClientH: b.clientHeight,
bodyScrollH: b.scrollHeight,
bodyScrolls: b.scrollHeight > b.clientHeight + 1,
overflowsViewport: cr.top < -0.5 || cr.bottom > innerHeight + 0.5,
}
})()`)
results.push({ win: w + 'x' + h, ...r })
win.destroy()
}
console.log('RESULTS ' + JSON.stringify(results))
app.quit()
}).catch(e => { console.log('ERR ' + e.message); app.quit() })
30 changes: 30 additions & 0 deletions __dlg.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!doctype html><html><head><style>
*{box-sizing:border-box;margin:0}
html,body{height:100%}
/* DialogContent = base + guide override, after tailwind-merge */
#content{
position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);
display:grid;grid-template-rows:auto 1fr auto;
width:min(672px,92vw);max-height:88vh;
border:1px solid #888;background:#111;color:#eee;outline:none;z-index:1100;
}
/* DialogHeader */
#hdr{border-bottom:1px solid #444;padding:12px 16px}
/* guide body */
#body{display:flex;min-height:0;flex-direction:column;gap:24px;overflow-y:auto;padding:16px;font-size:12px;line-height:1.6}
/* DialogFooter */
#ftr{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid #444;padding:12px 16px}
.step{display:flex;flex-direction:column;gap:8px}
.shot{height:128px;width:100%;border:1px dashed #555}
</style></head><body>
<div id="content">
<div id="hdr"><div>Configure Voice Dictation</div><div style="margin-top:4px;font-size:11px">Agent Code's inline dictation streams audio to Deepgram for transcription. New Deepgram accounts get $200 in free credits, which is generally enough for very long-term personal use.</div></div>
<div id="body">
<section class="step"><h3>1 Create a Deepgram account</h3><p>Open console.deepgram.com/signup and finish the signup — the $200 credit is applied automatically.</p><div class="shot"></div></section>
<section class="step"><h3>2 Create a project API key</h3><p>From the console, open API Keys in the sidebar, click Create a New API Key, give it the scope Member or broader, copy the string that appears once.</p><div class="shot"></div></section>
<section class="step"><h3>3 Paste the key into Settings</h3><p>In Agent Code, open Settings → Voice Dictation, paste the key into the Deepgram API Key row, and press Save. Your key is encrypted with your system keyring.</p><div class="shot"></div></section>
<div style="border:1px solid #444;padding:8px 12px;font-size:11px"><p style="font-weight:600">A note on the hotkey.</p><p>Dictation is triggered with Cmd+Shift+D by default: press once to record and again to finish, with no OS permission required. If you prefer holding Fn like macOS system dictation, switch the shortcut in Settings; macOS will then prompt for Accessibility permission the first time you enable dictation.</p></div>
</div>
<div id="ftr"><button style="height:28px">Done</button></div>
</div>
</body></html>
207 changes: 207 additions & 0 deletions docs/plans_and_ideas/2026-07-28-ui-primitive-theme-fidelity-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# UI primitive theme fidelity — plan

**Date:** 2026-07-28
**Branch:** `feat/ui-primitives-theme-fidelity`
**Status:** plan, then implementation in the same PR

## The finding

`src/renderer/src/components/ui/` disagrees with itself about which theme tokens
a control is made of.

`NumberInput` (`number-input.tsx`, the most recently added primitive) is built
entirely on the `control-*` family:

```
container border-control-border bg-control-bg
steppers text-control-fg hover:bg-control-hover-bg hover:text-ink
disabled:hover:bg-control-bg
field text-control-fg
```

`Button` (`button.tsx`) is not. Its `outline` variant hardcodes the tokens that
`control-*` happens to *alias to*, rather than the `control-*` tokens
themselves:

| `Button` variant `outline` | what the app's control chrome uses |
| -------------------------- | ---------------------------------- |
| `border-border` | `border-control-border` |
| `bg-transparent` | `bg-control-bg` |
| `text-ink-dim` | `text-control-fg` |
| `hover:border-border-hi` | `hover:border-control-border-hover`|
| *(no hover background)* | `hover:bg-control-hover-bg` |

`ghost` has the same problem in miniature: it already reaches for
`hover:bg-control-hover-bg`, but pairs it with `text-ink-dim` instead of
`text-control-fg`. So a single `DialogActions` footer renders a `ghost` Cancel
on `ink-dim` next to a `NumberInput` stepper on `control-fg` — two different
token families, one control strip.

### Why this is invisible today

`styles.css` aliases the whole family to exactly those values:

```css
--theme-control-bg: transparent;
--theme-control-hover-bg: var(--theme-surface-hi);
--theme-control-border: var(--theme-border);
--theme-control-border-hover: var(--theme-border-hi);
--theme-control-fg: var(--theme-ink-dim);
```

Under every built-in theme the two columns above render identically. Nothing
looks wrong, which is why this survived.

### Why it is a real bug anyway

`customAppearance.ts` exposes all seven `control-*` tokens as independently
user-editable, and documents them as being about buttons specifically:

```
controlBg 'Resting button/toggle/select background.'
controlBorder 'Resting control border.'
controlFg 'Resting control text/icon color.'
controlBorderHover 'Hover/focusable control border before true focus.'
```

`theme.ts` → `applyCustomAppearance()` writes every one of them as an inline
custom property on `<html>`, where they outrank the `[data-mode]` blocks.

So the moment a user edits `controlBg`, `controlFg`, or `controlBorder`:

- the 25 hand-rolled `control-*` buttons in the tree honour it;
- `NumberInput` honours it;
- **every `<Button>` in the app silently ignores it.**

The primitive is currently *less* theme-correct than the ad-hoc markup it
exists to replace. That is also the most plausible reason adoption stalled:
`Button` is not a drop-in for the app's most common button, so nobody could
migrate onto it without changing how their feature looked under a custom theme.

**This inverts the obvious remediation order.** Migrating call sites onto
`Button` first would spread the defect across the tree. `Button` gets fixed
first; migration follows.

## Audit numbers (against `main`, commit `6506b9aa`)

Counted across the desktop renderer, `providers/*/renderer`, and
`remote-client`.

| Measure | Count |
| --- | --- |
| Raw `<button>` in `src/renderer`, excluding tests | 223 |
| — carrying tokens matching an existing variant | ~180 (81%) |
| — genuinely bespoke (tab strips, segmented controls, chips) | 29 |
| — unstyled | 7 |
| Hand-rolled `control-*` button recipe | 25 sites / 12 files |
| Files importing `Button` (renderer only / all of `src`) | 20 / 24 |

Counting method, so these are reproducible: `git grep -c "<button" origin/main --
'src/renderer/**/*.tsx'` then drop lines whose path contains `.test.`. The
variant-shape buckets come from a token-presence classifier, so treat them as
indicative rather than exact.

Bucketed by the tokens each raw button carries: 77 outline-shaped, 52
ghost-shaped, 31 accent-shaped, 17 danger-shaped. "Shaped like" means *carries
those tokens*, not *verified drop-in* — the bespoke bucket exists because
segmented controls and tab strips legitimately are not `Button`s.

### Clean, and worth recording so nobody re-audits it

- Colour discipline is strong: 9 raw Tailwind palette colours app-wide, all in
debug modules. Zero `bg-[#...]` arbitrary values.
- `components/ui/` structure is fully README-compliant: flat, kebab-case, no
barrel, no nested taxonomy.
- Exactly one `fixed inset-0` outside `dialog.tsx`; no `createPortal` outside
Radix.
- Every provider *modal* uses `Dialog` + `Button` and contains zero raw
buttons. Note the scope: `providers/*/renderer` as a whole still has 13 raw
`<button>`s across 11 files, all of them disclosure toggles inside feed row
components, which is appropriate. The modals are the clean part, not the
whole surface.
- `remote-client` shares no primitives with the desktop renderer. It is a
separate Vite build using plain CSS classes, deliberately, for touch targets.
Out of scope here; noted so the next audit does not re-derive it.

## Scope

Three defects, in dependency order. Everything else found by the audit is
recorded above and deliberately left alone.

### 1. Retokenize `Button` onto `control-*`

`outline` and `ghost` point at the `control-*` family. `default`,
`destructive`, and `link` are unchanged — they are accent/danger chrome, not
control chrome, and have no `control-*` equivalent.

`secondary` is also unchanged. It is the filled/raised treatment
(`bg-surface-hi text-ink`) and there is no `control-*` token for "raised
resting" — `controlActiveBg` means *selected*, which is a different state.
Forcing it would be inventing a meaning the theme schema does not have.

**Accepted visual change:** `outline` gains `hover:bg-control-hover-bg`. Under
default themes that is `surface-hi`, i.e. the same hover fill the 25
hand-rolled control buttons already paint. This makes step 3 a convergence
rather than a second divergence.

### 2. `DictationGuideModal` → `DialogContent`

The only remaining hand-rolled app modal. It implements its own Escape handler,
its own Tab focus trap, its own `fixed inset-0` backdrop, its own focus
restoration, and copies `data-agent-code-interaction-owner="app"` onto its
root.

The `components/ui/README.md` forbids each of these by name:

> Feature modals must not add their own document-level Escape listener, focus
> trap, backdrop implementation, or background-input suppression.

> Do not copy the marker into feature dialogs. The primitive owns it for
> exactly the same lifetime as its portal and focus trap.

It is not one of the sanctioned full-screen takeovers (setup, new-agent
placement). Its footer also becomes `DialogActions`, which is what the rest of
the tree uses.

### 3. Text inputs onto `Input`

Three competing recipes exist for one control:

| Site | Recipe |
| --- | --- |
| `components/ui/input.tsx` | `border-input-border bg-input-bg` + `focus-visible:border-input-border-focus` + ring |
| `SettingsSearch.tsx` | hand-copy of the above, minus the ring |
| `DictationApiKeyRow.tsx` | **`control-*` tokens on a text input** — wrong family |

`DictationApiKeyRow` is the substantive one: `control-*` is button chrome,
`input-*` is field chrome, and the two are independently themeable.

### 4. Migrate the hand-rolled `control-*` buttons

The 25 sites in 11 files, onto `<Button variant="outline">` / `"ghost"`. This
is only safe *after* step 1; before it, each migration is a silent regression
under custom themes.

Sites that are toggles with a selected state keep their conditional class —
`Button` has no `aria-pressed` variant and inventing one for this is out of
scope.

## Explicitly out of scope

- The other ~150 raw buttons. `components/ui/README.md` says directly: *"No
migration of every existing button as a prerequisite."* Most are tab strips
and segmented controls where `Button` is not a drop-in.
- New primitives (checkbox ×7, radio ×4, `<select>` ×2 have ad-hoc
implementations and each now clears the README's "real consumer" bar). Worth
a follow-up; not this PR.
- `remote-client` chrome.

## Verification

Per `docs/testing/`: `electron-vite build` and `vitest` do not type-check, so
raw `tsc` runs on both projects are the gate.

1. `npx tsc -p tsconfig.node.json --noEmit`
2. `npx tsc -p tsconfig.web.json --noEmit`
3. `npm run test:renderer` — `dialog.renderer.test.tsx` covers the modal
contract step 2 hands to Radix.
66 changes: 63 additions & 3 deletions src/renderer/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,76 @@ import { cn } from '@renderer/lib/utils'
// Adapted from https://ui.shadcn.com/docs/components/button.
// Agent Code keeps the familiar API but maps it onto the app's dense,
// square, semantic-token visual language instead of shadcn's default theme.
//
// WHY `outline` and `ghost` name the `control-*` tokens and not the tokens
// those happen to alias to:
//
// styles.css defines the control family as aliases —
// `--theme-control-bg: transparent`, `--theme-control-border:
// var(--theme-border)`, `--theme-control-fg: var(--theme-ink-dim)`. So
// writing `border-border bg-transparent text-ink-dim` (what this file used
// to do) renders identically under every built-in theme, and looked fine
// forever.
//
// It is not fine, because customAppearance.ts exposes all seven `control-*`
// tokens as INDEPENDENTLY user-editable, documented as button chrome
// ("Resting button/toggle/select background", "Resting control text/icon
// color"), and applyCustomAppearance writes them as inline custom properties
// on <html> where they outrank the [data-mode] blocks. A user who edits
// controlFg used to get every hand-rolled control button in the tree
// honouring it while every <Button> silently ignored it — which made this
// primitive LESS theme-correct than the ad-hoc markup it exists to replace,
// and is the most plausible reason adoption never displaced the hand-rolled
// recipe (which still had ~24 call sites when this landed).
//
// NumberInput (number-input.tsx) already builds its steppers on `control-*`.
// Before this change a single DialogActions footer could render a `ghost`
// Cancel on ink-dim beside a NumberInput stepper on control-fg: two token
// families in one control strip.
//
// WHY `default`, `destructive`, and `link` are NOT retokenized: they are accent
// and danger chrome, not control chrome, and the control family has no
// equivalent for them.
//
// WHY `secondary` keeps `bg-surface-hi` and `text-ink` but DOES take the
// control border: it is the filled/raised treatment, and there is no
// `control-*` token meaning "raised resting" — `controlActiveBg` means
// SELECTED, a different state, and `controlFg` is the dimmer resting colour
// that `secondary` deliberately is not. Those two have no control equivalent.
// Its BORDER does: `controlBorder` is documented as "Resting control border"
// and a secondary button is unambiguously a control, so leaving it on the
// generic `border` token would reintroduce exactly the split this file is
// fixing — SettingsPage renders an `outline` Close and a `secondary` Cancel in
// the same view, and they must not disagree about their border.
//
// WHY `destructive-outline` exists as a variant rather than a className
// override: five call sites had independently written some spelling of
// "outline chrome, danger text" (dictation key row, three dictation history
// actions, composer Stop). Each had to actively CANCEL `outline`'s
// `hover:text-ink`, which is the signal that it is a variant and not
// feature-specific layout. It is the filled `destructive`'s quieter sibling:
// use it when the click OPENS a confirmation, and `destructive` when the click
// IS the destructive act.
const buttonVariants = cva(
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap border font-code font-medium transition-colors outline-none focus-visible:border-focus-ring focus-visible:ring-1 focus-visible:ring-focus-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'border-accent bg-accent text-accent-fg hover:brightness-110',
secondary: 'border-border bg-surface-hi text-ink hover:border-border-hi',
outline: 'border-border bg-transparent text-ink-dim hover:border-border-hi hover:text-ink',
ghost: 'border-transparent bg-transparent text-ink-dim hover:bg-control-hover-bg hover:text-ink',
secondary:
'border-control-border bg-surface-hi text-ink hover:border-control-border-hover',
// `hover:bg-control-hover-bg` is new relative to the pre-retokenization
// `outline`, which had no hover fill. It matches what the 25
// hand-rolled control-button call sites already paint, so migrating
// them onto this variant converges the two looks rather than splitting
// them again.
outline:
'border-control-border bg-control-bg text-control-fg hover:border-control-border-hover hover:bg-control-hover-bg hover:text-ink',
ghost:
'border-transparent bg-transparent text-control-fg hover:bg-control-hover-bg hover:text-ink',
destructive: 'border-danger bg-danger text-danger-fg hover:brightness-110',
'destructive-outline':
'border-control-border bg-control-bg text-danger hover:border-danger hover:bg-danger-soft hover:text-danger',
link: 'border-transparent bg-transparent text-accent underline-offset-4 hover:underline',
},
size: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getProviderFeatures } from '@providers/shared/featureCapabilities'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'

import { Button } from '@renderer/components/ui/button'
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -1629,13 +1630,9 @@ function OpenCommandPalette({
}
/>
{mode === 'prompt-template' && (
<button
type="button"
className="border border-control-border bg-control-bg px-2 py-1 text-[11px] text-control-fg hover:border-control-border-hover hover:bg-control-hover-bg hover:text-ink"
onClick={enterManagePromptTemplateMode}
>
<Button variant="outline" size="sm" onClick={enterManagePromptTemplateMode}>
Manage
</button>
</Button>
)}
</div>

Expand Down
Loading