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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- CLI: Local Worker lifecycle controls with `worker status --all` and ownership-safe `worker stop`. (#195)

### πŸ’… Changed
- UI: refine the console and Control Center with compact proportions, accessible primitives, calibrated theme/toggle styling, and deterministic nested-overlay behavior. (#298)
- UI: refine standby banner notification chrome with an accent hairline, standby pulse, and monospace branch/PR chips. (#297)
- Task: simplify the node header into a Note-style actions menu, shorten the run button label, and compact agent session rows to icon, state, and time. (#294)
- Settings: reorganize the panel into user-oriented groups for display/fonts, canvas/windows, AI assistants, tasks/shortcuts, worker connections, integrations, and advanced tools while preserving search and legacy section routing. (#266)
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,14 @@
"secrets:scan": "secretlint \"**/*\"",
"naming-check:staged": "node scripts/check-naming-staged.mjs",
"line-check:staged": "node scripts/check-max-lines.mjs",
"ui:style-check": "node scripts/check-ui-styles.mjs",
"test:staged": "node scripts/run-vitest-related-staged.mjs",
"test:e2e:pre-commit": "node scripts/run-precommit-e2e.mjs",
"release:patch": "node scripts/prepare-release.mjs patch",
"release:minor": "node scripts/prepare-release.mjs minor",
"release:major": "node scripts/prepare-release.mjs major",
"release:version": "node scripts/prepare-release.mjs",
"pre-commit": "pnpm line-check:staged && pnpm secret-check:staged && pnpm naming-check:staged && pnpm lint:fix && pnpm format-check:staged && pnpm check && pnpm test:staged && pnpm test:e2e:pre-commit",
"pre-commit": "pnpm line-check:staged && pnpm secret-check:staged && pnpm naming-check:staged && pnpm ui:style-check && pnpm lint:fix && pnpm format-check:staged && pnpm check && pnpm test:staged && pnpm test:e2e:pre-commit",
"prepare": "husky"
},
"dependencies": {
Expand Down
238 changes: 238 additions & 0 deletions scripts/check-ui-styles.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
#!/usr/bin/env node

import { readFileSync, readdirSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const DEFAULT_REPO_ROOT = resolve(SCRIPT_DIR, '..')
const UI_STYLE_ROOT = 'src/app/renderer'
const BASE_TOKEN_FILE = 'src/app/renderer/styles/base.css'
const THEME_DIRECTORY = 'src/app/renderer/styles/themes/'

const TOKEN_DECLARATION_PATTERN = /(--cove-[a-z0-9_-]+)\s*:/gi
const TOKEN_USAGE_PATTERN = /var\(\s*(--cove-[a-z0-9_-]+)/gi
const RAW_COLOR_PATTERN =
/(?:#[0-9a-f]{8}|#[0-9a-f]{6}|#[0-9a-f]{4}|#[0-9a-f]{3})(?![0-9a-f])|rgba?\s*\([^)]*\)/gi
const THEME_BRANCH_PATTERN = /\[\s*data-cove-theme(?:-id)?(?:\s*[~|^$*]?=|\s*\])/gi

function colorSet(colors) {
return new Set(colors.map(canonicalizeRawColor))
}

// These colors mirror the third-party OpenCode terminal palette. They are part
// of the terminal-provider presentation protocol rather than OpenCove chrome.
const OPENCODE_TERMINAL_COLORS = colorSet([
'rgba(107, 138, 236, 0.65)',
'rgba(104, 137, 226, 0.45)',
'rgba(0, 0, 0, 0.45)',
'rgba(153, 223, 255, 0.88)',
'rgba(168, 233, 255, 0.42)',
'rgba(0, 0, 0, 0.52)',
'rgba(7, 12, 24, 0.9)',
'rgba(18, 28, 50, 0.96)',
'#0a0f1d',
'#d6e4ff',
'rgba(94, 156, 255, 0.35)',
'rgba(255, 255, 255, 0.92)',
'rgba(255, 255, 255, 0.6)',
'rgba(255, 255, 255, 0.4)',
'rgba(255, 255, 255, 0.12)',
'rgba(255, 255, 255, 0.08)',
'rgba(255, 255, 255, 0.04)',
])

// Provider icon accents identify external agent brands. Only these audited
// constants are exempt; other colors in the same component still need tokens.
const AGENT_PROVIDER_BRAND_COLORS = colorSet([
'rgba(148, 163, 184, 0.92)',
'rgba(217, 119, 87, 0.96)',
'rgba(125, 211, 252, 0.96)',
'rgba(96, 165, 250, 0.96)',
'rgba(196, 181, 253, 0.96)',
])

export const DEFAULT_RAW_COLOR_ALLOWLIST = new Map([
['src/app/renderer/styles/terminal-node.theme-opencode.css', OPENCODE_TERMINAL_COLORS],
['src/app/renderer/styles/workspace-agent-item.css', AGENT_PROVIDER_BRAND_COLORS],
])

function normalizePath(filePath) {
return filePath.replaceAll('\\', '/')
}

function canonicalizeRawColor(color) {
return color.toLowerCase().replace(/\s+/g, ' ').trim()
}

function stripCommentsPreservingLocations(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\r\n]/g, ' '))
}

function isTokenSource(filePath) {
return filePath === BASE_TOKEN_FILE || filePath.startsWith(THEME_DIRECTORY)
}

function locationAt(content, index) {
const prefix = content.slice(0, index)
const line = prefix.split(/\r\n|\r|\n/).length
const lineStart = Math.max(prefix.lastIndexOf('\n'), prefix.lastIndexOf('\r')) + 1
const lineEndMatch = content.slice(index).search(/\r|\n/)
const lineEnd = lineEndMatch === -1 ? content.length : index + lineEndMatch

return {
line,
column: index - lineStart + 1,
excerpt: content.slice(lineStart, lineEnd).trim(),
}
}

function makeViolation({ file, content, index, rule, message }) {
return {
file,
rule,
message,
...locationAt(content, index),
}
}

function isAllowedRawColor(filePath, rawColor, allowlist) {
const allowedColors = allowlist.get(filePath)
return allowedColors?.has(canonicalizeRawColor(rawColor)) ?? false
}

export function checkUiStyleFiles(files, { rawColorAllowlist = DEFAULT_RAW_COLOR_ALLOWLIST } = {}) {
const normalizedFiles = files.map(file => {
const content = file.content ?? ''
return {
path: normalizePath(file.path),
content,
source: stripCommentsPreservingLocations(content),
}
})
const declaredTokens = new Set()
const tokenUsages = []
const violations = []

for (const file of normalizedFiles) {
for (const match of file.source.matchAll(TOKEN_DECLARATION_PATTERN)) {
declaredTokens.add(match[1].toLowerCase())
}

for (const match of file.source.matchAll(TOKEN_USAGE_PATTERN)) {
tokenUsages.push({ file, index: match.index, token: match[1].toLowerCase() })
}

if (isTokenSource(file.path)) {
continue
}

for (const match of file.source.matchAll(RAW_COLOR_PATTERN)) {
if (isAllowedRawColor(file.path, match[0], rawColorAllowlist)) {
continue
}

violations.push(
makeViolation({
file: file.path,
content: file.content,
index: match.index,
rule: 'raw-color',
message: `Replace raw color \`${match[0]}\` with a declared \`--cove-*\` token.`,
}),
)
}

for (const match of file.source.matchAll(THEME_BRANCH_PATTERN)) {
violations.push(
makeViolation({
file: file.path,
content: file.content,
index: match.index,
rule: 'component-theme-branch',
message: 'Move `data-cove-theme` branching into base.css or styles/themes/**.',
}),
)
}
}

for (const usage of tokenUsages) {
if (declaredTokens.has(usage.token)) {
continue
}

violations.push(
makeViolation({
file: usage.file.path,
content: usage.file.content,
index: usage.index,
rule: 'undeclared-token',
message: `Declare \`${usage.token}\` before using it.`,
}),
)
}

return violations.sort(
(left, right) =>
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.column - right.column ||
left.rule.localeCompare(right.rule),
)
}

function collectCssFiles(directory, repoRoot, files) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolutePath = resolve(directory, entry.name)
if (entry.isDirectory()) {
collectCssFiles(absolutePath, repoRoot, files)
continue
}
if (!entry.isFile() || !entry.name.endsWith('.css')) {
continue
}

files.push({
path: normalizePath(relative(repoRoot, absolutePath)),
content: readFileSync(absolutePath, 'utf8'),
})
}
}

export function readUiStyleFiles(repoRoot = DEFAULT_REPO_ROOT) {
const files = []
collectCssFiles(resolve(repoRoot, UI_STYLE_ROOT), repoRoot, files)
return files
}

export function formatUiStyleViolations(violations) {
if (violations.length === 0) {
return ''
}

const lines = [`[ui-style-check] Found ${violations.length} UI style violation(s):`]
for (const violation of violations) {
lines.push(
`- ${violation.file}:${violation.line}:${violation.column} [${violation.rule}]`,
` ${violation.message}`,
` ${violation.excerpt}`,
)
}
lines.push('', 'See docs/ui/README.md for the token and theme rules.')
return `${lines.join('\n')}\n`
}

export function runUiStyleCheck({ repoRoot = DEFAULT_REPO_ROOT, stderr = process.stderr } = {}) {
const violations = checkUiStyleFiles(readUiStyleFiles(repoRoot))
if (violations.length === 0) {
return 0
}

stderr.write(formatUiStyleViolations(violations))
return 1
}

const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null
if (invokedPath === import.meta.url) {
process.exitCode = runUiStyleCheck()
}
31 changes: 13 additions & 18 deletions src/app/renderer/components/CoveSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown } from 'lucide-react'
import { DismissableLayer } from './ui/DismissableLayer'
import { useIsWithinDialog } from './ui/Dialog'

export interface CoveSelectOption {
value: string
Expand Down Expand Up @@ -48,6 +50,7 @@ export function CoveSelect({
onChange: (nextValue: string) => void
}): React.JSX.Element {
const listboxId = useId()
const isWithinDialog = useIsWithinDialog()
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const menuRef = useRef<HTMLDivElement | null>(null)
Expand Down Expand Up @@ -142,29 +145,14 @@ export function CoveSelect({

updateMenuPosition()

const handlePointerDown = (event: PointerEvent) => {
const target = event.target
if (!(target instanceof Node)) {
return
}

if (rootRef.current?.contains(target) || menuRef.current?.contains(target)) {
return
}

closeMenu()
}

const handleWindowChange = () => {
updateMenuPosition()
}

document.addEventListener('pointerdown', handlePointerDown, true)
window.addEventListener('resize', handleWindowChange)
window.addEventListener('scroll', handleWindowChange, true)

return () => {
document.removeEventListener('pointerdown', handlePointerDown, true)
window.removeEventListener('resize', handleWindowChange)
window.removeEventListener('scroll', handleWindowChange, true)
}
Expand Down Expand Up @@ -337,12 +325,19 @@ export function CoveSelect({

{isOpen && menuPosition
? createPortal(
<div
<DismissableLayer
id={listboxId}
ref={menuRef}
className={`cove-select__menu${menuClassName ? ` ${menuClassName}` : ''}`}
className={`cove-select__menu${isWithinDialog ? ' cove-select__menu--within-dialog' : ''}${menuClassName ? ` ${menuClassName}` : ''}`}
data-testid={menuTestId ?? (testId ? `${testId}-menu` : undefined)}
role="listbox"
branchRefs={[rootRef]}
onDismiss={reason => {
closeMenu()
if (reason === 'escape') {
triggerRef.current?.focus()
}
}}
style={{
top: menuPosition.top,
left: menuPosition.left,
Expand Down Expand Up @@ -384,7 +379,7 @@ export function CoveSelect({
</button>
)
})}
</div>,
</DismissableLayer>,
document.body,
)
: null}
Expand Down
47 changes: 47 additions & 0 deletions src/app/renderer/components/ui/Button.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { Button } from './Button'

describe('Button', () => {
it('defaults to a non-submitting button and forwards its ref', () => {
const ref = React.createRef<HTMLButtonElement>()

render(<Button ref={ref}>Save</Button>)

const button = screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement
expect(button.type).toBe('button')
expect(button.classList.contains('cove-button--secondary')).toBe(true)
expect(button.classList.contains('cove-button--md')).toBe(true)
expect(ref.current).toBe(button)
})

it('supports an explicit submit type', () => {
const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault())

render(
<form onSubmit={onSubmit}>
<Button type="submit">Continue</Button>
</form>,
)

fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
expect(onSubmit).toHaveBeenCalledOnce()
})

it('exposes loading state and prevents activation', () => {
const onClick = vi.fn()

render(
<Button loading onClick={onClick}>
Save
</Button>,
)

const button = screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement
expect(button.disabled).toBe(true)
expect(button.getAttribute('aria-busy')).toBe('true')
fireEvent.click(button)
expect(onClick).not.toHaveBeenCalled()
})
})
Loading
Loading