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`)
+ }
+})