From 76480d7674c37aaf7a5069bea895f179b175e498 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Tue, 21 Jul 2026 13:40:32 +0000 Subject: [PATCH 1/8] =?UTF-8?q?viewer:=20ImageLayers=20=E2=80=94=20stacked?= =?UTF-8?q?=20images=20with=20per-layer=20visibility=20+=20opacity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare a render against its blueprint or flip a whole generation chain in one display. Dependency-free (pattern: ImageCarousel); layers prop is plain data with URL-scheme re-check; registered in resolver + core whitelist. Deployed + Playwright-verified (toggle delta test). --- packages/core/src/validate.ts | 1 + .../client/renderers/component-resolver.tsx | 2 + .../src/client/renderers/image-layers.tsx | 124 ++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 packages/viewer/src/client/renderers/image-layers.tsx diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 1c6e18f..1c8ead8 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -75,6 +75,7 @@ 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", + "ImageLayers", "SliderGallery", "TileGrid", "FilterSort", "PlacesExplorer", // canonical board-opener primitive — renders title + lede + optional legend + optional diff --git a/packages/viewer/src/client/renderers/component-resolver.tsx b/packages/viewer/src/client/renderers/component-resolver.tsx index d5297c4..1730bea 100644 --- a/packages/viewer/src/client/renderers/component-resolver.tsx +++ b/packages/viewer/src/client/renderers/component-resolver.tsx @@ -18,6 +18,7 @@ import { MapLink } from "./map-link-card.js"; import { Masonry } from "./masonry.js"; import { MapDetail } from "./map-detail.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"; @@ -73,6 +74,7 @@ const REGISTRY = { // dependency-free swipeable image strip (scroll-snap + arrows + dots) for per-card galleries ImageCarousel, // interactive slider gallery — shows one child at a time based on slider position + ImageLayers, SliderGallery, // interactive tile grid with hover-to-highlight same-cluster cells TileGrid, diff --git a/packages/viewer/src/client/renderers/image-layers.tsx b/packages/viewer/src/client/renderers/image-layers.tsx new file mode 100644 index 0000000..e8ee147 --- /dev/null +++ b/packages/viewer/src/client/renderers/image-layers.tsx @@ -0,0 +1,124 @@ +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. + * Dependency-free like ImageCarousel; `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; +} + +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" + ? 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 [vis, setVis] = useState(layers.map((l) => l.visible)); + const [op, setOp] = useState(layers.map((l) => l.opacity)); + + if (layers.length === 0) return null; + const width = props.w ?? 768; + const rendering = props.pixelated === false ? undefined : ("pixelated" as const); + + return ( +
+
+ {/* first layer sets the box height in normal flow */} + {layers.map((l, i) => ( + {l.label} + ))} +
+
+ {layers.map((l, i) => ( + + ))} +
+
+ ); +} From 70e4ef273109f1e1e4f37f02af0f5a3a2941c580 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 22 Jul 2026 02:25:08 +0000 Subject: [PATCH 2/8] ImageLayers: review fixes from 5-lens adversarial panel - sparse src-keyed user overrides replace index-array state: live patches that grow/reorder/re-author layers keep working (was: new layers permanently invisible with dead controls; reorders glued user toggles to slots) - hidden layers no longer mount their ; loading=lazy per the ImageCarousel convention (was: 109 PNGs / 68 MB eager fetch on the production board) - a11y: sliders get accessible names, slider moved out of the checkbox label (was: anonymous range inputs, value polluting the checkbox AX name); role=group on the control row; theme text token for labels (contrast) - NaN-guard via Number.isFinite; stable key={src} - registration comment hygiene + dedupe stray TileGrid whitelist entry --- packages/core/src/validate.ts | 2 + .../client/renderers/component-resolver.tsx | 3 +- .../src/client/renderers/image-layers.tsx | 111 +++++++++++------- 3 files changed, 71 insertions(+), 45 deletions(-) diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 1c8ead8..2e1ffa6 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -75,6 +75,8 @@ 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", "SliderGallery", "TileGrid", "FilterSort", "PlacesExplorer", diff --git a/packages/viewer/src/client/renderers/component-resolver.tsx b/packages/viewer/src/client/renderers/component-resolver.tsx index 1730bea..f23962e 100644 --- a/packages/viewer/src/client/renderers/component-resolver.tsx +++ b/packages/viewer/src/client/renderers/component-resolver.tsx @@ -73,8 +73,9 @@ const REGISTRY = { MapDetail, // dependency-free swipeable image strip (scroll-snap + arrows + dots) for per-card galleries ImageCarousel, - // interactive slider gallery — shows one child at a time based on slider position + // 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 TileGrid, diff --git a/packages/viewer/src/client/renderers/image-layers.tsx b/packages/viewer/src/client/renderers/image-layers.tsx index e8ee147..4555805 100644 --- a/packages/viewer/src/client/renderers/image-layers.tsx +++ b/packages/viewer/src/client/renderers/image-layers.tsx @@ -17,8 +17,15 @@ import { useState } from "react"; * "visible": false } ] } } * * `opacity` is 0–100 (default 100); `visible` defaults true. - * Dependency-free like ImageCarousel; `layers` is a plain-data - * prop, so unsafe URL schemes are re-checked here. + * 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 at all (a board + * of 8 instances × 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; @@ -39,6 +46,8 @@ function safeSrc(src: unknown): string | null { return null; } +type Override = { vis?: boolean; op?: number }; + export function ImageLayers(props: ImageLayersProps) { const raw = Array.isArray(props.layers) ? props.layers : []; const layers = raw @@ -46,77 +55,91 @@ export function ImageLayers(props: ImageLayersProps) { src: safeSrc(l?.src), label: typeof l?.label === "string" ? l.label : `layer ${i + 1}`, opacity: - typeof l?.opacity === "number" + 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 [vis, setVis] = useState(layers.map((l) => l.visible)); - const [op, setOp] = useState(layers.map((l) => l.opacity)); + const [ovr, setOvr] = useState>({}); + 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 (
- {/* first layer sets the box height in normal flow */} - {layers.map((l, i) => ( - {l.label} - ))} + {/* 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 ? ( + {l.label} + ) : null, + )}
-
- {layers.map((l, i) => ( - + ))}
From 21d170b7d18c858d432d63039d1872b6e093b7a3 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 22 Jul 2026 02:48:12 +0000 Subject: [PATCH 3/8] ImageLayers: close remaining review items - Mantine Checkbox/Slider replace raw native inputs (house style per Checklist/SliderGallery; fixes touch targets + control contrast as a side effect) - all/none bulk toggles per instance (218 flat controls at production density had no bulk affordance) - authoring-time validation for layers[] in core validate: src scheme (http(s)/data:image), opacity range, visible type - closes the stored-then-blank silent-breakage gap; covered by unit tests (17 pass) --- packages/core/src/validate.ts | 21 ++++++ packages/core/test/validate-board.test.ts | 23 +++++- .../src/client/renderers/image-layers.tsx | 75 ++++++++++--------- 3 files changed, 83 insertions(+), 36 deletions(-) diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 2e1ffa6..9e978c4 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -194,6 +194,27 @@ 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 === "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/renderers/image-layers.tsx b/packages/viewer/src/client/renderers/image-layers.tsx index 4555805..84c51e9 100644 --- a/packages/viewer/src/client/renderers/image-layers.tsx +++ b/packages/viewer/src/client/renderers/image-layers.tsx @@ -1,3 +1,4 @@ +import { Button, Checkbox, Group, Slider } from "@mantine/core"; import { useState } from "react"; /** @@ -21,7 +22,7 @@ import { useState } from "react"; * 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 at all (a board - * of 8 instances × 13 layers must not fetch 68 MB up front); the + * 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 @@ -103,44 +104,48 @@ export function ImageLayers(props: ImageLayersProps) { ) : null, )} -
- {eff.map((l) => ( - + + + + + {eff.map((l) => ( + + setLayer(l.src, { vis: !l.vis })} /> - {l.label} - - - setLayer(l.src, { op: Number(e.currentTarget.value) }) - } - style={{ width: 64 }} - /> - - ))} + setLayer(l.src, { op: v })} + /> + + ))} +
); From c70e8ce571c6e81e61efcc557188bc0992d14264 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Wed, 22 Jul 2026 06:24:21 +0000 Subject: [PATCH 4/8] viewer: floating scroll-to-bottom button Long boards append new content at the end; the pill appears only when the reader is more than ~3/4 of a viewport above the bottom (scroll + resize tracked) and smooth-scrolls the stage. Hidden in print. --- packages/viewer/src/client/index.html | 1 + packages/viewer/src/client/style.css | 22 ++++++++++++++++++++++ packages/viewer/src/client/viewer.ts | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+) 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

+