feat: initial shared design system extracted from newerror

Foundation + chrome for the bchen.dev static sites: design tokens, Spectral /
Helvetica / IBM Plex Mono type, base + .prose typography, data-driven nav +
footer, and prefers-color-scheme + [data-theme] dark mode with a pre-paint
init. Colors are overridable CSS custom properties (defaults = newerror's
palette); each site sets only its own colors.css.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C96hwEU9cM7mJpqATiv7R5
This commit is contained in:
2026-06-30 21:20:21 -04:00
commit b61cb986f1
7 changed files with 584 additions and 0 deletions

110
README.md Normal file
View File

@@ -0,0 +1,110 @@
# static-design-system
Shared visual foundation for the bchen.dev static (Jekyll) sites. The canonical
design is **A New Error** (newerror.bchen.dev) — Spectral serif body, Helvetica
sans UI, IBM Plex Mono. Every site shares fonts, spacing, typography,
nav/footer chrome, and light/dark behavior, and keeps **only its own colors**.
## What's here
| File | Purpose |
|---|---|
| `base.css` | Tokens, font stacks, base + `.prose` typography, `.nav`/`.foot` chrome, mobile menu, icon swaps. Colors are CSS custom properties with defaults; sites override them. |
| `theme-init.js` | Pre-paint theme read (inlined in `<head>`, no flash). |
| `theme-toggle.js` | Theme-toggle + mobile-menu behavior (served + loaded at end of body). |
| `head.html` | Shared `<head>` contents (meta/OG, fonts, CSS links, inlined theme-init). |
| `nav.html` | Data-driven nav + mobile menu. |
| `footer.html` | Shared footer. |
Blog-specific components (ledger feed, archive, reader/prose-article chrome)
are **not** here — they live in newerror's own repo.
## Consuming it in a Jekyll site
This is designed for **legacy GitHub Pages** (Jekyll in safe mode: no custom
plugins, no symlinks). The trick is to mount this repo as a submodule *inside*
`_includes/` and deliver CSS/JS through tiny served wrapper files.
1. **Add the submodule at `_includes/ds`** (use the read-only **https** URL — a
legacy Pages build cannot fetch ssh submodules):
```sh
git submodule add https://gitea.bchen.dev/brendan/static-design-system.git _includes/ds
```
2. **Serve the CSS** — create `assets/ds.css`:
```liquid
---
---
{% include ds/base.css %}
```
3. **Serve the JS** — create `assets/ds.js`:
```liquid
---
---
{% include ds/theme-toggle.js %}
```
4. **Add your palette** — create `assets/colors.css` overriding only the color
tokens (`--bg --ink --muted --faint --rule --rule-2 --accent --accent-hover
--field`) for light, `:root[data-theme="dark"]`, and the
`prefers-color-scheme: dark` block. This loads *after* `ds.css`.
5. **Nav links** — create `_data/nav.yml`:
```yaml
- title: Guides
url: /guides
- title: FAQ
url: /support
```
6. **Layout** — in `_layouts/default.html`:
```liquid
<!DOCTYPE html>
<html lang="en">
<head>{% include ds/head.html %}</head>
<body>
<div class="page">
<div class="shell">{% include ds/nav.html %}</div>
<main><div class="shell">
{% if page.prose %}<div class="prose">{{ content }}</div>{% else %}{{ content }}{% endif %}
</div></main>
<div class="shell">{% include ds/footer.html %}</div>
</div>
<script src="/assets/ds.js"></script>
</body>
</html>
```
Add `prose: true` to the front matter of markdown content pages to get the
shared `.prose` typography; leave it off for bespoke landing pages.
## Config keys the includes read
| Key | Used by | Notes |
|---|---|---|
| `site.title` | nav mast, head | |
| `site.description`, `site.author`, `site.url` | head meta | |
| `site.data.nav` | nav | list of `{ title, url }` |
| `site.rss_url` | nav | optional; shows an RSS link when set |
| `site.author_url` | footer | optional; links the author name |
| `site.footer_years` | footer | optional; e.g. `20252026` |
## Notes / gotchas
- **Push over ssh, reference over https.** Consuming sites' `.gitmodules` must
use the https URL for the Pages build to fetch it; you can still push here
over ssh.
- **Keep `base.css` / the `.js` files Liquid-safe** — no `{{` or `{%`
sequences, since their contents pass through the Jekyll include tag.
- **Build-time dependency:** a consuming site's build now depends on
`gitea.bchen.dev` being reachable (public, valid TLS).
- **Local dev:** clone consumers with `--recurse-submodules`, or run
`git submodule update --init` after cloning.
- **Versioning:** tags (`v1`, `v2`, …) mark known-good states; a consuming
site pins a commit and bumps deliberately.

316
base.css Normal file
View File

@@ -0,0 +1,316 @@
/* =====================================================================
static-design-system — base.css
Shared visual foundation for the bchen.dev static sites.
Canonical design: "A New Error" (newerror.bchen.dev) — the "Ledger"
direction: Spectral serif body, Helvetica sans UI, IBM Plex Mono.
HOW COLOR WORKS
Every color is a CSS custom property. The values below are DEFAULTS
(newerror's palette). Each consuming site overrides ONLY these color
tokens in its own colors.css, which loads AFTER this file. Fonts,
spacing, typography, nav/footer chrome, and dark-mode behavior are
shared and should NOT be re-declared per site.
IMPORTANT: keep this file Liquid-safe. Its contents are passed through the
Jekyll include tag by each site's served wrapper (assets/ds.css), so do NOT
introduce Liquid delimiters (double-brace or brace-percent sequences).
Plain CSS braces are fine.
===================================================================== */
:root {
/* Type */
--serif: "Spectral", Georgia, "Times New Roman", serif;
--sans: "Helvetica Neue", Helvetica, Arial, sans-serif;
--mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
/* Layout */
--measure: 760px; /* standard content column */
--read: 680px; /* narrower reading column (articles) */
--pad: 34px;
--gutter: 104px; /* date column width (ledger layouts) */
}
/* ---- Color tokens — DEFAULTS (newerror). Override per site in colors.css ---- */
:root, :root[data-theme="light"] {
--bg: #FAFBFC;
--ink: #1B2027;
--muted: #69717C;
--faint: #99A1AC;
--rule: #E6E9EF;
--rule-2: #b6b9be;
--accent: #1463C7;
--accent-hover: #0F4FA3;
--field: #F1F3F6;
}
:root[data-theme="dark"] {
--bg: #14171C;
--ink: #E2E6EC;
--muted: #9AA2AD;
--faint: #69717C;
--rule: #262B32;
--rule-2: #5e6670;
--accent: #6FA9F2;
--accent-hover: #97C1F8;
--field: #1D2128;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14171C;
--ink: #E2E6EC;
--muted: #9AA2AD;
--faint: #69717C;
--rule: #262B32;
--rule-2: #5e6670;
--accent: #6FA9F2;
--accent-hover: #97C1F8;
--field: #1D2128;
}
}
/* =====================================================================
Base
===================================================================== */
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--serif);
font-size: 18px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
transition: background-color 0.25s ease, color 0.25s ease;
}
::selection { background: color-mix(in oklab, var(--accent) 22%, transparent); }
a { color: inherit; }
/* ---- shell / responsive width ---- */
.shell {
width: 100%;
max-width: var(--measure);
margin-inline: auto;
padding-inline: var(--pad);
}
.page { min-height: 100vh; display: flex; flex-direction: column; }
.page > main { flex: 1 0 auto; }
/* =====================================================================
Top navigation
===================================================================== */
.nav {
font-family: var(--sans);
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 38px 0 18px;
border-bottom: 1px solid var(--rule);
}
.nav.minimal { border-bottom: none; padding-bottom: 0; }
.mast {
font-family: var(--serif);
font-size: 20px;
font-weight: 600;
letter-spacing: 0.005em;
color: var(--ink);
text-decoration: none;
white-space: nowrap;
}
.mast:hover { color: var(--accent); }
.nav-links {
display: flex;
align-items: baseline;
gap: 26px;
}
.nav-links a, .iconbtn {
font-family: var(--sans);
font-size: 13.5px;
letter-spacing: 0.01em;
color: var(--muted);
text-decoration: none;
cursor: pointer;
background: none;
border: none;
padding: 0;
}
.nav-links a:hover, .iconbtn:hover { color: var(--accent); }
.nav-links a.active { color: var(--ink); }
.nav-tools { display: flex; align-items: center; gap: 18px; }
.iconbtn {
display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 6px;
color: var(--muted);
}
.iconbtn:hover { color: var(--accent); background: var(--field); }
.iconbtn svg { width: 15px; height: 15px; display: block; }
/* hamburger — hidden on desktop */
.hamburger { display: none; }
.mobile-menu { display: none; }
/* =====================================================================
Footer
===================================================================== */
.foot {
font-family: var(--sans);
border-top: 1px solid var(--rule);
margin-top: 20px;
padding: 22px 0 40px;
display: flex; align-items: center; justify-content: space-between;
gap: 16px;
font-size: 12.5px;
color: var(--faint);
flex: 0 0 auto;
}
.foot a { color: var(--muted); text-decoration: none; }
.foot a:hover { color: var(--accent); }
.foot .rss-link { display: inline-flex; align-items: center; gap: 7px; }
/* =====================================================================
Prose — reusable content typography (markdown pages, articles)
Wrap rendered content in <div class="prose"> to opt in.
===================================================================== */
.prose { font-size: 19.5px; line-height: 1.68; color: var(--ink); }
.prose > :first-child { margin-top: 0; }
.prose p { margin: 0 0 1.3em; text-wrap: pretty; }
.prose h1 {
font-family: var(--serif);
font-size: 32px; line-height: 1.16; font-weight: 600;
letter-spacing: -0.018em; color: var(--ink);
margin: 0 0 0.5em; text-wrap: pretty;
}
.prose h2 {
font-family: var(--serif);
font-size: 25px; font-weight: 600; line-height: 1.25;
letter-spacing: -0.01em;
margin: 2em 0 0.6em;
}
.prose h3 {
font-family: var(--serif);
font-size: 21px; font-weight: 600;
margin: 1.7em 0 0.5em;
}
.prose a {
color: var(--ink);
text-decoration: underline;
text-decoration-color: var(--rule-2);
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
.prose a:hover { color: var(--accent); text-decoration-color: var(--accent); }
.prose strong { font-weight: 600; }
.prose em { font-style: italic; }
.prose blockquote {
margin: 1.6em 0;
padding-left: 22px;
border-left: 2px solid var(--rule-2);
color: var(--muted);
font-style: italic;
}
.prose blockquote p:last-child { margin-bottom: 0; }
.prose ul, .prose ol { margin: 0 0 1.3em; padding-left: 1.3em; }
.prose li { margin: 0.3em 0; padding-left: 0.2em; }
.prose li::marker { color: var(--faint); }
.prose hr {
border: none; height: 0;
border-top: 1px solid var(--rule);
margin: 2.4em auto;
width: 40%;
}
.prose code {
font-family: var(--mono);
font-size: 0.82em;
background: var(--field);
padding: 0.12em 0.4em;
border-radius: 4px;
}
.prose pre {
font-family: var(--mono);
font-size: 14px; line-height: 1.55;
background: var(--field);
padding: 18px 20px;
border-radius: 8px;
overflow-x: auto;
margin: 1.5em 0;
}
.prose pre code { background: none; padding: 0; font-size: inherit; }
.prose figure { margin: 2em 0; }
.prose figure img { display: block; width: 100%; height: auto; border-radius: 3px; }
.prose figcaption {
font-family: var(--sans);
font-size: 12.5px; color: var(--faint);
margin-top: 10px; text-align: left;
}
.prose img { max-width: 100%; height: auto; }
/* =====================================================================
Mobile
===================================================================== */
@media (max-width: 640px) {
:root { --pad: 22px; --gutter: 74px; }
body { font-size: 17px; }
.nav-links { display: none; }
.hamburger { display: inline-flex; }
.mobile-menu {
display: none;
font-family: var(--sans);
}
.mobile-menu.open {
display: flex; flex-direction: column;
gap: 2px;
padding: 8px 0 14px;
border-bottom: 1px solid var(--rule);
}
.mobile-menu a, .mobile-menu button {
font-family: var(--sans);
text-align: left;
background: none; border: none; cursor: pointer;
color: var(--ink);
font-size: 16px;
padding: 12px 4px;
text-decoration: none;
border-radius: 6px;
}
.mobile-menu a:hover, .mobile-menu button:hover { color: var(--accent); }
.mobile-menu .row { display: flex; align-items: center; gap: 10px; }
.prose { font-size: 18.5px; }
.prose h1 { font-size: 30px; }
}
/* =====================================================================
Icon swaps (theme toggle + hamburger), driven by [data-theme] / .is-open
===================================================================== */
.theme-toggle .ico-sun { display: none; }
.theme-toggle .ico-moon { display: block; }
:root[data-theme="dark"] .theme-toggle .ico-moon { display: none; }
:root[data-theme="dark"] .theme-toggle .ico-sun { display: block; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .theme-toggle .ico-moon { display: none; }
:root:not([data-theme="light"]) .theme-toggle .ico-sun { display: block; }
}
.hamburger .ico-close { display: none; }
.hamburger.is-open .ico-bars { display: none; }
.hamburger.is-open .ico-close { display: block; }
/* reduce motion */
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}

9
footer.html Normal file
View File

@@ -0,0 +1,9 @@
{%- comment -%}
static-design-system — footer.html
Shared footer (chrome). Place inside a .shell.
Uses site.author, optional site.author_url (links the name), and optional
site.footer_years (e.g. "20252026").
{%- endcomment -%}
<footer class="foot">
<span>&copy; {% if site.author_url %}<a href="{{ site.author_url }}">{{ site.author }}</a>{% else %}{{ site.author }}{% endif %}{% if site.footer_years %} {{ site.footer_years }}{% endif %}.</span>
</footer>

39
head.html Normal file
View File

@@ -0,0 +1,39 @@
{%- comment -%}
static-design-system — head.html
Shared contents of <head>. The consuming site wraps it:
<head>{% include ds/head.html %} ...site-specific tags... </head>
Expects site.title, site.description, site.author, site.url.
Loads the fonts, the shared CSS (/assets/ds.css), then the site's color
overrides (/assets/colors.css) — order matters — and inlines the pre-paint
theme script to avoid a flash.
{%- endcomment -%}
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ page.title | default: site.title }}</title>
<meta name="description" content="{{ page.description | default: site.description }}" />
<meta name="author" content="{{ site.author }}" />
<!-- Open Graph -->
<meta property="og:title" content="{{ page.title | default: site.title }}" />
<meta property="og:description" content="{{ page.description | default: site.description }}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ site.url }}{{ page.url | default: '/' }}" />
<meta property="og:site_name" content="{{ site.title }}" />
<meta property="og:locale" content="en_US" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ page.title | default: site.title }}" />
<meta name="twitter:description" content="{{ page.description | default: site.description }}" />
<!-- Fonts: Spectral (serif) + IBM Plex Mono. Helvetica sans is a system font. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,400;0,500;0,600;1,400;1,500&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
<!-- Shared design system, then site color overrides (order matters). -->
<link rel="stylesheet" href="/assets/ds.css" />
<link rel="stylesheet" href="/assets/colors.css" />
<!-- Pre-paint theme (avoids flash-of-wrong-theme) -->
<script>{% include ds/theme-init.js %}</script>

60
nav.html Normal file
View File

@@ -0,0 +1,60 @@
{%- comment -%}
static-design-system — nav.html
Data-driven top navigation + mobile menu (chrome). Place inside a .shell.
Links come from site.data.nav (a list of { title, url }). An RSS link is
shown only if site.rss_url is set. The theme toggle and hamburger are always
present; behavior is wired by /assets/ds.js (ds/theme-toggle.js).
{%- endcomment -%}
<nav class="nav">
<a class="mast" href="/">{{ site.title }}</a>
<div class="nav-tools">
<div class="nav-links">
{%- for item in site.data.nav %}
<a href="{{ item.url }}"{% if page.url == item.url %} class="active"{% endif %}>{{ item.title }}</a>
{%- endfor %}
{%- if site.rss_url %}
<a class="iconbtn" href="{{ site.rss_url }}" title="RSS feed" aria-label="RSS feed"><svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="3.4" cy="12.6" r="1.55" fill="currentColor" />
<path d="M2.2 7.1a6.7 6.7 0 0 1 6.7 6.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
<path d="M2.2 3.2a10.6 10.6 0 0 1 10.6 10.6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
</svg></a>
{%- endif %}
</div>
<button class="iconbtn theme-toggle" type="button" aria-label="Toggle color theme" title="Toggle theme">
<svg class="ico-moon" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M13 9.4A5.4 5.4 0 0 1 6.6 3 5.6 5.6 0 1 0 13 9.4Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" />
</svg>
<svg class="ico-sun" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="3.1" stroke="currentColor" stroke-width="1.3" />
<line x1="8" y1="0.6" x2="8" y2="2.4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<line x1="8" y1="13.6" x2="8" y2="15.4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<line x1="0.6" y1="8" x2="2.4" y2="8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<line x1="13.6" y1="8" x2="15.4" y2="8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<line x1="2.7" y1="2.7" x2="4" y2="4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<line x1="12" y1="12" x2="13.3" y2="13.3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<line x1="13.3" y1="2.7" x2="12" y2="4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<line x1="4" y1="12" x2="2.7" y2="13.3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</button>
<button class="iconbtn hamburger" type="button" aria-label="Menu" aria-expanded="false">
<svg class="ico-bars" width="16" height="16" viewBox="0 0 16 16"><path d="M2 4.5h12M2 8h12M2 11.5h12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
<svg class="ico-close" width="16" height="16" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</div>
</nav>
<div class="mobile-menu">
{%- for item in site.data.nav %}
<a href="{{ item.url }}">{{ item.title }}</a>
{%- endfor %}
{%- if site.rss_url %}
<a class="row" href="{{ site.rss_url }}"><svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="3.4" cy="12.6" r="1.55" fill="currentColor" />
<path d="M2.2 7.1a6.7 6.7 0 0 1 6.7 6.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
<path d="M2.2 3.2a10.6 10.6 0 0 1 10.6 10.6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
</svg> <span>RSS feed</span></a>
{%- endif %}
</div>

14
theme-init.js Normal file
View File

@@ -0,0 +1,14 @@
/* static-design-system — theme-init.js
Pre-paint: apply the stored [data-theme] before first paint so there is no
flash. This file is inlined into <head> by ds/head.html (see head.html); it
must run before the stylesheet paints, so keep it tiny and synchronous.
NOTE: no Liquid delimiters allowed here (this content is passed through the
Jekyll include tag). Storage key must match theme-toggle.js. */
(function () {
try {
var t = localStorage.getItem("bchen-theme");
if (t === "light" || t === "dark") {
document.documentElement.setAttribute("data-theme", t);
}
} catch (e) {}
})();

36
theme-toggle.js Normal file
View File

@@ -0,0 +1,36 @@
/* static-design-system — theme-toggle.js
Shared chrome behavior: color-theme toggle + mobile (hamburger) menu.
Served to each site via a wrapper (assets/ds.js) and loaded at end of body.
Storage key must match ds/theme-init.js. */
(function () {
"use strict";
var THEME_KEY = "bchen-theme";
function effectiveTheme() {
var set = document.documentElement.getAttribute("data-theme");
if (set) return set;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
/* ---- theme toggle ---- */
var toggleBtn = document.querySelector(".theme-toggle");
if (toggleBtn) {
toggleBtn.addEventListener("click", function () {
var next = effectiveTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try { localStorage.setItem(THEME_KEY, next); } catch (e) {}
});
}
/* ---- mobile menu ---- */
var hamburger = document.querySelector(".hamburger");
var mobileMenu = document.querySelector(".mobile-menu");
if (hamburger && mobileMenu) {
hamburger.addEventListener("click", function () {
var open = mobileMenu.classList.toggle("open");
hamburger.classList.toggle("is-open", open);
hamburger.setAttribute("aria-expanded", open ? "true" : "false");
});
}
})();