diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 2a51f03..80cf312 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -74,6 +74,8 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = { channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] }, { channel: 'codeburn:getOptimizeReport', args: ['month', 'openai'], argv: ['optimize', '--format', 'json', '--period', 'month', '--provider', 'openai'] }, { channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--from', '2026-07-01', '--to', '2026-07-11'] }, + { channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--claude-config-source', 'claude-config:91dda17e8cf35193'] }, + { channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] }, { channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] }, @@ -185,6 +187,8 @@ describe('createBridgeHandlers (IPC input validation)', () => { { name: 'device name that looks like a flag', channel: 'codeburn:removeDevice', args: ['-rf'] }, { name: 'relative export path', channel: 'codeburn:exportData', args: ['json', 'all', 'relative/out'] }, { name: 'compare model that looks like a flag', channel: 'codeburn:getCompare', args: ['month', 'all', '-a', 'model-b'] }, + { name: 'claude config source that looks like a flag', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, '-rf'] }, + { name: 'claude config source with shell metacharacters', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'id; rm -rf'] }, ] it.each(REJECTIONS)('rejects $name with a bad-args envelope and never spawns', async ({ channel, args }) => { diff --git a/app/electron/main.ts b/app/electron/main.ts index bc1606c..6ef32f8 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -18,6 +18,10 @@ function rangeArgs(range: DateRange | undefined): string[] { return range ? ['--from', range.from, '--to', range.to] : [] } +function configSourceArgs(source: string | null): string[] { + return source ? ['--claude-config-source', source] : [] +} + // Renderer-supplied strings become argv, so reject anything that could smuggle a // flag or shell metacharacter before it reaches the CLI. Thrown from the argv // builders, these surface through the same error envelope as any CliError. @@ -45,6 +49,14 @@ function vToken(value: string): string { if (value.startsWith('-')) throw new CliError('bad-args', 'argument must not start with "-"') return value } +// Claude config source ids are `:` (src/providers/claude.ts) — the +// colon is part of the real value, so the token class allows it while anchoring +// the first char to alphanumeric so a leading "-" can never smuggle a flag. +function vConfigSource(source: string | null | undefined): string | null { + if (source == null) return null + if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(source)) throw new CliError('bad-args', 'invalid claude config source') + return source +} function vOutPath(outPath: string): string { if (outPath.startsWith('-') || !path.isAbsolute(outPath)) throw new CliError('bad-args', 'export path must be absolute') return outPath @@ -91,8 +103,8 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re try { return { ok: true, value: await deps.getQuota({ force: Boolean(force) }) } } catch (error) { return { ok: false, error: { kind: 'nonzero', message: sanitizeError(error) } } } }, - 'codeburn:getOverview': run((period: string, provider: string, range?: DateRange) => [ - 'status', '--format', 'menubar-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), + 'codeburn:getOverview': run((period: string, provider: string, range?: DateRange, configSource?: string | null) => [ + 'status', '--format', 'menubar-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)), ]), 'codeburn:getPlans': run((period: string) => ['status', '--format', 'json', '--period', vPeriod(period)]), 'codeburn:getActReport': run(() => ['act', 'report', '--json']), diff --git a/app/electron/preload.ts b/app/electron/preload.ts index a28747d..9ac36a7 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -19,7 +19,7 @@ async function invoke(channel: string, ...args: unknown[]): Promise { // renderer-side where `window.codeburn` is declared as CodeburnBridge. const bridge = { getQuota: (force?: boolean) => invoke('codeburn:getQuota', force), - getOverview: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getOverview', period, provider, range), + getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null) => invoke('codeburn:getOverview', period, provider, range, configSource), getPlans: (period: string) => invoke('codeburn:getPlans', period), getActReport: () => invoke('codeburn:getActReport'), getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range), diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index f1dd1f3..19271ca 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -9,11 +9,12 @@ const stored = new Map() vi.stubGlobal('localStorage', { getItem: (key: string) => stored.get(key) ?? null, setItem: (key: string, value: string) => stored.set(key, value), + removeItem: (key: string) => stored.delete(key), clear: () => stored.clear(), }) const mocks = vi.hoisted(() => ({ - getOverview: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), + getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null) => Promise>(), getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getModels: vi.fn(), @@ -88,6 +89,22 @@ function overviewPayload(): MenubarPayload { } } +const CONFIG_A = 'claude-config:aaaa000011112222' +const CONFIG_B = 'claude-desktop:bbbb000011112222' + +function withConfigs(payload: MenubarPayload): MenubarPayload { + return { + ...payload, + claudeConfigs: { + selectedId: null, + options: [ + { id: CONFIG_A, label: 'Default Claude', path: '/Users/x/.claude' }, + { id: CONFIG_B, label: 'Claude Desktop', path: '/Users/x/Library/Application Support/Claude' }, + ], + }, + } +} + describe('App shortcuts', () => { beforeEach(() => { for (const mock of Object.values(mocks)) mock.mockReset() @@ -244,6 +261,77 @@ describe('App shortcuts', () => { await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'grok')) }) + it('hides the Claude config picker when the payload carries no claudeConfigs', async () => { + render() + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Claude config source' })).not.toBeInTheDocument() + }) + + it('shows the config picker, re-fetches overview with the flag, and persists the choice', async () => { + mocks.getOverview.mockResolvedValue(withConfigs(overviewPayload())) + render() + + const trigger = await screen.findByRole('button', { name: 'Claude config source' }) + fireEvent.click(trigger) + fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' })) + + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A)) + expect(localStorage.getItem('codeburn.claudeConfigSource')).toBe(CONFIG_A) + }) + + it('resets a non-Claude provider filter to all when a config is selected', async () => { + const payload = withConfigs(overviewPayload()) + payload.current.providers = { claude: 10, codex: 2 } + mocks.getOverview.mockResolvedValue(payload) + render() + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Providers' })) + fireEvent.click(await screen.findByRole('option', { name: 'Codex' })) + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'codex')) + + fireEvent.click(screen.getByRole('button', { name: 'Claude config source' })) + fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' })) + + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A)) + // The Claude-incompatible provider filter must never reach the CLI with the flag. + expect(mocks.getOverview.mock.calls).not.toContainEqual(['30days', 'codex', undefined, CONFIG_A]) + }) + + it('clears the config scope when a non-Claude provider is picked afterwards', async () => { + const payload = withConfigs(overviewPayload()) + payload.current.providers = { claude: 10, codex: 2 } + mocks.getOverview.mockResolvedValue(payload) + render() + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Claude config source' })) + fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' })) + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A)) + + fireEvent.click(screen.getByRole('button', { name: 'Providers' })) + fireEvent.click(await screen.findByRole('option', { name: 'Codex' })) + + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'codex')) + // The incompatible combination must never reach the CLI. + expect(mocks.getOverview.mock.calls).not.toContainEqual(['30days', 'codex', undefined, CONFIG_A]) + expect(localStorage.getItem('codeburn.claudeConfigSource')).toBeNull() + }) + + it('boots with the persisted config source and clears it via All Claude configs', async () => { + localStorage.setItem('codeburn.claudeConfigSource', CONFIG_A) + mocks.getOverview.mockResolvedValue(withConfigs(overviewPayload())) + render() + + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A)) + + fireEvent.click(await screen.findByRole('button', { name: 'Claude config source' })) + fireEvent.click(await screen.findByRole('option', { name: 'All Claude configs' })) + + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all')) + expect(localStorage.getItem('codeburn.claudeConfigSource')).toBeNull() + }) + it('applies a calendar range to overview and visible section polls', async () => { render() diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 6924974..60e3b28 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -52,6 +52,18 @@ function initialPeriod(): Period { return saved && isPeriod(saved) ? saved : '30days' } +/** Persisted Claude config override (empty/absent = aggregate all configs). */ +function initialConfigSource(): string | null { + try { return globalThis.localStorage?.getItem('codeburn.claudeConfigSource') || null } catch { return null } +} + +function persistConfigSource(id: string | null): void { + try { + if (id) globalThis.localStorage?.setItem('codeburn.claudeConfigSource', id) + else globalThis.localStorage?.removeItem('codeburn.claudeConfigSource') + } catch { /* storage can be unavailable */ } +} + function providerName(provider: string): string { if (provider === 'all') return 'All providers' return provider @@ -78,15 +90,20 @@ export function App() { const [provider, setProvider] = useState('all') const [detectedProviders, setDetectedProviders] = useState>([]) const [customRange, setCustomRange] = useState(null) + const [claudeConfigSource, setClaudeConfigSource] = useState(initialConfigSource) const [refreshToken, setRefreshToken] = useState(0) const [now, setNow] = useState(() => Date.now()) const [, setCurrencyTick] = useState(0) + // Preserve the 2/3-arg call shapes when no config is scoped so the CLI argv + // stays flag-free; only add --claude-config-source once a config is picked. const overview = usePolled( - () => customRange + () => claudeConfigSource + ? codeburn.getOverview(period, provider, customRange ?? undefined, claudeConfigSource) + : customRange ? codeburn.getOverview(period, provider, customRange) : codeburn.getOverview(period, provider), - [period, provider, customRange?.from, customRange?.to], + [period, provider, customRange?.from, customRange?.to, claudeConfigSource], ) const refreshOverview = overview.refresh @@ -162,12 +179,36 @@ export function App() { } } + // A Claude config scopes Claude usage only, so a non-Claude provider filter + // would make the CLI reject the flag: reset it to 'all' first (a 'claude' + // filter is already compatible and is left alone). + const onConfigSelect = (id: string) => { + const next = id || null + if (next && provider !== 'all' && provider !== 'claude') setProvider('all') + setClaudeConfigSource(next) + persistConfigSource(next) + } + + // Symmetric direction: picking a non-Claude provider while a config is + // scoped would hit the same CLI rejection, so drop the config scope. + const onProviderSelect = (value: string) => { + if (claudeConfigSource && value !== 'all' && value !== 'claude') { + setClaudeConfigSource(null) + persistConfigSource(null) + } + setProvider(value) + } + + const claudeConfigs = overview.data?.claudeConfigs const providerOptions = [ { value: 'all', label: 'All providers' }, ...detectedProviders.map(entry => ({ value: entry.id, label: entry.label })), ] const providerLabel = detectedProviders.find(entry => entry.id === provider)?.label ?? providerName(provider) - const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}` + const activeConfigLabel = claudeConfigSource + ? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null + : null + const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}` return ( @@ -177,7 +218,7 @@ export function App() { {section === 'plans' ? ( ) : section === 'settings' ? ( - + ) : ( <>
{section === 'overview' ? ( ) : section === 'sessions' ? ( - + ) : section === 'spend' ? ( ) : section === 'optimize' ? ( diff --git a/app/renderer/components/Dropdown.tsx b/app/renderer/components/Dropdown.tsx index 76972fd..8712a10 100644 --- a/app/renderer/components/Dropdown.tsx +++ b/app/renderer/components/Dropdown.tsx @@ -10,6 +10,7 @@ export function Dropdown({ id, width, renderIcon, + footer, }: { value: string options: DropdownOption[] @@ -19,6 +20,8 @@ export function Dropdown({ width?: React.CSSProperties['width'] /** Optional leading glyph (e.g. a provider logo) shown in the trigger and each option. */ renderIcon?: (value: string) => ReactNode + /** Optional non-interactive note pinned below the options in the open menu. */ + footer?: ReactNode }) { const [open, setOpen] = useState(false) const selectedIndex = Math.max(0, options.findIndex(option => option.value === value)) @@ -126,6 +129,7 @@ export function Dropdown({ {option.label} ))} + {footer &&
{footer}
}
)} diff --git a/app/renderer/components/TopBar.tsx b/app/renderer/components/TopBar.tsx index 2e6e7f9..4c789b2 100644 --- a/app/renderer/components/TopBar.tsx +++ b/app/renderer/components/TopBar.tsx @@ -1,10 +1,14 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' -import type { DateRange } from '../lib/types' +import type { ClaudeConfigSelector, DateRange } from '../lib/types' +import { Dropdown } from './Dropdown' import { ProviderPop, type ProviderOption } from './ProviderPop' import { RangeCalendar } from './RangeCalendar' import { SegTabs, type SegOption } from './SegTabs' +/** Sentinel option value: no --claude-config-source flag (aggregate all configs). */ +const ALL_CONFIGS = '' + /** The real CLI period vocabulary (`codeburn ... --period`). */ export const PERIOD_OPTIONS: SegOption[] = [ { value: 'today', label: 'Today' }, @@ -26,6 +30,9 @@ export function TopBar({ providerLabel, providerOptions, onProviderSelect, + claudeConfigs, + configSource, + onConfigSelect, }: { title: ReactNode scope?: ReactNode @@ -37,6 +44,9 @@ export function TopBar({ providerLabel: string providerOptions: ProviderOption[] onProviderSelect: (value: string) => void + claudeConfigs?: ClaudeConfigSelector + configSource: string | null + onConfigSelect: (id: string) => void }) { return (
@@ -46,10 +56,31 @@ export function TopBar({ + {claudeConfigs && }
) } +/** Claude config source switcher. Only getOverview honors the selection, so the + * footer names the limit; the active label is also echoed in the scope line. */ +function ConfigPicker({ configs, value, onSelect }: { configs: ClaudeConfigSelector; value: string | null; onSelect: (id: string) => void }) { + const options = [ + { value: ALL_CONFIGS, label: 'All Claude configs' }, + ...configs.options.map(option => ({ value: option.id, label: option.label })), + ] + return ( + + ) +} + function formatRange(range: DateRange): string { const from = new Date(`${range.from}T12:00:00`) const to = new Date(`${range.to}T12:00:00`) diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 71b74ad..0eb0022 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -490,7 +490,7 @@ export type CompareJsonReport = { export interface CodeburnBridge { getQuota(force?: boolean): Promise - getOverview(period: Period, provider: string, range?: DateRange): Promise + getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null): Promise getPlans(period: Period): Promise getActReport(): Promise readonly platform: string diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx index a99afd9..fb21f2f 100644 --- a/app/renderer/sections/Settings.test.tsx +++ b/app/renderer/sections/Settings.test.tsx @@ -101,6 +101,19 @@ describe('Settings', () => { expect(document.documentElement).not.toHaveAttribute('data-theme') }) + it('shows the active Claude config as a read-only line in General when multiple configs exist', async () => { + render() + expect(await screen.findByText('Claude config')).toBeInTheDocument() + expect(screen.getByText('Claude Desktop')).toBeInTheDocument() + expect(screen.getByText('Applies to the overview data. Manage config folders with the codeburn CLI.')).toBeInTheDocument() + }) + + it('omits the Claude config line when no multi-config selector is present', async () => { + render() + expect(await screen.findByRole('heading', { name: 'General' })).toBeInTheDocument() + expect(screen.queryByText('Claude config')).not.toBeInTheDocument() + }) + it('lists providers from the real overview payload', async () => { const user = userEvent.setup() render() diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index 97d0e34..21db722 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -9,7 +9,7 @@ import type { Section } from '../components/Sidebar' import { usePolled } from '../hooks/usePolled' import { formatConverted, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' -import type { ActionResult, AliasRow, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, ShareStatus, StatusJson } from '../lib/types' +import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, ShareStatus, StatusJson } from '../lib/types' export type SettingsPane = 'general' | 'providers' | 'aliases' | 'plans' | 'devices' | 'export' | 'privacy' type Pane = SettingsPane @@ -83,7 +83,7 @@ function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: st ) } -export function Settings({ period, refreshToken = 0, onNavigate, initialPane }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane }) { +export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null }) { const [pane, setPane] = useState(initialPane ?? 'general') return ( @@ -98,7 +98,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane }: ))}
- {pane === 'general' && } + {pane === 'general' && } {pane === 'providers' && } {pane === 'aliases' && } {pane === 'plans' && } @@ -112,7 +112,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane }: ) } -function GeneralPane({ period, refreshToken }: { period: Period; refreshToken: number }) { +function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null }) { const [currencyNonce, setCurrencyNonce] = useState(0) const plans = usePolled(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce]) const [theme, setTheme] = useState(() => { @@ -137,6 +137,10 @@ function GeneralPane({ period, refreshToken }: { period: Period; refreshToken: n } const currencies = [...CURRENCIES] if (plans.data?.currency && !currencies.includes(plans.data.currency)) currencies.push(plans.data.currency) + const hasConfigs = Boolean(claudeConfigs && claudeConfigs.options.length > 0) + const activeConfigLabel = claudeConfigSource + ? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? claudeConfigSource + : 'All Claude configs' return (
@@ -148,6 +152,12 @@ function GeneralPane({ period, refreshToken }: { period: Period; refreshToken: n {(['system', 'light', 'dark'] as Theme[]).map(value => )} + {hasConfigs && ( +
+
Claude config
+
Active configApplies to the overview data. Manage config folders with the codeburn CLI.{activeConfigLabel}
+
+ )}
Display
diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index d2f3bd5..e365672 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -264,6 +264,7 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); } .dropdown-menu { max-height: 240px; overflow-y: auto; } .dropdown-menu .pop-item { width: 100%; border: 0; background: transparent; font: inherit; text-align: left; } .dropdown-menu .pop-item:hover, .dropdown-menu .pop-item:focus-visible { background: var(--hover); } +.dropdown-foot { max-width: 220px; margin-top: 4px; padding: 7px 11px 2px; border-top: 1px solid var(--line2); color: var(--mut2); font-size: 10px; line-height: 1.35; white-space: normal; } .calendar-trigger { display: inline-flex; align-items: center; gap: 6px; height: 25px; padding: 3px 7px; border: 1px solid var(--hair2); border-radius: 7px; background: var(--panel);