diff --git a/DESIGN.md b/DESIGN.md index ac19ef0..f31e64d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -146,20 +146,88 @@ either: unknown is not a claim, and it must not be rendered as one. The line is `text-xs text-subtle-foreground`, the treatment the retrieval lines already use — a state of the answer, quieter than the answer itself. +## Home + +Implementation: `components/home/` and `components/pages/NotebookListPage.tsx`. Home +is a **starting desk, not a dashboard**. It has four sections, in the order they are +used, and each one is allowed to render nothing: + +| Section | Grammar | Ordered by | Source | +| ----------------- | --------------- | ----------------------- | ------------------------------------------ | +| Greeting + prompt | text, `text-xl` | — | local time | +| Search entry | input, `h-12` | — | notebooks + recent sources, filtered in JS | +| Recent notebooks | cards | `notebooks.updatedAt` | the notebook store | +| Continue | list rows | the user's own activity | `lib/recentlyOpened.ts` + `recentSessions` | +| Recently added | list rows | `documents.createdAt` | `recentDocuments` | + +Rules: + +- **No hero banner.** The greeting is one `text-xl` line and a T2 prompt. Home is + opened a hundred times; a banner is paid for on every one of them. The visual + centre is the entry below it, not the words above it. +- **One grammar per kind of thing.** Notebooks are cards, everything that is not a + notebook is a row, and creating is a button. Repeating one container for every + object is what makes a launcher read as a wall of identical boxes, and it hides + the difference between a place you work and a file that arrived. +- **"Continue" means where the user was, not what changed.** The shelf is ordered by + `updatedAt`, so a notebook renamed last week outranks the one read an hour ago. + Continue merges the notebooks the user actually opened with the conversation last + written to, ordered by real recency, and caps at three. +- **No dead affordances.** There is no "View all" link, because there is no Library + page to point at — Home is where notebooks are browsed, so the shelf expands in + place with a real disclosure. A section with nothing to show is omitted, not + rendered as an empty box with a heading. +- **One read, not a fan-out.** Home's data arrives in a single + `get-workspace-overview` call (`WorkspaceOverview`): source counts, the newest + sources across every notebook, and the last active conversation. One IPC per + notebook would grow with the library on a page that is opened constantly. +- **Counts are rendered only when known.** A notebook card takes an optional + `sourceCount` and omits the count rather than printing a zero it cannot vouch for. + +### What Home's search does, and does not + +`HomeSearch` filters **notebook and source names** in the renderer, over data Home +already has. It is not wired to retrieval, and the placeholder says so: search the +_text_ of the library and ask a question across all notebooks is a different change, +because `searchChunksFts` is scoped to one notebook and every notebook owns its own +vector table. A box labelled "ask your knowledge" that quietly matched only titles +would be worse than the honest label. + +The entry owns the `Cmd/Ctrl+K` binding on Home, so the hint it renders is a +shortcut that works. Results are a `combobox` / `listbox` pair with +`aria-activedescendant`; options prevent `mousedown` so clicking one does not blur +the input before the click lands. + +### Card hover + +A shelf card takes `hover:-translate-y-px` and `hover:shadow-control`, over 150ms. +This is the one place a `surface-raised` card may carry a shadow, and only while +hovered: it is a transient affordance on a control, not a panel floating over the +document. At rest the card is border + surface step, per [Borders and +shadow](#borders-and-elevation). Never widen this into a resting shadow or a +`shadow-elevation`. + ## Surfaces The app is a stack of opaque surfaces plus two translucent state fills. Higher in the stack means further from the window background, and **lighter** in both -colour schemes. - -| Token | Tailwind | Light | Dark | Use for | -| -------------------- | --------------------- | ------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `--surface-sunken` | `bg-surface-sunken` | `oklch(0.9551 0 0)` | `oklch(0.24 0.0127 258.3724)` | Window chrome that sits _behind_ content: the settings sidebar and dialog shell, a segmented-control track | -| `--surface-base` | `bg-surface-base` | `oklch(0.9851 0 0)` | `oklch(0.2925 0.0157 264.2965)` | App canvas, page background, the gap between panels | -| `--surface-raised` | `bg-surface-raised` | `oklch(1 0 0)` | `oklch(0.325 0.011 260)` | Content panels: document list, reader, chat, editor, cards | -| `--surface-overlay` | `bg-surface-overlay` | `oklch(1 0 0)` | `oklch(0.365 0.011 260)` | Floating layers: dialog, sheet, popover, menu, select, tooltip, toast | -| `--surface-hover` | `bg-surface-hover` | `foreground @ 5%` | `neutral 0.9 @ 6%` | Translucent hover fill for rows, menu items, ghost buttons | -| `--surface-selected` | `bg-surface-selected` | `foreground @ 9%` | `neutral 0.9 @ 10%` | Translucent selected/active fill for rows and toggles | +colour schemes. Each of the three opaque steps has one job, and a surface is +only ever used for that job: + +| Tier | Token | Tailwind | Light | Dark | Use for | +| --------------- | -------------------- | --------------------- | ----------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| **floor** | `--surface-sunken` | `bg-surface-sunken` | `oklch(0.946 0.0045 91)` | `oklch(0.1776 0.0015 91)` | The workspace floor: the window background and the gutter between panels. Also the recessed utility fill — a segmented-control track, a code block | +| **chrome** | `--surface-base` | `bg-surface-base` | `oklch(0.9756 0.0026 106.45)` | `oklch(0.2046 0.0015 91)` | Rails and bars: the library panel, the notes panel, the settings nav, every window title bar, a full-bleed page background | +| **content** | `--surface-raised` | `bg-surface-raised` | `oklch(1 0 0)` | `oklch(0.235 0.0015 91)` | What the user is reading or writing: the chat document, the reader, the editor, a notebook card, a dialog body | +| **floating** | `--surface-overlay` | `bg-surface-overlay` | `oklch(1 0 0)` | `oklch(0.285 0.0015 91)` | Layers above the page: dialog, sheet, popover, menu, select, tooltip, toast | +| **state fills** | `--surface-hover` | `bg-surface-hover` | `foreground @ 5%` | `neutral 0.93 @ 6%` | Translucent hover fill for rows, menu items, ghost buttons | +| | `--surface-selected` | `bg-surface-selected` | `foreground @ 9%` | `neutral 0.93 @ 10%` | Translucent selected/active fill for rows and toggles | + +The three opaque steps are the **floor → chrome → content** ladder. In the +workspace it reads as: the `p-2` gutter is floor, the library and notes panels +are chrome, and the chat document in the centre — the thing the notebook is +_about_ — is content. That is the whole hierarchy; no shadow or colour carries +it. Rules: @@ -180,9 +248,16 @@ Rules: `surface-raised` in both colour schemes. Do not nest `surface-raised` inside `surface-raised`. -**Not** part of the ladder: the window title bar. It is the OS window frame -rather than a panel, so every window draws it `bg-surface-base`, the same tone as -the canvas. Do not give it a surface step. +**The neutral ramp is warm.** Surfaces and text share hue ~91 (a warm grey) +rather than the zero-chroma grey this palette used to be, which is what makes a +white panel read as paper instead of as a default browser surface. Text and +surfaces remain two separate ramps — they agree on hue, nothing else. Do not +mix a warm surface with a cool grey label, and do not introduce a second +neutral hue. + +**Not** a separate tier: the window title bar. It is the OS window frame rather +than a panel, so it is drawn `bg-surface-base` — the chrome tier — in all four +windows. It takes no step of its own; do not give it one. Legacy aliases kept for compatibility while pages migrate: `--background`, `--card`, `--popover`, `--sidebar` and `--accent` — with their `-foreground` @@ -270,7 +345,7 @@ Rules: cancels). Without the lock, dragging past a panel's limit leaves the browser's native selection drag running and selects the text underneath. - **The seam is a gutter, and its width is layout.** The panels are separate - cards floating on `surface-base`; the canvas between them _is_ the handle. + cards floating on the `surface-sunken` floor; the gutter between them _is_ the handle. That is why `HANDLE_WIDTH` is subtracted from the width the panels may occupy, and why the handle paints nothing at rest. Painting it (`bg-border`) or shrinking it toward `w-px` closes the gutter and makes the cards read as @@ -550,25 +625,47 @@ use opacity on top of a level (`text-muted-foreground/70`). Sizes: -| Size | Class | Use for | -| ---- | ------------- | ------------------------------------------------------------------------------------- | -| 11px | `text-[11px]` | Keycaps, tiny counters (rare; prefer `text-xs`) | -| 12px | `text-xs` | Meta line, timestamps, badges, table headers, code | -| 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` | Focus content: home page title, flashcard face | -| 36px | `text-4xl` | A single focus readout (quiz score). One per screen | +| Size | Class | Use for | +| ---- | ------------- | ------------------------------------------------------------------------------------------ | +| 11px | `text-[11px]` | Keycaps, tiny counters (rare; prefer `text-xs`) | +| 12px | `text-xs` | Meta line, timestamps, badges, table headers, code | +| 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` | Focus content: home page title, flashcard face | +| 30px | `text-3xl` | The notebook title in the workspace header, `font-semibold tracking-tight`. One per screen | +| 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, 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`. +`font-semibold` and above are reserved for two things: headings inside long-form +prose (`.markdown-content h1–h4`, `.ProseMirror h1–h3`, all 600), and the one +focus readout per screen. A prose heading is never `font-bold`: an `h3` inside an +answer must not out-weigh the panel header above it. 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`. +**Measure.** A long-form reading surface is capped at `--reading-measure` (72ch) +and centred: + +```tsx +
+ {sources} · {updated} +
+
{i18n.t('common:errorDescription')}
diff --git a/src/renderer/src/components/common/NotebookCard.tsx b/src/renderer/src/components/common/NotebookCard.tsx
index 4a2ad49..4a11893 100644
--- a/src/renderer/src/components/common/NotebookCard.tsx
+++ b/src/renderer/src/components/common/NotebookCard.tsx
@@ -1,9 +1,9 @@
import { ReactElement } from 'react'
-import { Pencil, Trash2, Clock } from 'lucide-react'
+import { BookOpen, Pencil, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { Notebook } from '../../types/notebook'
-import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../ui/card'
-import { Button } from '../ui/button'
+import { formatRelativeDate } from '../../lib/relativeDate'
+import { Card } from '../ui/card'
import {
ContextMenu,
ContextMenuContent,
@@ -14,44 +14,59 @@ import {
interface NotebookCardProps {
notebook: Notebook
+ /**
+ * How many sources it holds. Optional: Home may render the shelf from the
+ * notebook store before the overview has answered, and a count is not something
+ * to guess — without it the meta line shows just the date.
+ */
+ sourceCount?: number
onClick: () => void
onDelete: () => void
onRename: () => void
}
-// 笔记本卡片。DESIGN.md 明确禁止在图表/图谱之外使用 --chart-* 颜色,
-// 所以卡片不再用彩色侧边条区分,颜色只用于正文层级和交互状态。
-
+/*
+ * A notebook on the Home shelf.
+ *
+ * The name is the subject and everything else is demoted: no description (every
+ * notebook carries the same creation-time filler, and nothing can author one — see
+ * `NotebookHeader`), the source count and the date merged into one T3 line, and no
+ * resting buttons. Rename and delete live in the context menu, which is where the
+ * note list already keeps its per-row actions; a footer of icons was what made the
+ * shelf read as a row of identical toolbars.
+ *
+ * DESIGN.md forbids colour-coded cards, so the vertical accent stripe this card
+ * used to draw from `--chart-*` is gone: color marks state and action, not
+ * identity.
+ */
export default function NotebookCard({
notebook,
+ sourceCount,
onClick,
onDelete,
onRename
}: NotebookCardProps): ReactElement {
const { t, i18n } = useTranslation('ui')
- const formatDate = (date: Date): string => {
- const now = new Date()
- const diff = now.getTime() - date.getTime()
- const days = Math.floor(diff / (1000 * 60 * 60 * 24))
-
- if (days === 0) return t('today')
- if (days === 1) return t('yesterday')
- if (days < 7) return t('daysAgo', { days })
-
- const locale = i18n.language === 'zh-CN' ? 'zh-CN' : 'en-US'
- return date.toLocaleDateString(locale)
- }
+ const meta = [
+ sourceCount === undefined ? null : t('sourceCount', { count: sourceCount }),
+ formatRelativeDate(notebook.updatedAt, t, i18n.language)
+ ]
+ .filter((part): part is string => part !== null)
+ .join(' · ')
return (
{meta}
- {t('totalNotebooks', { count: notebookCount })}
- {t('homePrompt')}{notebook.title}
+ {t('myNotebooks')}
-
+ {t(greetingKey(new Date().getHours()))}
+
+