From 86c99037fb20e4292fc03552cfb30cb759b3ff2a Mon Sep 17 00:00:00 2001 From: lunaqiu Date: Fri, 28 Aug 2026 17:23:16 +0800 Subject: [PATCH 1/4] fix(prompt): stop agents from pinning note heights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A note whose CREATE_NODES entry carries a numeric height is born with `heightMode: 'fixed'`, and both the mounted ResizeObserver and the offscreen prewarm queue skip pinned notes — so the node keeps that box for life and truncates its content as it grows. `layout-recipes.md` was telling agents to do exactly that for long or multi-section notes, which made the longest notes the most likely to be stranded. Tell agents to leave a note's height to Huabu, and settle the same contradiction in the external-agent guide, which already described the note as content-driven two lines earlier. Also repair `note-auto-height.spec.ts`'s mounted-editing case: it assumed the preview workspace opens itself on creation, which stopped being true with the unified preview workspace. Drive the shipped path (select, Expand, edit, Escape) instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/prompt/agent-loader.test.ts | 2 +- .../src/prompt/external-agent/access-huabu.md | 2 +- .../skills/space/references/layout-recipes.md | 2 +- apps/web/e2e/note-auto-height.spec.ts | 15 +++++++++++++-- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/server/src/prompt/agent-loader.test.ts b/apps/server/src/prompt/agent-loader.test.ts index 04cf735e4..6d8aa5e34 100644 --- a/apps/server/src/prompt/agent-loader.test.ts +++ b/apps/server/src/prompt/agent-loader.test.ts @@ -63,7 +63,7 @@ describe('renderTemplate {{include}}', () => { expect(out).toContain('nearNode: { id: ""'); expect(out).toContain('omit `size`'); - expect(out).toContain('Reserve `height: "auto"` for short Notes'); + expect(out).toContain("**Never pin a `note`'s height.**"); }); it('throws when the included path escapes PROMPT_ROOT', () => { diff --git a/apps/server/src/prompt/external-agent/access-huabu.md b/apps/server/src/prompt/external-agent/access-huabu.md index 584c6f979..eaf72e427 100644 --- a/apps/server/src/prompt/external-agent/access-huabu.md +++ b/apps/server/src/prompt/external-agent/access-huabu.md @@ -157,7 +157,7 @@ Before placing or resizing relative to existing content, query the relevant node | `frame` | 400 × 300px | | `question` | 200px wide; content-driven height (80px nominal layout height) | -Never pin top-level height for `text` or `question`; change rendered text scale with `data.style.fontSize`. Notes normally auto-size by content but may use an explicit fixed height. For free-form root or frame layouts, a useful starting heuristic is about 50px between nodes and 40px frame padding, adjusted to the actual queried sizes. +Never pin top-level height for `text`, `question`, or `note`; change rendered text scale with `data.style.fontSize`. A `note` sizes itself from its rendered Markdown, and passing a numeric `height` records it as user-pinned — it then keeps that box permanently and truncates its content instead of growing. Omit `height` (or pass `height: "auto"`); setting only `width` is fine. For free-form root or frame layouts, a useful starting heuristic is about 50px between nodes and 40px frame padding, adjusted to the actual queried sizes. Frames default to `free` layout, where child positions remain explicit and parent-local. `column`, `row`, and `grid` are structured layouts configured through `SET_FRAME_LAYOUT`; `gridCount` selects 1–12 tracks (columns for `column` and `grid`, rows for `row`) and defaults to 1. `column` and `row` are masonry: each track stacks independently, so a track holding fewer items pulls its next item up. `grid` additionally aligns rows: every child holds a cell, and a column with no child in a row leaves that cell blank, which is what keeps items in different columns side by side when one column has no counterpart. Structured frames compute final child geometry, so child `position` is only an ordering hint — in `grid` mode it does not decide rows at all. Use the same command's `cells` array to place children: `column` addresses columns, `row` addresses rows, `grid` addresses both. Their default `hug` sizing fits the frame to its content; `manual` preserves a pinned frame size while children still reflow and may overflow. Fetch the `SET_FRAME_LAYOUT` capability before using sizing or track options. diff --git a/apps/server/src/prompt/skills/space/references/layout-recipes.md b/apps/server/src/prompt/skills/space/references/layout-recipes.md index c157d81a3..d2db0793f 100644 --- a/apps/server/src/prompt/skills/space/references/layout-recipes.md +++ b/apps/server/src/prompt/skills/space/references/layout-recipes.md @@ -14,7 +14,7 @@ Opinionated layouts for structured diagrams (architecture diagrams, flowcharts, - In an existing layout, inspect the intended anchor or cluster before setting `size`. With an anchor id, the fastest path is one call: `inspect_nodes({ nearNode: { id: "", sameParent: true, maxCount: 5 }, byType: "" })`. Results include `size`; no separate anchor inspection or whole-Space outline is needed. Match peers with the same semantic role and use their median dimensions rather than copying an outlier. - Without an anchor id but with an intended absolute position, use `inspect_nodes({ nearPoint: { x, y, maxCount: 5 }, byType: "" })`. If neither query returns a comparable peer, do not spend another read trying to manufacture one. - If there is no comparable peer, omit `size` and let Huabu use the node type's canonical default. Do not invent an oversized box merely because the new node has more content. -- For a long or multi-section `note`, prefer a fixed numeric height so the canvas remains scannable: match nearby Notes when available; otherwise use `size: { width: 400, height: 400 }`. Reserve `height: "auto"` for short Notes whose full inline expansion is intentional. +- **Never pin a `note`'s height.** Omit `height` entirely (or pass `height: "auto"`) and let Huabu measure the rendered Markdown and size the node itself. A numeric height records the note as user-pinned, which permanently opts it out of re-measurement — the note then keeps that box forever and truncates its content as it grows. Setting only `width` is fine when you need to match nearby Notes. - `text` and `question` heights are always content-driven; never set their height. For `image`, match a comparable peer's width and omit height so Huabu derives it from the source aspect ratio. For `video`, match both dimensions of a comparable Video or omit `size` entirely. ## Positioning patterns diff --git a/apps/web/e2e/note-auto-height.spec.ts b/apps/web/e2e/note-auto-height.spec.ts index cab6f4814..f276b9b20 100644 --- a/apps/web/e2e/note-auto-height.spec.ts +++ b/apps/web/e2e/note-auto-height.spec.ts @@ -258,10 +258,17 @@ test.describe('note auto height', () => { parseFloat((element as HTMLElement).style.height), ); + // The preview workspace no longer opens itself when a note is + // created, so drive the shipped path: select the note, then hit + // Expand on its floating toolbar. + await note.click({ force: true }); + await page.getByRole('button', { name: 'Expand', exact: true }).click(); + await expect(page.locator('[data-search-scope="node"]')).toHaveCount(1); + const editor = page.locator( '[data-search-scope="node"] .ProseMirror[contenteditable="true"]', ); - await expect(editor).toHaveCount(1); + await expect(editor).toHaveCount(1, { timeout: 15_000 }); const longContent = Array.from( { length: 12 }, @@ -269,7 +276,11 @@ test.describe('note auto height', () => { `Section ${index + 1}. This manually edited paragraph is long enough to wrap and must expand the mounted note.`, ).join('\n\n'); await editor.fill(longContent); - await page.getByRole('button', { name: 'Close', exact: true }).click(); + // Leave the preview the way a user does. The close control is only + // rendered when the panel is not embedded, so drive the keyboard + // path, which works in both presentations. + await page.keyboard.press('Escape'); + await expect(page.locator('[data-search-scope="node"]')).toHaveCount(0); await expect .poll(() => From ffbea792d7685ac498599ff7319d2600f096c939 Mon Sep 17 00:00:00 2001 From: lunaqiu Date: Sat, 29 Aug 2026 20:10:55 +0800 Subject: [PATCH 2/4] feat(note): make notes readable by default and collapse very long ones Two of the three note-sizing complaints had the same shape: the box a note settles into was chosen for an empty note, not a written one. P1 - readable minimum. `note.minIntrinsicHeight` was 50, which renders as a 56px card: one line of text and a sliver of the next. Raise it to 244 (248px at the reference width). This is the only knob that reaches the rendered height; `nodeSizes.ts` never does, because `getNodeCreationStyle` drops the nominal height for auto-by-default types. Its stale 56 is corrected to match so the two stop disagreeing. P2 - collapse on create. A note past 800 characters is a document, not a note: auto height grows it to thousands of pixels and buries its neighbours at the moment it holds the most information. Such a note is now born pinned to the collapsed height, which is deliberately the same number a short note settles at - one definition of 'small', not two. Author preference wins: a numeric height pins as before, and an explicit `height: 'auto'` means 'expand this one', so only a caller who expressed no preference gets the heuristic. The rule lives in `policy.ts` so the headless engine and the web client cannot disagree and produce a geometry write on every load. The bottom chevron becomes a control again, so collapsing is not a trap. It was made inert for a good reason - a full-width hit target sitting where selection and resize gestures land - so the container stays `pointer-events-none` and only a 24x24 button opts back in, with `nodrag nopan`. It also appears on a selected, already-expanded long note, which truncation alone would never reveal. `HEIGHT_LAYOUT_VERSION` is bumped because the rendered height of unchanged content changed; without it every existing workspace keeps its old heights. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/prompt/external-agent/access-huabu.md | 4 +- .../skills/space/references/layout-recipes.md | 2 +- apps/web/e2e/note-auto-height.spec.ts | 63 ++++++++++++++++ .../src/components/Nodes/note/NoteNode.tsx | 72 ++++++++++++++++--- apps/web/src/i18n/resources/en/common.json | 2 + apps/web/src/i18n/resources/zh-CN/common.json | 2 + docs/architecture/node-auto-height.md | 8 ++- .../__tests__/createNodes.selection.test.ts | 2 +- .../__tests__/heightAuthority.test.ts | 4 +- .../src/canvas-engine/commands/createNodes.ts | 28 +++++++- .../height/__tests__/height.test.ts | 49 +++++++++++-- .../src/canvas-engine/height/compute.ts | 15 ++++ .../src/canvas-engine/height/freshness.ts | 2 +- .../shared/src/canvas-engine/height/index.ts | 3 + .../shared/src/canvas-engine/height/policy.ts | 50 ++++++++++++- packages/shared/src/canvas-engine/index.ts | 3 + .../src/canvas-engine/utils/nodeSizes.ts | 13 ++-- 17 files changed, 287 insertions(+), 35 deletions(-) diff --git a/apps/server/src/prompt/external-agent/access-huabu.md b/apps/server/src/prompt/external-agent/access-huabu.md index eaf72e427..48fd8d5d8 100644 --- a/apps/server/src/prompt/external-agent/access-huabu.md +++ b/apps/server/src/prompt/external-agent/access-huabu.md @@ -150,14 +150,14 @@ Before placing or resizing relative to existing content, query the relevant node | Node type | Default geometry | | ---------------------- | ------------------------------------------------------------------------------------ | | `text` | 200px wide; content-driven height | -| `note` | 400px wide; content-driven height (56px nominal layout height) | +| `note` | 400px wide; content-driven height (248px nominal layout height) | | `web`, `pdf`, `office` | 400 × 400px | | `video` | 400 × 300px | | `image` | 400px wide; height follows the source aspect ratio (300px nominal before resolution) | | `frame` | 400 × 300px | | `question` | 200px wide; content-driven height (80px nominal layout height) | -Never pin top-level height for `text`, `question`, or `note`; change rendered text scale with `data.style.fontSize`. A `note` sizes itself from its rendered Markdown, and passing a numeric `height` records it as user-pinned — it then keeps that box permanently and truncates its content instead of growing. Omit `height` (or pass `height: "auto"`); setting only `width` is fine. For free-form root or frame layouts, a useful starting heuristic is about 50px between nodes and 40px frame padding, adjusted to the actual queried sizes. +Never pin top-level height for `text`, `question`, or `note`; change rendered text scale with `data.style.fontSize`. A `note` sizes itself from its rendered Markdown, and passing a numeric `height` records it as user-pinned — it then keeps that box permanently and truncates its content instead of growing. Omit `height` entirely; setting only `width` is fine. Omitting it also lets Huabu start a very long note collapsed, with a one-click expand chevron, so it does not bury its neighbours; pass `height: "auto"` only when you want that note expanded to its full length no matter how long it is. For free-form root or frame layouts, a useful starting heuristic is about 50px between nodes and 40px frame padding, adjusted to the actual queried sizes. Frames default to `free` layout, where child positions remain explicit and parent-local. `column`, `row`, and `grid` are structured layouts configured through `SET_FRAME_LAYOUT`; `gridCount` selects 1–12 tracks (columns for `column` and `grid`, rows for `row`) and defaults to 1. `column` and `row` are masonry: each track stacks independently, so a track holding fewer items pulls its next item up. `grid` additionally aligns rows: every child holds a cell, and a column with no child in a row leaves that cell blank, which is what keeps items in different columns side by side when one column has no counterpart. Structured frames compute final child geometry, so child `position` is only an ordering hint — in `grid` mode it does not decide rows at all. Use the same command's `cells` array to place children: `column` addresses columns, `row` addresses rows, `grid` addresses both. Their default `hug` sizing fits the frame to its content; `manual` preserves a pinned frame size while children still reflow and may overflow. Fetch the `SET_FRAME_LAYOUT` capability before using sizing or track options. diff --git a/apps/server/src/prompt/skills/space/references/layout-recipes.md b/apps/server/src/prompt/skills/space/references/layout-recipes.md index d2db0793f..bdf8f786e 100644 --- a/apps/server/src/prompt/skills/space/references/layout-recipes.md +++ b/apps/server/src/prompt/skills/space/references/layout-recipes.md @@ -14,7 +14,7 @@ Opinionated layouts for structured diagrams (architecture diagrams, flowcharts, - In an existing layout, inspect the intended anchor or cluster before setting `size`. With an anchor id, the fastest path is one call: `inspect_nodes({ nearNode: { id: "", sameParent: true, maxCount: 5 }, byType: "" })`. Results include `size`; no separate anchor inspection or whole-Space outline is needed. Match peers with the same semantic role and use their median dimensions rather than copying an outlier. - Without an anchor id but with an intended absolute position, use `inspect_nodes({ nearPoint: { x, y, maxCount: 5 }, byType: "" })`. If neither query returns a comparable peer, do not spend another read trying to manufacture one. - If there is no comparable peer, omit `size` and let Huabu use the node type's canonical default. Do not invent an oversized box merely because the new node has more content. -- **Never pin a `note`'s height.** Omit `height` entirely (or pass `height: "auto"`) and let Huabu measure the rendered Markdown and size the node itself. A numeric height records the note as user-pinned, which permanently opts it out of re-measurement — the note then keeps that box forever and truncates its content as it grows. Setting only `width` is fine when you need to match nearby Notes. +- **Never pin a `note`'s height.** Omit `height` entirely and let Huabu measure the rendered Markdown and size the node itself. A numeric height records the note as user-pinned, which permanently opts it out of re-measurement — the note then keeps that box forever and truncates its content as it grows. Omitting `height` also lets Huabu start a very long note collapsed, with a one-click expand chevron, so a document-length note does not bury its neighbours; pass `height: "auto"` only when that note should be fully expanded regardless of length. Setting only `width` is fine when you need to match nearby Notes. - `text` and `question` heights are always content-driven; never set their height. For `image`, match a comparable peer's width and omit height so Huabu derives it from the source aspect ratio. For `video`, match both dimensions of a comparable Video or omit `size` entirely. ## Positioning patterns diff --git a/apps/web/e2e/note-auto-height.spec.ts b/apps/web/e2e/note-auto-height.spec.ts index f276b9b20..754ad6dad 100644 --- a/apps/web/e2e/note-auto-height.spec.ts +++ b/apps/web/e2e/note-auto-height.spec.ts @@ -294,6 +294,69 @@ test.describe('note auto height', () => { expect(overflow).toBeLessThanOrEqual(1); }); + test('a long agent note is born collapsed and expands from its chevron', async ({ + page, + }) => { + // The two halves of the same promise: a document-length note must + // not bury the canvas it lands on, and the way back out must be one + // click away. Only meaningful together — collapsing without a + // reachable expand affordance is a trap. + await openNewCanvas(page); + const markdown = Array.from( + { length: 20 }, + (_, index) => + `## Section ${index + 1}\n\nThis paragraph is long enough to wrap at the note width and would make an auto-height note thousands of pixels tall.`, + ).join('\n\n'); + + // No `size` at all: the collapse heuristic only applies when the + // caller expressed no height preference, so the shared + // `createAgentNote` helper (which passes `height: 'auto'`) would + // deliberately opt out. + await executeAgentCommands(page, [ + { + type: 'CREATE_NODES', + nodes: [ + { + nodeType: 'note', + data: { label: 'Agent document', content: markdown }, + position: { x: 100, y: 100 }, + }, + ], + }, + ]); + + const note = page.locator('.react-flow__node-note'); + await expect(note.locator('.ProseMirror')).toHaveCount(1); + + // This project emulates a touch device, where the pen tool is armed + // by default and its overlay owns every canvas pointer event. Leave + // that mode the way a user does, so the click below reaches the node + // rather than the sketch surface. + await page.keyboard.press('Escape'); + + const heightOf = () => + note.evaluate((element) => + parseFloat((element as HTMLElement).style.height), + ); + + // Give any stray measurement a chance to land before asserting the + // height stayed put — a collapsed note is pinned, so nothing should. + await page.waitForTimeout(1500); + const collapsedHeight = await heightOf(); + expect(collapsedHeight).toBeLessThan(400); + expect(collapsedHeight).toBeGreaterThan(200); + + await note.click({ force: true }); + await page + .getByRole('button', { name: 'Show the whole note', exact: true }) + .click(); + + await expect.poll(heightOf).toBeGreaterThan(collapsedHeight + 500); + + const [overflow] = await measureOverflows(page); + expect(overflow).toBeLessThanOrEqual(1); + }); + test('every auto note fits the content it was measured from', async ({ page, }) => { diff --git a/apps/web/src/components/Nodes/note/NoteNode.tsx b/apps/web/src/components/Nodes/note/NoteNode.tsx index 221c93626..84bc17eb4 100644 --- a/apps/web/src/components/Nodes/note/NoteNode.tsx +++ b/apps/web/src/components/Nodes/note/NoteNode.tsx @@ -3,11 +3,14 @@ import { type Node, type NodeProps, useStore } from '@xyflow/react'; import clsx from 'clsx'; -import { ChevronsDown, Fullscreen } from 'lucide-react'; +import { ChevronsDown, ChevronsUp, Fullscreen } from 'lucide-react'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { autoHeightKey } from '@huabu/shared/canvas-engine'; +import { + autoHeightKey, + NOTE_COLLAPSE_CONTENT_THRESHOLD, +} from '@huabu/shared/canvas-engine'; import { FloatingToolbar } from '@/components/Common/FloatingToolbar'; import { Loading } from '@/components/Common/Loading'; @@ -69,6 +72,7 @@ export const NoteNode = memo( ({ id, data, selected }: NodeProps) => { const { t } = useTranslation(); const updateNodeData = useCanvasStore((s) => s.updateNodeData); + const setNoteHeightMode = useCanvasStore((s) => s.setNoteHeightMode); const moveNoteBlockIntoNote = useCanvasStore( (s) => s.moveNoteBlockIntoNote, ); @@ -222,6 +226,23 @@ export const NoteNode = memo( const isTruncated = contentHeight > 0 && hostHeight > 0 && contentHeight - hostHeight > 1; + // An expanded note has nothing hidden, so `isTruncated` is false and + // the fade never appears — but the user still needs a way back. Show + // the collapse control on an expanded note whose content is long + // enough that it would have been collapsed at birth, and only while + // it is selected, so an unselected canvas stays free of chrome. + const showCollapseControl = + !isFixedHeight && + selected && + markdown.length > NOTE_COLLAPSE_CONTENT_THRESHOLD; + + // A name of its own rather than the toolbar's "Switch to fixed + // height": both controls can be on screen at once, and two buttons + // sharing an accessible name is ambiguous to a screen reader. + const heightToggleLabel = isFixedHeight + ? t('node.showFullNote') + : t('node.collapseNote'); + // Report the measured intrinsic height as a *proposal*. The queue // decides whether it is worth committing and when; the engine owns // the conversion to a layout height and the write to `style.height`. @@ -487,24 +508,53 @@ export const NoteNode = memo( )} - {isTruncated && ( + {(isTruncated || showCollapseControl) && (
- {/* Fade gradient */} -
+ {isTruncated && ( +
+ )}
- +
)} diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 0d3ea5d7d..6d74b5dbd 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -419,6 +419,8 @@ }, "node": { "untitled": "Untitled", + "showFullNote": "Show the whole note", + "collapseNote": "Collapse this note", "rename": "Rename node", "preview": "Preview", "navigateUpstream": "Navigate to a source node ({{shortcut}})", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index 0e3644ad1..7265717fe 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -419,6 +419,8 @@ }, "node": { "untitled": "未命名", + "showFullNote": "展开完整笔记", + "collapseNote": "折叠这条笔记", "rename": "重命名节点", "preview": "预览", "navigateUpstream": "导航到来源节点({{shortcut}})", diff --git a/docs/architecture/node-auto-height.md b/docs/architecture/node-auto-height.md index 57cb38108..61913afa0 100644 --- a/docs/architecture/node-auto-height.md +++ b/docs/architecture/node-auto-height.md @@ -21,6 +21,8 @@ The governing rule is that **rendering never causes a geometry change**. Zoom, p Intrinsic → layout is one pure function, [`intrinsicToLayoutHeight`](../../packages/shared/src/canvas-engine/height/compute.ts): clamp to the type's minimum, scale by the node's width, add the node shell's chrome, quantize to a 4 px step. The order mirrors the DOM — the minimum applies unscaled, the chrome is outside the scaled container and so is added after. +`note`'s minimum is a **reading** floor, not an anti-collapse guard. It is 244 intrinsic, which renders as 248 px at the reference width — deliberately generous. The previous value of 50 produced a 56 px card: one line of text and a sliver of the next, which is the box a brand-new note appears in and the box a collapsed note falls back to, and it is unreadable for anything already written. This is also the single knob for "notes are too short"; nothing in `nodeSizes.ts` reaches the rendered height, because `getNodeCreationStyle` drops the nominal height for auto-by-default types. + The scale divides the node's **content** width, its box minus the shell border, so the logical layout width lands on `refWidth` exactly at every node size. That is the premise the whole hint cache rests on: content measured at one node width wraps identically at any other. A legibility floor on the scale would break it — once engaged, the content stops shrinking and starts laying out _narrower_ than the reference, so `note` deliberately has none. Semantic zoom already replaces a tiny note's body with a placeholder long before its text would become unreadable. `HeightPolicy.minContentScale` carries the floor for the `manual` types, whose box is the user's and whose scale is therefore purely a rendering decision. ## 3. Ownership @@ -39,7 +41,9 @@ The scale divides the node's **content** width, its box minus the shell border, A resize gesture on an auto note pins it to `fixed`. That flip is implicit, so the toolbar's auto indicator reflects it at gesture end. -The `fixed → auto` flip is reachable **only** from the node toolbar (and the multi-select toolbar). A truncated note draws a fade + chevron along its bottom edge, but that is a hint, not a control: as a click target it spanned the card's full width right where selection and resize gestures land, so it fired by accident far more often than on purpose. +The `fixed ↔ auto` flip is reachable from the node toolbar (and the multi-select toolbar) and from the chevron a note draws along its bottom edge. That chevron was previously inert, and for a reason worth preserving: as a click target it spanned the card's full width right where selection and resize gestures land, so it fired by accident far more often than on purpose. It is now a control again, but a narrow one — the full-width container stays `pointer-events-none` and only a 24×24 button opts back in, carrying `nodrag nopan` so React Flow cannot read the press as the start of a drag or pan. Without a reachable expand affordance the collapse-on-create rule in §8 would be a trap rather than a default. + +The chevron renders when the note is truncated (pointing down: expand) and also on a selected, already-expanded note whose content is past the collapse threshold (pointing up: collapse). The second case exists because an expanded note hides nothing, so truncation alone would leave no way back. ## 4. Freshness @@ -101,6 +105,8 @@ measure ────────▶ propose → gate → APPLY_MEASURED_HEIGHT **Creation** records `heightMode` and materializes immediately, so the same batch's `fitFrames` and grid solver see a real footprint rather than zero. +A note created with content past [`NOTE_COLLAPSE_CONTENT_THRESHOLD`](../../packages/shared/src/canvas-engine/height/policy.ts) is born **collapsed**: pinned to `collapsedLayoutHeight`, which is the same number a short note settles at, so a collapsed long note and a one-line note are one size rather than two competing definitions of "small". Auto height is right for a note you can take in at a glance; past roughly a screenful the node grows to thousands of pixels and buries its neighbours at the moment it holds the most information. The rule is consulted only when the caller expressed no height preference at all — a numeric height pins as always, and an explicit `'auto'` is the author saying "expand this one", which outranks the heuristic. It lives in `policy.ts`, not in a creation helper, so the headless engine and the web client cannot disagree and produce a geometry write on every load. + **Load** runs [`normalizeNodeHeights`](../../apps/web/src/store/canvasStore/load/normalizeNodeHeights.ts) — write the inferred owner explicitly, then materialize — and then [`warmupNodeHeights`](../../apps/web/src/store/canvasStore/load/warmupNodeHeights.ts), which measures never-measured notes _before_ the canvas is shown, nearest to the restored viewport first, under a wall-clock budget. Warmup applies the completed measurements to the fetched snapshot through the pure canvas executor, so `APPLY_MEASURED_HEIGHT` performs the same freshness checks and parent Frame/Portal relayout as an interactive measurement without dispatching through the web store or creating load-time history. Normalization never writes a hint; a canvas saved before this model existed would otherwise paint a wall of collapsed cards and expand them one by one. **Prewarming** ([`prewarmQueue.ts`](../../apps/web/src/components/Nodes/shared/height/measure/prewarmQueue.ts)) then measures the rest on idle, ordered by distance from the viewport with never-measured notes ahead of stale ones. Each candidate carries the key of the content it is about to measure, captured before the async work starts, and the commit is dropped if the node's key moved meanwhile. diff --git a/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts b/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts index 183d8e997..09a9e1b23 100644 --- a/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts +++ b/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts @@ -51,7 +51,7 @@ describe('CREATE_NODES selection', () => { // Materialized from the note policy's minimum, not pinned: a // created note must have a real footprint for the same-batch frame // fit and grid solver, and its ownership is recorded explicitly. - height: 56, + height: 248, }); expect( ( diff --git a/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts b/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts index 79a8a3e20..4b1f4db0e 100644 --- a/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts +++ b/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts @@ -98,7 +98,7 @@ describe("SET_NODE_GEOMETRY height: 'auto'", () => { type: 'note', content: CONTENT, heightMode: 'auto', - autoHeight: { intrinsicHeight: 200, measuredFor: KEY }, + autoHeight: { intrinsicHeight: 400, measuredFor: KEY }, }, } as Partial); @@ -114,7 +114,7 @@ describe("SET_NODE_GEOMETRY height: 'auto'", () => { expect(styleOf(writeResult.nodes, 'n1')).toEqual({ width: 800, - height: 404, + height: 800, }); }); diff --git a/packages/shared/src/canvas-engine/commands/createNodes.ts b/packages/shared/src/canvas-engine/commands/createNodes.ts index 604414081..3dee9467d 100644 --- a/packages/shared/src/canvas-engine/commands/createNodes.ts +++ b/packages/shared/src/canvas-engine/commands/createNodes.ts @@ -3,8 +3,12 @@ import { noop, type CommandDefinition } from './types.js'; import { createId, type CanvasCommand } from '../../index.js'; +import { collapsedLayoutHeight } from '../height/compute.js'; import { materializeAutoHeight } from '../height/materialize.js'; -import { getHeightPolicy } from '../height/policy.js'; +import { + getHeightPolicy, + shouldCollapseNoteOnCreate, +} from '../height/policy.js'; import { deduplicateLabel, generateNextLabel } from '../utils/labels.js'; import { getNodeCreationStyle, @@ -119,8 +123,23 @@ const createNodes: CommandDefinition = { // that number as a pinned height on the next load. const isToggleableHeight = getHeightPolicy(nodeType).kind === 'toggleable'; + + // A note long enough to bury its neighbours starts collapsed at + // the policy minimum rather than auto-sized to its full content. + // Only when the caller expressed no height preference at all: a + // number pins as always, and an explicit `'auto'` is the author + // saying "expand this one", which outranks the heuristic. + const collapseOnCreate = + explicitSize?.height === undefined && + shouldCollapseNoteOnCreate(nodeType, inputData.content); + + const collapsedHeight = collapseOnCreate + ? collapsedLayoutHeight(nodeType, geometryStyle.width) + : undefined; + const heightMode = isToggleableHeight - ? typeof geometryStyle.height === 'number' + ? typeof geometryStyle.height === 'number' || + collapsedHeight !== undefined ? 'fixed' : 'auto' : undefined; @@ -138,7 +157,10 @@ const createNodes: CommandDefinition = { label, type: nodeType, }, - style: geometryStyle, + style: + collapsedHeight !== undefined + ? { ...geometryStyle, height: collapsedHeight } + : geometryStyle, }; // Materialize immediately so the node has a real footprint before diff --git a/packages/shared/src/canvas-engine/height/__tests__/height.test.ts b/packages/shared/src/canvas-engine/height/__tests__/height.test.ts index e8c477195..e913c77c6 100644 --- a/packages/shared/src/canvas-engine/height/__tests__/height.test.ts +++ b/packages/shared/src/canvas-engine/height/__tests__/height.test.ts @@ -11,7 +11,9 @@ import { import { HEIGHT_LAYOUT_VERSION, HEIGHT_QUANTIZATION_STEP, + NOTE_COLLAPSE_CONTENT_THRESHOLD, autoHeightKey, + collapsedLayoutHeight, contentScaleFor, getHeightPolicy, getHeightRefWidth, @@ -23,6 +25,7 @@ import { quantizeHeight, readAutoHeightHint, resolveHeightMode, + shouldCollapseNoteOnCreate, } from '../index.js'; import type { Node } from '@xyflow/react'; @@ -210,15 +213,16 @@ describe('intrinsicToLayoutHeight', () => { // The shell border lives outside the scaled container, so doubling // the width doubles the content but not the 6px chrome. The same // 6px also narrows the content box, which is why the scale at the - // reference width is 394/400 rather than 1. - expect(intrinsicToLayoutHeight(200, 'note', 400)).toBe(204); - expect(intrinsicToLayoutHeight(200, 'note', 800)).toBe(404); + // reference width is 394/400 rather than 1. The intrinsic value is + // above the note minimum so this measures the scaling, not the clamp. + expect(intrinsicToLayoutHeight(400, 'note', 400)).toBe(400); + expect(intrinsicToLayoutHeight(400, 'note', 800)).toBe(800); }); it('applies the minimum before scaling', () => { - // Note minimum is 50 unscaled; at half width the scale clamp is 0.5. - expect(intrinsicToLayoutHeight(10, 'note', 400)).toBe(56); - expect(intrinsicToLayoutHeight(10, 'note', 200)).toBe(32); + // Note minimum is 244 unscaled; at half width the scale halves too. + expect(intrinsicToLayoutHeight(10, 'note', 400)).toBe(248); + expect(intrinsicToLayoutHeight(10, 'note', 200)).toBe(128); }); it('does not scale types without a reference width', () => { @@ -233,7 +237,7 @@ describe('intrinsicToLayoutHeight', () => { // would render short. Semantic zoom, not this, is what keeps a tiny // note readable — it swaps the body for a placeholder. expect(contentScaleFor(getHeightPolicy('note'), 100)).toBeCloseTo(0.235); - expect(intrinsicToLayoutHeight(200, 'note', 100)).toBe(56); + expect(intrinsicToLayoutHeight(800, 'note', 100)).toBe(196); }); it('floors the scale for manual types, whose box the user owns', () => { @@ -460,3 +464,34 @@ describe('materializeAutoHeight', () => { expect(materializeAutoHeights(nodes)).toBe(nodes); }); }); + +describe('collapse on create', () => { + it('collapses only a toggleable type whose content exceeds the threshold', () => { + const long = 'x'.repeat(NOTE_COLLAPSE_CONTENT_THRESHOLD + 1); + const short = 'x'.repeat(NOTE_COLLAPSE_CONTENT_THRESHOLD); + + expect(shouldCollapseNoteOnCreate('note', long)).toBe(true); + expect(shouldCollapseNoteOnCreate('note', short)).toBe(false); + + // `text` and `question` size themselves through a different + // mechanism, and a manual type owns its box outright. + expect(shouldCollapseNoteOnCreate('text', long)).toBe(false); + expect(shouldCollapseNoteOnCreate('image', long)).toBe(false); + }); + + it('ignores content that is not a string', () => { + expect(shouldCollapseNoteOnCreate('note', undefined)).toBe(false); + expect(shouldCollapseNoteOnCreate('note', { text: 'x' })).toBe(false); + }); + + it('collapses to exactly the height a short note settles at', () => { + // One definition of "small", not two: whatever the policy minimum + // renders as is what a collapsed note gets, at any width. + for (const width of [200, 400, 800]) { + expect(collapsedLayoutHeight('note', width)).toBe( + intrinsicToLayoutHeight(0, 'note', width), + ); + } + expect(collapsedLayoutHeight('note', 400)).toBe(248); + }); +}); diff --git a/packages/shared/src/canvas-engine/height/compute.ts b/packages/shared/src/canvas-engine/height/compute.ts index 1baa31e4c..8c2ea8742 100644 --- a/packages/shared/src/canvas-engine/height/compute.ts +++ b/packages/shared/src/canvas-engine/height/compute.ts @@ -106,3 +106,18 @@ export function intrinsicToLayoutHeight( const scaled = clamped * contentScaleFor(policy, width); return quantizeHeight(scaled + (policy.insetY ?? 0)); } + +/** + * Layout height a *collapsed* node occupies: the type's minimum content + * height, converted for the node's own width. + * + * This is deliberately the same number a short note settles at, so a + * collapsed long note and a one-line note are the same size rather than + * two competing definitions of "small". + */ +export function collapsedLayoutHeight( + nodeType: string | undefined, + width: number | undefined, +): number { + return intrinsicToLayoutHeight(0, nodeType, width); +} diff --git a/packages/shared/src/canvas-engine/height/freshness.ts b/packages/shared/src/canvas-engine/height/freshness.ts index 6e06cc5ba..18ec9bb4b 100644 --- a/packages/shared/src/canvas-engine/height/freshness.ts +++ b/packages/shared/src/canvas-engine/height/freshness.ts @@ -29,7 +29,7 @@ import type { Node } from '@xyflow/react'; * measurement rule itself. Every stored hint becomes `stale` on the next * load, which costs one re-measurement per node and nothing else. */ -export const HEIGHT_LAYOUT_VERSION = 4; +export const HEIGHT_LAYOUT_VERSION = 5; /** * Identity of the thing an intrinsic height was measured against. diff --git a/packages/shared/src/canvas-engine/height/index.ts b/packages/shared/src/canvas-engine/height/index.ts index 0cf45d820..a23ccd1d7 100644 --- a/packages/shared/src/canvas-engine/height/index.ts +++ b/packages/shared/src/canvas-engine/height/index.ts @@ -18,15 +18,18 @@ export { type HeightMode, type HeightPolicy, NODE_SHELL_INSET, + NOTE_COLLAPSE_CONTENT_THRESHOLD, getHeightPolicy, getHeightRefWidth, isAlwaysAutoHeightType, isAutoHeightByDefaultType, resolveHeightMode, + shouldCollapseNoteOnCreate, } from './policy.js'; export { HEIGHT_QUANTIZATION_STEP, + collapsedLayoutHeight, contentScaleFor, intrinsicToLayoutHeight, quantizeHeight, diff --git a/packages/shared/src/canvas-engine/height/policy.ts b/packages/shared/src/canvas-engine/height/policy.ts index c0419ebf5..e9987737b 100644 --- a/packages/shared/src/canvas-engine/height/policy.ts +++ b/packages/shared/src/canvas-engine/height/policy.ts @@ -104,6 +104,44 @@ const MANUAL_POLICY: HeightPolicy = { kind: 'manual' }; */ export const NODE_SHELL_INSET = 6; +/** + * Authored content length (characters) past which a newly created note + * is born **collapsed** — pinned to the policy minimum with the expand + * affordance showing — instead of auto-sized to its full content. + * + * Auto height is the right default for a note you can take in at a + * glance. Past roughly a screenful it stops being a note and becomes a + * document: the node grows to thousands of pixels, buries its + * neighbours, and makes the canvas unusable at the very moment it holds + * the most information. Collapsing at birth keeps the canvas scannable + * and costs one click to undo. + * + * Only consulted at creation, and only when the caller expressed no + * height preference at all. An explicit numeric height pins as always, + * and an explicit `'auto'` means "expand this one fully" — the author + * has spoken either way, so neither is second-guessed. + */ +export const NOTE_COLLAPSE_CONTENT_THRESHOLD = 800; + +/** + * Whether a note created with this content should start collapsed. + * + * Lives beside the policy rather than in a creation helper because the + * headless engine and the web client must agree on it exactly: a note + * that the server collapses and the client expands would produce a + * geometry write on every load. + */ +export function shouldCollapseNoteOnCreate( + nodeType: string | undefined, + content: unknown, +): boolean { + if (getHeightPolicy(nodeType).kind !== 'toggleable') return false; + return ( + typeof content === 'string' && + content.length > NOTE_COLLAPSE_CONTENT_THRESHOLD + ); +} + /** * Height policy per node type. Types absent from this table are `manual`. * @@ -114,12 +152,22 @@ export const NODE_SHELL_INSET = 6; const HEIGHT_POLICIES: Readonly> = { // The note body measures `.ProseMirror` plus the host's own vertical // padding, so the only thing left to add is the node shell itself. + // + // `minIntrinsicHeight` is a *reading* floor, not an anti-collapse + // guard. At the old value of 50 a note settled at 56px — one line and + // a sliver of the next — which is unreadable for anything already + // written and is the box a brand-new note starts in. 244 renders as + // 248px at the reference width (quantized to the 4px step), which is + // also the height a long note collapses to, so a short note and a + // collapsed long one agree instead of forming two different "small + // note" sizes. + // // No `minContentScale`: its height is derived from the scale, and a // floor would make the content lay out narrower than `refWidth`. note: { kind: 'toggleable', refWidth: 400, - minIntrinsicHeight: 50, + minIntrinsicHeight: 244, insetY: NODE_SHELL_INSET, }, text: { kind: 'content' }, diff --git a/packages/shared/src/canvas-engine/index.ts b/packages/shared/src/canvas-engine/index.ts index 2dcbcda9b..5abe5dca0 100644 --- a/packages/shared/src/canvas-engine/index.ts +++ b/packages/shared/src/canvas-engine/index.ts @@ -122,7 +122,9 @@ export { HEIGHT_LAYOUT_VERSION, HEIGHT_QUANTIZATION_STEP, NODE_SHELL_INSET, + NOTE_COLLAPSE_CONTENT_THRESHOLD, autoHeightKey, + collapsedLayoutHeight, contentScaleFor, getHeightPolicy, getHeightRefWidth, @@ -135,6 +137,7 @@ export { readAutoHeightHint, resolveAutoLayoutHeight, resolveHeightMode, + shouldCollapseNoteOnCreate, } from './height/index.js'; export { getNodeDefaultSize, diff --git a/packages/shared/src/canvas-engine/utils/nodeSizes.ts b/packages/shared/src/canvas-engine/utils/nodeSizes.ts index 3aae02ab3..1027bee82 100644 --- a/packages/shared/src/canvas-engine/utils/nodeSizes.ts +++ b/packages/shared/src/canvas-engine/utils/nodeSizes.ts @@ -28,11 +28,14 @@ import type { Node } from '@xyflow/react'; // --------------------------------------------------------------------------- const DEFAULT_SIZES: Record = { text: { width: 200 }, - // Note nodes auto-size by content height but have a minimum intrinsic - // height of ~50px (the note policy's `minIntrinsicHeight`) plus borders/padding when empty. - // Use 56px as a nominal default for layout calculations (matches the - // minimum rendered height of an empty note at default zoom). - note: { width: 400, height: 56 }, + // Note nodes auto-size by content height. This height is *nominal*: + // `getNodeCreationStyle` drops it for auto-by-default types, so it is + // never written to `style.height` — it exists for layout calculations + // (viewport centring, placement) that need a footprint before the node + // has been measured. It mirrors the rendered minimum, which the note + // policy's `minIntrinsicHeight` (244) converts to 248px at the + // reference width. Change that constant, not this one. + note: { width: 400, height: 248 }, web: { width: 400, height: 400 }, pdf: { width: 400, height: 400 }, office: { width: 400, height: 400 }, From 8ab0ff46551f9b739d181d992f5df2b49b050c03 Mon Sep 17 00:00:00 2001 From: lunaqiu Date: Sat, 29 Aug 2026 20:39:48 +0800 Subject: [PATCH 3/4] fix(note): split the short-note floor from the collapsed preview height Measured in a real browser at the reference width: one paragraph line is 41px intrinsic and each further line adds 25. That makes the 244 floor shipped in 6f57601f render every note of nine lines or fewer at an identical 248px - it does not make short notes readable, it erases the difference between them. The two numbers were folded into one on the theory that a collapsed long note and a short note should be the same size. The measurement shows why that was wrong: they answer opposite questions. A floor asks how short a genuinely short note may be, and must stay small so a note under the collapse threshold can size itself to its content. A collapsed height is a preview window onto a note too long to show, and must be generous enough to read. So: `minIntrinsicHeight` 244 -> 91 (three lines, renders 96px, up from the 56px that showed one line and a sliver), and a new `collapsedIntrinsicHeight` 244 keeps the collapsed preview at 248px. `collapsedLayoutHeight` reads the latter, still routed through `intrinsicToLayoutHeight` so it scales with node width and can never land below the floor. Also close the width ratchet. Nothing in Huabu ever chooses 460 - agents invent it, because the guidance blessed 'setting only width is fine to match nearby Notes' while giving no canonical anchor. One wide node then becomes the width the next agent matches, and nothing pulls it back. The prompt now states a note is 400px wide, says to omit width unless inspected peers agree otherwise, takes the median rather than the maximum, and names length as the height's business. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/prompt/external-agent/access-huabu.md | 4 +- .../skills/space/references/layout-recipes.md | 5 ++- docs/architecture/node-auto-height.md | 4 +- .../__tests__/createNodes.selection.test.ts | 2 +- .../height/__tests__/height.test.ts | 30 +++++++++------ .../src/canvas-engine/height/compute.ts | 19 +++++++--- .../shared/src/canvas-engine/height/policy.ts | 38 ++++++++++++++----- .../src/canvas-engine/utils/nodeSizes.ts | 6 +-- 8 files changed, 72 insertions(+), 36 deletions(-) diff --git a/apps/server/src/prompt/external-agent/access-huabu.md b/apps/server/src/prompt/external-agent/access-huabu.md index 48fd8d5d8..865f693b1 100644 --- a/apps/server/src/prompt/external-agent/access-huabu.md +++ b/apps/server/src/prompt/external-agent/access-huabu.md @@ -150,14 +150,14 @@ Before placing or resizing relative to existing content, query the relevant node | Node type | Default geometry | | ---------------------- | ------------------------------------------------------------------------------------ | | `text` | 200px wide; content-driven height | -| `note` | 400px wide; content-driven height (248px nominal layout height) | +| `note` | 400px wide; content-driven height (96px nominal layout height) | | `web`, `pdf`, `office` | 400 × 400px | | `video` | 400 × 300px | | `image` | 400px wide; height follows the source aspect ratio (300px nominal before resolution) | | `frame` | 400 × 300px | | `question` | 200px wide; content-driven height (80px nominal layout height) | -Never pin top-level height for `text`, `question`, or `note`; change rendered text scale with `data.style.fontSize`. A `note` sizes itself from its rendered Markdown, and passing a numeric `height` records it as user-pinned — it then keeps that box permanently and truncates its content instead of growing. Omit `height` entirely; setting only `width` is fine. Omitting it also lets Huabu start a very long note collapsed, with a one-click expand chevron, so it does not bury its neighbours; pass `height: "auto"` only when you want that note expanded to its full length no matter how long it is. For free-form root or frame layouts, a useful starting heuristic is about 50px between nodes and 40px frame padding, adjusted to the actual queried sizes. +Never pin top-level height for `text`, `question`, or `note`; change rendered text scale with `data.style.fontSize`. A `note` sizes itself from its rendered Markdown, and passing a numeric `height` records it as user-pinned — it then keeps that box permanently and truncates its content instead of growing. Omit `height` entirely; omitting it also lets Huabu start a very long note collapsed, with a one-click expand chevron, so it does not bury its neighbours, while `height: "auto"` forces that note to stay expanded no matter how long it is. Omit `width` as well unless inspected neighbours agree on a different number: a note is 400px wide, length is the height's business, and copying one wide neighbour is how a Space ratchets wider with nothing to pull it back. For free-form root or frame layouts, a useful starting heuristic is about 50px between nodes and 40px frame padding, adjusted to the actual queried sizes. Frames default to `free` layout, where child positions remain explicit and parent-local. `column`, `row`, and `grid` are structured layouts configured through `SET_FRAME_LAYOUT`; `gridCount` selects 1–12 tracks (columns for `column` and `grid`, rows for `row`) and defaults to 1. `column` and `row` are masonry: each track stacks independently, so a track holding fewer items pulls its next item up. `grid` additionally aligns rows: every child holds a cell, and a column with no child in a row leaves that cell blank, which is what keeps items in different columns side by side when one column has no counterpart. Structured frames compute final child geometry, so child `position` is only an ordering hint — in `grid` mode it does not decide rows at all. Use the same command's `cells` array to place children: `column` addresses columns, `row` addresses rows, `grid` addresses both. Their default `hug` sizing fits the frame to its content; `manual` preserves a pinned frame size while children still reflow and may overflow. Fetch the `SET_FRAME_LAYOUT` capability before using sizing or track options. diff --git a/apps/server/src/prompt/skills/space/references/layout-recipes.md b/apps/server/src/prompt/skills/space/references/layout-recipes.md index bdf8f786e..4bf22b1bb 100644 --- a/apps/server/src/prompt/skills/space/references/layout-recipes.md +++ b/apps/server/src/prompt/skills/space/references/layout-recipes.md @@ -11,10 +11,11 @@ Opinionated layouts for structured diagrams (architecture diagrams, flowcharts, ## Choosing node size -- In an existing layout, inspect the intended anchor or cluster before setting `size`. With an anchor id, the fastest path is one call: `inspect_nodes({ nearNode: { id: "", sameParent: true, maxCount: 5 }, byType: "" })`. Results include `size`; no separate anchor inspection or whole-Space outline is needed. Match peers with the same semantic role and use their median dimensions rather than copying an outlier. +- In an existing layout, inspect the intended anchor or cluster before setting `size`. With an anchor id, the fastest path is one call: `inspect_nodes({ nearNode: { id: "", sameParent: true, maxCount: 5 }, byType: "" })`. Results include `size`; no separate anchor inspection or whole-Space outline is needed. Match peers with the same semantic role and use their **median** dimensions — never their maximum, and never a single outlier. A size you copied from one wide neighbour becomes the size the next agent copies, and the Space ratchets wider with nothing to pull it back. - Without an anchor id but with an intended absolute position, use `inspect_nodes({ nearPoint: { x, y, maxCount: 5 }, byType: "" })`. If neither query returns a comparable peer, do not spend another read trying to manufacture one. - If there is no comparable peer, omit `size` and let Huabu use the node type's canonical default. Do not invent an oversized box merely because the new node has more content. -- **Never pin a `note`'s height.** Omit `height` entirely and let Huabu measure the rendered Markdown and size the node itself. A numeric height records the note as user-pinned, which permanently opts it out of re-measurement — the note then keeps that box forever and truncates its content as it grows. Omitting `height` also lets Huabu start a very long note collapsed, with a one-click expand chevron, so a document-length note does not bury its neighbours; pass `height: "auto"` only when that note should be fully expanded regardless of length. Setting only `width` is fine when you need to match nearby Notes. +- **Never pin a `note`'s height.** Omit `height` entirely and let Huabu measure the rendered Markdown and size the node itself. A numeric height records the note as user-pinned, which permanently opts it out of re-measurement — the note then keeps that box forever and truncates its content as it grows. Omitting `height` also lets Huabu start a very long note collapsed, with a one-click expand chevron, so a document-length note does not bury its neighbours; pass `height: "auto"` only when that note should be fully expanded regardless of length. +- **A `note` is 400px wide. Omit `width` too, unless you have actually inspected neighbours and they agree on a different width.** Never widen a note because its content is long — length is the height's business, and the height is measured for you. Copying the width of a single wide node is how a Space ratchets: each new node matches the widest thing near it, nothing ever pulls the number back down, and the canvas drifts wider one node at a time. If inspected peers disagree, take 400 rather than their maximum. - `text` and `question` heights are always content-driven; never set their height. For `image`, match a comparable peer's width and omit height so Huabu derives it from the source aspect ratio. For `video`, match both dimensions of a comparable Video or omit `size` entirely. ## Positioning patterns diff --git a/docs/architecture/node-auto-height.md b/docs/architecture/node-auto-height.md index 61913afa0..e2d458c62 100644 --- a/docs/architecture/node-auto-height.md +++ b/docs/architecture/node-auto-height.md @@ -21,7 +21,7 @@ The governing rule is that **rendering never causes a geometry change**. Zoom, p Intrinsic → layout is one pure function, [`intrinsicToLayoutHeight`](../../packages/shared/src/canvas-engine/height/compute.ts): clamp to the type's minimum, scale by the node's width, add the node shell's chrome, quantize to a 4 px step. The order mirrors the DOM — the minimum applies unscaled, the chrome is outside the scaled container and so is added after. -`note`'s minimum is a **reading** floor, not an anti-collapse guard. It is 244 intrinsic, which renders as 248 px at the reference width — deliberately generous. The previous value of 50 produced a 56 px card: one line of text and a sliver of the next, which is the box a brand-new note appears in and the box a collapsed note falls back to, and it is unreadable for anything already written. This is also the single knob for "notes are too short"; nothing in `nodeSizes.ts` reaches the rendered height, because `getNodeCreationStyle` drops the nominal height for auto-by-default types. +`note` carries two floors, not one, because "how short may a short note be" and "how much of a long note do we preview" are opposite questions. `minIntrinsicHeight` is 91 — measured in a real browser, one paragraph line is 41px intrinsic and each further line adds 25, so that is three lines, rendering as a 96px card. It stays small on purpose: a note under the collapse threshold is meant to size itself to its content, and every pixel of floor above the real content erases the difference between a short note and a slightly longer one. A floor of 244 would render every note of nine lines or fewer at an identical 248px. `collapsedIntrinsicHeight` is 244 — the preview window for a note too long to show at all, generous enough to read under the fade. `nodeSizes.ts` reaches neither: `getNodeCreationStyle` drops the nominal height for auto-by-default types, so the policy is the only knob that changes what you see. The scale divides the node's **content** width, its box minus the shell border, so the logical layout width lands on `refWidth` exactly at every node size. That is the premise the whole hint cache rests on: content measured at one node width wraps identically at any other. A legibility floor on the scale would break it — once engaged, the content stops shrinking and starts laying out _narrower_ than the reference, so `note` deliberately has none. Semantic zoom already replaces a tiny note's body with a placeholder long before its text would become unreadable. `HeightPolicy.minContentScale` carries the floor for the `manual` types, whose box is the user's and whose scale is therefore purely a rendering decision. @@ -105,7 +105,7 @@ measure ────────▶ propose → gate → APPLY_MEASURED_HEIGHT **Creation** records `heightMode` and materializes immediately, so the same batch's `fitFrames` and grid solver see a real footprint rather than zero. -A note created with content past [`NOTE_COLLAPSE_CONTENT_THRESHOLD`](../../packages/shared/src/canvas-engine/height/policy.ts) is born **collapsed**: pinned to `collapsedLayoutHeight`, which is the same number a short note settles at, so a collapsed long note and a one-line note are one size rather than two competing definitions of "small". Auto height is right for a note you can take in at a glance; past roughly a screenful the node grows to thousands of pixels and buries its neighbours at the moment it holds the most information. The rule is consulted only when the caller expressed no height preference at all — a numeric height pins as always, and an explicit `'auto'` is the author saying "expand this one", which outranks the heuristic. It lives in `policy.ts`, not in a creation helper, so the headless engine and the web client cannot disagree and produce a geometry write on every load. +A note created with content past [`NOTE_COLLAPSE_CONTENT_THRESHOLD`](../../packages/shared/src/canvas-engine/height/policy.ts) is born **collapsed**: pinned to `collapsedLayoutHeight`, the type's `collapsedIntrinsicHeight` run through the same width scaling a measured height gets. Auto height is right for a note you can take in at a glance; past roughly a screenful the node grows to thousands of pixels and buries its neighbours at the moment it holds the most information. The rule is consulted only when the caller expressed no height preference at all — a numeric height pins as always, and an explicit `'auto'` is the author saying "expand this one", which outranks the heuristic. It lives in `policy.ts`, not in a creation helper, so the headless engine and the web client cannot disagree and produce a geometry write on every load. **Load** runs [`normalizeNodeHeights`](../../apps/web/src/store/canvasStore/load/normalizeNodeHeights.ts) — write the inferred owner explicitly, then materialize — and then [`warmupNodeHeights`](../../apps/web/src/store/canvasStore/load/warmupNodeHeights.ts), which measures never-measured notes _before_ the canvas is shown, nearest to the restored viewport first, under a wall-clock budget. Warmup applies the completed measurements to the fetched snapshot through the pure canvas executor, so `APPLY_MEASURED_HEIGHT` performs the same freshness checks and parent Frame/Portal relayout as an interactive measurement without dispatching through the web store or creating load-time history. Normalization never writes a hint; a canvas saved before this model existed would otherwise paint a wall of collapsed cards and expand them one by one. diff --git a/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts b/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts index 09a9e1b23..e90bfe67d 100644 --- a/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts +++ b/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts @@ -51,7 +51,7 @@ describe('CREATE_NODES selection', () => { // Materialized from the note policy's minimum, not pinned: a // created note must have a real footprint for the same-batch frame // fit and grid solver, and its ownership is recorded explicitly. - height: 248, + height: 96, }); expect( ( diff --git a/packages/shared/src/canvas-engine/height/__tests__/height.test.ts b/packages/shared/src/canvas-engine/height/__tests__/height.test.ts index e913c77c6..3caa7decd 100644 --- a/packages/shared/src/canvas-engine/height/__tests__/height.test.ts +++ b/packages/shared/src/canvas-engine/height/__tests__/height.test.ts @@ -220,9 +220,9 @@ describe('intrinsicToLayoutHeight', () => { }); it('applies the minimum before scaling', () => { - // Note minimum is 244 unscaled; at half width the scale halves too. - expect(intrinsicToLayoutHeight(10, 'note', 400)).toBe(248); - expect(intrinsicToLayoutHeight(10, 'note', 200)).toBe(128); + // Note minimum is 91 unscaled; at half width the scale halves too. + expect(intrinsicToLayoutHeight(10, 'note', 400)).toBe(96); + expect(intrinsicToLayoutHeight(10, 'note', 200)).toBe(52); }); it('does not scale types without a reference width', () => { @@ -484,14 +484,22 @@ describe('collapse on create', () => { expect(shouldCollapseNoteOnCreate('note', { text: 'x' })).toBe(false); }); - it('collapses to exactly the height a short note settles at', () => { - // One definition of "small", not two: whatever the policy minimum - // renders as is what a collapsed note gets, at any width. - for (const width of [200, 400, 800]) { - expect(collapsedLayoutHeight('note', width)).toBe( - intrinsicToLayoutHeight(0, 'note', width), - ); - } + it('collapses to a readable preview, not to the short-note floor', () => { + // The two constants exist precisely so these differ. Collapsing to + // the minimum would turn every long note into a stub, and raising + // the minimum to the collapsed height would render every note of + // nine lines or fewer at the same size. expect(collapsedLayoutHeight('note', 400)).toBe(248); + expect(intrinsicToLayoutHeight(0, 'note', 400)).toBe(96); + + // Scales with the node's own width, exactly like a measured height. + expect(collapsedLayoutHeight('note', 800)).toBe( + intrinsicToLayoutHeight(244, 'note', 800), + ); + + // A type with no collapsed height still cannot fall below its floor. + expect(collapsedLayoutHeight('image', 400)).toBe( + intrinsicToLayoutHeight(0, 'image', 400), + ); }); }); diff --git a/packages/shared/src/canvas-engine/height/compute.ts b/packages/shared/src/canvas-engine/height/compute.ts index 8c2ea8742..d354041a5 100644 --- a/packages/shared/src/canvas-engine/height/compute.ts +++ b/packages/shared/src/canvas-engine/height/compute.ts @@ -108,16 +108,23 @@ export function intrinsicToLayoutHeight( } /** - * Layout height a *collapsed* node occupies: the type's minimum content - * height, converted for the node's own width. + * Layout height a *collapsed* node occupies: the type's collapsed + * preview height, converted for the node's own width. * - * This is deliberately the same number a short note settles at, so a - * collapsed long note and a one-line note are the same size rather than - * two competing definitions of "small". + * Runs through {@link intrinsicToLayoutHeight} rather than returning a + * literal so it scales with the node's width exactly like a measured + * height does. Types without a collapsed height fall through to their + * minimum, which is also what the clamp inside that function guarantees: + * a collapsed node can never end up shorter than a short one. */ export function collapsedLayoutHeight( nodeType: string | undefined, width: number | undefined, ): number { - return intrinsicToLayoutHeight(0, nodeType, width); + const policy = getHeightPolicy(nodeType); + return intrinsicToLayoutHeight( + policy.collapsedIntrinsicHeight ?? 0, + nodeType, + width, + ); } diff --git a/packages/shared/src/canvas-engine/height/policy.ts b/packages/shared/src/canvas-engine/height/policy.ts index e9987737b..c7b71d3d0 100644 --- a/packages/shared/src/canvas-engine/height/policy.ts +++ b/packages/shared/src/canvas-engine/height/policy.ts @@ -49,6 +49,18 @@ export interface HeightPolicy { * before scaling. Keeps an empty node from collapsing to nothing. */ minIntrinsicHeight?: number; + /** + * Intrinsic content height (px, unscaled) a *collapsed* node of this + * type is pinned to. Deliberately separate from + * {@link minIntrinsicHeight}: the two look alike but do opposite jobs. + * + * The minimum is a floor on a note that is genuinely short, so it must + * stay small or it erases the difference between a two-line note and a + * nine-line one. The collapsed height is a *preview window* onto a note + * that is far too long to show, so it must be generous enough to read. + * Collapsing to the minimum would make every long note a stub. + */ + collapsedIntrinsicHeight?: number; /** * Chrome (px) that lives outside the measured element but inside the * node box — added after scaling. `0` where the measurement already @@ -153,21 +165,29 @@ const HEIGHT_POLICIES: Readonly> = { // The note body measures `.ProseMirror` plus the host's own vertical // padding, so the only thing left to add is the node shell itself. // - // `minIntrinsicHeight` is a *reading* floor, not an anti-collapse - // guard. At the old value of 50 a note settled at 56px — one line and - // a sliver of the next — which is unreadable for anything already - // written and is the box a brand-new note starts in. 244 renders as - // 248px at the reference width (quantized to the 4px step), which is - // also the height a long note collapses to, so a short note and a - // collapsed long one agree instead of forming two different "small - // note" sizes. + // Two different jobs, two different numbers — see the field docs. + // + // `minIntrinsicHeight` is a floor for a genuinely short note. Measured + // in a real browser at refWidth: one paragraph line is 41px intrinsic + // and each additional line adds 25, so 91 is three lines and renders + // as a 96px card. The old value of 50 rendered as 56 — one line and a + // sliver of the next. It stays deliberately small because a note under + // the collapse threshold is supposed to size itself to its content; + // every pixel of floor above the real content erases the difference + // between a short note and a slightly longer one. (A floor of 244 + // would render every note of nine lines or fewer at the same 248px.) + // + // `collapsedIntrinsicHeight` is the preview window for a note too long + // to show at all, so it is generous: 244 renders as 248px, roughly a + // nine-line excerpt, with the fade and the expand chevron over it. // // No `minContentScale`: its height is derived from the scale, and a // floor would make the content lay out narrower than `refWidth`. note: { kind: 'toggleable', refWidth: 400, - minIntrinsicHeight: 244, + minIntrinsicHeight: 91, + collapsedIntrinsicHeight: 244, insetY: NODE_SHELL_INSET, }, text: { kind: 'content' }, diff --git a/packages/shared/src/canvas-engine/utils/nodeSizes.ts b/packages/shared/src/canvas-engine/utils/nodeSizes.ts index 1027bee82..67014c46e 100644 --- a/packages/shared/src/canvas-engine/utils/nodeSizes.ts +++ b/packages/shared/src/canvas-engine/utils/nodeSizes.ts @@ -33,9 +33,9 @@ const DEFAULT_SIZES: Record = { // never written to `style.height` — it exists for layout calculations // (viewport centring, placement) that need a footprint before the node // has been measured. It mirrors the rendered minimum, which the note - // policy's `minIntrinsicHeight` (244) converts to 248px at the - // reference width. Change that constant, not this one. - note: { width: 400, height: 248 }, + // policy's `minIntrinsicHeight` (91) converts to 96px at the reference + // width. Change that constant, not this one. + note: { width: 400, height: 96 }, web: { width: 400, height: 400 }, pdf: { width: 400, height: 400 }, office: { width: 400, height: 400 }, From cb38c59218836fc1364f2d131a37cbebef3e8da0 Mon Sep 17 00:00:00 2001 From: lunaqiu Date: Tue, 1 Sep 2026 14:39:51 +0800 Subject: [PATCH 4/4] fix(note): collapse long notes at a screenful, not at two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The birth-collapse threshold was 800 characters, which inverts to about 1080px of rendered height. A note therefore had to outgrow an entire screen before it collapsed, and everything between 600 and 1080px rendered at full length next to a 248px collapsed neighbour — the same wall of notes looked arbitrary depending on which side of the line each one fell. Measured against real agent-written notes, intrinsic height tracks the character count at 1.34x with ~5% spread past ~600 characters (tighter than a line count predicts the same heights, ~9%), so the unit is fine and only the number was wrong. Inverting that ratio from the height we actually want to trigger on gives 550, a little over 700px. Replaying the 14 notes from a real test Space: the six short ones stay expanded and the eight long ones now all collapse, with nothing left straddling the boundary. Creation-time only, so stored measurements stay valid and HEIGHT_LAYOUT_VERSION does not move. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/architecture/node-auto-height.md | 2 +- .../shared/src/canvas-engine/height/policy.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/architecture/node-auto-height.md b/docs/architecture/node-auto-height.md index e2d458c62..d2837fc4e 100644 --- a/docs/architecture/node-auto-height.md +++ b/docs/architecture/node-auto-height.md @@ -105,7 +105,7 @@ measure ────────▶ propose → gate → APPLY_MEASURED_HEIGHT **Creation** records `heightMode` and materializes immediately, so the same batch's `fitFrames` and grid solver see a real footprint rather than zero. -A note created with content past [`NOTE_COLLAPSE_CONTENT_THRESHOLD`](../../packages/shared/src/canvas-engine/height/policy.ts) is born **collapsed**: pinned to `collapsedLayoutHeight`, the type's `collapsedIntrinsicHeight` run through the same width scaling a measured height gets. Auto height is right for a note you can take in at a glance; past roughly a screenful the node grows to thousands of pixels and buries its neighbours at the moment it holds the most information. The rule is consulted only when the caller expressed no height preference at all — a numeric height pins as always, and an explicit `'auto'` is the author saying "expand this one", which outranks the heuristic. It lives in `policy.ts`, not in a creation helper, so the headless engine and the web client cannot disagree and produce a geometry write on every load. +A note created with content past [`NOTE_COLLAPSE_CONTENT_THRESHOLD`](../../packages/shared/src/canvas-engine/height/policy.ts) is born **collapsed**: pinned to `collapsedLayoutHeight`, the type's `collapsedIntrinsicHeight` run through the same width scaling a measured height gets. Auto height is right for a note you can take in at a glance; past that the node grows to thousands of pixels and buries its neighbours at the moment it holds the most information. The threshold counts characters because nothing has been measured yet at creation, and it is derived by inverting the measured ~1.34x characters-to-intrinsic ratio from the rendered height we want to trigger on — so moving it means picking a target height first, not picking a rounder number. The rule is consulted only when the caller expressed no height preference at all — a numeric height pins as always, and an explicit `'auto'` is the author saying "expand this one", which outranks the heuristic. It lives in `policy.ts`, not in a creation helper, so the headless engine and the web client cannot disagree and produce a geometry write on every load. **Load** runs [`normalizeNodeHeights`](../../apps/web/src/store/canvasStore/load/normalizeNodeHeights.ts) — write the inferred owner explicitly, then materialize — and then [`warmupNodeHeights`](../../apps/web/src/store/canvasStore/load/warmupNodeHeights.ts), which measures never-measured notes _before_ the canvas is shown, nearest to the restored viewport first, under a wall-clock budget. Warmup applies the completed measurements to the fetched snapshot through the pure canvas executor, so `APPLY_MEASURED_HEIGHT` performs the same freshness checks and parent Frame/Portal relayout as an interactive measurement without dispatching through the web store or creating load-time history. Normalization never writes a hint; a canvas saved before this model existed would otherwise paint a wall of collapsed cards and expand them one by one. diff --git a/packages/shared/src/canvas-engine/height/policy.ts b/packages/shared/src/canvas-engine/height/policy.ts index c7b71d3d0..42906da40 100644 --- a/packages/shared/src/canvas-engine/height/policy.ts +++ b/packages/shared/src/canvas-engine/height/policy.ts @@ -132,8 +132,23 @@ export const NODE_SHELL_INSET = 6; * height preference at all. An explicit numeric height pins as always, * and an explicit `'auto'` means "expand this one fully" — the author * has spoken either way, so neither is second-guessed. + * + * The unit is characters because nothing has been measured yet at + * creation time, but it is a good proxy in the range that matters: + * across real agent-written notes past ~600 characters, intrinsic + * height lands at 1.34x the character count with only ~5% spread — + * tighter than a line count predicts the same heights (~9%). + * + * The number is chosen by inverting that ratio from the rendered height + * we actually want to trigger on, not picked for roundness. 550 lands a + * little over 700px, roughly 2.5x a typical short note and about a + * screenful, against the ~248px collapsed preview. An earlier value of + * 800 inverted to ~1080px, so a note had to outgrow an entire screen + * before it collapsed and everything between 600 and 1080px rendered at + * full length — which is exactly what made a wall of long notes look + * arbitrary next to a collapsed one. */ -export const NOTE_COLLAPSE_CONTENT_THRESHOLD = 800; +export const NOTE_COLLAPSE_CONTENT_THRESHOLD = 550; /** * Whether a note created with this content should start collapsed.