diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78743a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +quartz/graph-spa/node_modules/ +quartz/graph-spa/dist/ +*.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md index 970f6d3..e846bfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 9e709e5..4e166e8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 80ac796..95248d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 88dfbfb..c79bf37 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/quartz/components/GameCultGraphSpaShell.tsx b/quartz/components/GameCultGraphSpaShell.tsx index 6dec8fb..74caddd 100644 --- a/quartz/components/GameCultGraphSpaShell.tsx +++ b/quartz/components/GameCultGraphSpaShell.tsx @@ -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[] } } diff --git a/quartz/graph-spa/norn-revision.txt b/quartz/graph-spa/norn-revision.txt new file mode 100644 index 0000000..060e7ca --- /dev/null +++ b/quartz/graph-spa/norn-revision.txt @@ -0,0 +1 @@ +fa4a46bb13333eb703e97ccea9edc1b70ee109b7 diff --git a/quartz/graph-spa/package.json b/quartz/graph-spa/package.json index e78fad9..e4c26a0 100644 --- a/quartz/graph-spa/package.json +++ b/quartz/graph-spa/package.json @@ -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", diff --git a/quartz/graph-spa/src/main.tsx b/quartz/graph-spa/src/main.tsx index 29ba269..14d74df 100644 --- a/quartz/graph-spa/src/main.tsx +++ b/quartz/graph-spa/src/main.tsx @@ -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 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( - ".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): 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(null) + const [graphState, setGraphState] = React.useState(null) const [contentIndex, setContentIndex] = React.useState(null) const [selection, setSelection] = React.useState(null) const [viewportTarget, setViewportTarget] = React.useState(null) @@ -635,16 +625,16 @@ function App() { if (!graphState) { return ( -
+
Loading Quartz backlink graph...
) } return ( -
+
- @@ -837,7 +826,6 @@ function ArticlePanel({ {articleState.status !== "ready" && 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( - - - , - ) + const root = ReactDOM.createRoot(element) + flushSync(() => { + root.render( + + + , + ) + }) + element.dataset.ready = "true" }) - element.dataset.ready = "true" - }) } function scheduleGameCultGraphMount() { diff --git a/quartz/graph-spa/src/styles.css b/quartz/graph-spa/src/styles.css index f926d79..203f3e3 100644 --- a/quartz/graph-spa/src/styles.css +++ b/quartz/graph-spa/src/styles.css @@ -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; diff --git a/quartz/graph-spa/tsconfig.json b/quartz/graph-spa/tsconfig.json index 437bb4d..3f7db03 100644 --- a/quartz/graph-spa/tsconfig.json +++ b/quartz/graph-spa/tsconfig.json @@ -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" ] } diff --git a/quartz/graph-spa/viewer.html b/quartz/graph-spa/viewer.html index a03cbed..0b83a61 100644 --- a/quartz/graph-spa/viewer.html +++ b/quartz/graph-spa/viewer.html @@ -6,7 +6,7 @@ gamecult Fractal Eusocial Web -
+
diff --git a/quartz/graph-spa/vite.config.ts b/quartz/graph-spa/vite.config.ts index 76eacee..6641fd0 100644 --- a/quartz/graph-spa/vite.config.ts +++ b/quartz/graph-spa/vite.config.ts @@ -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", diff --git a/scripts/build-graph-spa.mjs b/scripts/build-graph-spa.mjs index a7a91e0..0cc4d8c 100644 --- a/scripts/build-graph-spa.mjs +++ b/scripts/build-graph-spa.mjs @@ -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) => {