Skip to content

ENG-2150 Nested pages in tldraw canvas - #1308

Open
mattakamatsu wants to merge 4 commits into
mainfrom
feat/nested-subpage-portals
Open

ENG-2150 Nested pages in tldraw canvas#1308
mattakamatsu wants to merge 4 commits into
mainfrom
feat/nested-subpage-portals

Conversation

@mattakamatsu

@mattakamatsu mattakamatsu commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Resolves ENG-2150.

Ports the click-verified nested-pages prototype from tldraw-local (reference: the private dg-team/dg-prototypes repo, nested-pages/SPEC.md + ROAM-PORT-PLAN.md) into the Roam plugin canvas (tldraw 2.4.6).

What it adds

Nested sub-page portals: a portal looks like a framed sub-canvas — a colored title bar (showing the live target-page name) over a live spatial preview of another tldraw page: one scaled box per shape in its true position, discourse-type colors from the graph's own node settings, images in place. Clicking the title bar enters the target page; a breadcrumb/back bar (real UI chrome in the HelperButtons slot, only on nested pages) navigates back up. Nesting is fractal. A "Create sub-canvas portal" context-menu action creates a child page + portal in one step.

Data model — built for backward compatibility

A portal is a native geo rectangle carrying meta.dgSubpage = { targetPageId, accent, title }, with a visible "⤵ page name" label; the page hierarchy lives in page.meta.dgNested.parentPageId. There is no custom shape type — deliberately:

  • Old plugin versions still read the canvas. tldraw validates meta and unknown migration sequences permissively, but an unknown shape type makes loadSnapshot throw — and useRoamStore then blanks the entire canvas. With meta-on-native-geo, an old client shows a labeled, movable, bindable rectangle and keeps sequential page navigation via the native page menu (they just can't click into portals). Verified empirically against 2.4.6 and pinned by nestedPagesCompat.test.ts, which simulates an old client loading a new board.
  • Cloudflare sync carries no new record type, so mixed-version collaboration in a room works instead of rejecting old clients.
  • Pages stay flat siblings; the native page menu remains a working escape hatch. Lineage walks are cycle-guarded (visited-set + depth cap 16). Deleting a page leaves portals showing a "target page not found" placeholder; deleting a portal never cascades.

Rendering: DgSubpageGeoUtil extends GeoShapeUtil draws the portal chrome for meta-carrying geos and defers to stock geo behavior otherwise. Registration goes through a new combineShapeUtilsWithDefaults (a custom util may now replace a stock util; registering "geo" twice throws).

Interaction rules (SPEC §4)

  • The title bar is the enter target via a DOM pointer handler — navigation never depends on tldraw selection state (first click always works).
  • The body is pointer-transparent, so the shape still selects/drags/resizes through canvas hit-testing.
  • Arrows bind natively (portals are geo shapes) and survive move/resize.
  • All navigation (header, crumb segment, back button) funnels through one enterPage (zoom-to-fit, inset 80, 200 ms).
  • The preview is a reactive live read (useValue), never a cached snapshot — zero bytes added to the document. The header shows the live page name, so renames reflect immediately.
  • toSvg shares the classifier, projection, paint table, and label thresholds with the live render so exports can't drift. Node labels drop their format prefix (the type-code chip carries it), cap at 90 chars, and image boxes keep the full image under a max-2-line label strip.
  • Page creation guards editor.options.maxPages (40) loudly before creating anything — tldraw's createPage silently no-ops at the cap, which would strand an orphan portal.

Tests / verification

  • 32 unit tests on the extracted pure logic (src/utils/nestedPages.ts): grammar-prefix derivation from node formats (never hardcoded codes; handles bracketed [[EVD]] - titles), cycle-guarded lineage, classifier skip rules + count==boxes-drawn, layout, label thresholds, maxPages guard — plus 2 backward-compat tests (nestedPagesCompat.test.ts) simulating an old client. Full suite: 130/130.
  • check-types, eslint, and the extension build all pass.
  • First live pass done by the ticket owner (portal render, preview classification, navigation); full SPEC §10 checklist in plugin-testing-akamatsulab2 still pending — draft until then.
  • ⚠️ Boards created with earlier commits of this branch (custom dg-subpage shapes) are not readable by this build — delete those test portals / reset the canvas State before loading. No released version ever wrote that type.

Follow-ups (deliberately not in this PR)

  • Frame→portal converter (ConvertToDialog precedent)
  • Cascade-delete affordance for orphan pages
  • dg-team-mcp canvas_* tool support for creating/linking portals (linkSubpagePortal is already exported for it)
  • Old-client label re-sync when a page is renamed by a new client (props.text updates on create/link only)
  • Preview-model caching keyed on the target page's change epoch, if profiling warrants

Scope check

  • Ran $scope-check against ENG-2150 and the final diff.
  • Scope beyond Done When: (1) linkSubpagePortal is exported with no UI caller yet; (2) portal titles live-sync to target-page renames; (3) the backward-compatibility data model (native geo + meta instead of a custom shape type).
  • Required now: (1) completes SPEC §7's lineage-write semantics and is the seam the converter/MCP follow-ups build on; (2) and (3) requested by the ticket owner during review of the first live build (2026-08-18).
  • Anyone affected or consulted: Yes — Matt (ticket owner) directed the port, the title-sync/label changes, and the backward-compatibility requirement.
  • Decision: dg-prototypes/nested-pages/ROAM-PORT-PLAN.md, the ticket's Solution section, and dg-prototypes/nested-pages/LOG-eng-2150-roam-port.md.

Review guide (PR exceeds the 400-line guideline)

Size: ~1,900 insertions across 10 files, of which ~500 are unit tests and ~700 the single self-contained portal renderer.

Why it isn't split: the feature is one closed loop — the portal's header calls enterPage, the breadcrumb walks the meta the creation action writes, and the pure utils exist only for these consumers. A foundational-first split (utils → renderer+registration → chrome+action) would make the first two PRs unreviewable in isolation. Happy to restack into dependent PRs if a reviewer prefers.

Read in this order:

  1. src/utils/__tests__/nestedPagesCompat.test.ts — the backward-compat contract, smallest file, explains the data model.
  2. src/utils/nestedPages.ts + its test file — meta schema and all classifier/lineage/layout decisions, documented inline.
  3. src/components/canvas/DgSubpageUtil.tsxDgSubpageGeoUtil: the portal/native branch, the two render paths (they intentionally share model, projection, paint table, and label thresholds).
  4. src/components/canvas/nestedPageNavigation.ts — create/enter/link semantics (maxPages guard, lineage meta, explicit parentId).
  5. useCanvasStoreAdapterArgs.ts (note combineShapeUtilsWithDefaults + the customShapeTypes filter), useRoamStore.ts, TldrawCanvasCloudflareSync.tsx, Tldraw.tsx, uiOverrides.tsx, DgSubpageBreadcrumb.tsx — registration + chrome diffs.

Testing path: pnpm test && pnpm check-types && pnpm build in apps/roam, then dev-load apps/roam/dist into a test graph and: right-click → "Create sub-canvas portal" → click the title bar (first click, with something else selected) → add nodes/images on the child page → back via breadcrumb → rename the child page (header follows) → copy as PNG (preview must render) → delete the child page (placeholder, no navigation). For the compat claim: open the same canvas page with the production extension — the portal shows as a "⤵ name" rectangle and the board still loads.

🤖 Generated with Claude Code

mattakamatsu and others added 2 commits August 18, 2026 19:02
Ports the nested sub-page portals prototype (dg-prototypes/nested-pages
SPEC.md) into the Roam canvas. A dg-subpage shape is a portal into
another tldraw page: a colored title bar (click = enter the target page)
over a live scaled map of the target page's shapes — discourse-type
colors, images in place, grammar-prefix classification derived from the
live node formats. Pages stay flat siblings; hierarchy lives only in
page.meta.dgNested.parentPageId plus the portal's props.targetPageId.

The classifier, lineage walk, layout projection, and label thresholds
are pure functions in utils/nestedPages.ts with unit tests. The shape
registers in both store paths with a day-one migration sequence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The breadcrumb/back bar renders in the HelperButtons UI slot (composed
with the default helper buttons) and only appears on nested pages; all
navigation funnels through one enterPage. "Create sub-canvas portal" is
a new action surfaced in the canvas context menu: it guards maxPages
loudly before creating anything, creates the child page eagerly with
lineage meta, and titles the portal from the actual (possibly deduped)
page name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 19, 2026

Copy link
Copy Markdown

ENG-2150

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
discourse-graph Skipped Skipped Aug 19, 2026 6:40am

Request Review

@supabase

supabase Bot commented Aug 19, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Feedback from first live test: (1) renaming the target page did not
update the portal header — the header (and nested-portal preview boxes)
now read the live page name, with props.title kept only as the
missing-page fallback; (2) EVD-style titles buried the key image — node
titles now drop their format prefix (the type code chip already carries
it, and Roam titles literally contain "[[EVD]] - "), labels cap at 90
chars, and image boxes show the full image with a max-2-line label strip
overlaid at the bottom, in both the live and SVG renderers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mattakamatsu mattakamatsu changed the title [ENG-2150] Nested sub-page portals in the Roam tldraw canvas ENG-2150 Nested pages in tldraw canvas Aug 19, 2026
@mattakamatsu
mattakamatsu requested a review from mdroidian August 19, 2026 05:57
@mattakamatsu
mattakamatsu marked this pull request as ready for review August 19, 2026 05:57
@graphite-app

graphite-app Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

PR size/scope check

This PR is over our review-size guideline.

  • Recommended: ~200 lines changed
  • Acceptable limit: up to 400 lines when well-scoped/self-contained
  • Preferred file count: fewer than 5 files

Please split this into smaller PRs unless there is a clear reason the changes need to land together.

If keeping it as one PR, please add a brief justification covering:

  • What single problem this PR solves
  • Why the files/changes are coupled

Copy link
Copy Markdown
Contributor Author

@mdroidian I'm not confident whether this is PR-quality yet; so you may not need to give a detailed review yet; more like "hey matt's agent, for the next round do X, then it'll be ready for my review" then I'll submit the PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +24 to +40
style={{
pointerEvents: "all",
display: "flex",
alignItems: "center",
gap: 8,
margin: "6px 0 0 8px",
padding: "5px 10px",
background: "rgba(255,255,255,0.94)",
border: "1px solid #e3e5e9",
borderRadius: 9,
boxShadow: "0 1px 6px rgba(20,20,40,0.10)",
font: "13px var(--tl-font-sans, Inter, system-ui, sans-serif)",
backdropFilter: "blur(6px)",
maxWidth: "70vw",
overflow: "hidden",
width: "fit-content",
}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New canvas breadcrumb bar introduces its own colors and hand-rolled styling instead of the host app's design system

The nested sub-page breadcrumb bar is styled with a brand-new inline color/shadow palette (background: "rgba(255,255,255,0.94)"boxShadow/backdropFilter at apps/roam/src/components/canvas/DgSubpageBreadcrumb.tsx:24-40) rather than Tailwind classes or platform-native components, so the new UI looks like a separate visual language from the rest of the extension.
Impact: Users see canvas chrome that does not match Roam's look and feel, and future style changes have to be maintained twice.

Repository styling rules that this violates

apps/roam/AGENTS.md states: "Platform-native UI - use BlueprintJS 3 components and Tailwind CSS" and "Do not introduce arbitrary visual styling, new shading colors, background palettes, gradients, accent colors, border colors, or text colors unless the user explicitly asks for them." The root AGENTS.md adds "Use Tailwind CSS for styling where possible" and "When refactoring inline styles, use tailwind classes".

The breadcrumb container (DgSubpageBreadcrumb.tsx:24-40), the back button (DgSubpageBreadcrumb.tsx:48-58) and the crumb buttons (DgSubpageBreadcrumb.tsx:83-95) all hard-code hex colors (#e3e5e9, #f7f8fa, #3a3d42, #5b6bd6, #b9bdc4), a custom shadow and a blur filter. The same pattern is repeated in the portal shape chrome (apps/roam/src/components/canvas/DgSubpageUtil.tsx:409-427). Existing canvas UI in this repo composes tldraw UI primitives (TldrawUiButton, TldrawUiIcon in apps/roam/src/components/canvas/uiOverrides.tsx:97-116) or Tailwind classes instead.

Prompt for agents
The new nested-page UI chrome introduces a bespoke inline visual palette, which apps/roam/AGENTS.md forbids ("Do not introduce arbitrary visual styling, new shading colors, background palettes, gradients, accent colors, border colors, or text colors unless the user explicitly asks for them") and the root AGENTS.md asks for Tailwind/platform-native components. Rework DgSubpageBreadcrumb.tsx (container at lines 24-40, back button 48-58, crumb buttons 83-95) so the bar is built from tldraw UI primitives (e.g. TldrawUiButton, as uiOverrides.tsx already does) and/or Tailwind utility classes that reuse existing repo styling patterns, instead of hard-coded hex colors, custom box-shadow and backdrop-filter. Do the same for the portal header/body chrome in DgSubpageUtil.tsx (lines 409-468) where practical, keeping only the layout styles tldraw requires for absolutely positioned shape content.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

import { DefaultHelperButtons, useEditor, useValue } from "tldraw";
import { enterPage, getLineage } from "./nestedPageNavigation";

const DgSubpageBreadcrumb = () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Several new functions omit explicit return types required by the repository style guide

The new breadcrumb components and navigation helpers are declared without explicit return types (for example the component declaration at apps/roam/src/components/canvas/DgSubpageBreadcrumb.tsx:9), which the repository style guide requires for all functions.
Impact: Contributors lose the guaranteed, self-documenting signatures the project standardises on, making accidental signature changes easier to miss.

Affected declarations and the rule

Root AGENTS.md → TypeScript Guidelines: "Use explicit return types for functions".

Missing return types in this PR:

  • DgSubpageBreadcrumb (apps/roam/src/components/canvas/DgSubpageBreadcrumb.tsx:9) and the go helper (:17)
  • NestedPageHelperButtons (apps/roam/src/components/canvas/DgSubpageBreadcrumb.tsx:109)
  • enterPage (apps/roam/src/components/canvas/nestedPageNavigation.ts:24)
  • DgSubpageUtil.readPreviewModel / component / indicator (apps/roam/src/components/canvas/DgSubpageUtil.tsx:269, :275, :707)

Existing code in the same area follows the rule (e.g. SyncModeMenuSwitchItem returns ReactElement in apps/roam/src/components/canvas/uiOverrides.tsx:85-96).

Suggested change
const DgSubpageBreadcrumb = () => {
const DgSubpageBreadcrumb = (): React.ReactElement | null => {
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

A plugin version without the nested-pages feature must still read a
canvas that contains portals — an unknown custom shape type makes
loadSnapshot throw, which blanks the entire canvas there. Portals are
now plain geo rectangles carrying meta.dgSubpage (with a visible "⤵
name" label for old clients), rendered by DgSubpageGeoUtil, a
GeoShapeUtil subclass that draws the portal chrome for meta-carrying
shapes and defers to stock geo behavior otherwise. Old clients see a
movable, bindable, labeled rectangle and keep sequential page-menu
navigation; Cloudflare rooms no longer carry a custom record type at
all, so mixed-version collaboration works.

Verified against tldraw 2.4.6 and pinned by nestedPagesCompat.test.ts:
geo-with-meta and unknown migration sequences load fine on a
default-utils store; an unknown shape type throws. The dg-subpage
custom type and its migration sequence are gone; util registration now
goes through combineShapeUtilsWithDefaults so a custom util can replace
a stock one (registering "geo" twice throws).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Member

hey matt's agent, for the next round can you make sure to run through our devOps list: DG Organization handbook

for tldraw specifically, have you checked to see if tldraw's latest versions support an interaction like this? Check their official docs, the github, and also their discord to see what has been discussed / prior art.

And for the ticket/body writeup, did you use the $discourse-engineering-writing-style skill?

@mdroidian mdroidian left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants