openvibers/openvibe.live Commits

Live: public routes block only on a first-paint subset of style.css (84 KB of 468); the full file follows in the same cascade place (#113)

OP OpenVibers committed 12 Oct 2026 · 8c5ac2bfe1 · 9 files, +829 −3

A phone-profile sweep of all 34 OpenVibe home pages (4x CPU, ~1.6 Mbit/s) put openvibe.live last, with first paint at 4.9–5.3 s. The main reason is CSS: every route blocked on all of `style.css`, 468 KB raw (~97 KB brotli as served). Yet across the public routes' first views (home, content, VODs, clips, moments, arena, chat, search, a channel, a VOD, a clip and pastes, at 412 and 1366 px), CDP rule coverage used 436 of its selectors, about 17 % of the file.

**What changes**
- `scripts/perf/critical-css.js` records that coverage in headless Chrome. It maps each used rule to its selectors with the same parser the server uses, over the exact text the page loaded, and writes `public/css/critical-selectors.json` (committed).
- `server/web/critical-css.js` builds the first-paint subset from the `style.css` on disk, and rebuilds it when the file or the list changes. The result is 84 KB raw, ~15 KB brotli. It keeps:
  - rules naming a listed selector;
  - the document-level rules (`:root`, `html`, `body`, `*`);
  - each of those inside its `@media`, `@supports` or `@container`;
  - only the `@keyframes` they use.
- `/css/style.core.css?v=<hash>` is served from memory, content-hashed and immutable under its hash, ahead of the `/css` static mount.
- `renderRoute`, for those public routes only:
  - links the subset as the blocking stylesheet;
  - adds the full `style.css` right after it with `media="print"` (flipped on load), plus a `<noscript>` copy.

  Both sit where `style.css` sat, before the feature stylesheets, so once the full file arrives the cascade is exactly what it was. The subset's rules come earlier and are repeated unchanged by the full file; only the moment before it arrives differs. Every other route (dashboard, settings, broadcast, admin, documentation, …) keeps `style.css` blocking, untouched.

**Measured.** I ran an A/B on production's own pages through a local same-origin proxy (brotli, phone profile, best of two runs):

| Page | First paint | LCP | CLS |
|---|---|---|---|
| Home | 2.48 → 1.47 s | unchanged (~5.1 s, set by late data cards) | 0.125, unchanged |
| Channel | 2.51 → 1.46 s | unchanged | already high on this page, unchanged |
| VOD | 2.24 → 1.48 s | 3.8 → 1.5 s | already high on this page, unchanged |

The settled pages are pixel-identical between the two versions apart from animations and timestamps (checked by diffing screenshots). The home page's blocking CSS is now 243 KB raw / 47.5 KB brotli, down from ~717 / ~130, and the budget is lowered to 270 / 55 to keep it there.

**Upkeep.** After changing how a public page looks, re-run `node scripts/perf/critical-css.js` (noted in AGENTS.md and `docs/performance-audit.md`). Until then, a rule it does not list arrives with the full file, a moment after the first paint. `test/critical-css.test.js` fails when more than a tenth of the listed selectors are gone from `style.css`.

**Tests:** the new `critical-css.test.js` covers:
- the parser: strings, `:is()` commas, nested blocks;
- extraction: wanted and document-level rules, `@media`/`@supports` wrapping, empty blocks dropped, only the keyframes in use;
- the real subset's size, and that the selector list is current;
- the shell on ten public routes (subset blocking, full file after it in style.css's place, no-JS copy) and on seven other routes (unchanged);
- the content hash.

`ov test` (the 20 files that cover the change) and `check-budgets.js` pass.

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 2e59b56a..8871023b 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -53,6 +53,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
## Key Pitfalls
- **No build step:** Changes to `public/` take effect on deploy without a restart; caching follows content hashes. `npm test` fails if the home page's size budgets grow (scripts/perf/check-budgets.js).
++- **First-paint CSS:** public routes block only on `/css/style.core.css`, a subset of `style.css` generated from `public/css/critical-selectors.json` (`server/web/critical-css.js`); the full `style.css` follows without blocking. After changing how a public page looks, re-run `node scripts/perf/critical-css.js` (docs/performance-audit.md).
- **Inline handlers on lazy features:** a function called from `onclick=` in markup that exists before its feature loads must be listed in that feature's `stubs`.
- **New page routes:** the SPA fallback answers 404 for any path [server/web/page-status.js](server/web/page-status.js) does not know. A new top-level route goes in both `routeFromURL()` and that file's page list.
- **innerHTML usage:** Frontend has heavy `innerHTML` — prefer DOM node creation for new code to avoid XSS.
ddiff --git a/docs/performance-audit.md b/docs/performance-audit.md
iindex bd406bc7..803c483a 100644
---- a/docs/performance-audit.md
++++ b/docs/performance-audit.md
@@@ -130,6 +130,28 @@ Shared 1.17 to 1.20 changed `release-watch.js`, which every page loads after fir
The release client adds one request (the EventSource, which excludes itself from idle-network checks) and no measurable paint or blocking cost. The size budgets in `npm test` (scripts/perf/check-budgets.js) still pass.
++## Measurement 2026-10-12: the first-paint stylesheet
++
++A phone-profile sweep of every OpenVibe home page (4x CPU, ~1.6 Mbit/s) put openvibe.live last: first paint 4.9–5.3 s.
++Every route blocked on all of `style.css` (468 KB raw, ~97 KB brotli as served), while CDP rule coverage over the public
++routes (home, content, VODs, clips, moments, arena, chat, search, a channel, a VOD, a clip, pastes) at 412 and 1366 px
++used 436 of its selectors, 17–18 % of the file.
++
++- `scripts/perf/critical-css.js` writes those selectors to `public/css/critical-selectors.json`; `server/web/critical-css.js`
++ builds the subset from the `style.css` on disk (rules naming a listed selector, `:root`/`html`/`body`/`*`, each inside
++ its `@media`/`@supports`/`@container`, and only the `@keyframes` they use): 84 KB raw, ~15 KB brotli.
++- On those routes the shell links `/css/style.core.css?v=…` (generated, content-hashed, served before the `/css` static
++ mount) as the blocking stylesheet, and the full `style.css` right after it with `media="print"` flipped on load, plus a
++ `<noscript>` copy. Both sit where `style.css` sat, before the feature stylesheets, so once the full file has arrived the
++ cascade is exactly the old one (the subset's rules come earlier and are repeated unchanged). Other routes (dashboard,
++ settings, broadcast, admin, …) keep `style.css` blocking.
++- A/B on production pages through a local same-origin proxy (brotli, phone profile): first paint home 2.48 → 1.47 s,
++ channel 2.51 → 1.46 s, VOD 2.24 → 1.48 s (LCP 3.8 → 1.5 s); LCP and CLS elsewhere unchanged; the settled pages are
++ pixel-identical apart from animations and timestamps.
++- Re-run `node scripts/perf/critical-css.js` after changing a public page's look: a rule it does not list still arrives
++ with the full file, just after the first paint. `test/critical-css.test.js` fails when more than a tenth of the listed
++ selectors are no longer in `style.css`.
++
## Budgets
`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly
ddiff --git a/public/css/critical-selectors.json b/public/css/critical-selectors.json
nnew file mode 100644
iindex 00000000..7b898437
---- /dev/null
++++ b/public/css/critical-selectors.json
@@@ -0,0 +1,457 @@
++{
++ "_about": "Generated by scripts/perf/critical-css.js: the style.css selectors a public page's first view uses (CDP rule coverage, signed out, phone and desktop widths). server/web/critical-css.js builds the blocking first-paint subset from it.",
++ "base": "https://openvibe.live",
++ "routes": [
++ "/",
++ "/content",
++ "/vods",
++ "/clips",
++ "/moments",
++ "/arena",
++ "/chat",
++ "/search?q=game",
++ "/@iZoR_One",
++ "/vod/5717",
++ "/clip/441",
++ "/pastes",
++ "/docs"
++ ],
++ "selectors": [
++ "#openvibe-bell-mount",
++ "#page-home",
++ "#page-home .hero",
++ "#page-home.page",
++ "#stream-grid-live .empty-state",
++ "*",
++ "*::after",
++ "*::before",
++ ".arena-beef-empty i",
++ ".arena-beef-empty p",
++ ".arena-beefs h2",
++ ".arena-beefs h2 small",
++ ".arena-board-head h2",
++ ".arena-board-head h2 small",
++ ".arena-board-head input",
++ ".arena-container",
++ ".arena-done summary",
++ ".arena-done summary small",
++ ".arena-hero h1",
++ ".arena-ladder h2",
++ ".arena-ladder h2 small",
++ ".arena-live h2",
++ ".arena-live h2 small",
++ ".arena-live-dot",
++ ".arena-loading i",
++ ".arena-lvl",
++ ".arena-mic-moment-body",
++ ".arena-mic-moment-head small",
++ ".arena-note",
++ ".arena-portrait",
++ ".arena-portrait img",
++ ".arena-portrait-glow",
++ ".arena-portrait-sm",
++ ".arena-portrait-xs",
++ ".arena-power b",
++ ".arena-power small",
++ ".arena-rank-1",
++ ".arena-rank-2",
++ ".arena-rank-3",
++ ".arena-row .arena-chevron",
++ ".arena-row-main",
++ ".arena-row-main strong",
++ ".arena-rules h3",
++ ".arena-rules ul",
++ ".arena-rules-list",
++ ".badge",
++ ".brand-mark",
++ ".brand-mark .ov-comet",
++ ".brand-mark .ov-dot",
++ ".brand-mark .ov-glow",
++ ".brand-mark .ov-spark",
++ ".brand-mark .ov-v",
++ ".brand-mark svg",
++ ".brand-mark--hero",
++ ".brand-mark--hero svg",
++ ".brand-mark.is-awake .ov-comet",
++ ".brand-mark.is-awake .ov-dot",
++ ".brand-mark.is-awake .ov-glow",
++ ".brand-mark.is-awake .ov-spark",
++ ".brand-mark.is-awake .ov-v",
++ ".brand-mark.is-awake svg",
++ ".brand-mark.is-settling svg",
++ ".brand-mark.is-spin svg",
++ ".btn",
++ ".btn-danger",
++ ".btn-follow",
++ ".btn-lg",
++ ".btn-openvibe-bucks-mini",
++ ".btn-openvibe-coins-mini",
++ ".btn-outline",
++ ".btn-primary",
++ ".btn-small",
++ ".card-ai-clamp",
++ ".card-ai-clamp i.fa-wand-magic-sparkles",
++ ".card-ai-overview",
++ ".card-ai-static",
++ ".card-ai-toggle",
++ ".ch-goal-widget",
++ ".ch-goal-widget:not(.cgw-multi)",
++ ".ch-offline-area",
++ ".ch-offline-area .ch-offline-screen",
++ ".ch-offline-header-info",
++ ".ch-offline-header-info h1",
++ ".ch-offline-overlay",
++ ".ch-offline-overlay > *",
++ ".ch-offline-overlay-close",
++ ".ch-offline-reopen",
++ ".ch-offline-screen",
++ ".ch-panel-bar > .ch-vods-filter-bar",
++ ".ch-panel-bar > .clips-taken-filters",
++ ".ch-tab",
++ ".ch-tab i",
++ ".ch-tab-badge",
++ ".ch-tab-badge[hidden]",
++ ".ch-tab-panel",
++ ".ch-tab-panel.active",
++ ".ch-tab.active",
++ ".ch-tab.active .ch-tab-badge",
++ ".ch-tab.active::after",
++ ".ch-tabs",
++ ".chat-avatar",
++ ".chat-avatar-letter",
++ ".chat-avatar-wrap",
++ ".chat-badge",
++ ".chat-badge i",
++ ".chat-badge-admin",
++ ".chat-badge-streamer",
++ ".chat-badge[data-tip]::after",
++ ".chat-badge[data-tip]::before",
++ ".chat-emote",
++ ".chat-emote-animated",
++ ".chat-header",
++ ".chat-header-actions",
++ ".chat-input-area",
++ ".chat-input-row",
++ ".chat-input-row .chat-send-btn",
++ ".chat-link",
++ ".chat-messages",
++ ".chat-mode-select",
++ ".chat-mode-select option:disabled",
++ ".chat-msg",
++ ".chat-msg .chat-user",
++ ".chat-msg-hoverable .chat-reply-btn",
++ ".chat-msg-tts-off",
++ ".chat-msg-tts-off .chat-user::after",
++ ".chat-msg.system",
++ ".chat-nickels-float .rewards-coin-balance",
++ ".chat-replay-empty p",
++ ".chat-replay-header h4",
++ ".chat-replay-messages",
++ ".chat-reply-header",
++ ".chat-reply-header i",
++ ".chat-reply-snippet",
++ ".chat-reply-user",
++ ".chat-send-btn",
++ ".chat-settings-btn",
++ ".chat-sidebar",
++ ".chat-sidebar .chat-header",
++ ".chat-sidebar .chat-header::before",
++ ".chat-sidebar .mobile-chat-close",
++ ".chat-slowmode-banner",
++ ".chat-stream-badge",
++ ".chat-textarea",
++ ".chat-textarea::placeholder",
++ ".chat-time-inline",
++ ".chat-tools-row",
++ ".chat-tools-row > button",
++ ".chat-tools-row > button.btn-openvibe-bucks-mini",
++ ".chat-tools-row > button.btn-openvibe-coins-mini",
++ ".chat-tools-row > button.chat-nickels-float",
++ ".chat-tools-row > button.chat-tts-toggle",
++ ".chat-tools-row > button.chat-tts-toggle.tts-active",
++ ".chat-tts-toggle",
++ ".chat-tts-toggle.tts-active",
++ ".chat-users-panel",
++ ".clip-modal-overlay",
++ ".comments-empty",
++ ".comments-header h3",
++ ".container",
++ ".content-container",
++ ".controls-dock",
++ ".cta-ambient",
++ ".cta-btn-glow",
++ ".cta-embers",
++ ".cta-embers span",
++ ".cta-hero",
++ ".cta-hero-grad",
++ ".cta-lede strong",
++ ".cta-method",
++ ".cta-method span",
++ ".cta-method span b",
++ ".cta-method strong",
++ ".cta-method-num",
++ ".cta-section-label",
++ ".cta-section-label i",
++ ".digest-chip",
++ ".digest-hot-line b",
++ ".digest-hot-line i",
++ ".digest-stat b",
++ ".digest-stat i",
++ ".digest-title i",
++ ".dvr-progress-bar",
++ ".dvr-progress-fill::after",
++ ".dvr-progress-wrap",
++ ".egg-key",
++ ".egg-key--letter",
++ ".emote-picker",
++ ".emote-picker-btn",
++ ".emote-picker-btn.gif-btn",
++ ".emote-picker-grid",
++ ".empty-state",
++ ".empty-state i",
++ ".fab-label",
++ ".fcw-unread-badge",
++ ".floating-chat-fab",
++ ".floating-chat-widget",
++ ".form-input",
++ ".form-input-sm",
++ ".gai-badge",
++ ".gai-strip-text",
++ ".global-chat-header",
++ ".global-chat-header .muted",
++ ".global-chat-header h2",
++ ".global-chat-main",
++ ".global-chat-main .chat-input-area",
++ ".global-chat-main .chat-input-row",
++ ".global-chat-main .chat-input-row .chat-textarea",
++ ".global-chat-main .chat-input-row input",
++ ".global-chat-messages",
++ ".goal-bar",
++ ".goal-fill",
++ ".hero",
++ ".hero .hero-tagline",
++ ".hero h1",
++ ".hero p",
++ ".hero-aurora",
++ ".hero-bg",
++ ".hero-bg-layer",
++ ".hero-bg-layer.active",
++ ".hero-collage",
++ ".hero-content",
++ ".hero-egg-titles",
++ ".hero-float",
++ ".hero-float img",
++ ".hero-float--clip .hero-float-badge",
++ ".hero-float--paste .hero-float-badge",
++ ".hero-float--vod .hero-float-badge",
++ ".hero-float-body",
++ ".hero-float-inner",
++ ".hero-latest b",
++ ".hero-philosophy-btn",
++ ".hero-quip.visible",
++ ".hero-rotate-wrap",
++ ".hero-rotate.visible",
++ ".hero-scrim",
++ ".hero-slogan-timer b",
++ ".hero-slogan-timer i",
++ ".hero-stat",
++ ".hero-stat > i",
++ ".hero-stat > i::before",
++ ".hero-stat-chart-hint",
++ ".hero-stat-delta b",
++ ".hero-stat-label",
++ ".hero-stat-meta",
++ ".hero-stat-more i",
++ ".hero-stat-num",
++ ".hero-stat-sub",
++ ".hero-stats",
++ ".hero::after",
++ ".hero::before",
++ ".home-about-body",
++ ".home-about-toggle",
++ ".home-changelog-fade",
++ ".home-changelog-wrapper",
++ ".home-changelog-wrapper.collapsed",
++ ".home-cta-banner",
++ ".home-cta-banner .btn-outline",
++ ".home-cta-banner .cta-hero",
++ ".home-cta-banner .cta-lede strong",
++ ".home-cta-banner .cta-method span",
++ ".home-cta-banner .cta-method span b",
++ ".home-cta-banner .cta-method strong",
++ ".home-cta-banner .home-cta-feature > span",
++ ".home-cta-banner .home-cta-feature > strong",
++ ".home-cta-feature",
++ ".home-cta-feature > i",
++ ".home-cta-feature > span",
++ ".home-cta-feature > strong",
++ ".home-cta-features .home-cta-feature:nth-child(1)",
++ ".home-cta-features .home-cta-feature:nth-child(2)",
++ ".home-cta-features .home-cta-feature:nth-child(3)",
++ ".home-cta-features .home-cta-feature:nth-child(4)",
++ ".home-cta-features .home-cta-feature:nth-child(5)",
++ ".home-cta-features .home-cta-feature:nth-child(6)",
++ ".home-cta-features .home-cta-feature:nth-child(7)",
++ ".home-cta-features .home-cta-feature:nth-child(8)",
++ ".home-cta-features .home-cta-feature:nth-child(9)",
++ ".home-cta-inner",
++ ".home-digest",
++ ".home-load-more i",
++ ".home-load-more small",
++ ".home-paste-icon",
++ ".home-paste-info",
++ ".home-paste-media",
++ ".home-paste-media img",
++ ".home-paste-type",
++ ".live-dot",
++ ".live-stream-tabs",
++ ".media-transcript-toggle > i:first-child",
++ ".mobile-chat-badge",
++ ".mobile-chat-close",
++ ".mobile-chat-toggle",
++ ".modal-overlay",
++ ".muted",
++ ".navbar",
++ ".navbar > *",
++ ".network-card-info",
++ ".network-card-info span",
++ ".network-card-info strong",
++ ".network-hub-header > i",
++ ".network-hub-header h3",
++ ".network-hub-header p",
++ ".off-cyc-title i",
++ ".off-top-dur",
++ ".off-top-rank",
++ ".off-top-thumb",
++ ".off-top-thumb img",
++ ".offline-global-chat",
++ ".offline-global-chat .chat-header",
++ ".offline-global-chat .chat-input-area",
++ ".offline-global-chat .chat-input-row",
++ ".offline-global-chat .chat-input-row .chat-textarea",
++ ".offline-global-chat .chat-input-row input",
++ ".offline-global-chat .chat-messages",
++ ".openvibe-navbar.navbar",
++ ".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link",
++ ".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active",
++ ".openvibe-navbar.navbar .ovnav-launch",
++ ".ov-comet",
++ ".ov-dot",
++ ".ov-glow",
++ ".ov-orbit",
++ ".ov-orbit--tail",
++ ".ov-ring",
++ ".ov-spark",
++ ".ov-spark--2",
++ ".ov-spark--3",
++ ".ov-v",
++ ".page",
++ ".page.active",
++ ".paste-modal-overlay",
++ ".pastes-grid",
++ ".pastes-pagination",
++ ".pc-tools-row > button",
++ ".pc-tools-row > button.btn-openvibe-bucks-mini",
++ ".pc-tools-row > button.btn-openvibe-coins-mini",
++ ".pc-tools-row > button.chat-nickels-float",
++ ".pc-tools-row > button.chat-tts-toggle.tts-active",
++ ".protocol-badge",
++ ".protocol-rtmp",
++ ".pulse-act i",
++ ".pulse-grid",
++ ".pulse-kicker i",
++ ".pulse-rank b",
++ ".pulse-sub b",
++ ".rewards-panel",
++ ".rs-lite .ov-orbit",
++ ".rs-lite .ov-spark",
++ ".section-header",
++ ".section-header .badge",
++ ".section-header .ovd",
++ ".section-header > *",
++ ".section-header > .badge",
++ ".section-header > .muted",
++ ".section-header > :not(h2)",
++ ".section-header > p",
++ ".section-header > span:not(.badge)",
++ ".section-header h2",
++ ".section-header h2 > i.live-dot",
++ ".section-header h2 > i:first-child",
++ ".section-header h2 > i:first-child::before",
++ ".section-header::after",
++ ".stream-card",
++ ".stream-card-avatar",
++ ".stream-card-avatar img",
++ ".stream-card-duration",
++ ".stream-card-info",
++ ".stream-card-streamer",
++ ".stream-card-thumb",
++ ".stream-card-thumb i",
++ ".stream-card-thumb img",
++ ".stream-card-title",
++ ".stream-card-viewers",
++ ".stream-card-viewers b",
++ ".stream-card-viewers i",
++ ".stream-grid",
++ ".stream-layout",
++ ".streamer-group-card .card-ai-overview",
++ ".streamer-group-name",
++ ".streamer-group-stream-info",
++ ".streamer-group-stream-thumb",
++ ".toast-container",
++ ".tour",
++ ".ts-seg",
++ ".ts-time",
++ ".vc-btn",
++ ".vc-channel-body",
++ ".vc-mini-bar",
++ ".vc-modal-overlay",
++ ".vc-progress-bar",
++ ".vc-progress-buffer",
++ ".vc-progress-fill",
++ ".vc-progress-fill::after",
++ ".vc-progress-wrap",
++ ".vc-sidebar-header h3",
++ ".vc-sidebar-header h3 i",
++ ".vc-spacer",
++ ".vc-time-display",
++ ".vc-volume-group",
++ ".vc-volume-group .volume-slider",
++ ".video-controls",
++ ".video-overlay",
++ ".vod-ai-memory-t",
++ ".vod-ai-timeline-hint i",
++ ".vod-ai-timeline-title i",
++ ".vod-extra-details .detail-chip i",
++ ".vod-player-container",
++ ".vod-player-info h2",
++ ".vod-player-main",
++ ".vod-source-info i",
++ ".volume-slider",
++ ".volume-slider::-webkit-slider-runnable-track",
++ ".volume-slider::-webkit-slider-thumb",
++ ".vp-live-indicator",
++ "::-webkit-scrollbar",
++ "::-webkit-scrollbar-thumb",
++ "::-webkit-scrollbar-track",
++ ":root",
++ "[data-fx-viewport]:not(.fx-live)",
++ "[data-fx-viewport]:not(.fx-live) *",
++ "[data-fx-viewport]:not(.fx-live) *::after",
++ "[data-fx-viewport]:not(.fx-live) *::before",
++ "a",
++ "body",
++ "body.ov-nav-overlay .navbar.ovnav-top",
++ "body.ov-nav-overlay .openvibe-navbar.navbar.ovnav-top",
++ "body::after",
++ "body::before",
++ "button",
++ "html",
++ "img",
++ "input",
++ "main#app",
++ "select",
++ "select.form-input",
++ "textarea"
++ ]
++}
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex 62cc8c9b..60cc2925 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -24,8 +24,8 @@ const BUDGETS = {
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsRawKB: 1150, // measured ~1000
jsBrotliKB: 262, // measured 260.1 (2026-10-09: the shared navbar grew with My Profile and the OpenRestream brand)
-- cssRawKB: 760, // measured ~717 (style.css + home + icons + i18n)
-- cssBrotliKB: 130,
++ cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
++ cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
// Code that must never be part of the home page's first load.
const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',
ddiff --git a/scripts/perf/critical-css.js b/scripts/perf/critical-css.js
nnew file mode 100644
iindex 00000000..61a6026e
---- /dev/null
++++ b/scripts/perf/critical-css.js
@@@ -0,0 +1,75 @@
++#!/usr/bin/env node
++'use strict';
++// Write public/css/critical-selectors.json: the style.css selectors a public page's first view uses.
++//
++// node scripts/perf/critical-css.js [--base https://openvibe.live] [--settle 9000]
++//
++// Opens each public route below at a phone and a desktop width in headless Chrome, records CSS rule coverage for
++// style.css (CDP CSS.startRuleUsageTracking) after the page settles, maps every used rule back to its selectors with
++// server/web/critical-css.js's parser (over the exact text the page loaded), and writes their union. The server builds
++// the blocking first-paint subset from it (server/web/critical-css.js). Re-run it after a large change to a public
++// page's look; a rule it does not list still arrives with the full style.css, just not before the first paint.
++const fs = require('fs');
++const path = require('path');
++const { launch, sleep } = require('./cdp');
++const { parse, splitSelectors, SELECTORS } = require('../../server/web/critical-css');
++
++const args = process.argv.slice(2);
++const opt = (name, def) => { const i = args.indexOf(name); return i === -1 ? def : args[i + 1]; };
++const BASE = String(opt('--base', 'https://openvibe.live')).replace(/\/+$/, '');
++const SETTLE = Number(opt('--settle', 9000));
++// The public routes people land on from search, links and the home page (signed out).
++const ROUTES = ['/', '/content', '/vods', '/clips', '/moments', '/arena', '/chat', '/search?q=game', '/@iZoR_One', '/vod/5717', '/clip/441', '/pastes', '/docs'];
++const WIDTHS = [[412, 900, true], [1366, 900, false]];
++
++async function coverage(url, [width, height, mobile]) {
++ const cdp = await launch({ width, height });
++ try {
++ await cdp.send('Page.enable');
++ await cdp.send('DOM.enable');
++ await cdp.send('CSS.enable');
++ if (mobile) await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 2.6, mobile: true });
++ const sheets = new Map();
++ cdp.on('CSS.styleSheetAdded', (e) => sheets.set(e.header.styleSheetId, e.header));
++ await cdp.send('CSS.startRuleUsageTracking');
++ await cdp.send('Page.navigate', { url });
++ await sleep(SETTLE);
++ const r = await cdp.send('CSS.stopRuleUsageTracking');
++ const out = new Set();
++ const byId = new Map();
++ for (const u of (r.result && r.result.ruleUsage) || []) {
++ if (!u.used) continue;
++ const h = sheets.get(u.styleSheetId);
++ if (!h || !/\/css\/style\.css(\?|$)/.test(h.sourceURL || '')) continue;
++ if (!byId.has(u.styleSheetId)) {
++ const t = await cdp.send('CSS.getStyleSheetText', { styleSheetId: u.styleSheetId });
++ const text = (t.result && t.result.text) || '';
++ const rules = [];
++ const walk = (list) => { for (const n of list) { if (n.type === 'rule') rules.push(n); else if (n.type === 'block') walk(n.children); } };
++ walk(parse(text));
++ byId.set(u.styleSheetId, rules);
++ }
++ // The rule whose text holds this range (coverage ranges are a rule's selector and body).
++ const rule = byId.get(u.styleSheetId).find((n) => n.start <= u.startOffset && u.startOffset < n.end);
++ if (rule) for (const s of splitSelectors(rule.selector)) out.add(s);
++ }
++ return out;
++ } finally {
++ cdp.close();
++ }
++}
++
++(async () => {
++ const all = new Set();
++ for (const route of ROUTES) {
++ for (const w of WIDTHS) {
++ const got = await coverage(BASE + route, w);
++ for (const s of got) all.add(s);
++ console.log(`${route.padEnd(18)} ${String(w[0]).padStart(4)}px ${got.size} selectors`);
++ }
++ }
++ const selectors = [...all].sort();
++ fs.writeFileSync(SELECTORS, `${JSON.stringify({ _about: 'Generated by scripts/perf/critical-css.js: the style.css selectors a public page\'s first view uses (CDP rule coverage, signed out, phone and desktop widths). server/web/critical-css.js builds the blocking first-paint subset from it.', base: BASE, routes: ROUTES, selectors }, null, 1)}\n`);
++ console.log(`${selectors.length} selectors → ${path.relative(process.cwd(), SELECTORS)}`);
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/server/index.js b/server/index.js
iindex 00e6390e..a88c1bc2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -619,6 +619,8 @@ app.use('/assets/sounds', express.static(soundsPath, {
const noCacheHeaders = assets.setNoCache;
const assetOpts = { etag: true, lastModified: true, setHeaders: assets.staticHeaders };
app.use('/js', assets.versionedStatic('/js'), express.static(path.join(assets.PUBLIC_DIR, 'js'), assetOpts));
++// The first-paint subset of style.css is generated (server/web/critical-css.js), so it is served before the static mount.
++app.get(assets.CORE_CSS, assets.serveCoreCss);
app.use('/css', assets.versionedStatic('/css'), express.static(path.join(assets.PUBLIC_DIR, 'css'), assetOpts));
// Page markup that is fetched when a route is first opened instead of shipping inside index.html.
app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.join(assets.PUBLIC_DIR, 'fragments'), { ...assetOpts, fallthrough: false }));
ddiff --git a/server/web/assets.js b/server/web/assets.js
iindex 773d9dbb..95e451fc 100644
---- a/server/web/assets.js
++++ b/server/web/assets.js
@@@ -33,6 +33,9 @@ const RECHECK_MS = 2000;
const HASH_LEN = 12;
// Directories whose files are versioned in documents and loadable through the manifest.
const MANIFEST_DIRS = ['js', 'css', 'fragments'];
++// The first-paint subset of style.css (server/web/critical-css.js): generated, not a file in public/.
++const critical = require('./critical-css');
++const CORE_CSS = '/css/style.core.css';
const REF_RE = /(["'])(\/(?:js|css|shared|fragments)\/[A-Za-z0-9_./-]+?\.(?:js|css|html))(?:\?v=[A-Za-z0-9_.-]*)?\1/g;
const MANIFEST_MARK = '<!--ov:asset-manifest-->';
const ROUTE_CSS_MARK = '<!--ov:route-css-->';
@@@ -64,6 +67,7 @@ function resolveIn(root, urlPath) {
* bytes belong to the old hash — and Cloudflare would cache that for a year.
*/
function hashOf(urlPath, { fresh = false } = {}) {
++ if (urlPath === CORE_CSS) { const c = critical.core(); return c ? c.hash : null; }
const now = Date.now();
const entry = files.get(urlPath);
if (entry && !fresh && now - entry.checkedAt < RECHECK_MS) return entry.hash;
@@@ -255,6 +259,7 @@ function renderRoute(html, urlPath) {
const jsTags = js.map((j) => `<script src="${versioned(j)}" defer data-ov-feature-asset></script>`).join('\n');
const bootTag = `<script type="application/json" id="ov-route-features">${JSON.stringify(names)}</script>`;
let out = html.replace(ROUTE_CSS_MARK, cssTags).replace(ROUTE_JS_MARK, `${bootTag}\n${jsTags}`);
++ out = firstPaintCss(out, urlPath);
if (lateTags) out = out.replace('</head>', ` ${lateTags}\n</head>`);
// The route's page markup, inline: a direct visit renders without a second round trip, and
// crawlers get the same content they did when every page shipped in index.html.
@@@ -274,6 +279,34 @@ function renderRoute(html, urlPath) {
return out;
}
++/**
++ * The public routes whose first view critical-selectors.json was measured on (scripts/perf/critical-css.js): their shell
++ * links the subset as the blocking stylesheet and the full style.css right after it, without blocking. Every other
++ * route (the dashboard, settings, the broadcast desk, admin…) keeps style.css blocking, exactly as before.
++ */
++const FIRST_PAINT_ROUTES = /^\/(?:$|content|vods|clips|moments|arena|chat|pastes|search)(?:[/?#]|$)|^\/@[A-Za-z0-9_]+\/?$|^\/(?:vod|clip)\/\d+\/?$/;
++const STYLE_LINK = /<link rel="stylesheet" href="(\/css\/style\.css\?v=[A-Za-z0-9_.-]+)">/;
++function firstPaintCss(html, urlPath) {
++ const p = String(urlPath || '/').split(/[?#]/)[0] || '/';
++ if (!FIRST_PAINT_ROUTES.test(p)) return html;
++ const m = STYLE_LINK.exec(html);
++ const core = m && hashOf(CORE_CSS);
++ if (!core) return html;
++ const full = m[1];
++ return html.replace(m[0], `<link rel="stylesheet" href="${CORE_CSS}?v=${core}" data-ov-first-paint>
++ <link rel="stylesheet" href="${full}" media="print" onload="this.media='all';this.onload=null">
++ <noscript><link rel="stylesheet" href="${full}"></noscript>`);
++}
++
++/** GET /css/style.core.css: the generated subset, immutable under its current ?v=, short-cached otherwise. */
++function serveCoreCss(req, res, next) {
++ const c = critical.core();
++ if (!c) return next();
++ const v = typeof req.query.v === 'string' ? req.query.v : '';
++ if (v && v === c.hash) setImmutable(res); else setNoCache(res);
++ res.type('text/css').send(c.css);
++}
++
/** A page fragment's HTML (public/fragments/<name>.html), cached until the file changes. */
const _fragments = new Map();
function fragment(name) {
@@@ -385,6 +418,6 @@ function staticHeaders(res) {
module.exports = {
PUBLIC_DIR, MANIFEST_MARK,
setSharedDir, hashOf, versioned, manifest, rewriteHtml, document, features, featuresFor, routeAssets, renderRoute, fragment, cspReportOnly,
-- versionedStatic, staticHeaders, setNoCache, setImmutable,
++ versionedStatic, staticHeaders, setNoCache, setImmutable, serveCoreCss, firstPaintCss, CORE_CSS, FIRST_PAINT_ROUTES,
_findPrevious: findPrevious,
};
ddiff --git a/server/web/critical-css.js b/server/web/critical-css.js
nnew file mode 100644
iindex 00000000..28a83051
---- /dev/null
++++ b/server/web/critical-css.js
@@@ -0,0 +1,155 @@
++'use strict';
++/**
++ * The first-paint subset of public/css/style.css.
++ *
++ * style.css holds every page's rules (~440 KB raw, ~97 KB brotli) and blocked the first paint of every route, while a
++ * public page's first view uses about a sixth of it (CDP rule coverage over the public routes at phone and desktop
++ * widths). On a public route the shell now links this subset as the blocking stylesheet, and the full style.css right
++ * after it without blocking (media="print", flipped to all on load; a <noscript> copy). Both links sit where style.css
++ * always sat, so once the full file has arrived the cascade is exactly what it was: the subset's rules come earlier and
++ * are repeated, unchanged, by the full file. Only the moment before it arrives differs, and that moment is painted with
++ * the rules the first view uses.
++ *
++ * Which rules: those whose selector list names a selector in public/css/critical-selectors.json (written by
++ * scripts/perf/critical-css.js from coverage), the document-level rules (`:root`, `html`, `body`, `*`), each inside the
++ * @media/@supports/@container blocks it came from, and the @keyframes the kept rules animate with. Built from the file
++ * on disk and rebuilt when it changes; a rule added to style.css later simply arrives with the full file until the
++ * selector list is regenerated.
++ *
++ * parse(css) → nodes: { type: 'rule', selector, start, end, text } | { type: 'block', prelude, children, text }
++ * | { type: 'raw', name, text } (@keyframes, @font-face, statements)
++ * splitSelectors(selector) → the selector list, split on top-level commas (not inside :is()/:not()/[...])
++ * extract(css, selectors) → the subset's CSS text
++ * core() → { css, hash, bytes, from } for the current style.css (cached by mtime)
++ */
++const fs = require('fs');
++const path = require('path');
++
++// The same public directory server/web/assets.js serves (OV_PUBLIC_DIR overrides it in tests).
++const PUBLIC_DIR = path.resolve(process.env.OV_PUBLIC_DIR
++ || (process.env.OV_APP_ROOT ? path.join(process.env.OV_APP_ROOT, 'public') : path.join(__dirname, '../../public')));
++const STYLE = path.join(PUBLIC_DIR, 'css', 'style.css');
++const SELECTORS = path.join(PUBLIC_DIR, 'css', 'critical-selectors.json');
++const DOCUMENT_LEVEL = new Set([':root', 'html', 'body', '*', '*::before', '*::after', 'body::before', 'body::after']);
++const NESTING = /^@(media|supports|container|layer)\b/i;
++
++/** Skip a comment or string starting at i; returns the index after it, or i when there is none. */
++function skip(css, i) {
++ if (css[i] === '/' && css[i + 1] === '*') { const e = css.indexOf('*/', i + 2); return e === -1 ? css.length : e + 2; }
++ if (css[i] === '"' || css[i] === "'") {
++ const q = css[i];
++ let j = i + 1;
++ while (j < css.length && css[j] !== q) j += css[j] === '\\' ? 2 : 1;
++ return j + 1;
++ }
++ return i;
++}
++
++/** The index just after the `}` that closes the block whose `{` is at open. */
++function closeOf(css, open) {
++ let depth = 0;
++ for (let i = open; i < css.length;) {
++ const n = skip(css, i);
++ if (n !== i) { i = n; continue; }
++ if (css[i] === '{') depth++;
++ else if (css[i] === '}' && --depth === 0) return i + 1;
++ i++;
++ }
++ return css.length;
++}
++
++function parse(css, from = 0, to = css.length) {
++ const nodes = [];
++ let i = from;
++ while (i < to) {
++ const n = skip(css, i);
++ if (n !== i) { i = n; continue; }
++ if (/\s/.test(css[i]) || css[i] === ';' || css[i] === '}') { i++; continue; }
++ // A prelude runs to the first `{` or `;` outside strings and comments.
++ const start = i;
++ let j = i;
++ while (j < to && css[j] !== '{' && css[j] !== ';') { const k = skip(css, j); j = k !== j ? k : j + 1; }
++ const prelude = css.slice(start, j).replace(/\/\*[\s\S]*?\*\//g, '').trim();
++ if (j >= to || css[j] === ';') { nodes.push({ type: 'raw', name: prelude.split(/\s/)[0], text: css.slice(start, Math.min(j + 1, to)) }); i = j + 1; continue; }
++ const end = closeOf(css, j);
++ if (prelude.startsWith('@')) {
++ if (NESTING.test(prelude)) nodes.push({ type: 'block', prelude, start, end, children: parse(css, j + 1, end - 1), text: css.slice(start, end) });
++ else nodes.push({ type: 'raw', name: prelude.split(/\s/)[0].toLowerCase(), prelude, start, end, text: css.slice(start, end) });
++ } else {
++ nodes.push({ type: 'rule', selector: prelude, start, end, text: css.slice(start, end) });
++ }
++ i = end;
++ }
++ return nodes;
++}
++
++/** "a, b:is(c, d), [x=','] e" → ['a', 'b:is(c, d)', "[x=','] e"], each with its whitespace collapsed. */
++function splitSelectors(selector) {
++ const out = [];
++ let depth = 0, cur = '', q = null;
++ for (const ch of String(selector)) {
++ if (q) { cur += ch; if (ch === q) q = null; continue; }
++ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
++ if (ch === '(' || ch === '[') depth++;
++ else if (ch === ')' || ch === ']') depth--;
++ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; }
++ cur += ch;
++ }
++ out.push(cur);
++ return out.map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
++}
++
++function keepRule(node, wanted) {
++ return splitSelectors(node.selector).some((s) => wanted.has(s) || DOCUMENT_LEVEL.has(s));
++}
++
++function pick(nodes, wanted) {
++ const out = [];
++ for (const node of nodes) {
++ if (node.type === 'rule') { if (keepRule(node, wanted)) out.push(node.text.trim()); continue; }
++ if (node.type === 'block') {
++ const inner = pick(node.children, wanted);
++ if (inner.length) out.push(`${node.prelude} {\n${inner.join('\n')}\n}`);
++ }
++ }
++ return out;
++}
++
++function extract(css, selectors) {
++ const wanted = selectors instanceof Set ? selectors : new Set(selectors);
++ const nodes = parse(css);
++ const kept = pick(nodes, wanted);
++ const body = kept.join('\n');
++ // The @keyframes (and @font-face) the kept rules use; nothing else that is raw.
++ const raws = [];
++ const walk = (list) => {
++ for (const node of list) {
++ if (node.type === 'block') walk(node.children);
++ if (node.type !== 'raw') continue;
++ if (node.name === '@font-face') raws.push(node.text.trim());
++ if (/^@(-webkit-)?keyframes$/.test(node.name)) {
++ const name = (node.prelude || '').split(/\s+/)[1];
++ if (name && new RegExp(`(^|[\\s,:])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s,;!]|$)`, 'm').test(body)) raws.push(node.text.trim());
++ }
++ }
++ };
++ walk(nodes);
++ return `/* Generated by server/web/critical-css.js from style.css: the rules a public page's first view uses. */\n${body}\n${raws.join('\n')}\n`;
++}
++
++let _cache = null;
++/** The subset for the style.css on disk, rebuilt when it or the selector list changes. */
++function core() {
++ let st, sel;
++ try { st = fs.statSync(STYLE); sel = fs.statSync(SELECTORS); } catch { return null; }
++ const key = `${st.mtimeMs}:${st.size}:${sel.mtimeMs}:${sel.size}`;
++ if (_cache && _cache.key === key) return _cache;
++ let selectors = [];
++ try { selectors = JSON.parse(fs.readFileSync(SELECTORS, 'utf8')).selectors || []; } catch { selectors = []; }
++ if (!selectors.length) return null;
++ const css = extract(fs.readFileSync(STYLE, 'utf8'), selectors);
++ _cache = { key, css, hash: require('crypto').createHash('sha256').update(css).digest('hex').slice(0, 12), bytes: Buffer.byteLength(css), from: st.size };
++ return _cache;
++}
++
++module.exports = { parse, splitSelectors, extract, core, STYLE, SELECTORS, DOCUMENT_LEVEL };
ddiff --git a/test/critical-css.test.js b/test/critical-css.test.js
nnew file mode 100644
iindex 00000000..5f359654
---- /dev/null
++++ b/test/critical-css.test.js
@@@ -0,0 +1,81 @@
++'use strict';
++// The first-paint subset of style.css (server/web/critical-css.js) and the shell swap (server/web/assets.js): a public
++// route's first paint blocks only on the subset, the full style.css follows without blocking in the same place (so the
++// final cascade is unchanged), every other route keeps style.css blocking, and the subset is a fraction of the file.
++const assert = require('assert');
++const fs = require('fs');
++const critical = require('../server/web/critical-css');
++const assets = require('../server/web/assets');
++
++let pass = 0;
++const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
++
++// ── The parser and the extraction ──
++{
++ const css = `/* a comment { with braces } */
++:root { --x: 1; }
++html { color: red; }
++.keep, .other:is(.a, .b) { color: blue; animation: spin 1s linear; }
++.drop { color: green; animation: fade 1s; }
++@media (max-width: 600px) { .keep { padding: 1px; } .drop { padding: 2px; } }
++@media print { .drop { display: none; } }
++@supports (display: grid) { .keep { display: grid; } }
++@keyframes spin { from { transform: rotate(0); } to { transform: rotate(1turn); } }
++@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
++.str::after { content: "}{"; }`;
++ assert.deepStrictEqual(critical.splitSelectors('.keep, .other:is(.a, .b), [x=","] y'), ['.keep', '.other:is(.a, .b)', '[x=","] y']);
++ const nodes = critical.parse(css);
++ assert.strictEqual(nodes.filter((n) => n.type === 'rule').length, 5, 'top-level rules, a string with braces included');
++ const out = critical.extract(css, ['.keep', '.str::after']);
++ assert.ok(out.includes(':root { --x: 1; }') && out.includes('html { color: red; }'), 'document-level rules always');
++ assert.ok(out.includes('.keep, .other:is(.a, .b) { color: blue;'), 'a rule naming a wanted selector, whole');
++ assert.ok(!out.includes('.drop'), 'nothing else');
++ assert.match(out, /@media \(max-width: 600px\) \{\n\.keep \{ padding: 1px; \}\n\}/, 'inside its @media, only the kept rule');
++ assert.ok(!out.includes('@media print'), 'an @media left empty is dropped');
++ assert.ok(out.includes('@supports (display: grid)'), '@supports kept with its rule');
++ assert.ok(out.includes('@keyframes spin') && !out.includes('@keyframes fade'), 'only the keyframes kept rules animate with');
++ assert.ok(out.includes('content: "}{"'), 'strings survive');
++ ok('extract keeps wanted and document-level rules in their @media/@supports, and only their keyframes');
++}
++
++// ── The real style.css ──
++const core = critical.core();
++assert.ok(core && core.css.length > 1000, 'a subset is built from the committed selector list');
++assert.ok(core.bytes < core.from * 0.3, `the subset is a fraction of style.css (${core.bytes} of ${core.from})`);
++assert.match(core.hash, /^[0-9a-f]{12}$/);
++assert.ok(core.css.includes(':root'), 'the theme variables are in it');
++{
++ const listed = JSON.parse(fs.readFileSync(critical.SELECTORS, 'utf8')).selectors;
++ const inFile = new Set();
++ const walk = (list) => { for (const n of list) { if (n.type === 'rule') critical.splitSelectors(n.selector).forEach((s) => inFile.add(s)); else if (n.type === 'block') walk(n.children); } };
++ walk(critical.parse(fs.readFileSync(critical.STYLE, 'utf8')));
++ const stale = listed.filter((s) => !inFile.has(s));
++ // A renamed or removed rule leaves its selector behind: harmless, but a list this stale should be regenerated.
++ assert.ok(stale.length <= listed.length * 0.1, `critical-selectors.json is stale (${stale.length} of ${listed.length} no longer in style.css): run node scripts/perf/critical-css.js`);
++}
++ok(`the subset of the real style.css is ${(100 * core.bytes / core.from).toFixed(0)}% of it, from a current selector list`);
++
++// ── The shell ──
++const doc = assets.document('index.html').html;
++const blockingLinks = (html) => [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"(?![^>]*media="print")[^>]*>/g)].map((m) => m[1]).filter((h) => !/noscript/.test(h));
++for (const url of ['/', '/content', '/vods', '/clips', '/chat', '/search?q=x', '/@someone', '/vod/12', '/clip/7', '/arena']) {
++ const html = assets.renderRoute(doc, url);
++ const head = html.slice(0, html.indexOf('</head>'));
++ assert.ok(head.includes(`<link rel="stylesheet" href="/css/style.core.css?v=${core.hash}" data-ov-first-paint>`), `${url}: the subset blocks the first paint`);
++ const full = /<link rel="stylesheet" href="(\/css\/style\.css\?v=[^"]+)" media="print" onload="this\.media='all';this\.onload=null">/.exec(head);
++ assert.ok(full, `${url}: the full style.css follows without blocking`);
++ assert.ok(head.indexOf('style.core.css') < head.indexOf(full[1]) && head.indexOf(full[1]) < head.indexOf('<meta name="ov-css-anchor">'), `${url}: in style.css's place in the cascade, before the feature stylesheets`);
++ assert.ok(head.includes(`<noscript><link rel="stylesheet" href="${full[1]}"></noscript>`), `${url}: a no-JS copy`);
++ assert.ok(!blockingLinks(head.replace(/<noscript>[\s\S]*?<\/noscript>/g, '')).some((h) => h.startsWith('/css/style.css')), `${url}: style.css itself no longer blocks`);
++}
++for (const url of ['/dashboard', '/broadcast', '/settings', '/admin', '/documentation', '/vod/12/edit', '/@someone/videos']) {
++ const html = assets.renderRoute(doc, url);
++ assert.ok(!html.includes('style.core.css'), `${url}: no subset`);
++ assert.match(html, /<link rel="stylesheet" href="\/css\/style\.css\?v=[^"]+">/, `${url}: style.css blocks as before`);
++}
++ok('public routes block on the subset and load style.css after it in the same place; every other route is unchanged');
++
++assert.strictEqual(assets.hashOf(assets.CORE_CSS), core.hash, 'the subset is versioned by its content');
++ok('the subset is versioned by its content');
++
++console.log(`critical-css: ${pass} checks passed`);