Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gcloudignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules
.git
out
*.out
packages/*/build
packages/*/dist
*.tsbuildinfo
.tmp-pr274
.claude
43 changes: 43 additions & 0 deletions packages/core/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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 <img> 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<string, unknown>;
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<string, unknown>;
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.
Expand Down
23 changes: 22 additions & 1 deletion packages/core/test/validate-board.test.ts
Original file line number Diff line number Diff line change
@@ -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 = () => ({
Expand Down Expand Up @@ -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/);
});
});
1 change: 1 addition & 0 deletions packages/viewer/src/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ <h1>boards</h1>
<div id="scope-intro" hidden></div>
<div id="history-banner" hidden></div>
<div id="stage"><div id="diagram"></div></div>
<button id="scroll-bottom" type="button" aria-label="Scroll to bottom" title="Scroll to bottom" hidden>&#8595;</button>
</main>
<button id="to-top" type="button" aria-label="Back to top" title="Back to top">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="18 15 12 9 6 15"/></svg>
Expand Down
5 changes: 5 additions & 0 deletions packages/viewer/src/client/renderers/component-resolver.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import { Video } from "./video.js";
import { MapLink } from "./map-link-card.js";
import { Masonry } from "./masonry.js";
import { MapDetail } from "./map-detail.js";
import { Pages } from "./pages.js";
import { ImageCarousel } from "./image-carousel.js";
import { ImageLayers } from "./image-layers.js";
import { SliderGallery } from "./slider-gallery.js";
import { TileGrid } from "./tile-grid.js";
import { BoardHeader } from "./board-header.js";
Expand Down Expand Up @@ -70,8 +72,11 @@ const REGISTRY = {
Masonry,
// docked detail pane for a sibling Map — renders the LAST-CLICKED marker's detail tree
MapDetail,
Pages,
// dependency-free swipeable image strip (scroll-snap + arrows + dots) for per-card galleries
ImageCarousel,
// stacked image layers with per-layer visibility + opacity
ImageLayers,
// interactive slider gallery — shows one child at a time based on slider position
SliderGallery,
// interactive tile grid with hover-to-highlight same-cluster cells
Expand Down
152 changes: 152 additions & 0 deletions packages/viewer/src/client/renderers/image-layers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { Button, Checkbox, Group, Slider } from "@mantine/core";
import { useState } from "react";

/**
* `ImageLayers` — stacked same-size images with per-layer visibility
* toggles and opacity sliders: compare a render against its
* blueprint, or flip through a whole generation chain in ONE
* display (plan / scaffold / draws / winner / segmask / cut
* layers). All layers share the first layer's natural aspect; they
* are absolutely stacked and the controls sit underneath.
*
* Authoring:
* { "type": "ImageLayers", "props": { "w": 768, "layers": [
* { "src": "https://…/10_blueprint.png", "label": "blueprint" },
* { "src": "https://…/40_winner.png", "label": "winner",
* "opacity": 55 },
* { "src": "https://…/70_segmask.png", "label": "segmask",
* "visible": false } ] } }
*
* `opacity` is 0–100 (default 100); `visible` defaults true.
* User overrides are kept SPARSE and keyed by src (not index), so
* live patches that grow/reorder/re-author `layers` keep working:
* authored values stay the fallback and user intent follows the
* layer. Hidden layers do not mount their <img> at all (a board
* of 8 instances x 13 layers must not fetch 68 MB up front); the
* first layer always mounts (it sets the box height) and every
* img is loading="lazy" per the ImageCarousel convention.
* `layers` is a plain-data prop, so unsafe URL schemes are
* re-checked here.
*/
export interface ImageLayer {
src: string;
label?: string;
opacity?: number;
visible?: boolean;
}
export interface ImageLayersProps {
layers?: ImageLayer[];
w?: number | string;
pixelated?: boolean;
}

function safeSrc(src: unknown): string | null {
if (typeof src !== "string") return null;
const s = src.trim();
if (/^https?:\/\//i.test(s) || s.startsWith("data:image/")) return s;
return null;
}

type Override = { vis?: boolean; op?: number };

export function ImageLayers(props: ImageLayersProps) {
const raw = Array.isArray(props.layers) ? props.layers : [];
const layers = raw
.map((l, i) => ({
src: safeSrc(l?.src),
label: typeof l?.label === "string" ? l.label : `layer ${i + 1}`,
opacity:
typeof l?.opacity === "number" && Number.isFinite(l.opacity)
? Math.max(0, Math.min(100, l.opacity))
: 100,
visible: l?.visible !== false,
}))
.filter((l): l is typeof l & { src: string } => l.src !== null);

const [ovr, setOvr] = useState<Record<string, Override>>({});
const setLayer = (src: string, patch: Override) =>
setOvr((o) => ({ ...o, [src]: { ...o[src], ...patch } }));

if (layers.length === 0) return null;
const width = props.w ?? 768;
const rendering = props.pixelated === false ? undefined : ("pixelated" as const);

const eff = layers.map((l) => ({
...l,
vis: ovr[l.src]?.vis ?? l.visible,
op: ovr[l.src]?.op ?? l.opacity,
}));

return (
<div style={{ maxWidth: typeof width === "number" ? `${width}px` : width }}>
<div style={{ position: "relative", width: "100%" }}>
{/* the first layer always mounts — it sets the box height
in normal flow; hidden non-first layers mount nothing */}
{eff.map((l, i) =>
i === 0 || l.vis ? (
<img
key={l.src}
src={l.src}
alt={l.label}
loading="lazy"
style={{
display: "block",
visibility: l.vis ? "visible" : "hidden",
position: i === 0 ? "relative" : "absolute",
top: 0,
left: 0,
width: "100%",
opacity: l.vis ? l.op / 100 : 0,
imageRendering: rendering,
borderRadius: 6,
}}
/>
) : null,
)}
</div>
<div role="group" aria-label="image layers" style={{ marginTop: 6 }}>
<Group gap={4} mb={4}>
<Button
size="compact-xs"
variant="subtle"
onClick={() =>
setOvr(Object.fromEntries(layers.map((l) => [l.src, { ...ovr[l.src], vis: true }])))
}
>
all
</Button>
<Button
size="compact-xs"
variant="subtle"
onClick={() =>
setOvr(Object.fromEntries(layers.map((l) => [l.src, { ...ovr[l.src], vis: false }])))
}
>
none
</Button>
</Group>
<Group gap="xs" wrap="wrap">
{eff.map((l) => (
<Group key={l.src} gap={6} wrap="nowrap">
<Checkbox
size="xs"
label={l.label}
checked={l.vis}
onChange={() => setLayer(l.src, { vis: !l.vis })}
/>
<Slider
size="xs"
w={72}
min={0}
max={100}
value={l.op}
aria-label={`${l.label} opacity`}
onChange={(v) => setLayer(l.src, { op: v })}
/>
</Group>
))}
</Group>
</div>
</div>
);
}
53 changes: 53 additions & 0 deletions packages/viewer/src/client/renderers/pages.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Group gap="md" my="sm" wrap="wrap" key={where}>
<Pagination
total={pages.length}
value={idx + 1}
onChange={setPage}
size="sm"
siblings={2}
/>
<Text size="sm" c="dimmed">
{idx + 1}/{pages.length} · {title}
</Text>
</Group>
);
return (
<div className="tc-pages">
{control("top")}
<div key={idx}>{resolve(entry.node)}</div>
{control("bottom")}
</div>
);
}
22 changes: 22 additions & 0 deletions packages/viewer/src/client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; } }
Loading