From f52b8226beffc7871054b62448139b89893d1d31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:03:42 +0000 Subject: [PATCH 1/7] Hide the drawers until their element is upgraded A custom element renders its children until it is upgraded, and a side drawer keeps its closed state in its shadow root. The script that defines it is an async module, so it runs after the first paint: until then the search drawer painted over the header and the table of contents drawer over the article, which read as a collapsed layout for a moment. Measured while undefined, the search drawer was 500x98 over the header and the table of contents drawer 500x216 over the article. The nav drawer was not visible on a wide screen, because it sits in the mobile nav, which is hidden there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW --- .changeset/olive-moons-shake.md | 10 ++++++++++ packages/markdown/assets/shell.css | 7 +++++++ website/en/book/changelog.md | 2 ++ 3 files changed, 19 insertions(+) create mode 100644 .changeset/olive-moons-shake.md diff --git a/.changeset/olive-moons-shake.md b/.changeset/olive-moons-shake.md new file mode 100644 index 000000000..b12d62fac --- /dev/null +++ b/.changeset/olive-moons-shake.md @@ -0,0 +1,10 @@ +--- +"@hyperbook/markdown": patch +"hyperbook": patch +"hyperbook-studio": patch +--- + +Fix the drawers flashing over the page while it loads. A custom element renders +its children until it is upgraded, and a side drawer is only hidden by its +shadow root, so the search drawer and the table of contents drawer painted over +the header and the article until `side-drawer.js` had run. diff --git a/packages/markdown/assets/shell.css b/packages/markdown/assets/shell.css index 5d39024d9..200dca032 100644 --- a/packages/markdown/assets/shell.css +++ b/packages/markdown/assets/shell.css @@ -41,6 +41,13 @@ side-drawer { display: none; } +/* A custom element renders its children until it is upgraded. The drawers are + only hidden by their shadow root, so without this their content paints over + the page until side-drawer.js has run. */ +side-drawer:not(:defined) { + display: none; +} + /* Hide mobile nav toggle when JavaScript is disabled */ .no-js .mobile-nav .toggle { display: none; diff --git a/website/en/book/changelog.md b/website/en/book/changelog.md index e1dcd2c5e..573abdac4 100644 --- a/website/en/book/changelog.md +++ b/website/en/book/changelog.md @@ -62,6 +62,8 @@ If you need a new feature, open an [issue](https://github.com/openpatch/hyperboo **bookmarks**: A heading that contains a quote or a backslash can be bookmarked again. +**shell**: The search and table of contents drawers no longer flash over the page for a moment while it loads. + ::: :::: From 3df69aca695d3757d97ed217e0acde86e07593ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 11:14:49 +0000 Subject: [PATCH 2/7] Inline the script that loads the theme stylesheets The light and dark stylesheets sit in a noscript element and are written into the page by a script. Nothing can paint before they are there, so loading that script as a file put a round trip in front of every first paint, and a second one in front of the stylesheets it writes. The script is 1.5 kB, so it is part of the page now. On the documentation, over a connection throttled to 1.5 Mbit/s with 100 ms of latency, the first contentful paint went from a median of 1128 ms to 984 ms over seven runs each. The behaviour is unchanged: a mode the reader picked before still wins over the system preference, in both directions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW --- .changeset/gentle-pans-smile.md | 11 + .../darkModeStylesheetsLoader.ts} | 27 +- packages/markdown/src/rehypeHtmlStructure.ts | 15 +- .../tests/__snapshots__/process.test.ts.snap | 324 +++++++++++++++++- website/en/book/changelog.md | 2 + 5 files changed, 349 insertions(+), 30 deletions(-) create mode 100644 .changeset/gentle-pans-smile.md rename packages/markdown/{assets/dark-mode-toggle-stylesheets-loader.js => src/darkModeStylesheetsLoader.ts} (50%) diff --git a/.changeset/gentle-pans-smile.md b/.changeset/gentle-pans-smile.md new file mode 100644 index 000000000..b56f7faad --- /dev/null +++ b/.changeset/gentle-pans-smile.md @@ -0,0 +1,11 @@ +--- +"@hyperbook/markdown": patch +"hyperbook": patch +"hyperbook-studio": patch +--- + +Inline the script that loads the light and dark stylesheets. It was a file, and +nothing can paint before the stylesheets it writes are there, so every first +paint waited for a round trip to fetch 1.5 kB and then another for the +stylesheets themselves. It is part of the page now, which measured about 140 ms +off the first contentful paint of the documentation on a throttled connection. diff --git a/packages/markdown/assets/dark-mode-toggle-stylesheets-loader.js b/packages/markdown/src/darkModeStylesheetsLoader.ts similarity index 50% rename from packages/markdown/assets/dark-mode-toggle-stylesheets-loader.js rename to packages/markdown/src/darkModeStylesheetsLoader.ts index 9e888b15e..5f08e2224 100644 --- a/packages/markdown/assets/dark-mode-toggle-stylesheets-loader.js +++ b/packages/markdown/src/darkModeStylesheetsLoader.ts @@ -1,19 +1,17 @@ /** - * Copyright 2024 Google LLC + * Writes the light and dark stylesheets into the page, honoring a mode the + * reader picked before. The stylesheets sit in a noscript element, so they are + * only text until this runs. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * This is inlined into the head instead of being loaded as a file. Nothing can + * paint before the stylesheets are there, so an external script would put a + * round trip in front of every first paint, and a second one in front of the + * stylesheets it writes. * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * © 2024 Google LLC. Licensed under the Apache License, Version 2.0. + * https://www.apache.org/licenses/LICENSE-2.0 */ - +export const darkModeStylesheetsLoader = ` // @license © 2024 Google LLC. Licensed under the Apache License, Version 2.0. (() => { const ELEMENT_ID = 'dark-mode-toggle-stylesheets'; @@ -28,8 +26,8 @@ mode = localStorage.getItem(STORAGE_NAME); } catch (e) {} - const lightCSSMediaRegex = /\(\s*prefers-color-scheme\s*:\s*light\s*\)/gi; - const darkCSSMediaRegex = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/gi; + const lightCSSMediaRegex = /\\(\\s*prefers-color-scheme\\s*:\\s*light\\s*\\)/gi; + const darkCSSMediaRegex = /\\(\\s*prefers-color-scheme\\s*:\\s*dark\\s*\\)/gi; switch (mode) { case LIGHT: @@ -47,3 +45,4 @@ document.write(stylesheets); })(); +// @license-end`; diff --git a/packages/markdown/src/rehypeHtmlStructure.ts b/packages/markdown/src/rehypeHtmlStructure.ts index 5254a7f9b..bdc7331ed 100644 --- a/packages/markdown/src/rehypeHtmlStructure.ts +++ b/packages/markdown/src/rehypeHtmlStructure.ts @@ -4,6 +4,7 @@ import { HyperbookContext } from "@hyperbook/types"; import { ElementContent, Root } from "hast"; import { VFile } from "vfile"; +import { darkModeStylesheetsLoader } from "./darkModeStylesheetsLoader"; function parseFont(font: string): [string, string] { const parts = font.split(":"); @@ -379,13 +380,13 @@ window.hyperbook.emoji = { style: "twemoji", base: "${makeUrl(["emoji"], "assets { type: "element", tagName: "script", - properties: { - src: makeUrl( - ["dark-mode-toggle-stylesheets-loader.js"], - "assets", - ), - }, - children: [], + properties: {}, + children: [ + { + type: "raw", + value: darkModeStylesheetsLoader, + }, + ], }, { type: "element", diff --git a/packages/markdown/tests/__snapshots__/process.test.ts.snap b/packages/markdown/tests/__snapshots__/process.test.ts.snap index d811d1025..ebb30b558 100644 --- a/packages/markdown/tests/__snapshots__/process.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/process.test.ts.snap @@ -41,7 +41,41 @@ body { }
My Hyperbook
+
My Hyperbook
@@ -285,7 +285,7 @@ window.Prism = window.Prism || {}; window.Prism.manual = true;
My Hyperbook
+
My Hyperbook
@@ -382,7 +382,7 @@ window.Prism = window.Prism || {}; window.Prism.manual = true;
My Hyperbook
+
My Hyperbook
@@ -477,7 +477,7 @@ window.Prism = window.Prism || {}; window.Prism.manual = true;
My Hyperbook
+
My Hyperbook
@@ -572,7 +572,7 @@ window.Prism = window.Prism || {}; window.Prism.manual = true;
My Hyperbook
+
My Hyperbook
@@ -669,7 +669,7 @@ window.Prism = window.Prism || {}; window.Prism.manual = true;
My Hyperbook
+
My Hyperbook
@@ -771,7 +771,7 @@ window.Prism = window.Prism || {}; window.Prism.manual = true;
My Hyperbook
+
My Hyperbook
diff --git a/packages/markdown/tests/rehypeShell.test.ts b/packages/markdown/tests/rehypeShell.test.ts index c9f02f604..d8103bd95 100644 --- a/packages/markdown/tests/rehypeShell.test.ts +++ b/packages/markdown/tests/rehypeShell.test.ts @@ -323,4 +323,80 @@ describe("rehypeShell", () => { const value = toHtml("", rootCtx).value; expect(value).not.toContain('class="breadcrumb"'); }); + + describe("navigation order", () => { + // The order of the labels in the navigation, so a test reads like the + // sidebar looks. A section is a
, a page an . + const order = (navigation: Partial) => { + const html = String( + toHtml("", { + ...ctx, + navigation: { + ...ctx.navigation, + sections: [], + pages: [], + ...navigation, + }, + }).value, + ); + // The breadcrumb is a