From 1bd337f9c4667f38e33d7228242c61910ede60f5 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 29 Sep 2026 15:27:06 +0000 Subject: [PATCH 1/3] Optimize documentation image delivery and initial loading --- .github/workflows/docs-preview-deploy.yml | 2 + .github/workflows/docs-preview.yml | 2 + docs/development/index.md | 12 +++ docs/javascripts/dev-notes.js | 4 + docs/javascripts/pi-traces.js | 28 ++++- docs/stylesheets/dev-notes.css | 2 + overrides/partials/header.html | 12 +++ requirements-docs.txt | 2 + scripts/build-docs.sh | 2 + scripts/optimize-site.py | 123 ++++++++++++++++++++++ tests/docs-preview.test.js | 2 + tests/test_dev_notes_layout.py | 91 ++++++++++++++++ tests/test_optimize_site.py | 97 +++++++++++++++++ zensical.toml | 1 + 14 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 scripts/optimize-site.py create mode 100644 tests/test_optimize_site.py diff --git a/.github/workflows/docs-preview-deploy.yml b/.github/workflows/docs-preview-deploy.yml index 36fa3ccc..3a1bbe64 100644 --- a/.github/workflows/docs-preview-deploy.yml +++ b/.github/workflows/docs-preview-deploy.yml @@ -97,11 +97,13 @@ jobs: '.github/workflows/docs-preview-deploy.yml', 'requirements-docs.txt', 'scripts/build-docs.sh', + 'scripts/optimize-site.py', 'scripts/publish-agent-markdown.py', 'scripts/render-dev-notes.py', 'scripts/stage-project-docs.py', 'tests/test_agent_markdown.py', 'tests/test_docs_404.py', + 'tests/test_optimize_site.py', 'tests/test_render_dev_notes.py', 'tests/test_dev_note_headers.py', 'tests/test_dev_notes_layout.py', diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 8b3487d5..72adf492 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -45,11 +45,13 @@ jobs: '.github/workflows/docs-preview-deploy.yml', 'requirements-docs.txt', 'scripts/build-docs.sh', + 'scripts/optimize-site.py', 'scripts/publish-agent-markdown.py', 'scripts/render-dev-notes.py', 'scripts/stage-project-docs.py', 'tests/test_agent_markdown.py', 'tests/test_docs_404.py', + 'tests/test_optimize_site.py', 'tests/test_render_dev_notes.py', 'tests/test_dev_note_headers.py', 'tests/test_dev_notes_layout.py', diff --git a/docs/development/index.md b/docs/development/index.md index 40109930..08df5904 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -127,6 +127,18 @@ viewport. Heroes fill that available space up to the reading-column width and 560px height, preserving their native proportions without cropping. Longer text leaves less room for the hero; authors should keep titles and subtitles concise. The index continues to use compact thumbnails. +The clean build runs `scripts/optimize-site.py` over rendered HTML. Local PNG, +JPEG, and static WebP images receive content-addressed WebP `srcset` variants +from 320 to 1920 pixels wide, intrinsic dimensions, and asynchronous decoding. +Original image URLs and full-size links stay intact, as do published Markdown +sources. Do not commit the generated `site/assets/responsive/` files. Authored +responsive pictures and animated images are preserved. Card `sizes` follow their +featured or archive role, including after filtering. Hidden light/dark variants +use native lazy loading so only the visible theme downloads. Body images load +lazily, and video posters use compressed variants. The site uses system fonts +without external font stylesheets. +The transcript viewer defers its JSON download and DOM construction until it +approaches the viewport; transcript source fragment links initialize it directly. Clicking a hero opens the original image, so a diagram can remain compact without losing access to its details. Index images use bounded frames with the full image visible. Do not add per-post title/hero sizing or force an image into a different diff --git a/docs/javascripts/dev-notes.js b/docs/javascripts/dev-notes.js index 9ae873f4..531c61e2 100644 --- a/docs/javascripts/dev-notes.js +++ b/docs/javascripts/dev-notes.js @@ -62,6 +62,10 @@ matches.forEach((card, index) => { card.element.classList.toggle("dev-note-card--featured", index === 0); card.element.classList.toggle("dev-note-card--recent", index !== 0); + card.element.querySelectorAll("img[data-featured-sizes]").forEach(image => { + image.sizes = index === 0 ? image.dataset.featuredSizes : image.dataset.recentSizes; + image.fetchPriority = index === 0 ? "high" : "auto"; + }); if (index === 0) featured.insertBefore(card.element, empty); else recentList.append(card.element); }); diff --git a/docs/javascripts/pi-traces.js b/docs/javascripts/pi-traces.js index 8a89bef9..ca2ca72a 100644 --- a/docs/javascripts/pi-traces.js +++ b/docs/javascripts/pi-traces.js @@ -298,8 +298,34 @@ } } + let cleanupPending = () => {}; function enhance() { - document.querySelectorAll(".pi-traces").forEach(initialize); + cleanupPending(); + const pending = new Set(document.querySelectorAll(".pi-traces:not([data-initialized])")); + if (!pending.size) return; + // The transcript payload and its DOM are substantial. Prepare them just + // before the reader reaches the viewer, or immediately for source links. + const observer = "IntersectionObserver" in window + ? new IntersectionObserver(entries => { + entries.filter(entry => entry.isIntersecting).forEach(entry => start(entry.target)); + }, { rootMargin: "600px" }) + : null; + const start = viewer => { + pending.delete(viewer); + observer?.unobserve(viewer); + initialize(viewer); + if (!pending.size) cleanupPending(); + }; + const followPendingSource = () => { + if (/^#trace-source-/.test(window.location.hash)) [...pending].forEach(start); + }; + cleanupPending = () => { + observer?.disconnect(); + window.removeEventListener("hashchange", followPendingSource); + }; + window.addEventListener("hashchange", followPendingSource); + followPendingSource(); + pending.forEach(viewer => observer ? observer.observe(viewer) : start(viewer)); } if (window.document$?.subscribe) window.document$.subscribe(enhance); else if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", enhance, { once: true }); diff --git a/docs/stylesheets/dev-notes.css b/docs/stylesheets/dev-notes.css index d4bdf66d..609c2e07 100644 --- a/docs/stylesheets/dev-notes.css +++ b/docs/stylesheets/dev-notes.css @@ -32,6 +32,8 @@ --openshell-serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino, Georgia, serif; --openshell-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --openshell-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + --md-text-font: var(--openshell-sans); + --md-code-font: var(--openshell-mono); --md-default-bg-color: var(--openshell-paper); --md-default-fg-color: var(--openshell-ink); --md-primary-fg-color: var(--openshell-paper-raised); diff --git a/overrides/partials/header.html b/overrides/partials/header.html index 7d373ad0..67024173 100644 --- a/overrides/partials/header.html +++ b/overrides/partials/header.html @@ -42,6 +42,18 @@ {% endif %} {% if not config.theme.palette is mapping %} {% include "partials/javascripts/palette.html" %} + {% endif %} {% if config.extra.alternate %} {% include "partials/alternate.html" %} diff --git a/requirements-docs.txt b/requirements-docs.txt index ecaaf088..9069e7c7 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,2 +1,4 @@ pytest==8.4.2 +beautifulsoup4==4.14.3 +Pillow==12.1.1 zensical==0.0.62 diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 6c4e642b..9761ee32 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -33,6 +33,8 @@ python -m pip install -r requirements-docs.txt python scripts/stage-project-docs.py python scripts/render-dev-notes.py zensical build --clean --strict +python scripts/optimize-site.py +python -m pytest -q tests/test_optimize_site.py python scripts/publish-agent-markdown.py REQUIRE_RENDERED_AGENT_MARKDOWN=1 python tests/test_agent_markdown.py REQUIRE_RENDERED_404=1 python tests/test_docs_404.py diff --git a/scripts/optimize-site.py b/scripts/optimize-site.py new file mode 100644 index 00000000..b81d8f81 --- /dev/null +++ b/scripts/optimize-site.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Add responsive images to the built site without changing published sources.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +from urllib.parse import quote, unquote, urlsplit + +from bs4 import BeautifulSoup +from PIL import Image, ImageOps + + +ROOT = Path(__file__).resolve().parents[1] +WIDTHS = (320, 640, 960, 1440, 1920) +THEME_CLASSES = { + "dev-note-image--light", "dev-note-image--dark", + "openshell-home-brand__light", "openshell-home-brand__dark", +} +FEATURED_SIZES = "(max-width: 44rem) min(100vw - 32px, 26rem), 640px" +RECENT_SIZES = "(max-width: 44rem) 5rem, 10rem" +ARTICLE_SIZES = "(max-width: 800px) calc(100vw - 32px), 900px" + + +def local_asset(site: Path, page: Path, url: str) -> Path | None: + parsed = urlsplit(url) + # Site-relative URLs work identically at /, a project prefix, and PR previews. + if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith("/"): + return None + asset = (page.parent / unquote(parsed.path)).resolve() + if not asset.is_relative_to(site) or not asset.is_file(): + return None + return asset + + +def image_variants(asset: Path, site: Path) -> tuple[int, int, list[tuple[Path, int]]] | None: + if asset.is_relative_to(site / "assets" / "responsive"): + return None + if asset.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp"}: + return None + with Image.open(asset) as original: + if getattr(original, "is_animated", False): + return None + transparent = "A" in original.getbands() or "transparency" in original.info + image = ImageOps.exif_transpose(original).convert("RGBA" if transparent else "RGB") + width, height = image.size + digest = hashlib.sha256(asset.read_bytes()).hexdigest()[:16] + output = site / "assets" / "responsive" + output.mkdir(parents=True, exist_ok=True) + variants = [] + for size in sorted({min(width, candidate) for candidate in WIDTHS}): + target = output / f"{digest}-{size}.webp" + if not target.exists(): + resized = image.resize((size, max(1, round(height * size / width))), Image.Resampling.LANCZOS) + resized.save(target, "WEBP", quality=88, method=6) + variants.append((target, size)) + return width, height, variants + + +def relative_url(asset: Path, page: Path) -> str: + return quote(Path(os.path.relpath(asset, page.parent)).as_posix()) + + +def optimize_site(site: Path) -> int: + site = site.resolve() + if not (site / "index.html").is_file(): + raise ValueError(f"Build the site before optimizing it: {site}") + cache = {} + count = 0 + for page in sorted(site.rglob("*.html")): + soup = BeautifulSoup(page.read_text(), "html.parser") + before = str(soup) + for image in soup.select("img[src]"): + classes = set(image.get("class", [])) | set(image.parent.get("class", [])) + image.attrs.setdefault("decoding", "async") + # Native lazy loading leaves display:none theme variants unfetched, + # including when a saved palette differs from the system preference. + if classes & THEME_CLASSES: + image["loading"] = "lazy" + elif not image.has_attr("loading") and image.find_parent("article"): + image["loading"] = "lazy" + asset = local_asset(site, page, image["src"]) + if asset is None or image.has_attr("srcset") or image.find_parent("picture"): + continue + if asset not in cache: + cache[asset] = image_variants(asset, site) + if not cache[asset]: + continue + width, height, variants = cache[asset] + # Keep authored dimensions together; otherwise reserve the native ratio. + if not image.has_attr("width") and not image.has_attr("height"): + image["width"], image["height"] = str(width), str(height) + image["srcset"] = ", ".join(f"{relative_url(path, page)} {size}w" for path, size in variants) + card = image.find_parent(class_="dev-note-card") + image["sizes"] = ( + FEATURED_SIZES if card and "dev-note-card--featured" in card.get("class", []) + else RECENT_SIZES if card else ARTICLE_SIZES + ) + if card: + image["data-featured-sizes"] = FEATURED_SIZES + image["data-recent-sizes"] = RECENT_SIZES + count += 1 + for video in soup.select("video[poster]"): + asset = local_asset(site, page, video["poster"]) + if asset is not None: + if asset not in cache: + cache[asset] = image_variants(asset, site) + if cache[asset]: + variants = cache[asset][2] + poster = next((path for path, size in variants if size >= 960), variants[-1][0]) + video["poster"] = relative_url(poster, page) + after = str(soup) + if after != before: + page.write_text(after, encoding="utf-8") + return count + + +if __name__ == "__main__": + print(f"Optimized {optimize_site(ROOT / 'site')} image placements with responsive WebP variants.") diff --git a/tests/docs-preview.test.js b/tests/docs-preview.test.js index 0966961c..530fefc8 100644 --- a/tests/docs-preview.test.js +++ b/tests/docs-preview.test.js @@ -28,6 +28,8 @@ for (const workflow of ["docs-preview.yml", "docs-preview-deploy.yml"]) { "docs/dev-notes/posts/example.md", "overrides/main.html", "scripts/stage-project-docs.py", + "scripts/optimize-site.py", + "tests/test_optimize_site.py", "tests/docs-preview.test.js", "tests/test_dev_note_headers.py", "tests/test_dev_notes_layout.py", diff --git a/tests/test_dev_notes_layout.py b/tests/test_dev_notes_layout.py index 245ba923..3e300361 100644 --- a/tests/test_dev_notes_layout.py +++ b/tests/test_dev_notes_layout.py @@ -167,6 +167,83 @@ def test_index_reading_proportions(page, site_url, viewport): assert_index_proportions(page, viewport) +def test_index_image_budget_and_theme_loading(page, site_url): + images = [] + page.on("response", lambda response: images.append({ + "name": response.url, "bytes": int(response.headers.get("content-length", 0)), + }) if response.request.resource_type == "image" and response.url.startswith(site_url) else None) + open_page(page, f"{site_url}/dev-notes/") + featured = page.locator(".dev-note-card--featured img.dev-note-card__visual-image") + # Do not force hidden images eager: these assertions exercise real loading. + for image in featured.all(): + if image.is_visible(): + image.evaluate("image => image.decode()") + resources = page.evaluate("""performance.getEntriesByType('resource').map(entry => ({ + name: entry.name, type: entry.initiatorType, bytes: entry.encodedBodySize + }))""") + assert sum(entry["bytes"] for entry in images) < 300_000, images + assert not any(urlparse(entry["name"]).path.endswith(".png") for entry in images), images + assert not any("fonts.googleapis.com" in entry["name"] for entry in resources) + for image in featured.all(): + if not image.is_visible(): + # WebKit selects currentSrc even while the image remains unfetched. + assert image.evaluate("image => image.currentSrc") not in {entry["name"] for entry in images} + assert image.evaluate("image => image.naturalWidth") == 0 + page.evaluate("""document.body.dataset.mdColorScheme = + document.body.dataset.mdColorScheme === 'slate' ? 'default' : 'slate'""") + for image in featured.all(): + if image.is_visible(): + page.wait_for_function("image => image.complete && image.naturalWidth > 0", arg=image.element_handle()) + assert "/assets/responsive/" in image.evaluate("image => image.currentSrc") + + +def test_transcripts_wait_for_viewport_and_follow_source_links(page, site_url): + url = f"{site_url}/dev-notes/posts/2026-09-26-network-redaction-is-not-enough/" + requests = [] + page.on("request", lambda request: requests.append(request.url)) + open_page(page, url) + assert not any(request.endswith("viewer.json") for request in requests) + viewer = page.locator(".pi-traces") + viewer.scroll_into_view_if_needed() + expect(viewer.locator(".pi-traces__app")).to_be_visible() + assert sum(request.endswith("viewer.json") for request in requests) == 1 + open_page(page, f"{site_url}/dev-notes/") + open_page(page, url + "#trace-source-kimi-on") + expect(page.locator("#trace-panel-kimi-on")).to_be_visible() + expect(page.locator("#trace-panel-kimi-off")).to_be_hidden() + page.locator("#trace-tab-kimi-off").click() + expect(page.locator("#trace-panel-kimi-off")).to_be_visible() + page.locator("#trace-model-tab-kimi").focus() + page.keyboard.press("ArrowRight") + expect(page.locator("#trace-model-tab-glm")).to_be_focused() + expect(page.locator("#trace-panel-glm-off")).to_be_visible() + viewer.locator(".pi-traces__collapse").click() + expect(viewer.locator(".pi-traces__content")).to_be_hidden() + viewer.locator(".pi-traces__collapse").click() + expect(page.locator("#trace-panel-glm-off")).to_be_visible() + + +def test_saved_theme_loads_only_its_hero_on_retina_screen(browser, site_url): + page = browser.new_page(viewport={"width": 390, "height": 844}, device_scale_factor=2, color_scheme="light") + page.route("**/*", lambda route: route.continue_() if route.request.url.startswith(site_url) else route.abort()) + page.add_init_script("""localStorage.setItem('/.__palette', JSON.stringify({ + color: {scheme: 'slate', media: '(prefers-color-scheme: dark)', primary: 'black', accent: 'green'} + }))""") + requests = [] + page.on("request", lambda request: requests.append(request.url)) + try: + page.goto(f"{site_url}/dev-notes/", wait_until="networkidle") + assert page.locator("body").get_attribute("data-md-color-scheme") == "slate" + hero = page.locator(".dev-note-card--featured .dev-note-image--dark") + hero.evaluate("image => image.decode()") + assert "/assets/responsive/" in hero.evaluate("image => image.currentSrc") + hidden = page.locator(".dev-note-card--featured .dev-note-image--light") + assert hidden.evaluate("image => image.currentSrc") not in requests + assert hidden.evaluate("image => image.naturalWidth") == 0 + finally: + page.close() + + def test_featured_image_also_works_as_a_thumbnail(page, site_url, viewport): open_page(page, f"{site_url}/dev-notes/") if page.locator(".dev-note-card--recent").count() == 0: @@ -370,6 +447,20 @@ def test_shared_documentation_navigation(page, site_url, viewport): ), "Closing the drawer should restore focus" +def test_homepage_brand_and_navigation(page, site_url): + open_page(page, site_url + "/") + assert_no_horizontal_overflow(page) + brand = page.locator(".openshell-home-brand img:visible") + assert brand.count() == 1 + brand.evaluate("image => image.decode()") + assert "/assets/responsive/" in brand.evaluate("image => image.currentSrc") + page.locator(".openshell-home-link").filter(has=page.get_by_text("Dev Notes", exact=True)).click() + expect(page.locator(".dev-notes-page")).to_be_visible() + page.go_back(wait_until="networkidle") + page.locator(".openshell-home-link").filter(has=page.get_by_text("Documentation", exact=True)).click() + expect(page.locator("h1")).to_contain_text("Documentation") + + @pytest.mark.parametrize("post", VIDEO_POSTS, ids=lambda post: post.stem) def test_embedded_video_playback(page, site_url, post): open_page(page, f"{site_url}/dev-notes/posts/{post.stem}/") diff --git a/tests/test_optimize_site.py b/tests/test_optimize_site.py new file mode 100644 index 00000000..9f620db9 --- /dev/null +++ b/tests/test_optimize_site.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import importlib.util +from pathlib import Path + +from bs4 import BeautifulSoup +from PIL import Image + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("optimize_site", ROOT / "scripts/optimize-site.py") +optimizer = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(optimizer) + + +def test_responsive_images_preserve_originals_and_preview_paths(tmp_path): + site = tmp_path / "pr-preview/pr-123" + asset = site / "assets/diagram.png" + asset.parent.mkdir(parents=True) + Image.new("RGB", (1600, 800), "green").save(asset) + original = asset.read_bytes() + (site / "index.html").write_text("") + page = site / "dev-notes/posts/example/index.html" + page.parent.mkdir(parents=True) + page.write_text('''
+ + Diagram + + + + +
''') + assert optimizer.optimize_site(site) == 1 + soup = BeautifulSoup(page.read_text(), "html.parser") + image = soup.select_one("a img") + assert image["src"] == soup.a["href"] + assert image["alt"] == "Diagram" + assert image["loading"] == "lazy" + assert image["fetchpriority"] == "high" + assert (image["width"], image["height"]) == ("1600", "800") + for entry in image["srcset"].split(", "): + url, width = entry.split() + variant = (page.parent / url).resolve() + assert variant.is_relative_to(site) + with Image.open(variant) as resized: + assert resized.width == int(width[:-1]) + assert resized.width == resized.height * 2 + assert asset.read_bytes() == original + assert "srcset" not in soup.select("img")[1].attrs + assert (page.parent / soup.video["poster"]).is_file() + assert soup.video["preload"] == "none" + assert soup.script.string == 'const value = "&world";' + before = page.read_bytes() + assert optimizer.optimize_site(site) == 0 + assert page.read_bytes() == before + + +def test_asset_resolution_stays_inside_site(tmp_path): + site = tmp_path / "site" + site.mkdir() + page = site / "index.html" + outside = tmp_path / "private.png" + outside.write_bytes(b"not an image") + (site / "linked.png").symlink_to(outside) + for url in ("../private.png", "linked.png", "https://example.com/x.png", "//example.com/x.png", "data:image/png;base64,abc"): + assert optimizer.local_asset(site, page, url) is None + + +def test_existing_picture_sources_and_animated_images_are_preserved(tmp_path): + Image.new("RGB", (20, 10), "red").save(tmp_path / "still.png") + Image.new("RGB", (20, 10), "red").save( + tmp_path / "animated.webp", save_all=True, + append_images=[Image.new("RGB", (20, 10), "blue")], duration=100, loop=0, + ) + page = tmp_path / "index.html" + page.write_text(''' + ''') + assert optimizer.optimize_site(tmp_path) == 0 + soup = BeautifulSoup(page.read_text(), "html.parser") + assert not soup.picture.img.has_attr("srcset") + assert not soup.select("img")[1].has_attr("srcset") + assert soup.select("img")[2]["srcset"] == "custom.png 2x" + + +def test_palette_transparency_survives_compression(tmp_path): + asset = tmp_path / "transparent.png" + original = Image.new("P", (20, 10), 0) + original.putpalette([0, 0, 0, 255, 0, 0] + [0] * 762) + original.info["transparency"] = 0 + original.putpixel((10, 5), 1) + original.save(asset) + width, height, variants = optimizer.image_variants(asset, tmp_path) + assert (width, height) == (20, 10) + with Image.open(variants[0][0]) as converted: + assert converted.getpixel((0, 0))[3] == 0 + assert converted.getpixel((10, 5))[3] == 255 diff --git a/zensical.toml b/zensical.toml index 902282ee..4fbf3cf6 100644 --- a/zensical.toml +++ b/zensical.toml @@ -68,6 +68,7 @@ pygments_lang_class = true alternate_style = true [project.theme] +font = false custom_dir = "overrides" favicon = "assets/brand/favicon.svg" logo = "assets/brand/openshell-mark.svg" From febdafc696724088b50edea22e744b1cfae8de8e Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 29 Sep 2026 15:43:13 +0000 Subject: [PATCH 2/3] Prevent Dev Notes content shifting during filter initialization --- docs/dev-notes/index.md | 2 +- docs/development/index.md | 3 +++ docs/javascripts/dev-notes.js | 2 +- docs/stylesheets/dev-notes.css | 5 +++-- overrides/main.html | 1 + scripts/render-dev-notes.py | 2 +- tests/test_dev_notes_layout.py | 20 ++++++++++++++++++++ 7 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/dev-notes/index.md b/docs/dev-notes/index.md index 0b48d7e9..be6ad9b2 100644 --- a/docs/dev-notes/index.md +++ b/docs/dev-notes/index.md @@ -24,7 +24,7 @@ hide:
-