Extract shared graph SPA

This commit is contained in:
Meta Vens 2026-05-24 03:01:36 +01:00
commit 2c773a2a8c
13 changed files with 2556 additions and 16 deletions

View file

@ -6,6 +6,7 @@ Shared Quartz engine, reusable GitHub Pages workflow, and the minimum amount of
- Shared Quartz source, dependencies, and build tooling
- `scripts/build-site.mjs` for staging a site repo against the shared engine
- `scripts/build-graph-spa.mjs` for building the shared fullscreen graph SPA into a consumer site
- Reusable Pages workflow at `.github/workflows/quartz-pages.yml`
- Generic defaults only
@ -39,6 +40,21 @@ Then from a consumer repo, use its launcher script, or call the shared builder d
node ..\\GameCult-Quartz\\scripts\\build-site.mjs build --siteRoot E:\\Projects\\gamecult-site --contentDir GameCult --overlayDir site --outputDir quartz-site/public
```
## Fullscreen Graph SPA
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.
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
```
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.
## Shared Components
### `AutoIndexFolder`

1241
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -40,11 +40,13 @@
"@myriaddreamin/rehype-typst": "^0.6.0",
"@napi-rs/simple-git": "0.1.22",
"@tweenjs/tween.js": "^25.0.0",
"@vitejs/plugin-react": "^5.2.0",
"ansi-truncate": "^1.4.0",
"async-mutex": "^0.5.0",
"chokidar": "^5.0.0",
"cli-spinner": "^0.2.10",
"d3": "^7.9.0",
"elkjs": "^0.11.1",
"esbuild-sass-plugin": "^3.6.0",
"flexsearch": "^0.8.205",
"github-slugger": "^2.0.0",
@ -66,6 +68,8 @@
"preact-render-to-string": "^6.6.5",
"pretty-bytes": "^7.1.0",
"pretty-time": "^1.1.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-citation": "^2.3.1",
@ -93,6 +97,7 @@
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"vfile": "^6.0.3",
"vite": "^7.3.3",
"workerpool": "^10.0.1",
"ws": "^8.19.0",
"yargs": "^18.0.0"

View file

@ -0,0 +1,26 @@
import { QuartzComponent, QuartzComponentConstructor } from "./types"
export type GameCultGraphSpaShellOptions = {
stylesheetHref: string
moduleSrc: string
rootClassName?: string
}
export default ((options?: Partial<GameCultGraphSpaShellOptions>) => {
const shellOptions: GameCultGraphSpaShellOptions = {
stylesheetHref: "/static/epiphany-graph/assets/viewer.css",
moduleSrc: "/static/epiphany-graph/assets/viewer.js",
rootClassName: "gamecult-epiphany-graph-root",
...options,
}
const GameCultGraphSpaShell: QuartzComponent = () => (
<>
<link rel="stylesheet" href={shellOptions.stylesheetHref} />
<div class={shellOptions.rootClassName}></div>
<script type="module" src={shellOptions.moduleSrc}></script>
</>
)
return GameCultGraphSpaShell
}) satisfies QuartzComponentConstructor<Partial<GameCultGraphSpaShellOptions> | undefined>

View file

@ -24,6 +24,7 @@ import Comments from "./Comments"
import Flex from "./Flex"
import ConditionalRender from "./ConditionalRender"
import AutoIndexFolder from "./AutoIndexFolder"
import GameCultGraphSpaShell from "./GameCultGraphSpaShell"
export {
ArticleTitle,
@ -52,4 +53,7 @@ export {
Comments,
Flex,
ConditionalRender,
GameCultGraphSpaShell,
}
export type { GameCultGraphSpaShellOptions } from "./GameCultGraphSpaShell"

View file

@ -0,0 +1,18 @@
{
"name": "@gamecult/quartz-graph-spa",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build --base ./"
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.1",
"elkjs": "^0.11.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^5.9.3",
"vite": "^7.2.4"
},
"devDependencies": {}
}

View file

@ -0,0 +1,826 @@
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 type {
EpiphanyGraphEdge,
EpiphanyGraphNode,
EpiphanyGraphsState,
GraphKey,
PositionedNode,
ViewerSelection,
} from "../../../../EpiphanyGraph/web/epiphany-graph-viewer/src/lib/types"
import "./styles.css"
type QuartzContentEntry = {
slug: string
filePath?: string
title?: string
links?: string[]
tags?: string[]
content?: string
}
type QuartzContentIndex = Record<string, QuartzContentEntry>
type ArticleState =
| {
status: "idle"
html: string | null
}
| {
status: "loading"
html: string | null
}
| {
status: "ready"
html: string
}
| {
status: "error"
html: string | null
message: string
}
declare global {
interface Window {
fetchData?: Promise<QuartzContentIndex>
}
}
type GraphErrorBoundaryState = {
error: Error | null
}
class GraphErrorBoundary extends React.Component<React.PropsWithChildren, GraphErrorBoundaryState> {
state: GraphErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
render() {
if (this.state.error) {
return (
<pre className="gamecult-graph-error">
{this.state.error.message}
{"\n"}
{this.state.error.stack}
</pre>
)
}
return this.props.children
}
}
const SECTION_ORDER = [
"World",
"Ecology",
"Species",
"Civilizations",
"Technologies",
"Conflicts",
"Themes",
"Root",
]
function entryTitle(slug: string, entry?: QuartzContentEntry) {
if (entry?.title) {
return entry.title
}
return slug
.split("/")
.at(-1)!
.replace(/-/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase())
}
function normalizeSlug(slug: string) {
if (slug.endsWith("/")) {
return `${slug.slice(0, -1)}/index`
}
return slug
}
function sectionForSlug(slug: string) {
return slug.includes("/") ? slug.split("/")[0] : "Root"
}
function slugToArticleUrl(slug: string) {
if (slug === "index") {
return "/index.html"
}
return `/${slug.split("/").map(encodeURIComponent).join("/")}.html`
}
function slugFromArticleUrl(url: string, slugs: Set<string>) {
const parsed = new URL(url, window.location.href)
if (parsed.origin !== window.location.origin) {
return null
}
const decodedPath = decodeURIComponent(parsed.pathname)
.replace(/^\//, "")
.replace(/\.html$/, "")
.replace(/\/$/, "/index")
const slug = normalizeSlug(decodedPath || "index")
return slugs.has(slug) ? slug : null
}
function parseHashSelection(slugs: Set<string>): ViewerSelection | null {
const match = window.location.hash.match(/^#note=(.+)$/)
if (!match) {
return null
}
const slug = normalizeSlug(decodeURIComponent(match[1]))
if (!slugs.has(slug)) {
return null
}
return {
kind: "node",
graphKey: "architecture",
nodeId: slug,
}
}
function currentRouteSelection(slugs: Set<string>): ViewerSelection | null {
const bodySlug = document.body.dataset.slug
const pathSlug = decodeURIComponent(window.location.pathname)
.replace(/^\//, "")
.replace(/\.html$/, "")
.replace(/\/$/, "/index")
const slug = normalizeSlug(bodySlug || pathSlug || "index")
if (!slugs.has(slug)) {
return null
}
return {
kind: "node",
graphKey: "architecture",
nodeId: slug,
}
}
function compareSections(left: string, right: string) {
const leftIndex = SECTION_ORDER.indexOf(left)
const rightIndex = SECTION_ORDER.indexOf(right)
if (leftIndex !== -1 || rightIndex !== -1) {
const orderedLeft = leftIndex === -1 ? SECTION_ORDER.length : leftIndex
const orderedRight = rightIndex === -1 ? SECTION_ORDER.length : rightIndex
return orderedLeft - orderedRight
}
return left.localeCompare(right)
}
function summarizeContent(entry: QuartzContentEntry) {
const text = (entry.content ?? "")
.replace(/\s+/g, " ")
.replace(/^Table of contents\s*/i, "")
.trim()
if (!text) {
return `Vault note at ${entry.filePath ?? entry.slug}.`
}
const sentenceEnd = text.search(/[.!?](\s|$)/)
const sentence = sentenceEnd >= 0 ? text.slice(0, sentenceEnd + 1) : text
if (sentence.length <= 220) {
return sentence
}
return `${sentence.slice(0, 217).trim()}...`
}
function validLinkedSlugs(entry: QuartzContentEntry, slugs: Set<string>) {
return (entry.links ?? [])
.map(normalizeSlug)
.filter(
(targetSlug, index, links) => slugs.has(targetSlug) && links.indexOf(targetSlug) === index,
)
}
function buildIncomingLinks(entries: Array<[string, QuartzContentEntry]>, slugs: Set<string>) {
const incoming = new Map<string, string[]>()
for (const [slug] of entries) {
incoming.set(slug, [])
}
for (const [sourceSlug, entry] of entries) {
for (const targetSlug of validLinkedSlugs(entry, slugs)) {
incoming.get(targetSlug)?.push(sourceSlug)
}
}
return incoming
}
function buildSectionEdges(entries: Array<[string, QuartzContentEntry]>, slugs: Set<string>) {
const edgeCounts = new Map<string, number>()
for (const [sourceSlug, entry] of entries) {
const sourceSection = sectionForSlug(sourceSlug)
for (const targetSlug of validLinkedSlugs(entry, slugs)) {
const targetSection = sectionForSlug(targetSlug)
const edgeId = `${sourceSection}->${targetSection}`
edgeCounts.set(edgeId, (edgeCounts.get(edgeId) ?? 0) + 1)
}
}
return Array.from(edgeCounts.entries()).map(([edgeId, count]) => {
const [sourceSection, targetSection] = edgeId.split("->")
return {
id: `section:${edgeId}`,
source_id: `section:${sourceSection}`,
target_id: `section:${targetSection}`,
kind: sourceSection === targetSection ? "internal_links" : "cross_links",
label: `${count} ${count === 1 ? "link" : "links"}`,
mechanism:
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
})
}
function buildGraphState(contentIndex: QuartzContentIndex): EpiphanyGraphsState {
const entries = Object.entries(contentIndex)
.map(([slug, entry]) => {
const normalizedSlug = normalizeSlug(entry.slug || slug)
return [normalizedSlug, { ...entry, slug: normalizedSlug }] as [string, QuartzContentEntry]
})
.filter(([slug]) => !slug.startsWith("Inspirations/"))
.sort(([left], [right]) => left.localeCompare(right))
const entryBySlug = new Map(entries)
const slugs = new Set(entries.map(([slug]) => slug))
const incoming = buildIncomingLinks(entries, slugs)
const architectureNodes: EpiphanyGraphNode[] = entries.map(([slug, entry]) => {
const outgoingCount = validLinkedSlugs(entry, slugs).length
const backlinkCount = incoming.get(slug)?.length ?? 0
return {
id: slug,
title: entryTitle(slug, entry),
status: backlinkCount > 0 ? "linked" : "source",
purpose: summarizeContent(entry),
mechanism: `${outgoingCount} outgoing wiki ${outgoingCount === 1 ? "link" : "links"}; ${backlinkCount} incoming ${backlinkCount === 1 ? "backlink" : "backlinks"}. Folder: ${sectionForSlug(slug)}.`,
metaphor: entry.filePath ?? slug,
}
})
const architectureEdges: EpiphanyGraphEdge[] = entries.flatMap(([sourceSlug, entry]) =>
validLinkedSlugs(entry, slugs).map((targetSlug, index) => ({
id: `${sourceSlug}->${targetSlug}:${index}`,
source_id: sourceSlug,
target_id: targetSlug,
kind: "wiki_link",
label: "links to",
mechanism: `${entryTitle(sourceSlug, entry)} links to ${entryTitle(targetSlug, entryBySlug.get(targetSlug))}.`,
})),
)
const sections = Array.from(new Set(entries.map(([slug]) => sectionForSlug(slug)))).sort(
compareSections,
)
const dataflowNodes: EpiphanyGraphNode[] = 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]) => {
return (
count +
validLinkedSlugs(entry, slugs).filter((targetSlug) => !sectionSlugs.has(targetSlug)).length
)
}, 0)
const incomingCrossSection = entries.reduce((count, [sourceSlug, entry]) => {
if (sectionForSlug(sourceSlug) === section) {
return count
}
return (
count +
validLinkedSlugs(entry, slugs).filter((targetSlug) => sectionSlugs.has(targetSlug)).length
)
}, 0)
return {
id: `section:${section}`,
title: section,
status: "active",
purpose: `${sectionEntries.length} ${sectionEntries.length === 1 ? "note" : "notes"} in this vault section.`,
mechanism: `${outgoingCrossSection} outgoing cross-section ${outgoingCrossSection === 1 ? "link" : "links"}; ${incomingCrossSection} incoming cross-section ${incomingCrossSection === 1 ? "backlink" : "backlinks"}.`,
metaphor: "Quartz backlink cluster",
}
})
return {
architecture: {
nodes: architectureNodes,
edges: architectureEdges,
},
dataflow: {
nodes: dataflowNodes,
edges: buildSectionEdges(entries, slugs),
},
links: entries.map(([slug]) => ({
architecture_node_id: slug,
dataflow_node_id: `section:${sectionForSlug(slug)}`,
relationship: "section membership",
})),
}
}
async function loadGraphState() {
const contentIndex = window.fetchData
? await window.fetchData
: await fetch("/static/contentIndex.json").then(
(response) => response.json() as Promise<QuartzContentIndex>,
)
return {
contentIndex,
graphState: buildGraphState(contentIndex),
}
}
async function loadArticleHtml(slug: string) {
const response = await fetch(slugToArticleUrl(slug))
if (!response.ok) {
throw new Error(`Could not load ${slug}: ${response.status}`)
}
const html = await response.text()
const documentHtml = new DOMParser().parseFromString(html, "text/html")
const article = documentHtml.querySelector("article")
if (!article) {
throw new Error(`No article body found for ${slug}`)
}
const articleUrl = new URL(slugToArticleUrl(slug), window.location.href)
article.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((anchor) => {
anchor.href = new URL(anchor.getAttribute("href") ?? "", articleUrl).toString()
})
article.querySelectorAll<HTMLImageElement>("img[src]").forEach((image) => {
image.src = new URL(image.getAttribute("src") ?? "", articleUrl).toString()
})
return article.innerHTML
}
function selectedNoteSlug(selection: ViewerSelection | null) {
return selection?.kind === "node" && selection.graphKey === "architecture"
? selection.nodeId
: null
}
function App() {
const [graphState, setGraphState] = React.useState<EpiphanyGraphsState | 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)
const [articleCache, setArticleCache] = React.useState<Record<string, ArticleState>>({})
const [error, setError] = React.useState<Error | null>(null)
const articleCacheRef = React.useRef<Record<string, ArticleState>>({})
const loadingArticlesRef = React.useRef<Set<string>>(new Set())
const slugs = React.useMemo(
() => new Set(Object.keys(contentIndex ?? {}).map(normalizeSlug)),
[contentIndex],
)
const noteSlug = selectedNoteSlug(selection)
React.useEffect(() => {
articleCacheRef.current = articleCache
}, [articleCache])
React.useEffect(() => {
document.body.classList.add("gamecult-graph-spa-active")
return () => {
document.body.classList.remove("gamecult-graph-spa-active")
}
}, [])
React.useEffect(() => {
let cancelled = false
loadGraphState()
.then(({ contentIndex: nextContentIndex, graphState: nextGraphState }) => {
if (!cancelled) {
const nextSlugs = new Set(Object.keys(nextContentIndex).map(normalizeSlug))
const initialViewportTarget = parseHashSelection(nextSlugs) ??
currentRouteSelection(nextSlugs) ?? {
kind: "node" as const,
graphKey: "architecture" as const,
nodeId: nextSlugs.has("index")
? "index"
: (nextGraphState.architecture.nodes[0]?.id ?? ""),
}
setContentIndex(nextContentIndex)
setGraphState(nextGraphState)
setViewportTarget(initialViewportTarget)
}
})
.catch((caught) => {
if (!cancelled) {
setError(caught instanceof Error ? caught : new Error(String(caught)))
}
})
return () => {
cancelled = true
}
}, [])
const ensureArticle = React.useCallback((slug: string) => {
const current = articleCacheRef.current[slug]
if (current?.status === "ready" || loadingArticlesRef.current.has(slug)) {
return
}
loadingArticlesRef.current.add(slug)
setArticleCache((currentCache) => ({
...currentCache,
[slug]: { status: "loading", html: currentCache[slug]?.html ?? null },
}))
loadArticleHtml(slug)
.then((html) => {
loadingArticlesRef.current.delete(slug)
setArticleCache((currentCache) => ({
...currentCache,
[slug]: { status: "ready", html },
}))
})
.catch((caught) => {
loadingArticlesRef.current.delete(slug)
setArticleCache((currentCache) => ({
...currentCache,
[slug]: {
status: "error",
html: null,
message: caught instanceof Error ? caught.message : String(caught),
},
}))
})
}, [])
React.useEffect(() => {
if (noteSlug) {
ensureArticle(noteSlug)
}
}, [ensureArticle, noteSlug])
React.useEffect(() => {
if (!noteSlug) {
return
}
window.history.replaceState(null, "", `#note=${encodeURIComponent(noteSlug)}`)
}, [noteSlug])
React.useEffect(() => {
const onHashChange = () => {
const hashSelection = parseHashSelection(slugs)
if (hashSelection) {
setViewportTarget(hashSelection)
}
}
window.addEventListener("hashchange", onHashChange)
return () => window.removeEventListener("hashchange", onHashChange)
}, [slugs])
const selectNote = React.useCallback(
(slug: string) =>
setViewportTarget({
kind: "node",
graphKey: "architecture",
nodeId: slug,
}),
[],
)
const onViewportTargetComplete = React.useCallback(() => {
setViewportTarget(null)
}, [])
const onArticleLinkClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
const anchor = (event.target as HTMLElement).closest("a[href]")
if (!(anchor instanceof HTMLAnchorElement)) {
return
}
const slug = slugFromArticleUrl(anchor.href, slugs)
if (!slug) {
return
}
event.preventDefault()
event.stopPropagation()
selectNote(slug)
},
[selectNote, slugs],
)
if (error) {
return (
<pre className="gamecult-graph-error">
{error.message}
{"\n"}
{error.stack}
</pre>
)
}
if (!graphState) {
return (
<main className="gamecult-epiphany-graph-app">
<div className="gamecult-graph-loading">Loading Quartz backlink graph...</div>
</main>
)
}
return (
<main className="gamecult-epiphany-graph-app">
<GraphErrorBoundary>
<EpiphanyGraphViewer
state={graphState}
title="GameCult Knowledge Graph"
selection={selection}
viewportTarget={viewportTarget}
onViewportTargetComplete={onViewportTargetComplete}
onSelectionChange={setSelection}
overlayPanels
className="gamecult-graph-shell"
showSidebar={false}
focusSelection
selectionFocusMode="article"
onExpandedNodeClick={onArticleLinkClick}
nodeArticle={{
className: "gamecult-expanded-article-node",
ariaLabel: (node) => `${node.title} article node`,
content: (node) => (
<ViewportNodeArticle
node={node}
articleCache={articleCache}
contentIndex={contentIndex}
ensureArticle={ensureArticle}
onSelectNote={selectNote}
onArticleLinkClick={onArticleLinkClick}
/>
),
}}
graphLabels={{
architecture: "Notes",
dataflow: "Sections",
}}
graphDescriptions={{
architecture:
"Notes are individual vault pages from the gamecult knowledge base. Their edges are Quartz wiki links, with incoming backlinks counted into each node.",
dataflow:
"Sections are folder-level clusters. Their edges summarize how links and backlinks move between regions of the vault.",
}}
layoutMode={{
architecture: "combined-force",
dataflow: "combined-force",
}}
/>
</GraphErrorBoundary>
</main>
)
}
function ViewportNodeArticle({
node,
articleCache,
contentIndex,
ensureArticle,
onSelectNote,
onArticleLinkClick,
}: {
node: PositionedNode
articleCache: Record<string, ArticleState>
contentIndex: QuartzContentIndex | null
ensureArticle: (slug: string) => void
onSelectNote: (slug: string) => void
onArticleLinkClick: (event: React.MouseEvent<HTMLElement>) => void
}) {
const noteSlug = node.graphKey === "architecture" ? node.id : null
const section = node.graphKey === "dataflow" ? node.id.replace(/^section:/, "") : null
React.useEffect(() => {
if (noteSlug) {
ensureArticle(noteSlug)
}
}, [ensureArticle, noteSlug])
return (
<ArticlePanel
articleState={
noteSlug
? (articleCache[noteSlug] ?? { status: "idle", html: null })
: { status: "idle", html: null }
}
contentIndex={contentIndex}
noteSlug={noteSlug}
section={section}
onSelectNote={onSelectNote}
onArticleLinkClick={onArticleLinkClick}
/>
)
}
function ArticlePanel({
articleState,
contentIndex,
noteSlug,
section,
onSelectNote,
onArticleLinkClick,
}: {
articleState: ArticleState
contentIndex: QuartzContentIndex | null
noteSlug: string | null
section: string | null
onSelectNote: (slug: string) => void
onArticleLinkClick: (event: React.MouseEvent<HTMLElement>) => void
}) {
const note = noteSlug && contentIndex ? contentIndex[noteSlug] : null
const sectionNotes = React.useMemo(() => {
if (!section || !contentIndex) {
return []
}
return Object.entries(contentIndex)
.map(
([slug, entry]) =>
[normalizeSlug(entry.slug || slug), entry] as [string, QuartzContentEntry],
)
.filter(([slug]) => sectionForSlug(slug) === section)
.sort(([left, leftEntry], [right, rightEntry]) =>
entryTitle(left, leftEntry).localeCompare(entryTitle(right, rightEntry)),
)
}, [contentIndex, section])
if (section) {
return (
<article className="gamecult-spa-panel">
<header className="gamecult-spa-article-header">
<span className="gamecult-spa-node-badge" aria-hidden="true">
{initialsForTitle(section)}
</span>
<span className="gamecult-spa-title-cluster">
<p className="gamecult-spa-kicker">Section Cluster</p>
<h1>{section}</h1>
</span>
</header>
<p className="gamecult-spa-summary">
{sectionNotes.length} notes live in this section. Pick one and the graph becomes the table
of contents it was clearly auditioning to be.
</p>
<div className="gamecult-spa-note-list">
{sectionNotes.map(([slug, entry]) => (
<button key={slug} type="button" onClick={() => onSelectNote(slug)}>
<strong>{entryTitle(slug, entry)}</strong>
<span>{summarizeContent(entry)}</span>
</button>
))}
</div>
</article>
)
}
if (!noteSlug || !note) {
return (
<article className="gamecult-spa-panel">
<header className="gamecult-spa-article-header">
<span className="gamecult-spa-node-badge" aria-hidden="true">
GC
</span>
<span className="gamecult-spa-title-cluster">
<p className="gamecult-spa-kicker">Graph Atlas</p>
<h1>gamecult</h1>
</span>
</header>
<p className="gamecult-spa-summary">
Select a note to read it here. The graph is the site map now, which is probably what it
was trying to confess.
</p>
</article>
)
}
return (
<article className="gamecult-spa-panel">
<header className="gamecult-spa-article-header">
<span className="gamecult-spa-node-badge" aria-hidden="true">
{initialsForTitle(entryTitle(noteSlug, note))}
</span>
<span className="gamecult-spa-title-cluster">
<p className="gamecult-spa-kicker">{sectionForSlug(noteSlug)}</p>
<h1>{entryTitle(noteSlug, note)}</h1>
</span>
</header>
{articleState.status === "loading" && (
<div className="gamecult-spa-status">Loading article...</div>
)}
{articleState.status === "error" && (
<div className="gamecult-spa-status gamecult-spa-status-error">{articleState.message}</div>
)}
{articleState.status === "ready" && (
<div
className="gamecult-spa-article"
data-epiphany-article-content
onClick={onArticleLinkClick}
dangerouslySetInnerHTML={{ __html: articleState.html }}
/>
)}
{articleState.status !== "ready" && articleState.html && (
<div
className="gamecult-spa-article"
data-epiphany-article-content
onClick={onArticleLinkClick}
dangerouslySetInnerHTML={{ __html: articleState.html }}
/>
)}
</article>
)
}
function initialsForTitle(title: string) {
const words = title
.replace(/['’]/g, "")
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
if (words.length === 0) {
return "GC"
}
return words
.slice(0, 2)
.map((word) => word[0]?.toUpperCase() ?? "")
.join("")
}
function mountGameCultGraph() {
document.querySelectorAll(".gamecult-epiphany-graph-root").forEach((element) => {
if (!(element instanceof HTMLElement)) {
return
}
if (element.dataset.ready === "true" && element.childElementCount > 0) {
return
}
const root = ReactDOM.createRoot(element)
flushSync(() => {
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
})
element.dataset.ready = "true"
})
}
function scheduleGameCultGraphMount() {
mountGameCultGraph()
window.setTimeout(mountGameCultGraph, 50)
window.setTimeout(mountGameCultGraph, 300)
window.setTimeout(mountGameCultGraph, 1000)
window.setTimeout(mountGameCultGraph, 2000)
}
document.addEventListener("nav", scheduleGameCultGraphMount)
document.addEventListener("DOMContentLoaded", scheduleGameCultGraphMount)
scheduleGameCultGraphMount()

View file

@ -0,0 +1,277 @@
html,
body,
#root {
width: 100%;
min-height: 100%;
margin: 0;
}
body {
background:
radial-gradient(circle at 72% 12%, rgba(255, 174, 88, 0.16), transparent 24%),
radial-gradient(circle at 10% 18%, rgba(142, 223, 176, 0.16), transparent 28%),
linear-gradient(180deg, #03070d 0%, #07110f 52%, #091712 100%);
color: #effcf8;
font-family: "Ubuntu", "Segoe UI", sans-serif;
}
.gamecult-epiphany-graph-app {
box-sizing: border-box;
width: 100vw;
min-height: 100vh;
padding: 0;
}
.gamecult-epiphany-graph-app * {
box-sizing: border-box;
}
.gamecult-epiphany-graph-root {
position: fixed;
inset: 0;
z-index: 1000;
width: 100vw;
height: 100vh;
min-height: 100vh;
margin: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: #03070d;
}
.gamecult-graph-error {
white-space: pre-wrap;
color: #fecaca;
background: rgba(69, 10, 10, 0.6);
border: 1px solid rgba(248, 113, 113, 0.45);
border-radius: 16px;
padding: 16px;
}
.gamecult-graph-loading {
display: grid;
min-height: 320px;
place-items: center;
color: #cfeee4;
font-size: 0.95rem;
}
body.gamecult-graph-spa-active {
overflow: hidden;
}
.gamecult-graph-shell {
width: 100vw;
height: 100vh;
}
.gamecult-graph-shell .epiphany-graph-overlay-panel {
opacity: 0.25;
transition:
opacity 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.gamecult-graph-shell .epiphany-graph-overlay-panel:hover,
.gamecult-graph-shell .epiphany-graph-overlay-panel:focus-within {
opacity: 1;
transform: translateY(-1px);
}
.gamecult-graph-shell aside.epiphany-graph-overlay-panel > section {
display: none !important;
}
.gamecult-graph-shell aside.epiphany-graph-overlay-panel > section:last-child {
display: grid !important;
grid-row: 1 / -1;
}
.gamecult-expanded-article-node {
box-sizing: border-box;
padding: var(--node-pad-y, 0.62rem) var(--node-pad-x, 0.82rem);
scrollbar-color: rgba(142, 223, 176, 0.58) rgba(3, 7, 13, 0.32);
transition:
border-radius 120ms ease,
box-shadow 120ms ease,
padding 120ms ease,
opacity 120ms ease;
}
.gamecult-expanded-article-node .gamecult-spa-panel {
max-height: none;
overflow: visible;
}
.gamecult-spa-panel {
min-height: 0;
max-height: 100%;
overflow: auto;
color: #effcf8;
}
.gamecult-spa-panel h1 {
max-height: var(--node-title-max-height, 1.12em);
margin: 0;
overflow: hidden;
color: #f7fffb;
font-size: var(--node-title-size, 1.08rem);
line-height: 1.04;
letter-spacing: 0;
}
.gamecult-spa-kicker {
margin: 0 0 var(--node-kicker-margin, 0.12rem);
color: #8edfb0;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
opacity: var(--node-preview, 0);
transition: opacity 120ms ease;
}
.gamecult-spa-summary,
.gamecult-spa-status {
color: rgba(239, 252, 248, 0.76);
line-height: 1.55;
max-height: var(--node-preview-height, 0rem);
margin-block: var(--node-status-margin, 0rem);
overflow: hidden;
opacity: var(--node-preview, 0);
}
.gamecult-spa-status {
margin: 1rem 0;
padding: 0.75rem 0.9rem;
border: 1px solid rgba(142, 223, 176, 0.16);
border-radius: 16px;
background: rgba(3, 7, 13, 0.45);
}
.gamecult-spa-status-error {
color: #fecaca;
border-color: rgba(248, 113, 113, 0.35);
}
.gamecult-spa-article-header {
display: grid;
grid-template-columns: var(--node-badge-size, 3rem) minmax(0, 1fr);
align-items: center;
gap: var(--node-panel-gap, 0.72rem);
min-height: var(--node-badge-size, 3rem);
margin-bottom: var(--node-header-margin, 0rem);
}
.gamecult-spa-node-badge {
display: grid;
width: var(--node-badge-size, 3rem);
height: var(--node-badge-size, 3rem);
place-items: center;
border-radius: 999px;
background: linear-gradient(160deg, #69e2ef, #c2f5f0);
color: #08313a;
font-size: var(--node-title-size, 1.08rem);
font-weight: 900;
line-height: 1;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.28),
0 0 var(--node-badge-glow, 1rem) rgba(94, 234, 212, 0.3);
}
.gamecult-spa-title-cluster {
min-width: 0;
}
.gamecult-spa-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.gamecult-spa-actions a,
.gamecult-spa-note-list button {
border: 1px solid rgba(142, 223, 176, 0.18);
border-radius: 16px;
background: rgba(142, 223, 176, 0.08);
color: #effcf8;
}
.gamecult-spa-actions a {
padding: 0.5rem 0.7rem;
text-decoration: none;
}
.gamecult-spa-article {
color: rgba(239, 252, 248, 0.86);
line-height: 1.65;
max-height: var(--node-article-height, 0rem);
overflow: hidden;
opacity: var(--node-article, 0);
transform: translateY(var(--node-article-offset, 0.65rem));
transition:
opacity 120ms ease,
transform 120ms ease;
}
.gamecult-spa-article h1 {
display: none;
}
.gamecult-spa-article h2,
.gamecult-spa-article h3 {
color: #f7fffb;
line-height: 1.12;
}
.gamecult-spa-article a {
color: #ffe0a8;
}
.gamecult-spa-article img {
max-width: 100%;
border-radius: 18px;
}
.gamecult-spa-note-list {
display: grid;
gap: 0.7rem;
max-height: var(--node-note-list-height, 0rem);
overflow: hidden;
opacity: var(--node-article, 0);
}
.gamecult-spa-note-list button {
display: grid;
gap: 0.35rem;
padding: 0.85rem;
text-align: left;
cursor: pointer;
}
.gamecult-spa-note-list button:hover {
border-color: rgba(255, 174, 88, 0.38);
background: rgba(255, 174, 88, 0.11);
}
.gamecult-spa-note-list strong {
color: #f7fffb;
}
.gamecult-spa-note-list span {
color: rgba(239, 252, 248, 0.68);
font-size: 0.92rem;
line-height: 1.45;
}
@media (max-width: 900px) {
.gamecult-graph-shell aside.epiphany-graph-overlay-panel {
left: 12px;
right: 12px;
top: auto !important;
bottom: 12px !important;
width: auto !important;
max-height: 48vh;
}
}

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gamecult Fractal Eusocial Web</title>
</head>
<body>
<div class="gamecult-epiphany-graph-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,43 @@
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { fileURLToPath } from "node:url"
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 outDir = process.env.GAMECULT_GRAPH_SPA_OUT_DIR
? path.resolve(process.env.GAMECULT_GRAPH_SPA_OUT_DIR)
: fileURLToPath(new URL("./dist", import.meta.url))
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
react: reactPath,
"react-dom": reactDomPath,
},
},
server: {
host: "127.0.0.1",
port: 4182,
},
build: {
outDir,
emptyOutDir: true,
rollupOptions: {
input: {
viewer: viewerEntry,
},
output: {
manualChunks: {
react: ["react", "react-dom"],
elk: ["elkjs/lib/elk.bundled.js"],
},
entryFileNames: "assets/[name].js",
chunkFileNames: "assets/[name].js",
assetFileNames: "assets/[name][extname]",
},
},
},
})

View file

@ -0,0 +1,94 @@
import fs from "node:fs/promises"
import path from "node:path"
import { spawn } from "node:child_process"
import { fileURLToPath } from "node:url"
const engineRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
function parseArgs(argv) {
const options = {
siteRoot: process.cwd(),
outputDir: "static/epiphany-graph",
}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (!arg.startsWith("--")) continue
const key = arg.slice(2)
const next = argv[i + 1]
if (next && !next.startsWith("--")) {
options[key] = next
i++
} else {
options[key] = "true"
}
}
return options
}
async function exists(targetPath) {
try {
await fs.lstat(targetPath)
return true
} catch {
return false
}
}
async function linkNodeModules(graphRoot) {
const source = path.join(engineRoot, "node_modules")
const dest = path.join(graphRoot, "node_modules")
if (!(await exists(source))) {
throw new Error(
"GameCult-Quartz dependencies are missing. Run `npm ci` in the GameCult-Quartz repo first.",
)
}
await fs.rm(dest, { recursive: true, force: true })
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,
},
},
)
child.on("exit", (code) => {
if (code === 0) {
resolve()
return
}
reject(new Error(`Graph SPA build exited with code ${code}`))
})
child.on("error", reject)
})
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const siteRoot = path.resolve(options.siteRoot)
const outputRoot = path.resolve(siteRoot, options.outputDir)
const graphRoot = path.join(engineRoot, "quartz", "graph-spa")
await linkNodeModules(graphRoot)
await runBuild(graphRoot, outputRoot)
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})

View file

@ -70,7 +70,13 @@ async function copyEngineScaffold(runtimeRoot) {
const dest = path.join(runtimeRoot, entry.name)
if (entry.isDirectory()) {
await fs.cp(source, dest, { recursive: true })
await fs.cp(source, dest, {
recursive: true,
filter: (candidate) => {
const relative = path.relative(engineRoot, candidate).split(path.sep).join("/")
return relative !== "quartz/graph-spa/node_modules"
},
})
} else {
await fs.copyFile(source, dest)
}

View file

@ -18,5 +18,5 @@
"jsxImportSource": "preact"
},
"include": ["**/*.ts", "**/*.tsx", "./package.json"],
"exclude": ["build/**/*.d.ts"]
"exclude": ["build/**/*.d.ts", "quartz/graph-spa/**"]
}