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
39 changes: 39 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,38 @@ Two rules for the composer and for leaving the workspace:
through. The in-panel Back button is not special-cased — it uses the same
state. A navigation path that skips this guard is a bug.

### Geometry lives in a module, and it is tested

The arithmetic above — `availableFrom`, `maxSideWidth`, the golden-ratio split, the
stored-layout parser, and the key-to-width mapping — lives in
`components/layouts/panelGeometry.ts`, not inside the component. It used to be
inline, where the only way to exercise it was to drag a mouse, and it was wrong:
`ArrowLeft` grew the left panel and shrank the right one. That passed typecheck and
lint, and only a second reviewer found it.

`test/panelGeometry.test.ts` covers the constraint math, the stored-layout parser
(absent, corrupt, `NaN`, `Infinity`, zero) and the arrow-key direction on **both**
seams. Note what does _not_ catch the inversion: an invariant like "the two seams
move oppositely" stays true when both are flipped together. Only assertions on the
absolute direction do.

### Motion and announcement

- **`prefers-reduced-motion` is honoured** in `theme.css`: transitions and
decorative animation collapse, scroll behaviour goes instant. Spinners keep
turning, slowed rather than stopped — a spinner is a status indicator, and
removing it removes information, not motion. The `!important` there is
load-bearing: a `*` selector cannot outrank Tailwind's utility classes.
- **The transcript announces completion, never tokens.** A `role="status"` region
inside `MessageList` reports when a turn ends, with the node keyed so an
identical message still re-announces. Putting the live region on the transcript
itself would chatter on every token, which is worse than the silence it replaces.
- **The tab close control is a sibling of its tab, not a child.** A real
`<button>` with an accessible name, next to the `role="tab"` rather than nested
inside it as a `role="button"` span. The tablist uses a roving tabindex, so the
old inner `tabIndex={0}` added a second focus stop per open notebook and
duplicated the Enter/Space handling the trigger already had.

## Borders and elevation

Two border weights exist, and only two:
Expand Down Expand Up @@ -587,6 +619,13 @@ than a raw palette colour, and this guard only looked at utility classes — not
inline style objects. `CHART_SURFACES` is the only allowlist, and it names one
file: the mind-map node, which is a graph node.

`test/designGuard.test.ts` covers the allowlist predicate and the rule list. It
exists because the first version of that predicate compared a forward-slash
pattern against a platform path: on Windows it never matched, so the one file it
was written to exempt was the one file it flagged. Linux and macOS CI passed;
the Windows job failed. A test asserts both separator styles now, so the same bug
is caught locally on any platform.

Known gap, stated rather than implied: a raw **hex** in an inline style
(`style={{ background: '#fff' }}`) still passes. That is deliberate — the one
current use is the mind-map PNG export, which needs a fixed background rather
Expand Down
27 changes: 21 additions & 6 deletions scripts/check-design-tokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
import { pathToFileURL } from 'node:url'

const SCAN_ROOT = join(process.cwd(), 'src', 'renderer', 'src')
const EXTENSIONS = ['.ts', '.tsx', '.css']
Expand Down Expand Up @@ -50,6 +51,19 @@ const PALETTE =
*/
const CHART_SURFACES = ['/components/notebook/mindmap/CustomNode.tsx']

/**
* Whether a file is allowed to use `--chart-*`.
*
* Exported so it can be tested: the first version compared a forward-slash
* allowlist against a platform path, so on Windows it never matched and the only
* file it was meant to exempt was the only file it flagged. Linux and macOS CI
* both passed; the Windows job failed.
*/
export const isChartSurfaceAllowed = (file) => {
const normalized = file ? file.replace(/\\/g, '/') : ''
return CHART_SURFACES.some((allowed) => normalized.endsWith(allowed))
}

const RULES = [
{
id: 'radius',
Expand Down Expand Up @@ -104,11 +118,7 @@ const RULES = [
describe: 'chart colours stay on chart and graph surfaces',
applies: ['.ts', '.tsx'],
scan(line, check, file) {
// Separators are normalised first: `file` is a platform path, so the
// allowlist below never matched on Windows and every legitimately allowed
// use was reported. CI caught it, not the local run.
const normalized = file ? file.replace(/\\/g, '/') : ''
if (CHART_SURFACES.some((allowed) => normalized.endsWith(allowed))) return
if (isChartSurfaceAllowed(file)) return
const patterns = [
/\b(bg|text|border|ring|fill|stroke|from|to|via)-chart-[1-5](?:\/\d+)?\b/g,
/var\(--chart-[1-5]\)/g
Expand Down Expand Up @@ -237,4 +247,9 @@ function main() {
process.exitCode = 1
}

main()
// Only run when invoked directly (`npm run check:design`), so tests can import
// the allowlist predicate without executing the scan.
const isDirectRun =
process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href

if (isDirectRun) main()
31 changes: 31 additions & 0 deletions src/renderer/src/assets/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,37 @@
}
}

/*
* Reduced motion is a preference, not a nicety. The app pulses rows and cursors
* while work is in flight and animates every popover and dialog, and none of that
* was conditional on the user's setting.
*
* Spinners keep turning. A spinner is a status indicator, not decoration — it is
* the only thing telling the user that something is happening, so collapsing it
* would remove information rather than motion. Slowed rather than removed for the
* same reason.
*
* `!important` is deliberate and load-bearing here. These declarations have to
* beat Tailwind's utility classes (`animate-pulse`, `transition-colors`) applied
* to individual elements, and a `*` selector has no specificity to do that with.
* Collapsing the cascade is the point of a global preference override.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}

.animate-spin {
animation-duration: 1.5s !important;
animation-iteration-count: infinite !important;
}
}

@layer utilities {
.themed-scrollbar {
scrollbar-width: thin;
Expand Down
37 changes: 19 additions & 18 deletions src/renderer/src/components/common/TopNavigationBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,29 +128,30 @@ export default function TopNavigationBar({
<span>{t('home')}</span>
</TabsTrigger>

{/* 打开的笔记本标签 */}
{/* 打开的笔记本标签。
关闭控件是 trigger 的**兄弟**节点而不是子节点:原先它是一个
`role="button" tabIndex={0}` 的 span 嵌在 `role="tab"`(本身
是 <button>)里 —— 交互角色套交互角色,而且 tablist 用 roving
tabindex,内层恒为 0 的 tabIndex 会给每个打开的笔记本多造一个
焦点停留点。现在它是一个真的 <button>,有自己的可访问名称。 */}
{openedNotebooks.map((notebook) => (
<TabsTrigger
key={notebook.id}
value={notebook.id}
className="h-7 gap-1 pr-2 pl-3 rounded-md border border-transparent text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground data-[state=active]:border-border data-[state=active]:bg-surface-selected data-[state=active]:text-foreground data-[state=active]:font-medium max-w-[200px]"
>
<span className="truncate">{notebook.title}</span>
<span
<div key={notebook.id} className="relative flex items-center">
<TabsTrigger
value={notebook.id}
className="h-7 pr-7 pl-3 rounded-md border border-transparent text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground data-[state=active]:border-border data-[state=active]:bg-surface-selected data-[state=active]:text-foreground data-[state=active]:font-medium max-w-[200px]"
>
<span className="truncate">{notebook.title}</span>
</TabsTrigger>
<button
type="button"
onClick={(e) => handleCloseOpenedNotebook(notebook.id, e)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleCloseOpenedNotebook(notebook.id, e)
}
}}
className="ml-1 inline-flex items-center justify-center h-4 w-4 p-0.5 rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors"
className="absolute right-1 inline-flex h-4 w-4 items-center justify-center rounded-md transition-colors hover:bg-destructive/20 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
title={t('closeTab')}
aria-label={t('closeTab')}
>
<X className="w-3 h-3" />
</span>
</TabsTrigger>
</button>
</div>
))}

{/* 新建按钮 */}
Expand Down
5 changes: 2 additions & 3 deletions src/renderer/src/components/layouts/DragHandle.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import * as React from 'react'
import { ReactElement } from 'react'
import { cn } from '@/lib/utils'

export type DragHandleSide = 'left' | 'right'
import type { PanelSide } from './panelGeometry'

export interface DragHandleProps {
/** Which panel this handle resizes. */
side: DragHandleSide
side: PanelSide
/** Current width of that panel, in CSS pixels. */
value: number
min: number
Expand Down
114 changes: 25 additions & 89 deletions src/renderer/src/components/layouts/ResizableLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, ReactNode, ReactElement } from 'react'
import { useTranslation } from 'react-i18next'
import DragHandle, { type DragHandleSide } from './DragHandle'
import DragHandle from './DragHandle'
import { lockDrag } from '../../lib/dragLock'
import {
DEFAULT_LEFT_WIDTH,
DEFAULT_RIGHT_WIDTH,
MIN_SIDE_WIDTH,
calculateGoldenRatioWidths,
isResizeKey,
maxSideWidth,
nextPanelWidth,
readStoredWidths,
writeStoredWidths,
type PanelSide,
type PanelWidths
} from './panelGeometry'

export interface ResizableLayoutProps {
leftPanel: ReactNode
Expand All @@ -12,73 +25,6 @@ export interface ResizableLayoutProps {
defaultRightWidth?: number
}

/** Minimum width of a side panel, in CSS pixels. */
const MIN_SIDE_WIDTH = 260
/** Minimum width of the centre panel, in CSS pixels. */
const MIN_CENTER_WIDTH = 420
/** The seam is `w-3`. The canvas gutter between two panels is the handle, so it costs layout. */
const HANDLE_WIDTH = 12
/** `p-2` on the container, both sides. */
const CONTAINER_PADDING_X = 16
const DEFAULT_LEFT_WIDTH = 320
const DEFAULT_RIGHT_WIDTH = 360
const KEYBOARD_STEP = 10
const KEYBOARD_STEP_FAST = 50
const STORAGE_KEY = 'knownote:panel-widths'

/** The sides start at the golden ratio of the free space. Documented in DESIGN.md. */
const GOLDEN_RATIO = 1.618

interface PanelWidths {
left: number
right: number
}

const readStoredWidths = (): PanelWidths | null => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<PanelWidths>
if (
typeof parsed.left !== 'number' ||
typeof parsed.right !== 'number' ||
!Number.isFinite(parsed.left) ||
!Number.isFinite(parsed.right)
) {
return null
}
return { left: parsed.left, right: parsed.right }
} catch {
return null
}
}

const writeStoredWidths = (widths: PanelWidths): void => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(widths))
} catch {
// Persistence is a convenience; losing it must never break the layout.
}
}

/** Space the panels and seams leave free inside a container of this width. */
const availableFrom = (containerWidth: number): number =>
Math.max(0, containerWidth - CONTAINER_PADDING_X - HANDLE_WIDTH * 2)

/**
* The widest a side panel may become: everything except the other side panel and
* the centre's minimum. The floor is `MIN_SIDE_WIDTH`, so a window too narrow for
* both minimums degrades by letting the centre shrink rather than by clamping a
* side panel to something unusable.
*/
const maxSideWidth = (other: number, containerWidth: number): number =>
Math.max(MIN_SIDE_WIDTH, availableFrom(containerWidth) - other - MIN_CENTER_WIDTH)

const calculateGoldenRatioWidths = (containerWidth: number): PanelWidths => {
const width = Math.floor(availableFrom(containerWidth) * (1 / (2 + GOLDEN_RATIO)))
return { left: width, right: width }
}

interface DragSession {
detach: (cancelled?: boolean) => void
}
Expand All @@ -101,7 +47,7 @@ export default function ResizableLayout({
() => defaultRightWidth ?? storedWidths?.right ?? DEFAULT_RIGHT_WIDTH
)
const [containerWidth, setContainerWidth] = useState(0)
const [draggingSide, setDraggingSide] = useState<DragHandleSide | null>(null)
const [draggingSide, setDraggingSide] = useState<PanelSide | null>(null)
const [isLeftCollapsed, setIsLeftCollapsed] = useState(false)
const [isRightCollapsed, setIsRightCollapsed] = useState(false)

Expand All @@ -117,7 +63,7 @@ export default function ResizableLayout({
const hasAppliedInitial = useRef(false)

/** The single writer for panel widths: state and both mirrors move together. */
const setPanelWidth = useCallback((side: DragHandleSide, value: number): void => {
const setPanelWidth = useCallback((side: PanelSide, value: number): void => {
if (side === 'left') {
leftWidthRef.current = value
setLeftWidth(value)
Expand Down Expand Up @@ -183,7 +129,7 @@ export default function ResizableLayout({
* layout is read while the pointer is moving.
*/
const beginDrag = useCallback(
(side: DragHandleSide, event: React.PointerEvent<HTMLDivElement>): void => {
(side: PanelSide, event: React.PointerEvent<HTMLDivElement>): void => {
if (event.button !== 0) return
const container = containerRef.current
if (!container) return
Expand Down Expand Up @@ -293,27 +239,17 @@ export default function ResizableLayout({
}, [])

const handleKeyDown = useCallback(
(side: DragHandleSide, event: React.KeyboardEvent<HTMLDivElement>): void => {
const step = event.shiftKey ? KEYBOARD_STEP_FAST : KEYBOARD_STEP
(side: PanelSide, event: React.KeyboardEvent<HTMLDivElement>): void => {
// The mapping lives in panelGeometry so it can be tested; arrow keys move the
// seam, which makes the same key grow one panel and shrink the other.
if (!isResizeKey(event.key)) return
event.preventDefault()

const current = side === 'left' ? leftWidthRef.current : rightWidthRef.current
const other = side === 'left' ? rightWidthRef.current : leftWidthRef.current
const max = maxSideWidth(other, containerWidthRef.current)
// The seam moves in the direction of the arrow, so the panel it resizes moves
// the other way: ArrowLeft shrinks the panel on the left of the seam and
// grows the panel on the right of it. Getting this backwards made the two
// separators report opposite value changes for the same key.
const growsWithArrowRight = side === 'left'

let next: number | null = null
if (event.key === 'ArrowLeft') next = current + (growsWithArrowRight ? -step : step)
else if (event.key === 'ArrowRight') next = current + (growsWithArrowRight ? step : -step)
else if (event.key === 'Home') next = MIN_SIDE_WIDTH
else if (event.key === 'End') next = max

if (next === null) return
event.preventDefault()

setPanelWidth(side, Math.max(MIN_SIDE_WIDTH, Math.min(max, next)))
setPanelWidth(side, nextPanelWidth(event.key, side, current, max, event.shiftKey))
lastSizeRef.current = { left: leftWidthRef.current, right: rightWidthRef.current }
writeStoredWidths(lastSizeRef.current)
},
Expand All @@ -325,7 +261,7 @@ export default function ResizableLayout({
* way back is a trap: one bad drag kept a 260px reading pane forever.
*/
const resetPanel = useCallback(
(side: DragHandleSide): void => {
(side: PanelSide): void => {
const golden = calculateGoldenRatioWidths(containerWidthRef.current)
const other = side === 'left' ? rightWidthRef.current : leftWidthRef.current
const target = side === 'left' ? golden.left : golden.right
Expand Down
Loading
Loading