Commit Graph

55 Commits

Author SHA1 Message Date
shadowdao 4b36ce0d6a fix(builder): route live header/footer edits correctly on save
Editing the Header/Footer sets activePageId to '__header__'/'__footer__',
which matches no entry in `pages`. save() was serializing the live canvas
into the top-level page slots (mislabeled as page content, matching no
page) while exporting header/footer from stale stored state — auto-save
every 30s silently dropped header/footer edits.

Extract buildSavePayload() as a pure, unit-tested helper: header/footer
craft state now comes from the live canvas when that zone is being
edited (else stored state), and the top-level page fields fall back to
the landing page's stored state when a header/footer zone is active,
so page content is never clobbered or mislabeled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:28:38 -07:00
shadowdao 1a88baa95d fix(builder): deep-clone node data on id regeneration to avoid shared props
regenerateTreeIds shallow-copied each node's data, leaving data.props (and
data.custom) as the same object reference between the original node and its
duplicate/pasted copy. Craft.js's setProp mutates data.props in place, so
editing the duplicate's props silently mutated the original too. Deep-clone
data via structuredClone before applying id remaps so no mutable sub-object
is shared between original and regenerated nodes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:25:22 -07:00
shadowdao 97123c4c58 fix(builder): regenerate node ids on duplicate/paste to prevent state corruption
Craft.js duplicate (ContextMenu + keyboard shortcut) and paste were reusing
the original node's toNodeTree() output verbatim, so addNodeTree() inserted
duplicate node ids into the editor tree. Added regenerateTreeIds() which
deep-clones a NodeTree and remaps rootNodeId, node map keys, node.id,
internal node.data.parent, node.data.nodes, and node.data.linkedNodes via
Craft.js's own getRandomId(). Also fixed pasteNode to insert as a sibling
of the right-clicked node (using its parent) instead of using a leaf node
as the new parent, which previously threw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:18:44 -07:00
shadowdao 4c001e1af4 fix(builder): preserve data-URI semicolons in css value sanitizer
sanitizeCssValue's blanket `;` strip ran on the whole value AFTER url(...)
content was already safely re-wrapped, corrupting legitimate
data:image/png;base64,... URLs pasted into Background Image fields
(the MIME/base64 separator `;` was deleted, breaking the data URI in
exported/published HTML). Scope the `;`/`"` breakout sanitization to the
segments outside url(...) matches only -- the url() branch is already
fully safe via escapeAttr(safeUrl(...)) and must not be re-stripped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:15:23 -07:00
shadowdao fb4e9f87be fix(builder): sanitize style-string emission
cssPropsToString() joined raw CSSProperties values into a style="..."
attribute with zero escaping, so any component spreading user-controlled
values into inline styles (background-image url(), etc.) could break
out of the attribute or inject a second declaration -- this is what
made BackgroundSection/HeroSimple/CallToAction/Section's bg-image
url() sites (flagged in the A3 brief) safe without needing a per-call-
site fix, since they already route through this helper.

Each string value is now sanitized: url(...) contents are validated
through safeUrl and re-wrapped escaped, stray `;` (the only way to
inject a second live declaration) is stripped, and any raw `"` is
entity-encoded so it can't terminate the attribute early. Legitimate
multi-part values (box-shadow, gradients) that contain none of these
characters pass through byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:06:16 -07:00
shadowdao 7179287087 fix(builder): sanitize HtmlBlock/Countdown/Gallery JS contexts
Entity-escaping alone doesn't protect JS-string or raw-HTML sinks:

- HtmlBlock.toHtml exported props.code raw; now runs it through the
  same purifyHtml (DOMPurify) config already used for the live editor
  preview, so <script>/on*= payloads can't survive export either.
- Countdown.toHtml interpolated targetDate directly into
  `new Date("${targetDate}")` inside an inline <script> -- a value
  like `2026-01-01");alert(1)//` broke out of the string literal. Now
  validated against a strict date/datetime shape and JSON.stringify'd
  before embedding, falling back to `new Date()` for anything invalid.
- Gallery.toHtml's lightbox used
  `onclick="${id}_open('${esc(img.src)}')"`, which a single quote in
  img.src could break out of. Replaced with a `data-lb-src` attribute
  per thumbnail and one delegated click listener on the grid
  (`e.target.closest('[data-lb-src]')`) instead of a per-item inline
  handler string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:06:07 -07:00
shadowdao 48d0441be3 fix(builder): neutralize javascript:/breakout URLs in export
Every user-controlled URL emitted by a component's static toHtml (href,
src, action, and CSS url()) now runs through escapeAttr(safeUrl(...))
before hitting the exported HTML string, closing the XSS gaps flagged
in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/
HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a
few more found via a grep sweep of href=/src=/action=/url( inside
toHtml template strings: MapEmbed's iframe src and the shared
form-relay-wiring fallback form action (a javascript: form action
executes on submit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:05:57 -07:00
shadowdao fad1882117 refactor(builder): use shared escaper everywhere, drop 26 local copies
Replace divergent, buggy local esc/escapeHtml helpers across 26 files with
imports from src/utils/escape (escapeHtml/escapeAttr). Attribute call sites use
escapeAttr, text-content sites use escapeHtml. Several toHtml outputs now
correctly escape & where old local escapers omitted it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:49:10 -07:00
shadowdao cce984508f feat(builder): add shared escape/safeUrl util
Adds craft/src/utils/escape.ts as the single exported escaping/URL-safety
util (escapeHtml, escapeAttr, safeUrl) for later hardening tasks to
consolidate the 27 divergent local copies into. html-export.ts now
imports escapeHtml from it instead of keeping a private copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:41:11 -07:00
shadowdao 94140990c2 Add implementation plan: site builder hardening + asset picker
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:36:27 -07:00
shadowdao 02e99f7623 Add design spec: site builder security & data-loss hardening
Covers Critical (stored-XSS escaping cluster) + High (copy/paste id reuse,
header/footer save corruption, AI-boundary validation) findings from the
2026-07-12 audit. All claims verified against code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:27:57 -07:00
shadowdao aaa305cc3e Add design spec: unified image/asset picker for site builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:14:00 -07:00
shadowdao 97cb439508 site-builder: fix Menu guided panel (empty Colors) + surface layout/nav colors
The shared NavStylePanel gated its Colors controls on the Navbar's prop
names (backgroundColor/textColor/ctaColor), so a Menu -- whose color props
are linkColor/linkHoverColor/ctaBgColor/ctaTextColor -- rendered an empty
Colors section (customer report: 'nothing to select').

- Add navColorFields() helper: derives the visible color controls from the
  props actually present, covering both the Navbar and Menu schemas
  (+ Navbar's hoverColor, which render/toHtml consume but had no control).
- Add a Menu Layout section (alignment/orientation/gap/font size), guarded
  on Menu's own props so it never leaks into Navbar or a standalone Logo.
- Unit test (navColorFields.test.ts) locks each component to its real props.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:10:46 -07:00
shadowdao d20b77e66d site-builder: dedicated Features editor in guided panel (per-card icon/image+upload/button, robust to template items missing keys) 2026-07-07 17:19:37 -07:00
shadowdao 5a26e4ef43 site-builder: FeaturesGrid renders image when 'image' set + expose image/button keys so the guided array editor edits them 2026-07-07 14:16:14 -07:00
shadowdao 1cfb51f181 site-builder: surface Spotify/Twitch + contact-form recipient in the Styles (guided) panel
The right panel only renders GuidedStyles (there is no Settings tab), so the
per-component Settings panels I'd edited never showed. Add Spotify/Twitch to
SocialStylePanel's platform dropdown and the 'Send submissions to' + thank-you
fields to FormStylePanel (shown for any form with a recipientEmail prop).
2026-07-07 14:06:24 -07:00
shadowdao 814ad29b91 Merge branch 'site-builder-feedback-batch' 2026-07-07 13:50:12 -07:00
shadowdao b9c5d3dd1c site-builder: relay wiring on FormContainer (template forms) + shared helper
The recipient field was only on the ContactForm block; templates build forms
from FormContainer + InputField, so template-based contact forms had no way to
set a target address. Add 'Send submissions to' + thank-you fields to
FormContainer, and extract the marker/placeholder/honeypot into a shared
form-relay-wiring helper so ContactForm and FormContainer can't drift.
2026-07-07 13:35:18 -07:00
jknapp 6b9c258d26 Merge pull request 'ContactForm relay wiring (recipient, thank-you, honeypot, marker)' (#2) from contact-form-relay into main 2026-07-07 19:35:27 +00:00
shadowdao 4877a63a3b site-builder: pin non-relay byte-identity on the realistic (non-empty fields) case [PR #2 review] 2026-07-07 12:22:28 -07:00
shadowdao cf5d30382a site-builder: ContactForm non-relay output byte-identical (honeypot whitespace fix) + test guard
Fix non-relay form output to match pre-change byte-for-byte by conditionally omitting the honeypot and fields lines when empty. Add backward-compat regex assertion to catch extra blank lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 08:30:36 -07:00
shadowdao 66117d375e site-builder: ContactForm relay wiring (recipient, thank-you, honeypot, marker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 08:24:56 -07:00
shadowdao 53c40f856f site-builder: feedback batch (social, features, header menu, spacer)
Five of six items from user feedback (Contact Form email delivery split
into a focused, live-tested follow-up):

- Social Links: add Spotify + Twitch (FA 4.7.0 already ships both glyphs).
- Features Grid: per-feature icon/image toggle (upload + URL) and an
  optional button (text + url); render, settings, and HTML export updated,
  backward compatible with existing icon-only features.
- Header: seed the default header with a Navbar (logo + Home/About/Services/
  Contact) so new sites open with an editable menu-with-links instead of an
  empty header zone. Adds a vitest guard that the seed deserializes and
  exports a real <nav>.
- Canvas: slim the empty header/footer placeholder from a padded band to a
  thin hint line so an empty zone no longer reads as a stray spacer.

Design spec: docs/superpowers/specs/2026-07-06-site-builder-feedback-batch-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:53:22 -07:00
shadowdao d0925d9e2d site-builder: dynamic CTAs, section anchors, edit-with-Sitesmith
Three related features:

1. Dynamic CTA buttons on HeroSimple, CTASection, CallToAction.
   New shared ctas[] array (text + href + variant + target) replaces the
   primary/secondary pair. Settings panel gets add/remove/reorder controls.
   Legacy fields stay readable for backwards compat — first user edit
   migrates the section onto the new array.

2. Anchor IDs on all layout/section components (Container, Section,
   BackgroundSection, ColumnLayout, plus 6 section blocks done by parallel
   subagent, plus Hero/CTA/CallToAction). Anchor input lives in the
   settings panel with an "auto from heading" button that walks the
   subtree for the first Heading.text. Renders as id="..." on the
   outermost element so #anchor URLs resolve.

3. Edit-with-Sitesmith targeted invocation. Right-click → "Ask Sitesmith"
   and a button at the top of the right-side settings panel both open the
   modal pre-targeted at the selected node. The node's serialized subtree
   is sent to the server; system prompt is augmented to require a patch
   with replace_node. Editor lifts modal state into a new SitesmithContext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:43:28 -07:00
shadowdao 7b747f775f site-builder: lock landing page to index.html regardless of name
The first page is now treated as the landing page: it always publishes to
index.html no matter what the user names it, and its slug is forced to
'index' in state so .htaccess clean-URL rewrites stay consistent.

- useWhpApi.ts: force pages[0].filename='index.html' at save time
- PageContext.tsx: heal pages[0].slug to 'index' on load and on rename
- PagesPanel.tsx: "LANDING" badge on first page, slug shown as '/',
  rename hides slug input (locked), delete button hidden

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:14:26 -07:00
shadowdao 330032eea3 sitesmith: publish home page to index.html, not home.html
replaceAllPages was slugifying every page name including the first, so
the home page landed at home.html. Apache resolves '/' to index.html, so
the published root URL appeared blank while the actual content was at
/home.html. First page now hard-codes slug='index'.
2026-05-24 17:50:08 -07:00
shadowdao 5e60415311 sitesmith: strip diagnostic shim + state-dump now that the fix is verified
Apply path is stable end-to-end with the linkedNode pre-creation patch;
diagnostic shim + window.__sitesmithLastState dump are no longer earning
their footprint. Reverts:
  - vite.config.ts: drops the tiny-invariant alias
  - src/utils/tiny-invariant-shim.ts: deleted
  - PageContext.tsx: removes the post-walk dump/scan block
2026-05-24 17:32:38 -07:00
shadowdao 87dd4340f7 sitesmith: pre-create section-inner/bg-section-inner/form-inner linkedNodes
The Invariant 'component type (undefined) does not exist in the resolver'
was Craft.js's toNodeTree choking on the linkedNode that <Element id="X">
auto-creates at render time inside Section / BackgroundSection /
FormContainer. The auto-created node stores its type as the Container
React component class itself, not as {resolvedName:'Container'}, so the
later type.resolvedName lookup returns undefined.

For each shell, treeToState (and apply-ai-response's buildNodeTree) now
synthesizes the linkedNode container up-front with a proper serialized
type, moves the AI's direct children into it, and reparents them. This
matches the canonical shape Craft.js writes when the user manually builds
a site, so Craft.js never has to materialize the linkedNode itself.
2026-05-24 17:22:40 -07:00
shadowdao a1ec51afc3 sitesmith: filter known-benign invariants from diagnostic shim
Craft.js uses several invariants as try/catched control-flow checks
(notably isDraggable -> 'A top-level Node cannot be moved' for ROOT and
linkedNode children). These fire on every render and are NOT errors —
they're how Craft.js asks 'should I attach drag to this node?'. Filter
them out of the shim's console.error so only genuinely-broken invariants
show up.
2026-05-24 16:37:08 -07:00
shadowdao 43627bddb0 sitesmith: alias tiny-invariant to a diagnostic shim
The prod build of tiny-invariant strips all failure messages, leaving
us with bare 'Error: Invariant failed' and no actionable info. Aliasing
the package to a shim that always emits the message + a stack-trace
console.error before throwing — so the next Craft.js invariant we hit
tells us which assertion (ERROR_NOT_IN_RESOLVER, ERROR_NOPARENT,
ERROR_INVALID_NODE_ID, etc.) is actually failing.

Temporary; will revert once the Sitesmith apply flow is stable.
2026-05-24 16:32:50 -07:00
shadowdao 849f432330 sitesmith: narrow CANVAS_TYPES to just Container
The canonical Craft.js state from real saves shows that layout shells
(Section, BackgroundSection, HeroSimple, FeaturesGrid, ColumnLayout,
CTASection, FormContainer, Navbar, Footer) all serialize with
isCanvas:false. Only Container instances are canvases. The shells use
internal <Element canvas id="..."> linkedNodes for their drop targets.

Our previous CANVAS_TYPES set claimed all those shells were canvases,
which made Craft.js's toNodeTree walker hit an uncaught Invariant —
the shell asserted "I'm a canvas" but its render ignores data.nodes,
so the walker would chase phantom children.
2026-05-24 16:27:38 -07:00
shadowdao 6428f93cec sitesmith: route ColumnLayout children through linkedNodes (Invariant fix)
ColumnLayout's render uses <Element id="col-0" is={Container} canvas>
which expects the columns to live in linkedNodes, not data.nodes. The
AI nests its column containers as direct children, so they'd land in
data.nodes — Craft.js's render ignores them (the layout draws fresh
empty Elements), but the orphaned children remain in state with
parent: <columnlayout-id>. Any subsequent toNodeTree walk then trips
on this inconsistency and the uncaught Invariant kills the editor.

Normalizer added in two places — treeToState (for scope=site/page
replaces) and buildNodeTree (for scope=section inserts and patch ops):
when we see a ColumnLayout with direct children, move them into
linkedNodes keyed col-0/col-1/col-2..., clear data.nodes, set the
column nodes' isCanvas to true (they hold content), and sync the
"columns" prop to the actual count.
2026-05-24 16:17:25 -07:00
shadowdao 906695379b sitesmith: null-safe esc() in Navbar/Menu/Logo + clear chat button
The prior null-safe esc patch only matched 'const esc =' declarations;
Menu/Navbar/Logo use 'function esc(str: string)' syntax and slipped
through. Patched those three to coerce non-strings the same way.

Added "Clear chat" button in the modal header that appears when there's
any message history. Confirms with the user before posting to the new
clear_history endpoint, which deletes all messages + the thread row
for the current site (usage rows are preserved for billing).
2026-05-24 16:03:02 -07:00
shadowdao 069ea1235a sitesmith: null-safe esc() across all toHtml + WorkingIndicator
Real-world AI output frequently sends mismatched prop names (e.g.
items vs features, cta object vs buttonText/Href). The toHtml functions
of section/form/sections-folder components each defined a local
esc = (s: string) => s.replace(...) that crashed when called with
undefined, taking the auto-save export with it.

Patched every local esc() to coerce non-strings:
  const esc = (s: any) => String(s ?? "").replace(...)
17 files touched; behavior unchanged for valid string inputs.

Also adds a WorkingIndicator (Claude Code-style spinner + rotating
phrase + elapsed seconds) shown in the modal footer while a generation
is in flight, replacing the disabled "Thinking..." placeholder.
2026-05-24 15:54:48 -07:00
shadowdao ac0347ae5f sitesmith: fix blank canvas on Replace site
treeToState() was setting isCanvas:true on every node, including leaf
components (Heading, TextBlock, ButtonLink, Spacer, ImageBlock). Craft.js
then renders those as empty drop-canvas wrappers instead of their actual
content, so the canvas appears blank after applying an AI-generated
'replace' response.

Now uses a CANVAS_TYPES set matching the apply-ai-response utility:
only the layout wrappers (Container, Section, ColumnLayout, Hero/Features/
CTA sections, FormContainer, Navbar, Footer, etc.) are canvases. ROOT is
forced to be a canvas regardless of source type so children render.

Also defensively normalizes props.style: AI sometimes emits an empty
array instead of an object, which can confuse downstream consumers.
2026-05-24 15:35:05 -07:00
jknapp 5c5066c20b Merge pull request 'Sitesmith: AI site builder addon (frontend)' (#1) from sitesmith-ai-builder into main
Reviewed-on: #1
2026-05-24 17:11:03 +00:00
shadowdao 0f943bacc7 sitesmith: playwright e2e suite (locked/cap/bonus/build+patch)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:27:34 -07:00
shadowdao 2ca1ff0cf9 sitesmith: layers panel prefers props.aiName when present 2026-05-23 14:25:43 -07:00
shadowdao e651becdbe sitesmith: chat modal (messages, input, banner, scope confirm) 2026-05-23 14:25:28 -07:00
shadowdao b4d71340e1 sitesmith: upgrade banner + scope-replace confirmation dialog 2026-05-23 14:24:20 -07:00
shadowdao bf55ee85b9 sitesmith: topbar button with locked/capped states 2026-05-23 14:23:51 -07:00
shadowdao cf3457aa15 sitesmith: apply-ai-response utility (replace + patch + ask) + PageContext helpers
Add apply-ai-response.ts with serializeTreeForCraft, buildNodeTree, findNodeIdByAiNodeId,
and useApplyAiResponse hook covering replace (site/page/section), patch (5 ops), and ask.
Extend PageContext with replaceAllPages, replaceCurrentPage, setHeader, setFooter helpers
that mirror the existing actions.deserialize/loadState pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:20:51 -07:00
shadowdao f6243d3ffe sitesmith: useSitesmith hook (entitlement, history, send) 2026-05-23 14:16:20 -07:00
shadowdao 8d094a9c67 sitesmith: typescript types for messages, responses, patch ops 2026-05-23 14:15:15 -07:00
shadowdao 14a957f57c sitesmith: canvas summary serializer with unit tests 2026-05-23 14:14:38 -07:00
shadowdao bd15a33984 sitesmith: harden HtmlBlock with DOMPurify + add Vitest setup
Closes XSS hole in HtmlBlock by sanitizing user/AI-supplied markup
through DOMPurify before passing to dangerouslySetInnerHTML. Adds
Vitest + jsdom for unit testing with 5 passing tests covering script
stripping, on-event handler removal, javascript: URL blocking, iframe
allowlist, and form/input stripping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:13:42 -07:00
shadowdao 606c9b78c8 fix(image-radius): split out 3x-scale IMAGE_RADIUS_PRESETS for the image picker
Image radii need to be visibly larger than the radius scale that works for
buttons/containers — at typical photo dimensions, 16px reads as nearly
square. Add an image-specific scale at 3x the shared values (S=24px,
M=48px, L=96px) and route ImageStylePanel through it. Other components
(buttons, sections, containers) keep RADIUS_PRESETS unchanged.

Note: this commit also bundles unrelated pre-existing working-tree changes
in the legacy GrapesJS site-builder root (CLAUDE.md, index.html,
css/editor.css, js/assets.js, js/editor.js, js/whp-integration.js) that
were inadvertently picked up by an earlier `git add -u`. The image-radius
change is the only intentional content of this commit; the rest is
in-progress legacy work that happened to be sitting uncommitted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 05:18:31 -07:00
shadowdao 8eeaecd857 fix(radius): bump shared RADIUS_PRESETS so S/M/L are visible on real elements
The actual radius picker shown to users for images, sections, and
containers comes from ImageStylePanel etc. via the shared
RADIUS_PRESETS — not from each component's own settings panel. Earlier
fix only bumped ImageBlock's local scale, which is a different control.

Bump shared scale: S=8px, M=16px, L=32px, Full=9999px (unchanged).
Existing saved sites are unaffected — only future preset clicks pick
up the new values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:22:57 -07:00
shadowdao c2bacb41bf feat(image): bump radius presets so S/M/L are actually visible on real images
4px and 8px were imperceptible on typical image sizes. New scale
0/8/16/32/50% gives visible steps for None/S/M/L and keeps Full as
round.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:05:25 -07:00
shadowdao 1558626b84 fix(delete): redirect Delete to owning component when target is empty linked node
Linked Craft.js nodes (column children of ColumnLayout, section-inner of
Section, etc.) are structurally non-deletable — actions.delete throws and
the error was silently swallowed. Empty layouts ended up undeletable from
the canvas because clicks always landed on the linked children that fill
the layout's visible area.

Adds findDeletableTarget(): when target is a linked node and ALL its
linked siblings are also empty (i.e., the layout itself is empty),
redirect deletion to the owning parent. Refuses to redirect when any
sibling has content, to protect against nuking a 3-col layout that has
content in other cols.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:42:17 -07:00