diff --git a/.github/images/notes-studio-dark.png b/.github/images/notes-studio-dark.png new file mode 100644 index 0000000..ebf1d2b Binary files /dev/null and b/.github/images/notes-studio-dark.png differ diff --git a/.github/images/notes-studio-light.png b/.github/images/notes-studio-light.png new file mode 100644 index 0000000..e1979d7 Binary files /dev/null and b/.github/images/notes-studio-light.png differ diff --git a/DESIGN.md b/DESIGN.md index c18ecfb..6d46f29 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -235,7 +235,9 @@ Rules: - **Never invent a background colour.** `bg-white`, `bg-black`, `bg-gray-*`, `bg-slate-*`, `bg-[…]` and raw `oklch()`/`hsl()` in a component are all violations. If the surface you need is not in the table, the table is wrong — - extend it here. + extend it here. The one narrow, deliberate exception is the four [tool + fills](#tool-fills): a coloured card background on the Notes creation tools and + nothing else. - `surface-hover` and `surface-selected` are translucent so that they adapt to whatever they are drawn on. Do not add alpha to them again (`bg-surface-hover/50` is a bug). @@ -272,6 +274,42 @@ blue-tinted neutral in the palette. They have been deleted; do not reintroduce them — the `secondary` **variant** name is unrelated to a `--secondary` colour and is spelled with `bg-muted`. +### Tool fills + +Implementation: `components/notebook/noteTools.ts` (the token on each tool), +`assets/theme.css` (values and Tailwind mappings), and +`components/notebook/NotePanel.tsx` (the only consumer). + +These four tokens exist for the Notes panel's creation tools — create note, mind +map, quiz, Anki — which are the one place a **card** carries colour. They are not +a surface tier, not an accent and not chart colours: a decorative pastel fill that +lets the four action cards be told apart at a glance. The grid is `2 x 2`, in the +order create note, mind map, quiz, Anki. + +| Token | Tailwind | Light | Dark | Tool | +| ---------------- | ----------------- | ------------------------ | ---------------------- | ----------- | +| `--tool-note` | `bg-tool-note` | `oklch(0.935 0.045 250)` | `oklch(0.32 0.04 250)` | Create note | +| `--tool-mindmap` | `bg-tool-mindmap` | `oklch(0.935 0.045 300)` | `oklch(0.32 0.04 300)` | Mind map | +| `--tool-quiz` | `bg-tool-quiz` | `oklch(0.935 0.045 150)` | `oklch(0.32 0.04 150)` | Quiz | +| `--tool-anki` | `bg-tool-anki` | `oklch(0.935 0.045 85)` | `oklch(0.32 0.045 85)` | Anki | + +Rules: + +- **Scope is exactly these four cards.** A fifth use — a badge, a tinted list row, + a status chip — is a rule change and belongs in this document before it belongs in + the code. `--chart-*` and `--primary` may not be borrowed for this either; the + chart guard stays as it is. +- **Text and icons stay neutral.** The icon stays `text-muted-foreground` (T2, + neutral) and the label stays `text-foreground` (T1, `text-sm font-medium`). Colour + carries the tool's identity; the glyph and the word carry the meaning, so colour is + never the only carrier of anything. +- **Low saturation on purpose.** Chroma stays at 0.04–0.05 so a pastel tile never + competes with the `--primary` action or a chart. They sit deliberately outside the + surface ladder: light values are a step _below_ `--surface-base` (a recessed tile) + and dark values a step _above_ it (a muted raise), so the card reads as a distinct + tile rather than as a panel in either scheme. Both stay above `--border`, so the + card's hairline is visible in both themes. + ## Window chrome The title bar is the one piece of the interface the OS and the renderer draw @@ -1039,6 +1077,54 @@ area, so a panel-level empty state opts out with `border-none` (`DocumentList`). The primitive keeps its dashed hairline — that opt-out is the exception, and it belongs to the panel that asked for it, not to `Empty`. +### Tool card + +Implementation: `components/notebook/NotePanel.tsx`, over the four +[Tool fills](#tool-fills). The Notes panel's creation grid is a `2 x 2` of these, +rendered as the first block **inside** the panel's content scroll area so a short +window scrolls the cards and the item list together. The panel header keeps the +`Notes` title only — it carries no tool buttons. + +```tsx +
+ +
+``` + +- **Density:** `p-3` card padding; `gap-2` between icon and label and between + cards. Height is content-driven (never `h-9`): a wrapped English label + ("Generate Anki Cards") sets the row height in a 260px panel. +- **Radius:** `rounded-lg` (8px), the container radius — a card is a container even + though it is a button. Border `border border-border`; **no shadow**. +- **Label never truncates:** `whitespace-normal` overrides `Button`'s default + `whitespace-nowrap`, which would clip the label at the panel minimum. The icon + sits above a left-aligned label (`flex-col items-start`), not beside it. +- **Hover is composited, not swapped.** The tool fill is opaque, so + `hover:bg-surface-hover` would replace it; the sanctioned overlay from + [Raised card](#raised-card) adds the translucent fill on top instead, and + each card's matching `hover:bg-tool-*` keeps its opaque fill instead of the + `ghost` variant's default hover fill. +- **Focus and disabled** come from `Button`: + `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background` + and `disabled:pointer-events-none disabled:opacity-50`. +- **No selected state.** These are actions, not a mode switch: pressing one runs and + returns (a blank note opens the editor; the other three generate an artifact), so + no card stays pressed. That state is unreachable and is recorded here rather than + invented. + ### Form field Implementation: `components/ui/field.tsx` — `Field`, `FieldSet`, `FieldLegend`, diff --git a/src/renderer/src/assets/theme.css b/src/renderer/src/assets/theme.css index 6ded888..1686d42 100644 --- a/src/renderer/src/assets/theme.css +++ b/src/renderer/src/assets/theme.css @@ -81,6 +81,22 @@ --chart-4: oklch(0.8249 0.097 82.2597); --chart-5: oklch(0.6936 0.1635 318.2073); + /* + * Tool fills. Four low-saturation card backgrounds used **only** by the Notes + * panel's creation-tool cards (create note / mind map / quiz / Anki), in that + * order. This is a sanctioned, narrow exception to the surface ladder: the tool + * cards are a decorative grid of action buttons, and a pastel tint is what lets + * the four be told apart at a glance. Nothing else may use these tokens, they + * carry neutral text and icons (the icon stays T2 and the label T1), and they + * are not chart colours — see DESIGN.md, "Tool fills". Light values sit a step + * below `--surface-base` (a recessed tile); the dark values below are the + * matching muted raise above it. + */ + --tool-note: oklch(0.935 0.045 250); + --tool-mindmap: oklch(0.935 0.045 300); + --tool-quiz: oklch(0.935 0.045 150); + --tool-anki: oklch(0.935 0.045 85); + /* Elevation: floating layers, plus a small lift for knobs and handles. */ --shadow-elevation: 0 6px 16px -4px hsl(0 0% 0% / 0.12), 0 2px 4px -1px hsl(0 0% 0% / 0.06); --shadow-control: 0 1px 2px 0 hsl(0 0% 0% / 0.16); @@ -202,6 +218,14 @@ --chart-4: oklch(0.8249 0.097 82.2597); --chart-5: oklch(0.6936 0.1635 318.2073); + /* Tool fills (Notes creation cards). A muted raise above `--surface-base`, and + above `--border` (0.3052), so the card's hairline stays visible in dark mode + the same way the light tile's does. */ + --tool-note: oklch(0.32 0.04 250); + --tool-mindmap: oklch(0.32 0.04 300); + --tool-quiz: oklch(0.32 0.04 150); + --tool-anki: oklch(0.32 0.045 85); + --shadow-elevation: 0 8px 22px -6px hsl(0 0% 0% / 0.55), 0 2px 4px -1px hsl(0 0% 0% / 0.35); --shadow-control: 0 1px 2px 0 hsl(0 0% 0% / 0.5); @@ -259,6 +283,10 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-tool-note: var(--tool-note); + --color-tool-mindmap: var(--tool-mindmap); + --color-tool-quiz: var(--tool-quiz); + --color-tool-anki: var(--tool-anki); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); diff --git a/src/renderer/src/components/notebook/NotePanel.tsx b/src/renderer/src/components/notebook/NotePanel.tsx index 780fee7..8207373 100644 --- a/src/renderer/src/components/notebook/NotePanel.tsx +++ b/src/renderer/src/components/notebook/NotePanel.tsx @@ -1,13 +1,15 @@ import { ReactElement, useEffect, useRef, useState } from 'react' -import { Save, Trash2, ArrowLeft, Network, FileText, ClipboardCheck, Layers } from 'lucide-react' +import { Save, Trash2, ArrowLeft } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useParams } from 'react-router-dom' +import { cn } from '@/lib/utils' import { useItemStore } from '../../store/itemStore' import { useUIStore } from '../../store/uiStore' import { setupMindMapListeners } from '../../store/mindmapStore' import NoteEditor from './note/NoteEditor' import ItemList from './item/ItemList' +import { NOTE_TOOLS, type NoteTool, type NoteToolId } from './noteTools' import QuizStartDialog, { QuizStartParams } from './quiz/QuizStartDialog' import AnkiConfigDialog from './anki/AnkiConfigDialog' import { ScrollArea } from '../ui/scroll-area' @@ -137,6 +139,40 @@ function NoteEditorPanel({ ) } +// 创建工具卡片:图标在上、左对齐标签在下。悬停用合成填充(半透明 surface-hover +// 叠在彩色卡片上),不替换彩色底色。这些是动作按钮,没有持续选中态。 +function NoteToolCard({ + tool, + label, + onClick +}: { + tool: NoteTool + label: string + onClick: () => void +}): ReactElement { + const { Icon } = tool + return ( + + ) +} + export default function NotePanel(): ReactElement { const { t } = useTranslation('notebook') const { id: notebookId } = useParams() @@ -324,6 +360,14 @@ export default function NotePanel(): ReactElement { setShowQuizStartDialog(true) } + // 四张工具卡片的动作,按 noteTools 中的顺序引用 + const toolActions: Record void> = { + note: handleCreateNote, + mindmap: handleGenerateMindMap, + quiz: handleGenerateQuiz, + anki: handleGenerateAnki + } + // 开始生成答题 const handleQuizStart = async (params: QuizStartParams) => { if (!notebookId) return @@ -378,7 +422,7 @@ export default function NotePanel(): ReactElement { ) : ( // 列表页面 <> - {/* 顶部工具栏 */} + {/* 顶部标题栏:只保留标题,工具入口移到下方内容区 */} } - right={ - <> - - - - - - } /> - {/* Items 列表(笔记 + 思维导图等) */} + {/* 内容区:工具卡片与条目列表共用同一个滚动区,矮窗口下一起滚动 */} +
+ {NOTE_TOOLS.map((tool) => ( + + ))} +
readFileSync(path, 'utf8') + +const rendererSources = (dir: string, files: string[] = []): string[] => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry) + if (statSync(path).isDirectory()) rendererSources(path, files) + else if (/\.(ts|tsx)$/.test(path)) files.push(path.replace(/\\/g, '/')) + } + return files +} + +test('the tool grid is create note, mind map, quiz, Anki in that order', () => { + assert.deepEqual( + NOTE_TOOLS.map((tool) => tool.id), + ['note', 'mindmap', 'quiz', 'anki'] + ) +}) + +test('each tool carries its own approved fill and a locale label key', () => { + assert.deepEqual( + NOTE_TOOLS.map((tool) => tool.surfaceClassName.split(' ')[0]), + ['bg-tool-note', 'bg-tool-mindmap', 'bg-tool-quiz', 'bg-tool-anki'] + ) + assert.deepEqual( + NOTE_TOOLS.map((tool) => tool.labelKey), + ['createNote', 'generateMindMap', 'generateQuiz', 'generateAnki'] + ) + // The four are distinct; a duplicated token would make two cards indistinguishable. + assert.equal(new Set(NOTE_TOOLS.map((tool) => tool.surfaceClassName)).size, 4) +}) + +test('hover retains each opaque tool fill underneath the state overlay', () => { + for (const tool of NOTE_TOOLS) { + const [fill, hover] = tool.surfaceClassName.split(' ') + assert.equal(hover, `hover:${fill}`) + } + const panel = read('src/renderer/src/components/notebook/NotePanel.tsx') + assert.doesNotMatch(panel, /hover:bg-transparent/) + assert.match(panel, /bg-surface-hover opacity-0 transition-opacity group-hover:opacity-100/) +}) + +test('theme.css defines a light value, a dark value and a Tailwind mapping per fill', () => { + const css = read('src/renderer/src/assets/theme.css') + + for (const token of ['tool-note', 'tool-mindmap', 'tool-quiz', 'tool-anki']) { + const values = css.match(new RegExp(`--${token}: oklch\\(`, 'g')) || [] + assert.equal(values.length, 2, `${token} needs one light and one dark value`) + assert.ok(css.includes(`--color-${token}: var(--${token})`), `${token} needs a mapping`) + } +}) + +test('the tool fills are used only by the Notes tool cards', () => { + const users = rendererSources(join('src', 'renderer', 'src')).filter((file) => + read(file).includes('bg-tool-') + ) + assert.deepEqual(users, ['src/renderer/src/components/notebook/noteTools.ts']) +}) + +test('both locales carry the tool labels and point at the card, not a top-right plus', () => { + for (const locale of ['en-US', 'zh-CN']) { + const messages = JSON.parse(read(`src/renderer/src/locales/${locale}/notebook.json`)) + for (const key of ['createNote', 'generateMindMap', 'generateQuiz', 'generateAnki']) { + assert.ok(messages[key], `${locale} is missing ${key}`) + } + const description: string = messages.noNotesYetDesc + assert.ok(description, `${locale} has noNotesYetDesc`) + assert.doesNotMatch(description, /top right|右上角/) + // The empty-state hint names the card the user now clicks. + assert.ok(description.includes(messages.createNote), `${locale} should name the create card`) + } +})