From a959b16bcb7208e686edddf4d709ffd6e2e11bf7 Mon Sep 17 00:00:00 2001
From: Arthur
Date: Fri, 25 Sep 2026 17:56:43 +0200
Subject: [PATCH 1/5] feat(wiki): sort the wiki menu by what the family looks
for
The wiki's menu showed the vault's folders, which are laid out for the
programs that write them: kinds of records and topics side by side
under `family/`, the diary three folders down, documents only by date,
folder slugs instead of names.
The menu now follows the family's questions, in this order: Start,
Recent, Diary, People, Topics, Documents, Notes & links.
- Topics group the ontology's topics and the family's chat-room topics
by area of life. A topic appears once something is filed under it,
with a count.
- Documents are listed by type, by year and by sender.
- Topics without a page of their own, document types, notes and
bookmarks get generated list pages.
- Labels follow `[core] language` (English and German).
Nothing moves on disk. The ontology gains an optional `area` per topic
and `[area.]` groups, which the classifier ignores. A vault seeded
before areas existed takes them from the ontology shipped with the
wiki, so an existing instance needs only `./stack restart memory`.
README files are no longer published in the wiki.
---
docs/user-guide.md | 11 +
stacklets/memory/Dockerfile | 11 +
stacklets/memory/docker-compose.yml | 3 +
.../memory/quartz/components/FamilyList.tsx | 103 ++++
.../memory/quartz/components/FamilyNav.tsx | 192 ++++++
.../memory/quartz/components/familyModel.ts | 568 ++++++++++++++++++
.../components/scripts/familyNav.inline.ts | 77 +++
.../memory/quartz/plugins/familyLists.tsx | 79 +++
stacklets/memory/quartz/quartz.config.ts | 8 +-
stacklets/memory/quartz/quartz.layout.ts | 28 +-
stacklets/memory/seeds/README.md | 3 +-
stacklets/memory/seeds/ontology.toml | 79 +++
12 files changed, 1136 insertions(+), 26 deletions(-)
create mode 100644 stacklets/memory/quartz/components/FamilyList.tsx
create mode 100644 stacklets/memory/quartz/components/FamilyNav.tsx
create mode 100644 stacklets/memory/quartz/components/familyModel.ts
create mode 100644 stacklets/memory/quartz/components/scripts/familyNav.inline.ts
create mode 100644 stacklets/memory/quartz/plugins/familyLists.tsx
diff --git a/docs/user-guide.md b/docs/user-guide.md
index d06c79dd..ec9ae75d 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -192,6 +192,17 @@ Everything the archivist files becomes more than a pile of documents: the memory
Open it at `http://:42070` (or `memory.` if the admin set up a domain). It updates itself as things get filed.
+The menu on the left is sorted by what you look for, not by how the files are stored:
+
+- **Recent**: the topics where something was filed in the last four weeks.
+- **Diary**: the family diary, month by month.
+- **People**: a page per family member.
+- **Topics**: everything filed about a subject, grouped by area of life (money and work, health, home, the car, …). A topic appears once something is filed under it, with the number of entries next to it.
+- **Documents**: every document by type, by year and by sender.
+- **Notes & links**: every note and saved link, newest first.
+
+The menu follows the language set for your famstack.
+
Every page has an edit link: edits happen in Forgejo (the family's private git server), so every change is tracked and nothing is ever lost. If you use Obsidian, you can clone the vault and browse it there; it's all plain Markdown.
---
diff --git a/stacklets/memory/Dockerfile b/stacklets/memory/Dockerfile
index 504ebf04..4516fac0 100644
--- a/stacklets/memory/Dockerfile
+++ b/stacklets/memory/Dockerfile
@@ -78,6 +78,17 @@ COPY quartz/components/scripts/mermaid.inline.ts ./quartz/components/scripts/mer
COPY quartz/components/FamstackTitle.tsx ./quartz/components/FamstackTitle.tsx
COPY quartz/components/Welcome.tsx ./quartz/components/Welcome.tsx
+# The family navigation: a sidebar that replaces the Explorer, the list
+# pages behind it, and the emitter that writes both. See familyModel.ts.
+COPY quartz/components/familyModel.ts ./quartz/components/familyModel.ts
+COPY quartz/components/FamilyNav.tsx ./quartz/components/FamilyNav.tsx
+COPY quartz/components/FamilyList.tsx ./quartz/components/FamilyList.tsx
+COPY quartz/components/scripts/familyNav.inline.ts ./quartz/components/scripts/familyNav.inline.ts
+COPY quartz/plugins/familyLists.tsx ./quartz/plugins/emitters/familyLists.tsx
+# The shipped ontology, for the topic areas a vault installed earlier
+# does not name yet (see readOntology in familyModel.ts).
+COPY seeds/ontology.toml ./ontology.seed.toml
+
# 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 0ad741dd..6799d8b5 100644
--- a/stacklets/memory/docker-compose.yml
+++ b/stacklets/memory/docker-compose.yml
@@ -56,6 +56,9 @@ services:
# 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:-}
+ # The instance language: labels in the navigation and Quartz's own
+ # strings (dates, "Backlinks") follow it.
+ WIKI_LANGUAGE: ${LANGUAGE:-en}
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/FamilyList.tsx b/stacklets/memory/quartz/components/FamilyList.tsx
new file mode 100644
index 00000000..16546e2f
--- /dev/null
+++ b/stacklets/memory/quartz/components/FamilyList.tsx
@@ -0,0 +1,103 @@
+import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
+import { PageList } from "./PageList"
+import { FullSlug, resolveRelative } from "../util/path"
+import { familyModel, L } from "./familyModel"
+
+// NEW COMPONENT (not an upstream override): the body of a page the
+// FamilyLists emitter writes: a topic without an about page, a document
+// type, needs attention, all notes, all bookmarks. Sections without
+// entries are left out, so a topic with only tasks shows only tasks.
+
+const FamilyList: QuartzComponent = (props: QuartzComponentProps) => {
+ const { ctx, fileData, allFiles } = props
+ const here = fileData.slug as FullSlug
+ const page = familyModel(ctx, allFiles).lists.get(here)
+ if (!page) return null
+
+ const bySlug = new Map(allFiles.map((f) => [f.slug, f]))
+ const sections = page.sections.filter((s) => s.tasks?.length || s.slugs?.length)
+ const multi = sections.length > 1
+
+ return (
+
)
}
@@ -37,31 +41,50 @@ FamstackTitle.css = `
.famstack-title {
display: flex;
flex-direction: column;
- /* Room for the title's underline to sit clear of the wordmark. */
- gap: 0.5rem;
+ gap: 0.3rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid rgba(61, 143, 160, 0.14);
}
+/* The family's name in the headline face, like the page titles. It is
+ the way back to the front page from anywhere; the hover says so. */
.famstack-title .page-title {
- font-size: 1.6rem;
+ font-size: 1.65rem;
margin: 0;
- font-family: var(--titleFont);
- font-weight: 600;
- letter-spacing: -0.03em;
+ font-family: var(--headerFont);
+ font-weight: 500;
+ letter-spacing: -0.02em;
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;
+ color: var(--dark);
+ font-weight: 500;
+ transition: color 0.2s ease;
}
.famstack-title .page-title > a:hover {
- border-bottom-color: var(--tertiary);
+ color: var(--tertiary);
+}
+
+.famstack-title .fs-byline {
+ display: flex;
+ align-items: baseline;
+ gap: 0.45rem;
+ margin: 0;
+}
+
+.famstack-title .fs-tag {
+ font-family: var(--codeFont);
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--gray);
+}
+
+.famstack-title .fs-tag::before {
+ content: "·";
+ margin-right: 0.45rem;
}
.famstack-title .fs-brandmark {
@@ -103,8 +126,9 @@ FamstackTitle.css = `
/* 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; }
+ .famstack-title .fs-byline { display: none; }
+ .famstack-title { padding-bottom: 0; border-bottom: 0; }
+ .famstack-title .page-title { font-size: 1.2rem; }
}
`
diff --git a/stacklets/memory/quartz/components/Welcome.tsx b/stacklets/memory/quartz/components/Welcome.tsx
index 2231f031..b8b06716 100644
--- a/stacklets/memory/quartz/components/Welcome.tsx
+++ b/stacklets/memory/quartz/components/Welcome.tsx
@@ -1,5 +1,6 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
+import { L } from "./familyModel"
// NEW COMPONENT (not an upstream override) — the greeting on the home
// page, and only there. The layout gates it on the index slug.
@@ -10,17 +11,13 @@ import { classNames } from "../util/lang"
// 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.
+// The copy follows the instance language, from the label table in
+// familyModel.ts.
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.
+
{L.welcome}
+
{L.welcomeHint}
)
}
diff --git a/stacklets/memory/quartz/components/familyModel.ts b/stacklets/memory/quartz/components/familyModel.ts
index 01e81afc..fecf83dd 100644
--- a/stacklets/memory/quartz/components/familyModel.ts
+++ b/stacklets/memory/quartz/components/familyModel.ts
@@ -52,6 +52,22 @@ const LABELS = {
topicIntro: "Everything filed about this topic, newest first.",
notesIntro: "Every note the family has saved, newest first.",
bookmarksIntro: "Every link the family has saved, newest first.",
+ documentsIntro: "Every document the family has filed. The latest are below; the menu sorts them by type, year and sender.",
+ yearIntro: "Every document dated this year, newest first.",
+ latest: "Latest",
+ document: "Document",
+ note: "Note",
+ bookmark: "Link",
+ email: "Email",
+ person: "Person",
+ topic: "Topic",
+ sender: "Sender",
+ filedBy: "filed by",
+ updated: "updated",
+ familyWiki: "family wiki",
+ welcome: "Everything the family has kept: documents, notes, the diary, and the people they belong to.",
+ welcomeHint: "Start with the menu on the left, or search for a name.",
+ editOnForgejo: "Edit on Forgejo",
},
de: {
nav: "Familienwiki",
@@ -79,11 +95,31 @@ const LABELS = {
topicIntro: "Alles, was zu diesem Thema abgelegt ist, das Neueste zuerst.",
notesIntro: "Alle Notizen der Familie, die neuesten zuerst.",
bookmarksIntro: "Alle Links der Familie, die neuesten zuerst.",
+ documentsIntro: "Alle Dokumente der Familie. Unten die neuesten; im Menü nach Art, Jahr und Absender sortiert.",
+ yearIntro: "Alle Dokumente aus diesem Jahr, die neuesten zuerst.",
+ latest: "Neueste",
+ document: "Dokument",
+ note: "Notiz",
+ bookmark: "Link",
+ email: "E-Mail",
+ person: "Person",
+ topic: "Thema",
+ sender: "Absender",
+ filedBy: "abgelegt von",
+ updated: "aktualisiert",
+ familyWiki: "Familienwiki",
+ welcome: "Alles, was die Familie aufbewahrt: Dokumente, Notizen, das Tagebuch und die Menschen, zu denen sie gehören.",
+ welcomeHint: "Fangt im Menü links an oder sucht nach einem Namen.",
+ editOnForgejo: "In Forgejo bearbeiten",
},
} as const
export const L = LABELS[lang]
+export const locale = lang === "de" ? "de-DE" : "en-US"
+const dateFormat = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", year: "numeric" })
+export const fmtDate = (d: Date) => dateFormat.format(d)
+
// ── Tuning ───────────────────────────────────────────────────────────────
// A topic counts as recent when something was filed in it within this
@@ -217,9 +253,20 @@ export interface ListPage {
sections: ListSection[]
}
+// What the page header shows above and below a page's title.
+export interface Head {
+ kicker: string[]
+ meta: string[]
+ date?: Date
+ chips: { label: string; slug?: string }[]
+}
+
export interface Model {
nav: NavNode[]
lists: Map
+ head: (slug: string) => Head
+ crumbs: (slug: string) => NavNode[]
+ describe: (f: QuartzPluginData) => { date?: Date; meta: string[] }
}
type Kind = "document" | "note" | "bookmark" | "email"
@@ -230,6 +277,8 @@ interface Rec {
slug: string
kind: Kind
filed?: Date
+ // The date a list sorts by: a document's own date, else when it was filed.
+ dated?: Date
}
interface Topic {
@@ -265,7 +314,7 @@ const openTasks = (text: string) =>
[...text.matchAll(/^\s*[-*] \[ \] (.+)$/gm)].map((m) => m[1].trim())
const newest = (a?: Date, b?: Date) => (!a ? b : !b ? a : a > b ? a : b)
-const byFiledDesc = (a: Rec, b: Rec) => (b.filed?.getTime() ?? 0) - (a.filed?.getTime() ?? 0)
+const byDatedDesc = (a: Rec, b: Rec) => (b.dated?.getTime() ?? 0) - (a.dated?.getTime() ?? 0)
// Every page of a build renders the sidebar, and the emitters each pass
// their own copy of the file list, so the model is kept per list.
@@ -319,6 +368,7 @@ function build(ctx: BuildCtx, allFiles: QuartzPluginData[]): Model {
}
const records: Rec[] = []
+ const recTopics = new Map()
const people: NavNode[] = []
const diaryYears = new Map()
let diaryRoot: string | undefined
@@ -369,6 +419,7 @@ function build(ctx: BuildCtx, allFiles: QuartzPluginData[]): Model {
kind: meta.type as Kind,
filed: parseDate(meta.timestamp) ?? parseDate(meta.date) ?? f.dates?.modified,
}
+ rec.dated = meta.type === "document" ? parseDate(meta.date) ?? rec.filed : rec.filed
records.push(rec)
// A record belongs to every topic its category or tags name, and to
@@ -380,6 +431,7 @@ function build(ctx: BuildCtx, allFiles: QuartzPluginData[]): Model {
if (entry) hits.add(topicFor(entry))
}
if (segs[0] === bucket && segs.length > 2 && !KIND_FOLDERS.has(segs[1])) hits.add(folderTopic(segs[1]))
+ recTopics.set(slug, [...hits])
for (const t of hits) {
t.records.push(rec)
t.last = newest(t.last, rec.filed)
@@ -388,7 +440,7 @@ function build(ctx: BuildCtx, allFiles: QuartzPluginData[]): Model {
const lists = new Map()
const addList = (page: ListPage) => lists.set(page.slug, page)
- const slugs = (rs: Rec[]) => [...rs].sort(byFiledDesc).map((r) => r.slug)
+ const slugs = (rs: Rec[]) => [...rs].sort(byDatedDesc).map((r) => r.slug)
// Every visible topic gets a page: its own about page when the curator
// wrote one, otherwise a list page with its tasks and records.
@@ -489,14 +541,28 @@ function build(ctx: BuildCtx, allFiles: QuartzPluginData[]): Model {
return { label: g.label, slug, count: g.recs.length }
})
- const years = new Map()
+ const years = new Map()
for (const r of docs) {
const y = r.slug.split("/")[2]
- if (/^\d{4}$/.test(y)) years.set(y, (years.get(y) ?? 0) + 1)
+ if (/^\d{4}$/.test(y)) years.set(y, [...(years.get(y) ?? []), r])
}
const yearNodes = [...years.entries()]
.sort((a, b) => Number(b[0]) - Number(a[0]))
- .map(([y, n]) => ({ label: y, slug: `${bucket}/documents/${y}/index`, count: n }))
+ .map(([y, rs]) => {
+ const slug = `lists/year/${y}`
+ addList({ slug, title: y, intro: L.yearIntro, sections: [{ slugs: slugs(rs) }] })
+ return { label: y, slug, count: rs.length }
+ })
+ if (docs.length)
+ addList({
+ slug: "lists/documents",
+ title: L.documents,
+ intro: L.documentsIntro,
+ sections: [
+ { heading: L.needsAttention, slugs: slugs(attention) },
+ { heading: L.latest, slugs: slugs(docs).slice(0, 30) },
+ ],
+ })
// Senders: correspondent pages, the ones that wrote most recently first.
const corrPages = new Map()
@@ -561,8 +627,115 @@ function build(ctx: BuildCtx, allFiles: QuartzPluginData[]): Model {
nav.push({ key: "people", label: L.people, children: people.sort((a, b) => a.label.localeCompare(b.label, lang)) })
if (areaNodes.length) nav.push({ key: "topics", label: L.topics, children: areaNodes })
if (docs.length || emails.length)
- nav.push({ key: "documents", label: L.documents, slug: `${bucket}/documents/index`, count: docs.length, children: docChildren })
+ nav.push({ key: "documents", label: L.documents, slug: "lists/documents", count: docs.length, children: docChildren })
if (captureNodes.length) nav.push({ key: "notes", label: L.notesLinks, children: captureNodes })
- return { nav, lists }
+ // ── page header and breadcrumbs ──
+ const bySlug = new Map(allFiles.map((f) => [String(f.slug), f]))
+ const peopleBy = new Map(people.map((p) => [fold(p.label), p.slug!]))
+ const personChip = (name: string) => {
+ const slug = peopleBy.get(fold(name)) ?? peopleBy.get(fold(name.split(" ")[0]))
+ return { label: name.charAt(0).toUpperCase() + name.slice(1), slug }
+ }
+ const kindLabel: Record = { note: L.note, bookmark: L.bookmark, email: L.email }
+
+ const describe = (f: QuartzPluginData) => {
+ const m = fm(f)
+ const meta: string[] = []
+ let date: Date | undefined
+ if (m.type === "document") {
+ const e = typeIdx.get(fold(String(m.document_type ?? "")))
+ if (m.document_type) meta.push(e ? nameOf(e) : String(m.document_type))
+ if (m.correspondent) meta.push(String(m.correspondent))
+ date = parseDate(m.date) ?? parseDate(m.timestamp)
+ } else {
+ meta.push(kindLabel[m.type] ?? String(m.type))
+ if (m.type === "bookmark" && m.resource) {
+ try {
+ meta.push(new URL(String(m.resource)).hostname.replace(/^www\./, ""))
+ } catch {}
+ }
+ date = parseDate(m.timestamp) ?? parseDate(m.date)
+ }
+ const persons = asList(m.persons)
+ if (persons.length) meta.push(persons.map((p) => personChip(p).label).join(", "))
+ return { date, meta }
+ }
+
+ const trim = (s?: string) => (s ?? "").replace(/\/index$/, "")
+ const pathTo = (nodes: NavNode[], slug: string, trail: NavNode[] = []): NavNode[] | undefined => {
+ for (const n of nodes) {
+ if (n.key === "recent") continue
+ if (n.slug && trim(n.slug) === trim(slug)) return trail
+ const hit = n.children && pathTo(n.children, slug, [...trail, n])
+ if (hit) return hit
+ }
+ return undefined
+ }
+ const topLevel = (key: string) => nav.find((n) => n.key === key)
+ const crumbs = (slug: string): NavNode[] => {
+ const start = nav[0]
+ const inNav = pathTo(nav, slug)
+ if (inNav) return [start, ...inNav]
+ const m = fm(bySlug.get(slug) ?? ({} as QuartzPluginData))
+ const topic = recTopics.get(slug)?.find((t) => t.room && t.slug)
+ if (topic) {
+ const trail = pathTo(nav, topic.slug!)
+ if (trail) return [start, ...trail, topicNode(topic)]
+ }
+ if (m.type === "document") {
+ const e = typeIdx.get(fold(String(m.document_type ?? "")))
+ const node = topLevel("documents")?.children?.find((c) => c.label === L.byType)?.children
+ ?.find((c) => c.slug === `lists/type/${e?.id ?? ""}`)
+ return [start, topLevel("documents")!, ...(node ? [node] : [])].filter(Boolean)
+ }
+ if (m.type === "correspondent") {
+ const docsNode = topLevel("documents")
+ const senders = docsNode?.children?.find((c) => c.label === L.senders)
+ return [start, ...(docsNode ? [docsNode] : []), ...(senders ? [senders] : [])]
+ }
+ if (m.type === "note" || m.type === "bookmark") {
+ const notesNode = topLevel("notes")
+ const leaf = notesNode?.children?.find((c) => c.slug === `lists/${m.type}s`)
+ return [start, ...(notesNode ? [notesNode] : []), ...(leaf ? [leaf] : [])]
+ }
+ return [start]
+ }
+
+ const head = (slug: string): Head => {
+ const f = bySlug.get(slug)
+ const m = fm(f ?? ({} as QuartzPluginData))
+ const list = lists.get(slug)
+ if (list) {
+ const trail = crumbs(slug).slice(1)
+ return { kicker: trail.map((n) => n.label), meta: [], chips: [] }
+ }
+ if (RECORD_KINDS.has(m.type)) {
+ const d = describe(f!)
+ const [first, ...rest] = d.meta
+ const kicker = m.type === "document" ? [L.document, first] : [first]
+ const topicsOf = (recTopics.get(slug) ?? []).filter((t) => t.slug)
+ return {
+ kicker: kicker.filter(Boolean),
+ meta: m.type === "document" ? rest.slice(0, 1) : m.filed_by ? [`${L.filedBy} ${personChip(String(m.filed_by)).label}`] : [],
+ date: d.date,
+ chips: [
+ ...topicsOf.map((t) => ({ label: t.label, slug: t.slug })),
+ ...asList(m.persons).map(personChip),
+ ],
+ }
+ }
+ const updated = f?.dates?.modified
+ if (m.type === "person") return { kicker: [L.person], meta: [], date: updated, chips: [] }
+ if (m.type === "correspondent") return { kicker: [L.sender], meta: [], date: updated, chips: [] }
+ if (m.type === "topic") {
+ const t = [...topics.values()].find((x) => x.slug === slug)
+ const area = onto.areas.find((a) => a.id === t?.area)
+ return { kicker: [L.topic, ...(area ? [nameOf(area)] : [])], meta: [], date: updated, chips: [] }
+ }
+ if (slug.startsWith(`${bucket}/diary/`)) return { kicker: [L.diary], meta: [], date: updated, chips: [] }
+ return { kicker: [], meta: [], date: slug === "index" ? undefined : updated, chips: [] }
+ }
+
+ return { nav, lists, head, crumbs, describe }
}
diff --git a/stacklets/memory/quartz/custom.scss b/stacklets/memory/quartz/custom.scss
index fc6981be..edb1cd5b 100644
--- a/stacklets/memory/quartz/custom.scss
+++ b/stacklets/memory/quartz/custom.scss
@@ -106,10 +106,14 @@ h6 {
0 10px 28px rgba(22, 31, 36, 0.05);
padding: 2rem 2.5rem 2.5rem;
box-sizing: border-box;
+ // Down from the window edge, so the first line on the page sits level
+ // with the family's name in the sidebar.
+ margin-top: 3.5rem;
@media all and (max-width: 800px) {
padding: 1.25rem 1.25rem 2rem;
border-radius: 8px;
+ margin-top: 0;
}
}
@@ -278,3 +282,58 @@ footer {
footer ul {
font-size: 0.72rem;
}
+
+// ── Scrollbars ──────────────────────────────────────────────────────
+//
+// Thin and quiet, in the teal of the borders. The sidebars scroll on
+// their own, and a stock scrollbar is the heaviest line on the page.
+
+* {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(61, 143, 160, 0.3) transparent;
+}
+
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: rgba(61, 143, 160, 0.3);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+// ── One title per page ──────────────────────────────────────────────
+//
+// FamilyHead takes a page's own leading `# Heading` as the title, so the
+// copy at the top of the article goes. Only a heading that opens the
+// article: one further down is a section and stays.
+
+.center article > h1:first-child {
+ display: none;
+}
+
+// The writers put a meta line under that heading ("From: … · About: …",
+// "About … · Captured …"), which FamilyHead now shows as the date line
+// and chips. Only a quote that opens with a bold label goes, so a plain
+// quote under a heading (the home page's address) stays.
+.center article > h1:first-child + blockquote:has(> p:first-child > strong:first-child) {
+ display: none;
+}
+
+// ── Search, as the sketch's ask box ─────────────────────────────────
+
+.search > .search-button {
+ background: var(--fs-paper);
+ border: 1px solid var(--fs-border);
+ border-radius: 10px;
+ min-height: 2.5rem;
+}
+
+.search > .search-button:hover {
+ border-color: var(--secondary);
+}
diff --git a/stacklets/memory/quartz/quartz.layout.ts b/stacklets/memory/quartz/quartz.layout.ts
index fbd04528..688b1db2 100644
--- a/stacklets/memory/quartz/quartz.layout.ts
+++ b/stacklets/memory/quartz/quartz.layout.ts
@@ -16,6 +16,9 @@ import * as Component from "./quartz/components"
import FamstackTitle from "./quartz/components/FamstackTitle"
import Welcome from "./quartz/components/Welcome"
import FamilyNav from "./quartz/components/FamilyNav"
+import FamilyCrumbs from "./quartz/components/FamilyCrumbs"
+import FamilyHead from "./quartz/components/FamilyHead"
+import { L } from "./quartz/components/familyModel"
// `CODE_URL` is set in the container env from {code_url} — the
// user-facing Forgejo URL. Empty falls back to a `#` placeholder so
@@ -29,7 +32,7 @@ export const sharedPageComponents: SharedLayout = {
afterBody: [],
footer: Component.Footer({
links: {
- "Edit on Forgejo": repoUrl,
+ [L.editOnForgejo]: repoUrl,
},
}),
}
@@ -43,13 +46,13 @@ export const sharedPageComponents: SharedLayout = {
// palette, not before.
export const defaultContentPageLayout: PageLayout = {
beforeBody: [
+ // Breadcrumbs, a label, one title, and the page's topics and people.
+ // See FamilyCrumbs.tsx and FamilyHead.tsx.
Component.ConditionalRender({
- component: Component.Breadcrumbs(),
+ component: FamilyCrumbs(),
condition: (page) => page.fileData.slug !== "index",
}),
- Component.ArticleTitle(),
- Component.ContentMeta(),
- Component.TagList(),
+ FamilyHead(),
// The greeting belongs to the front door only.
Component.ConditionalRender({
component: Welcome(),
@@ -60,10 +63,7 @@ export const defaultContentPageLayout: PageLayout = {
FamstackTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Flex({
- components: [
- { Component: Component.Search(), grow: true },
- { Component: Component.ReaderMode() },
- ],
+ components: [{ Component: Component.Search(), grow: true }],
}),
// The family's structure, not the vault's folders. See FamilyNav.tsx.
FamilyNav(),
@@ -80,9 +80,8 @@ export const defaultContentPageLayout: PageLayout = {
// graph.
export const defaultListPageLayout: PageLayout = {
beforeBody: [
- Component.Breadcrumbs(),
- Component.ArticleTitle(),
- Component.ContentMeta(),
+ FamilyCrumbs(),
+ FamilyHead(),
],
left: [
FamstackTitle(),
From 3862fe864bd61650c31a7fd6ba450e5ecc269bc3 Mon Sep 17 00:00:00 2001
From: Arthur
Date: Fri, 25 Sep 2026 21:38:24 +0200
Subject: [PATCH 4/5] style(wiki): tighten the menu and fit it to phone screens
Less space between the title, search and menu, shorter menu rows, and
both sidebars start at the top edge of the page card.
On a phone the menu opens as a panel over the page, under the header
row, instead of inside that row where it squeezed the title and search.
It folds when a page opens or on a tap outside it. The family's name
stays on one line.
---
.../memory/quartz/components/FamilyNav.tsx | 31 ++++++++++++-----
.../quartz/components/FamstackTitle.tsx | 2 +-
.../components/scripts/familyNav.inline.ts | 11 ++++--
stacklets/memory/quartz/custom.scss | 34 +++++++++++++++++++
4 files changed, 66 insertions(+), 12 deletions(-)
diff --git a/stacklets/memory/quartz/components/FamilyNav.tsx b/stacklets/memory/quartz/components/FamilyNav.tsx
index bf9a1df6..13576455 100644
--- a/stacklets/memory/quartz/components/FamilyNav.tsx
+++ b/stacklets/memory/quartz/components/FamilyNav.tsx
@@ -27,7 +27,7 @@ FamilyNav.afterDOMLoaded = script
FamilyNav.css = `
.family-nav {
- margin-top: 0.5rem;
+ margin-top: 0;
min-height: 0;
overflow-y: auto;
flex: 1 1 auto;
@@ -77,8 +77,8 @@ FamilyNav.css = `
display: flex;
align-items: baseline;
gap: 0.5rem;
- min-height: 2rem;
- padding: 0.2rem 0.5rem;
+ min-height: 1.7rem;
+ padding: 0.1rem 0.5rem;
border-radius: 6px;
color: var(--darkgray);
/* Quartz makes every link bold; here weight marks the current page only. */
@@ -110,7 +110,7 @@ FamilyNav.css = `
/* Level 1 reads like the site's headings: Newsreader, a little larger.
A leaf at level 1 (Start) is indented to line up with the folded ones. */
.family-nav .fn-l1 {
- margin-bottom: 0.2rem;
+ margin-bottom: 0.1rem;
}
.family-nav .fn-l1 > .fn-link,
@@ -126,7 +126,7 @@ FamilyNav.css = `
}
.family-nav .fn-l1 ul {
- margin: 0.1rem 0 0.35rem 0.3rem;
+ margin: 0 0 0.3rem 0.3rem;
padding-left: 0.6rem;
border-left: 1px solid var(--lightgray);
}
@@ -165,12 +165,14 @@ FamilyNav.css = `
.family-nav * { animation: none !important; transition: none !important; }
}
-/* On a phone the sidebar is a row above the page; the navigation folds
- behind a Menu button there. */
+/* On a phone the sidebar is a row above the page: title, search, and a
+ Menu button. The menu opens as a panel over the page, under that row,
+ because inside the row it would squeeze everything beside it. */
@media all and (max-width: 800px) {
.family-nav {
- flex-basis: 100%;
+ flex: 0 0 auto;
margin-top: 0;
+ overflow: visible;
}
.family-nav .fn-menu > summary {
@@ -184,7 +186,18 @@ FamilyNav.css = `
}
.family-nav .fn-menu[open] > .fn-tree {
- margin-top: 0.6rem;
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: calc(100% + 0.5rem);
+ z-index: 30;
+ max-height: calc(100vh - 7rem);
+ overflow-y: auto;
+ padding: 0.75rem 0.75rem 0.75rem 0.4rem;
+ background: #f7f6f0;
+ border: 1px solid rgba(61, 143, 160, 0.22);
+ border-radius: 12px;
+ box-shadow: 0 12px 32px rgba(22, 31, 36, 0.16);
}
}
`
diff --git a/stacklets/memory/quartz/components/FamstackTitle.tsx b/stacklets/memory/quartz/components/FamstackTitle.tsx
index deca69ab..06b9e666 100644
--- a/stacklets/memory/quartz/components/FamstackTitle.tsx
+++ b/stacklets/memory/quartz/components/FamstackTitle.tsx
@@ -42,7 +42,7 @@ FamstackTitle.css = `
display: flex;
flex-direction: column;
gap: 0.3rem;
- padding-bottom: 1rem;
+ padding-bottom: 0.8rem;
border-bottom: 1px solid rgba(61, 143, 160, 0.14);
}
diff --git a/stacklets/memory/quartz/components/scripts/familyNav.inline.ts b/stacklets/memory/quartz/components/scripts/familyNav.inline.ts
index 3c9c73d9..08486189 100644
--- a/stacklets/memory/quartz/components/scripts/familyNav.inline.ts
+++ b/stacklets/memory/quartz/components/scripts/familyNav.inline.ts
@@ -70,8 +70,15 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
for (const root of document.querySelectorAll(".family-nav")) {
const tree = root.querySelector(".fn-tree")!
tree.replaceChildren(...nav.map((n) => item(n, 1, here)))
- // On a phone the navigation starts folded behind its Menu button.
+ // On a phone the navigation starts folded behind its Menu button, and
+ // a tap anywhere outside the open panel folds it again.
+ const phone = window.matchMedia("(max-width: 800px)")
const menu = root.querySelector(".fn-menu")!
- menu.open = !window.matchMedia("(max-width: 800px)").matches
+ menu.open = !phone.matches
+ const fold = (ev: MouseEvent) => {
+ if (phone.matches && menu.open && !root.contains(ev.target as Node)) menu.open = false
+ }
+ document.addEventListener("click", fold)
+ window.addCleanup(() => document.removeEventListener("click", fold))
}
})
diff --git a/stacklets/memory/quartz/custom.scss b/stacklets/memory/quartz/custom.scss
index edb1cd5b..27153a54 100644
--- a/stacklets/memory/quartz/custom.scss
+++ b/stacklets/memory/quartz/custom.scss
@@ -337,3 +337,37 @@ footer ul {
.search > .search-button:hover {
border-color: var(--secondary);
}
+
+// ── Left sidebar rhythm ─────────────────────────────────────────────
+//
+// Upstream spaces sidebar panels for a search box, a graph and a file
+// tree. Ours is a title, search and one menu, which reads as one block
+// when the gaps are smaller.
+
+.page > #quartz-body .sidebar.left {
+ gap: 1rem;
+}
+
+// Both sidebars start level with the top edge of the page card.
+@media all and (min-width: 801px) {
+ .page > #quartz-body .sidebar.left,
+ .page > #quartz-body .sidebar.right {
+ padding-top: 3.5rem;
+ }
+}
+
+// ── Phone header ────────────────────────────────────────────────────
+//
+// The sidebar becomes the row above the page. It is the positioned box
+// the open menu panel hangs from (see FamilyNav.tsx), and the family's
+// name stays on one line in it.
+
+@media all and (max-width: 800px) {
+ .page > #quartz-body .sidebar.left {
+ position: relative;
+ }
+
+ .famstack-title .page-title {
+ white-space: nowrap;
+ }
+}
From 7f529e72186a9cff804b694cc199cb604e9b7776 Mon Sep 17 00:00:00 2001
From: Arthur
Date: Fri, 25 Sep 2026 21:47:14 +0200
Subject: [PATCH 5/5] fix(diary): link the diary index to its years and months
The diary index linked each year as `2026/about` and each year page its
months as `03`, relative to the page. The wiki resolves links from the
vault root, where those name no page, so every year and month link on
the diary pages opened a 404.
The links are now root-based (`/family/diary/2026/03`), like the other
links the wiki generates. The pages are rewritten the next time the
diary compiles.
---
stacklets/memory/bot/cli/diary.py | 4 +--
stacklets/memory/bot/diary.py | 26 +++++++++-------
tests/unit/stacklets/test_memory_diary.py | 30 ++++++++++---------
.../unit/stacklets/test_memory_diary_card.py | 4 +--
4 files changed, 36 insertions(+), 28 deletions(-)
diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py
index 5183358f..0a8f3c4c 100644
--- a/stacklets/memory/bot/cli/diary.py
+++ b/stacklets/memory/bot/cli/diary.py
@@ -1193,7 +1193,7 @@ async def run(llm, argv: list[str]) -> int:
# explains how to record something.
_err(f"nothing recorded in {room_arg} yet, publishing an empty diary")
await transcriber.aclose()
- return _publish_pages(diary.pages_for([]),
+ return _publish_pages(diary.pages_for([], bucket=bucket),
bucket=bucket, dry_run=dry_run)
_err(f"{len(messages)} message(s) in {room_arg}")
@@ -1372,7 +1372,7 @@ async def _project(records, llm, summaries_cache, *, room_id: str,
summaries_cache.save()
summaries_cache.save()
- pages = diary.pages_for(entries, room_id=room_id, summaries=summaries,
+ pages = diary.pages_for(entries, bucket=bucket, room_id=room_id, summaries=summaries,
media=diary_card.media_of(on_file))
return _publish_pages(pages, bucket=bucket, dry_run=dry_run)
diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py
index 111b2f52..99b0da0e 100644
--- a/stacklets/memory/bot/diary.py
+++ b/stacklets/memory/bot/diary.py
@@ -1122,7 +1122,7 @@ def render_month(entries, *, room_id: str = "", summary: str = "",
return "\n".join(lines).rstrip() + "\n"
-def render_year(entries) -> str:
+def render_year(entries, *, bucket: str) -> str:
"""A year's landing page: its months, and who is in them.
Deterministic. Counting entries and naming the people who appear is
@@ -1144,15 +1144,20 @@ def render_year(entries) -> str:
label = _month_name(month[0].on)
count = len(month)
lines.append(
- f"- [{label}]({key}): "
+ f"- [{label}](/{bucket}/{DIARY_DIR}/{year}/{key}): "
f"{_counted(count, 'entry_one', 'entry_many')}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
-def render_index(entries) -> str:
- """The diary's front door: what it is, and a way into every year."""
+def render_index(entries, *, bucket: str) -> str:
+ """The diary's front door: what it is, and a way into every year.
+
+ Links are root-based (`//diary/2026/about`), like every link
+ the wiki generates: the wiki resolves links from the vault root, where
+ a page-relative `2026/about` names no page.
+ """
lines = [
f"# {_L['diary_title']}",
"",
@@ -1172,7 +1177,7 @@ def render_index(entries) -> str:
count = len(year)
months = len(_by_month(year))
lines.append(
- f"- [{key}]({key}/about): "
+ f"- [{key}](/{bucket}/{DIARY_DIR}/{key}/about): "
f"{_counted(count, 'entry_one', 'entry_many')} "
f"{_L['across']} "
f"{_counted(months, 'month_one', 'month_many')}")
@@ -1202,7 +1207,7 @@ def _and_list(names: list[str]) -> str:
return ", ".join(names[:-1]) + f" {_L['and']} {names[-1]}"
-def pages_for(entries, *, room_id: str = "",
+def pages_for(entries, *, bucket: str, room_id: str = "",
summaries: "dict[str, str] | None" = None,
media: "dict[str, str] | None" = None,
) -> list[tuple[str, str, str]]:
@@ -1219,18 +1224,19 @@ def pages_for(entries, *, room_id: str = "",
contents nobody can read.
Paths are relative to the shared bucket, which the caller prefixes
- -- the bucket is named in config (`family`, `office`, a surname)
- and this module has no business knowing which.
+ -- the bucket is named in config (`family`, `office`, a surname).
+ `bucket` is that name, needed only for the links between these pages,
+ which the wiki resolves from the vault root.
`summaries` and `media` arrive the same way and for the same
reason: both are the result of work this module cannot do (a model
call; a download and a file write), handed in keyed by month and by
event id so rendering stays a pure function of its inputs.
"""
- out = [(f"{DIARY_DIR}/about.md", render_index(entries), _L["diary_title"])]
+ out = [(f"{DIARY_DIR}/about.md", render_index(entries, bucket=bucket), _L["diary_title"])]
for year, in_year in sorted(_by_year(entries).items()):
out.append((
- f"{DIARY_DIR}/{year}/about.md", render_year(in_year), year,
+ f"{DIARY_DIR}/{year}/about.md", render_year(in_year, bucket=bucket), year,
))
for month, in_month in sorted(_by_month(in_year).items()):
out.append((
diff --git a/tests/unit/stacklets/test_memory_diary.py b/tests/unit/stacklets/test_memory_diary.py
index 1d872ef9..eed73db8 100644
--- a/tests/unit/stacklets/test_memory_diary.py
+++ b/tests/unit/stacklets/test_memory_diary.py
@@ -851,7 +851,7 @@ def test_a_month_without_a_summary_goes_straight_to_its_entries(self):
def test_a_summary_reaches_the_month_it_describes(self):
pages = {path: body for path, body, _title in
- diary.pages_for(_compile(),
+ diary.pages_for(bucket="family", entries=_compile(),
summaries={"2026-03": "Only March."})}
assert "Only March." in pages["diary/2026/03.md"]
@@ -860,25 +860,27 @@ def test_a_summary_reaches_the_month_it_describes(self):
def test_the_index_lists_a_page_for_every_year(self):
entries = _compile()
- index = diary.render_index(entries)
+ index = diary.render_index(entries, bucket="family")
+ # Root-based, like every generated link: the wiki resolves links
+ # from the vault root, where `2026/about` names no page.
for key in {diary.year_key(e.on) for e in entries}:
- assert f"]({key}/about)" in index
+ assert f"](/family/diary/{key}/about)" in index
def test_a_year_lists_its_months_and_who_is_in_them(self):
entries = _compile()
in_2026 = [e for e in entries if e.on.year == 2026]
- page = diary.render_year(in_2026)
+ page = diary.render_year(in_2026, bucket="family")
assert "## Months" in page
- assert "[March](03)" in page
+ assert "[March](/family/diary/2026/03)" in page
assert "recorded by Marge and Homer" in page
def test_a_year_credits_only_who_recorded(self):
"""An addressee is the model's reading, not a fact about the
household, so it never reaches a landing page."""
- page = diary.render_year([diary.Entry(
+ page = diary.render_year(bucket="family", entries=[diary.Entry(
on=date(2026, 3, 16), confidence="spoken", basis="b", kind="voice",
sender="marge", body="words", addressee="Bart")])
@@ -891,7 +893,7 @@ def test_a_diary_nests_month_inside_year(self):
Flat `2026-03.md` files pile every month of every year into one
folder, which is the explorer sidebar the family actually reads.
"""
- paths = [path for path, _body, _title in diary.pages_for(_compile())]
+ paths = [path for path, _body, _title in diary.pages_for(bucket="family", entries=_compile())]
assert "diary/about.md" in paths
assert "diary/2026/about.md" in paths
@@ -902,7 +904,7 @@ def test_a_folders_own_page_is_about_not_index(self):
"""Quartz renders a folder URL through a layout with no body in
this wiki, so an `index.md` would be unreadable. Every other
entity here is `about.md` for the same reason."""
- paths = [path for path, _body, _title in diary.pages_for(_compile())]
+ paths = [path for path, _body, _title in diary.pages_for(bucket="family", entries=_compile())]
assert not any(p.endswith("index.md") for p in paths)
@@ -910,7 +912,7 @@ def test_a_month_page_is_titled_with_its_year(self):
"""The folder gives context in the sidebar; a link or a search
result does not, so the title carries the year itself."""
titles = {path: title for path, _body, title in
- diary.pages_for(_compile())}
+ diary.pages_for(bucket="family", entries=_compile())}
assert titles["diary/2026/03.md"] == "March 2026"
assert titles["diary/2026/about.md"] == "2026"
@@ -1160,7 +1162,7 @@ class TestEmptyDiary:
"""
def test_it_says_how_to_record_something(self):
- page = diary.render_index([])
+ page = diary.render_index([], bucket="family")
assert "Memories" in page # names the room
assert "voice message" in page.lower()
@@ -1169,7 +1171,7 @@ def test_it_says_how_to_record_something(self):
def test_it_keeps_the_diary_title_and_opening(self):
"""Not a separate error page: the same front door, with the
years replaced by the note on how to fill them."""
- page = diary.render_index([])
+ page = diary.render_index([], bucket="family")
assert page.startswith("# Family Diary")
assert "## Years" not in page
@@ -1178,7 +1180,7 @@ def test_it_explains_the_two_things_that_are_not_obvious(self):
"""Speaking the date and replying to a message both change what
the compiler does with a recording, and neither is guessable
from the room."""
- page = diary.render_index([])
+ page = diary.render_index([], bucket="family")
assert "Today is the third of March" in page
assert "Reply to a message" in page
@@ -1186,7 +1188,7 @@ def test_it_explains_the_two_things_that_are_not_obvious(self):
def test_it_is_written_in_the_household_language(self):
diary.configure_language("de")
try:
- page = diary.render_index([])
+ page = diary.render_index([], bucket="family")
assert "Noch nichts aufgenommen" in page
assert "Nothing recorded yet" not in page
finally:
@@ -1195,7 +1197,7 @@ def test_it_is_written_in_the_household_language(self):
def test_a_compiled_diary_shows_years_instead(self):
"""The note is for the empty case only. One entry and the page
goes back to being an index."""
- page = diary.render_index(_compile())
+ page = diary.render_index(_compile(), bucket="family")
assert "Press record." not in page
assert "## Years" in page
diff --git a/tests/unit/stacklets/test_memory_diary_card.py b/tests/unit/stacklets/test_memory_diary_card.py
index 61d9e83e..ca78b877 100644
--- a/tests/unit/stacklets/test_memory_diary_card.py
+++ b/tests/unit/stacklets/test_memory_diary_card.py
@@ -87,12 +87,12 @@ def test_the_diary_pages_from_cards_are_the_pages_from_entries(self):
direct = diary.pages_for(
[replace(e, gist="", moments=[]) for e in entries],
- room_id=ROOM, media=media)
+ bucket="family", room_id=ROOM, media=media)
# What the card adds on purpose (its title and summary) is set
# aside: this pins that nothing of the entry itself is lost.
from_cards = diary.pages_for(
[replace(diary_card.to_entry(c), gist="", moments=[], title="") for c in cards],
- room_id=ROOM, media=diary_card.media_of(cards))
+ bucket="family", room_id=ROOM, media=diary_card.media_of(cards))
assert from_cards == direct
def test_a_long_recording_shows_the_cards_summary_and_its_quotes(self):