diff --git a/lib/stack/__init__.py b/lib/stack/__init__.py
index e50c65c0..9ad990fa 100644
--- a/lib/stack/__init__.py
+++ b/lib/stack/__init__.py
@@ -11,7 +11,7 @@
from .secrets import TomlSecretStore
from .hooks import HookResolver, StackContext, build_hook_ctx
from .output import SilentOutput, CollectorOutput
-from .users import user_id
+from .users import user_id, family_display_name, family_plural
from .ai import resolve_model
from . import docker
from .cli import CLI
@@ -29,6 +29,8 @@
"SilentOutput",
"CollectorOutput",
"user_id",
+ "family_display_name",
+ "family_plural",
"resolve_model",
"docker",
"CLI",
diff --git a/lib/stack/installer_v2.py b/lib/stack/installer_v2.py
index 6755be3d..3dbb1261 100644
--- a/lib/stack/installer_v2.py
+++ b/lib/stack/installer_v2.py
@@ -16,6 +16,7 @@
clear, nl, out, dim, bold, done, warn,
heading, section, banner, rule, Spinner, ask, confirm,
)
+from .users import family_plural
# ── Helpers ──────────────────────────────────────────────────────────────────
@@ -393,8 +394,7 @@ def wizard():
nl()
rule()
nl()
- plural = family_name if family_name.lower().endswith("s") else family_name + "s"
- bold(f"The {ORANGE}{plural}{RESET}")
+ bold(f"The {ORANGE}{family_plural(family_name)}{RESET}")
nl()
for u in users:
uid = user_id(u)
@@ -507,8 +507,7 @@ def wizard():
clear()
nl()
out(f"{ORANGE}{BOLD}famstack{RESET}")
- plural = family_name if family_name.lower().endswith("s") else family_name + "s"
- out(f"{GREEN}The {plural} are online{RESET}")
+ out(f"{GREEN}The {family_plural(family_name)} are online{RESET}")
from .users import user_id as uid2
admin_id = uid2(admin)
diff --git a/lib/stack/stack.py b/lib/stack/stack.py
index 08ea6359..5517d3e4 100644
--- a/lib/stack/stack.py
+++ b/lib/stack/stack.py
@@ -248,8 +248,16 @@ def _build_template_vars(self) -> dict:
# Tech admin — internal service account for all stacklets
from .users import (
TECH_ADMIN_USERNAME, TECH_ADMIN_EMAIL,
- get_admin_password, load_users, user_id,
+ family_display_name, get_admin_password, load_users, user_id,
)
+
+ # What the household calls itself ("The Simpsons"), for surfaces
+ # the family actually reads. Empty on instances installed before
+ # stack_owner existed, so anything using it needs a fallback.
+ template_vars["family_display_name"] = family_display_name(
+ self._cfg("core", "stack_owner", "")
+ )
+
template_vars["admin_username"] = TECH_ADMIN_USERNAME
template_vars["admin_email"] = TECH_ADMIN_EMAIL
template_vars["admin_password"] = get_admin_password(self.secrets) or ""
diff --git a/lib/stack/users.py b/lib/stack/users.py
index 07b9a591..ff64d5f0 100644
--- a/lib/stack/users.py
+++ b/lib/stack/users.py
@@ -14,6 +14,40 @@
TECH_ADMIN_EMAIL = "stackadmin@home.local"
+# ── Naming the household ──────────────────────────────────────────────
+#
+# stack.toml's [core] stack_owner is the surname the installer asked
+# for. It reaches the family on the installer's closing line and in the
+# title of their wiki, so both go through here and spell it the same.
+#
+# "Family name" gets answered two ways: one person types "Simpson", the
+# next types "Simpsons". Both mean the same household, and only one of
+# them needs an s adding.
+
+
+def family_plural(owner: str | None) -> str:
+ """The surname as you would address the whole household: "Simpsons".
+
+ Returns "" when no owner is configured, so callers can fall back to
+ something generic rather than render a name with a hole in it.
+ Instances predating stack_owner still run, so that is a live path
+ rather than a hypothetical.
+ """
+ name = (owner or "").strip()
+ if not name:
+ return ""
+ return name if name.lower().endswith("s") else name + "s"
+
+
+def family_display_name(owner: str | None) -> str:
+ """The household as it appears on screen: "The Simpsons".
+
+ Empty when no owner is configured. See `family_plural`.
+ """
+ plural = family_plural(owner)
+ return f"The {plural}" if plural else ""
+
+
def load_users(root: Path) -> list[dict]:
"""Load all users from users.toml."""
path = root / "users.toml"
diff --git a/stacklets/memory/Dockerfile b/stacklets/memory/Dockerfile
index 876f88bf..504ebf04 100644
--- a/stacklets/memory/Dockerfile
+++ b/stacklets/memory/Dockerfile
@@ -34,12 +34,50 @@ WORKDIR /app
RUN git clone --depth 1 --branch "${QUARTZ_TAG}" https://github.com/jackyzha0/quartz.git . \
&& npm ci
+# Mermaid, served from our own host. Upstream's diagram renderer
+# imports itself from cdnjs at page-view time; the overlay below points
+# it at /static/mermaid instead, and this is where that comes from.
+# Same version upstream pins, so the swap is location-only.
+#
+# Only the minified ESM graph is copied. The full dist is 61MB, mostly
+# type declarations, docs and source maps a browser never asks for; the
+# entry plus its chunks is 2.5MB, and the browser fetches only the
+# chunks a given diagram type actually needs.
+RUN npm install --no-save --no-audit --no-fund mermaid@11.4.0 \
+ && mkdir -p quartz/static/mermaid/chunks \
+ && cp node_modules/mermaid/dist/mermaid.esm.min.mjs quartz/static/mermaid/ \
+ && cp -r node_modules/mermaid/dist/chunks/mermaid.esm.min quartz/static/mermaid/chunks/ \
+ && find quartz/static/mermaid -name '*.map' -delete
+
# Overlay our config on top of the upstream defaults. `quartz/*.ts`
# in this stacklet directory is the source of truth for site title,
# theme, plugins, and the "edit on Forgejo" link template.
COPY quartz/quartz.config.ts ./quartz.config.ts
COPY quartz/quartz.layout.ts ./quartz.layout.ts
+# The famstack look. `custom.scss` is Quartz's sanctioned override slot
+# (upstream ships it near-empty for exactly this) and `fonts.scss`
+# carries the @font-face rules, because `fontOrigin: "local"` makes
+# Quartz emit no font CSS of its own. `static/` holds the woff2 files,
+# which the Static emitter copies into the built site.
+COPY quartz/custom.scss ./quartz/styles/custom.scss
+COPY quartz/fonts.scss ./quartz/styles/fonts.scss
+COPY quartz/static/ ./quartz/static/
+
+# Two upstream components we keep our own copy of, each to remove a
+# third-party request no config option can reach. Both carry a comment
+# saying what changed and why. On a QUARTZ_TAG bump, re-copy them from
+# the new tag and re-apply the edit rather than merging into these.
+COPY quartz/components/Head.tsx ./quartz/components/Head.tsx
+COPY quartz/components/scripts/mermaid.inline.ts ./quartz/components/scripts/mermaid.inline.ts
+
+# Our own components: the sidebar lockup and the home-page greeting.
+# These are additions, not overrides, so a tag bump leaves them alone.
+# quartz.layout.ts imports them directly, which is why upstream's
+# components/index.ts does not need overlaying.
+COPY quartz/components/FamstackTitle.tsx ./quartz/components/FamstackTitle.tsx
+COPY quartz/components/Welcome.tsx ./quartz/components/Welcome.tsx
+
# The entrypoint serves the vault with Quartz — a pure view. The
# curator sidecar owns the git pull. See quartz/entrypoint.sh.
COPY quartz/entrypoint.sh /usr/local/bin/wiki-entrypoint.sh
diff --git a/stacklets/memory/docker-compose.yml b/stacklets/memory/docker-compose.yml
index 97473d7e..c968d951 100644
--- a/stacklets/memory/docker-compose.yml
+++ b/stacklets/memory/docker-compose.yml
@@ -42,6 +42,9 @@ services:
WIKI_HOST: ${WIKI_HOST}
WIKI_IP: ${WIKI_IP}
WIKI_PORT: "42070"
+ # The household's own name for the site. Blank is expected on
+ # instances predating stack_owner, and quartz.config.ts falls back.
+ WIKI_TITLE: ${WIKI_TITLE:-}
ports:
# Container's Quartz preview server listens on 8080; we publish
# on the stacklet's declared port (42070). PORT_BIND_IP is set
diff --git a/stacklets/memory/quartz/components/FamstackTitle.tsx b/stacklets/memory/quartz/components/FamstackTitle.tsx
new file mode 100644
index 00000000..be9b2c43
--- /dev/null
+++ b/stacklets/memory/quartz/components/FamstackTitle.tsx
@@ -0,0 +1,111 @@
+import { pathToRoot } from "../util/path"
+import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
+import { classNames } from "../util/lang"
+import { i18n } from "../i18n"
+
+// NEW COMPONENT (not an upstream override) — the sidebar lockup that
+// replaces PageTitle.
+//
+// Two names, deliberately in this order. The wiki belongs to the
+// family, so its own name leads; famstack is the software underneath
+// and sits below in small type, the way a maker's mark does. Getting
+// that backwards would put our branding on their memories.
+//
+// The wordmark repeats famstack.dev's: "fam" in slate, "stack" in lava,
+// with the a lifted onto two teal dots. It is built from styled spans
+// rather than an image so it inherits the page's colours and stays
+// sharp at any zoom, and so there is no asset to keep in sync.
+const FamstackTitle: QuartzComponent = ({ fileData, cfg, displayClass }: QuartzComponentProps) => {
+ const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
+ const baseDir = pathToRoot(fileData.slug!)
+ return (
+
+ )
+}
+
+FamstackTitle.css = `
+.famstack-title {
+ display: flex;
+ flex-direction: column;
+ /* Room for the title's underline to sit clear of the wordmark. */
+ gap: 0.5rem;
+}
+
+.famstack-title .page-title {
+ font-size: 1.6rem;
+ margin: 0;
+ font-family: var(--titleFont);
+ font-weight: 600;
+ letter-spacing: -0.03em;
+ line-height: 1.1;
+}
+
+/* Underlined, because it is the way back to the front page from
+ anywhere and should look like somewhere you can go. Drawn as a
+ border rather than text-decoration so it sits clear of the
+ descenders in a name like Simpsons. */
+.famstack-title .page-title > a {
+ border-bottom: 2px solid var(--secondary);
+ padding-bottom: 2px;
+ transition: border-color 0.2s ease;
+}
+
+.famstack-title .page-title > a:hover {
+ border-bottom-color: var(--tertiary);
+}
+
+.famstack-title .fs-brandmark {
+ font-family: var(--bodyFont);
+ font-weight: 600;
+ font-size: 0.78rem;
+ letter-spacing: 0.01em;
+ line-height: 1;
+ color: var(--darkgray);
+ user-select: none;
+}
+
+.famstack-title .fs-brand-accent {
+ color: var(--tertiary);
+}
+
+/* The raised a, standing on two teal dots. */
+.famstack-title .fs-brand-a {
+ position: relative;
+ display: inline-block;
+ vertical-align: baseline;
+ top: -0.2em;
+}
+
+.famstack-title .fs-brand-a::before,
+.famstack-title .fs-brand-a::after {
+ content: "";
+ position: absolute;
+ width: 0.15em;
+ height: 0.15em;
+ border-radius: 50%;
+ background: var(--secondary);
+ bottom: -0.1em;
+}
+
+.famstack-title .fs-brand-a::before { left: 0.08em; }
+.famstack-title .fs-brand-a::after { right: 0.1em; }
+
+/* On mobile the sidebar becomes a header row and space is tight, so
+ the maker's mark steps aside and the wiki name carries it alone. */
+@media all and (max-width: 800px) {
+ .famstack-title .fs-brandmark { display: none; }
+ .famstack-title .page-title { font-size: 1.3rem; }
+}
+`
+
+export default (() => FamstackTitle) satisfies QuartzComponentConstructor
diff --git a/stacklets/memory/quartz/components/Head.tsx b/stacklets/memory/quartz/components/Head.tsx
new file mode 100644
index 00000000..a6ae0a60
--- /dev/null
+++ b/stacklets/memory/quartz/components/Head.tsx
@@ -0,0 +1,120 @@
+import { i18n } from "../i18n"
+import { FullSlug, getFileExtension, joinSegments, pathToRoot } from "../util/path"
+import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources"
+import { googleFontHref, googleFontSubsetHref } from "../util/theme"
+import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
+import { unescapeHTML } from "../util/escape"
+import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage"
+
+// OVERLAY — a verbatim copy of quartz/components/Head.tsx at v4.5.2
+// with exactly one line removed:
+//
+//
+//
+// It sat outside every conditional, so it fired on every page load of
+// the family wiki no matter how the theme was configured. A preconnect
+// is not a passive hint: it opens a real TCP and TLS connection, which
+// tells Cloudflare the household's IP address and when somebody is
+// reading. No config switch reaches it, so the file is the only lever.
+//
+// Copying an upstream component means owning it. When QUARTZ_TAG moves,
+// re-copy this file from the new tag and re-apply the deletion rather
+// than merging into this copy — the diff is one line and it is easier
+// to redo than to reconcile.
+export default (() => {
+ const Head: QuartzComponent = ({
+ cfg,
+ fileData,
+ externalResources,
+ ctx,
+ }: QuartzComponentProps) => {
+ const titleSuffix = cfg.pageTitleSuffix ?? ""
+ const title =
+ (fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
+ const description =
+ fileData.frontmatter?.socialDescription ??
+ fileData.frontmatter?.description ??
+ unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
+
+ const { css, js, additionalHead } = externalResources
+
+ const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
+ const path = url.pathname as FullSlug
+ const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!)
+ const iconPath = joinSegments(baseDir, "static/icon.png")
+
+ // Url of current page
+ const socialUrl =
+ fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!)
+
+ const usesCustomOgImage = ctx.cfg.plugins.emitters.some(
+ (e) => e.name === CustomOgImagesEmitterName,
+ )
+ const ogImageDefaultPath = `https://${cfg.baseUrl}/static/og-image.png`
+
+ return (
+
+ {title}
+
+ {cfg.theme.cdnCaching && cfg.theme.fontOrigin === "googleFonts" && (
+ <>
+
+
+
+ {cfg.theme.typography.title && (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {!usesCustomOgImage && (
+ <>
+
+
+
+
+ >
+ )}
+
+ {cfg.baseUrl && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+
+
+ {css.map((resource) => CSSResourceToStyleElement(resource, true))}
+ {js
+ .filter((resource) => resource.loadTime === "beforeDOMReady")
+ .map((res) => JSResourceToScriptElement(res, true))}
+ {additionalHead.map((resource) => {
+ if (typeof resource === "function") {
+ return resource(fileData)
+ } else {
+ return resource
+ }
+ })}
+
+ )
+ }
+
+ return Head
+}) satisfies QuartzComponentConstructor
diff --git a/stacklets/memory/quartz/components/Welcome.tsx b/stacklets/memory/quartz/components/Welcome.tsx
new file mode 100644
index 00000000..2231f031
--- /dev/null
+++ b/stacklets/memory/quartz/components/Welcome.tsx
@@ -0,0 +1,50 @@
+import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
+import { classNames } from "../util/lang"
+
+// NEW COMPONENT (not an upstream override) — the greeting on the home
+// page, and only there. The layout gates it on the index slug.
+//
+// It lives in the layout rather than in the vault's index.md on
+// purpose. index.md is a generated page: the curator rewrites it on
+// every sweep from what the vault actually contains, so a welcome
+// written into it would survive exactly until the next rebuild. Chrome
+// belongs in the chrome.
+//
+// The copy is English because quartz.config.ts pins locale to en-US.
+// When the wiki is localised, this string moves with the locale rather
+// than staying here.
+const Welcome: QuartzComponent = ({ displayClass }: QuartzComponentProps) => {
+ return (
+
+
+ Everything the household has kept: documents, the notes about them, and the people they
+ belong to.
+
+
Search from the sidebar, or start with a name.
+
+ )
+}
+
+Welcome.css = `
+.fs-welcome {
+ margin: 0.75rem 0 0.5rem;
+ padding: 0.9rem 1.1rem;
+ border-left: 3px solid var(--tertiary);
+ background: rgba(240, 125, 69, 0.06);
+ border-radius: 0 8px 8px 0;
+}
+
+.fs-welcome > p {
+ margin: 0;
+ font-size: 0.95rem;
+ line-height: 1.5rem;
+}
+
+.fs-welcome > p.fs-welcome-hint {
+ margin-top: 0.2rem;
+ font-size: 0.85rem;
+ color: var(--gray);
+}
+`
+
+export default (() => Welcome) satisfies QuartzComponentConstructor
diff --git a/stacklets/memory/quartz/components/scripts/mermaid.inline.ts b/stacklets/memory/quartz/components/scripts/mermaid.inline.ts
new file mode 100644
index 00000000..18668f4f
--- /dev/null
+++ b/stacklets/memory/quartz/components/scripts/mermaid.inline.ts
@@ -0,0 +1,273 @@
+import { registerEscapeHandler, removeAllChildren } from "./util"
+
+interface Position {
+ x: number
+ y: number
+}
+
+class DiagramPanZoom {
+ private isDragging = false
+ private startPan: Position = { x: 0, y: 0 }
+ private currentPan: Position = { x: 0, y: 0 }
+ private scale = 1
+ private readonly MIN_SCALE = 0.5
+ private readonly MAX_SCALE = 3
+
+ cleanups: (() => void)[] = []
+
+ constructor(
+ private container: HTMLElement,
+ private content: HTMLElement,
+ ) {
+ this.setupEventListeners()
+ this.setupNavigationControls()
+ this.resetTransform()
+ }
+
+ private setupEventListeners() {
+ // Mouse drag events
+ const mouseDownHandler = this.onMouseDown.bind(this)
+ const mouseMoveHandler = this.onMouseMove.bind(this)
+ const mouseUpHandler = this.onMouseUp.bind(this)
+ const resizeHandler = this.resetTransform.bind(this)
+
+ this.container.addEventListener("mousedown", mouseDownHandler)
+ document.addEventListener("mousemove", mouseMoveHandler)
+ document.addEventListener("mouseup", mouseUpHandler)
+ window.addEventListener("resize", resizeHandler)
+
+ this.cleanups.push(
+ () => this.container.removeEventListener("mousedown", mouseDownHandler),
+ () => document.removeEventListener("mousemove", mouseMoveHandler),
+ () => document.removeEventListener("mouseup", mouseUpHandler),
+ () => window.removeEventListener("resize", resizeHandler),
+ )
+ }
+
+ cleanup() {
+ for (const cleanup of this.cleanups) {
+ cleanup()
+ }
+ }
+
+ private setupNavigationControls() {
+ const controls = document.createElement("div")
+ controls.className = "mermaid-controls"
+
+ // Zoom controls
+ const zoomIn = this.createButton("+", () => this.zoom(0.1))
+ const zoomOut = this.createButton("-", () => this.zoom(-0.1))
+ const resetBtn = this.createButton("Reset", () => this.resetTransform())
+
+ controls.appendChild(zoomOut)
+ controls.appendChild(resetBtn)
+ controls.appendChild(zoomIn)
+
+ this.container.appendChild(controls)
+ }
+
+ private createButton(text: string, onClick: () => void): HTMLButtonElement {
+ const button = document.createElement("button")
+ button.textContent = text
+ button.className = "mermaid-control-button"
+ button.addEventListener("click", onClick)
+ window.addCleanup(() => button.removeEventListener("click", onClick))
+ return button
+ }
+
+ private onMouseDown(e: MouseEvent) {
+ if (e.button !== 0) return // Only handle left click
+ this.isDragging = true
+ this.startPan = { x: e.clientX - this.currentPan.x, y: e.clientY - this.currentPan.y }
+ this.container.style.cursor = "grabbing"
+ }
+
+ private onMouseMove(e: MouseEvent) {
+ if (!this.isDragging) return
+ e.preventDefault()
+
+ this.currentPan = {
+ x: e.clientX - this.startPan.x,
+ y: e.clientY - this.startPan.y,
+ }
+
+ this.updateTransform()
+ }
+
+ private onMouseUp() {
+ this.isDragging = false
+ this.container.style.cursor = "grab"
+ }
+
+ private zoom(delta: number) {
+ const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
+
+ // Zoom around center
+ const rect = this.content.getBoundingClientRect()
+ const centerX = rect.width / 2
+ const centerY = rect.height / 2
+
+ const scaleDiff = newScale - this.scale
+ this.currentPan.x -= centerX * scaleDiff
+ this.currentPan.y -= centerY * scaleDiff
+
+ this.scale = newScale
+ this.updateTransform()
+ }
+
+ private updateTransform() {
+ this.content.style.transform = `translate(${this.currentPan.x}px, ${this.currentPan.y}px) scale(${this.scale})`
+ }
+
+ private resetTransform() {
+ this.scale = 1
+ const svg = this.content.querySelector("svg")!
+ this.currentPan = {
+ x: svg.getBoundingClientRect().width / 2,
+ y: svg.getBoundingClientRect().height / 2,
+ }
+ this.updateTransform()
+ }
+}
+
+const cssVars = [
+ "--secondary",
+ "--tertiary",
+ "--gray",
+ "--light",
+ "--lightgray",
+ "--highlight",
+ "--dark",
+ "--darkgray",
+ "--codeFont",
+] as const
+
+let mermaidImport = undefined
+document.addEventListener("nav", async () => {
+ const center = document.querySelector(".center") as HTMLElement
+ const nodes = center.querySelectorAll("code.mermaid") as NodeListOf
+ if (nodes.length === 0) return
+
+ // OVERLAY — upstream imports mermaid straight from
+ // cdnjs.cloudflare.com here. That request only fires on a page that
+ // actually contains a diagram, which is why the demo vault never
+ // triggered it and nobody noticed: the guarantee was a property of
+ // the *content*, not of the build. The first family note with a
+ // flowchart in it would have started calling Cloudflare.
+ //
+ // The Dockerfile installs mermaid at the same pinned version and
+ // copies its minified ESM graph into static/mermaid, so this loads
+ // from our own host. The chunks import each other by relative path,
+ // so the entry file is the only URL that needs rewriting.
+ //
+ // The specifier goes through a variable deliberately: esbuild bundles
+ // dynamic imports whose specifier is a string literal, and a literal
+ // "/static/…" would be resolved against the filesystem at build time
+ // and fail the build. Through a variable it stays a runtime import.
+ const mermaidUrl = "/static/mermaid/mermaid.esm.min.mjs"
+ // @ts-ignore
+ mermaidImport ||= await import(mermaidUrl)
+ const mermaid = mermaidImport.default
+
+ const textMapping: WeakMap = new WeakMap()
+ for (const node of nodes) {
+ textMapping.set(node, node.innerText)
+ }
+
+ async function renderMermaid() {
+ // de-init any other diagrams
+ for (const node of nodes) {
+ node.removeAttribute("data-processed")
+ const oldText = textMapping.get(node)
+ if (oldText) {
+ node.innerHTML = oldText
+ }
+ }
+
+ const computedStyleMap = cssVars.reduce(
+ (acc, key) => {
+ acc[key] = window.getComputedStyle(document.documentElement).getPropertyValue(key)
+ return acc
+ },
+ {} as Record<(typeof cssVars)[number], string>,
+ )
+
+ const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: "loose",
+ theme: darkMode ? "dark" : "base",
+ themeVariables: {
+ fontFamily: computedStyleMap["--codeFont"],
+ primaryColor: computedStyleMap["--light"],
+ primaryTextColor: computedStyleMap["--darkgray"],
+ primaryBorderColor: computedStyleMap["--tertiary"],
+ lineColor: computedStyleMap["--darkgray"],
+ secondaryColor: computedStyleMap["--secondary"],
+ tertiaryColor: computedStyleMap["--tertiary"],
+ clusterBkg: computedStyleMap["--light"],
+ edgeLabelBackground: computedStyleMap["--highlight"],
+ },
+ })
+
+ await mermaid.run({ nodes })
+ }
+
+ await renderMermaid()
+ document.addEventListener("themechange", renderMermaid)
+ window.addCleanup(() => document.removeEventListener("themechange", renderMermaid))
+
+ for (let i = 0; i < nodes.length; i++) {
+ const codeBlock = nodes[i] as HTMLElement
+ const pre = codeBlock.parentElement as HTMLPreElement
+ const clipboardBtn = pre.querySelector(".clipboard-button") as HTMLButtonElement
+ const expandBtn = pre.querySelector(".expand-button") as HTMLButtonElement
+
+ const clipboardStyle = window.getComputedStyle(clipboardBtn)
+ const clipboardWidth =
+ clipboardBtn.offsetWidth +
+ parseFloat(clipboardStyle.marginLeft || "0") +
+ parseFloat(clipboardStyle.marginRight || "0")
+
+ // Set expand button position
+ expandBtn.style.right = `calc(${clipboardWidth}px + 0.3rem)`
+ pre.prepend(expandBtn)
+
+ // query popup container
+ const popupContainer = pre.querySelector("#mermaid-container") as HTMLElement
+ if (!popupContainer) return
+
+ let panZoom: DiagramPanZoom | null = null
+ function showMermaid() {
+ const container = popupContainer.querySelector("#mermaid-space") as HTMLElement
+ const content = popupContainer.querySelector(".mermaid-content") as HTMLElement
+ if (!content) return
+ removeAllChildren(content)
+
+ // Clone the mermaid content
+ const mermaidContent = codeBlock.querySelector("svg")!.cloneNode(true) as SVGElement
+ content.appendChild(mermaidContent)
+
+ // Show container
+ popupContainer.classList.add("active")
+ container.style.cursor = "grab"
+
+ // Initialize pan-zoom after showing the popup
+ panZoom = new DiagramPanZoom(container, content)
+ }
+
+ function hideMermaid() {
+ popupContainer.classList.remove("active")
+ panZoom?.cleanup()
+ panZoom = null
+ }
+
+ expandBtn.addEventListener("click", showMermaid)
+ registerEscapeHandler(popupContainer, hideMermaid)
+
+ window.addCleanup(() => {
+ panZoom?.cleanup()
+ expandBtn.removeEventListener("click", showMermaid)
+ })
+ }
+})
diff --git a/stacklets/memory/quartz/custom.scss b/stacklets/memory/quartz/custom.scss
new file mode 100644
index 00000000..fc6981be
--- /dev/null
+++ b/stacklets/memory/quartz/custom.scss
@@ -0,0 +1,280 @@
+// custom.scss — the famstack layer over Quartz.
+//
+// Quartz ships this file as its sanctioned override slot: upstream's
+// copy is `@use "./base.scss";` and a comment inviting you to write
+// here. Keeping that first line is not optional. It is what pulls in
+// base, syntax and callouts; drop it and the site loses every style it
+// has, including the ones this file assumes are already there.
+//
+// The nine colours and four font slots in quartz.config.ts carry the
+// palette and the type. They cannot carry the rest of what makes a page
+// look like famstack: paper sitting on parchment, teal edges at the
+// weight of a pencil line, mono uppercase labels over each sidebar
+// panel. That is this file's job.
+//
+// Colours here are rgba() rather than the color-mix() the website uses.
+// Quartz minifies through lightningcss targeting Safari 15.6, which
+// predates color-mix, and a browser that cannot parse the border simply
+// has no border. rgba composites over parchment and over paper alike,
+// so one value works on both.
+
+@use "./base.scss";
+@use "./fonts.scss";
+
+// ── Tokens ──────────────────────────────────────────────────────────
+//
+// Quartz writes its own :root block *after* this file, so anything
+// named --light, --secondary and friends belongs in quartz.config.ts,
+// not here. These are the famstack tokens with no Quartz equivalent.
+
+:root {
+ --fs-paper: #f7f6f0;
+ --fs-border: rgba(61, 143, 160, 0.22);
+ --fs-border-light: rgba(61, 143, 160, 0.14);
+ --fs-border-faint: rgba(61, 143, 160, 0.08);
+ --fs-lava: #f07d45;
+ --fs-mark-line: #f5c842aa;
+ --fs-code-bg: #161f24;
+ --fs-code-text: #d4e8ec;
+}
+
+// ── Canvas and type ─────────────────────────────────────────────────
+
+body {
+ line-height: 1.7;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+// Newsreader carries the headlines, Space Grotesk the smaller headings.
+// The website splits them at the same place: h1 and h2 are editorial,
+// h3 downwards are labels on sections of a page.
+h1,
+h2 {
+ font-weight: 500;
+ letter-spacing: -0.02em;
+ line-height: 1.15;
+}
+
+h3,
+h4,
+h5,
+h6 {
+ font-family: var(--titleFont);
+ font-weight: 600;
+ letter-spacing: -0.02em;
+}
+
+.page-title {
+ font-family: var(--titleFont);
+ font-weight: 600;
+ letter-spacing: -0.03em;
+}
+
+// Markdown emphasis renders as a highlighter stroke rather than a
+// filled block: the line sits under the text baseline, the way a person
+// marking up a printed page would draw it.
+.text-highlight {
+ background-color: transparent;
+ background-image: linear-gradient(
+ 180deg,
+ transparent 50%,
+ var(--fs-mark-line) 50%,
+ var(--fs-mark-line) 88%,
+ transparent 88%
+ );
+ border-radius: 0;
+ padding: 0 3px;
+}
+
+// ── The page as paper ───────────────────────────────────────────────
+//
+// One card holds the title and the body together. Quartz keeps them in
+// separate elements (.page-header, then article), so the card goes on
+// their shared parent and the header's stock top margin comes off.
+
+.page > #quartz-body .center {
+ background: var(--fs-paper);
+ border: 1px solid var(--fs-border);
+ border-radius: 10px;
+ // Paper and parchment are four values apart, which is the point:
+ // this is one sheet on a desk, not a window on a screen. At that
+ // distance the border alone does not lift it, so a shadow does the
+ // separating and the edge stays quiet.
+ box-shadow:
+ 0 1px 2px rgba(22, 31, 36, 0.04),
+ 0 10px 28px rgba(22, 31, 36, 0.05);
+ padding: 2rem 2.5rem 2.5rem;
+ box-sizing: border-box;
+
+ @media all and (max-width: 800px) {
+ padding: 1.25rem 1.25rem 2rem;
+ border-radius: 8px;
+ }
+}
+
+.page > #quartz-body .center > .page-header {
+ margin-top: 0;
+}
+
+// ── Sidebar panels ──────────────────────────────────────────────────
+//
+// Every panel gets the same mono uppercase label. It is the strongest
+// single cue that this is a famstack surface, and it does the honest
+// work of telling four stacked panels apart at a glance.
+
+.sidebar .toc-header > h3,
+.sidebar .backlinks > h3,
+.sidebar .graph > h3,
+.sidebar .explorer-toggle > h2,
+.sidebar .recent-notes > h3 {
+ font-family: var(--codeFont);
+ font-size: 0.7rem;
+ font-weight: 500;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--gray);
+}
+
+.sidebar .explorer-ul,
+.sidebar .toc-content {
+ font-size: 0.85rem;
+}
+
+// Folder names are structure, not prose, so they take the UI face
+// rather than the editorial one. Upstream sets --headerFont on the
+// button and the anchor themselves, not on the .folder-title span
+// inside them, so those are the elements to catch.
+.explorer .folder-container div > a,
+.explorer .folder-container div > button {
+ font-family: var(--titleFont);
+ font-size: 0.9rem;
+}
+
+// ── Links ───────────────────────────────────────────────────────────
+//
+// Teal, not lava. DESIGN.md keeps lava for calls to action and makes
+// one exception: article and guide body links read better in teal on
+// parchment. A wiki page is body text from top to bottom, so the
+// exception is the rule here. Lava stays for hover, which base already
+// wires to --tertiary.
+
+// Quartz gives internal links a filled background. On a stock digital
+// garden with a handful of links per page that reads as a nice touch;
+// on a family wiki page it does not. Our pages are reference-dense by
+// construction — a person page is mostly links to documents, a document
+// page mostly links to people — so the fill turns whole paragraphs into
+// a run of coloured blocks and the reader loses the sentence.
+//
+// Underline instead, at a weight that says "link" without shouting. The
+// --highlight token stays as configured because code line highlighting
+// uses it too; only the link background is overridden.
+//
+// Heading anchors carry .internal as well and must keep their bare
+// look, hence the :not().
+a.internal:not([role="anchor"]) {
+ background-color: transparent;
+ border-radius: 0;
+ padding: 0;
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-decoration-color: rgba(61, 143, 160, 0.4);
+ text-underline-offset: 2px;
+}
+
+a.internal:not([role="anchor"]):hover {
+ background-color: transparent;
+ text-decoration-color: var(--tertiary);
+}
+
+// ── Quotes, rules, tables ───────────────────────────────────────────
+
+blockquote {
+ background: rgba(61, 143, 160, 0.05);
+ border-radius: 0 6px 6px 0;
+ padding: 0.5rem 1rem;
+}
+
+hr {
+ background-color: var(--fs-border-light);
+}
+
+.table-container > table {
+ margin: 1rem 0;
+ padding: 0;
+}
+
+th {
+ font-family: var(--codeFont);
+ font-size: 0.72rem;
+ font-weight: 500;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ color: var(--gray);
+ border-bottom: 1px solid var(--fs-border);
+}
+
+tr {
+ border-bottom: 1px solid var(--fs-border-faint);
+}
+
+// ── Code ────────────────────────────────────────────────────────────
+//
+// Blocks go dark, the way they do on famstack.dev. This only reads if
+// the syntax theme agrees, which is why quartz.config.ts sets the light
+// mode highlighter to github-dark. Inline code stays light: it sits
+// inside a sentence and a dark chip mid-paragraph is a hole in the page.
+
+pre {
+ background: var(--fs-code-bg);
+ border: none;
+ border-radius: 8px;
+ padding: 0.25rem 0.9rem;
+}
+
+pre > code {
+ color: var(--fs-code-text);
+ font-size: 0.82rem;
+}
+
+figure[data-rehype-pretty-code-figure] > [data-rehype-pretty-code-title] {
+ font-family: var(--codeFont);
+ font-size: 0.72rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ border: 1px solid var(--fs-border-light);
+ border-radius: 5px 5px 0 0;
+ border-bottom: none;
+ background: var(--fs-paper);
+ color: var(--gray);
+}
+
+code {
+ background: rgba(61, 143, 160, 0.1);
+ border-radius: 3px;
+}
+
+// ── Overlays ────────────────────────────────────────────────────────
+
+.popover > .popover-inner,
+.search > .search-container > .search-space {
+ background: var(--fs-paper);
+ border: 1px solid var(--fs-border);
+ border-radius: 10px;
+}
+
+.search .search-button {
+ border-radius: 6px;
+}
+
+// ── Footer ──────────────────────────────────────────────────────────
+
+footer {
+ font-family: var(--codeFont);
+ font-size: 0.72rem;
+ color: var(--gray);
+}
+
+footer ul {
+ font-size: 0.72rem;
+}
diff --git a/stacklets/memory/quartz/fonts.scss b/stacklets/memory/quartz/fonts.scss
new file mode 100644
index 00000000..c7e3a672
--- /dev/null
+++ b/stacklets/memory/quartz/fonts.scss
@@ -0,0 +1,129 @@
+// fonts.scss — the four famstack families, served from our own host.
+//
+// Quartz's `fontOrigin: "local"` does not mean "Quartz finds your fonts".
+// Read the upstream source and it says so out loud: the local branch of
+// componentResources.ts is a no-op with the comment "let the user do it
+// themselves in css". This file is that css.
+//
+// The faces are the @fontsource-variable packages the famstack.dev site
+// already ships, so the wiki and the website render from byte-identical
+// files. Generated from those packages rather than hand-copied: the
+// weight axes differ per family (Newsreader stops at 800, Space Grotesk
+// at 700) and the unicode-ranges are long enough to get wrong by hand.
+//
+// latin + latin-ext only. German lives entirely in the latin range, and
+// unicode-range means a reader who never types a Polish name never
+// downloads the file that would render it.
+//
+// Paths are relative to the emitted stylesheet, which Quartz writes to
+// the site root as index.css, so `static/fonts/…` resolves from a page
+// at any depth. The files arrive there via the Static emitter, which
+// globs quartz/static/** into the output.
+
+@font-face {
+ font-family: 'Inter Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 100 900;
+ src: url(static/fonts/inter-latin-ext-wght-normal.woff2) format('woff2-variations');
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Inter Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 100 900;
+ src: url(static/fonts/inter-latin-wght-normal.woff2) format('woff2-variations');
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
+}
+
+@font-face {
+ font-family: 'Inter Variable';
+ font-style: italic;
+ font-display: swap;
+ font-weight: 100 900;
+ src: url(static/fonts/inter-latin-ext-wght-italic.woff2) format('woff2-variations');
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Inter Variable';
+ font-style: italic;
+ font-display: swap;
+ font-weight: 100 900;
+ src: url(static/fonts/inter-latin-wght-italic.woff2) format('woff2-variations');
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
+}
+
+@font-face {
+ font-family: 'Newsreader Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 200 800;
+ src: url(static/fonts/newsreader-latin-ext-wght-normal.woff2) format('woff2-variations');
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Newsreader Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 200 800;
+ src: url(static/fonts/newsreader-latin-wght-normal.woff2) format('woff2-variations');
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
+}
+
+@font-face {
+ font-family: 'Newsreader Variable';
+ font-style: italic;
+ font-display: swap;
+ font-weight: 200 800;
+ src: url(static/fonts/newsreader-latin-ext-wght-italic.woff2) format('woff2-variations');
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Newsreader Variable';
+ font-style: italic;
+ font-display: swap;
+ font-weight: 200 800;
+ src: url(static/fonts/newsreader-latin-wght-italic.woff2) format('woff2-variations');
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
+}
+
+@font-face {
+ font-family: 'Space Grotesk Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 300 700;
+ src: url(static/fonts/space-grotesk-latin-ext-wght-normal.woff2) format('woff2-variations');
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Space Grotesk Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 300 700;
+ src: url(static/fonts/space-grotesk-latin-wght-normal.woff2) format('woff2-variations');
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 100 800;
+ src: url(static/fonts/jetbrains-mono-latin-ext-wght-normal.woff2) format('woff2-variations');
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'JetBrains Mono Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 100 800;
+ src: url(static/fonts/jetbrains-mono-latin-wght-normal.woff2) format('woff2-variations');
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
+}
diff --git a/stacklets/memory/quartz/quartz.config.ts b/stacklets/memory/quartz/quartz.config.ts
index 96106a69..52789d55 100644
--- a/stacklets/memory/quartz/quartz.config.ts
+++ b/stacklets/memory/quartz/quartz.config.ts
@@ -24,9 +24,16 @@ const wikiPort = process.env.WIKI_PORT || "42070"
const haveRealHost = wikiHost && !wikiHost.endsWith(".")
const baseUrl = haveRealHost ? wikiHost : `${wikiIp || "localhost"}:${wikiPort}`
+// The household's own name for the site, from stack.toml [core]
+// stack_owner by way of WIKI_TITLE ("The Simpsons"). A family reading
+// their own wiki should see themselves at the top of it, not a product
+// noun. Instances installed before stack_owner existed send nothing, so
+// the generic title stays as the fallback.
+const wikiTitle = process.env.WIKI_TITLE?.trim() || "Family Memory"
+
const config: QuartzConfig = {
configuration: {
- pageTitle: "Family Memory",
+ pageTitle: wikiTitle,
pageTitleSuffix: "",
enableSPA: true,
enablePopovers: true,
@@ -40,35 +47,55 @@ const config: QuartzConfig = {
ignorePatterns: [".git", ".obsidian", "private", "templates"],
defaultDateType: "modified",
theme: {
- fontOrigin: "googleFonts",
- cdnCaching: true,
+ // Self-hosted. The comment above about analytics applies with more
+ // force to fonts: Google Fonts would hand Google the IP and the
+ // page path of every read, on the surface holding the family's
+ // most private material. "local" makes Quartz emit nothing at all
+ // (it is a no-op branch upstream, by design) — the @font-face
+ // rules and the files live in quartz/fonts.scss and static/fonts.
+ fontOrigin: "local",
+ cdnCaching: false,
+ // Four slots, four famstack roles. The names are the
+ // @fontsource-variable family names, which is what the vendored
+ // files declare and what famstack.dev already renders with.
typography: {
- header: "Schibsted Grotesk",
- body: "Source Sans Pro",
- code: "IBM Plex Mono",
+ title: "Space Grotesk Variable",
+ header: "Newsreader Variable",
+ body: "Inter Variable",
+ code: "JetBrains Mono Variable",
},
+ // The famstack palette, mapped onto the nine slots Quartz gives
+ // us. `secondary` is the link colour and it gets teal, not lava:
+ // DESIGN.md reserves lava for calls to action but makes an
+ // exception for article and guide body links, and a wiki page is
+ // body text end to end. Lava stays on hover, via `tertiary`.
+ //
+ // Light mode only for v1. DESIGN.md has no dark palette yet and
+ // says not to port one without a full pass, so darkMode repeats
+ // lightMode rather than inventing colours, and the toggle is gone
+ // from both layouts.
colors: {
lightMode: {
- light: "#faf8f8",
- lightgray: "#e5e5e5",
- gray: "#b8b8b8",
- darkgray: "#4e4e4e",
- dark: "#2b2b2b",
- secondary: "#284b63",
- tertiary: "#84a59d",
- highlight: "rgba(143, 159, 169, 0.15)",
- textHighlight: "#fff23688",
+ light: "#EFEEE6", // --parchment, the page
+ lightgray: "#e6e4db", // --bg-subtle, borders and inline code
+ gray: "#6a7d82", // --text-muted, labels and line numbers
+ darkgray: "#3A4447", // --charcoal, body text
+ dark: "#161F24", // --slate, headings
+ secondary: "#3D8FA0", // --teal, links
+ tertiary: "#F07D45", // --lava, hover and selection
+ highlight: "rgba(61, 143, 160, 0.14)", // teal wash behind internal links
+ textHighlight: "#F5C842aa", // --mark-line
},
darkMode: {
- light: "#161618",
- lightgray: "#393639",
- gray: "#646464",
- darkgray: "#d4d4d4",
- dark: "#ebebec",
- secondary: "#7b97aa",
- tertiary: "#84a59d",
- highlight: "rgba(143, 159, 169, 0.15)",
- textHighlight: "#b3aa0288",
+ light: "#EFEEE6",
+ lightgray: "#e6e4db",
+ gray: "#6a7d82",
+ darkgray: "#3A4447",
+ dark: "#161F24",
+ secondary: "#3D8FA0",
+ tertiary: "#F07D45",
+ highlight: "rgba(61, 143, 160, 0.14)",
+ textHighlight: "#F5C842aa",
},
},
},
@@ -81,8 +108,12 @@ const config: QuartzConfig = {
Plugin.CreatedModifiedDate({
priority: ["frontmatter", "git", "filesystem"],
}),
+ // github-dark in light mode is not a typo. custom.scss puts code
+ // blocks on slate, the way famstack.dev does, and a light token
+ // set on a dark block is unreadable. `keepBackground: false`
+ // leaves the background to our CSS and takes only the colours.
Plugin.SyntaxHighlighting({
- theme: { light: "github-light", dark: "github-dark" },
+ theme: { light: "github-dark", dark: "github-dark" },
keepBackground: false,
}),
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
@@ -90,7 +121,15 @@ const config: QuartzConfig = {
Plugin.TableOfContents(),
Plugin.CrawlLinks({ markdownLinkResolution: "absolute" }),
Plugin.Description(),
- Plugin.Latex({ renderEngine: "katex" }),
+ // MathJax, not KaTeX, and the reason is privacy rather than
+ // typesetting. Quartz's KaTeX engine attaches a stylesheet and a
+ // script from cdn.jsdelivr.net to *every* page, whether or not it
+ // contains any maths — see the unconditional `externalResources()`
+ // in quartz/plugins/transformers/latex.ts. The MathJax engine
+ // declares none and renders to SVG at build time instead. Nobody
+ // in a family vault writes LaTeX often, but everybody would have
+ // been calling jsdelivr on every page load.
+ Plugin.Latex({ renderEngine: "mathjax" }),
],
filters: [Plugin.RemoveDrafts()],
emitters: [
diff --git a/stacklets/memory/quartz/quartz.layout.ts b/stacklets/memory/quartz/quartz.layout.ts
index 6a68111c..bb1e0e73 100644
--- a/stacklets/memory/quartz/quartz.layout.ts
+++ b/stacklets/memory/quartz/quartz.layout.ts
@@ -9,6 +9,12 @@
import { PageLayout, SharedLayout } from "./quartz/cfg"
import * as Component from "./quartz/components"
+// Our own components, imported directly rather than through the
+// `Component` namespace so we do not have to overlay upstream's
+// components/index.ts as well. Quartz collects a component's `.css` by
+// walking the layout, so a direct import styles itself just the same.
+import FamstackTitle from "./quartz/components/FamstackTitle"
+import Welcome from "./quartz/components/Welcome"
// `CODE_URL` is set in the container env from {code_url} — the
// user-facing Forgejo URL. Empty falls back to a `#` placeholder so
@@ -27,10 +33,13 @@ export const sharedPageComponents: SharedLayout = {
}),
}
-// Single-page layout (one note): keep the upstream sidebar with
-// search + dark mode toggle on the left, graph + ToC + backlinks
-// on the right. This is the layout that makes the vault feel like
-// a wiki rather than a folder dump.
+// Single-page layout (one note): search on the left, graph + ToC +
+// backlinks on the right. This is the layout that makes the vault feel
+// like a wiki rather than a folder dump.
+//
+// No dark mode toggle. famstack has no dark palette yet, so the toggle
+// could only swap parchment for parchment. It comes back with the
+// palette, not before.
export const defaultContentPageLayout: PageLayout = {
beforeBody: [
Component.ConditionalRender({
@@ -40,14 +49,18 @@ export const defaultContentPageLayout: PageLayout = {
Component.ArticleTitle(),
Component.ContentMeta(),
Component.TagList(),
+ // The greeting belongs to the front door only.
+ Component.ConditionalRender({
+ component: Welcome(),
+ condition: (page) => page.fileData.slug === "index",
+ }),
],
left: [
- Component.PageTitle(),
+ FamstackTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Flex({
components: [
{ Component: Component.Search(), grow: true },
- { Component: Component.Darkmode() },
{ Component: Component.ReaderMode() },
],
}),
@@ -70,13 +83,10 @@ export const defaultListPageLayout: PageLayout = {
Component.ContentMeta(),
],
left: [
- Component.PageTitle(),
+ FamstackTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Flex({
- components: [
- { Component: Component.Search(), grow: true },
- { Component: Component.Darkmode() },
- ],
+ components: [{ Component: Component.Search(), grow: true }],
}),
Component.Explorer(),
],
diff --git a/stacklets/memory/quartz/static/fonts/LICENSE-inter.txt b/stacklets/memory/quartz/static/fonts/LICENSE-inter.txt
new file mode 100644
index 00000000..40589daa
--- /dev/null
+++ b/stacklets/memory/quartz/static/fonts/LICENSE-inter.txt
@@ -0,0 +1,93 @@
+Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/stacklets/memory/quartz/static/fonts/LICENSE-jetbrains-mono.txt b/stacklets/memory/quartz/static/fonts/LICENSE-jetbrains-mono.txt
new file mode 100644
index 00000000..8f7ed671
--- /dev/null
+++ b/stacklets/memory/quartz/static/fonts/LICENSE-jetbrains-mono.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/stacklets/memory/quartz/static/fonts/LICENSE-newsreader.txt b/stacklets/memory/quartz/static/fonts/LICENSE-newsreader.txt
new file mode 100644
index 00000000..03ad13c3
--- /dev/null
+++ b/stacklets/memory/quartz/static/fonts/LICENSE-newsreader.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader) Newsreader-Italic[opsz,wght].ttf: Copyright 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/stacklets/memory/quartz/static/fonts/LICENSE-space-grotesk.txt b/stacklets/memory/quartz/static/fonts/LICENSE-space-grotesk.txt
new file mode 100644
index 00000000..d5666d70
--- /dev/null
+++ b/stacklets/memory/quartz/static/fonts/LICENSE-space-grotesk.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/stacklets/memory/quartz/static/fonts/inter-latin-ext-wght-italic.woff2 b/stacklets/memory/quartz/static/fonts/inter-latin-ext-wght-italic.woff2
new file mode 100644
index 00000000..7bf20e12
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/inter-latin-ext-wght-italic.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/inter-latin-ext-wght-normal.woff2 b/stacklets/memory/quartz/static/fonts/inter-latin-ext-wght-normal.woff2
new file mode 100644
index 00000000..479d010d
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/inter-latin-ext-wght-normal.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/inter-latin-wght-italic.woff2 b/stacklets/memory/quartz/static/fonts/inter-latin-wght-italic.woff2
new file mode 100644
index 00000000..2f06c651
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/inter-latin-wght-italic.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/inter-latin-wght-normal.woff2 b/stacklets/memory/quartz/static/fonts/inter-latin-wght-normal.woff2
new file mode 100644
index 00000000..d15208de
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/inter-latin-wght-normal.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 b/stacklets/memory/quartz/static/fonts/jetbrains-mono-latin-ext-wght-normal.woff2
new file mode 100644
index 00000000..01769d9e
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/jetbrains-mono-latin-wght-normal.woff2 b/stacklets/memory/quartz/static/fonts/jetbrains-mono-latin-wght-normal.woff2
new file mode 100644
index 00000000..cd5102a4
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/jetbrains-mono-latin-wght-normal.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/newsreader-latin-ext-wght-italic.woff2 b/stacklets/memory/quartz/static/fonts/newsreader-latin-ext-wght-italic.woff2
new file mode 100644
index 00000000..7a7ae44c
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/newsreader-latin-ext-wght-italic.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/newsreader-latin-ext-wght-normal.woff2 b/stacklets/memory/quartz/static/fonts/newsreader-latin-ext-wght-normal.woff2
new file mode 100644
index 00000000..06116f40
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/newsreader-latin-ext-wght-normal.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/newsreader-latin-wght-italic.woff2 b/stacklets/memory/quartz/static/fonts/newsreader-latin-wght-italic.woff2
new file mode 100644
index 00000000..11a48eca
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/newsreader-latin-wght-italic.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/newsreader-latin-wght-normal.woff2 b/stacklets/memory/quartz/static/fonts/newsreader-latin-wght-normal.woff2
new file mode 100644
index 00000000..816261a5
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/newsreader-latin-wght-normal.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/space-grotesk-latin-ext-wght-normal.woff2 b/stacklets/memory/quartz/static/fonts/space-grotesk-latin-ext-wght-normal.woff2
new file mode 100644
index 00000000..db732c27
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/space-grotesk-latin-ext-wght-normal.woff2 differ
diff --git a/stacklets/memory/quartz/static/fonts/space-grotesk-latin-wght-normal.woff2 b/stacklets/memory/quartz/static/fonts/space-grotesk-latin-wght-normal.woff2
new file mode 100644
index 00000000..0f3474ee
Binary files /dev/null and b/stacklets/memory/quartz/static/fonts/space-grotesk-latin-wght-normal.woff2 differ
diff --git a/stacklets/memory/quartz/static/icon.png b/stacklets/memory/quartz/static/icon.png
new file mode 100644
index 00000000..f7975d85
Binary files /dev/null and b/stacklets/memory/quartz/static/icon.png differ
diff --git a/stacklets/memory/stacklet.toml b/stacklets/memory/stacklet.toml
index 13ef2a42..d7d2e1bf 100644
--- a/stacklets/memory/stacklet.toml
+++ b/stacklets/memory/stacklet.toml
@@ -103,3 +103,10 @@ WIKI_NIGHTLY = "{wiki_nightly}"
# auto-detected LAN IP via `{ip}` so the site stays reachable.
WIKI_HOST = "wiki.{domain}"
WIKI_IP = "{ip}"
+
+# What the wiki calls itself, in the sidebar and the browser tab: the
+# household's own name from stack.toml [core] stack_owner, rendered as
+# "The Simpsons". Empty on instances installed before stack_owner
+# existed, and the wiki falls back to a generic title rather than show a
+# half-written name.
+WIKI_TITLE = "{family_display_name}"
diff --git a/tests/framework/test_family_name.py b/tests/framework/test_family_name.py
new file mode 100644
index 00000000..a2259e96
--- /dev/null
+++ b/tests/framework/test_family_name.py
@@ -0,0 +1,69 @@
+"""Behavior tests for how the household is named back to itself.
+
+stack.toml's [core] stack_owner holds whatever surname the installer
+asked for. Two surfaces render it: the installer's closing line ("The
+Simpsons are online") and the family wiki's title. Both go through these
+helpers so the household is spelled the same way in both places.
+
+The awkward part is that "Family name" is a question people answer two
+different ways. One person types "Simpson", the next types "Simpsons",
+and both mean the same household. Getting that wrong prints "The
+Simpsonss" on the first screen a family ever sees.
+"""
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib"))
+
+from stack import family_display_name, family_plural
+
+
+class TestPluralisation:
+ """A surname becomes the way you address the whole household."""
+
+ def test_singular_surname_gains_an_s(self):
+ assert family_plural("Simpson") == "Simpsons"
+
+ def test_surname_already_plural_is_left_alone(self):
+ assert family_plural("Simpsons") == "Simpsons"
+
+ def test_plural_check_ignores_case(self):
+ # "SIMPSONS" already ends in an s; shouting is not a new surname.
+ assert family_plural("SIMPSONS") == "SIMPSONS"
+
+ def test_case_of_the_name_is_never_changed(self):
+ # The family's own capitalisation is theirs, not ours to correct.
+ assert family_plural("van Houten") == "van Houtens"
+
+
+class TestDisplayName:
+ """The full phrase, as it appears on screen."""
+
+ def test_reads_as_the_family(self):
+ assert family_display_name("Simpson") == "The Simpsons"
+
+ def test_does_not_double_the_plural(self):
+ assert family_display_name("Simpsons") == "The Simpsons"
+
+
+class TestUnset:
+ """An instance with no stack_owner must not print a half-formed name.
+
+ Instances created before stack_owner existed still run, so this is a
+ live path, not a hypothetical. Returning empty lets each caller pick
+ its own fallback instead of showing the family "The s".
+ """
+
+ def test_missing_owner_is_empty(self):
+ assert family_display_name("") == ""
+ assert family_plural("") == ""
+
+ def test_none_is_empty(self):
+ assert family_display_name(None) == ""
+
+ def test_whitespace_only_is_empty(self):
+ assert family_display_name(" ") == ""
+
+ def test_surrounding_whitespace_is_trimmed(self):
+ assert family_display_name(" Simpson ") == "The Simpsons"