From b00692249cb13cc7c8d75a897bab3a4fdcf0467a Mon Sep 17 00:00:00 2001 From: nym21 Date: Fri, 5 Jun 2026 18:12:46 +0200 Subject: [PATCH] website: redesign part 8 --- website_next/AGENTS.md | 8 ++- website_next/header/index.js | 44 +++++++------ website_next/header/style.css | 2 +- website_next/index.html | 4 +- website_next/learn/contents/index.js | 20 +++--- website_next/learn/contents/style.css | 3 +- website_next/learn/index.js | 89 +++------------------------ website_next/learn/scroll-spy.js | 63 +++++++++++++++++++ website_next/learn/style.css | 15 ++--- website_next/main.js | 61 ++++-------------- website_next/routes.js | 35 +++++++++++ website_next/styles/main.css | 11 +--- website_next/styles/variables.css | 5 +- website_next/utils/event.js | 16 +++++ website_next/utils/id.js | 4 ++ website_next/utils/timing.js | 15 ----- website_next/utils/transition.js | 47 ++++++++++++++ 17 files changed, 241 insertions(+), 201 deletions(-) create mode 100644 website_next/learn/scroll-spy.js create mode 100644 website_next/routes.js create mode 100644 website_next/utils/event.js create mode 100644 website_next/utils/id.js delete mode 100644 website_next/utils/timing.js create mode 100644 website_next/utils/transition.js diff --git a/website_next/AGENTS.md b/website_next/AGENTS.md index 4873a8318..1ea5f041c 100644 --- a/website_next/AGENTS.md +++ b/website_next/AGENTS.md @@ -14,15 +14,17 @@ npx --package typescript tsc --noEmit --pretty false | grep -v "modules/" ALWAYS -- fast +- very fast +- light (memory) - KISS - DRY - very well organized - contained - colocated +- composed - prefer one concept per file - prefer more files and folders than big files - reads like english -- very easy to understand -- very easy to maintain +- easy to understand +- maintainability - avoid defensive checks when the code itself guarantees correctness diff --git a/website_next/header/index.js b/website_next/header/index.js index 2180a468c..28b943852 100644 --- a/website_next/header/index.js +++ b/website_next/header/index.js @@ -1,24 +1,32 @@ import { createCube } from "../cube/index.js"; +import { primaryRoutes } from "../routes.js"; -const header = document.createElement("header"); +export function createHeader() { + const header = document.createElement("header"); -const home = document.createElement("a"); -const cube = document.createElement("span"); + const home = document.createElement("a"); + const cube = document.createElement("span"); -home.href = "/"; -home.ariaLabel = "bitview home"; -cube.append(createCube()); -home.append(cube, "bitview"); + home.href = "/"; + home.ariaLabel = "bitview home"; + cube.append(createCube()); + home.append(cube, "bitview"); -const nav = document.createElement("nav"); -nav.setAttribute("aria-label", "Primary"); -nav.innerHTML = ` - -`; + const nav = document.createElement("nav"); + const list = document.createElement("ul"); + nav.setAttribute("aria-label", "Primary"); -header.append(home, nav); -document.body.append(header); + for (const { pathname, label } of primaryRoutes) { + const item = document.createElement("li"); + const anchor = document.createElement("a"); + + anchor.href = pathname; + anchor.append(label); + item.append(anchor); + list.append(item); + } + + nav.append(list); + header.append(home, nav); + return header; +} diff --git a/website_next/header/style.css b/website_next/header/style.css index bad412d1e..4ccf7a9f1 100644 --- a/website_next/header/style.css +++ b/website_next/header/style.css @@ -1,7 +1,7 @@ body { > header { position: fixed; - inset: 1.5rem 2rem auto; + inset: 1.5rem var(--page-x) auto; z-index: var(--layer-header); display: grid; grid-template-columns: 1fr auto 1fr; diff --git a/website_next/index.html b/website_next/index.html index f8dd7a51c..12bb4f42f 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -89,9 +89,9 @@ - - + + diff --git a/website_next/learn/contents/index.js b/website_next/learn/contents/index.js index e67a26bb4..d02e286dc 100644 --- a/website_next/learn/contents/index.js +++ b/website_next/learn/contents/index.js @@ -1,25 +1,19 @@ -/** - * @param {{ title: string, children: Section[] }} section - */ -function createSectionId(section) { - return section.title.toLowerCase().replaceAll(" ", "-"); -} +import { createId } from "../../utils/id.js"; /** * @param {{ title: string, children: Section[] }} section - * @param {number[]} indexes */ -function createContentsItem(section, indexes) { +function createContentsItem(section) { const item = document.createElement("li"); const anchor = document.createElement("a"); - anchor.href = `#${createSectionId(section)}`; + anchor.href = `#${createId(section.title)}`; anchor.append(section.title); if (section.children.length) { const list = document.createElement("ol"); - for (const [index, child] of section.children.entries()) { - list.append(createContentsItem(child, indexes.concat(index + 1))); + for (const child of section.children) { + list.append(createContentsItem(child)); } item.append(list); } @@ -35,8 +29,8 @@ export function createContents(sections) { nav.setAttribute("aria-label", "Learn contents"); - for (const [index, section] of sections.entries()) { - list.append(createContentsItem(section, [index + 1])); + for (const section of sections) { + list.append(createContentsItem(section)); } nav.append(list); diff --git a/website_next/learn/contents/style.css b/website_next/learn/contents/style.css index 6757c72fd..cc8a17127 100644 --- a/website_next/learn/contents/style.css +++ b/website_next/learn/contents/style.css @@ -1,5 +1,6 @@ main.learn { > nav { + counter-reset: content-theme; position: sticky; top: 0; padding-top: var(--top-offset); @@ -44,7 +45,7 @@ main.learn { opacity: 0.5; } - &:hover { + &:is(:hover, [aria-current="location"]) { color: var(--orange); } } diff --git a/website_next/learn/index.js b/website_next/learn/index.js index 96e2a383a..5926fbce9 100644 --- a/website_next/learn/index.js +++ b/website_next/learn/index.js @@ -1,5 +1,7 @@ import { createContents } from "./contents/index.js"; import { sections } from "./data.js"; +import { initScrollSpy } from "./scroll-spy.js"; +import { createId } from "../utils/id.js"; /** @param {string} label */ function createChart(label) { @@ -14,23 +16,16 @@ function createChart(label) { return figure; } -/** - * @param {string} title - */ -function createSectionId(title) { - return title.toLowerCase().replaceAll(" ", "-"); -} - /** * @param {Section} section - * @param {number[]} indexes + * @param {number} [level] */ -function createSection(section, indexes) { +function createSection(section, level = 1) { const element = document.createElement("section"); - const title = document.createElement(indexes.length === 1 ? "h1" : "h2"); + const title = document.createElement(level === 1 ? "h1" : "h2"); const anchor = document.createElement("a"); const description = document.createElement("p"); - const id = createSectionId(section.title); + const id = createId(section.title); element.id = id; anchor.href = `#${id}`; @@ -39,84 +34,20 @@ function createSection(section, indexes) { description.append(section.description); element.append(title, description, createChart(section.chart)); - for (const [index, child] of section.children.entries()) { - element.append(createSection(child, indexes.concat(index + 1))); + for (const child of section.children) { + element.append(createSection(child, level + 1)); } return element; } -/** @param {HTMLElement} main */ -function initScrollSpy(main) { - const headings = [...main.querySelectorAll("article h1, article h2")]; - const visibleHeadings = new Set(); - const links = new Map( - [...main.querySelectorAll('nav a[href^="#"]')].map((link) => [ - link.getAttribute("href"), - link, - ]), - ); - - /** @type {string | null} */ - let current = null; - - /** @param {Element} heading */ - function getHash(heading) { - const section = /** @type {HTMLElement} */ ( - heading.closest("section[id]") - ); - return `#${section.id}`; - } - - /** @param {string} hash */ - function getLink(hash) { - return /** @type {HTMLAnchorElement} */ (links.get(hash)); - } - - /** @param {string} hash */ - function setCurrent(hash) { - if (hash === current) return; - - if (current) getLink(current).removeAttribute("aria-current"); - getLink(hash).setAttribute("aria-current", "location"); - history.replaceState(null, "", hash); - current = hash; - } - - function update() { - if (main.hidden) return; - - const heading = headings.findLast((heading) => - visibleHeadings.has(heading), - ); - if (heading) setCurrent(getHash(heading)); - } - - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - visibleHeadings.add(entry.target); - } else { - visibleHeadings.delete(entry.target); - } - } - - update(); - }, - { rootMargin: "0px 0px -80% 0px" }, - ); - - for (const heading of headings) observer.observe(heading); -} - export function createLearnPage() { const main = document.createElement("main"); main.className = "learn"; const article = document.createElement("article"); - for (const [index, section] of sections.entries()) { - article.append(createSection(section, [index + 1])); + for (const section of sections) { + article.append(createSection(section)); } main.append(createContents(sections), article); diff --git a/website_next/learn/scroll-spy.js b/website_next/learn/scroll-spy.js new file mode 100644 index 000000000..1b36bad8a --- /dev/null +++ b/website_next/learn/scroll-spy.js @@ -0,0 +1,63 @@ +/** @param {HTMLElement} main */ +export function initScrollSpy(main) { + const headings = [...main.querySelectorAll("article h1, article h2")]; + const visibleHeadings = new Set(); + const links = new Map( + [...main.querySelectorAll('nav a[href^="#"]')].map((link) => [ + link.getAttribute("href"), + link, + ]), + ); + + /** @type {string | null} */ + let current = null; + + /** @param {Element} heading */ + function getHash(heading) { + const section = /** @type {HTMLElement} */ ( + heading.closest("section[id]") + ); + return `#${section.id}`; + } + + /** @param {string} hash */ + function getLink(hash) { + return /** @type {HTMLAnchorElement} */ (links.get(hash)); + } + + /** @param {string} hash */ + function setCurrent(hash) { + if (hash === current) return; + + if (current) getLink(current).removeAttribute("aria-current"); + getLink(hash).setAttribute("aria-current", "location"); + history.replaceState(null, "", hash); + current = hash; + } + + function update() { + if (main.hidden) return; + + const heading = headings.findLast((heading) => + visibleHeadings.has(heading), + ); + if (heading) setCurrent(getHash(heading)); + } + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + visibleHeadings.add(entry.target); + } else { + visibleHeadings.delete(entry.target); + } + } + + update(); + }, + { rootMargin: "0px 0px -80% 0px" }, + ); + + for (const heading of headings) observer.observe(heading); +} diff --git a/website_next/learn/style.css b/website_next/learn/style.css index a77600439..1410a5d54 100644 --- a/website_next/learn/style.css +++ b/website_next/learn/style.css @@ -1,11 +1,11 @@ main.learn { --top-offset: 6rem; - --sidebar-bottom: 1rem; + --content-width: 52rem; display: grid; grid-template-columns: 14rem minmax(0, 1fr); gap: 4rem; - padding: 0 2rem; + padding: 0 var(--page-x); article { counter-reset: theme; @@ -18,7 +18,6 @@ main.learn { top: 0; z-index: 2; display: block; - width: min(100%, 52rem); height: var(--top-offset); margin-top: calc(-1 * var(--top-offset)); margin-inline: auto; @@ -30,7 +29,7 @@ main.learn { > section { counter-increment: theme; counter-reset: topic; - width: min(100%, 52rem); + width: min(100%, var(--content-width)); margin-inline: auto; scroll-margin-top: var(--top-offset); } @@ -53,13 +52,15 @@ main.learn { h1, h2 { position: sticky; + top: var(--top-offset); + padding-bottom: 0.5rem; background: var(--black); line-height: 1; a { position: relative; display: inline-block; - color: white; + color: var(--white); text-decoration: none; &::before { @@ -90,8 +91,6 @@ main.learn { h1 { z-index: 3; - top: var(--top-offset); - padding-bottom: 0.5rem; border-bottom: 1px solid var(--dark-gray); font-size: 2.75rem; @@ -102,9 +101,7 @@ main.learn { h2 { z-index: 1; - top: var(--top-offset); padding-top: 4.5rem; - padding-bottom: 0.5rem; border-bottom: 1px dashed var(--dark-gray); font-size: 1.5rem; diff --git a/website_next/main.js b/website_next/main.js index d163db183..47b19c7a3 100644 --- a/website_next/main.js +++ b/website_next/main.js @@ -1,17 +1,7 @@ -import "./header/index.js"; -import { createBuildPage } from "./build/index.js"; -import { createExplorePage } from "./explore/index.js"; -import { createHomePage } from "./home/index.js"; -import { createLearnPage } from "./learn/index.js"; -import { readCssDuration, wait } from "./utils/timing.js"; - -/** @type {Record HTMLElement>} */ -const routes = { - "/": createHomePage, - "/build": createBuildPage, - "/explore": createExplorePage, - "/learn": createLearnPage, -}; +import { createHeader } from "./header/index.js"; +import { createRoutePage, isRoute, normalizePath } from "./routes.js"; +import { getEventAnchor } from "./utils/event.js"; +import { revealPage, transitionPage } from "./utils/transition.js"; /** @type {HTMLElement | undefined} */ let currentPage; @@ -19,24 +9,16 @@ let currentPage; /** @type {Map} */ const pageByPath = new Map(); -function waitForTransition() { - return wait(readCssDuration("--transition-duration")); -} +const header = createHeader(); +document.body.append(header); -function waitForReveal() { - return wait(readCssDuration("--reveal-duration")); -} - -/** @param {string} pathname */ -function normalizePath(pathname) { - return pathname in routes ? pathname : "/"; -} +const navLinks = [...header.querySelectorAll("nav a")]; /** @param {string} pathname */ function updateCurrentLink(pathname) { const currentPath = normalizePath(pathname); - for (const link of document.querySelectorAll("body > header > nav a")) { + for (const link of navLinks) { const linkPath = new URL(/** @type {HTMLAnchorElement} */ (link).href) .pathname; @@ -53,7 +35,7 @@ function getPage(pathname) { let page = pageByPath.get(pathname); if (!page) { - page = routes[pathname](); + page = createRoutePage(pathname); page.hidden = true; page.inert = true; pageByPath.set(pathname, page); @@ -85,16 +67,7 @@ function renderPage() { function navigate(pathname) { if (pathname === window.location.pathname) return; history.pushState(null, "", pathname); - transitionPage(); -} - -async function transitionPage() { - document.documentElement.dataset.transition = ""; - await waitForTransition(); - renderPage(); - requestAnimationFrame(() => { - delete document.documentElement.dataset.transition; - }); + void transitionPage(renderPage); } document.addEventListener("click", (event) => { @@ -102,16 +75,14 @@ document.addEventListener("click", (event) => { return; } - const anchor = /** @type {HTMLAnchorElement | null} */ ( - /** @type {HTMLElement} */ (event.target).closest("a[href]") - ); + const anchor = getEventAnchor(event); if (!anchor) return; const url = new URL(anchor.href); if (url.origin !== window.location.origin) return; if (url.pathname === window.location.pathname && url.hash) return; - if (!(url.pathname in routes)) return; + if (!isRoute(url.pathname)) return; event.preventDefault(); navigate(url.pathname); @@ -122,11 +93,5 @@ window.addEventListener("popstate", renderPage); renderPage(); requestAnimationFrame(() => { - waitForTransition().then(() => { - delete document.documentElement.dataset.loading; - document.documentElement.dataset.revealing = ""; - waitForReveal().then(() => { - delete document.documentElement.dataset.revealing; - }); - }); + void revealPage(); }); diff --git a/website_next/routes.js b/website_next/routes.js new file mode 100644 index 000000000..3b6a63fb8 --- /dev/null +++ b/website_next/routes.js @@ -0,0 +1,35 @@ +import { createBuildPage } from "./build/index.js"; +import { createExplorePage } from "./explore/index.js"; +import { createHomePage } from "./home/index.js"; +import { createLearnPage } from "./learn/index.js"; + +const pages = [ + { pathname: "/", createPage: createHomePage }, + { pathname: "/explore", label: "Explore", createPage: createExplorePage }, + { pathname: "/learn", label: "Learn", createPage: createLearnPage }, + { pathname: "/build", label: "Build", createPage: createBuildPage }, +]; + +/** @type {Record HTMLElement>} */ +const routes = Object.fromEntries( + pages.map(({ pathname, createPage }) => [pathname, createPage]), +); + +export const primaryRoutes = pages.flatMap(({ pathname, label }) => + label ? [{ pathname, label }] : [], +); + +/** @param {string} pathname */ +export function isRoute(pathname) { + return pathname in routes; +} + +/** @param {string} pathname */ +export function normalizePath(pathname) { + return isRoute(pathname) ? pathname : "/"; +} + +/** @param {string} pathname */ +export function createRoutePage(pathname) { + return routes[pathname](); +} diff --git a/website_next/styles/main.css b/website_next/styles/main.css index 617914a11..6463ce09e 100644 --- a/website_next/styles/main.css +++ b/website_next/styles/main.css @@ -13,20 +13,13 @@ html { body { > main { - color: white; + min-height: 100dvh; + color: var(--white); } } -main { - min-height: 100dvh; -} - @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } - - body::before { - transition: none; - } } diff --git a/website_next/styles/variables.css b/website_next/styles/variables.css index b15a50256..ceaee18b8 100644 --- a/website_next/styles/variables.css +++ b/website_next/styles/variables.css @@ -1,5 +1,5 @@ :root { - color-scheme: light dark; + color-scheme: dark; --white: oklch(95% 0 0); --dark-white: oklch(92.5% 0 0); @@ -44,11 +44,10 @@ --font-size-xl: 1.25rem; --line-height-xl: calc(1.75 / 1.25); + --page-x: 2rem; --main-padding: 2rem; --negative-main-padding: calc(-1 * var(--main-padding)); --font-weight-base: 400; --max-main-width: 70dvw; - --layer-transition: 8; --layer-header: 10; - --layer-loading: 100; } diff --git a/website_next/utils/event.js b/website_next/utils/event.js new file mode 100644 index 000000000..bdc4aeac2 --- /dev/null +++ b/website_next/utils/event.js @@ -0,0 +1,16 @@ +/** + * @param {Event} event + * @param {string} selector + */ +export function getEventTarget(event, selector) { + return /** @type {HTMLElement | null} */ ( + /** @type {HTMLElement} */ (event.target).closest(selector) + ); +} + +/** @param {Event} event */ +export function getEventAnchor(event) { + return /** @type {HTMLAnchorElement | null} */ ( + getEventTarget(event, "a[href]") + ); +} diff --git a/website_next/utils/id.js b/website_next/utils/id.js new file mode 100644 index 000000000..9ac10c1a3 --- /dev/null +++ b/website_next/utils/id.js @@ -0,0 +1,4 @@ +/** @param {string} value */ +export function createId(value) { + return value.toLowerCase().replaceAll(" ", "-"); +} diff --git a/website_next/utils/timing.js b/website_next/utils/timing.js deleted file mode 100644 index fd7f3d62b..000000000 --- a/website_next/utils/timing.js +++ /dev/null @@ -1,15 +0,0 @@ -/** @param {number} ms */ -export function wait(ms) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -/** @param {string} name */ -export function readCssDuration(name) { - const value = getComputedStyle(document.documentElement) - .getPropertyValue(name) - .trim(); - - return Number.parseFloat(value) * (value.endsWith("ms") ? 1 : 1000); -} diff --git a/website_next/utils/transition.js b/website_next/utils/transition.js new file mode 100644 index 000000000..19a2398e8 --- /dev/null +++ b/website_next/utils/transition.js @@ -0,0 +1,47 @@ +let transitionId = 0; + +/** @param {number} ms */ +function wait(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** @param {string} name */ +function readCssDuration(name) { + const value = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + + return Number.parseFloat(value) * (value.endsWith("ms") ? 1 : 1000); +} + +function waitForTransition() { + return wait(readCssDuration("--transition-duration")); +} + +function waitForReveal() { + return wait(readCssDuration("--reveal-duration")); +} + +/** @param {() => void} render */ +export async function transitionPage(render) { + const id = ++transitionId; + document.documentElement.dataset.transition = ""; + await waitForTransition(); + if (id !== transitionId) return; + + render(); + + requestAnimationFrame(() => { + if (id === transitionId) delete document.documentElement.dataset.transition; + }); +} + +export async function revealPage() { + await waitForTransition(); + delete document.documentElement.dataset.loading; + document.documentElement.dataset.revealing = ""; + await waitForReveal(); + delete document.documentElement.dataset.revealing; +}