Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
629270d
docs: add approved inline text editing design spec
DavidBabinec Jun 10, 2026
f975e9a
feat(module-engine): add inlineTextEdit contract, declare on text/but…
DavidBabinec Jun 10, 2026
a0a7f24
feat(editor-store): add inline text edit session slice with coalesced…
DavidBabinec Jun 10, 2026
6bfcbf6
feat(editor-store): force-close inline edit sessions on deletion and …
DavidBabinec Jun 11, 2026
20edb1d
feat(canvas): add inline-edit typography mirroring helpers
DavidBabinec Jun 11, 2026
6bad4b7
feat(canvas): add InlineTextEditOverlay, mounted per breakpoint frame
DavidBabinec Jun 11, 2026
ee59e9f
feat(canvas): wire double-click inline editing and hide doubled text
DavidBabinec Jun 11, 2026
41b2575
docs: document canvas inline text editing (parent-doc overlay)
DavidBabinec Jun 11, 2026
a617084
docs: fix remaining stale slice counts in editor.md (11 -> 12)
DavidBabinec Jun 11, 2026
83bf384
docs: fix stale slice count in adminUi header comment (11 -> 12)
DavidBabinec Jun 11, 2026
7447335
Merge origin/main into feat/inline-text-editing
DavidBabinec Jun 11, 2026
6cfeb1a
fix(canvas): render canvas + inline-edit field in the site's fonts, n…
DavidBabinec Jun 11, 2026
61b3457
fix(canvas): stop inline-edit field scroll-shifting multi-line text
DavidBabinec Jun 11, 2026
b7b265a
fix(canvas): remove inline-edit open flash by positioning in a layout…
DavidBabinec Jun 11, 2026
a23853d
feat(canvas): rewrite inline text editing to edit the real element in…
DavidBabinec Jun 11, 2026
84b59ea
fix(canvas): stop React clobbering the inline-edit caret every keystroke
DavidBabinec Jun 11, 2026
4fe6f20
fix(canvas): let the spacebar type a space during inline editing
DavidBabinec Jun 11, 2026
3486ff0
fix(canvas): finalize inline editing — keyboard-forward guard, select…
DavidBabinec Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(canvas): render canvas + inline-edit field in the site's fonts, n…
…ot the editor's

Two font-fidelity bugs the inline text editor exposed:

1. EditorChromeInjector copied the admin app's --font-sans (Inter) straight
   onto the iframe :root. The injector is unlayered, so it beat the site's
   own --font-sans (which lives in @layer user-authored) — every canvas
   element silently rendered in the editor font instead of the site's
   configured font (e.g. PP Right Grotesk), and Inter isn't even loaded in
   the iframe so it fell back to Helvetica. Forward the editor font under a
   chrome-namespaced --editor-chrome-font-sans instead; the site's
   --font-sans is left alone and now wins for content. Admin chrome is
   unchanged (its own --font-sans still drives the UI).

2. The inline-edit field is a parent-document overlay, but the site's font
   @font-face rules were injected only into the canvas iframe. Mirroring a
   node's font-family onto the field couldn't render it — the parent fell
   back to a different typeface than the published text the field covers.
   New ParentDocumentSiteFontsInjector (mounted in CanvasRoot) injects the
   site @font-face into the parent <head> — faces only, no --font-* token
   variables, so the admin UI font is untouched.

Verified in a real browser: the edited heading's text now measures
byte-identical between the canvas and the overlay field (0px delta, both
PP Right Grotesk), where it was ~6.7% off before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • Loading branch information
DavidBabinec and claude committed Jun 11, 2026
commit 6cfeb1aec6b48644dad0dec7e6946333fa98faff
3 changes: 2 additions & 1 deletion docs/features/canvas-iframe-per-frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Five `<style>` elements are injected per iframe (three from `ClassStyleInjector`

| Injector | `id` attribute | Cascade layer | Purpose |
|---|---|---|---|
| `EditorChromeInjector` | `instatic-editor-chrome` | **unlayered** | Editor chrome: placeholder, slot-instance, unknown-module styles. Copies design tokens (`--editor-*`) from parent `:root` onto iframe `:root`. |
| `EditorChromeInjector` | `instatic-editor-chrome` | **unlayered** | Editor chrome: placeholder, slot-instance, unknown-module styles. Copies design tokens (`--editor-*`) from parent `:root` onto iframe `:root`. The editor UI font is forwarded as a **chrome-namespaced** `--editor-chrome-font-sans` (NOT `--font-sans`): because the injector is unlayered it would otherwise clobber the site's own `--font-sans` and render all canvas content in the editor font. |
| `ClassStyleInjector` | `mc-classes` | `@layer user-authored` | Publisher reset + framework CSS + class registry CSS |
| `ClassStyleInjector` | `mc-classes-preview` | `@layer user-authored` | Higher-specificity preview rule (doubled selector) while a property control is hovered. Empty for state-pseudo rules — those use `mc-classes-force-state` instead. |
| `ClassStyleInjector` | `mc-classes-force-state` | `@layer user-authored` | Force-paints the active state-pseudo rule (`.btn:hover`, `.card:focus`, etc.) onto the selected node via a doubled `[data-node-id]` selector so the state is visible/editable without physically triggering it. Mirrors the full `contextStyles` emission per breakpoint and condition. |
Expand Down Expand Up @@ -160,6 +160,7 @@ Double-click a text-bearing node to edit its text in place. The editor is a real
- **Live commit:** every keystroke calls `updateNodeProps(nodeId, { [prop]: value })`. Single-field patches coalesce under `props:<nodeId>:<prop>`, so the whole burst is ONE undo entry and every other frame previews the edit live. `startInlineEdit`/`endInlineEdit` reset `_historyCoalesceKey` so the session burst never folds into a Properties-panel burst for the same prop.
- **Hidden doubled text:** the edited node — in the session's frame only — carries `data-instatic-inline-editing`, and the canvas-chrome CSS paints it with `-webkit-text-fill-color: transparent` (NOT `color: transparent`: the overlay mirrors the element's computed `color` for the field's own text).
- **Typography mirroring:** `mirrorInlineEditTypography` (canvasOverlayGeometry.ts) copies font family/weight/style, color, text-align, and text-transform from `iframe.contentWindow.getComputedStyle(el)` and scales font-size / line-height / letter-spacing by the iframe zoom factor on every RAF tick.
- **Font parity:** the field lives in the parent document, so for the mirrored `font-family` to render the SAME typeface as the canvas, the parent needs the site's font files too. `ParentDocumentSiteFontsInjector` (mounted in `CanvasRoot`) injects the site's `@font-face` rules — via `generateSiteFontsCss`, **faces only, no `--font-*` token variables** so the admin UI's own `--font-sans` is untouched — into the parent `<head>`. Without it the field falls back to a different typeface than the published text it covers (custom/Google site fonts are loaded only inside the iframe).
- **End:** Enter or Cmd/Ctrl+Enter commits + closes — plain Enter commits even in multiline mode because `base.text` renders raw text into HTML where newlines collapse; Shift+Enter inserts a native newline for authors who add `white-space: pre-wrap` themselves. Blur commits + closes. Escape cancels: a single `undo()` iff the session committed anything.
- **Force-close:** node deleted (pruned in `pruneCanvasSelectionDraft`), document/page switch (`clearCanvasSelectionDraft`), frame unmount (breakpoint collapsed, live-mode switch), or an unmeasurable rect mid-session.

Expand Down
42 changes: 42 additions & 0 deletions src/__tests__/canvas/editorChromeInjector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Regression guard: the editor-chrome injector must NOT overwrite the site's
* own `--font-sans` on the iframe `:root`.
*
* The injector is unlayered, so anything it sets on `:root` beats the site's
* font tokens (which live in `@layer user-authored`). It used to copy the
* admin `--font-sans` straight onto the iframe root, silently rendering every
* canvas element in the editor's font instead of the site's configured one —
* and making parent-doc overlays (the inline text-edit field) mismatch. The
* editor font must ride a chrome-namespaced variable instead.
*/
import { describe, expect, it, afterEach } from 'bun:test'
import { render, cleanup } from '@testing-library/react'
import { EditorChromeInjector } from '@site/canvas/EditorChromeInjector'

afterEach(cleanup)

/** A detached document whose :root carries the admin `--font-sans`. */
function makeParentDoc(): Document {
document.documentElement.style.setProperty('--font-sans', '"Inter Variable", system-ui, sans-serif')
return document
}

describe('EditorChromeInjector font isolation', () => {
it('forwards the editor font under a chrome-namespaced variable, never the site --font-sans', () => {
const target = document.implementation.createHTMLDocument('iframe')
render(<EditorChromeInjector targetDocument={target} parentDocument={makeParentDoc()} />)

const css = target.getElementById('instatic-editor-chrome')?.textContent ?? ''
expect(css).not.toBe('')

// The chrome font is exposed as a namespaced var carrying the editor font…
expect(css).toContain('--editor-chrome-font-sans: "Inter Variable", system-ui, sans-serif;')
// …and chrome rules reference it.
expect(css).toContain('font-family: var(--editor-chrome-font-sans);')

// It must NEVER set the site's own --font-sans on :root, nor reference it —
// doing so clobbers the site's font tokens for all canvas content.
expect(css).not.toMatch(/^\s*--font-sans:/m)
expect(css).not.toContain('var(--font-sans)')
})
})
5 changes: 5 additions & 0 deletions src/admin/pages/site/canvas/CanvasRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { useRuntimeScriptBuild } from './useRuntimeScriptBuild'
import { CanvasNotch } from './CanvasNotch'
import { CanvasModeToggle } from './CanvasModeToggle'
import { CanvasContextSelector } from './CanvasContextSelector'
import { ParentDocumentSiteFontsInjector } from './ParentDocumentSiteFontsInjector'
import { CanvasSelectionContext, CanvasViewportActionsContext } from './CanvasContexts'
// Class / user-stylesheet injectors are now mounted per breakpoint frame
// (inside each iframe's document) by `IframeFrameSurface`. CanvasRoot no
Expand Down Expand Up @@ -366,6 +367,10 @@ export function CanvasRoot({ editable = true }: CanvasRootProps) {
return (
<CanvasViewportActionsContext.Provider value={viewportActionsContextValue}>
<CanvasSelectionContext.Provider value={selectionContextValue}>
{/* Mirror the site's @font-face into the parent document so parent-doc
overlays (the inline text-edit field) render site fonts identically
to the canvas iframe. */}
<ParentDocumentSiteFontsInjector />
<div
ref={canvasRef}
role="region"
Expand Down
38 changes: 27 additions & 11 deletions src/admin/pages/site/canvas/EditorChromeInjector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,21 @@ const CHROME_TOKENS = [
'--editor-border-med',
'--editor-border',
'--editor-danger',
'--font-sans',
] as const

/**
* The editor's UI font, forwarded into the iframe for CHROME elements ONLY.
*
* Read from the parent's `--font-sans` but WRITTEN under a chrome-namespaced
* variable. Setting `--font-sans` itself on the iframe `:root` (as this used
* to) clobbers the SITE's `--font-sans` — the chrome injector is unlayered,
* and unlayered always beats the site's font tokens in `@layer user-authored`,
* so every canvas element silently rendered in the editor's font instead of
* the site's configured one. CHROME_RULES reference `var(--editor-chrome-font-sans)`.
*/
const CHROME_FONT_SOURCE = '--font-sans'
const CHROME_FONT_TARGET = '--editor-chrome-font-sans'

interface EditorChromeInjectorProps {
/** The iframe document to inject the chrome stylesheet into. */
targetDocument: Document
Expand All @@ -78,7 +90,9 @@ interface EditorChromeInjectorProps {
/**
* Read the listed tokens from parentDoc's computed :root and return a
* `:root { ... }` block that sets them on the iframe's root. Only tokens
* that resolve to a non-empty value are included.
* that resolve to a non-empty value are included. The editor font is mapped
* onto a chrome-namespaced variable so it never overrides the site's own
* `--font-sans` (see CHROME_FONT_TARGET).
*
* Module-scope so the React Compiler doesn't flag the getComputedStyle call
* as a side-effect inside a component body.
Expand All @@ -89,6 +103,8 @@ function buildTokenBlock(parentDoc: Document): string {
const value = parentStyles.getPropertyValue(token).trim()
return value ? [` ${token}: ${value};`] : []
})
const chromeFont = parentStyles.getPropertyValue(CHROME_FONT_SOURCE).trim()
if (chromeFont) declarations.push(` ${CHROME_FONT_TARGET}: ${chromeFont};`)
if (declarations.length === 0) return ''
return `:root {\n${declarations.join('\n')}\n}`
}
Expand Down Expand Up @@ -122,7 +138,7 @@ const CHROME_RULES = `
background: var(--canvas-placeholder-bg);
color: var(--editor-text-muted);
font-size: 12px;
font-family: var(--font-sans);
font-family: var(--editor-chrome-font-sans);
font-weight: 400;
font-style: normal;
line-height: 1.4;
Expand Down Expand Up @@ -208,7 +224,7 @@ const CHROME_RULES = `
padding: 0;
color: var(--editor-text-secondary);
font-size: 12px;
font-family: var(--font-sans);
font-family: var(--editor-chrome-font-sans);
font-weight: 600;
font-style: normal;
line-height: 1.4;
Expand All @@ -228,7 +244,7 @@ const CHROME_RULES = `
padding: 0;
color: var(--editor-text-muted);
font-size: 11px;
font-family: var(--font-sans);
font-family: var(--editor-chrome-font-sans);
font-weight: 500;
font-style: normal;
line-height: 1.4;
Expand All @@ -255,7 +271,7 @@ const CHROME_RULES = `
background: var(--editor-surface);
color: var(--editor-text-bright);
font-size: 11px;
font-family: var(--font-sans);
font-family: var(--editor-chrome-font-sans);
font-weight: 600;
font-style: normal;
letter-spacing: 0.01em;
Expand Down Expand Up @@ -291,7 +307,7 @@ const CHROME_RULES = `
box-sizing: border-box;
color: var(--editor-text-muted);
font-size: 11px;
font-family: var(--font-sans);
font-family: var(--editor-chrome-font-sans);
font-weight: 400;
font-style: normal;
letter-spacing: normal;
Expand All @@ -307,7 +323,7 @@ const CHROME_RULES = `
border-bottom: 1px dashed var(--editor-border);
color: var(--editor-text-muted);
font-size: 11px;
font-family: var(--font-sans);
font-family: var(--editor-chrome-font-sans);
font-weight: 400;
font-style: normal;
line-height: 1.5;
Expand All @@ -323,7 +339,7 @@ const CHROME_RULES = `
color: var(--editor-text-secondary);
font-size: 11px;
font-style: italic;
font-family: var(--font-sans);
font-family: var(--editor-chrome-font-sans);
font-weight: 400;
line-height: inherit;
letter-spacing: normal;
Expand All @@ -344,7 +360,7 @@ const CHROME_RULES = `
[data-instatic-list-placeholder] {
color: var(--editor-text-muted);
margin-bottom: 6px;
font-family: var(--font-sans);
font-family: var(--editor-chrome-font-sans);
font-weight: initial;
font-style: initial;
font-size: initial;
Expand All @@ -361,7 +377,7 @@ const CHROME_RULES = `
outline: 1px dashed var(--editor-danger);
padding: 4px;
color: var(--editor-text-muted);
font-family: var(--font-sans);
font-family: var(--editor-chrome-font-sans);
font-size: 12px;
font-weight: 400;
font-style: normal;
Expand Down
56 changes: 56 additions & 0 deletions src/admin/pages/site/canvas/ParentDocumentSiteFontsInjector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* ParentDocumentSiteFontsInjector — makes the site's installed fonts
* (`@font-face` only) available in the PARENT editor document.
*
* Why this exists
* ───────────────
* The canvas renders each breakpoint inside an iframe, and the site's font
* `@font-face` rules are injected there by `ClassStyleInjector`. But overlays
* that live in the PARENT document and sit visually on top of the canvas — the
* inline text-edit field (`InlineTextEditOverlay`) — render in the parent's
* font environment, which only has the admin UI font (Inter). When such an
* overlay mirrors a node's computed `font-family` (e.g. "PP Right Grotesk"),
* the parent has no matching `@font-face`, so the browser falls back to a
* different typeface and the overlay reads as a different font than the
* published text it covers.
*
* Injecting ONLY the `@font-face` rules (never the `--font-*` token variables,
* which would re-point the admin chrome's own `--font-sans`) gives the parent
* document the exact same font *resources* as the iframe. Custom and Google
* fonts have unique family names, so they cannot collide with the admin UI.
*
* One instance, mounted at the canvas root.
*/
import { useEffect } from 'react'
import { useEditorStore } from '@site/store/store'
import { generateSiteFontsCss } from '@core/fonts'

const STYLE_TAG_ID = 'instatic-parent-site-fonts'

export function ParentDocumentSiteFontsInjector() {
const fonts = useEditorStore((s) => s.site?.settings.fonts ?? null)

useEffect(() => {
// `@font-face` only — `generateSiteFontsCss` never emits the `--font-*`
// token variables, so the admin UI's own `--font-sans` is untouched.
const css = generateSiteFontsCss(fonts)
const head = document.head
let styleEl = document.getElementById(STYLE_TAG_ID) as HTMLStyleElement | null

if (!css) {
styleEl?.remove()
return
}
if (!styleEl) {
styleEl = document.createElement('style')
styleEl.id = STYLE_TAG_ID
head.appendChild(styleEl)
}
if (styleEl.textContent !== css) styleEl.textContent = css
}, [fonts])

// The stylesheet is global to the document; intentionally NOT removed on
// unmount — the editor owns the parent document for its whole lifetime, and
// leaving the faces cached avoids a reload flash when the canvas remounts.
return null
}