Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/docs-preview-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/docs-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion docs/dev-notes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ hide:
<!-- dev-notes:posts:start -->
<!-- Generated by scripts/render-dev-notes.py; edit posts and authors.json. -->
<div class="dev-notes-toolbar">
<form class="dev-notes-filters" aria-label="Filter Dev Notes" hidden>
<form class="dev-notes-filters" aria-label="Filter Dev Notes" inert>
<label for="dev-notes-category">Category
<select id="dev-notes-category" name="category" aria-controls="dev-notes-results">
<option value="">All categories</option>
Expand Down
20 changes: 20 additions & 0 deletions docs/development/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ featured; the remaining matches keep their chronological order. Filters use
Back/Forward navigation. Article byline names and categories link to these
filtered views. Empty combinations show a message and a clear-filters action.
Keep the full index readable when JavaScript is unavailable.
Reserve the filter controls' layout space while their inert form initializes,
so revealing the controls does not move the posts on first load or reload.
The no-JavaScript fallback omits the toolbar entirely.

An optional `card_variant` must have matching card and
artwork CSS modifiers in `docs/stylesheets/dev-notes.css`. Set `hero_image` to
Expand Down Expand Up @@ -127,6 +130,23 @@ 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.
SVG images keep their vector sources and receive dimensions from their viewBox
so heroes, logos, and diagrams reserve space before downloading.
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.
Its responsive frame reserves space while loading and keeps a constant height
when switching models. Filtered index links reveal their cards after applying
the URL filters; the toolbar reserves room for its clear button.
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
Expand Down
6 changes: 5 additions & 1 deletion docs/javascripts/dev-notes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -105,7 +109,7 @@
clear.addEventListener("click", clearFilters);
window.addEventListener("popstate", applyFilters);
applyFilters();
form.hidden = false;
form.inert = false;
cleanup = () => {
form.removeEventListener("change", updateUrl);
form.removeEventListener("submit", updateUrl);
Expand Down
28 changes: 27 additions & 1 deletion docs/javascripts/pi-traces.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
16 changes: 14 additions & 2 deletions docs/stylesheets/dev-notes.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -781,8 +783,18 @@ body:has(.openshell-home-page) .md-path {
border-bottom: 1px solid var(--openshell-rule);
}

.dev-notes-toolbar:not(:has(.dev-notes-filters:not([hidden]))) {
display: none;
.dev-notes-filters[inert] {
/* Reserve the controls' responsive height before enhancement completes. */
visibility: hidden;
}

html[data-dev-notes-filtered] .dev-notes-page:has(.dev-notes-filters[inert]) :is(.dev-notes-featured, .dev-notes-recent) {
visibility: hidden;
}

.dev-notes-page .dev-notes-filters__clear[hidden] {
display: block !important;
visibility: hidden;
}

.dev-notes-filters {
Expand Down
13 changes: 11 additions & 2 deletions docs/stylesheets/pi-traces.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@
--trace-canvas: #12181b;
}
.pi-traces [hidden] { display: none !important; }
/* Keep the viewer's footprint stable while data arrives and tabs change.
The conversation uses the space left below its responsive controls. */
.pi-traces__content { display: flow-root; height: calc(min(38rem, 80svh) + 17rem); }
.pi-traces__app { height: 100%; }
.pi-traces__app, .pi-traces__model-panel, .pi-traces__panel { display: flex; flex-direction: column; min-height: 0; }
.pi-traces__model-panel, .pi-traces__panel { flex: 1; }
.pi-traces__app > *, .pi-traces__model-panel > *, .pi-traces__panel > * { flex-shrink: 0; }
.pi-traces .pi-traces__collapse[hidden] { display: block !important; visibility: hidden; }
.md-typeset .pi-traces button { cursor: pointer; font-family: inherit; }
.pi-traces button:focus-visible, .pi-traces summary:focus-visible,
.pi-traces a:focus-visible,
Expand Down Expand Up @@ -97,7 +105,7 @@
.pi-traces__expand { position: absolute; top: calc(100% + .3rem); right: 0; z-index: 5; display: grid; gap: .4rem; width: 13rem; max-width: calc(100vw - 3rem); padding: .6rem; border: 1px solid var(--openshell-rule); border-radius: .4rem; background: var(--trace-surface); box-shadow: 0 .3rem 1rem #00000020; color: var(--openshell-ink); font-size: .6rem; }
.pi-traces__display-row { display: flex; align-items: center; justify-content: space-between; gap: .7rem; }
.pi-traces__display-row .pi-traces__button { min-width: 4.5rem; }
.pi-traces__conversation { height: 38rem; max-height: 80vh; overflow-y: auto; overscroll-behavior-y: contain; scrollbar-gutter: stable; padding: .7rem 1.2rem 1.4rem; border-block: 1px solid var(--openshell-rule); background: var(--trace-canvas); }
.pi-traces__conversation { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; scrollbar-gutter: stable; padding: .7rem 1.2rem 1.4rem; border-block: 1px solid var(--openshell-rule); background: var(--trace-canvas); }
.pi-traces__prompt { display: flex; align-items: center; gap: .65rem; margin: .8rem 0 1.1rem; color: var(--openshell-muted); font: .5rem var(--openshell-mono); text-transform: uppercase; letter-spacing: .08em; }
.pi-traces__prompt::before, .pi-traces__prompt::after { content: ""; height: 1px; flex: 1; background: var(--openshell-rule); }
.pi-chat { --role-color: var(--trace-assistant); --role-bg: var(--trace-assistant-bg); max-width: 92%; margin: 0 0 1.1rem; font-size: .68rem; line-height: 1.65; }
Expand Down Expand Up @@ -153,7 +161,8 @@
.pi-traces__tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.pi-traces__modes { margin-inline: .7rem; }
.pi-traces__legend, .pi-traces__run, .pi-traces__toolbar { padding-inline: .7rem; }
.pi-traces__conversation { padding-inline: .55rem; height: 34rem; max-height: 75vh; }
.pi-traces__content { height: calc(min(34rem, 75svh) + 22rem); }
.pi-traces__conversation { padding-inline: .55rem; }
.pi-chat { max-width: 97%; }
.pi-chat--user { max-width: 94%; }
.pi-chat--call, .pi-chat--result { margin-left: .3rem; max-width: calc(100% - .3rem); }
Expand Down
9 changes: 9 additions & 0 deletions overrides/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
{% block extrahead %}
{{ super() }}
{% include "partials/seo.html" %}
<noscript><style>
.dev-notes-toolbar, .pi-traces__loading { display: none; }
.pi-traces__content { height: auto; }
</style></noscript>
{% if page.meta and page.meta.reset_scroll_on_reload %}
<script>
// Run before the theme follows fragment links or the browser restores scroll.
Expand All @@ -22,6 +26,11 @@
</script>
{% endif %}
<script>
// Shared filter links should never flash the unfiltered card list.
const noteFilters = new URLSearchParams(location.search);
if (noteFilters.has("category") || noteFilters.has("author")) {
document.documentElement.dataset.devNotesFiltered = "true";
}
try {
document.documentElement.dataset.navigationDrawer =
window.sessionStorage.getItem("openshell.navigationDrawerOpen") === "true"
Expand Down
12 changes: 12 additions & 0 deletions overrides/partials/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@
{% endif %}
{% if not config.theme.palette is mapping %}
{% include "partials/javascripts/palette.html" %}
<script>
// Apply the first-visit system theme before images enter the document.
// The theme bundle otherwise selects it after the initial image fetches.
(() => {
let savedPalette;
try { savedPalette = __md_get("__palette"); } catch {}
if (!savedPalette?.color) {
document.body.dataset.mdColorScheme =
matchMedia("(prefers-color-scheme: dark)").matches ? "slate" : "default";
}
})();
</script>
{% endif %}
{% if config.extra.alternate %}
{% include "partials/alternate.html" %}
Expand Down
2 changes: 2 additions & 0 deletions requirements-docs.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
pytest==8.4.2
beautifulsoup4==4.14.3
Pillow==12.1.1
zensical==0.0.62
2 changes: 2 additions & 0 deletions scripts/build-docs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions scripts/optimize-site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/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 xml.etree import ElementTree

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() == ".svg":
# Keep vectors intact; their viewBox supplies the ratio before download.
view_box = ElementTree.parse(asset).getroot().get("viewBox")
if view_box:
_, _, width, height = map(float, view_box.replace(",", " ").split())
return round(width), round(height), []
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]
# Preserve authored sizing while completing its intrinsic ratio.
if not image.has_attr("width") and not image.has_attr("height"):
image["width"], image["height"] = str(width), str(height)
elif image.get("width", "").isdigit() and not image.has_attr("height"):
image["height"] = str(round(int(image["width"]) * height / width))
elif image.get("height", "").isdigit() and not image.has_attr("width"):
image["width"] = str(round(int(image["height"]) * width / height))
if not variants:
continue
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] and cache[asset][2]:
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.")
2 changes: 1 addition & 1 deletion scripts/render-dev-notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ def render_browse_filters(posts: list[dict[str, Any]]) -> str:
f' <option value="{html.escape(author_id, quote=True)}">{html.escape(author["name"])}</option>'
for author_id, author in sorted(authors.items(), key=lambda item: item[1]["name"].casefold())
)
return f""" <form class="dev-notes-filters" aria-label="Filter Dev Notes" hidden>
return f""" <form class="dev-notes-filters" aria-label="Filter Dev Notes" inert>
<label for="dev-notes-category">Category
<select id="dev-notes-category" name="category" aria-controls="dev-notes-results">
<option value="">All categories</option>
Expand Down
Loading
Loading