Use Norn for the shared graph SPA

This commit is contained in:
Meta Vens 2026-08-27 00:20:52 +02:00
commit 1cf053a4d3
14 changed files with 201 additions and 91 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
quartz/graph-spa/node_modules/
quartz/graph-spa/dist/
*.tsbuildinfo

View file

@ -8,6 +8,7 @@
- Keep site-specific copy, branding, mastheads, sidebars, and layout quirks in the consumer repo overlays.
- Shared engine changes should be generic by default and useful to more than one site.
- Norn owns reusable graph interaction and rendering. `quartz/graph-spa` owns only the generic Quartz content-index projection, article navigation, and shell; consumer taxonomy, filtering, and styling must arrive through overlay configuration.
- If a consumer repo needs custom page context, provide it through `site/quartz/components/sitePageContext.ts` in that repo instead of hardcoding the shared renderer.
- Do not point sample config at live production domains.
- Prefer reusable GitHub Actions workflows here over copy-pasted Pages workflows in every site repo.

View file

@ -45,15 +45,25 @@ node ..\\GameCult-Quartz\\scripts\\build-site.mjs build --siteRoot E:\\Projects\
Consumer sites can render the shared graph surface with `Component.GameCultGraphSpaShell(...)`.
The component only owns the mount point and asset tags; the consumer overlay decides where it appears.
The shell's default `epiphany-graph` asset route and mount class remain a compatibility contract for
the deployed Mimir and gamecult.org sites. New or migrated consumers should pass the
`norn-graph` asset paths and `gamecult-norn-graph-root` explicitly.
Build the static SPA assets into the consumer repo when its graph payload changes:
```powershell
node ..\\GameCult-Quartz\\scripts\\build-graph-spa.mjs --siteRoot E:\\Projects\\Mimir --outputDir static/epiphany-graph
node ..\\GameCult-Quartz\\scripts\\build-graph-spa.mjs --siteRoot E:\\Projects\\Mimir --outputDir static/norn-graph
```
The SPA source lives in `quartz/graph-spa`. It currently imports the shared `EpiphanyGraphViewer`
from a sibling `EpiphanyGraph` checkout during local builds, so generated assets should be committed
by consumer sites until that viewer is packaged as a normal dependency.
The SPA source lives in `quartz/graph-spa`. It consumes the admitted build of
`@gamecult/norn-viewer` from a sibling `Norn` checkout. The builder verifies the
revision in `quartz/graph-spa/norn-revision.txt`, builds the Norn package, and then
builds the Quartz adapter. Generated assets remain committed by consumer sites.
GameCult-Quartz owns content-index projection, article loading, hash navigation,
and the fullscreen shell. Norn owns graph interaction and rendering. Consumer
sites provide taxonomy, filtering, placement, and presentation through
`GameCultGraphSpaShell` configuration and overlay styles.
## Shared Interactive Embeds

29
package-lock.json generated
View file

@ -86,6 +86,8 @@
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.0.10",
"@types/pretty-time": "^1.1.5",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.1",
"@types/yargs": "^17.0.35",
@ -3163,6 +3165,26 @@
"integrity": "sha512-5yl+BYwmnRWZb783W8YYoHXvPY8q/rp7ctHBVaGBB9RxlzGpHNJ72tGQMK7TrUSnxzl1dbDcBDuBCSbtfnSQGg==",
"dev": true
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/source-map-support": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.5.10.tgz",
@ -3655,6 +3677,13 @@
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",

View file

@ -109,6 +109,8 @@
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.0.10",
"@types/pretty-time": "^1.1.5",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.1",
"@types/yargs": "^17.0.35",

View file

@ -7,13 +7,19 @@ export type GameCultGraphSpaShellOptions = {
config?: {
title?: string
architectureDescription?: string
layoutMode?: "layered" | "stress" | "force" | "combined-force" | {
architecture?: "layered" | "stress" | "force" | "combined-force"
dataflow?: "layered" | "stress" | "force" | "combined-force"
}
layoutMode?:
| "layered"
| "stress"
| "force"
| "combined-force"
| {
architecture?: "layered" | "stress" | "force" | "combined-force"
dataflow?: "layered" | "stress" | "force" | "combined-force"
}
allowedSlugPrefixes?: string[]
blockedSlugPrefixes?: string[]
blockedPathSegments?: string[]
sectionOrder?: string[]
}
}

View file

@ -0,0 +1 @@
fa4a46bb13333eb703e97ccea9edc1b70ee109b7

View file

@ -8,7 +8,6 @@
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.1",
"elkjs": "^0.11.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^5.9.3",

View file

@ -1,16 +1,16 @@
import React from "react"
import { flushSync } from "react-dom"
import ReactDOM from "react-dom/client"
import { EpiphanyGraphViewer } from "../../../../EpiphanyGraph/web/epiphany-graph-viewer/src/lib/EpiphanyGraphViewer"
import { NornViewer } from "@gamecult/norn-viewer"
import type {
EpiphanyGraphEdge,
EpiphanyGraphNode,
EpiphanyGraphLayoutModeConfig,
EpiphanyGraphsState,
NornGraphEdge,
NornGraphNode,
NornGraphLayoutModeConfig,
NornGraphsState,
GraphKey,
PositionedNode,
ViewerSelection,
} from "../../../../EpiphanyGraph/web/epiphany-graph-viewer/src/lib/types"
} from "@gamecult/norn-viewer"
import "./styles.css"
type QuartzContentEntry = {
@ -27,7 +27,8 @@ type QuartzContentIndex = Record<string, QuartzContentEntry>
type GraphSpaConfig = {
title?: string
architectureDescription?: string
layoutMode?: EpiphanyGraphLayoutModeConfig
layoutMode?: NornGraphLayoutModeConfig
sectionOrder?: string[]
allowedSlugPrefixes?: string[]
blockedSlugPrefixes?: string[]
blockedPathSegments?: string[]
@ -59,7 +60,6 @@ declare global {
}
}
const DEFAULT_BLOCKED_SLUG_PREFIXES = ["Inspirations/", "GameCult-Quartz/"]
const DEFAULT_BLOCKED_PATH_SEGMENTS = [
"/.git/",
"/node_modules/",
@ -94,17 +94,6 @@ class GraphErrorBoundary extends React.Component<React.PropsWithChildren, GraphE
}
}
const SECTION_ORDER = [
"World",
"Ecology",
"Species",
"Civilizations",
"Technologies",
"Conflicts",
"Themes",
"Root",
]
function entryTitle(slug: string, entry?: QuartzContentEntry) {
if (entry?.title) {
return entry.title
@ -127,7 +116,7 @@ function normalizeSlug(slug: string) {
function graphSpaConfig() {
const mountedConfig = document.querySelector<HTMLElement>(
".gamecult-epiphany-graph-root[data-graph-config]",
".gamecult-norn-graph-root[data-graph-config], .gamecult-epiphany-graph-root[data-graph-config]",
)?.dataset.graphConfig
if (mountedConfig) {
@ -149,7 +138,7 @@ function slugMatchesPrefix(slug: string, prefix: string) {
function isAllowedGraphSlug(slug: string, entry: QuartzContentEntry, config = graphSpaConfig()) {
const normalizedSlug = normalizeSlug(slug)
const blockedPrefixes = [...DEFAULT_BLOCKED_SLUG_PREFIXES, ...(config.blockedSlugPrefixes ?? [])]
const blockedPrefixes = config.blockedSlugPrefixes ?? []
if (blockedPrefixes.some((prefix) => slugMatchesPrefix(normalizedSlug, prefix))) {
return false
@ -240,13 +229,13 @@ function currentRouteSelection(slugs: Set<string>): ViewerSelection | null {
}
}
function compareSections(left: string, right: string) {
const leftIndex = SECTION_ORDER.indexOf(left)
const rightIndex = SECTION_ORDER.indexOf(right)
function compareSections(left: string, right: string, sectionOrder: string[]) {
const leftIndex = sectionOrder.indexOf(left)
const rightIndex = sectionOrder.indexOf(right)
if (leftIndex !== -1 || rightIndex !== -1) {
const orderedLeft = leftIndex === -1 ? SECTION_ORDER.length : leftIndex
const orderedRight = rightIndex === -1 ? SECTION_ORDER.length : rightIndex
const orderedLeft = leftIndex === -1 ? sectionOrder.length : leftIndex
const orderedRight = rightIndex === -1 ? sectionOrder.length : rightIndex
return orderedLeft - orderedRight
}
@ -324,11 +313,12 @@ function buildSectionEdges(entries: Array<[string, QuartzContentEntry]>, slugs:
sourceSection === targetSection
? `${count} wiki ${count === 1 ? "link stays" : "links stay"} inside ${sourceSection}.`
: `${count} wiki ${count === 1 ? "link runs" : "links run"} from ${sourceSection} into ${targetSection}.`,
} satisfies EpiphanyGraphEdge
} satisfies NornGraphEdge
})
}
function buildGraphState(contentIndex: QuartzContentIndex): EpiphanyGraphsState {
function buildGraphState(contentIndex: QuartzContentIndex): NornGraphsState {
const config = graphSpaConfig()
const entries = Object.entries(contentIndex)
.map(([slug, entry]) => {
const normalizedSlug = normalizeSlug(entry.slug || slug)
@ -342,7 +332,7 @@ function buildGraphState(contentIndex: QuartzContentIndex): EpiphanyGraphsState
const slugs = new Set(entries.map(([slug]) => slug))
const incoming = buildIncomingLinks(entries, slugs)
const architectureNodes: EpiphanyGraphNode[] = entries.map(([slug, entry]) => {
const architectureNodes: NornGraphNode[] = entries.map(([slug, entry]) => {
const outgoingCount = validLinkedSlugs(entry, slugs).length
const backlinkCount = incoming.get(slug)?.length ?? 0
@ -356,7 +346,7 @@ function buildGraphState(contentIndex: QuartzContentIndex): EpiphanyGraphsState
}
})
const architectureEdges: EpiphanyGraphEdge[] = entries.flatMap(([sourceSlug, entry]) =>
const architectureEdges: NornGraphEdge[] = entries.flatMap(([sourceSlug, entry]) =>
validLinkedSlugs(entry, slugs).map((targetSlug, index) => ({
id: `${sourceSlug}->${targetSlug}:${index}`,
source_id: sourceSlug,
@ -367,10 +357,10 @@ function buildGraphState(contentIndex: QuartzContentIndex): EpiphanyGraphsState
})),
)
const sections = Array.from(new Set(entries.map(([slug]) => sectionForSlug(slug)))).sort(
compareSections,
const sections = Array.from(new Set(entries.map(([slug]) => sectionForSlug(slug))).values()).sort(
(left, right) => compareSections(left, right, config.sectionOrder ?? []),
)
const dataflowNodes: EpiphanyGraphNode[] = sections.map((section) => {
const dataflowNodes: NornGraphNode[] = sections.map((section) => {
const sectionEntries = entries.filter(([slug]) => sectionForSlug(slug) === section)
const sectionSlugs = new Set(sectionEntries.map(([slug]) => slug))
const outgoingCrossSection = sectionEntries.reduce((count, [, entry]) => {
@ -470,7 +460,7 @@ function selectedNoteSlug(selection: ViewerSelection | null) {
}
function App() {
const [graphState, setGraphState] = React.useState<EpiphanyGraphsState | null>(null)
const [graphState, setGraphState] = React.useState<NornGraphsState | null>(null)
const [contentIndex, setContentIndex] = React.useState<QuartzContentIndex | null>(null)
const [selection, setSelection] = React.useState<ViewerSelection | null>(null)
const [viewportTarget, setViewportTarget] = React.useState<ViewerSelection | null>(null)
@ -635,16 +625,16 @@ function App() {
if (!graphState) {
return (
<main className="gamecult-epiphany-graph-app">
<main className="gamecult-norn-graph-app">
<div className="gamecult-graph-loading">Loading Quartz backlink graph...</div>
</main>
)
}
return (
<main className="gamecult-epiphany-graph-app">
<main className="gamecult-norn-graph-app">
<GraphErrorBoundary>
<EpiphanyGraphViewer
<NornViewer
state={graphState}
title={graphSpaConfig().title ?? "GameCult Knowledge Graph"}
selection={selection}
@ -829,7 +819,6 @@ function ArticlePanel({
{articleState.status === "ready" && (
<div
className="gamecult-spa-article"
data-epiphany-article-content
onClick={onArticleLinkClick}
dangerouslySetInnerHTML={{ __html: articleState.html }}
/>
@ -837,7 +826,6 @@ function ArticlePanel({
{articleState.status !== "ready" && articleState.html && (
<div
className="gamecult-spa-article"
data-epiphany-article-content
onClick={onArticleLinkClick}
dangerouslySetInnerHTML={{ __html: articleState.html }}
/>
@ -863,25 +851,27 @@ function initialsForTitle(title: string) {
}
function mountGameCultGraph() {
document.querySelectorAll(".gamecult-epiphany-graph-root").forEach((element) => {
if (!(element instanceof HTMLElement)) {
return
}
document
.querySelectorAll(".gamecult-norn-graph-root, .gamecult-epiphany-graph-root")
.forEach((element) => {
if (!(element instanceof HTMLElement)) {
return
}
if (element.dataset.ready === "true" && element.childElementCount > 0) {
return
}
if (element.dataset.ready === "true" && element.childElementCount > 0) {
return
}
const root = ReactDOM.createRoot(element)
flushSync(() => {
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
const root = ReactDOM.createRoot(element)
flushSync(() => {
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
})
element.dataset.ready = "true"
})
element.dataset.ready = "true"
})
}
function scheduleGameCultGraphMount() {

View file

@ -15,17 +15,18 @@ body {
font-family: "Ubuntu", "Segoe UI", sans-serif;
}
.gamecult-epiphany-graph-app {
.gamecult-norn-graph-app {
box-sizing: border-box;
width: 100vw;
min-height: 100vh;
padding: 0;
}
.gamecult-epiphany-graph-app * {
.gamecult-norn-graph-app * {
box-sizing: border-box;
}
.gamecult-norn-graph-root,
.gamecult-epiphany-graph-root {
position: fixed;
inset: 0;
@ -65,7 +66,7 @@ body.gamecult-graph-spa-active {
height: 100vh;
}
.gamecult-graph-shell .epiphany-graph-overlay-panel {
.gamecult-graph-shell .norn-overlay-panel {
opacity: 0.25;
transition:
opacity 160ms ease,
@ -73,17 +74,17 @@ body.gamecult-graph-spa-active {
box-shadow 160ms ease;
}
.gamecult-graph-shell .epiphany-graph-overlay-panel:hover,
.gamecult-graph-shell .epiphany-graph-overlay-panel:focus-within {
.gamecult-graph-shell .norn-overlay-panel:hover,
.gamecult-graph-shell .norn-overlay-panel:focus-within {
opacity: 1;
transform: translateY(-1px);
}
.gamecult-graph-shell aside.epiphany-graph-overlay-panel > section {
.gamecult-graph-shell aside.norn-overlay-panel > section {
display: none !important;
}
.gamecult-graph-shell aside.epiphany-graph-overlay-panel > section:last-child {
.gamecult-graph-shell aside.norn-overlay-panel > section:last-child {
display: grid !important;
grid-row: 1 / -1;
}
@ -266,7 +267,7 @@ body.gamecult-graph-spa-active {
}
@media (max-width: 900px) {
.gamecult-graph-shell aside.epiphany-graph-overlay-panel {
.gamecult-graph-shell aside.norn-overlay-panel {
left: 12px;
right: 12px;
top: auto !important;

View file

@ -3,13 +3,15 @@
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"types": ["react", "react-dom"]
"types": ["react", "react-dom"],
"paths": {
"@gamecult/norn-viewer": ["../../../Norn/web/norn-viewer/dist/index.d.ts"]
}
},
"include": [
"./src/**/*.ts",
"./src/**/*.tsx",
"./vite.config.ts",
"../../../EpiphanyGraph/web/epiphany-graph-viewer/src/**/*.ts",
"../../../EpiphanyGraph/web/epiphany-graph-viewer/src/**/*.tsx"
"../../../Norn/web/norn-viewer/dist/index.d.ts"
]
}

View file

@ -6,7 +6,7 @@
<title>gamecult Fractal Eusocial Web</title>
</head>
<body>
<div class="gamecult-epiphany-graph-root"></div>
<div class="gamecult-norn-graph-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -6,6 +6,10 @@ import path from "node:path"
const viewerEntry = fileURLToPath(new URL("./viewer.html", import.meta.url))
const reactPath = fileURLToPath(new URL("./node_modules/react", import.meta.url))
const reactDomPath = fileURLToPath(new URL("./node_modules/react-dom", import.meta.url))
const nornViewerRoot = process.env.GAMECULT_NORN_VIEWER_ROOT
if (!nornViewerRoot) {
throw new Error("GAMECULT_NORN_VIEWER_ROOT is required. Use scripts/build-graph-spa.mjs.")
}
const outDir = process.env.GAMECULT_GRAPH_SPA_OUT_DIR
? path.resolve(process.env.GAMECULT_GRAPH_SPA_OUT_DIR)
: fileURLToPath(new URL("./dist", import.meta.url))
@ -16,6 +20,7 @@ export default defineConfig({
alias: {
react: reactPath,
"react-dom": reactDomPath,
"@gamecult/norn-viewer": path.join(nornViewerRoot, "dist", "index.js"),
},
},
server: {
@ -32,7 +37,6 @@ export default defineConfig({
output: {
manualChunks: {
react: ["react", "react-dom"],
elk: ["elkjs/lib/elk.bundled.js"],
},
entryFileNames: "assets/[name].js",
chunkFileNames: "assets/[name].js",

View file

@ -1,6 +1,7 @@
import fs from "node:fs/promises"
import path from "node:path"
import { spawn } from "node:child_process"
import { execFileSync } from "node:child_process"
import { fileURLToPath } from "node:url"
const engineRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
@ -9,6 +10,7 @@ function parseArgs(argv) {
const options = {
siteRoot: process.cwd(),
outputDir: "static/epiphany-graph",
nornRoot: path.resolve(engineRoot, "..", "Norn"),
}
for (let i = 0; i < argv.length; i++) {
@ -51,20 +53,69 @@ async function linkNodeModules(graphRoot) {
await fs.symlink(source, dest, "junction")
}
function runBuild(graphRoot, outputRoot) {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
["./node_modules/vite/bin/vite.js", "build", "--base", "./"],
{
cwd: graphRoot,
stdio: "inherit",
env: {
...process.env,
GAMECULT_GRAPH_SPA_OUT_DIR: outputRoot,
},
},
async function prepareNorn(nornRoot) {
const viewerRoot = path.join(nornRoot, "web", "norn-viewer")
const revisionPath = path.join(engineRoot, "quartz", "graph-spa", "norn-revision.txt")
const expectedRevision = (await fs.readFile(revisionPath, "utf8")).trim()
const packagePath = path.join(viewerRoot, "package.json")
const tsupPath = path.join(viewerRoot, "node_modules", "tsup", "dist", "cli-default.js")
if (!(await exists(packagePath))) {
throw new Error(
`Norn was not found at '${nornRoot}'. Clone GameCult/Norn beside GameCult-Quartz or pass --nornRoot.`,
)
}
const actualRevision = execFileSync("git", ["-C", nornRoot, "rev-parse", "HEAD"], {
encoding: "utf8",
}).trim()
if (actualRevision !== expectedRevision) {
throw new Error(`Norn must be revision ${expectedRevision}; found ${actualRevision}.`)
}
const status = execFileSync(
"git",
["-C", nornRoot, "status", "--porcelain", "--untracked-files=normal"],
{ encoding: "utf8" },
).trim()
if (status) {
throw new Error(`Norn must be clean before GameCult-Quartz consumes it: ${nornRoot}`)
}
if (!(await exists(tsupPath))) {
throw new Error(`Norn dependencies are missing. Run npm ci in '${viewerRoot}'.`)
}
await runProcess(
process.execPath,
[
tsupPath,
"src/index.ts",
"--format",
"esm,cjs",
"--dts",
"--external",
"react",
"--external",
"react-dom",
"--clean",
],
viewerRoot,
)
return viewerRoot
}
function runProcess(command, args, cwd, extraEnv = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: "inherit",
env: {
...process.env,
...extraEnv,
},
})
child.on("exit", (code) => {
if (code === 0) {
@ -72,7 +123,7 @@ function runBuild(graphRoot, outputRoot) {
return
}
reject(new Error(`Graph SPA build exited with code ${code}`))
reject(new Error(`${path.basename(command)} exited with code ${code}`))
})
child.on("error", reject)
})
@ -83,9 +134,19 @@ async function main() {
const siteRoot = path.resolve(options.siteRoot)
const outputRoot = path.resolve(siteRoot, options.outputDir)
const graphRoot = path.join(engineRoot, "quartz", "graph-spa")
const nornRoot = path.resolve(options.nornRoot)
const nornViewerRoot = await prepareNorn(nornRoot)
await linkNodeModules(graphRoot)
await runBuild(graphRoot, outputRoot)
await runProcess(
process.execPath,
["./node_modules/vite/bin/vite.js", "build", "--base", "./"],
graphRoot,
{
GAMECULT_GRAPH_SPA_OUT_DIR: outputRoot,
GAMECULT_NORN_VIEWER_ROOT: nornViewerRoot,
},
)
}
main().catch((error) => {