Compare commits

...

5 commits

Author SHA1 Message Date
mwiegand
2942467cfd
feat(files-overlay): filename click opens editor, actions align next to row
Replaces the dedicated edit button with a click target on the filename
itself (download stays as a separate ⬇ action). Drops margin-left:auto on
.files-row-actions so action buttons sit immediately after the row's name
instead of at the far right.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:40:54 +02:00
mwiegand
54842f71c6
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>
2026-05-17 10:27:28 +02:00
mwiegand
2f1a1ef284
Revert "fix(editor-v2): reserve editor slot to stop layout shift on mount"
This reverts commit b915f2e766.
2026-05-17 02:34:24 +02:00
mwiegand
b915f2e766
fix(editor-v2): reserve editor slot to stop layout shift on mount
The previous flicker fix hid the textarea via CSS but display: none
removes it from layout entirely — so the page rendered with zero
height where the editor would go, then cm6 mounted and pushed the
surrounding form down by its full height (CLS).

Wrap each editor textarea in <div class="editor-mount" style="min-height: …rem">
so the slot is reserved before cm6 mounts. The wrapper is a flex
column with cm6 as flex: 1 so cm6 fills the reserved space rather
than collapsing to content-height with a gap below (the seeded
blueprint has 2 chars of content; without flex the editor would
shrink to one line).

Min-heights calibrated to rows × ~1.25rem + ~1.5rem chrome:
- config (rows=8)  → 12rem
- files (rows=14)  → 19rem
- script (rows=20) → 27rem

.cm-editor's own min-height: 8em rule removed — the wrapper is the
floor now, and the inner cm6 stretches to fill via flex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:29:43 +02:00
mwiegand
fd0d96b349
fix(editor-v2): eliminate first-paint flicker
Three changes that together stop the page from briefly painting the
raw textareas before cm6 takes over:

1. base.html gains a {% block extra_head %}{% endblock %} hook.
2. blueprint_detail.html and overlay_detail.html include
   _editor_assets.html via that extra_head block instead of inside
   {% block content %}. Editor CSS now loads from <head>, so the
   textarea pre-hide rule (added below) applies before first paint;
   the defer'd scripts also download in parallel with HTML parse,
   which is the better default anyway.
3. editor.css adds
      textarea[data-editor-language] { display: none; }
   so opt-in textareas are hidden from the very first paint.

editor.js + _editor_assets.html cover the three paths the pre-hide
must not break:
- bundle didn't load: top-of-IIFE bails early and un-hides every
  matching textarea via style.display = "revert".
- per-textarea mount throws: init()'s catch un-hides that specific
  textarea so the form stays usable.
- JS disabled entirely: _editor_assets.html ships a <noscript>
  <style> override that un-hides via display: revert.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:25:52 +02:00
12 changed files with 93 additions and 19 deletions

View file

@ -254,6 +254,23 @@ dialog.modal::backdrop {
padding-left: calc(1ch + var(--space-xs)); padding-left: calc(1ch + var(--space-xs));
} }
.file-tree-name-button {
background: none;
border: 0;
padding: 0;
font: inherit;
color: var(--color-link);
cursor: pointer;
text-align: left;
text-decoration: underline;
text-underline-offset: 2px;
}
.file-tree-name-button:hover,
.file-tree-name-button:focus-visible {
text-decoration-thickness: 2px;
}
.file-tree-toggle[aria-expanded="true"] .chevron { .file-tree-toggle[aria-expanded="true"] .chevron {
transform: rotate(90deg); transform: rotate(90deg);
} }
@ -583,8 +600,7 @@ button.danger-outline:hover {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-xs); gap: var(--space-xs);
margin-left: auto; padding-left: var(--space-s);
padding-left: var(--space-m);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity 80ms ease; transition: opacity 80ms ease;

View file

@ -3,10 +3,23 @@
* in tokens.css; this file scopes the editor container's chrome to * in tokens.css; this file scopes the editor container's chrome to
* match the rest of the app. */ * match the rest of the app. */
/* Pre-hide opt-in textareas to avoid the textarea-visible-then-hidden
* flicker on page load. editor.js un-hides them again if the bundle
* fails to load or a per-textarea mount throws. The <noscript> rule
* in _editor_assets.html un-hides for JS-disabled users. */
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

@ -4,8 +4,23 @@
// a named alias for the files-editor modal. // a named alias for the files-editor modal.
(function () { (function () {
"use strict"; "use strict";
// editor.css pre-hides every textarea[data-editor-language] so the
// page never paints the raw textarea before cm6 takes over. Both
// failure paths below restore the textarea by clearing the CSS rule
// with an inline display:revert.
function unhideTextarea(ta) {
ta.style.display = "revert";
}
if (!window.__editor || typeof window.__editor.mount !== "function") { if (!window.__editor || typeof window.__editor.mount !== "function") {
return; // bundle didn't load — graceful no-JS fallback // Bundle didn't load — un-hide every editor textarea so the form
// is still usable. Mirrors the <noscript> override for the
// JS-disabled case.
for (const ta of document.querySelectorAll("textarea[data-editor-language]")) {
unhideTextarea(ta);
}
return;
} }
let vocabPromise = null; let vocabPromise = null;
@ -37,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");
@ -72,7 +88,10 @@
function init() { function init() {
for (const ta of document.querySelectorAll("textarea[data-editor-language]")) { for (const ta of document.querySelectorAll("textarea[data-editor-language]")) {
mountOne(ta).catch(err => console.error("[editor] mount failed", err)); mountOne(ta).catch(err => {
console.error("[editor] mount failed", err);
unhideTextarea(ta); // restore the form-usable raw textarea
});
} }
} }

View file

@ -3,7 +3,8 @@
// overlay is type='files' and the user can edit). The script binds: // overlay is type='files' and the user can edit). The script binds:
// //
// * Per-row hover actions: + new file, + new folder, ⬇ zip, ✕ on // * Per-row hover actions: + new file, + new folder, ⬇ zip, ✕ on
// folders; edit, ✕ on files (download is a regular <a>). // folders; ⬇ (download), ✕ on files. Clicking the filename opens
// the editor (binary fallback for non-editable files).
// * Drag-and-drop: dragging from the OS uploads (one POST per file, // * Drag-and-drop: dragging from the OS uploads (one POST per file,
// queue with concurrency 3); dragging a row inside the tree moves // queue with concurrency 3); dragging a row inside the tree moves
// (rename/move via /files/move). // (rename/move via /files/move).
@ -977,7 +978,9 @@
// ---------- click delegation: action buttons ---------------------------- // ---------- click delegation: action buttons ----------------------------
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
const action = event.target?.closest?.(".files-row-action[data-action]"); const action = event.target?.closest?.(
".files-row-action[data-action], .file-tree-name-button[data-action]"
);
if (!action) return; if (!action) return;
if (!manager.contains(action)) return; if (!manager.contains(action)) return;
const op = action.dataset.action; const op = action.dataset.action;

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

@ -1,5 +1,10 @@
{# Editor assets — include on any page that mounts a <textarea data-editor-language>. #} {# Editor assets — included from {% block extra_head %} on any page that
mounts a <textarea data-editor-language>. Loading from <head> means the
CSS pre-hide rule in editor.css applies before first paint (no textarea
flicker), and the defer'd scripts download in parallel with HTML parse
and execute before DOMContentLoaded. #}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/editor.bundle.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/editor.bundle.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/editor.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/editor.css') }}">
<noscript><style>textarea[data-editor-language] { display: revert; }</style></noscript>
<script src="{{ url_for('static', filename='vendor/editor.bundle.js') }}" nonce="{{ g.csp_nonce }}" defer></script> <script src="{{ url_for('static', filename='vendor/editor.bundle.js') }}" nonce="{{ g.csp_nonce }}" defer></script>
<script src="{{ url_for('static', filename='js/editor.js') }}" nonce="{{ g.csp_nonce }}" defer></script> <script src="{{ url_for('static', filename='js/editor.js') }}" nonce="{{ g.csp_nonce }}" defer></script>

View file

@ -24,7 +24,9 @@
<span>{{ entry.name }}</span> <span>{{ entry.name }}</span>
<span class="file-tree-badge file-tree-badge-warn">broken link</span> <span class="file-tree-badge file-tree-badge-warn">broken link</span>
{% else %} {% else %}
{% if download_supported %} {% if files_overlay %}
<button type="button" class="file-tree-name-button" data-action="edit" data-target-path="{{ entry.rel }}" data-editable="{{ '1' if entry.editable else '0' }}" title="Open in editor">{{ entry.name }}</button>
{% elif download_supported %}
<a href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}">{{ entry.name }}</a> <a href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}">{{ entry.name }}</a>
{% else %} {% else %}
<span>{{ entry.name }}</span> <span>{{ entry.name }}</span>
@ -34,7 +36,6 @@
{% endif %} {% endif %}
{% if files_overlay and not entry.broken %} {% if files_overlay and not entry.broken %}
<span class="files-row-actions" aria-label="File actions"> <span class="files-row-actions" aria-label="File actions">
<button type="button" class="files-row-action" data-action="edit" data-target-path="{{ entry.rel }}" data-editable="{{ '1' if entry.editable else '0' }}" title="Edit">edit</button>
<a class="files-row-action" href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}" title="Download"></a> <a class="files-row-action" href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}" title="Download"></a>
<button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="file" data-row-name="{{ entry.name }}" title="Delete"></button> <button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="file" data-row-name="{{ entry.name }}" title="Delete"></button>
</span> </span>

View file

@ -9,6 +9,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/logs.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/logs.css') }}">
{% block extra_head %}{% endblock %}
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header">

View file

@ -2,6 +2,8 @@
{% block title %}Blueprint {{ blueprint.name }} | left4me{% endblock %} {% block title %}Blueprint {{ blueprint.name }} | left4me{% endblock %}
{% block extra_head %}{% include "_editor_assets.html" %}{% endblock %}
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<div class="page-heading"> <div class="page-heading">
@ -49,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>
@ -92,5 +94,4 @@
</div> </div>
</dialog> </dialog>
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script> <script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
{% include "_editor_assets.html" %}
{% endblock %} {% endblock %}

View file

@ -2,6 +2,8 @@
{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %} {% block title %}Overlay {{ overlay.name }} | left4me{% endblock %}
{% block extra_head %}{% include "_editor_assets.html" %}{% endblock %}
{% block content %} {% block content %}
{% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script', 'files'] and overlay.user_id == g.user.id) %} {% set can_edit = g.user.admin or (overlay.type in ['workshop', 'script', 'files'] and overlay.user_id == g.user.id) %}
{% set is_files_overlay = overlay.type == 'files' %} {% set is_files_overlay = overlay.type == 'files' %}
@ -22,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 %}
@ -184,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>
@ -282,5 +284,4 @@
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script> <script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
{% endif %} {% endif %}
{% include "_editor_assets.html" %}
{% endblock %} {% endblock %}

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(),