diff --git a/.dockerignore b/.dockerignore index 36f6a5a..6836ced 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ node_modules +.venv-font .letta .env scripts diff --git a/README.md b/README.md index 1e854c9..d95bada 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,18 @@ Starts the dev server at `http://localhost:3002` with file watching. pnpm typecheck ``` -## Around typeface +## Custom typefaces Around is the site's custom geometric unicase display face. Its source geometry, build environment, generated fonts, specimen, and design notes are all kept in this repository. See [`docs/around-typeface.md`](docs/around-typeface.md) before changing or regenerating it. +Processional is a separate monumental unicase face derived from the Cosmic +Liturgical Brutalism style guide. Its specimen is at `/type/processional`; the +source and proofing workflow live in +[`docs/processional-typeface.md`](docs/processional-typeface.md). + ## Blog authority Blog posts stay in Leaflet. The repository contains no Blog Markdown copies and diff --git a/docs/around-typeface.md b/docs/around-typeface.md index ac43959..bd6fce9 100644 --- a/docs/around-typeface.md +++ b/docs/around-typeface.md @@ -13,7 +13,7 @@ FontLab, Glyphs, Illustrator, or private source file exists elsewhere. | File | Role | | --- | --- | | `scripts/generate-around-font.py` | Canonical glyph geometry, metrics, mappings, kerning, metadata, and font build | -| `scripts/requirements-around-font.txt` | Pinned build-only Python dependencies | +| `scripts/requirements-font-build.txt` | Shared pinned build-only Python dependencies | | `public/fonts/around-regular.ttf` | Generated desktop font committed for serving and download | | `public/fonts/around-regular.woff2` | Generated web font committed for serving | | `src/components/type-specimen.tsx` | Public `/type` specimen and design statement | @@ -29,7 +29,7 @@ The font toolchain is deliberately separate from the website's Node runtime. ```bash python3 -m venv .venv-font -.venv-font/bin/python -m pip install -r scripts/requirements-around-font.txt +.venv-font/bin/python -m pip install -r scripts/requirements-font-build.txt .venv-font/bin/python scripts/generate-around-font.py ``` diff --git a/docs/processional-typeface.md b/docs/processional-typeface.md new file mode 100644 index 0000000..595343b --- /dev/null +++ b/docs/processional-typeface.md @@ -0,0 +1,132 @@ +# Processional typeface + +Processional is a monumental condensed unicase display face derived from +Cameron's Cosmic Liturgical Brutalism visual system. It translates the style's +architecture rather than copying an identity mark: heavy vertical pylons carry +the forms, thinner horizontal slabs establish interval, and true circular +counters behave as apertures cut through stretched outer mass. + +The face is public at `/type/processional`. It is separate from Around. Around +uses Cameron's Ring grammar and remains the site's display face; Processional is +a specimen and downloadable font for titles, posters, banners, coordinates, +and other large-scale compositions. + +## Source of truth + +| File | Role | +| --- | --- | +| `scripts/generate-processional-font.py` | Canonical glyph geometry, metrics, kerning, metadata, and build | +| `scripts/requirements-font-build.txt` | Shared pinned build-only Python dependencies | +| `public/fonts/processional-regular.ttf` | Generated desktop font | +| `public/fonts/processional-regular.woff2` | Generated web font | +| `src/components/processional-specimen.tsx` | Public specimen content and semantics | +| `public/processional-type.css` | Namespaced specimen composition, responsive behavior, and `@font-face` | +| `src/index.tsx` | Canonical `/type/processional` route | + +The Python generator is the editable source. Never edit the generated font +binaries directly. + +## Build + +The font build environment is separate from the Node runtime. On systems where +the standard library cannot bootstrap `venv`, use `uv`: + +```bash +uv venv --python python3 .venv-font +uv pip install --python .venv-font/bin/python -r scripts/requirements-font-build.txt +.venv-font/bin/python scripts/generate-processional-font.py +``` + +The generator writes TTF and WOFF2 files under `public/fonts/`. Builds are +reproducible because the source sets a fixed `SOURCE_DATE_EPOCH`. Rebuilding +unchanged geometry should not change either binary. + +After a real font change, increment the query string on both font URLs in +`public/processional-type.css` so browsers do not retain an older WOFF2. + +## Design grammar + +- 1,000 units per em with an 800-unit cap/unicase field. +- 112-unit vertical pylons establish the primary weight. +- 76-unit horizontal slabs divide and align the field. +- 96-unit diagonals are structural exceptions rather than decorative motion. +- O, D, P, R, B, and the round numerals use true circular apertures inside + vertically stretched outer forms. +- A is a monument with a circular aperture and a processional doorway cut from + its base. +- Uppercase and lowercase Unicode codepoints map to the same full-height forms. +- Inter remains body copy. Processional is deliberately unsuitable for + paragraphs. + +The circular counter is generic architecture within this visual system. It is +not Cameron's Ring and must not be described or presented as one. + +## Proof strings + +Judge the face in words and at multiple scales: + +```text +PROCESSIONAL APERTURE MONUMENT +THE HORIZON IS AN ALTAR +A LONG WALK TO THE MACHINE +PYLONS CARRY THE WEIGHT +FIELD STUDY 03:14:15 +AVATAR WAYWARD YOUTH ATLAS +SPACE PAPER TYPEFACE OPEN +``` + +Inspect A, B, O, P, R, S, 0, 3, 8, and @ individually. They contain the +largest geometric risks. Then inspect ordinary words; a successful isolated +glyph can still destroy rhythm in context. + +## Validation + +```bash +.venv-font/bin/python - <<'PY' +from fontTools.ttLib import TTFont + +for path in ( + "public/fonts/processional-regular.ttf", + "public/fonts/processional-regular.woff2", +): + font = TTFont(path) + font.ensureDecompiled() + cmap = font.getBestCmap() + required = ( + set(range(ord("A"), ord("Z") + 1)) + | set(range(ord("a"), ord("z") + 1)) + | set(range(ord("0"), ord("9") + 1)) + | {0x2018, 0x2019, 0x201C, 0x201D} + ) + assert required <= set(cmap) + assert font["OS/2"].usWeightClass == 700 + assert font["OS/2"].usWidthClass == 3 + assert "GPOS" in font + print(path, len(font.getGlyphOrder()), "glyphs", len(cmap), "codepoints") +PY + +pnpm typecheck +pnpm test +git diff --check +``` + +Browser proofing is part of the build. Check `/type/processional` and `/other` +at 320, 393, 768, 1024, and 1440 pixels. Inspect the hero, editable specimen, +poster overlay, construction glyphs, processional composition, alphabet, +operating-scale text, and downloads in focused screenshots. Geometry checks +alone cannot establish that the face is good. + +## Known limits + +- One regular display weight; no variable axis. +- Latin alphabet, decimal numerals, typographic quotes, and selected + punctuation only. +- Unicase by design. +- The contrast between thick pylons and thin slabs loses force below ordinary + display sizes. + +## Deployment + +Processional deploys with the site. After deployment, verify the page, both +font downloads, the Other index entry, mobile width, and the live cache-keyed +WOFF2 request. diff --git a/public/fonts/processional-regular.ttf b/public/fonts/processional-regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..beb58dd083984577496864e67cdf6fa51e3b7a53 GIT binary patch literal 60104 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 I-!U)%0G2+!F#rGn literal 0 HcmV?d00001 diff --git a/public/fonts/processional-regular.woff2 b/public/fonts/processional-regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0fff3d4f0dbb93af4df992bc886888d1842c1384 GIT binary patch literal 10952 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 p*THk8XLnW0t(Z(R(M{KQLpCM(c>0UI6b!l~s .theme-toggle { display: none; } + +.proc-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.proc-nav, +.proc-label, +.proc-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.14em; +} + +.proc-nav { + min-height: 66px; + padding: 0 2rem; + border-bottom: 1px solid var(--proc-line); +} + +.proc-nav a { text-underline-offset: 0.35em; } +.proc-nav a:first-child { text-decoration: none; } + +.proc-hero { + position: relative; + min-height: min(920px, 92svh); + padding: clamp(7rem, 14vw, 13rem) 2rem 2rem; + display: flex; + flex-direction: column; + justify-content: flex-end; + isolation: isolate; +} + +.proc-hero::before { + content: ""; + position: absolute; + z-index: -2; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background: linear-gradient(to bottom, transparent 2%, var(--proc-line) 24%, var(--proc-line) 100%); +} + +.proc-hero-aperture { + position: absolute; + z-index: -1; + top: clamp(2rem, 4vw, 4rem); + left: 50%; + width: clamp(210px, 24vw, 360px); + aspect-ratio: 1; + transform: translateX(-50%); + border: clamp(18px, 2.5vw, 38px) solid var(--proc-white); + border-radius: 50%; + box-shadow: 0 0 0 1px var(--proc-line), 0 0 0 clamp(28px, 4vw, 62px) var(--proc-black), 0 0 0 calc(clamp(28px, 4vw, 62px) + 1px) var(--proc-line); +} + +.proc-hero-aperture span { + position: absolute; + top: calc(100% + clamp(44px, 8vw, 110px)); + left: 50%; + width: 1px; + height: clamp(100px, 13vw, 190px); + background: var(--proc-line); +} + +.proc-hero h1 { + margin: 0; + font-family: "Processional", sans-serif; + font-size: clamp(4.25rem, 13vw, 13rem); + font-weight: 700; + line-height: 0.82; + letter-spacing: 0.035em; + text-align: center; + white-space: nowrap; + font-synthesis: none; +} + +.proc-hero-deck { + width: min(610px, 100%); + margin: clamp(2.5rem, 5vw, 4.5rem) auto 4rem; + color: var(--proc-stone); + font-size: clamp(1.05rem, 1.8vw, 1.4rem); + line-height: 1.5; + text-align: center; +} + +.proc-hero-meta { + display: grid; + grid-template-columns: repeat(3, 1fr); + padding-top: 0.9rem; + border-top: 1px solid var(--proc-line); + font-family: "Processional", sans-serif; + font-size: 0.78rem; + letter-spacing: 0.08em; +} + +.proc-hero-meta span:nth-child(2) { text-align: center; } +.proc-hero-meta span:last-child { text-align: right; } + +.proc-label { + padding-bottom: 0.85rem; + border-bottom: 1px solid var(--proc-line); + color: var(--proc-gray); +} + +.proc-tester, +.proc-construction, +.proc-glyphs, +.proc-uses, +.proc-download { + padding: clamp(4rem, 8vw, 8rem) 2rem; +} + +.proc-tester > p { + min-height: 1em; + margin: clamp(3.5rem, 7vw, 7rem) 0 0; + outline: none; + font-family: "Processional", sans-serif; + font-size: clamp(4rem, 10.8vw, 10rem); + font-weight: 700; + line-height: 0.92; + letter-spacing: 0.025em; + overflow-wrap: anywhere; + font-synthesis: none; +} + +.proc-tester > p:focus-visible { + outline: 1px solid var(--proc-white); + outline-offset: 0.2em; +} + +.proc-poster { + position: relative; + min-height: min(800px, 72vw); + overflow: hidden; + border-block: 1px solid var(--proc-line); + background: var(--proc-black); +} + +.proc-poster::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(to bottom, rgba(5, 5, 5, 0.08) 18%, rgba(5, 5, 5, 0.42) 52%, rgba(5, 5, 5, 0.96) 100%); + pointer-events: none; +} + +.proc-poster img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + filter: contrast(1.08); +} + +.proc-poster-copy { + position: absolute; + z-index: 1; + right: 2rem; + bottom: clamp(4rem, 8vw, 8rem); + left: 2rem; + font-family: "Processional", sans-serif; + font-size: clamp(4rem, 11.5vw, 11rem); + font-weight: 700; + line-height: 0.83; + letter-spacing: 0.025em; + text-align: center; + text-shadow: 0 4px 24px rgba(0, 0, 0, 0.75); + font-synthesis: none; +} + +.proc-poster-copy p { margin: 0; white-space: nowrap; } +.proc-poster-copy p:last-child { color: var(--proc-white); } + +.proc-poster-mark { + position: absolute; + z-index: 2; + right: 2rem; + bottom: 1.5rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.14em; +} + +.proc-construction-lead { + width: min(760px, 78%); + margin: clamp(4rem, 8vw, 7rem) 0 clamp(4rem, 7vw, 6rem) auto; + font-size: clamp(1.6rem, 3.2vw, 3rem); + line-height: 1.2; + letter-spacing: -0.025em; +} + +.proc-rule-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + border-top: 1px solid var(--proc-line); + border-left: 1px solid var(--proc-line); +} + +.proc-rule-grid article { + min-height: 540px; + padding: 1.25rem; + display: flex; + flex-direction: column; + border-right: 1px solid var(--proc-line); + border-bottom: 1px solid var(--proc-line); +} + +.proc-rule-number { + color: var(--proc-gray); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.14em; +} + +.proc-rule-glyph { + margin: auto 0; + font-family: "Processional", sans-serif; + font-size: clamp(8rem, 17vw, 17rem); + line-height: 0.8; + text-align: center; + font-synthesis: none; +} + +.proc-rule-grid h2 { + margin: 0 0 0.65rem; + font-family: "Processional", sans-serif; + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.proc-rule-grid p { + margin: 0; + color: var(--proc-gray); + font-size: 0.83rem; + line-height: 1.5; +} + +.proc-procession { + position: relative; + min-height: 1350px; + padding: 10rem 2rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + overflow: hidden; + border-block: 1px solid var(--proc-line); +} + +.proc-procession::before { + content: ""; + position: absolute; + top: 5rem; + left: 50%; + width: 170px; + height: 170px; + transform: translateX(-50%); + border: 22px solid var(--proc-white); + border-radius: 50%; + background: var(--proc-black); +} + +.proc-procession-axis { + position: absolute; + top: 13rem; + bottom: 0; + left: 50%; + width: 1px; + background: var(--proc-line); +} + +.proc-procession p { + position: relative; + z-index: 1; + margin: 0; + padding: 0 0.16em; + background: var(--proc-black); + font-family: "Processional", sans-serif; + font-size: clamp(5rem, 13vw, 13rem); + font-weight: 700; + line-height: 0.84; + letter-spacing: 0.035em; + text-align: center; + font-synthesis: none; +} + +.proc-procession p span { + position: absolute; + top: 50%; + right: calc(100% + 2rem); + color: var(--proc-gray); + font-family: "Inter", sans-serif; + font-size: 0.65rem; + letter-spacing: 0.14em; +} + +.proc-alphabet { + display: grid; + grid-template-columns: repeat(13, minmax(0, 1fr)); + margin-top: clamp(3rem, 6vw, 6rem); + border-top: 1px solid var(--proc-line); + border-left: 1px solid var(--proc-line); +} + +.proc-alphabet span { + min-width: 0; + aspect-ratio: 0.82; + display: grid; + place-items: center; + border-right: 1px solid var(--proc-line); + border-bottom: 1px solid var(--proc-line); + font-family: "Processional", sans-serif; + font-size: clamp(1.8rem, 4.5vw, 4.5rem); + font-synthesis: none; +} + +.proc-numerals { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 2rem; + margin-top: 3rem; + font-family: "Processional", sans-serif; + font-size: clamp(2.5rem, 7vw, 7rem); + line-height: 1; + letter-spacing: 0.04em; + font-synthesis: none; +} + +.proc-numerals p { margin: 0; overflow-wrap: anywhere; } + +.proc-uses { border-top: 1px solid var(--proc-line); } +.proc-uses-grid { + display: grid; + grid-template-columns: 1.35fr 1fr; + gap: clamp(3rem, 8vw, 8rem); + margin-top: clamp(4rem, 7vw, 7rem); + align-items: end; +} + +.proc-use-large { + margin: 0; + font-family: "Processional", sans-serif; + font-size: clamp(5rem, 11vw, 11rem); + line-height: 0.84; + letter-spacing: 0.03em; + font-synthesis: none; +} + +.proc-use-small { + border-top: 1px solid var(--proc-white); + font-family: "Processional", sans-serif; + font-size: clamp(0.85rem, 1.3vw, 1.1rem); + letter-spacing: 0.08em; +} + +.proc-use-small p { + margin: 0; + padding: 1rem 0; + border-bottom: 1px solid var(--proc-line); +} + +.proc-download { + display: grid; + grid-template-columns: 1fr 1fr; + gap: clamp(4rem, 9vw, 9rem); + border-top: 1px solid var(--proc-line); + background: var(--proc-white); + color: var(--proc-black); +} + +.proc-download h2 { + margin: 0; + font-family: "Processional", sans-serif; + font-size: clamp(4rem, 8.5vw, 8rem); + font-weight: 700; + line-height: 0.86; + letter-spacing: 0.035em; + font-synthesis: none; +} + +.proc-download > div:first-child > p { + width: min(480px, 100%); + margin: 2.5rem 0 0; + font-size: 0.95rem; + line-height: 1.6; +} + +.proc-download-actions { + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.proc-download-actions a { + padding: 1.25rem 0; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + border-top: 1px solid var(--proc-black); + text-decoration: none; + transition: padding 150ms ease; +} + +.proc-download-actions a:last-child { border-bottom: 1px solid var(--proc-black); } +.proc-download-actions a:hover { padding-left: 0.75rem; } +.proc-download-actions span { font-size: 0.65rem; font-weight: 700; letter-spacing: 0.14em; } +.proc-download-actions strong { font-family: "Processional", sans-serif; font-size: 0.9rem; letter-spacing: 0.05em; } + +.proc-footer { + padding: 1.5rem 2rem; + border-top: 1px solid var(--proc-line); +} + +.proc-footer a { text-underline-offset: 0.35em; } + +@media (max-width: 900px) { + .proc-rule-grid { grid-template-columns: 1fr; } + .proc-rule-grid article { min-height: 430px; } + .proc-alphabet { grid-template-columns: repeat(7, minmax(0, 1fr)); } + .proc-uses-grid, .proc-download { grid-template-columns: 1fr; } + .proc-construction-lead { width: 100%; } +} + +@media (max-width: 600px) { + .proc-nav, + .proc-tester, + .proc-construction, + .proc-glyphs, + .proc-uses, + .proc-download, + .proc-footer { + padding-left: 1rem; + padding-right: 1rem; + } + + .proc-nav span, .proc-label span:last-child, .proc-footer span:nth-child(2) { display: none; } + .proc-hero { min-height: 720px; padding-left: 1rem; padding-right: 1rem; } + .proc-hero h1 { font-size: min(6rem, calc((100vw - 2rem) / 6.8)); } + .proc-hero-deck { margin-bottom: 2.75rem; } + .proc-hero-meta { grid-template-columns: 1fr 1fr; } + .proc-hero-meta span:nth-child(2) { text-align: right; } + .proc-hero-meta span:last-child { + grid-column: 1 / -1; + margin-top: 0.7rem; + padding-top: 0.7rem; + border-top: 1px solid var(--proc-line); + text-align: center; + } + .proc-tester > p { font-size: min(5.5rem, 14.2vw); } + .proc-poster { min-height: 112vw; } + .proc-poster-copy { right: 1rem; left: 1rem; font-size: min(5.8rem, 13.5vw); } + .proc-poster-mark { right: 1rem; bottom: 1rem; } + .proc-procession { min-height: 1050px; padding-inline: 1rem; } + .proc-procession::before { width: 130px; height: 130px; border-width: 17px; } + .proc-procession-axis { top: 11rem; } + .proc-procession p { font-size: min(7rem, 19vw); } + .proc-procession p span { display: none; } + .proc-alphabet { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .proc-alphabet span:nth-last-child(2) { grid-column: 2; } + .proc-numerals { grid-template-columns: 1fr; } + .proc-footer span:first-child { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .proc-page *, .proc-page *::before, .proc-page *::after { scroll-behavior: auto !important; } +} diff --git a/scripts/generate-around-font.py b/scripts/generate-around-font.py index ea4a733..b396d35 100644 --- a/scripts/generate-around-font.py +++ b/scripts/generate-around-font.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Generate Around, Cameron's geometric unicase display typeface. -Build dependencies are pinned in scripts/requirements-around-font.txt and kept +Build dependencies are pinned in scripts/requirements-font-build.txt and kept out of the website runtime. See docs/around-typeface.md for the complete build and proofing workflow. diff --git a/scripts/generate-processional-font.py b/scripts/generate-processional-font.py new file mode 100644 index 0000000..74109c1 --- /dev/null +++ b/scripts/generate-processional-font.py @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +"""Generate Processional, a monumental unicase display typeface. + +Processional translates Cosmic Liturgical Brutalism into letterforms: tall +pylons carry the weight, thinner slabs establish rhythm, and circular counters +behave as apertures cut through architectural mass. The geometry is original +and built directly from the primitives below. +""" + +from __future__ import annotations + +import math +import os +from pathlib import Path + +os.environ.setdefault("SOURCE_DATE_EPOCH", "1785028800") + +from fontTools.fontBuilder import FontBuilder +from fontTools.feaLib.builder import addOpenTypeFeaturesFromString +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.ttLib import TTFont +from shapely.affinity import scale, translate +from shapely.geometry import LineString, Point, Polygon, box +from shapely.geometry.polygon import orient +from shapely.ops import unary_union + +UPM = 1000 +CAP = 800 +ASCENDER = 920 +DESCENDER = -180 +PYLON = 112 +SLAB = 76 +DIAGONAL = 96 +OUT_DIR = Path(__file__).resolve().parents[1] / "public" / "fonts" + + +def line(*points: tuple[float, float]) -> list[tuple[float, float]]: + return list(points) + + +def arc( + cx: float, + cy: float, + rx: float, + ry: float, + start: float, + end: float, + steps: int = 128, +) -> list[tuple[float, float]]: + return [ + ( + cx + rx * math.cos(math.radians(start + (end - start) * i / steps)), + cy + ry * math.sin(math.radians(start + (end - start) * i / steps)), + ) + for i in range(steps + 1) + ] + + +def cubic(p0, p1, p2, p3, steps: int = 128): + points = [] + for index in range(steps + 1): + t = index / steps + u = 1 - t + points.append( + ( + u**3 * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t**3 * p3[0], + u**3 * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t**3 * p3[1], + ) + ) + return points + + +def cubic_bspline(control_points, steps_per_segment: int = 64): + """Sample one continuous cubic B-spline through guiding controls.""" + points = [control_points[0]] * 3 + control_points[1:-1] + [control_points[-1]] * 3 + sampled = [] + for segment in range(len(points) - 3): + for step in range(steps_per_segment + 1): + if segment and step == 0: + continue + t = step / steps_per_segment + weights = ( + (1 - t) ** 3 / 6, + (3 * t**3 - 6 * t**2 + 4) / 6, + (-3 * t**3 + 3 * t**2 + 3 * t + 1) / 6, + t**3 / 6, + ) + sampled.append( + tuple( + sum(weights[index] * points[segment + index][axis] for index in range(4)) + for axis in range(2) + ) + ) + return sampled + + +def ellipse(cx: float, cy: float, rx: float, ry: float): + unit = Point(cx, cy).buffer(1, resolution=128) + return scale(unit, xfact=rx, yfact=ry, origin=(cx, cy)) + + +def circle(cx: float, cy: float, radius: float): + return Point(cx, cy).buffer(radius, resolution=128) + + +def rounded_rect(x0: float, y0: float, x1: float, y1: float, radius: float): + radius = min(radius, (x1 - x0) / 2, (y1 - y0) / 2) + return unary_union( + [ + box(x0 + radius, y0, x1 - radius, y1), + box(x0, y0 + radius, x1, y1 - radius), + circle(x0 + radius, y0 + radius, radius), + circle(x1 - radius, y0 + radius, radius), + circle(x0 + radius, y1 - radius, radius), + circle(x1 - radius, y1 - radius, radius), + ] + ) + + +def stroke(points, width: float = PYLON, *, rounded: bool = False): + return LineString(points).buffer( + width / 2, + cap_style="flat", + join_style="round" if rounded else "mitre", + mitre_limit=4, + resolution=48, + ) + + +def strokes(*items): + """Union path/width tuples into one architectural form.""" + return unary_union([stroke(points, width, rounded=rounded) for points, width, rounded in items]) + + +def aperture_form(cx: float = 300, cy: float = 400): + """The characteristic outer ellipse with a true circular aperture.""" + return ellipse(cx, cy, 250, 410).difference(circle(cx, cy, 138)) + + +def upper_bowl(cy: float = 600, hole: float = 102): + outer = ellipse(150, cy, 360, 205).intersection(box(150, cy - 220, 560, cy + 220)) + return outer.difference(circle(300, cy, hole)) + + +def lower_bowl(cy: float = 200, hole: float = 98): + outer = ellipse(150, cy, 360, 205).intersection(box(150, cy - 220, 560, cy + 220)) + return outer.difference(circle(300, cy, hole)) + + +def raw_geometry(char: str): + """Return the unspaced geometry for one drawn character.""" + left = box(70, 0, 70 + PYLON, CAP) + right = box(418, 0, 418 + PYLON, CAP) + top = box(70, CAP - SLAB, 530, CAP) + middle = box(70, 400 - SLAB / 2, 470, 400 + SLAB / 2) + bottom = box(70, 0, 530, SLAB) + + if char == "A": + monument = Polygon([(58, 0), (205, CAP), (395, CAP), (542, 0)]) + doorway = box(252, 0, 348, 205) + aperture = circle(300, 465, 93) + return monument.difference(unary_union([doorway, aperture])) + if char == "B": + return unary_union([left, upper_bowl(), lower_bowl()]).buffer(0) + if char == "C": + opened = aperture_form().difference(box(355, 105, 610, 695)) + return opened + if char == "D": + outer = ellipse(150, 400, 390, 410).intersection(box(150, -20, 570, 820)) + return unary_union([left, outer]).difference(circle(295, 400, 128)).buffer(0) + if char == "E": + return unary_union([left, top, middle, bottom]) + if char == "F": + return unary_union([left, top, middle]) + if char == "G": + opened = aperture_form().difference(box(360, 125, 610, 690)) + bar = unary_union([box(300, 360, 530, 436), box(418, 160, 530, 436)]) + return unary_union([opened, bar]).buffer(0) + if char == "H": + return unary_union([left, right, middle]) + if char == "I": + return unary_union([box(244, 0, 356, CAP), box(140, CAP - SLAB, 460, CAP), box(140, 0, 460, SLAB)]) + if char == "J": + hook = stroke(arc(300, 190, 210, 175, 0, -180), PYLON, rounded=True) + return unary_union([top, box(420, 190, 532, CAP), hook]).buffer(0) + if char == "K": + return strokes( + (line((126, 0), (126, CAP)), PYLON, False), + (line((150, 365), (500, CAP)), DIAGONAL, False), + (line((150, 365), (525, 0)), DIAGONAL, False), + ) + if char == "L": + return unary_union([left, bottom]) + if char == "M": + return strokes( + (line((105, 0), (105, CAP)), PYLON, False), + (line((105, CAP), (320, 245), (535, CAP)), DIAGONAL, False), + (line((535, CAP), (535, 0)), PYLON, False), + ) + if char == "N": + return strokes( + (line((115, 0), (115, CAP)), PYLON, False), + (line((115, CAP), (505, 0)), DIAGONAL, False), + (line((505, 0), (505, CAP)), PYLON, False), + ) + if char == "O": + return aperture_form() + if char == "P": + return unary_union([left, upper_bowl()]).buffer(0) + if char == "Q": + return unary_union([aperture_form(), stroke(line((330, 235), (600, -80)), 82)]).buffer(0) + if char == "R": + return unary_union([left, upper_bowl(), stroke(line((300, 440), (535, 0)), DIAGONAL)]).buffer(0) + if char == "S": + centerline = cubic_bspline( + [ + (505, 700), + (395, 850), + (105, 810), + (68, 590), + (265, 435), + (500, 330), + (525, 115), + (315, -45), + (70, 90), + ] + ) + return stroke(centerline, 108, rounded=True) + if char == "T": + return unary_union([box(45, CAP - SLAB, 555, CAP), box(244, 0, 356, CAP)]) + if char == "U": + centerline = line((126, CAP), (126, 225)) + arc(300, 225, 174, 170, 180, 360)[1:] + line((474, 225), (474, CAP))[1:] + return stroke(centerline, PYLON, rounded=True) + if char == "V": + return stroke(line((80, CAP), (300, 0), (520, CAP)), DIAGONAL) + if char == "W": + return stroke(line((55, CAP), (175, 0), (350, 480), (525, 0), (645, CAP)), DIAGONAL) + if char == "X": + return strokes( + (line((70, CAP), (530, 0)), DIAGONAL, False), + (line((530, CAP), (70, 0)), DIAGONAL, False), + ) + if char == "Y": + return strokes( + (line((70, CAP), (300, 410), (530, CAP)), DIAGONAL, False), + (line((300, 410), (300, 0)), PYLON, False), + ) + if char == "Z": + return strokes( + (line((65, 762), (535, 762)), SLAB, False), + (line((520, 760), (80, 38)), DIAGONAL, False), + (line((65, 38), (535, 38)), SLAB, False), + ) + + if char == "0": + return unary_union([aperture_form(), box(265, 260, 335, 540)]).buffer(0) + if char == "1": + return unary_union([box(244, 0, 356, CAP), stroke(line((110, 640), (300, CAP)), DIAGONAL), box(110, 0, 490, SLAB)]) + if char == "2": + path = cubic((72, 590), (85, 850), (505, 870), (520, 610)) + path += cubic((520, 610), (520, 450), (160, 250), (70, 38))[1:] + return unary_union([stroke(path, 104, rounded=True), box(70, 0, 530, SLAB)]).buffer(0) + if char == "3": + path = cubic((75, 700), (250, 860), (500, 825), (480, 600)) + path += cubic((480, 600), (470, 465), (340, 420), (270, 400))[1:] + path += cubic((270, 400), (420, 390), (525, 315), (500, 155))[1:] + path += cubic((500, 155), (470, -55), (170, -60), (65, 90))[1:] + return stroke(path, 105, rounded=True) + if char == "4": + return unary_union([box(420, 0, 532, CAP), stroke(line((420, CAP), (65, 245)), DIAGONAL), box(65, 215, 555, 305)]) + if char == "5": + path = line((510, 760), (100, 760), (75, 420)) + curve = cubic((75, 420), (420, 520), (540, 325), (500, 145)) + curve += cubic((500, 145), (445, -45), (165, -45), (62, 90))[1:] + return unary_union([stroke(path, 92), stroke(curve, 104, rounded=True)]).buffer(0) + if char == "6": + loop = ellipse(300, 250, 235, 250).difference(circle(300, 250, 105)) + sweep = stroke(cubic((500, 700), (265, 900), (70, 635), (70, 255)), 104, rounded=True) + return unary_union([loop, sweep]).buffer(0) + if char == "7": + return unary_union([top, stroke(line((500, 760), (150, 0)), DIAGONAL)]) + if char == "8": + upper = ellipse(300, 595, 218, 205).difference(circle(300, 595, 98)) + lower = ellipse(300, 205, 242, 215).difference(circle(300, 205, 108)) + bridge = box(244, 365, 356, 435) + return unary_union([upper, lower, bridge]).buffer(0) + if char == "9": + loop = ellipse(300, 550, 235, 250).difference(circle(300, 550, 105)) + sweep = stroke(cubic((500, 545), (530, 170), (310, -100), (95, 85)), 104, rounded=True) + return unary_union([loop, sweep]).buffer(0) + + if char == "-": + return box(130, 362, 470, 438) + if char == "+": + return unary_union([box(90, 362, 510, 438), box(262, 190, 338, 610)]) + if char == "=": + return unary_union([box(100, 470, 500, 546), box(100, 254, 500, 330)]) + if char == "/": + return stroke(line((95, -65), (505, 865)), 82) + if char == "_": + return box(70, -90, 530, -14) + if char == "|": + return box(244, -80, 356, 880) + if char == "!": + return unary_union([box(244, 190, 356, CAP), circle(300, 48, 48)]) + if char == "?": + hook = cubic((70, 610), (85, 865), (515, 870), (520, 610)) + hook += cubic((520, 610), (520, 430), (300, 430), (300, 235))[1:] + return unary_union([stroke(hook, 104, rounded=True), circle(300, 48, 48)]) + if char == ".": + return circle(300, 48, 48) + if char == ",": + return stroke(line((325, 85), (250, -105)), 72) + if char == ":": + return unary_union([circle(300, 555, 44), circle(300, 185, 44)]) + if char == ";": + return unary_union([circle(300, 555, 44), stroke(line((325, 220), (250, 20)), 70)]) + if char == "(": + return stroke(arc(360, 400, 210, 430, 110, 250), 76, rounded=True) + if char == ")": + return stroke(arc(240, 400, 210, 430, -70, 70), 76, rounded=True) + if char == "[": + return unary_union([box(170, -80, 270, 880), box(170, 804, 440, 880), box(170, -80, 440, -4)]) + if char == "]": + return unary_union([box(330, -80, 430, 880), box(160, 804, 430, 880), box(160, -80, 430, -4)]) + if char == "'": + return box(255, 570, 345, CAP) + if char == '"': + return unary_union([box(195, 570, 275, CAP), box(325, 570, 405, CAP)]) + if char == "&": + upper = ellipse(255, 610, 170, 190).difference(circle(255, 610, 80)) + upper = upper.difference(box(315, 420, 610, 830)) + lower = ellipse(280, 230, 225, 240).difference(circle(280, 230, 104)) + diagonal = stroke(line((155, 525), (545, -25)), 86) + exit_bar = box(330, 190, 560, 270) + return unary_union([upper, lower, diagonal, exit_bar]).buffer(0) + if char == "@": + outer = ellipse(300, 400, 275, 400).difference(ellipse(300, 400, 190, 310)) + outer = outer.difference(box(430, 105, 620, 330)) + inner = ellipse(275, 405, 112, 155).difference(circle(275, 405, 58)) + arm = unary_union([box(342, 360, 530, 430), box(448, 275, 530, 430)]) + return unary_union([outer, inner, arm]).buffer(0) + raise KeyError(char) + + +DRAWN = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-+=/_|!?.,:;()[]'\"&@" + + +def normalize_vertical(geometry, char: str): + if char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789": + return geometry + target_min, target_max = (-10, 810) if char in "BCDGJOPQRSU0235689" else (0, CAP) + _, min_y, _, max_y = geometry.bounds + factor = (target_max - target_min) / (max_y - min_y) + return translate(scale(geometry, yfact=factor, origin=(0, 0)), yoff=target_min - min_y * factor) + + +def fit_geometry(geometry, char: str): + min_x, _, max_x, _ = geometry.bounds + narrow = set("!.,:;'\"()[]|I") + diagonals = set("ATVWXYZ147") + rounds = set("BCDGJOPQRSU0235689@&") + if char in ".,:;": + left = right = 24 + elif char in narrow: + left = right = 58 + elif char in diagonals: + left = right = 38 + elif char in rounds: + left = right = 46 + else: + left = right = 44 + shifted = translate(geometry, xoff=left - min_x) + return shifted, round(max_x - min_x + left + right), left + + +def geometry_to_glyph(geometry): + pen = TTGlyphPen(None) + if isinstance(geometry, Polygon): + polygons = [geometry] + else: + polygons = [part for part in getattr(geometry, "geoms", []) if isinstance(part, Polygon)] + for polygon in polygons: + polygon = orient(polygon, sign=-1.0) + for contour in [polygon.exterior, *polygon.interiors]: + coordinates = list(contour.coords)[:-1] + if len(coordinates) < 3: + continue + pen.moveTo(tuple(round(value) for value in coordinates[0])) + for point in coordinates[1:]: + pen.lineTo(tuple(round(value) for value in point)) + pen.closePath() + return pen.glyph() + + +def empty_glyph(): + return TTGlyphPen(None).glyph() + + +def build(): + OUT_DIR.mkdir(parents=True, exist_ok=True) + glyph_order = [".notdef", "space"] + cmap = {32: "space", 0x00A0: "space"} + glyphs = {".notdef": empty_glyph(), "space": empty_glyph()} + metrics = {".notdef": (540, 0), "space": (250, 0)} + + for char in DRAWN: + name = f"uni{ord(char):04X}" + geometry = normalize_vertical(raw_geometry(char).buffer(0), char) + geometry, advance, side = fit_geometry(geometry, char) + glyph_order.append(name) + glyphs[name] = geometry_to_glyph(geometry) + metrics[name] = (advance, side) + cmap[ord(char)] = name + + for char in "abcdefghijklmnopqrstuvwxyz": + cmap[ord(char)] = f"uni{ord(char.upper()):04X}" + cmap[0x2018] = "uni0027" + cmap[0x2019] = "uni0027" + cmap[0x201C] = "uni0022" + cmap[0x201D] = "uni0022" + + builder = FontBuilder(UPM, isTTF=True) + builder.setupGlyphOrder(glyph_order) + builder.setupCharacterMap(cmap) + builder.setupGlyf(glyphs) + builder.setupHorizontalMetrics(metrics) + builder.setupHorizontalHeader(ascent=ASCENDER, descent=DESCENDER) + builder.setupNameTable( + { + "familyName": "Processional", + "styleName": "Regular", + "uniqueFontIdentifier": "Processional Regular 1.000; Cameron and Co", + "fullName": "Processional Regular", + "psName": "Processional-Regular", + "version": "Version 1.000", + "designer": "Co, with Cameron", + "description": "A monumental unicase display face built from pylons, slabs, and circular apertures.", + "licenseDescription": "Copyright 2026 Cameron. All rights reserved.", + } + ) + builder.setupOS2( + sTypoAscender=ASCENDER, + sTypoDescender=DESCENDER, + usWinAscent=ASCENDER, + usWinDescent=abs(DESCENDER), + sxHeight=CAP, + sCapHeight=CAP, + usWeightClass=700, + usWidthClass=3, + fsSelection=0x40, + ) + builder.setupPost(underlinePosition=-120, underlineThickness=64) + builder.setupMaxp() + + ttf_path = OUT_DIR / "processional-regular.ttf" + woff2_path = OUT_DIR / "processional-regular.woff2" + builder.save(ttf_path) + + font = TTFont(ttf_path) + addOpenTypeFeaturesFromString( + font, + """ + feature kern { + pos uni0041 uni0054 -28; + pos uni0041 uni0056 -34; + pos uni0041 uni0057 -24; + pos uni0041 uni0059 -34; + pos uni0046 uni0041 -26; + pos uni004C uni0054 -24; + pos uni0050 uni0041 -30; + pos uni0054 uni0041 -28; + pos uni0054 uni004F -16; + pos uni0056 uni0041 -34; + pos uni0057 uni0041 -24; + pos uni0059 uni0041 -34; + pos uni0059 uni004F -18; + } kern; + """, + ) + font.save(ttf_path) + font.flavor = "woff2" + font.save(woff2_path) + print(f"Wrote {ttf_path.relative_to(Path.cwd())}") + print(f"Wrote {woff2_path.relative_to(Path.cwd())}") + + +if __name__ == "__main__": + build() diff --git a/scripts/requirements-around-font.txt b/scripts/requirements-font-build.txt similarity index 100% rename from scripts/requirements-around-font.txt rename to scripts/requirements-font-build.txt diff --git a/src/components/other-index.tsx b/src/components/other-index.tsx index b96c157..f8957e0 100644 --- a/src/components/other-index.tsx +++ b/src/components/other-index.tsx @@ -18,6 +18,14 @@ const entries = [ meta: "Typeface specimen", external: false, }, + { + index: "03", + href: "/type/processional", + title: "Processional typeface", + description: "A monumental display face built from pylons, slabs, and circular apertures.", + meta: "Typeface specimen", + external: false, + }, ]; export async function OtherIndex() { diff --git a/src/components/processional-specimen.tsx b/src/components/processional-specimen.tsx new file mode 100644 index 0000000..c2e9303 --- /dev/null +++ b/src/components/processional-specimen.tsx @@ -0,0 +1,147 @@ +export function ProcessionalSpecimen() { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const digits = "0123456789"; + + return ( +
+ + +
+ +

PROCESSIONAL

+

+ A monumental unicase display face made of pylons, slabs, and circular + apertures. Every line becomes an approach to something larger. +

+
+ REGULAR / 2026 + CONDENSED / UNICASE + DESIGNED BY CO +
+
+ +
+
+ 01 / LIVE SPECIMEN + ENTER TEXT +
+

+ THE HORIZON IS AN ALTAR +

+
+ +
+ A monochrome ceremonial basin descending toward a circular court and a vast white-ringed aperture. +
+

THE HORIZON

+

IS AN ALTAR

+
+ PROCESSIONAL / FIELD STUDY 01 +
+ +
+
+ 02 / CONSTRUCTION + ONE THOUSAND UNITS / THREE MATERIALS +
+

+ A letter is treated as a site. Vertical mass establishes authority, + thinner slabs set the rhythm, and a true circle cuts the aperture. +

+
+
+ 01 + H +

PYLONS CARRY THE WEIGHT

+

112-unit verticals make every word stand before it begins to move.

+
+
+ 02 + E +

SLABS ESTABLISH INTERVAL

+

76-unit horizontals divide the field without competing with the columns.

+
+
+ 03 + O +

THE COUNTER IS AN APERTURE

+

Round counters remain true circles even when the outer form is stretched.

+
+
+
+ +
+ +

A processional text composition

+

01A LONG

+

02WALK

+

03TO THE

+

04MACHINE

+
+ +
+
+ 03 / CHARACTER SET + UNICASE LATIN / NUMERALS / CORE PUNCTUATION +
+
+ {alphabet.split("").map((letter) => {letter})} +
+
+

{digits}

+

& @ ? ! + / |

+
+
+ +
+
+ 04 / OPERATING SCALE + DISPLAY ONLY / NEVER BODY COPY +
+
+

APERTURE
OPEN

+
+

A-03 / PROCESSION AUTHORIZED

+

HORIZON 045° / MONUMENT 12

+

03:14:15 / FIELD CONDITION BLACK

+
+
+
+ +
+
+

TAKE THE
LONG WAY.

+

+ Processional is a display face. Use it for titles, posters, banners, + coordinates, and other moments that can afford to become architecture. +

+
+ +
+ + +
+ ); +} diff --git a/src/index.tsx b/src/index.tsx index 110b4db..c82ea40 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,7 @@ import { CodeActivity } from "./components/code-activity.tsx"; import { Bluesky } from "./components/bluesky.tsx"; import { Travel } from "./components/travel.tsx"; import { TypeSpecimen } from "./components/type-specimen.tsx"; +import { ProcessionalSpecimen } from "./components/processional-specimen.tsx"; import { Artifacts } from "./components/artifacts.tsx"; import { TinkerGuide } from "./components/tinker-guide.tsx"; import { LoraGuide } from "./components/lora-guide.tsx"; @@ -291,6 +292,24 @@ app.get("/type", async (c) => { }); }); +app.get("/type/processional", async (c) => { + const stream = renderToReadableStream( + + + + ); + return c.body(stream, { + headers: { + "Content-Type": "text/html; charset=UTF-8", + "Transfer-Encoding": "chunked", + }, + }); +}); + app.get("/artifacts", async (c) => { const stream = renderToReadableStream(