diff --git a/web/scripts/outline-wordmark.py b/web/scripts/outline-wordmark.py new file mode 100644 index 0000000..5c8a807 --- /dev/null +++ b/web/scripts/outline-wordmark.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Convert the in the brand lockups to outlines. + +Why: the lockups in src/lib/assets are loaded via (and inlined as +data URIs at build time). An -loaded SVG is an isolated document -- it +cannot reach the page's @font-face rules, so the app's self-hosted Overpass +Mono never applies to a element inside one. The wordmark therefore +rendered in whatever each visitor's default monospace happened to be, and +changed shape by platform. Outlining against the font we already ship makes +it pixel-stable everywhere and matches the product's own typography. + +Re-run this after refreshing the logo package from upstream (upstream ships +live ). Idempotent: files with no left are skipped. + + python3 web/scripts/outline-wordmark.py + +Only is rewritten; every other byte of each file is left alone. +""" + +import re +import sys +from pathlib import Path +from xml.dom import minidom + +from fontTools.pens.svgPathPen import SVGPathPen +from fontTools.pens.transformPen import TransformPen +from fontTools.ttLib import TTFont +from fontTools.varLib import instancer + +ROOT = Path(__file__).resolve().parent.parent +FONT = ROOT / "static/fonts/overpass-mono-variable.woff2" +ASSETS = ROOT / "src/lib/assets" + +# Every lockup sets font-weight 700; the variable font defaults to 300. +WEIGHT = 700 + +TEXT_RE = re.compile(r"[ \t]*\s*?\n", re.DOTALL) + + +def ntos(v): + """Two decimals is ~0.005px at the largest size any lockup is drawn at. + + The default str() emits full float repr (15.264000000000001), which more + than doubles the path data for no visible gain -- and these files ship + inlined in the bundle. + """ + return f"{v:.2f}".rstrip("0").rstrip(".") or "0" + + +def load_glyphset(): + font = TTFont(FONT) + font = instancer.instantiateVariableFont(font, {"wght": WEIGHT}) + return font.getGlyphSet(), font.getBestCmap(), font["hmtx"], font["head"].unitsPerEm + + +def runs_of(node): + """Flatten a into (string, fill-override) runs, in document order. + + Handles the one nesting the lockups use: bare text plus . + """ + out = [] + for child in node.childNodes: + if child.nodeType == child.TEXT_NODE: + if child.data: + out.append((child.data, None)) + elif child.nodeType == child.ELEMENT_NODE and child.localName == "tspan": + text = "".join(c.data for c in child.childNodes if c.nodeType == c.TEXT_NODE) + out.append((text, child.getAttribute("fill") or None)) + return [(t, f) for t, f in out if t] + + +def outline(gs, cmap, hmtx, upem, runs, x, y, size, spacing, anchor): + """Return [(fill, path-d)], laying runs out left to right from the anchor.""" + scale = size / upem + advances = [hmtx[cmap[ord(c)]][0] * scale for t, _ in runs for c in t] + n = len(advances) + # Trailing letter-spacing is excluded: it is not ink, and including it + # would bias a centred lockup half a unit to the left. + width = sum(advances) + spacing * (n - 1) if n else 0.0 + + if anchor == "middle": + cursor = x - width / 2 + elif anchor == "end": + cursor = x - width + else: + cursor = x + + paths = [] + for text, fill in runs: + pen = SVGPathPen(gs, ntos=ntos) + for ch in text: + gname = cmap[ord(ch)] + # y flips: font units are y-up, SVG user space is y-down. + gs[gname].draw(TransformPen(pen, (scale, 0, 0, -scale, cursor, y))) + cursor += hmtx[gname][0] * scale + spacing + paths.append((fill, pen.getCommands())) + return paths + + +def convert(path, gs, cmap, hmtx, upem): + src = path.read_text() + blocks = TEXT_RE.findall(src) + if not blocks: + return False + + out = src + for block in blocks: + node = minidom.parseString(block.strip()).documentElement + indent = re.match(r"[ \t]*", block).group(0) + + runs = runs_of(node) + base_fill = node.getAttribute("fill") or "#000" + paths = outline( + gs, cmap, hmtx, upem, runs, + x=float(node.getAttribute("x") or 0), + y=float(node.getAttribute("y") or 0), + size=float(node.getAttribute("font-size")), + spacing=float(node.getAttribute("letter-spacing") or 0), + anchor=node.getAttribute("text-anchor") or "start", + ) + + rendered = "".join( + f'{indent}\n' + for fill, d in paths if d + ) + out = out.replace(block, rendered, 1) + + path.write_text(out) + return True + + +def main(): + if not FONT.exists(): + sys.exit(f"font not found: {FONT}") + gs, cmap, hmtx, upem = load_glyphset() + + touched = 0 + for svg in sorted(ASSETS.glob("*.svg")): + if convert(svg, gs, cmap, hmtx, upem): + print(f"outlined {svg.relative_to(ROOT)}") + touched += 1 + else: + print(f"no {svg.relative_to(ROOT)}") + print(f"\n{touched} file(s) rewritten") + + +if __name__ == "__main__": + main() diff --git a/web/src/lib/assets/README.md b/web/src/lib/assets/README.md new file mode 100644 index 0000000..a26d846 --- /dev/null +++ b/web/src/lib/assets/README.md @@ -0,0 +1,70 @@ +# Brand assets + +Cairn OBS logo package **v2 ("faceted glow")**. Source package: +`cairn-obs-logo-v2`, copied in here and renamed to this directory's +convention (the `cairn-obs-` prefix is dropped -- the directory already +says whose brand it is). + +Everything is a plain `` over four shared gradient stops, so a +re-theme is a find/replace on the stops rather than a redraw. + +## What the app actually uses + +| File | Used by | +| --- | --- | +| `favicon.svg` | `routes/+layout.svelte` (``) | +| `logo-horizontal-dark.svg` / `-light.svg` | `components/NavSidebar.svelte`, picked on theme | +| `logo-stacked-dark.svg` / `-light.svg` | `routes/+page.svelte` (landing), picked on theme | + +The rest are carried for completeness and are **not** imported anywhere, +so Vite does not emit them into the bundle: + +- `icon-glow.svg` / `icon-flat.svg` -- mark only, with and without the + ambient glow. Use `-flat` anywhere an SVG filter gets stripped. +- `icon-mono-white.svg` / `icon-mono-black.svg` -- single-colour + silhouettes. +- `wordmark-dark.svg` / `wordmark-light.svg` -- text only. +- `hero-grid.svg` -- 800x480 banner scene (social card, splash). + +Raster favicons live in `web/static/icons/` (16/32/48/180/512), served +from `/icons/` and referenced by absolute path in the layout head. + +## Two things to know before editing + +**`logo-stacked-light.svg` is derived, not vendored.** The v2 package +ships no light-background stacked lockup, but the landing page needs one +for light theme. It is the stacked-dark file with the treatment the +package itself applies to its horizontal pair: same stone geometry and +gradients, ambient glow dropped (it reads as haze on white), wordmark +switched to the light-surface ink and accent (`#111315` / `#E6690E`). +If the package is refreshed, re-derive it rather than assuming an +upstream file appeared. + +**The wordmark is outlined, not live text.** Upstream ships the lockups +with a `` element set in `'JetBrains Mono','Fira Code',ui-monospace, +monospace`. That does not survive the way this app loads them: they go +through `` (and get inlined as data URIs at build time), and an +``-loaded SVG is an isolated document that cannot reach the page's +`@font-face` rules. Neither our self-hosted Overpass Mono nor JetBrains +Mono applied, so the wordmark fell back to each visitor's default +monospace and changed shape by platform. + +Every `` has therefore been converted to `` outlines set in +**Overpass Mono Bold** -- the font the app already ships and uses for its +own monospace UI, so the lockup and the interface now agree. The +conversion is scripted: + + python3 web/scripts/outline-wordmark.py + +Re-run it after refreshing the package from upstream (upstream will ship +live `` again). It rewrites only `` elements, leaves every +other byte alone, and skips files that have none, so it is safe to re-run. +Editing the wordmark's copy or spacing now means editing the upstream +`` and re-running the script -- not hand-editing path data. + +## Aspect ratios + +Changed from v1 -- constrain one axis and leave the other `auto`: + +- horizontal lockups: 640x160 (v1 was 600x140) +- stacked lockups: 360x320, **not square** (v1 was 360x360) diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg index 4462b29..7109002 100644 --- a/web/src/lib/assets/favicon.svg +++ b/web/src/lib/assets/favicon.svg @@ -1,9 +1,20 @@ - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/web/src/lib/assets/hero-grid.svg b/web/src/lib/assets/hero-grid.svg new file mode 100644 index 0000000..291a8a2 --- /dev/null +++ b/web/src/lib/assets/hero-grid.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/lib/assets/icon-flat.svg b/web/src/lib/assets/icon-flat.svg new file mode 100644 index 0000000..62c1e00 --- /dev/null +++ b/web/src/lib/assets/icon-flat.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/lib/assets/icon-glow.svg b/web/src/lib/assets/icon-glow.svg new file mode 100644 index 0000000..940fec4 --- /dev/null +++ b/web/src/lib/assets/icon-glow.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/lib/assets/icon-mono-black.svg b/web/src/lib/assets/icon-mono-black.svg new file mode 100644 index 0000000..4ebcb8d --- /dev/null +++ b/web/src/lib/assets/icon-mono-black.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/src/lib/assets/icon-mono-white.svg b/web/src/lib/assets/icon-mono-white.svg new file mode 100644 index 0000000..28c5396 --- /dev/null +++ b/web/src/lib/assets/icon-mono-white.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/src/lib/assets/logo-horizontal-dark.svg b/web/src/lib/assets/logo-horizontal-dark.svg index 0fa3526..30135d5 100644 --- a/web/src/lib/assets/logo-horizontal-dark.svg +++ b/web/src/lib/assets/logo-horizontal-dark.svg @@ -1,12 +1,39 @@ - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - cairn - obs - + + + + diff --git a/web/src/lib/assets/logo-horizontal-light.svg b/web/src/lib/assets/logo-horizontal-light.svg index 8dcd73a..3ce7085 100644 --- a/web/src/lib/assets/logo-horizontal-light.svg +++ b/web/src/lib/assets/logo-horizontal-light.svg @@ -1,12 +1,31 @@ - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - cairn - obs - + + + + diff --git a/web/src/lib/assets/logo-stacked-dark.svg b/web/src/lib/assets/logo-stacked-dark.svg index 5a14136..f3b6743 100644 --- a/web/src/lib/assets/logo-stacked-dark.svg +++ b/web/src/lib/assets/logo-stacked-dark.svg @@ -1,10 +1,38 @@ - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - cairn obs + + + diff --git a/web/src/lib/assets/logo-stacked-light.svg b/web/src/lib/assets/logo-stacked-light.svg index b187a8f..ea34bf3 100644 --- a/web/src/lib/assets/logo-stacked-light.svg +++ b/web/src/lib/assets/logo-stacked-light.svg @@ -1,10 +1,31 @@ - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - cairn obs + + + diff --git a/web/src/lib/assets/wordmark-dark.svg b/web/src/lib/assets/wordmark-dark.svg new file mode 100644 index 0000000..ff772cf --- /dev/null +++ b/web/src/lib/assets/wordmark-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/lib/assets/wordmark-light.svg b/web/src/lib/assets/wordmark-light.svg new file mode 100644 index 0000000..6514aa9 --- /dev/null +++ b/web/src/lib/assets/wordmark-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 9e96e60..8cc0bb2 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -56,8 +56,10 @@ max-width: 48rem; } .logo { + /* The stacked lockup is 360x320, not square -- height must stay + auto or the wordmark under the cairn stretches. */ width: 11rem; - height: 11rem; + height: auto; } .tagline { margin-top: var(--space-4); diff --git a/web/static/icons/favicon-16.png b/web/static/icons/favicon-16.png index b2c751f..f7fc0bb 100644 Binary files a/web/static/icons/favicon-16.png and b/web/static/icons/favicon-16.png differ diff --git a/web/static/icons/favicon-180.png b/web/static/icons/favicon-180.png index 36b590b..f3ab67e 100644 Binary files a/web/static/icons/favicon-180.png and b/web/static/icons/favicon-180.png differ diff --git a/web/static/icons/favicon-32.png b/web/static/icons/favicon-32.png index 1b9499e..1c80955 100644 Binary files a/web/static/icons/favicon-32.png and b/web/static/icons/favicon-32.png differ diff --git a/web/static/icons/favicon-48.png b/web/static/icons/favicon-48.png index 37cf2ff..fdd004f 100644 Binary files a/web/static/icons/favicon-48.png and b/web/static/icons/favicon-48.png differ diff --git a/web/static/icons/favicon-512.png b/web/static/icons/favicon-512.png index 0d389a1..cba8b8f 100644 Binary files a/web/static/icons/favicon-512.png and b/web/static/icons/favicon-512.png differ