// ARCHILYZER MEDIA — the YouTube channel's brand, as data and pure SVG.
//
// plans/brand-and-themes.md "S4 — Archilyzer Media" is the source of every
// value here, and the approved canvas (row "Archilyzer Media · the YouTube
// channel") is what each drawing reproduces:
//
// - the mark is M2 "Signal": the parent mark's geometry (lib/brand.ts MARK,
// drawn by markSvg — never redrawn here), lit in Signal;
// - the lockup is "flush · caps matched": the `Archi|lyzer` wordmark over
// `MEDIA`, both Archivo at wdth 118, MEDIA at MEDIA_RATIO × the wordmark's
// size and letter-spaced to exactly the wordmark's width, the mark as tall
// as the text block beside it;
// - the banner is a field of dim transcript lines wrapping round a clear
// gutter about YouTube's safe area, the lockup centred in it, and one lit
// found line in the desktop strip to its right.
//
// The letters are OUTLINES from lib/brandMediaGlyphs.ts (generated by
// common/bin/gen-media-glyphs.py from the Archivo the umtool preset vendors),
// so rendering any of this needs no font: rsvg-convert, satori's and
// ImageMagick all draw the same shapes.
//
// PURE: no I/O, no framework imports. The PNG side is common/bin/brand-media.ts.
import { ACCENTS, ICON_PALETTES, MARK_VIEWBOX, markSvg, type IconPalette, type MarkVariant } from "./brand";
import {
MEDIA_CAP_HEIGHT,
MEDIA_RUNS,
MEDIA_UNITS_PER_EM,
type MediaGlyph,
type MediaRun,
} from "./brandMediaGlyphs";
// ── Palette ─────────────────────────────────────────────────────────────────
// The slate of the parent mark (lib/brand.ts ICON_PALETTES.archilyzer), lit in
// Signal. `media` is MEDIA's ink; `amber` is the annotation colour the video
// preset carries for kickers and markers.
export type MediaPalette = {
ground: string;
dim: string;
lit: string;
fg: string;
muted: string;
media: string;
};
export const MEDIA_PALETTE = {
ground: ICON_PALETTES.archilyzer.ground,
dim: ICON_PALETTES.archilyzer.dim,
lit: ACCENTS.signal.onDark,
fg: "#e7edf1",
muted: "#8496a2",
media: ACCENTS.signal.onDark,
amber: "#e3b15c",
} as const satisfies MediaPalette & { amber: string };
// Mark M2 · Signal.
export const MEDIA_MARK_PALETTE: IconPalette = {
ground: MEDIA_PALETTE.ground,
dim: MEDIA_PALETTE.dim,
lit: MEDIA_PALETTE.lit,
};
// The dim lines of the banner's field: a step above the ground, below the
// mark's own dim, so the field reads as texture and the mark as the object.
export const MEDIA_FIELD_LINE = "#1e262d";
// ── The lockup's proportions ────────────────────────────────────────────────
// MEDIA's size over the wordmark's. At this ratio MEDIA, spread to the
// wordmark's width, has 0.30 em between its letters.
export const MEDIA_RATIO = 1.035;
// The wordmark's tracking, as the sites set it (common/components/Wordmark.tsx).
// Its measured width — the advances of `Archi` at 720 and `lyzer` at 380, less
// this per letter — is the width both lines share: 5.334 em.
export const MEDIA_WORDMARK_TRACKING_EM = -0.01;
// From the wordmark's baseline to MEDIA's cap top, in wordmark em.
export const MEDIA_LINE_GAP_EM = 0.3;
// From the mark's right edge to the text, in wordmark em.
export const MEDIA_MARK_GAP_EM = 0.46;
const EM = MEDIA_UNITS_PER_EM;
const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
const sum = (xs: ReadonlyArray): number => xs.reduce((a, b) => a + b, 0);
const advances = (r: MediaRun): number => sum(r.glyphs.map((g) => g.advance));
// The wordmark's shared width in em: the spec's measurement, from the glyphs.
export function mediaWordmarkAdvanceEm(): number {
const letters = MEDIA_RUNS.archi.glyphs.length + MEDIA_RUNS.lyzer.glyphs.length;
return (advances(MEDIA_RUNS.archi) + advances(MEDIA_RUNS.lyzer)) / EM + MEDIA_WORDMARK_TRACKING_EM * letters;
}
// MEDIA's natural advance (no spacing) in em of its own size.
export function mediaWordAdvanceEm(): number {
return (advances(MEDIA_RUNS.media) + sum(MEDIA_RUNS.media.kerning)) / EM;
}
export type PlacedGlyph = { glyph: MediaGlyph; x: number; baseline: number; scale: number; tone: "fg" | "muted" | "media" };
export type MediaLockupGeometry = {
px: number;
// The block: wordmark cap top (y 0) to MEDIA's baseline, mark to text end.
blockWidth: number;
blockHeight: number;
markSize: number;
textX: number;
textWidth: number;
wordmarkBaseline: number;
mediaPx: number;
mediaBaseline: number;
// MEDIA's letter gap, px.
mediaSpacing: number;
// The ink's overshoot above the block (the ascenders of h, i and l rise
// past the cap line), px; the SVG adds it on top so nothing is clipped.
inkTop: number;
glyphs: ReadonlyArray;
// Where each line's advance ends, px from the text origin.
wordmarkAdvance: number;
mediaAdvance: number;
};
// Place the lockup at a wordmark size of `px`. Block coordinates: the mark's
// top-left is (0, 0), which is also the wordmark's cap line.
export function mediaLockupGeometry(px: number): MediaLockupGeometry {
if (!(px > 0) || !Number.isFinite(px)) throw new Error(`mediaLockupGeometry: px must be > 0, got ${px}`);
const s = px / EM;
const mediaPx = px * MEDIA_RATIO;
const sm = mediaPx / EM;
const cap = MEDIA_CAP_HEIGHT / EM;
const wordmarkBaseline = cap * px;
const mediaBaseline = wordmarkBaseline + MEDIA_LINE_GAP_EM * px + cap * mediaPx;
const blockHeight = mediaBaseline;
const markSize = blockHeight;
const textX = markSize + MEDIA_MARK_GAP_EM * px;
const width = mediaWordmarkAdvanceEm() * px;
const glyphs: PlacedGlyph[] = [];
// The wordmark: kerned inside each run (a browser kerns inside a span, not
// across two spans of different weight), then one uniform adjustment per
// gap brings the whole line to the measured width. That is the canvas's
// `textLength` + `lengthAdjust="spacing"`.
const word: Array<{ glyph: MediaGlyph; tone: "fg" | "muted"; kernAfter: number }> = [];
for (const [run, tone] of [
[MEDIA_RUNS.archi, "fg"],
[MEDIA_RUNS.lyzer, "muted"],
] as const) {
run.glyphs.forEach((glyph, i) => word.push({ glyph, tone, kernAfter: run.kerning[i] ?? 0 }));
}
const natural = sum(word.map((w, i) => w.glyph.advance + (i < word.length - 1 ? w.kernAfter : 0))) * s;
const perGap = (width - natural) / (word.length - 1);
let x = 0;
word.forEach((w, i) => {
glyphs.push({ glyph: w.glyph, x, baseline: wordmarkBaseline, scale: s, tone: w.tone });
x += w.glyph.advance * s + (i < word.length - 1 ? w.kernAfter * s + perGap : 0);
});
const wordmarkAdvance = x;
// MEDIA: its own advances (and kerning, if the font has any for it) at
// MEDIA_RATIO × px, the rest of the width shared equally between its gaps.
const media = MEDIA_RUNS.media;
const mediaSpacing = (width - mediaWordAdvanceEm() * mediaPx) / (media.glyphs.length - 1);
x = 0;
media.glyphs.forEach((glyph, i) => {
glyphs.push({ glyph, x, baseline: mediaBaseline, scale: sm, tone: "media" });
x += glyph.advance * sm + (i < media.glyphs.length - 1 ? (media.kerning[i] ?? 0) * sm + mediaSpacing : 0);
});
const mediaAdvance = x;
const inkTop = Math.max(0, -Math.min(...glyphs.map((g) => g.baseline + g.glyph.top * g.scale)));
return {
px,
blockWidth: textX + width,
blockHeight,
markSize,
textX,
textWidth: width,
wordmarkBaseline,
mediaPx,
mediaBaseline,
mediaSpacing,
inkTop,
glyphs,
wordmarkAdvance,
mediaAdvance,
};
}
const fmt = (n: number): string => String(Number(n.toFixed(3)));
function checkPalette(fn: string, palette: Record): void {
for (const [slot, colour] of Object.entries(palette)) {
if (!HEX_COLOR_RE.test(colour)) {
throw new Error(`${fn}: palette.${slot} must be #rrggbb, got ${JSON.stringify(colour)}`);
}
}
}
// markSvg's document, re-rooted as a nested