Make Zyphos graph the fullscreen site shell

This commit is contained in:
Meta Vens 2026-05-18 04:44:43 +01:00
commit 799303b847
4 changed files with 518 additions and 35 deletions

View file

@ -1 +1 @@
html,body,#root{min-height:100%;margin:0}body{background:radial-gradient(circle at 72% 12%,rgba(255,174,88,.16),transparent 24%),radial-gradient(circle at 10% 18%,rgba(142,223,176,.16),transparent 28%),linear-gradient(180deg,#03070d,#07110f 52%,#091712);color:#effcf8;font-family:Ubuntu,Segoe UI,sans-serif}.zyphos-epiphany-graph-app{box-sizing:border-box;min-height:100vh;padding:16px}.zyphos-epiphany-graph-app *{box-sizing:border-box}.zyphos-graph-error{white-space:pre-wrap;color:#fecaca;background:#450a0a99;border:1px solid rgba(248,113,113,.45);border-radius:16px;padding:16px}.zyphos-graph-loading{display:grid;min-height:320px;place-items:center;color:#cfeee4;font-size:.95rem}
html,body,#root{width:100%;min-height:100%;margin:0}body{background:radial-gradient(circle at 72% 12%,rgba(255,174,88,.16),transparent 24%),radial-gradient(circle at 10% 18%,rgba(142,223,176,.16),transparent 28%),linear-gradient(180deg,#03070d,#07110f 52%,#091712);color:#effcf8;font-family:Ubuntu,Segoe UI,sans-serif}.zyphos-epiphany-graph-app{box-sizing:border-box;width:100vw;min-height:100vh;padding:0}.zyphos-epiphany-graph-app *{box-sizing:border-box}.zyphos-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}.zyphos-graph-error{white-space:pre-wrap;color:#fecaca;background:#450a0a99;border:1px solid rgba(248,113,113,.45);border-radius:16px;padding:16px}.zyphos-graph-loading{display:grid;min-height:320px;place-items:center;color:#cfeee4;font-size:.95rem}body.zyphos-graph-spa-active{overflow:hidden}.zyphos-graph-shell{width:100vw;height:100vh}.zyphos-graph-shell .epiphany-graph-overlay-panel{opacity:.25;transition:opacity .16s ease,transform .16s ease,box-shadow .16s ease}.zyphos-graph-shell .epiphany-graph-overlay-panel:hover,.zyphos-graph-shell .epiphany-graph-overlay-panel:focus-within{opacity:1;transform:translateY(-1px)}.zyphos-graph-shell aside.epiphany-graph-overlay-panel>section{display:none!important}.zyphos-graph-shell aside.epiphany-graph-overlay-panel>section:last-child{display:grid!important;grid-row:1 / -1}.zyphos-spa-panel{min-height:0;max-height:100%;overflow:auto;color:#effcf8}.zyphos-spa-panel h1{margin:0 0 .9rem;color:#f7fffb;font-size:clamp(1.7rem,2.8vw,3.2rem);line-height:1;letter-spacing:0}.zyphos-spa-kicker{margin:0 0 .55rem;color:#8edfb0;font-size:.76rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase}.zyphos-spa-summary,.zyphos-spa-status{color:#effcf8c2;line-height:1.55}.zyphos-spa-status{margin:1rem 0;padding:.75rem .9rem;border:1px solid rgba(142,223,176,.16);border-radius:16px;background:#03070d73}.zyphos-spa-status-error{color:#fecaca;border-color:#f8717159}.zyphos-spa-article-header{margin-bottom:1rem}.zyphos-spa-actions{display:flex;gap:.6rem;flex-wrap:wrap}.zyphos-spa-actions a,.zyphos-spa-note-list button{border:1px solid rgba(142,223,176,.18);border-radius:16px;background:#8edfb014;color:#effcf8}.zyphos-spa-actions a{padding:.5rem .7rem;text-decoration:none}.zyphos-spa-article{color:#effcf8db;line-height:1.65}.zyphos-spa-article h1{display:none}.zyphos-spa-article h2,.zyphos-spa-article h3{color:#f7fffb;line-height:1.12}.zyphos-spa-article a{color:#ffe0a8}.zyphos-spa-article img{max-width:100%;border-radius:18px}.zyphos-spa-note-list{display:grid;gap:.7rem}.zyphos-spa-note-list button{display:grid;gap:.35rem;padding:.85rem;text-align:left;cursor:pointer}.zyphos-spa-note-list button:hover{border-color:#ffae5861;background:#ffae581c}.zyphos-spa-note-list strong{color:#f7fffb}.zyphos-spa-note-list span{color:#effcf8ad;font-size:.92rem;line-height:1.45}@media(max-width:900px){.zyphos-graph-shell aside.epiphany-graph-overlay-panel{left:12px;right:12px;top:auto!important;bottom:12px!important;width:auto!important;max-height:48vh}}

File diff suppressed because one or more lines are too long

View file

@ -6,6 +6,8 @@ import type {
EpiphanyGraphEdge,
EpiphanyGraphNode,
EpiphanyGraphsState,
GraphKey,
ViewerSelection,
} from "../../../../EpiphanyGraph/web/epiphany-graph-viewer/src/lib/types"
import "./styles.css"
@ -20,6 +22,25 @@ type QuartzContentEntry = {
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>
@ -87,6 +108,50 @@ 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 compareSections(left: string, right: string) {
const leftIndex = SECTION_ORDER.indexOf(left)
const rightIndex = SECTION_ORDER.indexOf(right)
@ -259,20 +324,83 @@ async function loadGraphState() {
? await window.fetchData
: await fetch("/static/contentIndex.json").then((response) => response.json() as Promise<QuartzContentIndex>)
return buildGraphState(contentIndex)
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 selectedSection(selection: ViewerSelection | null) {
if (selection?.kind !== "node" || selection.graphKey !== "dataflow") {
return null
}
return selection.nodeId.replace(/^section:/, "")
}
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 [articleState, setArticleState] = React.useState<ArticleState>({ status: "idle", html: null })
const [error, setError] = React.useState<Error | null>(null)
const slugs = React.useMemo(() => new Set(Object.keys(contentIndex ?? {}).map(normalizeSlug)), [contentIndex])
const noteSlug = selectedNoteSlug(selection)
React.useEffect(() => {
document.body.classList.add("zyphos-graph-spa-active")
return () => {
document.body.classList.remove("zyphos-graph-spa-active")
}
}, [])
React.useEffect(() => {
let cancelled = false
loadGraphState()
.then((state) => {
.then(({ contentIndex: nextContentIndex, graphState: nextGraphState }) => {
if (!cancelled) {
setGraphState(state)
const nextSlugs = new Set(Object.keys(nextContentIndex).map(normalizeSlug))
setContentIndex(nextContentIndex)
setGraphState(nextGraphState)
setSelection(parseHashSelection(nextSlugs) ?? {
kind: "node",
graphKey: "architecture",
nodeId: nextSlugs.has("index") ? "index" : nextGraphState.architecture.nodes[0]?.id ?? "",
})
}
})
.catch((caught) => {
@ -286,6 +414,57 @@ function App() {
}
}, [])
React.useEffect(() => {
if (!noteSlug) {
setArticleState({ status: "idle", html: null })
return
}
let cancelled = false
setArticleState((current) => ({ status: "loading", html: current.html }))
loadArticleHtml(noteSlug)
.then((html) => {
if (!cancelled) {
setArticleState({ status: "ready", html })
}
})
.catch((caught) => {
if (!cancelled) {
setArticleState({
status: "error",
html: null,
message: caught instanceof Error ? caught.message : String(caught),
})
}
})
return () => {
cancelled = true
}
}, [noteSlug])
React.useEffect(() => {
if (!noteSlug) {
return
}
window.history.replaceState(null, "", `#note=${encodeURIComponent(noteSlug)}`)
}, [noteSlug])
React.useEffect(() => {
const onHashChange = () => {
const hashSelection = parseHashSelection(slugs)
if (hashSelection) {
setSelection(hashSelection)
}
}
window.addEventListener("hashchange", onHashChange)
return () => window.removeEventListener("hashchange", onHashChange)
}, [slugs])
if (error) {
return (
<pre className="zyphos-graph-error">
@ -310,6 +489,25 @@ function App() {
<EpiphanyGraphViewer
state={graphState}
title="Zyphos Vault Backlink Web"
selection={selection}
onSelectionChange={setSelection}
overlayPanels
className="zyphos-graph-shell"
sidebarWidth="minmax(360px, 44vw)"
sidebar={
<ArticlePanel
articleState={articleState}
contentIndex={contentIndex}
selection={selection}
onSelectNote={(slug) =>
setSelection({
kind: "node",
graphKey: "architecture",
nodeId: slug,
})
}
/>
}
graphLabels={{
architecture: "Notes",
dataflow: "Sections",
@ -324,6 +522,115 @@ function App() {
)
}
function ArticlePanel({
articleState,
contentIndex,
selection,
onSelectNote,
}: {
articleState: ArticleState
contentIndex: QuartzContentIndex | null
selection: ViewerSelection | null
onSelectNote: (slug: string) => void
}) {
const noteSlug = selectedNoteSlug(selection)
const section = selectedSection(selection)
const slugs = React.useMemo(() => new Set(Object.keys(contentIndex ?? {}).map(normalizeSlug)), [contentIndex])
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])
const onArticleClick = 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()
onSelectNote(slug)
},
[onSelectNote, slugs],
)
if (section) {
return (
<article className="zyphos-spa-panel">
<p className="zyphos-spa-kicker">Section Cluster</p>
<h1>{section}</h1>
<p className="zyphos-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="zyphos-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="zyphos-spa-panel">
<p className="zyphos-spa-kicker">Graph Atlas</p>
<h1>Zyphos</h1>
<p className="zyphos-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="zyphos-spa-panel">
<header className="zyphos-spa-article-header">
<p className="zyphos-spa-kicker">{sectionForSlug(noteSlug)}</p>
<h1>{entryTitle(noteSlug, note)}</h1>
<div className="zyphos-spa-actions">
<a href={slugToArticleUrl(noteSlug)}>Open page</a>
</div>
</header>
{articleState.status === "loading" && <div className="zyphos-spa-status">Loading article...</div>}
{articleState.status === "error" && (
<div className="zyphos-spa-status zyphos-spa-status-error">{articleState.message}</div>
)}
{articleState.status === "ready" && (
<div
className="zyphos-spa-article"
onClick={onArticleClick}
dangerouslySetInnerHTML={{ __html: articleState.html }}
/>
)}
{articleState.status !== "ready" && articleState.html && (
<div
className="zyphos-spa-article"
onClick={onArticleClick}
dangerouslySetInnerHTML={{ __html: articleState.html }}
/>
)}
</article>
)
}
function mountZyphosGraph() {
document.querySelectorAll(".zyphos-epiphany-graph-root").forEach((element) => {
if (!(element instanceof HTMLElement)) {

View file

@ -1,6 +1,7 @@
html,
body,
#root {
width: 100%;
min-height: 100%;
margin: 0;
}
@ -16,14 +17,28 @@ body {
.zyphos-epiphany-graph-app {
box-sizing: border-box;
width: 100vw;
min-height: 100vh;
padding: 16px;
padding: 0;
}
.zyphos-epiphany-graph-app * {
box-sizing: border-box;
}
.zyphos-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;
}
.zyphos-graph-error {
white-space: pre-wrap;
color: #fecaca;
@ -40,3 +55,164 @@ body {
color: #cfeee4;
font-size: 0.95rem;
}
body.zyphos-graph-spa-active {
overflow: hidden;
}
.zyphos-graph-shell {
width: 100vw;
height: 100vh;
}
.zyphos-graph-shell .epiphany-graph-overlay-panel {
opacity: 0.25;
transition:
opacity 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.zyphos-graph-shell .epiphany-graph-overlay-panel:hover,
.zyphos-graph-shell .epiphany-graph-overlay-panel:focus-within {
opacity: 1;
transform: translateY(-1px);
}
.zyphos-graph-shell aside.epiphany-graph-overlay-panel > section {
display: none !important;
}
.zyphos-graph-shell aside.epiphany-graph-overlay-panel > section:last-child {
display: grid !important;
grid-row: 1 / -1;
}
.zyphos-spa-panel {
min-height: 0;
max-height: 100%;
overflow: auto;
color: #effcf8;
}
.zyphos-spa-panel h1 {
margin: 0 0 0.9rem;
color: #f7fffb;
font-size: clamp(1.7rem, 2.8vw, 3.2rem);
line-height: 1;
letter-spacing: 0;
}
.zyphos-spa-kicker {
margin: 0 0 0.55rem;
color: #8edfb0;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.zyphos-spa-summary,
.zyphos-spa-status {
color: rgba(239, 252, 248, 0.76);
line-height: 1.55;
}
.zyphos-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);
}
.zyphos-spa-status-error {
color: #fecaca;
border-color: rgba(248, 113, 113, 0.35);
}
.zyphos-spa-article-header {
margin-bottom: 1rem;
}
.zyphos-spa-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.zyphos-spa-actions a,
.zyphos-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;
}
.zyphos-spa-actions a {
padding: 0.5rem 0.7rem;
text-decoration: none;
}
.zyphos-spa-article {
color: rgba(239, 252, 248, 0.86);
line-height: 1.65;
}
.zyphos-spa-article h1 {
display: none;
}
.zyphos-spa-article h2,
.zyphos-spa-article h3 {
color: #f7fffb;
line-height: 1.12;
}
.zyphos-spa-article a {
color: #ffe0a8;
}
.zyphos-spa-article img {
max-width: 100%;
border-radius: 18px;
}
.zyphos-spa-note-list {
display: grid;
gap: 0.7rem;
}
.zyphos-spa-note-list button {
display: grid;
gap: 0.35rem;
padding: 0.85rem;
text-align: left;
cursor: pointer;
}
.zyphos-spa-note-list button:hover {
border-color: rgba(255, 174, 88, 0.38);
background: rgba(255, 174, 88, 0.11);
}
.zyphos-spa-note-list strong {
color: #f7fffb;
}
.zyphos-spa-note-list span {
color: rgba(239, 252, 248, 0.68);
font-size: 0.92rem;
line-height: 1.45;
}
@media (max-width: 900px) {
.zyphos-graph-shell aside.epiphany-graph-overlay-panel {
left: 12px;
right: 12px;
top: auto !important;
bottom: 12px !important;
width: auto !important;
max-height: 48vh;
}
}