diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 0000000..9075db0 --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,9 @@ +node_modules +.git +out +*.out +packages/*/build +packages/*/dist +*.tsbuildinfo +.tmp-pr274 +.claude diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 1c6e18f..26928a7 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -75,6 +75,11 @@ export const KNOWN_COMPONENTS = [ // custom nodes (charts, map, interactive checklist, video card, masonry gallery, // map detail pane, image carousel, filter/sort) "VegaLite", "Map", "MapLink", "Checklist", "Video", "Masonry", "MapDetail", "ImageCarousel", + // stacked same-size images with per-layer visibility toggles + opacity sliders + // (deepsong gen chains: blueprint / render / segmask / cut layers overlaid) + "ImageLayers", + // top-level board pagination — one lazily-resolved page at a time + "Pages", "SliderGallery", "TileGrid", "FilterSort", "PlacesExplorer", // canonical board-opener primitive — renders title + lede + optional legend + optional @@ -191,6 +196,44 @@ export function validateComponentTree(node: unknown, path = "root", depth = 0): ); if (n.props !== undefined && (typeof n.props !== "object" || n.props === null || Array.isArray(n.props))) return `${path}(${n.type}).props must be an object`; + if (n.type === "Pages") { + // pages[].node lives in a plain-data prop (lazy-resolved by + // the client) — walk each page tree here or broken pages + // would store fine and render as markers later. + const props = (n.props ?? {}) as Record; + const pages = props.pages; + if (!Array.isArray(pages) || pages.length === 0) + return `${path}(Pages).props.pages must be a non-empty array`; + for (let i = 0; i < pages.length; i++) { + const p = pages[i] as Record; + const pp = `${path}(Pages).props.pages[${i}]`; + if (!p || typeof p !== "object" || Array.isArray(p)) return `${pp} must be an object`; + if (p.title !== undefined && typeof p.title !== "string") return `${pp}.title must be a string`; + const e = validateComponentTree(p.node, `${pp}.node`, depth + 1); + if (e) return e; + } + } + if (n.type === "ImageLayers") { + // layers[].src is rendered as an src by the client with only a runtime allowlist — + // catch bad schemes and shapes at authoring time instead of storing a 204'd board that + // renders blank layers (the exact silent-breakage mode this validator exists to prevent). + const props = (n.props ?? {}) as Record; + const layers = props.layers; + if (layers !== undefined) { + if (!Array.isArray(layers)) return `${path}(ImageLayers).props.layers must be an array`; + for (let i = 0; i < layers.length; i++) { + const l = layers[i] as Record; + const lp = `${path}(ImageLayers).props.layers[${i}]`; + if (!l || typeof l !== "object" || Array.isArray(l)) return `${lp} must be an object`; + if (typeof l.src !== "string" || !(/^https?:\/\//i.test(l.src.trim()) || l.src.trim().startsWith("data:image/"))) + return `${lp}.src must be an http(s) or data:image/ URL (got ${JSON.stringify(l.src).slice(0, 80)})`; + if (l.opacity !== undefined && (typeof l.opacity !== "number" || !(l.opacity >= 0 && l.opacity <= 100))) + return `${lp}.opacity must be a number 0-100`; + if (l.visible !== undefined && typeof l.visible !== "boolean") + return `${lp}.visible must be a boolean`; + } + } + } // Node-valued props (icon/label/leftSection/… given as {type,...} trees, or arrays of them) are // resolved to elements by the client, so they're validated like children. Anything the client // treats as plain data (no `type`, or an unknown type with no props/children) is left alone. diff --git a/packages/core/test/validate-board.test.ts b/packages/core/test/validate-board.test.ts index eef0ad4..ca87784 100644 --- a/packages/core/test/validate-board.test.ts +++ b/packages/core/test/validate-board.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isBoardDef, validateBoardDef } from "../src/index.js"; +import { isBoardDef, validateBoardDef, validateContent } from "../src/index.js"; // A minimal, fully-valid board definition. Each test mutates a clone to isolate one rule. const valid = () => ({ @@ -101,3 +101,24 @@ describe("validateBoardDef", () => { expect(validateBoardDef({ ...valid(), lifecycle: { runs: 1.5 } })).toMatch(/runs/); }); }); + +describe("ImageLayers authoring-time validation", () => { + const content = (layers: unknown) => + JSON.stringify({ type: "ImageLayers", props: { layers } }); + it("accepts http(s) and data:image sources", () => { + expect( + validateContent("component", content([ + { src: "https://example.com/a.png", label: "a" }, + { src: "data:image/png;base64,AAAA", opacity: 55, visible: false }, + ])), + ).toBeNull(); + }); + it("rejects javascript: and relative sources at authoring time", () => { + expect(validateContent("component", content([{ src: "javascript:alert(1)" }]))).toMatch(/src must be/); + expect(validateContent("component", content([{ src: "/relative.png" }]))).toMatch(/src must be/); + }); + it("rejects out-of-range opacity and non-boolean visible", () => { + expect(validateContent("component", content([{ src: "https://x/a.png", opacity: 150 }]))).toMatch(/opacity/); + expect(validateContent("component", content([{ src: "https://x/a.png", visible: 1 }]))).toMatch(/visible/); + }); +}); diff --git a/packages/viewer/src/client/index.html b/packages/viewer/src/client/index.html index 83d273d..1017111 100644 --- a/packages/viewer/src/client/index.html +++ b/packages/viewer/src/client/index.html @@ -71,6 +71,7 @@

boards

+ + + + + {eff.map((l) => ( + + setLayer(l.src, { vis: !l.vis })} + /> + setLayer(l.src, { op: v })} + /> + + ))} + + + + ); +} diff --git a/packages/viewer/src/client/renderers/pages.tsx b/packages/viewer/src/client/renderers/pages.tsx new file mode 100644 index 0000000..b86c4c8 --- /dev/null +++ b/packages/viewer/src/client/renderers/pages.tsx @@ -0,0 +1,53 @@ +import { Group, Pagination, Text } from "@mantine/core"; +import { useState } from "react"; +import { resolve } from "./component-resolver.js"; + +/** + * `Pages` — top-level board pagination: one page rendered at a + * time with a Pagination control above and below the content. + * Off-page trees are never resolved, so their images are never + * fetched — a 400-node board pays only for the page in view. + * + * Authoring: + * { "type": "Pages", "props": { "pages": [ + * { "title": "Walkthrough", "node": { ...any tree... } }, + * { "title": "Archive", "node": { ... } } ] } } + * + * `pages` is a plain-data prop (the MapDetail.items convention), + * so the resolver leaves the trees raw and this component + * resolves only the active one via `resolve()`. + */ +export interface PageEntry { + title?: string; + node?: unknown; +} + +export function Pages(props: { pages?: PageEntry[] }) { + const pages = Array.isArray(props.pages) ? props.pages : []; + const [page, setPage] = useState(1); + if (pages.length === 0) return null; + const idx = Math.min(Math.max(page, 1), pages.length) - 1; + const entry = pages[idx] ?? {}; + const title = String(entry.title ?? `Page ${idx + 1}`); + const control = (where: string) => ( + + + + {idx + 1}/{pages.length} · {title} + + + ); + return ( +
+ {control("top")} +
{resolve(entry.node)}
+ {control("bottom")} +
+ ); +} diff --git a/packages/viewer/src/client/style.css b/packages/viewer/src/client/style.css index 85b8c06..e45e7d4 100644 --- a/packages/viewer/src/client/style.css +++ b/packages/viewer/src/client/style.css @@ -804,3 +804,25 @@ body.history-open #tc-history { display: flex; } /* on-demand board fetch (lite snapshot) — brief placeholder while content arrives */ .board-loading { padding: 48px; color: var(--muted, #888); font: 14px/1.4 system-ui, sans-serif; } + +/* Scroll-to-bottom: floating pill over the stage, shown only when + the reader is away from the bottom (long boards append there). */ +#scroll-bottom { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 60; + width: 38px; + height: 38px; + border-radius: 50%; + border: 1px solid var(--border, #444); + background: var(--panel, #1a1b1e); + color: var(--text, #c1c2c5); + font-size: 18px; + line-height: 1; + cursor: pointer; + opacity: 0.85; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} +#scroll-bottom:hover { opacity: 1; } +@media print { #scroll-bottom { display: none !important; } } diff --git a/packages/viewer/src/client/viewer.ts b/packages/viewer/src/client/viewer.ts index 1965b58..b571104 100644 --- a/packages/viewer/src/client/viewer.ts +++ b/packages/viewer/src/client/viewer.ts @@ -189,6 +189,54 @@ let wanted = readHash(); let wantedAnchor = parseHash().anchor; // Deep-link autoscroll: bring the element with id=`anchor` into view once it renders. +/** Floating scroll-to-bottom button: long boards append new content at the end, so give readers a + * one-click jump. Visible only when #stage is scrolled more than a viewport away from the bottom; + * hidden otherwise so it never covers content the reader is already looking at. */ +function initScrollBottom(): void { + const stage = document.getElementById("stage"); + const btn = document.getElementById("scroll-bottom"); + if (!stage || !btn) return; + const update = () => { + const away = stage.scrollHeight - stage.scrollTop - stage.clientHeight; + btn.hidden = away < stage.clientHeight * 0.75; + }; + stage.addEventListener("scroll", update, { passive: true }); + if (typeof ResizeObserver !== "undefined") { + new ResizeObserver(update).observe(stage.firstElementChild ?? stage); + } + // image-heavy boards grow under the scroll as lazy images load, + // and multi-second network fetches emit no resize events — so a + // "resize quiet" heuristic ends the chase mid-download. Instead: + // tick every 300ms; while the smooth scroll is moving, wait; if + // stalled short of the bottom, pin scrollTop there; stop once the + // height holds stable AT the bottom (or 20s cap / user input). + btn.onclick = () => { + const t0 = performance.now(); + let lastHeight = -1; + let lastTop = -1; + let stable = 0; + const stop = () => clearInterval(tick); + for (const ev of ["wheel", "touchstart", "keydown", "pointerdown"] as const) { + window.addEventListener(ev, stop, { once: true, capture: true }); + } + stage.scrollTo({ top: stage.scrollHeight, behavior: "smooth" }); + const tick = setInterval(() => { + const away = stage.scrollHeight - stage.scrollTop - stage.clientHeight; + if (away <= 2) { + stable = stage.scrollHeight === lastHeight ? stable + 1 : 0; + if (stable >= 5 || performance.now() - t0 > 20000) stop(); + } else { + stable = 0; + if (performance.now() - t0 > 20000) stop(); + else if (stage.scrollTop === lastTop) stage.scrollTop = stage.scrollHeight; + } + lastHeight = stage.scrollHeight; + lastTop = stage.scrollTop; + }, 300); + }; + update(); +} + function scrollToAnchor(id: string) { if (!id) return; let tries = 0; @@ -1528,6 +1576,7 @@ if (isShareMode) { initTheme(); const followBtn = document.getElementById("follow"); if (followBtn) followBtn.onclick = () => setFollowing(followBtn.getAttribute("aria-pressed") !== "true"); + initScrollBottom(); if (READONLY) { // read-only demo: no agent to talk to, no history to rewind, no template library document.getElementById("console-toggle")?.remove(); document.getElementById("history-toggle")?.remove();