commit b61cb986f1d6f8d8e8dd121b359234b486b3af0b Author: Brendan Chen Date: Tue Jun 30 21:20:21 2026 -0400 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) Claude-Session: https://claude.ai/code/session_01C96hwEU9cM7mJpqATiv7R5 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5b7013 --- /dev/null +++ b/README.md @@ -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 ``, no flash). | +| `theme-toggle.js` | Theme-toggle + mobile-menu behavior (served + loaded at end of body). | +| `head.html` | Shared `` 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 + + + {% include ds/head.html %} + +
+
{% include ds/nav.html %}
+
+ {% if page.prose %}
{{ content }}
{% else %}{{ content }}{% endif %} +
+
{% include ds/footer.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. `2025–2026` | + +## 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. diff --git a/base.css b/base.css new file mode 100644 index 0000000..ab8ce51 --- /dev/null +++ b/base.css @@ -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
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; } +} diff --git a/footer.html b/footer.html new file mode 100644 index 0000000..c3883d4 --- /dev/null +++ b/footer.html @@ -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. "2025–2026"). +{%- endcomment -%} +
+ © {% if site.author_url %}{{ site.author }}{% else %}{{ site.author }}{% endif %}{% if site.footer_years %} {{ site.footer_years }}{% endif %}. +
diff --git a/head.html b/head.html new file mode 100644 index 0000000..ee9cf6d --- /dev/null +++ b/head.html @@ -0,0 +1,39 @@ +{%- comment -%} + static-design-system — head.html + Shared contents of . The consuming site wraps it: + {% include ds/head.html %} ...site-specific tags... + 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 -%} + + + +{{ page.title | default: site.title }} + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nav.html b/nav.html new file mode 100644 index 0000000..03e02d8 --- /dev/null +++ b/nav.html @@ -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 -%} + + +
+ {%- for item in site.data.nav %} + {{ item.title }} + {%- endfor %} + {%- if site.rss_url %} + RSS feed + {%- endif %} +
diff --git a/theme-init.js b/theme-init.js new file mode 100644 index 0000000..0305a6a --- /dev/null +++ b/theme-init.js @@ -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 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) {} +})(); diff --git a/theme-toggle.js b/theme-toggle.js new file mode 100644 index 0000000..90c2338 --- /dev/null +++ b/theme-toggle.js @@ -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"); + }); + } +})();