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
6 changes: 6 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ jobs:
- name: Typecheck
run: npm run typecheck

# Cheap and platform-independent: enforces DESIGN.md's mechanically
# checkable rules (radius, elevation, colours, text levels) before the
# long packaging step below does any work.
- name: Design tokens
run: npm run check:design

- name: Unit tests
run: npm test

Expand Down
45 changes: 31 additions & 14 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ Rules:
- Adjacent surfaces must differ in lightness; two neighbours at the same value
mean you are expressing hierarchy through a shadow, which is not allowed.
- Inside a panel, nested containers (a bubble, an inline code block, an icon
tile) use `bg-muted` — a quiet opaque fill — not another surface step. Do not
nest `surface-raised` inside `surface-raised`.
tile) use `bg-muted` — a quiet opaque fill that is _recessed_ relative to
`surface-raised` in both colour schemes. Do not nest `surface-raised` inside
`surface-raised`.

Legacy aliases kept for compatibility while pages migrate:
`--background`, `--card`, `--popover`, `--sidebar`, `--accent`,
Expand Down Expand Up @@ -132,6 +133,7 @@ Base rhythm is 4 / 8 / 12 / 16 (`gap-1,2,3,4`; `p-2,3,4,6`). Half steps
| Input / Select trigger | `h-9` | `px-3` |
| List row, compact | `h-8` | `px-2` |
| List row, comfortable | `h-9` | `px-3` |
| List row, multi-line (title + meta) | — | `px-2 py-2`, `rounded-md` |
| Sidebar nav item | `h-8` | `px-2` |
| Card / panel body padding | — | `p-4` |
| Dialog padding | — | `p-5` |
Expand Down Expand Up @@ -168,12 +170,14 @@ Sizes:
| 14px | `text-sm` | **Default UI size**: buttons, labels, list rows, inputs, prose in chat and the editor |
| 16px | `text-base` | Dialog titles, empty-state body (only where 14px reads cramped) |
| 18px | `text-lg` | Page titles, empty-state titles |
| 20px | `text-xl` | Home hero only |
| 20px | `text-xl` | Focus content: home page title, flashcard face |
| 36px | `text-4xl` | A single focus readout (quiz score). One per screen |

Weights: `font-normal` for body, `font-medium` for interactive labels, panel
headers, section titles and the selected state of anything. `font-semibold` and
above are reserved for page titles and the home hero. Never use weight alone to
express selection — pair it with `bg-surface-selected`.
headers, section titles, titles and the selected state of anything.
`font-semibold` and above are reserved for the one focus readout per screen.
Never use weight alone to express selection — pair it with
`bg-surface-selected`.

Line height: UI text uses Tailwind defaults. Long-form reading surfaces
(`markdown.css`, `noteEditor.css`) use 14px / `line-height: 1.75`.
Expand All @@ -194,7 +198,12 @@ Line height: UI text uses Tailwind defaults. Long-form reading surfaces
Everywhere else — toolbars, icon buttons, headers, cards, badges, tab labels,
progress — the UI is neutral. A blue icon in a neutral toolbar is a violation.
`--destructive` is allowed only on a destructive action and its confirmation
dialog. `--chart-*` are for charts, graph nodes and mind-map topic colouring.
dialog, and `--success` only on the outcome of a graded result (correct answer,
passing check). Neither is decoration, and neither may be the only carrier of
state: pair it with an icon or a label. `--chart-*` are for charts, graph nodes
and mind-map topic colouring, and are used as small data dots rather than as
text-bearing fills (they sit at one lightness in both themes, so no single text
colour reads on them).

State expressions (use these literal forms, they are the contract):

Expand Down Expand Up @@ -329,12 +338,20 @@ the same PR.

## Enforcement

`npm run check:design` (`scripts/check-design-tokens.mjs`) fails on:
`npm run check:design` (`scripts/check-design-tokens.mjs`) reads the renderer
sources — no build, no dependencies — and fails on:

- a radius utility outside the three allowed values;
- a shadow utility other than `shadow-elevation` / `shadow-control`;
- a background colour that is not a surface, `muted`, `transparent` or a
`--chart-*` token.
| Rule | Fails when |
| ------------ | ------------------------------------------------------------------------------------------------------------------- |
| `radius` | a radius utility other than `rounded-md`, `rounded-lg`, `rounded-full` and their `t/b/l/r` forms |
| `shadow` | an elevation utility other than `shadow-elevation`, `shadow-control`, `shadow-none` |
| `palette` | a colour utility from the raw Tailwind palette (`bg-slate-100`, `text-gray-500`) or an arbitrary colour (`bg-[#…]`) |
| `text-level` | alpha stacked on a text level (`text-muted-foreground/70`) |
| `css-radius` | a raw CSS `border-radius` outside `0.375rem` / `0.5rem` / `9999px` |

The check runs as part of the `Verify` workflow. It reads source text, so it is
also the fastest way to audit a branch: `npm run check:design -- --list`.
`npm run check:design -- --list` prints the rules and the allowlists. The check
runs in the `Verify` workflow, before the build matrix.

What it does **not** enforce, and therefore relies on review: the surface ladder
(which surface a panel uses), accent discipline, spacing and density, and the
four interaction states. Those are the rules a reviewer must hold the line on.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache .",
"check:design": "node scripts/check-design-tokens.mjs",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck:test": "tsc -p tsconfig.test.json",
Expand Down
184 changes: 184 additions & 0 deletions scripts/check-design-tokens.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/usr/bin/env node
/**
* Design-token guard.
*
* Enforces the mechanically checkable half of DESIGN.md: which radius values,
* which elevation tokens, which colours and which text-level modifiers are
* allowed in the renderer. It reads source text, so it is fast, needs no build
* and can run before anything else in CI.
*
* The rules are deliberately not configurable. DESIGN.md is the source of
* truth; if a rule is wrong, change the document and this file in the same PR
* rather than adding an allowlist here.
*
* npm run check:design # report violations, exit 1 if any
* npm run check:design -- --list # print the rules and exit 0
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'

const SCAN_ROOT = join(process.cwd(), 'src', 'renderer', 'src')
const EXTENSIONS = ['.ts', '.tsx', '.css']

/** Tailwind class characters, including `!`, `/`, `[]`, `()` and `.`. */
const CLASS_CHAR = 'a-zA-Z0-9\\-_\\[\\](),.%_!/'

const ALLOWED_RADII = new Set([
'rounded-md',
'rounded-lg',
'rounded-full',
...['t', 'b', 'l', 'r', 'tl', 'tr', 'bl', 'br'].flatMap((side) =>
['md', 'lg', 'full'].map((size) => `rounded-${side}-${size}`)
)
])

const ALLOWED_SHADOWS = new Set(['shadow-elevation', 'shadow-control', 'shadow-none'])

const ALLOWED_CSS_RADII = new Set(['0.375rem', '0.5rem', '9999px'])

const PALETTE =
'white|black|gray|slate|zinc|neutral|stone|blue|green|red|purple|amber|yellow|orange|pink|teal|cyan|emerald|indigo|violet|rose|sky|lime|fuchsia'

const RULES = [
{
id: 'radius',
describe: 'radius is rounded-md (control), rounded-lg (container) or rounded-full (pill)',
applies: ['.ts', '.tsx'],
scan(line, check) {
for (const token of utilities(line, 'rounded')) {
if (!ALLOWED_RADII.has(token)) check(token, 'use rounded-md / rounded-lg / rounded-full')
}
}
},
{
id: 'shadow',
describe: 'elevation is shadow-elevation (floating layers) or shadow-control (knobs)',
applies: ['.ts', '.tsx'],
scan(line, check) {
for (const token of utilities(line, 'shadow')) {
if (!ALLOWED_SHADOWS.has(token)) {
check(token, 'use shadow-elevation / shadow-control / shadow-none')
}
}
}
},
{
id: 'palette',
describe: 'colours come from tokens, never from the raw Tailwind palette',
applies: ['.ts', '.tsx'],
scan(line, check) {
const literal = new RegExp(
`\\b(bg|text|border|ring|fill|stroke|from|to|via)-(?:${PALETTE})(?:-\\d{2,3})?\\b`,
'g'
)
const arbitrary = /\b(bg|text|border|ring|fill|stroke)-\[(?:#|rgb|hsl|oklch|color)/g
for (const match of [...line.matchAll(literal), ...line.matchAll(arbitrary)]) {
check(match[0], 'use a surface/text/accent token')
}
}
},
{
id: 'text-level',
describe: 'a text level carries no opacity of its own',
applies: ['.ts', '.tsx'],
scan(line, check) {
const pattern = /\btext-(foreground|muted-foreground|subtle-foreground)\/\d+/g
for (const match of line.matchAll(pattern)) {
check(match[0], 'use a darker level (T1/T2/T3) instead of stacking alpha')
}
}
},
{
id: 'css-radius',
describe: 'raw CSS border-radius matches the radius scale',
applies: ['.css'],
scan(line, check) {
for (const match of line.matchAll(/border-radius:\s*([^;]+);/g)) {
const value = match[1].trim()
if (!ALLOWED_CSS_RADII.has(value)) {
check(value, 'use 0.375rem (control), 0.5rem (container) or 9999px (pill)')
}
}
}
}
]

/**
* Extracts whole utility tokens that start with `prefix` and are not part of a
* larger word (`box-shadow` and `var(--shadow-sm)` are definitions, not usages).
*/
function utilities(line, prefix) {
const pattern = new RegExp(`(?<![\\w-])${prefix}[${CLASS_CHAR}]*`, 'g')
return [...line.matchAll(pattern)].map((match) =>
match[0].replace(/!+$/, '').replace(/[^a-zA-Z0-9[\]().%_/-]+$/, '')
)
}

function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry)
if (statSync(path).isDirectory()) walk(path, files)
else if (EXTENSIONS.some((extension) => path.endsWith(extension))) files.push(path)
}
return files
}

function list() {
console.log('Design-token guard rules (see DESIGN.md):\n')
for (const rule of RULES) {
console.log(` ${rule.id.padEnd(12)} ${rule.describe}`)
console.log(` ${' '.repeat(12)} files: ${rule.applies.join(', ')}`)
}
console.log(`\nAllowed radii: ${[...ALLOWED_RADII].join(' ')}`)
console.log(`Allowed shadows: ${[...ALLOWED_SHADOWS].join(' ')}\n`)
}

function main() {
if (process.argv.includes('--list')) {
list()
return
}

const violations = []

for (const path of walk(SCAN_ROOT)) {
const extension = EXTENSIONS.find((candidate) => path.endsWith(candidate))
const lines = readFileSync(path, 'utf8').split('\n')

lines.forEach((line, index) => {
const trimmed = line.trim()
const column = line.length - line.trimStart().length + 1
for (const rule of RULES) {
if (!rule.applies.includes(extension)) continue
rule.scan(line, (token, hint) => {
violations.push({
file: relative(process.cwd(), path),
line: index + 1,
column,
rule: rule.id,
token,
hint,
context: trimmed
})
})
}
})
}

if (violations.length === 0) {
console.log('check:design — no violations.')
return
}

for (const violation of violations) {
console.error(
`${violation.file}:${violation.line}:${violation.column} ${violation.rule} ${violation.token}`
)
console.error(` ${violation.hint}`)
console.error(` ${violation.context}`)
}
console.error(`\ncheck:design — ${violations.length} violation(s). See DESIGN.md for the rules.`)
process.exitCode = 1
}

main()
9 changes: 5 additions & 4 deletions src/main/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'
import type { QuizQuestion } from '../../shared/types/quiz'

/**
* 笔记本表
Expand Down Expand Up @@ -63,7 +64,7 @@ export const chatMessages = sqliteTable(
role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(),
content: text('content').notNull(),
reasoningContent: text('reasoning_content'), // DeepSeek Reasoner 推理过程内容
metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown>>(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
},
(table) => ({
Expand Down Expand Up @@ -131,7 +132,7 @@ export const documents = sqliteTable(
contentHash: text('content_hash'), // 内容哈希,用于检测变更
mimeType: text('mime_type'), // 文件类型
fileSize: integer('file_size'), // 文件大小(字节)
metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown>>(),
status: text('status', { enum: ['pending', 'processing', 'indexed', 'failed'] })
.notNull()
.default('pending'),
Expand Down Expand Up @@ -167,7 +168,7 @@ export const chunks = sqliteTable(
chunkIndex: integer('chunk_index').notNull(), // 在文档中的顺序
startOffset: integer('start_offset'), // 原文起始位置
endOffset: integer('end_offset'), // 原文结束位置
metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown>>(),
tokenCount: integer('token_count'), // token 估算值
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
},
Expand Down Expand Up @@ -258,7 +259,7 @@ export const quizzes = sqliteTable(
.references(() => notebooks.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
version: integer('version').notNull().default(1), // 版本号
questionsData: text('questions_data', { mode: 'json' }).notNull(), // 题目数组
questionsData: text('questions_data', { mode: 'json' }).$type<QuizQuestion[]>().notNull(), // 题目数组
chunkMapping: text('chunk_mapping', { mode: 'json' }).notNull(), // questionId -> chunkIds映射
metadata: text('metadata', { mode: 'json' }).$type<{
model: string
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function App(): React.JSX.Element {
// 如果正在加载引导状态,显示加载中
if (onboardingLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="flex items-center justify-center min-h-screen bg-surface-base">
<div className="text-foreground">Loading...</div>
</div>
)
Expand Down
14 changes: 13 additions & 1 deletion src/renderer/src/assets/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
/* Translucent state fills: they adapt to whatever surface they are drawn on. */
--surface-hover: oklch(0.3496 0.0141 274.5028 / 0.05);
--surface-selected: oklch(0.3496 0.0141 274.5028 / 0.09);
/* Scrim behind a modal layer. The one place a raw black is legitimate, so it
is a token instead of a literal. */
--scrim: oklch(0 0 0 / 0.5);

/* Opaque quiet fill for nested containers (bubbles, icon tiles, code). */
--muted: oklch(0.9551 0 0);
Expand All @@ -44,6 +47,7 @@
--ring: oklch(0.6017 0.193 263.2461);
--destructive: oklch(0.6058 0.1839 29.6718);
--destructive-foreground: oklch(1 0 0);
--success: oklch(0.55 0.12 150);

/* Hairline. --input is the neutral fill of unchecked controls, not a border. */
--border: oklch(0.9067 0 0);
Expand Down Expand Up @@ -124,13 +128,19 @@
--surface-overlay: oklch(0.365 0.011 260);
--surface-hover: oklch(0.9 0.01 262 / 0.06);
--surface-selected: oklch(0.9 0.01 262 / 0.1);
--scrim: oklch(0 0 0 / 0.65);

--muted: oklch(0.3863 0.0235 265.6941);
/* --muted is a *recessed* fill: below surface-raised in both modes
(light 0.9551 vs 1.0, dark 0.275 vs 0.325) so a bubble, icon tile or
inline code block reads as inset instead of flipping direction. */
--muted: oklch(0.275 0.014 260);

--foreground: oklch(0.7621 0.0202 262.9873);
--muted-foreground: oklch(0.68 0.02 262.9873);
--subtle-foreground: oklch(0.58 0.02 262.9873);

--success: oklch(0.75 0.13 150);

--primary: oklch(0.7304 0.1213 245.2972);
--ring: oklch(0.7304 0.1213 245.2972);
--destructive: oklch(0.6709 0.1448 16.9989);
Expand Down Expand Up @@ -175,6 +185,7 @@
--color-surface-overlay: var(--surface-overlay);
--color-surface-hover: var(--surface-hover);
--color-surface-selected: var(--surface-selected);
--color-scrim: var(--scrim);

--color-background: var(--background);
--color-foreground: var(--foreground);
Expand All @@ -193,6 +204,7 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
Expand Down
Loading
Loading