From f1c75ada4590144b978a0ab263e151a38f9811f3 Mon Sep 17 00:00:00 2001 From: mrsibe Date: Mon, 28 Sep 2026 22:21:32 +0800 Subject: [PATCH] feat(ui): give the workspace a surface ladder and make Home a starting desk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface read as flat: one plane of near-identical greys, panels that were cards, and a 14px label where a document title should be. This pass gives it the hierarchy it was missing and rebuilds Home around what a returning user actually does. Surfaces (DESIGN.md "Surfaces", theme.css) - Three named tiers with one job each: floor (window + gutter), chrome (rails, left/right panels, title bars), content (chat, cards, dialogs). The workspace gutter is the floor and the rails sit a step above it, so the hierarchy is carried by surfaces rather than by shadows. - The neutral ramp is warm (hue ~91) in both schemes. Dark is deeper and its T1 is much brighter (0.7621 -> 0.93), so content stops competing with chrome. - Measured WCAG contrast replaces the previous estimates. Two real defects are fixed on the way: light T2 was 4.29:1 (below AA for body text, now 5.28:1) and light T3 was 2.88:1 (below 3:1, now 3.57:1). Library (DocumentList) - Sources are grouped by kind under T3 headings, shown only when more than one group exists. - Retry, re-index, open and delete move into one hover-revealed menu, so a row stops carrying a permanent column of buttons. The row button and the menu trigger are siblings: a button inside a button is the defect the tab strip already fixed. - A row leads with what the source is: `PDF · 202 chunks`, from a tested `sourceKindOf` (mime type -> path -> URL host) shared with Home. Chat (ProcessPanel, MessageList, NotebookHeader) - The composer is an opaque floating layer, one line tall when empty, and no longer takes a backdrop blur that had nothing to blur. Its reserve fallback is re-derived from the new geometry. - The notebook title leaves the panel header and becomes a 30px document header that scrolls with the answer. The panel header keeps its toggles and an empty centre that preserves the drag surface; the tab strip still carries the name. - The 12px dead band under the panel header is gone (top-14 -> top-11). Typography - Long-form content is capped at --reading-measure (72ch) and centred, shared by the transcript and the note editor. Uncapped, a wide panel set a line at ~130 characters. The source reader is deliberately excluded, with the reason recorded: its highlight rects are measured once, so a re-centring column would strand them. - Prose headings are 600, not 700: an h3 inside an answer must not out-weigh the panel header above it. Alpha-on-text (del, editor placeholder) is replaced with a text level. Home (components/home, NotebookListPage) - A starting desk, not a dashboard: greeting, search entry, recent notebooks, Continue, recently added. Every section may render nothing. - One grammar per kind of thing - notebooks are cards, everything else is a row - so the page does not read as a wall of identical boxes. - "Continue" is ordered by the user's own activity (a new localStorage record), not by updatedAt, which answers "what changed" rather than "where was I". - No dead affordances: there is no Library page, so the shelf expands in place instead of linking to a "View all" that does not exist. - Home's data arrives in one get-workspace-overview call (source counts, newest sources across all notebooks, last active conversation) rather than one IPC per notebook on a page that is opened constantly. - Home's search filters notebook and source names in the renderer, and says so. Cross-notebook full-text search needs a notebook-free retrieval path; a box labelled "ask your knowledge" that matched only titles would be worse. Verification - npm run typecheck, npm test (446 pass, +20 new), npm run check:design, npx electron-vite build, npm run build:unpack, npm run smoke:packaged (29 checks) all pass. - New tests: test/sourceKind.test.ts (null mime, Windows paths, lookalike hosts), test/recentlyOpened.test.ts (corrupt storage, per-entry rejection, NaN/Infinity, untrusted order, cap). - Not verified: pixels. No screenshot tooling is available in this environment, so no visual pass was performed; DESIGN.md records the intent instead. --- DESIGN.md | 309 ++++++++++++++++-- package-lock.json | 31 ++ package.json | 1 + src/main/db/queries.ts | 77 ++++- src/main/ipc/notebookHandlers.ts | 11 + src/preload/index.d.ts | 5 +- src/preload/index.ts | 2 + src/renderer/src/App.tsx | 2 +- src/renderer/src/assets/theme.css | 97 ++++-- .../components/common/AppErrorBoundary.tsx | 2 +- .../src/components/common/NotebookCard.tsx | 95 +++--- .../src/components/common/WindowTitleBar.tsx | 7 +- .../src/components/common/sourceIcon.ts | 37 +++ src/renderer/src/components/home/Hero.tsx | 23 -- src/renderer/src/components/home/Home.tsx | 108 +++++- .../src/components/home/HomeSearch.tsx | 213 ++++++++++++ .../src/components/home/HomeSections.tsx | 192 +++++++++++ .../src/components/home/NotebookShelf.tsx | 83 +++++ .../src/components/layouts/DragHandle.tsx | 4 +- .../src/components/notebook/NotePanel.tsx | 2 +- .../src/components/notebook/NotebookGrid.tsx | 33 -- .../components/notebook/NotebookLayout.tsx | 2 +- .../src/components/notebook/ProcessPanel.tsx | 112 ++----- .../src/components/notebook/SourcePanel.tsx | 2 +- .../components/notebook/chat/MessageList.tsx | 26 +- .../notebook/chat/NotebookHeader.tsx | 101 ++++++ .../src/components/notebook/chat/markdown.css | 15 +- .../components/notebook/chat/stickToBottom.ts | 12 +- .../components/notebook/note/NoteEditor.tsx | 12 +- .../components/notebook/note/noteEditor.css | 8 +- .../notebook/source/DocumentList.tsx | 244 +++++++++----- .../src/components/pages/NotebookListPage.tsx | 66 +++- .../src/components/pages/OnboardingPage.tsx | 2 +- .../src/components/ui/dropdown-menu.tsx | 69 ++++ src/renderer/src/lib/recentlyOpened.ts | 108 ++++++ src/renderer/src/lib/relativeDate.ts | 22 ++ src/renderer/src/lib/sourceKind.ts | 112 +++++++ src/renderer/src/locales/en-US/ui.json | 34 +- src/renderer/src/locales/zh-CN/ui.json | 34 +- src/shared/types/index.ts | 3 + src/shared/types/workspace.ts | 56 ++++ test/recentlyOpened.test.ts | 90 +++++ test/sourceKind.test.ts | 130 ++++++++ 43 files changed, 2194 insertions(+), 400 deletions(-) create mode 100644 src/renderer/src/components/common/sourceIcon.ts delete mode 100644 src/renderer/src/components/home/Hero.tsx create mode 100644 src/renderer/src/components/home/HomeSearch.tsx create mode 100644 src/renderer/src/components/home/HomeSections.tsx create mode 100644 src/renderer/src/components/home/NotebookShelf.tsx delete mode 100644 src/renderer/src/components/notebook/NotebookGrid.tsx create mode 100644 src/renderer/src/components/notebook/chat/NotebookHeader.tsx create mode 100644 src/renderer/src/components/ui/dropdown-menu.tsx create mode 100644 src/renderer/src/lib/recentlyOpened.ts create mode 100644 src/renderer/src/lib/relativeDate.ts create mode 100644 src/renderer/src/lib/sourceKind.ts create mode 100644 src/shared/types/workspace.ts create mode 100644 test/recentlyOpened.test.ts create mode 100644 test/sourceKind.test.ts 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 +
+``` + +The chat transcript (`MessageList.tsx`) and the note editor (`NoteEditor.tsx`, +`p-4`) both read the token, so a paragraph is the same width on either surface. +Widening a panel adds margin around the column; it must never lengthen the line — +uncapped, a 1000px centre panel sets an answer at roughly 130 characters per +line. The token is defined once in `theme.css`; take it as a variant rather than +hard-coding a `ch` value or a pixel width. + +The source reader does **not** read it yet, and the reason is a contract rather +than an oversight: `TextSourceReader` measures its highlight rectangles once from +the laid-out range, so a column that re-centres on resize would strand the +highlight. Make that measurement resize-aware before capping that surface. + ## Accent and states `--primary` is the only accent. It is allowed in exactly five places: @@ -669,7 +766,22 @@ Copy these shapes. `cn()` (`@/lib/utils`) is used for merging class overrides. ### Panel +`Panel` is the frame for a workspace zone. Which tier it takes depends on what +the zone holds — a rail is chrome, the thing being read is content: + +- **Rail** (library, notes, settings nav): `bg-surface-base`. +- **Content** (chat transcript, reader, editor): `bg-surface-raised`. + +A rail and a content panel share the same hairline; what separates them is the +tier under it. The hairline is one step lighter than it used to be, because the +surface ladder now carries part of the separation the outline used to carry +alone — do not compensate by reaching for a darker border, and do not add a +second outline to a rail that already sits in the gutter. + ```tsx +// rail + +// content
…
@@ -684,6 +796,48 @@ Implementation: `components/ui/panel-header.tsx`.
``` +A panel header is chrome. It carries the panel's own controls and nothing that +belongs to the document — in the chat panel that is the two collapse toggles, and +its centre is an empty `` that exists only to keep the centre a drag +surface. Do not put the notebook title back in it: the title is the document +header's job (see [Notebook header](#notebook-header)), and the window's tab strip +already carries the name at all times. + +The content below a panel header starts at `top-11`, the header's own height. +Nothing sits in between; a `top-14` left a 12px dead band under every header. + +### Notebook header + +Implementation: `components/notebook/chat/NotebookHeader.tsx`, rendered as the +first block inside the transcript's scroll area (it is passed to `MessageList` as +`header`, not positioned above it). + +```tsx +
+ +

+ {sources} · {updated} +

+
+``` + +- **The title scrolls with the answer**, like a page title, instead of sitting + pinned above the scroll area. Pinning it would change the boxes the composer + reserve, the scroll fade and the follow-the-answer observer are measured + against; scrolling it costs nothing and reads as a document. +- **The title is a `
``` +### Rail list + +A list inside a chrome panel (the library, the notes list) is not a table. Rows +are separated by a 4px gap and identified by a `hover` fill on a `rounded-md` +block, never by a hairline; groups are separated by 12px and labelled with a T3 +section heading. + +```tsx +
+
+

+ {label} +

+ {/* one row */} +
+ + +
+
+
+``` + +Rules: + +- **A heading is rendered only when there is more than one group.** A heading over + the only group restates the panel header. +- **Secondary actions live in one `···` menu, revealed on hover or focus.** A row + in a rail never carries a permanent column of buttons — that is what made every + source row read as a toolbar. Retry, re-index, open and delete are items in the + same menu, applied in that order with the destructive item last, below a + separator. +- **The hover-revealed control is always laid out**, with `opacity-0` rather than + `hidden`/conditional rendering, so revealing it cannot reflow the title. +- **The row button and its menu trigger are siblings**, never nested. A button + inside a button is unreachable by keyboard and is the same defect the tab strip + already fixed. +- **The row button keeps a real hover fill** (`hover:bg-surface-hover` on the + wrapper). The revealed control uses `hover:bg-surface-selected` so it stays + visible on top of it. +- **A source row leads with what the source is.** The metadata line is + ` · chunks`, where the kind comes from `lib/sourceKind.ts` + (`sourceKindOf`), not from `document.type` — `file` covers PDF, Word and Slides, + and "202 chunks" on its own is machine vocabulary, not a fact about the source. + The leading glyph is `KIND_ICON[kind]` in the same module's vocabulary, drawn + from one library at one stroke weight and left neutral: the kind is carried by + the glyph **and** the word, never by a tint (a colour icon in neutral chrome is + a violation). +- **The kind is derived, never stored twice.** `sourceKindOf` is the single + reader of `mimeType` / path / URL, it is total (an unknown file is `file`, not a + guessed `text`), and it is asserted in `test/sourceKind.test.ts`. It is + deliberately _not_ shared with `SourceReader`'s `isPdf`: that predicate answers + "which reader renders this?", which is a rendering contract, and folding the two + together would let a display change pick a different reader. +- **There is no persistent selected row in the library.** Clicking a source + replaces the list with the reader (see [Library and Reading are one zone, two + states](#library-and-reading-are-one-zone-two-states)), so there is no state in + which a selected row is on screen; do not add one back. The notes rail does have + a real selection and uses the [Selected row](#selected-row) recipe. + ### Selected row ```tsx @@ -730,6 +956,33 @@ Dialog, popover, menu, toast:
``` +### Composer + +Implementation: `components/notebook/ProcessPanel.tsx`. The chat composer is the +one floating layer that lives permanently on the page rather than over it. + +```tsx +
+ +