feat(design-system): gate renderer UI with @shadcn/lint

Wires shadcn-ui/lint's Oxlint plugin into the two places this repo already
ratchets: the changed-lines PR gate for rules the renderer can't satisfy
today, and `pnpm lint` for the one that is already at zero.

- config/oxlint-design-system.json: no-restyle (layout allowed),
  no-raw-colors, require-static-classes -- scoped to src/renderer/**/*.tsx,
  run over added lines only. Measured at 10 findings across the last 60
  commits (771 changed files), so it holds the line without a migration.
- config/oxlint-dead-classes.json: no-unknown-classes repo-wide, with the
  renderer's plain-CSS hook namespaces allow-listed. Now at zero.
- no-inline-styles and no-arbitrary-values stay off; STYLEGUIDE says why.

Fixes the three live bugs the linter found:

- `--editor-surface` never reached `@theme inline`, so `bg-editor-surface`
  generated no CSS -- 12 editor/artifact/notebook panes fell through to the
  page background instead of #1e1e1e in dark mode.
- `scrollbar-none` is not a Tailwind utility and was declared nowhere, so
  the remote file browser breadcrumbs showed the scrollbar they meant to
  hide. Declared as a real `@utility`.
- Notebook markdown cells used `markdown-preview-body`, which no stylesheet
  defines; the styled class is `markdown-body`. They rendered unstyled.
This commit is contained in:
Neil
2026-09-14 17:49:06 -07:00
parent db09a7bd50
commit 94bd674f77
11 changed files with 982 additions and 5 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# Design System
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Most of it is linted: `pnpm run check:code-quality:changed` fails on new restyles of a `components/ui/` primitive, raw palette colors, and computed `className` strings; `pnpm lint` fails on any class Tailwind cannot generate. See the Enforcement section of the style guide before suppressing either. Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
## Electron UI Validation
@@ -46,6 +46,7 @@ Avoid type assertions except `as const`. Unavoidable casts need a line-specific
- **Typecheck**: `pnpm tc` (or `tc:node` / `tc:cli` / `tc:web`)
- **Test**: `pnpm test [path/to/file.test.ts]`
- **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format`
- **Design system**: `pnpm run lint:design-system` for the full renderer report (not a gate); the changed-lines gate above is what CI enforces
# Considerations
+77
View File
@@ -0,0 +1,77 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": [],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"jsPlugins": [
{
"name": "shadcn",
"specifier": "@shadcn/lint"
}
],
"settings": {
"shadcn": {
"note": "See docs/STYLEGUIDE.md for the role each token and primitive plays."
}
},
"rules": {},
"overrides": [
{
"files": ["**/src/renderer/**/*.tsx"],
"rules": {
"shadcn/no-unknown-classes": [
"error",
{
"allow": [
"agent-map-*",
"comment-md-*",
"compact-agent-*",
"feature-wall-*",
"is-*",
"markdown-annotation-*",
"markdown-body",
"markdown-dark",
"markdown-doc-link*",
"markdown-light",
"markdown-preview",
"markdown-preview-search*",
"markdown-preview-shell",
"markdown-review-*",
"markdown-toc-*",
"mobile-browser-driver-banner",
"mobile-driver-banner",
"native-chat-*",
"orca-*",
"pdfViewer",
"popover-scroll-content",
"popover-wheel-scroll",
"ravpr-*",
"ravs-*",
"scrollbar-editor",
"scrollbar-sleek",
"scrollbar-sleek-lg",
"scrollbar-sleek-parent",
"toaster",
"worktree-sidebar-scrollbar",
"xterm-*"
]
}
]
}
},
{
"files": ["**/*.test.tsx"],
"rules": {
"shadcn/no-unknown-classes": "off"
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "cloud/**", "mobile/**"]
}
+54
View File
@@ -0,0 +1,54 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": [],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"jsPlugins": [
{
"name": "shadcn",
"specifier": "@shadcn/lint"
}
],
"settings": {
"shadcn": {
"note": "See docs/STYLEGUIDE.md for the role each token and primitive plays."
}
},
"rules": {},
"overrides": [
{
"files": ["**/src/renderer/**/*.tsx"],
"rules": {
"shadcn/no-restyle": [
"error",
{
"allow": ["layout"]
}
],
"shadcn/no-raw-colors": [
"error",
{
"allow": ["shadow-floating"]
}
],
"shadcn/require-static-classes": "error"
}
},
{
"files": ["**/*.test.tsx"],
"rules": {
"shadcn/no-restyle": "off",
"shadcn/no-raw-colors": "off",
"shadcn/require-static-classes": "off"
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "cloud/**", "mobile/**"]
}
@@ -29,6 +29,12 @@ export const OXLINT_SCANS = [
{
label: 'React Doctor',
args: ['--config', 'config/oxlint-react-doctor.json']
},
{
// Why changed-lines only: the renderer carries ~4.7k pre-existing restyle/raw-color
// findings. Gating added lines holds the line without a repo-wide migration.
label: 'design system',
args: ['--config', 'config/oxlint-design-system.json']
}
]
+26
View File
@@ -312,3 +312,29 @@ If you have a UI question this doc doesn't answer:
2. Check `src/renderer/src/components/ui/` for a primitive that already encodes the pattern.
3. If it's a token question, `main.css` is canonical — use what's there, or add a new one in both light and dark.
4. If none of those resolve it, ask the user before inventing.
## Enforcement
Most of this guide is checked by [`@shadcn/lint`](https://github.com/shadcn-ui/lint), an Oxlint JS plugin that reads `components.json` and `main.css` and knows which Tailwind classes each primitive already owns. Its diagnostics name the fix — the variant, size, or token to use instead — rather than only the violation.
| Command | Scope | What it does |
| ------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------ |
| `pnpm run check:dead-classes` | Whole renderer, in `pnpm lint` | Fails on any class Tailwind can't generate. Currently at zero — keep it there. |
| `pnpm run check:code-quality:changed` | Lines a PR adds | Fails on new restyles, raw palette colors, and computed `className` strings. |
| `pnpm run lint:design-system` | Whole renderer, report only | The full picture, including the pre-existing backlog. Not a gate. |
Rules live in `config/oxlint-design-system.json` (the PR gate) and `config/oxlint-dead-classes.json` (the repo-wide one). Both are scoped to `src/renderer/**/*.tsx`.
**What's enforced on new code:**
- `no-restyle` — don't reach into a primitive's own spacing, typography, color, shape, or motion through `className`. Layout classes (flex, grid, position, sizing) are allowed, because layout belongs to the parent. If a primitive genuinely lacks the treatment you need, add a variant or size to the file in `components/ui/` rather than patching it at one call site.
- `no-raw-colors` — no `bg-pink-500`. Use a token from the tables above, or add one to `main.css` in both `:root` and `.dark`.
- `require-static-classes` — a `className` built at runtime can't be checked by anything, including this linter. Enumerate the variants instead.
- `no-unknown-classes` — a Tailwind-shaped name that Tailwind doesn't generate is dead text. Plain CSS hook classes (`ravs-*`, `agent-map-*`, `comment-md-*`, …) are allow-listed by namespace in `config/oxlint-dead-classes.json`; adding a new namespace means adding it there. A name that _looks_ like a utility should be a real `@utility` in `main.css`.
**What's deliberately off:**
- `no-inline-styles` — the renderer computes real geometry inline (virtualized row offsets, terminal metrics, graph lane positions) and the rule can't distinguish that from a hardcoded color. Inline styles still owe you a reason; `main.css` is still canonical for anything static.
- `no-arbitrary-values` — `main.css` defines no `--text-*` scale, so the 11px and 13px sizes this guide documents have no token to point at. Turning this on means adding that scale first.
Neither gate rewrites existing code: the PR gate only looks at lines a change adds, so the renderer's pre-existing findings stay put until someone touches them.
+4 -1
View File
@@ -14,13 +14,15 @@
"audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src",
"test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-runtime-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-runtime-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
"audit:react-doctor": "pnpm dlx react-doctor@0.9.1 . --yes --no-supply-chain --no-telemetry --blocking none",
"audit:dead-code": "pnpm dlx knip@5.88.1 --config config/knip.json",
"check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs",
"check:dead-classes": "oxlint --config config/oxlint-dead-classes.json src/renderer",
"lint:design-system": "oxlint --config config/oxlint-design-system.json src/renderer",
"check:react-doctor:changed": "node config/scripts/check-react-doctor-changed.mjs",
"check:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs --check",
"doctor": "pnpm dlx react-doctor@0.9.1 . --no-telemetry",
@@ -199,6 +201,7 @@
"@monaco-editor/react": "^4.7.0",
"@playwright/test": "^1.59.1",
"@sanity/diff-match-patch": "^3.2.0",
"@shadcn/lint": "^0.1.0",
"@stablyai/playwright-test": "^2.1.14",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-virtual": "^3.14.10",
+774 -2
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,6 +17,7 @@ minimumReleaseAgeExclude:
- pdfjs-dist@6.3.289
- zod@4.5.4
- electron@43.7.0
- '@shadcn/lint@0.1.0'
shamefullyHoist: true
# Orca always launches the user's own resolved Claude CLI via
+13
View File
@@ -60,6 +60,7 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-editor-surface: var(--editor-surface);
--color-agent-question: var(--agent-question);
--color-agent-question-text: var(--agent-question-text);
--color-chart-1: var(--chart-1);
@@ -509,6 +510,18 @@
}
}
/* Why @utility, not a plain class: this is a Tailwind-shaped name, so it has to be
one Tailwind generates or `scrollbar-none` silently produces no CSS. */
@utility scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
/* ── Sleek scrollbar (VS Code-like) ─────────────────── */
.scrollbar-sleek {
@@ -0,0 +1,19 @@
import fs from 'node:fs'
import { describe, expect, it } from 'vitest'
const mainCss = fs.readFileSync(new URL('./main.css', import.meta.url), 'utf8')
const themeBlock = /@theme inline\s*{([\s\S]*?)\n}/.exec(mainCss)?.[1] ?? ''
// Why: a token that never reaches `@theme inline`, and a Tailwind-shaped name that is only a
// plain CSS selector, both generate no CSS at all -- the utility silently does nothing.
describe('main.css utility generation', () => {
it('exposes --editor-surface to Tailwind so bg-editor-surface generates', () => {
expect(mainCss).toMatch(/--editor-surface:/)
expect(themeBlock).toMatch(/--color-editor-surface:\s*var\(--editor-surface\)/)
})
it('declares scrollbar-none as a utility rather than a plain class', () => {
expect(mainCss).toMatch(/@utility scrollbar-none\s*{/)
expect(mainCss).not.toMatch(/^\.scrollbar-none\b/m)
})
})
@@ -4,6 +4,7 @@ import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
import { monaco } from '@/lib/monaco-setup'
import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
import { resolveDocumentTheme } from '@/lib/document-theme'
@@ -14,8 +15,12 @@ import type { IpynbCell } from './ipynb-parse'
import MonacoCodeExcerpt from './MonacoCodeExcerpt'
export function IpynbMarkdownCell({ source }: { source: string }): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const isDark = resolveDocumentTheme(settings?.theme ?? 'system')
return (
<div className="markdown-preview-body px-4 py-3 text-sm">
<div
className={cn('markdown-body px-4 py-3 text-sm', isDark ? 'markdown-dark' : 'markdown-light')}
>
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
{source || '\u00a0'}
</Markdown>