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 ( +
+

+ {title} +

+ + fam + + stack + + +
+ ) +} + +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"