Skip to content

Add slides layout change UI with placeholder identity#197

Merged
hackerwins merged 26 commits into
mainfrom
slides-layout-change
May 9, 2026
Merged

Add slides layout change UI with placeholder identity#197
hackerwins merged 26 commits into
mainfrom
slides-layout-change

Conversation

@hackerwins

@hackerwins hackerwins commented May 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Lets slides authors pick a layout when inserting a new slide and change the layout of an existing slide, without losing typed content.

  • Element.placeholderRef = { type, index } so slot identity survives layout switches. Type-first matching (with index fallback for same-type slots like the two-column layout's two body slots).
  • Single applyLayoutToSlide pure function backs both MemSlidesStore and YorkieSlidesStore. Orphan placeholders demote to plain elements rather than getting destroyed; users rely on undo, not confirm dialogs.
  • Vanilla-DOM showLayoutPicker popover. Opened by a new split on the thumbnail panel + button (existing left zone keeps blank-insert) and by a "Change layout…" item on the slide canvas context menu.
  • Layout previews render through renderThumbnail against a synthetic slide, with a module-level cache keyed by (theme, master, layout, size).

Spec: docs/design/slides/slides-layout-change.md
Plan + lessons: docs/tasks/active/20260508-slides-layout-change-todo.md, docs/tasks/active/20260508-slides-layout-change-lessons.md

Test plan

  • pnpm verify:fast (47 files / 764 tests green)
  • Slides package: 249 tests, including 6 applyLayoutToSlide scenarios (blank → fresh, preserve typed into same slot, ambiguous body 0/1 by index, fewer slots empty → delete, fewer slots non-empty → demote, user elements untouched), 5 showLayoutPicker cases, 4 renderLayoutPreview cache cases, picker wiring on the thumbnail panel split and on the slide context menu.
  • Yorkie integration: two new gated cases in yorkie-slides-concurrent.integration.ts cover concurrent applyLayout convergence and "edit + applyLayout" without proxy crash.
  • Browser smoke (reviewer) — see § Smoke checklist below before merging.

Smoke checklist

Run pnpm dev, then walk through:

  1. New deck → click + Add slide (left zone) appends a blank slide.
  2. Click the next to + Add slide → picker shows 11 layout previews → pick title-body → slide appears with title + body placeholders.
  3. Type into the title and body → right-click slide background → "Change layout…" → picker appears with title-body outlined → pick title-only → "Hello" stays in title; the body text remains as a free-floating text box at its old position (placeholder demoted, not deleted).
  4. Insert title-two-columns. Type L in left body, R in right. Change to title-only → exactly one demoted body element remains besides the title slot. Switch back to title-two-columns → original body slot pair re-appears (matched by (type, index)).
  5. Switch theme via the existing theme panel → reopen the picker → previews reflect new theme colors.
  6. Press Cmd/Ctrl-Z after a layout change → reverts to previous layout in one step.

Notes for reviewers

  • Lessons file calls out two subtle Yorkie traps that took iteration to land — read docs/tasks/active/20260508-slides-layout-change-lessons.md § 1 and § 4 before suggesting code changes to applyLayoutToSlide or to any new @wafflebase/slides export.
  • One latent bug noted but not fixed in this PR: YorkieSlidesStore.read() strips placeholderRef in its conversion path. Doesn't bite the layout-change feature (writes go through the live proxy), but worth a follow-up before any consumer reads placeholderRef off read(). See lessons § 2.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added ability to switch slide layouts after creation with content preservation.
    • Introduced layout picker UI with cached visual previews of all available layouts.
    • Added "Change layout…" context menu option on slide canvas.
    • Added split-button layout selector in thumbnail panel for quick layout switching.
  • Documentation

    • Added comprehensive design and implementation documentation for layout switching feature.

hackerwins and others added 17 commits May 9, 2026 07:47
The slides package already exposes applyLayout in the store API
but no UI surfaces it, and the current implementation is
additive-only (placeholder accumulation across switches). The
design adds placeholder identity tracking on Element so layout
switches preserve typed content by slot type and demote orphans
instead of dropping them. The plan splits the work into eleven
TDD-driven tasks shipped as a single PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Lays groundwork for layout-change UX where slot identity must
survive layout switches. Without a ref on the element, we cannot
tell user-added content from layout-generated placeholders, and
orphan handling on layout downgrade has no signal to act on.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Replaces `as never` Block casts with a typed `textBlock(text)`
helper so future changes to Block can no longer silently bypass
type-checking in this test file. Adds three edges that pin the
contract: empty blocks array (vacuous-true), block with empty
inlines (vacuous-true), and multi-block where one is non-empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Required for type-first slot matching when applying a new layout.
Without this, switching from title-body to title-only could route
the body's text into the new title slot purely by index, which
would surprise the author. Eleven slot mappings are kept in one
place to keep the spec/code in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Single source of truth for layout-change semantics so MemSlidesStore
and YorkieSlidesStore can never drift. The three-pass algorithm
(partition / type-first match / demote orphans) preserves typed
content across slot-count changes and never destroys non-empty
elements; users rely on undo, not confirm dialogs.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
addSlide now stamps placeholderRef on each generated element so a
later applyLayout can match by slot identity. applyLayout itself
delegates to the shared pure helper, replacing the conservative
additive-only body with type-first matching and orphan demote.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
addSlide and applyLayoutToSlide both compute the (type, index)
identity for every layout placeholder. Keeping two copies of the
formula creates a drift hazard — divergence would silently
misroute typed content on layout switches. The shared helper also
collapses the per-call O(n^2) slice+filter into a one-pass Map
counter at the cost of one extra import.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Both stores now share the slot-matching pure function so collab
clients see identical local-first results. The Yorkie variant
runs inside this.doc.update; applyLayoutToSlide switched to
in-place elements.splice so the same body works on both plain
arrays and Yorkie array proxies. Yorkie element types gain an
optional placeholderRef to match the model.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A two-client scenario in the existing slides integration suite
asserts that simultaneous applyLayout calls on the same slide
converge to one layoutId via last-writer-wins. Without this gate,
a future change to mutation patterns inside applyLayoutToSlide
could silently break collab parity for layout switches.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
When `slide.elements` is a Yorkie array proxy, the previous
implementation crashed in two compounding ways:

1. `{...reuse, frame: ..., placeholderRef: ...}` spread the proxy and
   captured non-serializable proxy methods (toJSON), which Yorkie's
   subsequent splice rejected with "Unsupported type of value:
   function".

2. Even with the spread removed, re-`splice`-ing the same proxy back
   into the array fails because Yorkie's CRDTArray proxy is not
   recognized as an Array (`Array.isArray` returns false on proxies),
   so `buildCRDTElement` falls through to `ObjectProxy.buildObjectMembers`,
   which `Object.entries(...)`s the proxy and surfaces the CRDTArray's
   internal `elements` field — a function — and throws.

The new shape decides each existing element's fate (reuse / demote /
delete / leave-alone) without removing it, mutates surviving entries
in place via per-field assignment (which Yorkie ObjectProxy turns into
proper CRDT operations), removes dead orphans via single-element
splices from highest index down, and pushes only fresh-from-spec
elements (plain JSON, safe to splice in). MemSlidesStore behavior is
unchanged; previously-broken Yorkie applyLayout now works.

T6's "edit + applyLayout" integration case re-enabled to pin the
regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Reuses renderThumbnail against a synthetic slide so picker cells
reflect the active theme without a dedicated icon set per theme.
The module-level cache is keyed by (theme, master, layout, size);
theme switches naturally route to different keys, so old entries
become GC-eligible without an explicit invalidation API.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
v1.0 layouts ship with empty staticElements arrays so the omission
was invisible, but v1.5 will populate them with decorative
dividers, page numbers, and footer text per the spec. Including
them now means picker cells reflect the full layout the user is
choosing, not just the placeholder skeleton.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Promotes the JSON deep-clone helper from store/memory.ts into
model/clone.ts so addSlide, applyLayoutToSlide, and any future
caller share a single implementation. Verified renderThumbnail
and its callees never mutate slide/element state, so the
synthetic-slide clones in layout-preview were dead defensive
copies; removing them shaves work per cache miss and aligns the
code with reality. The comment justifying the clones was also
factually wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The slides package stays framework-free, so the picker mounts a
plain div with a 4-column canvas grid. Both upcoming call sites
(split-button on the thumbnail panel, slide context menu) reuse
this single API rather than each rolling their own popover. Esc
and outside-click resolve to onClose without firing onPick.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Plain click still adds a blank slide so existing muscle memory
keeps working; the new dropdown zone opens the layout picker so
authors discover the eleven built-in layouts without leaving
the rail. Picker onPick routes through the same store.batch the
plain click uses, so undo collapses to one step.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Right-clicking the slide background now offers "Change layout…"
seeded with the current slide's layoutId so authors see what
they have selected. Pick fires applyLayout inside store.batch so
undo collapses to one step. The lastContext{X,Y} viewport coords
are captured at contextmenu time so the picker anchors at the
click position rather than at logical (slide) coords.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Records the surprises that came out of the eleven-task implementation —
chief among them the Yorkie array-proxy spread bug that broke a
naive "build new array, reassign" applyLayout. Encoding the lesson
makes the next agent jump straight to the splice-based pattern
instead of repeating the discovery from scratch.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Rate limit exceeded

@hackerwins has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 18 minutes and 18 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4474bb6f-b629-455c-96ee-31aa726306e3

📥 Commits

Reviewing files that changed from the base of the PR and between b1fff25 and 437fd52.

📒 Files selected for processing (21)
  • docs/design/slides/slides-layout-change.md
  • docs/tasks/README.md
  • docs/tasks/archive/2026/05/20260508-slides-layout-change-lessons.md
  • docs/tasks/archive/2026/05/20260508-slides-layout-change-todo.md
  • docs/tasks/archive/README.md
  • packages/frontend/src/app/slides/yorkie-slides-store.ts
  • packages/frontend/tests/app/slides/yorkie-slides-concurrent.integration.ts
  • packages/slides/src/index.ts
  • packages/slides/src/model/layout-apply.test.ts
  • packages/slides/src/model/layout.ts
  • packages/slides/src/model/master.ts
  • packages/slides/src/model/placeholder-blocks.ts
  • packages/slides/src/model/placeholder-hints.ts
  • packages/slides/src/store/memory.test.ts
  • packages/slides/src/store/memory.ts
  • packages/slides/src/view/canvas/element-renderer.ts
  • packages/slides/src/view/canvas/layout-preview.ts
  • packages/slides/src/view/canvas/text-renderer.test.ts
  • packages/slides/src/view/canvas/text-renderer.ts
  • packages/slides/src/view/editor/layout-picker.test.ts
  • packages/slides/src/view/editor/layout-picker.ts
📝 Walkthrough

Walkthrough

This PR implements end-to-end slide layout switching with placeholder identity preservation. The feature enables authors to change slide layouts while preserving element content and identity through typed slot matching. Changes span model types, a shared pure layout application algorithm, store integration, canvas preview rendering with caching, vanilla-DOM picker UI, and editor wiring with comprehensive tests and documentation.

Changes

Slides Layout Switching Feature

Layer / File(s) Summary
Type Definitions & Contracts
packages/slides/src/model/element.ts, packages/frontend/src/types/slides-document.ts, packages/slides/src/index.ts, packages/slides/src/model/presentation.ts
Introduces PlaceholderType (title, subtitle, body, etc.) and PlaceholderRef ({type, index}) types. Extends ElementBase and all Yorkie element variants with optional placeholderRef field. Refines PlaceholderSpec to require placeholder.type. Re-exports types from @wafflebase/slides.
Layout Slot Specifications
packages/slides/src/model/layout.ts, packages/slides/src/model/layout.test.ts
Updates textPlaceholder helper to accept PlaceholderType and emit typed placeholder specs. Assigns explicit placeholder types (title, subtitle, body, etc.) to all 11 built-in layouts. Adds snapshot test validating each layout's slot type sequence.
Layout Application Algorithm
packages/slides/src/model/clone.ts, packages/slides/src/model/element.ts, packages/slides/src/model/element.test.ts, packages/slides/src/model/layout.ts, packages/slides/src/model/layout-apply.test.ts
Adds clone<T> utility for snapshot cloning. Implements isElementEmpty predicate for orphan element classification. Exports slotRefsForLayout(layout) to compute per-slot placeholder identities and applyLayoutToSlide(slide, newLayout) to re-slot elements via type-indexed matching with demotion/deletion for orphans. Includes six behavioral test cases covering placeholder preservation, index-based matching, and orphan handling.
Public Module Exports
packages/slides/src/index.ts
Re-exports applyLayoutToSlide and slotRefsForLayout alongside existing layout utilities for downstream store/UI consumption.
MemSlidesStore Integration
packages/slides/src/store/memory.ts, packages/slides/src/store/memory.test.ts
addSlide computes slot refs via slotRefsForLayout and stamps placeholderRef on generated placeholder elements. applyLayout delegates to shared applyLayoutToSlide. Tests verify placeholder ref population for title-two-columns, blank, and caption layouts.
YorkieSlidesStore Integration
packages/frontend/src/app/slides/yorkie-slides-store.ts
addSlide stamps placeholderRef on Yorkie element proxies using slotRefsForLayout. applyLayout delegates to applyLayoutToSlide via cast slide within Yorkie mutation.
Layout Preview Rendering
packages/slides/src/view/canvas/layout-preview.ts, packages/slides/src/view/canvas/layout-preview.test.ts
Implements renderLayoutPreview(layout, theme, master, size) generating cached HTMLCanvasElement renderings. Caches by (theme, master, layout, width, height) string key. Constructs synthetic slides from layout placeholders and renders via renderThumbnail. Tests validate canvas sizing, cache reuse, and compatibility with all built-in layouts.
Layout Picker UI
packages/slides/src/view/editor/layout-picker.ts, packages/slides/src/view/editor/layout-picker.test.ts
Exports vanilla-DOM showLayoutPicker(host, opts) function creating fixed-position 4-column grid popover. Renders preview canvas per layout using current theme/master from store. Clicking a cell invokes onPick(layoutId) then closes. Escape keydown and outside mousedown invoke onClose without pick. Selected layout receives outline styling. Tests cover render, click, selection marking, escape, and outside-click behaviors.
Editor UI Integration
packages/slides/src/view/editor/thumbnail-panel.ts, packages/slides/src/view/editor/thumbnail-panel.test.ts, packages/slides/src/view/editor/editor.ts, packages/slides/src/view/editor/editor.test.ts
Thumbnail panel converts "+ Add slide" button into split-button: main zone adds blank slide, dropdown (▾) opens layout picker anchored to dropdown position; onPick adds slide with chosen layout. Editor captures last context-menu click position in viewport coordinates. Adds "Change layout…" canvas context menu item opening picker at click position seeded with current slide's layoutId. Tests verify split-button interaction and context-menu picker invocation with correct anchor and layout selection.
Concurrent Behavior
packages/frontend/tests/app/slides/yorkie-slides-concurrent.integration.ts
Adds Yorkie integration tests: (1) two clients concurrently applying different layouts converge on same layoutId, and (2) client A edits placeholder text while client B applies new layout; both clients converge on layout and title element persists across switch.
Documentation & Tasks
docs/design/README.md, docs/design/slides/slides-layout-change.md, docs/tasks/README.md, docs/tasks/active/20260508-slides-layout-change-todo.md, docs/tasks/active/20260508-slides-layout-change-lessons.md
Design document specifies feature scope, model semantics (placeholderRef, PlaceholderSpec.type), store behavior (addSlide annotation, applyLayout via applyLayoutToSlide), UI contracts (vanilla-DOM picker, context menu), preview caching, testing strategy, and risk/mitigation analysis. Task plan enumerates 11 implementation steps with sub-task guidance. Lessons document Yorkie array-splice gotchas, placeholderRef serialization, framework boundaries (vanilla DOM vs Radix), build ordering, element ordering preservation, clone location, and smoke-test ownership. README entries register the new feature and active task.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • wafflebase/wafflebase#190: Introduces the foundation for slides layouts and master/theme selection that this PR extends with placeholder identity tracking and layout switching.
  • wafflebase/wafflebase#191: Related slides package enhancements including layout/placeholder model and UI patterns that overlap with the layout switching feature implementation.

Poem

🐰 A rabbit hops through layout slots with glee,
Each placeholder type now keeps its ID!
When switching themes, the content doesn't flee—
Identity preserved in harmony.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the primary change: adding a layout change UI with placeholder identity tracking, which is the core feature enabling layout switching in the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slides-layout-change

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 159.2s

Lane Status Duration
sheets:build ✅ pass 12.9s
docs:build ✅ pass 11.6s
slides:build ✅ pass 8.4s
verify:fast ✅ pass 85.5s
frontend:build ✅ pass 16.0s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.5s
cli:build ✅ pass 1.9s
verify:entropy ✅ pass 18.1s

Verification: verify:integration

Result: ✅ PASS

@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/frontend/src/app/slides/yorkie-slides-store.ts (1)

502-532: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

rebuildSlide drops placeholderRef, silently breaking placeholder identity on slide reorder.

Both branches reconstruct each element with only id, type, frame, and dataplaceholderRef is not propagated. Since rebuildSlide is the only path through moveSlide (Line 469) and moveSlides (Line 483), any user drag-reorder erases placeholder identity. moveSlides is especially destructive because it rebuilds every slide in the deck, not just the moved ones (Lines 482‑486), so a single bulk reorder strips the ref from every element.

Once placeholderRef is gone, the next applyLayout on that slide treats former placeholders as user-added — content no longer follows its slot, and orphan demotion no longer applies. Integration tests under applyLayout cover the live-proxy path and don't exercise reorder-then-apply, so this slips through CI.

The same omission exists in replaceRoot's text branch (Lines 338‑345). It's currently masked because the snapshot comes from read(), which (per lessons.md §2) already strips the field — but fixing read() alone won’t fix replaceRoot; both reconstructions need the propagation.

🛠️ Proposed fix — propagate placeholderRef through both reconstructions
   private rebuildSlide(src: YorkieSlide): YorkieSlide {
     const background = yorkieToPlain<YorkieSlide['background']>((src as { background: unknown }).background);
     const layoutId = (src as { layoutId: string }).layoutId;
     const id = (src as { id: string }).id;
     const elements = ((src as { elements: unknown[] }).elements ?? []).map((e) => {
-      const el = e as { id: string; type: string; frame: unknown; data: unknown };
+      const el = e as {
+        id: string;
+        type: string;
+        frame: unknown;
+        data: unknown;
+        placeholderRef?: unknown;
+      };
+      const placeholderRef = el.placeholderRef
+        ? yorkieToPlain<YorkieElement['placeholderRef']>(el.placeholderRef)
+        : undefined;
       if (el.type === 'text') {
         const blocks = yorkieToPlain<Block[]>((el.data as { blocks?: unknown }).blocks) ?? [];
         return {
           id: el.id,
           type: 'text',
           frame: yorkieToPlain<Frame>(el.frame),
+          ...(placeholderRef ? { placeholderRef } : {}),
           data: { blocks: clone(blocks) },
         } as YorkieElement;
       }
       return {
         id: el.id,
         type: el.type as 'image' | 'shape',
         frame: yorkieToPlain<Frame>(el.frame),
+        ...(placeholderRef ? { placeholderRef } : {}),
         data: yorkieToPlain<object>(el.data),
       } as YorkieElement;
     });

A matching change is needed in replaceRoot's text branch around Line 338, and a follow-up to propagate placeholderRef in read() (per the lessons doc) — once read() carries the field, clone(e) in replaceRoot's non-text branch will pick it up automatically.

Worth adding a regression test along the lines of: addSlide('title-body') → moveSlide(...) → assert each element still has placeholderRef.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/frontend/src/app/slides/yorkie-slides-store.ts` around lines 502 -
532, rebuildSlide currently reconstructs elements without copying
placeholderRef, which causes placeholder identity to be lost during
moveSlide/moveSlides; update rebuildSlide's element mapping to propagate (e.g.
include (el as any).placeholderRef when building both the text branch and the
non-text branch so YorkieElement retains placeholderRef, and make the analogous
change in replaceRoot's text branch (replaceRoot) so that reconstructed text
elements also keep placeholderRef; also follow up by ensuring read() preserves
placeholderRef so clone(e) in replaceRoot's non-text branch carries it through.
🧹 Nitpick comments (1)
packages/slides/src/model/layout.ts (1)

295-300: ⚡ Quick win

Fresh placeholder leaks the spec's placeholder field into the Element.

clone(slot.spec) copies the entire PlaceholderSpec, which includes the extra placeholder: { type } field that doesn't belong on Element. The as Element cast hides this from TypeScript, but the property is silently retained on every freshly materialized placeholder — bloating snapshots, leaking layout-spec metadata into element data, and potentially confusing any code that pattern-matches on 'placeholder' in element.

♻️ Strip `placeholder` before composing the element
   for (let si = 0; si < slots.length; si++) {
     if (usedSlots.has(si)) continue;
     const slot = slots[si];
-    const fresh = {
-      ...clone(slot.spec),
-      id: generateId(),
-      placeholderRef: slot.ref,
-    } as Element;
+    const { placeholder: _ph, ...rest } = clone(slot.spec);
+    const fresh = {
+      ...rest,
+      id: generateId(),
+      placeholderRef: slot.ref,
+    } as Element;
     slide.elements.push(fresh);
   }

The same fix applies to the addSlide mappers in packages/slides/src/store/memory.ts and packages/frontend/src/app/slides/yorkie-slides-store.ts if they spread the spec the same way.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/slides/src/model/layout.ts` around lines 295 - 300, The fresh
Element creation is leaking the PlaceholderSpec.placeholder field by doing
clone(slot.spec) and casting to Element; modify the composition so you omit the
placeholder property before casting—e.g., create a shallow copy of slot.spec
without the placeholder key and then spread that into the new object with id:
generateId() and placeholderRef: slot.ref before pushing to slide.elements;
apply the same omission fix in the analogous mappers in addSlide within
packages/slides/src/store/memory.ts and
packages/frontend/src/app/slides/yorkie-slides-store.ts to prevent layout-spec
metadata from being retained on Element instances.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/frontend/tests/app/slides/yorkie-slides-concurrent.integration.ts`:
- Around line 175-198: The test currently mutates the title via
ctx.storeA.withTextElement(...) but never asserts the text value survived; add
explicit assertions after the sync and after the existing presence checks: use
ctx.storeA.read() and ctx.storeB.read(), locate the title element by id
(titleId) in a.slides[0].elements and b.slides[0].elements, and assert that the
element's inline text (e.g., element.inlines[0].text) equals 'Hello' on both A
and B to ensure the guarded write didn't silently no-op.

In `@packages/slides/src/model/layout.ts`:
- Around line 248-265: Reused placeholder-bearing elements (found in reuses via
slide.elements and slots) are left in their original positions, so final element
order can diverge from newLayout.placeholders; after the current matching loop
in applyLayoutToSlide, reorder only the surviving reused elements to follow the
order of newLayout.placeholders: build a map from placeholder ref (type+index)
to its reused element (from reuses), remove those reused elements from
slide.elements while preserving the relative order of non-placeholder/user-added
elements, then insert the reused elements back in the sequence in the exact
order of newLayout.placeholders so z-order/tab-order matches the new layout;
keep deletions/demotions logic unchanged.
- Around line 269-278: The demotion loop currently sets d.element.placeholderRef
= undefined which creates a "property set" CRDT operation; replace that
assignment with a delete operation so the field is removed from the Yorkie
object (i.e., use delete on the placeholderRef property for items in the
demotions array). Update the demotions loop that touches
d.element.placeholderRef (the demotions variable and element.placeholderRef
symbol) to call delete d.element.placeholderRef instead of assigning undefined
to ensure proper CRDT delete semantics and consistency with other code like
yorkie-slides-store.ts.

In `@packages/slides/src/view/canvas/layout-preview.ts`:
- Around line 53-69: The code currently caches the canvas even when
canvas.getContext('2d') returns null, causing a permanently blank canvas to be
reused; update the logic in layout-preview.ts so cache.set(key, canvas) only
runs when a valid 2D context was obtained (i.e., move or duplicate the cache.set
call inside the if (ctx) block after renderThumbnail or return early when ctx is
null), referencing the existing canvas.getContext('2d') check, the ctx variable,
renderThumbnail(ctx, ...), syntheticSlide(layout) and the cache.set(key, canvas)
call to locate and change the behavior.

In `@packages/slides/src/view/editor/layout-picker.ts`:
- Around line 31-32: The popover is positioned directly from opts.anchor.x/y
which can place it offscreen; after appending the popover element, measure its
size (popover.getBoundingClientRect()) and clamp left and top to the viewport
using window.innerWidth/innerHeight minus the popover width/height (and a small
margin, e.g. 8px), ensuring values are >= 0; update popover.style.left/top with
the clamped values (use the existing popover and opts.anchor symbols) so the
picker never extends past the right or bottom edges.
- Around line 49-99: Add basic keyboard/focus support so keyboard and assistive
tech can use the picker: make each cell focusable and operable (set
cell.tabIndex = 0 and role="button"), attach a keydown handler on the cell to
call opts.onPick(layout.id) and close() when Enter or Space is pressed
(preventDefault), and preserve the existing click handler; when creating the
popover set popover.tabIndex = -1 and move focus into it (popover.focus()) after
it is added so focus enters the dialog; keep existing close(), onKey(), and
onOutside() behavior and ensure dataset.selected/visual outline remains in sync
for the currently selected layout.

---

Outside diff comments:
In `@packages/frontend/src/app/slides/yorkie-slides-store.ts`:
- Around line 502-532: rebuildSlide currently reconstructs elements without
copying placeholderRef, which causes placeholder identity to be lost during
moveSlide/moveSlides; update rebuildSlide's element mapping to propagate (e.g.
include (el as any).placeholderRef when building both the text branch and the
non-text branch so YorkieElement retains placeholderRef, and make the analogous
change in replaceRoot's text branch (replaceRoot) so that reconstructed text
elements also keep placeholderRef; also follow up by ensuring read() preserves
placeholderRef so clone(e) in replaceRoot's non-text branch carries it through.

---

Nitpick comments:
In `@packages/slides/src/model/layout.ts`:
- Around line 295-300: The fresh Element creation is leaking the
PlaceholderSpec.placeholder field by doing clone(slot.spec) and casting to
Element; modify the composition so you omit the placeholder property before
casting—e.g., create a shallow copy of slot.spec without the placeholder key and
then spread that into the new object with id: generateId() and placeholderRef:
slot.ref before pushing to slide.elements; apply the same omission fix in the
analogous mappers in addSlide within packages/slides/src/store/memory.ts and
packages/frontend/src/app/slides/yorkie-slides-store.ts to prevent layout-spec
metadata from being retained on Element instances.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b63b2a6e-48b9-482d-9550-4d0ffa3d032a

📥 Commits

Reviewing files that changed from the base of the PR and between 3c2b924 and b1fff25.

📒 Files selected for processing (26)
  • docs/design/README.md
  • docs/design/slides/slides-layout-change.md
  • docs/tasks/README.md
  • docs/tasks/active/20260508-slides-layout-change-lessons.md
  • docs/tasks/active/20260508-slides-layout-change-todo.md
  • packages/frontend/src/app/slides/yorkie-slides-store.ts
  • packages/frontend/src/types/slides-document.ts
  • packages/frontend/tests/app/slides/yorkie-slides-concurrent.integration.ts
  • packages/slides/src/index.ts
  • packages/slides/src/model/clone.ts
  • packages/slides/src/model/element.test.ts
  • packages/slides/src/model/element.ts
  • packages/slides/src/model/layout-apply.test.ts
  • packages/slides/src/model/layout.test.ts
  • packages/slides/src/model/layout.ts
  • packages/slides/src/model/presentation.ts
  • packages/slides/src/store/memory.test.ts
  • packages/slides/src/store/memory.ts
  • packages/slides/src/view/canvas/layout-preview.test.ts
  • packages/slides/src/view/canvas/layout-preview.ts
  • packages/slides/src/view/editor/editor.test.ts
  • packages/slides/src/view/editor/editor.ts
  • packages/slides/src/view/editor/layout-picker.test.ts
  • packages/slides/src/view/editor/layout-picker.ts
  • packages/slides/src/view/editor/thumbnail-panel.test.ts
  • packages/slides/src/view/editor/thumbnail-panel.ts

Comment thread packages/slides/src/model/layout.ts
Comment thread packages/slides/src/model/layout.ts
Comment thread packages/slides/src/view/canvas/layout-preview.ts
Comment thread packages/slides/src/view/editor/layout-picker.ts
Comment thread packages/slides/src/view/editor/layout-picker.ts
hackerwins and others added 9 commits May 9, 2026 08:22
Assigning undefined to a Yorkie ObjectProxy field generates a
"property set" CRDT op that keeps the field with an undefined
value; delete generates the correct "remove field" op so peers
and the read view never see a leftover key. Functionally both
worked for our truthy-check partition, but the delete form is
faithful to the lessons file's algorithm description and matches
yorkie-slides-store.ts. Also documents the array-order
preservation contract that the T6.1 rewrite established but the
docstring did not yet state.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Layout picker now exposes role=listbox + role=option, focusable
  cells, Enter/Space handler, and an on-mount focus move so
  keyboard and assistive-tech users can drive the feature.
- Picker also clamps to the viewport so the 4-column grid never
  clips off the right or bottom edge when invoked near a corner.
- renderLayoutPreview no longer caches a blank canvas when the
  2D context is unavailable, leaving callers free to retry.
- The "edit + applyLayout" Yorkie integration test now asserts
  the title text actually survived rather than letting the
  guarded write silently no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Surfaced during PR #197 smoke: a fresh slide from a non-blank
layout shows empty placeholder boxes that aren't visually
distinguishable from each other. Authors can't tell where the
title goes vs. the body. Plans the same Google Slides / Keynote
fix — muted "Click to add title" hints inside empty placeholders
that vanish on first character typed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A new slide from a non-blank layout otherwise reads as visually
empty — authors cannot tell where the title goes vs. the body.
Google Slides / PowerPoint / Keynote all solve this with muted
"Click to add title" hints inside empty placeholders. The hint
table is a single seam ready for future i18n, and only fires for
ref-bearing elements so user-added text boxes are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
read() rebuilt each element from the Yorkie proxy but dropped the
placeholderRef field, so the rendering path saw user-added shapes
where layout placeholders should have been. T12 ghost text
relies on this signal — without the propagation the hint never
fired in the live editor even though MemSlidesStore tests had
been green all along. Lessons file flagged this as latent in
T6.1; T12 is what made it bite.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Marks lesson #2 as no longer latent; T12 ghost text was the
trigger that turned the dropped-field bug into a live render
miss, and the fix is one yorkieToPlain unwrap per element
branch. The new lesson cites the audit rule for future agents:
new element fields need both write (addSlide) AND read
(read()) propagation, MemSlidesStore parity tests miss it.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The first cut of T12 hard-coded 32px sans-serif left-aligned for
every slot, so the title hint and the body hint and the caption
hint all looked identical — defeating the whole point of the
hint, which is to telegraph what the slot will look like once
typed into. Hint now reads its font role + size, color role, and
alignment from the active master's PlaceholderStyle, matching
the typography the user will actually see on first character.

Adds caption (14 px body / textSecondary) and big-number (96 px
heading / centered) entries to DEFAULT_MASTER so all five
placeholder slots have a defined style.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Ghost text already adopts the slot's master PlaceholderStyle
(font role/size, color role, alignment), but typed text was
falling back to the docs DEFAULT_INLINE_STYLE (11px Arial) so
the user saw a tiny black string where the ghost had promised
a 44px heading. Seeding the placeholder element's first inline
with fontSize, fontFamily, and a role-bound color at addSlide
time means typed characters inherit the right typography from
the very first keystroke. applyLayoutToSlide takes the same
seeding path for slots it has to materialize fresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Marks every step in the todo file as completed and moves the
todo + lessons pair into docs/tasks/archive/2026/05/ so
docs/tasks/active/ shows only in-flight work. The lessons
file's #2 entry was already updated mid-PR when the
read() placeholderRef stripping turned out to be live, not
latent — that record carries forward into the archive.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@hackerwins
hackerwins merged commit 008d8b5 into main May 9, 2026
1 check passed
@hackerwins
hackerwins deleted the slides-layout-change branch May 9, 2026 01:03
@hackerwins hackerwins mentioned this pull request May 11, 2026
6 tasks
hackerwins added a commit that referenced this pull request May 11, 2026
Adds @wafflebase/slides as a third surface alongside Sheets and Docs,
plus 53-shape library (Phase 1+2), adjustment handles for 9 pilot
shapes (P3-A.1), live snap guides, align/distribute toolbar, themed
authoring, and layout-change UI. Also ships Sheets cell comments
(Phase B), docs peer-avatar caret jump, yorkie-js-sdk 0.7.8 upgrade,
and CLI/REST API improvements (docs export imageFetcher, expired
session refresh, REST docs split). Minor bump because slides is a
new top-level package.

Highlights: #184 #185 #186 #187 #188 #189 #190 #191 #192 #197 #198
#201 #202 #203 #204 #205 #206 #207 #209 #210
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.

1 participant