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>
This commit is contained in:
mwiegand 2026-05-17 02:25:52 +02:00
parent 704e4cdfd1
commit fd0d96b349
No known key found for this signature in database
6 changed files with 37 additions and 5 deletions

View file

@ -3,6 +3,12 @@
* 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; }
.cm-editor { .cm-editor {
border: var(--line); border: var(--line);
border-radius: var(--radius-s); border-radius: var(--radius-s);

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;
@ -72,7 +87,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

@ -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

@ -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">
@ -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' %}
@ -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 %}