diff --git a/DESIGN.md b/DESIGN.md index abd1dc3..586e176 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 + ` + ))} {/* 新建按钮 */} diff --git a/src/renderer/src/components/layouts/DragHandle.tsx b/src/renderer/src/components/layouts/DragHandle.tsx index 6cfdb96..926fcef 100644 --- a/src/renderer/src/components/layouts/DragHandle.tsx +++ b/src/renderer/src/components/layouts/DragHandle.tsx @@ -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 diff --git a/src/renderer/src/components/layouts/ResizableLayout.tsx b/src/renderer/src/components/layouts/ResizableLayout.tsx index 8dbdcd5..fb294d1 100644 --- a/src/renderer/src/components/layouts/ResizableLayout.tsx +++ b/src/renderer/src/components/layouts/ResizableLayout.tsx @@ -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 @@ -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 - 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 } @@ -101,7 +47,7 @@ export default function ResizableLayout({ () => defaultRightWidth ?? storedWidths?.right ?? DEFAULT_RIGHT_WIDTH ) const [containerWidth, setContainerWidth] = useState(0) - const [draggingSide, setDraggingSide] = useState(null) + const [draggingSide, setDraggingSide] = useState(null) const [isLeftCollapsed, setIsLeftCollapsed] = useState(false) const [isRightCollapsed, setIsRightCollapsed] = useState(false) @@ -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) @@ -183,7 +129,7 @@ export default function ResizableLayout({ * layout is read while the pointer is moving. */ const beginDrag = useCallback( - (side: DragHandleSide, event: React.PointerEvent): void => { + (side: PanelSide, event: React.PointerEvent): void => { if (event.button !== 0) return const container = containerRef.current if (!container) return @@ -293,27 +239,17 @@ export default function ResizableLayout({ }, []) const handleKeyDown = useCallback( - (side: DragHandleSide, event: React.KeyboardEvent): void => { - const step = event.shiftKey ? KEYBOARD_STEP_FAST : KEYBOARD_STEP + (side: PanelSide, event: React.KeyboardEvent): 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) }, @@ -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 diff --git a/src/renderer/src/components/layouts/panelGeometry.ts b/src/renderer/src/components/layouts/panelGeometry.ts new file mode 100644 index 0000000..4fec5c3 --- /dev/null +++ b/src/renderer/src/components/layouts/panelGeometry.ts @@ -0,0 +1,139 @@ +/** + * Panel geometry and resize intent for the three-zone workspace. + * + * These were inline in `ResizableLayout`, where the interesting arithmetic could + * only be exercised by dragging a mouse. The arrow-key mapping in particular was + * wrong in a way nothing could catch: `ArrowLeft` grew the left panel and shrank + * the right one, so the two seams told a screen-reader user the opposite of what + * they had pressed. It is pure arithmetic, so it lives here and is tested. + */ + +/** Minimum width of a side panel, in CSS pixels. */ +export const MIN_SIDE_WIDTH = 260 +/** Minimum width of the centre panel, in CSS pixels. */ +export const MIN_CENTER_WIDTH = 420 +/** The seam is `w-3`. The canvas gutter between two panels is the handle, so it costs layout. */ +export const HANDLE_WIDTH = 12 +/** `p-2` on the container, both sides. */ +export const CONTAINER_PADDING_X = 16 +export const DEFAULT_LEFT_WIDTH = 320 +export const DEFAULT_RIGHT_WIDTH = 360 +export const KEYBOARD_STEP = 10 +export const KEYBOARD_STEP_FAST = 50 +export const STORAGE_KEY = 'knownote:panel-widths' + +/** The sides start at the golden ratio of the free space. Documented in DESIGN.md. */ +export const GOLDEN_RATIO = 1.618 + +export interface PanelWidths { + left: number + right: number +} + +export type PanelSide = 'left' | 'right' + +/** Space the panels and seams leave free inside a container of this width. */ +export 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. + */ +export const maxSideWidth = (other: number, containerWidth: number): number => + Math.max(MIN_SIDE_WIDTH, availableFrom(containerWidth) - other - MIN_CENTER_WIDTH) + +export const calculateGoldenRatioWidths = (containerWidth: number): PanelWidths => { + const width = Math.floor(availableFrom(containerWidth) * (1 / (2 + GOLDEN_RATIO))) + return { left: width, right: width } +} + +/** + * Validate a stored layout. Anything that is not two finite numbers is treated as + * absent, so a corrupt or hand-edited entry degrades to the default split instead + * of rendering a `NaN`-wide panel. + */ +export const parseStoredWidths = (raw: string | null): PanelWidths | null => { + if (!raw) return null + try { + const parsed = JSON.parse(raw) as Partial | null + if (!parsed || typeof parsed !== 'object') return null + const { left, right } = parsed + if ( + typeof left !== 'number' || + typeof right !== 'number' || + !Number.isFinite(left) || + !Number.isFinite(right) + ) { + return null + } + return { left, right } + } catch { + return null + } +} + +export const readStoredWidths = (): PanelWidths | null => { + try { + return parseStoredWidths(window.localStorage.getItem(STORAGE_KEY)) + } catch { + return null + } +} + +export 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. + } +} + +export type ResizeKey = 'ArrowLeft' | 'ArrowRight' | 'Home' | 'End' + +const RESIZE_KEYS: readonly string[] = ['ArrowLeft', 'ArrowRight', 'Home', 'End'] + +export const isResizeKey = (key: string): key is ResizeKey => RESIZE_KEYS.includes(key) + +/** + * The width a resize key produces. + * + * Arrow keys move the **seam**, not the panel: `ArrowLeft` shrinks the panel on + * the left of the seam and grows the panel on the right of it. The same key + * therefore has opposite effects on the two seams, and the panel a seam controls + * is the one it reports. `Home` / `End` go to the minimum and maximum of that + * value, on both seams. + * + * Returns `null` for a key that is not a resize key, and always returns a value + * inside `[MIN_SIDE_WIDTH, max]`. + */ +export const nextPanelWidth = ( + key: ResizeKey, + side: PanelSide, + current: number, + max: number, + fast: boolean +): number => { + const step = fast ? KEYBOARD_STEP_FAST : KEYBOARD_STEP + const growsWithArrowRight = side === 'left' + + let next: number + switch (key) { + case 'ArrowLeft': + next = current + (growsWithArrowRight ? -step : step) + break + case 'ArrowRight': + next = current + (growsWithArrowRight ? step : -step) + break + case 'Home': + next = MIN_SIDE_WIDTH + break + case 'End': + next = max + break + } + + return Math.max(MIN_SIDE_WIDTH, Math.min(max, next)) +} diff --git a/src/renderer/src/components/notebook/chat/MessageList.tsx b/src/renderer/src/components/notebook/chat/MessageList.tsx index 65839d5..e95417d 100644 --- a/src/renderer/src/components/notebook/chat/MessageList.tsx +++ b/src/renderer/src/components/notebook/chat/MessageList.tsx @@ -36,8 +36,30 @@ const MessageList = forwardRef(function Mes const bottomRef = useRef(null) const pinnedRef = useRef(true) const [isPinned, setIsPinned] = useState(true) + // Completion announcements. Kept as `{ text, id }` so the node is replaced and + // the live region re-announces: setting the same string twice is a no-op for a + // screen reader. + const [announcement, setAnnouncement] = useState<{ text: string; id: number } | null>(null) + const streamingRef = useRef(false) const { t } = useTranslation() + const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined + const isStreaming = Boolean(lastMessage?.isStreaming) + + useEffect(() => { + // Announce the END of a turn, never its tokens. A live region on the + // transcript would re-announce on every token, which is worse than silence — + // the previous state of this surface was no announcement at all, and the + // product's entire output arrived silently. + if (streamingRef.current && !isStreaming) { + setAnnouncement((previous) => ({ + text: t('ui:answerComplete'), + id: (previous?.id ?? 0) + 1 + })) + } + streamingRef.current = isStreaming + }, [isStreaming, t]) + const scrollToBottom = useCallback((): void => { // Deliberately instant, not smooth: while an answer streams this runs per // token, and an animated scroll queued that often never settles — it fights @@ -82,6 +104,11 @@ const MessageList = forwardRef(function Mes // arrive would never be observed and the follow would silently never work. return (
+ {/* Completion only, and never the tokens in between. */} +
+ {announcement?.text} +
+ {messages.length === 0 ? (
diff --git a/src/renderer/src/locales/en-US/ui.json b/src/renderer/src/locales/en-US/ui.json index 2a8e29e..3990700 100644 --- a/src/renderer/src/locales/en-US/ui.json +++ b/src/renderer/src/locales/en-US/ui.json @@ -63,5 +63,6 @@ "onboardingTagline": "Research your documents. Trust every answer.", "onboardingGetStarted": "Get Started", "onboardingStarting": "Starting...", - "selectLanguage": "Select Language" + "selectLanguage": "Select Language", + "answerComplete": "Answer complete" } diff --git a/src/renderer/src/locales/zh-CN/ui.json b/src/renderer/src/locales/zh-CN/ui.json index b625e87..7461ed6 100644 --- a/src/renderer/src/locales/zh-CN/ui.json +++ b/src/renderer/src/locales/zh-CN/ui.json @@ -63,5 +63,6 @@ "onboardingTagline": "读你的文档。信每一个回答。", "onboardingGetStarted": "开始使用", "onboardingStarting": "正在启动...", - "selectLanguage": "选择语言" + "selectLanguage": "选择语言", + "answerComplete": "回答完成" } diff --git a/test/designGuard.test.ts b/test/designGuard.test.ts new file mode 100644 index 0000000..19dfaa7 --- /dev/null +++ b/test/designGuard.test.ts @@ -0,0 +1,77 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { isChartSurfaceAllowed } from '../scripts/check-design-tokens.mjs' + +/** + * The design guard is a build gate, so a silent hole in it is worse than a + * missing rule: everything downstream reads "no violations" as proof. + * + * The allowlist predicate is tested directly because the first version of it + * 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 both passed and the Windows job failed. + */ + +const ALLOWED = '/components/notebook/mindmap/CustomNode.tsx' + +test('the chart allowlist matches a posix path', () => { + assert.equal(isChartSurfaceAllowed('/home/dev/repo/src/renderer/src' + ALLOWED), true) +}) + +test('the chart allowlist matches a windows path', () => { + // The exact shape that failed CI: backslashes from the platform path. + assert.equal( + isChartSurfaceAllowed( + 'C:\\dev\\repo\\src\\renderer\\src\\components\\notebook\\mindmap\\CustomNode.tsx' + ), + true + ) +}) + +test('the chart allowlist matches a relative windows path', () => { + assert.equal( + isChartSurfaceAllowed('src\\renderer\\src\\components\\notebook\\mindmap\\CustomNode.tsx'), + true + ) +}) + +test('the chart allowlist does not match any other file, on either platform', () => { + const others = [ + '/home/dev/repo/src/renderer/src/components/notebook/item/ItemList.tsx', + '/home/dev/repo/src/renderer/src/components/notebook/chat/MessageList.tsx', + 'C:\\dev\\repo\\src\\renderer\\src\\components\\notebook\\item\\ItemList.tsx', + 'C:\\dev\\repo\\src\\renderer\\src\\components\\notebook\\mindmap\\CustomNode.tsx.bak', + '' + ] + for (const file of others) { + assert.equal(isChartSurfaceAllowed(file), false, file) + } +}) + +test('a missing path is not allowed', () => { + assert.equal(isChartSurfaceAllowed(undefined), false) + assert.equal(isChartSurfaceAllowed(null), false) +}) + +test('the guard still reports its rules and exits clean on the repository', () => { + // Read-only end-to-end check: `--list` must name every rule, so deleting one + // silently is not possible, and a plain run must stay green. + const list = execFileSync('node', ['scripts/check-design-tokens.mjs', '--list'], { + encoding: 'utf8' + }) + for (const id of [ + 'radius', + 'shadow', + 'palette', + 'text-level', + 'chart-scope', + 'raw-colour-in-style', + 'css-radius' + ]) { + assert.ok(list.includes(id), `--list should mention the ${id} rule`) + } + + const run = execFileSync('node', ['scripts/check-design-tokens.mjs'], { encoding: 'utf8' }) + assert.match(run, /check:design — no violations\./) +}) diff --git a/test/panelGeometry.test.ts b/test/panelGeometry.test.ts new file mode 100644 index 0000000..006ec13 --- /dev/null +++ b/test/panelGeometry.test.ts @@ -0,0 +1,159 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + CONTAINER_PADDING_X, + DEFAULT_LEFT_WIDTH, + DEFAULT_RIGHT_WIDTH, + HANDLE_WIDTH, + KEYBOARD_STEP, + KEYBOARD_STEP_FAST, + MIN_CENTER_WIDTH, + MIN_SIDE_WIDTH, + availableFrom, + calculateGoldenRatioWidths, + isResizeKey, + maxSideWidth, + nextPanelWidth, + parseStoredWidths +} from '../src/renderer/src/components/layouts/panelGeometry.ts' + +/** + * These exist because the resize arithmetic was only reachable by dragging a + * mouse, and it was wrong in a way nothing could catch: `ArrowLeft` grew the left + * panel and shrank the right one, so the two seams told a screen-reader user the + * opposite of what they had pressed. It passed typecheck and lint. The second + * critique run found it; this file is what should have found it. + */ + +const WIDE = 1400 + +test('available space subtracts the container padding and both seams', () => { + assert.equal(availableFrom(WIDE), WIDE - CONTAINER_PADDING_X - HANDLE_WIDTH * 2) +}) + +test('available space never goes negative in a tiny container', () => { + assert.equal(availableFrom(4), 0) + assert.equal(availableFrom(0), 0) +}) + +test('a side panel may not squeeze the centre below its minimum', () => { + const other = 300 + assert.equal(maxSideWidth(other, WIDE), availableFrom(WIDE) - other - MIN_CENTER_WIDTH) +}) + +test('a side panel keeps its own minimum when the window is too narrow', () => { + // 700px cannot hold 260 + 420 + 260. The sides hold their minimum and the + // centre absorbs the shortfall rather than a side being clamped to nothing. + assert.equal(maxSideWidth(300, 700), MIN_SIDE_WIDTH) +}) + +test('the golden ratio split is symmetric and divides the free space', () => { + const { left, right } = calculateGoldenRatioWidths(WIDE) + assert.equal(left, right) + assert.ok(left > 0) + assert.ok(left < availableFrom(WIDE) / 2, 'a side is narrower than half the free space') +}) + +test('isResizeKey accepts exactly the four resize keys', () => { + for (const key of ['ArrowLeft', 'ArrowRight', 'Home', 'End']) { + assert.equal(isResizeKey(key), true, key) + } + for (const key of ['ArrowUp', 'ArrowDown', 'Enter', 'Escape', 'a', '']) { + assert.equal(isResizeKey(key), false, key) + } +}) + +test('arrow keys move the seam: the left panel grows with ArrowRight', () => { + const current = 400 + const max = 900 + assert.equal(nextPanelWidth('ArrowRight', 'left', current, max, false), current + KEYBOARD_STEP) + assert.equal(nextPanelWidth('ArrowLeft', 'left', current, max, false), current - KEYBOARD_STEP) +}) + +test('arrow keys move the seam: the right panel grows with ArrowLeft', () => { + const current = 400 + const max = 900 + assert.equal(nextPanelWidth('ArrowLeft', 'right', current, max, false), current + KEYBOARD_STEP) + assert.equal(nextPanelWidth('ArrowRight', 'right', current, max, false), current - KEYBOARD_STEP) +}) + +test('the same arrow key moves the two seams in opposite directions', () => { + // The regression: both seams moved the same way, so the value a user heard + // changed opposite to the key they pressed on one of them. + const current = 400 + const max = 900 + const leftDelta = nextPanelWidth('ArrowRight', 'left', current, max, false) - current + const rightDelta = nextPanelWidth('ArrowRight', 'right', current, max, false) - current + assert.equal(leftDelta, -rightDelta) +}) + +test('Shift uses the larger step on both seams', () => { + assert.equal(nextPanelWidth('ArrowRight', 'left', 400, 900, true), 400 + KEYBOARD_STEP_FAST) + assert.equal(nextPanelWidth('ArrowLeft', 'right', 400, 900, true), 400 + KEYBOARD_STEP_FAST) +}) + +test('Home and End go to the minimum and maximum of the value on both seams', () => { + for (const side of ['left', 'right'] as const) { + assert.equal(nextPanelWidth('Home', side, 400, 900, false), MIN_SIDE_WIDTH, side) + assert.equal(nextPanelWidth('End', side, 400, 900, false), 900, side) + } +}) + +test('a keyboard resize stays inside the constraints', () => { + const max = 900 + for (const side of ['left', 'right'] as const) { + for (const key of ['ArrowLeft', 'ArrowRight', 'Home', 'End'] as const) { + for (const current of [0, MIN_SIDE_WIDTH, 400, max, 10_000]) { + const next = nextPanelWidth(key, side, current, max, false) + assert.ok(next >= MIN_SIDE_WIDTH, `${side} ${key} from ${current} -> ${next}`) + assert.ok(next <= max, `${side} ${key} from ${current} -> ${next}`) + } + } + } +}) + +test('a max below the minimum cannot invert the clamp', () => { + // maxSideWidth floors at MIN_SIDE_WIDTH, so this cannot happen in the app — but + // the clamp must not produce a width below the minimum if it ever did. + const next = nextPanelWidth('End', 'left', 400, MIN_SIDE_WIDTH - 100, false) + assert.equal(next, MIN_SIDE_WIDTH) +}) + +test('stored widths are parsed only when both sides are finite numbers', () => { + assert.deepEqual(parseStoredWidths('{"left":320,"right":360}'), { left: 320, right: 360 }) + + // absent, corrupt, or structurally wrong: all treated as "nothing stored" + for (const raw of [ + null, + '', + 'not json', + '{}', + 'null', + '"a string"', + '42', + '[1,2]', + '{"left":320}', + '{"left":320,"right":"360"}', + '{"left":null,"right":360}', + '{"left":320,"right":null}' + ]) { + assert.equal(parseStoredWidths(raw), null, String(raw)) + } +}) + +test('stored widths reject NaN and Infinity, which JSON cannot express but a bug can', () => { + // JSON.parse turns bare `NaN` into a parse error and `1e999` into Infinity. + assert.equal(parseStoredWidths('{"left":NaN,"right":360}'), null) + assert.equal(parseStoredWidths('{"left":1e999,"right":360}'), null) + assert.equal(parseStoredWidths('{"left":-1e999,"right":360}'), null) +}) + +test('stored widths accept zero, which is a collapsed panel rather than a corrupt entry', () => { + assert.deepEqual(parseStoredWidths('{"left":0,"right":360}'), { left: 0, right: 360 }) +}) + +test('the documented fallbacks are usable widths', () => { + for (const value of [DEFAULT_LEFT_WIDTH, DEFAULT_RIGHT_WIDTH, MIN_SIDE_WIDTH]) { + assert.ok(Number.isFinite(value) && value >= MIN_SIDE_WIDTH, String(value)) + } +})