Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 14ce4c3d69204dcf226174318058a369df646157
parent 35f88e34e939a041d77ae4d4f5bb86fb1d96959f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 21:30:59 -0400

export: the header is the mark + the split wordmark, the footer credit carries the parent mark (brand S1)

The rotated square becomes the Found-line mark: on a site an ink tile whose
lit line is var(--brand-mark, var(--brand)), so it follows the reader's accent
once the themes slice defines --brand-mark and the site's until then; the hub
wears the parent mark. The wordmark splits on site.wordmarkLead. The footer
puts a small parent mark before "Built with", outside the link, whose name
stays exactly "Archilyzer". The fixture site gains wordmarkLead "Fixture".
New brand.spec (header link name + spans + weights, mark fills, footer
credit, <head> icon links, the site-lit icon.svg); pwa.spec checks each
icon's type, PNG magic and IHDR size, the SVGs, favicon.ico's ICO directory,
a 404 outside the set, and the manifest's icon.svg / theme / background;
e2e-hub/brand.spec pins the hub's parent-mark icons and header.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mexport/app/components/Footer.tsx | 24+++++++++++++++---------
Mexport/app/components/Header.tsx | 27+++++++++++++++++----------
Mexport/app/lib/brand.ts | 21++++++++++++++++-----
Aexport/e2e-hub/brand.spec.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/brand.spec.ts | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/fixtures/sites/testsite/site.json | 1+
Mexport/e2e/pwa.spec.ts | 52++++++++++++++++++++++++++++++++++++++++++++--------
7 files changed, 276 insertions(+), 32 deletions(-)

diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx @@ -12,6 +12,8 @@ import { PROJECT_NAME, PROJECT_URL, } from "yt-dlp-transcript-common/lib/project"; +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; +import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; import { currentSite } from "../lib/site"; import { instanceMode } from "../lib/mode"; import { hasArchives } from "../lib/archives"; @@ -79,15 +81,19 @@ export default function Footer() { different thing), and NOT target="_blank": the header's hub link navigates in the same tab, and only the social icons open new ones. "Built with" sits OUTSIDE the anchor so the accessible name is - exactly "Archilyzer". */} - <span className="text-xs"> - Built with{" "} - <a - href={PROJECT_URL} - className="underline underline-offset-2 hover:text-foreground transition-colors" - > - {PROJECT_NAME} - </a> + exactly "Archilyzer" — and so does the parent mark before it, + which is decorative (aria-hidden) and the same on every site. */} + <span className="inline-flex items-center gap-1.5 text-xs"> + <BrandMark palette={ICON_PALETTES.archilyzer} className="size-4 shrink-0" /> + <span> + Built with{" "} + <a + href={PROJECT_URL} + className="underline underline-offset-2 hover:text-foreground transition-colors" + > + {PROJECT_NAME} + </a> + </span> </span> {/* Ko-fi: a bare link, no copy — and on the HUB only. The hub is the project's own deployment; a site build is an archive whoever runs diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx @@ -8,18 +8,23 @@ import { } from "yt-dlp-transcript-common/lib/site"; import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu"; +import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; +import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; import { currentSite } from "../lib/site"; +import { headerMarkPalette } from "../lib/brand"; import { instanceMode } from "../lib/mode"; import { hasArchives } from "../lib/archives"; import { hasDuplicates } from "../lib/duplicates"; import SiblingSwitcher from "./SiblingSwitcher"; import MobileMenu from "./MobileMenu"; -// The export site's masthead: a brand-colored mark + wordmark, calm sans nav, -// and the cross-site "family" chrome (hub backlink + sibling switcher). The -// wordmark and mark track the active theme — sans + blue under the default base -// family, serif + brass under Archive. All cross-site data is resolved at build -// time from the pool, so single-site installs simply render neither. +// The export site's masthead: the Found-line mark + the split wordmark, calm +// sans nav, and the cross-site "family" chrome (hub backlink + sibling +// switcher). The mark's lit line follows the reader's accent (lib/brand.ts +// headerMarkPalette); the hub wears the parent mark. The link's accessible name +// is the header title exactly — the mark is decorative and the wordmark's two +// spans join without a space. All cross-site data is resolved at build time +// from the pool, so single-site installs simply render neither. export default function Header() { const site = currentSite(); const settings = getSettings(); @@ -65,11 +70,13 @@ export default function Header() { put the overflow rows OUTSIDE the sticky header's background at phone widths, and the page scrolled through them. */} <div className="max-w-6xl mx-auto px-4 sm:px-6 flex min-h-14 items-center gap-x-5 gap-y-1 flex-nowrap"> - <Link href="/" className="group flex min-w-0 items-center gap-2.5"> - <span className="h-2.5 w-2.5 shrink-0 rotate-45 rounded-[3px] bg-brand shadow-[0_0_14px_var(--brand)] transition-colors group-hover:bg-brand-strong" /> - <span className="truncate font-display text-[1.35rem] font-semibold leading-none tracking-tight text-foreground"> - {site.headerTitle} - </span> + <Link href="/" className="flex min-w-0 items-center gap-2.5"> + <BrandMark palette={headerMarkPalette()} className="size-7 shrink-0" /> + <Wordmark + title={site.headerTitle} + lead={site.wordmarkLead} + className="min-w-0 truncate text-[1.35rem] leading-none tracking-[-0.01em]" + /> </Link> <nav className="hidden md:flex items-center gap-4 text-sm font-medium"> diff --git a/export/app/lib/brand.ts b/export/app/lib/brand.ts @@ -1,15 +1,26 @@ import { ICON_PALETTES, type IconPalette } from "yt-dlp-transcript-common/lib/brand"; import { siteIconPalette } from "yt-dlp-transcript-common/lib/brandIcons"; +import type { BrandMarkPalette } from "yt-dlp-transcript-common/components/BrandMark"; import { currentSite } from "./site"; import { instanceMode } from "./mode"; -// The palette this build's icons and header mark are drawn in. The hub is the -// family's own tool, so it wears the parent mark — achromatic, like the -// homepage and the editor. Every archive site is lit with its own accent's -// on-dark value (the favicon keeps the SITE default; the header mark follows -// the reader's pick through --brand-mark). Server-only, like currentSite(). +// The palette this build's icons are drawn in. The hub is the family's own +// tool, so it wears the parent mark — achromatic, like the homepage and the +// editor. Every archive site is lit with its own accent's on-dark value. +// Server-only, like currentSite(). export function iconPalette(): IconPalette { return instanceMode() === "hub" ? ICON_PALETTES.archilyzer : siteIconPalette(currentSite().accent); } + +// The header mark's palette. On a site it is the icon's ink tile, but its lit +// line follows the READER's accent — `--brand-mark` is the picked accent's +// on-dark value (the tile is always ink) — while the favicon keeps the site's +// default. Until the base × accent tokens define `--brand-mark`, it falls back +// to `--brand`. The hub's mark is the parent mark and does not follow. +export function headerMarkPalette(): BrandMarkPalette { + return instanceMode() === "hub" + ? ICON_PALETTES.archilyzer + : { ...ICON_PALETTES.child, lit: "var(--brand-mark, var(--brand))" }; +} diff --git a/export/e2e-hub/brand.spec.ts b/export/e2e-hub/brand.spec.ts @@ -0,0 +1,50 @@ +import { expect, test, type Route } from "@playwright/test"; +import { BASE_GROUNDS, ICON_PALETTES, markSvg } from "../../common/lib/brand"; + +// The hub is the family's own tool, so it wears the PARENT mark — bone on +// slate, achromatic — in its icons and its header, like the homepage and the +// editor; never an archive's accent (plans/brand-and-themes.md, slice S1). + +async function fulfillJson(route: Route, body: unknown) { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }); +} + +test("the hub's icons are the parent mark", async ({ request }) => { + const svg = await (await request.get("/icons/icon.svg")).text(); + expect(svg).toContain(`fill="${ICON_PALETTES.archilyzer.ground}"`); // #151b20 + expect(svg).toContain(`fill="${ICON_PALETTES.archilyzer.lit}"`); // #e7edf1 + expect(svg).toBe(markSvg(ICON_PALETTES.archilyzer)); + + const png = await request.get("/icons/icon-192.png"); + expect(png.headers()["content-type"]).toBe("image/png"); + const ico = await request.get("/favicon.ico"); + expect([...(await ico.body()).subarray(0, 4)]).toEqual([0, 0, 1, 0]); + + const m = await (await request.get("/manifest.webmanifest")).json(); + expect(m.background_color).toBe(ICON_PALETTES.archilyzer.ground); + expect(m.theme_color).toBe(BASE_GROUNDS.dark); +}); + +test("the hub header is the parent mark + Archi|lyzer", async ({ page }) => { + // No members: the shelf stays empty and nothing reaches a real origin. + await page.route("**/hub-sites.json", (r) => fulfillJson(r, [])); + await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" })); + await page.goto("/"); + const home = page + .getByRole("banner") + .getByRole("link", { name: "Archilyzer", exact: true }); + await expect(home).toBeVisible(); + await expect(home.locator("[data-wordmark-lead]")).toHaveText("Archi"); + await expect(home.locator("[data-wordmark-suffix]")).toHaveText("lyzer"); + const fills = await home.locator("svg[data-brand-mark]").evaluate((svg) => + ["ground", "dim", "lit"].map( + (t) => getComputedStyle(svg.querySelector(`[data-tone="${t}"]`) as Element).fill, + ), + ); + expect(fills).toEqual(["rgb(21, 27, 32)", "rgb(63, 76, 86)", "rgb(231, 237, 241)"]); +}); diff --git a/export/e2e/brand.spec.ts b/export/e2e/brand.spec.ts @@ -0,0 +1,133 @@ +import { test, expect, type Locator } from "@playwright/test"; +import { ICON_PALETTES, childIconPalette, markSvg } from "../../common/lib/brand"; +import { resolveAccent } from "../../common/lib/accent"; +import { installRoutes } from "./helpers"; + +// The family brand on an archive site (plans/brand-and-themes.md, slice S1): +// the Found-line mark and the two-weight wordmark in the header, the parent +// mark beside the footer credit, and the icon set rendered per build from the +// site's accent. The fixture site (e2e/fixtures/sites/testsite/site.json) is +// headerTitle "Fixture Header", wordmarkLead "Fixture", accent #cc3366. + +// The computed fill of each part of a BrandMark, plus what the reader's accent +// resolves to — so the lit line can be checked against the token it follows +// without pinning a colour the themes slice will change. +async function markFills(mark: Locator) { + return mark.evaluate((svg) => { + const fill = (tone: string) => + getComputedStyle(svg.querySelector(`[data-tone="${tone}"]`) as Element).fill; + const probe = document.createElement("span"); + probe.style.color = "var(--brand-mark, var(--brand))"; + document.body.append(probe); + const accent = getComputedStyle(probe).color; + probe.remove(); + return { ground: fill("ground"), dim: fill("dim"), lit: fill("lit"), accent }; + }); +} + +test("the header link is the mark + the split wordmark, named by the header title", async ({ + page, +}) => { + await installRoutes(page); + await page.goto("/"); + const home = page + .getByRole("banner") + .getByRole("link", { name: "Fixture Header", exact: true }); + await expect(home).toBeVisible(); + await expect(home).toHaveAttribute("href", "/"); + + // Two adjacent spans, no whitespace node between them: the lead is the + // configured prefix, the suffix is the rest (its leading space included). + const wordmark = home.locator("[data-wordmark]"); + const parts = await wordmark.evaluate((w) => + [...w.childNodes].map((n) => ({ + tag: n.nodeName, + text: n.textContent, + weight: n instanceof Element ? getComputedStyle(n).fontWeight : null, + display: n instanceof Element ? getComputedStyle(n).display : null, + })), + ); + expect(parts).toEqual([ + { tag: "SPAN", text: "Fixture", weight: "720", display: "inline" }, + { tag: "SPAN", text: " Header", weight: "380", display: "inline" }, + ]); + + const mark = home.locator("svg[data-brand-mark]"); + await expect(mark).toHaveCount(1); + await expect(mark).toHaveAttribute("aria-hidden", "true"); + await expect(mark).toHaveAttribute("focusable", "false"); + await expect(mark).toBeVisible(); +}); + +test("the header mark is the ink tile, its lit line following the reader's accent", async ({ + page, +}) => { + await installRoutes(page); + await page.goto("/"); + const mark = page.getByRole("banner").locator("svg[data-brand-mark]"); + const f = await markFills(mark); + expect(f.ground).toBe("rgb(12, 10, 8)"); // ICON_PALETTES.child.ground + expect(f.dim).toBe("rgb(59, 51, 39)"); // ICON_PALETTES.child.dim + expect(f.lit).toBe(f.accent); + expect(f.lit).not.toBe(f.dim); +}); + +test("the footer carries the parent mark before the credit, outside the link", async ({ + page, +}) => { + await installRoutes(page); + await page.goto("/"); + const footer = page.locator("footer"); + const credit = footer.getByRole("link", { name: "Archilyzer", exact: true }); + await expect(credit).toBeVisible(); + await expect(credit.locator("svg")).toHaveCount(0); + + const mark = footer.locator("svg[data-brand-mark]"); + await expect(mark).toHaveCount(1); + await expect(mark).toHaveAttribute("aria-hidden", "true"); + // The mark's next sibling holds "Built with" and the credit. + await expect( + footer.locator("svg[data-brand-mark] + span").getByRole("link", { + name: "Archilyzer", + exact: true, + }), + ).toHaveCount(1); + const f = await markFills(mark); + expect(f.ground).toBe("rgb(21, 27, 32)"); // #151b20 + expect(f.lit).toBe("rgb(231, 237, 241)"); // #e7edf1, bone: the parent mark +}); + +test("<head> links the rendered icon set, SVG first", async ({ page }) => { + await installRoutes(page); + await page.goto("/"); + const icons = await page + .locator('head link[rel="icon"]') + .evaluateAll((ls) => + ls.map((l) => [l.getAttribute("href"), l.getAttribute("type"), l.getAttribute("sizes")]), + ); + expect(icons).toEqual([ + ["/icons/icon.svg", "image/svg+xml", null], + ["/icons/icon-32.png", "image/png", "32x32"], + ["/icons/icon-192.png", "image/png", "192x192"], + ["/icons/icon-512.png", "image/png", "512x512"], + ]); + await expect(page.locator('head link[rel="apple-touch-icon"]')).toHaveAttribute( + "href", + "/icons/apple-touch-icon.png", + ); +}); + +test("the site's icons are lit with its own accent, fitted to the ink tile", async ({ + request, +}) => { + const lit = resolveAccent("#cc3366").dark; + const res = await request.get("/icons/icon.svg"); + expect(res.ok()).toBeTruthy(); + expect(await res.text()).toBe(markSvg(childIconPalette(lit))); + const maskable = await request.get("/icons/maskable.svg"); + expect(await maskable.text()).toBe( + markSvg(childIconPalette(lit), { variant: "maskable" }), + ); + // Not the parent mark: that is the hub's, the homepage's and the editor's. + expect(await res.text()).not.toContain(ICON_PALETTES.archilyzer.ground); +}); diff --git a/export/e2e/fixtures/sites/testsite/site.json b/export/e2e/fixtures/sites/testsite/site.json @@ -3,6 +3,7 @@ "siteTitle": "Fixture Site Title", "siteDescription": "Fixture site description", "headerTitle": "Fixture Header", + "wordmarkLead": "Fixture", "homeTagline": "Fixture tagline here", "accent": "#cc3366", "socialLinks": [ diff --git a/export/e2e/pwa.spec.ts b/export/e2e/pwa.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { BASE_GROUNDS, ICON_PALETTES } from "../../common/lib/brand"; // PWA surface for the export (Archive) sites: an installable web app manifest, // a registered service worker, and the /offline management page. The transcript @@ -23,22 +24,57 @@ test("serves a valid web app manifest with per-site fields", async ({ expect( m.icons.some((i: { purpose?: string }) => i.purpose === "maskable"), ).toBe(true); - // theme_color is a hex string (from the site accent or the family brass). - expect(m.theme_color).toMatch(/^#[0-9a-f]{6}$/i); + // The scalable mark, for launchers that take one. + expect(m.icons).toContainEqual({ + src: "/icons/icon.svg", + sizes: "any", + type: "image/svg+xml", + purpose: "any", + }); + // The chrome is the dark base's ground and the splash the icon's own tile — + // never the accent, which a reader may change and the manifest cannot follow. + expect(m.theme_color).toBe(BASE_GROUNDS.dark); + expect(m.background_color).toBe(ICON_PALETTES.child.ground); }); +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + test("serves the service worker and the icon set", async ({ request }) => { const sw = await request.get("/sw.js"); expect(sw.ok()).toBeTruthy(); - for (const icon of [ - "/icons/icon-192.png", - "/icons/icon-512.png", - "/icons/maskable-512.png", - "/icons/apple-touch-icon.png", - ]) { + // Rendered by app/icons/[file]/route.ts, not committed: each is a real PNG + // of its size (IHDR width/height at bytes 16 and 20). + for (const [icon, size] of [ + ["/icons/icon-32.png", 32], + ["/icons/icon-192.png", 192], + ["/icons/icon-512.png", 512], + ["/icons/maskable-512.png", 512], + ["/icons/apple-touch-icon.png", 180], + ] as const) { + const r = await request.get(icon); + expect(r.ok(), icon).toBeTruthy(); + expect(r.headers()["content-type"], icon).toBe("image/png"); + const body = await r.body(); + expect([...body.subarray(0, 8)], icon).toEqual(PNG_MAGIC); + expect([body.readUInt32BE(16), body.readUInt32BE(20)], icon).toEqual([size, size]); + } + for (const icon of ["/icons/icon.svg", "/icons/maskable.svg"]) { const r = await request.get(icon); expect(r.ok(), icon).toBeTruthy(); + expect(r.headers()["content-type"], icon).toBe("image/svg+xml"); + expect(await r.text(), icon).toMatch(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/); } + // /favicon.ico: an ICO directory (00 00 01 00) of PNG entries. + const ico = await request.get("/favicon.ico"); + expect(ico.ok()).toBeTruthy(); + expect(ico.headers()["content-type"]).toBe("image/x-icon"); + const b = await ico.body(); + expect([...b.subarray(0, 4)]).toEqual([0, 0, 1, 0]); + expect(b.readUInt16LE(4)).toBeGreaterThan(0); + const first = b.readUInt32LE(6 + 12); + expect([...b.subarray(first, first + 8)]).toEqual(PNG_MAGIC); + // Anything not in the set is not an icon. + expect((await request.get("/icons/icon-64.png")).status()).toBe(404); }); // Note: the service worker registers in PRODUCTION builds only (it caches