Bottom-anchor banner artwork, instant navigation via in-memory page cache, seed/stars/structure tweaks

- Fix banner artwork sizing: explicit 100% grid track so children
  stretch instead of resolving height:100% against a content-sized row
  (SVG intrinsic ratio bloated the row, cropping the artwork's bottom).
  Bottom-anchor via object-position, transform-origin and YMax slice.
- pagerite.js: in-memory page cache — preload every visible internal
  link once, serve navigation from memory without fetching; editors'
  loadPlain keeps the cache in sync (pagerite:page-fetched).
- Structure editor: delete pages directly, no two-step confirmation.
- New 'stars' banner design (drifting starfield) alongside 'eyes'.
- Rewrite seed content: welcome page, three-level docs section covering
  all Markdown features (source + rendered), showcase hierarchy with
  image positioning and a simple leaf-page banner example.
This commit is contained in:
2026-08-19 01:40:26 +00:00
parent 3b074c02ed
commit 512ed91b31
14 changed files with 409 additions and 222 deletions
+2 -20
View File
@@ -228,23 +228,7 @@ async function commitSlug(node, ev) {
}
}
// Two-step delete (no dialogs): the first click arms the row's button for
// a few seconds, the second actually deletes.
const arming = ref(null)
let armTimer = null
function armRemove(node) {
if (arming.value === node.path) {
clearTimeout(armTimer)
arming.value = null
removePage(node)
} else {
arming.value = node.path
clearTimeout(armTimer)
armTimer = setTimeout(() => { arming.value = null }, 3000)
}
}
// Deletion is immediate, no confirmation.
async function removePage(node) {
const res = await fetch(`/_api/pages/${node.path}`, { method: 'DELETE' })
if (res.ok) {
@@ -267,8 +251,7 @@ async function removePage(node) {
provide('structureHandlers', {
current: () => path.value,
open: navigate,
arming: () => arming.value,
armRemove,
removePage,
reorder: onReorder,
titleInput: onTitleInput,
commitSlug,
@@ -284,7 +267,6 @@ onMounted(() => {
})
onUnmounted(() => {
clearTimeout(armTimer)
for (const t of Object.values(timers)) clearTimeout(t)
removeEventListener('pagerite:editor-shown', onEditorShown)
})
+2 -9
View File
@@ -144,12 +144,11 @@ function onEnd() {
v-if="element.has_content || !element.children.length"
type="button"
class="act del"
:class="{ armed: handlers.arming() === element.path }"
:title="element.children.length
? 'delete the landing page (the category keeps its subpages)'
: 'delete page'"
@click="handlers.armRemove(element)"
>{{ handlers.arming() === element.path ? 'delete?' : '' }}</button>
@click="handlers.removePage(element)"
></button>
</span>
</template>
</div>
@@ -300,12 +299,6 @@ body.tree-dragging .treelist {
white-space: nowrap;
}
/* Two-step delete: the first click arms the button, the second deletes. */
.act.armed {
color: #e06c75;
font-weight: 600;
}
.del:hover {
color: #e06c75;
}
+18 -5
View File
@@ -132,22 +132,35 @@ body {
overflow: hidden;
/* Stack the design artwork and the page's own banner code on top of
each other (artwork first): the banner is a background layer, author
code overlays it. A single child behaves exactly as before. */
code overlays it. A single child behaves exactly as before. The track
is explicitly banner-sized: an auto row would size to the content
(an SVG's intrinsic aspect ratio makes it far taller than the banner),
and children's height:100% would resolve against that bloated row. */
display: grid;
grid-template: 100% / 100%;
}
/* :not(style, script): author-level display:block would override the UA's
display:none on those and render their source as banner text. */
display:none on those and render their source as banner text. No
width/height: the default stretch alignment fills the track without a
percentage-resolution cycle. */
#page-banner>*:not(style, script) {
grid-area: 1 / 1;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
/* Banner artwork is bottom-anchored: the meaningful content (horizon,
ground, characters) sits at the bottom, so on wide viewports — where
the fixed-height banner crops the artwork vertically — the sky/top is
what scrolls out of view. object-position covers replaced elements
(img, canvas); inline SVG is anchored by the transform-origin below
together with preserveAspectRatio="...YMax slice" in the artwork. */
object-position: bottom;
/* Scroll parallax: pagerite.js sets --pry on <html>; the banner stays
windowed in place while the artwork drifts inside it. The scale
provides overscan so the drift never reveals an edge. */
provides overscan so the drift never reveals an edge; scaling from the
bottom keeps the artwork's bottom edge pinned to the banner's. */
transform: scale(1.25) translateY(var(--pry, 0px));
transform-origin: bottom;
will-change: transform;
}
+35 -28
View File
@@ -268,29 +268,31 @@ import "overlayscrollbars/overlayscrollbars.css";
}
}
// --- Preloading ------------------------------------------------------
// Warm the HTTP cache with all linked pages and their resources, so
// navigation (and the cube transition) is instant. Pages carry ETags,
// so re-running this after each navigation revalidates cheaply (304)
// and picks up changed content and images.
// --- Page cache / preloading ------------------------------------------
// Articles are deliberately NOT HTTP-cacheable, so speed comes from an
// in-memory cache instead: at load (and after each swap) every visible
// internal link is fetched exactly once, and navigation is served from
// memory with no fetch at all. Editor re-renders (swapdoc.loadPlain)
// announce their fresh copies via pagerite:page-fetched, keeping the
// cache in sync after edits.
const pageCache = new Map(); // pathname -> HTML text
addEventListener("pagerite:page-fetched", (ev) => {
pageCache.set(new URL(ev.detail.url, location.href).pathname, ev.detail.html);
});
function preload() {
const urls = new Set();
for (const a of document.querySelectorAll('#nav a[href^="/"], #main a[href^="/"]')) {
const urls = new Set([location.pathname]);
for (const a of document.querySelectorAll(
'#nav a[href^="/"], #sidebar a[href^="/"], #main a[href^="/"]',
)) {
urls.add(a.pathname);
}
for (const url of urls) {
if (url === location.pathname) continue;
if (pageCache.has(url)) continue;
fetch(url)
.then((r) => (r.ok ? r.text() : ""))
.then((html) => {
if (!html) return;
// Off-screen parse: load the page's images and other resources
const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of doc.querySelectorAll("img")) {
const i = new Image();
i.src = img.src;
}
})
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
? r.text() : ""))
.then((html) => { if (html) pageCache.set(url, html); })
.catch(() => {});
}
}
@@ -333,16 +335,21 @@ import "overlayscrollbars/overlayscrollbars.css";
}
let doc;
let finalUrl = url;
try {
const res = await fetch(url);
const type = res.headers.get("content-type") || "";
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
// Reflect any redirect the server issued.
if (res.redirected) finalUrl = res.url;
doc = new DOMParser().parseFromString(await res.text(), "text/html");
} catch {
location.href = url; // fall back to a normal navigation
return;
const cached = pageCache.get(new URL(url, location.href).pathname);
if (cached) {
doc = new DOMParser().parseFromString(cached, "text/html");
} else {
try {
const res = await fetch(url);
const type = res.headers.get("content-type") || "";
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
// Reflect any redirect the server issued.
if (res.redirected) finalUrl = res.url;
doc = new DOMParser().parseFromString(await res.text(), "text/html");
} catch {
location.href = url; // fall back to a normal navigation
return;
}
}
if (REGIONS.some((id) => !doc.getElementById(id))) {
location.href = url;
+5 -1
View File
@@ -100,18 +100,22 @@ function swapRegions(doc) {
export async function loadPlain(p) {
let doc
let finalUrl = `/${p}`
let html
try {
const res = await fetch(finalUrl)
const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return null
if (res.redirected) finalUrl = res.url
doc = new DOMParser().parseFromString(await res.text(), 'text/html')
html = await res.text()
doc = new DOMParser().parseFromString(html, 'text/html')
} catch { return null }
if (!doc.getElementById('main')) return null
swapRegions(doc)
history.replaceState(null, '', finalUrl)
runScripts(document.getElementById('page-banner'))
runScripts(document.getElementById('main'))
// Keep pagerite.js's in-memory page cache in sync with the fresh copy.
dispatchEvent(new CustomEvent('pagerite:page-fetched', { detail: { url: finalUrl, html } }))
dispatchEvent(new CustomEvent('pagerite:preview')) // re-inject + re-tuck the edit pens
return finalUrl
}