fix(editor-v2): fix cm6 to rows-derived height, eliminate layout shift

CLS verified zero (0.00000) on /blueprints/1 and /overlays/1 via
PerformanceObserver({type: 'layout-shift', buffered: true}) on a
real browser session — previously CLS=0.00859 from a 253 px shift
when cm6 mounted into a display:none slot.

Mechanism:
- editor-entry.js: mount() accepts `rows`. When provided, prepends
  an EditorView.theme that pins
    .cm-editor { height: calc(rows * 1.84rem + 1.125rem) }
  and sets .cm-scroller overflow:auto. cm6 renders at a fixed,
  predictable height; long content scrolls internally (same UX the
  raw <textarea rows="N"> used to give).
- editor.js: reads textarea.rows attribute and passes it to mount().
- editor.css: new .editor-mount wrapper uses the same calc on
  min-height keyed off an inline --editor-rows CSS custom property,
  so the slot is pre-reserved BEFORE cm6 mounts. Wrapper and cm6
  match exactly (browser-measured 254 / 254 px for rows=8 and
  607 / 607 px for rows=20).
- Templates: each editor textarea wrapped in
  <div class="editor-mount" style="--editor-rows: N">. Single source
  of truth on N (only the rows attribute + the inline custom prop
  vary per call site).

Per-row metric 1.84 rem derived empirically: 253 px for rows=8 minus
1.125 rem chrome = 235 px content, ÷ 8 ≈ 29.4 px = 1.84 rem.

Fast suite + e2e suite still green (3 + 2 pass, 0 fail).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 10:27:28 +02:00
parent 2f1a1ef284
commit 54842f71c6
No known key found for this signature in database
7 changed files with 30 additions and 8 deletions

View file

@ -9,10 +9,17 @@
* in _editor_assets.html un-hides for JS-disabled users. */ * in _editor_assets.html un-hides for JS-disabled users. */
textarea[data-editor-language] { display: none; } textarea[data-editor-language] { display: none; }
/* Pre-reserves the slot cm6 will mount into. The min-height formula
* matches the height EditorView.theme applies inside editor-entry.js,
* keyed off the textarea's `rows` attribute via the inline --editor-rows
* custom property. Same expression on both sides = zero layout shift. */
.editor-mount {
min-height: calc(var(--editor-rows, 8) * 1.84rem + 1.125rem);
}
.cm-editor { .cm-editor {
border: var(--line); border: var(--line);
border-radius: var(--radius-s); border-radius: var(--radius-s);
min-height: 8em;
} }
.cm-editor.cm-focused { .cm-editor.cm-focused {

View file

@ -52,7 +52,8 @@
} }
const vocab = (lang === "srccfg") ? await loadSrccfgVocab() : null; const vocab = (lang === "srccfg") ? await loadSrccfgVocab() : null;
const controller = window.__editor.mount(textarea, { language: lang, vocab }); const rows = parseInt(textarea.getAttribute("rows") || "0", 10) || 0;
const controller = window.__editor.mount(textarea, { language: lang, vocab, rows });
// Submit-time copy bridge // Submit-time copy bridge
const form = textarea.closest("form"); const form = textarea.closest("form");

File diff suppressed because one or more lines are too long

View file

@ -1,2 +1,2 @@
6700b694fe25837f52e77c780d88f3eb5aef2a1591dc461c26efa3fa9724290b editor.bundle.js 910031cfc346106af240df71b9ef8069f1b38f1a4c63128392c2aa074e7e57b2 editor.bundle.js
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 editor.bundle.css e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 editor.bundle.css

View file

@ -51,7 +51,7 @@
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg <pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
{% endfor %}</pre> {% endfor %}</pre>
{% endif %} {% endif %}
<textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea> <div class="editor-mount" style="--editor-rows: 8"><textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea></div>
</div> </div>
</label> </label>
<button type="submit">Save blueprint</button> <button type="submit">Save blueprint</button>

View file

@ -24,7 +24,7 @@
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack"> <form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Bash script <label>Bash script
<textarea name="script" rows="20" spellcheck="false" data-editor-language="bash">{{ overlay.script or "" }}</textarea> <div class="editor-mount" style="--editor-rows: 20"><textarea name="script" rows="20" spellcheck="false" data-editor-language="bash">{{ overlay.script or "" }}</textarea></div>
</label> </label>
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p> <p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
{% if not latest_build_is_running %} {% if not latest_build_is_running %}
@ -186,7 +186,7 @@
</label> </label>
<label class="files-editor-field"> <label class="files-editor-field">
<span class="files-field-label">Content</span> <span class="files-field-label">Content</span>
<textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto"></textarea> <div class="editor-mount" style="--editor-rows: 14"><textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto"></textarea></div>
</label> </label>
<div class="files-editor-meta muted"> <div class="files-editor-meta muted">
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span> <span class="files-editor-byte-count">UTF-8 · 0 bytes</span>

View file

@ -21,7 +21,7 @@ function pickThemeForMatchMedia(mm) {
return mm.matches ? editorDarkTheme : editorLightTheme; return mm.matches ? editorDarkTheme : editorLightTheme;
} }
function mount(textarea, { language = "plain", vocab = null } = {}) { function mount(textarea, { language = "plain", vocab = null, rows = 0 } = {}) {
const langCompartment = new Compartment(); const langCompartment = new Compartment();
const themeCompartment = new Compartment(); const themeCompartment = new Compartment();
const autocompleteCompartment = new Compartment(); const autocompleteCompartment = new Compartment();
@ -29,7 +29,21 @@ function mount(textarea, { language = "plain", vocab = null } = {}) {
const lang = pickLanguage(language); const lang = pickLanguage(language);
const mm = window.matchMedia("(prefers-color-scheme: dark)"); const mm = window.matchMedia("(prefers-color-scheme: dark)");
// Fix the editor's rendered height to match the textarea's `rows`
// attribute via a small EditorView.theme — keeps the wrapper's
// pre-reserved space (editor.css `.editor-mount`) and the actual
// cm6 height in lockstep, eliminating the mount-time layout shift.
// Empirical per-row metric on this build: 1.84rem content + 1.125rem
// chrome (8px top + 8px bottom padding + 1px×2 border).
const heightThemes = rows > 0 ? [
EditorView.theme({
"&": { height: `calc(${rows} * 1.84rem + 1.125rem)` },
".cm-scroller": { overflow: "auto" },
}),
] : [];
const extensions = [ const extensions = [
...heightThemes,
history(), history(),
lineNumbers(), lineNumbers(),
highlightActiveLine(), highlightActiveLine(),