diff --git a/docs/design_handoff/CLAUDE_CODE_PROMPT.md b/docs/design_handoff/CLAUDE_CODE_PROMPT.md new file mode 100644 index 0000000000000000000000000000000000000000..98ae5f91cce52244c4354aff05eac60a47cc5aa4 --- /dev/null +++ b/docs/design_handoff/CLAUDE_CODE_PROMPT.md @@ -0,0 +1,43 @@ +# Prompt for Claude Code · v0.4.5 polish session + +Copy-paste this whole block into Claude Code as your opening message. + +--- + +v0.4.4 has shipped to the local development environment. The Findings region with the Five Stones, evidence cards, and Capstone meta-card are live and rendering against real queries via local uvicorn (`uvicorn web.main:app --host 127.0.0.1 --port 7860`). Screenshots from 80 Pioneer Street, Red Hook confirm the architecture working: four Stones producing genuinely different evidence-card kinds, each carrying its tier badge, the briefing prose with citations resolving cleanly, and the map with three tier-encoded layers. + +**v0.4.5 is polish.** Nine specific issues observed in production-shaped local runs, plus a new Stone-tinted light-theming layer. **None of the fixes are structural. Don't rebuild anything.** Read the current implementations of `src/lib/components/findings/StoneRegion.svelte`, `src/lib/components/findings/EvidenceCard.svelte`, and `src/lib/components/findings/CapstoneCard.svelte` and apply the deltas described in `V0.4.5_SPEC.md`. + +**Important context.** + +- Public mirrors (GitHub, HF Space) were deleted pre-hackathon for caution and re-publish during the hackathon window. **The HF Space link is currently delisted; do not reference it.** All references in this work target local development. +- The codebase is **SvelteKit (Svelte 5 with runes)**. Stay in idiomatic Svelte: `$state`, `$derived`, `$props`, scoped ` + + + +
+ + + + + + + + diff --git a/docs/design_handoff/design_files/Riprap Stone-Grouped UI v0.4.4.html b/docs/design_handoff/design_files/Riprap Stone-Grouped UI v0.4.4.html new file mode 100644 index 0000000000000000000000000000000000000000..af36e040567134c7ca05d5605dfb1d7b1d9b0c56 --- /dev/null +++ b/docs/design_handoff/design_files/Riprap Stone-Grouped UI v0.4.4.html @@ -0,0 +1,369 @@ + + + + + +Riprap , Stone-grouped UI mockup v0.4.4 + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + diff --git a/docs/design_handoff/design_files/Riprap Stone-Grouped UI v0.4.5.html b/docs/design_handoff/design_files/Riprap Stone-Grouped UI v0.4.5.html new file mode 100644 index 0000000000000000000000000000000000000000..00d755a6190daabe98cc94456de523fd5b303f9d --- /dev/null +++ b/docs/design_handoff/design_files/Riprap Stone-Grouped UI v0.4.5.html @@ -0,0 +1,369 @@ + + + + + +Riprap · Stone-grouped UI mockup v0.4.5 + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + diff --git a/docs/design_handoff/design_files/briefing.jsx b/docs/design_handoff/design_files/briefing.jsx new file mode 100644 index 0000000000000000000000000000000000000000..376ea79226fa9a23a0f810729df1a82506ff9d57 --- /dev/null +++ b/docs/design_handoff/design_files/briefing.jsx @@ -0,0 +1,312 @@ +/* Briefing prose with epistemic-tier glyph margin. + Each renders the glyph in the left margin + and a hoverable superscript citation that scrolls the citation drawer. +*/ + +const { useState, useRef, useEffect, useMemo } = React; + +/* ── Citation registry for the sample briefing ───────────────────── */ +const CITATIONS = { + c1: { + n: 1, + tier: "empirical", + source: "USGS", + title: "Hurricane Sandy storm tide elevations, NY-NJ Harbor", + docId: "USGS-OFR-2013-1234", + url: "https://pubs.usgs.gov/of/2013/1234/", + vintage: "2013-05", + retrieved: "2026-04-28", + }, + c2: { + n: 2, + tier: "empirical", + source: "NYC OEM", + title: "Hurricane Sandy Inundation Zone (2012)", + docId: "NYCOEM-SIZ-2013", + url: "https://data.cityofnewyork.us/dataset/sandy-inundation-zone", + vintage: "2013-01", + retrieved: "2026-04-28", + }, + c3: { + n: 3, + tier: "empirical", + source: "FloodNet NYC", + title: "Sensor BK-RH-002 , Coffey Park, monthly exceedance", + docId: "FN-BK-RH-002", + url: "https://floodnet.nyc/sensor/BK-RH-002", + vintage: "2026-04", + retrieved: "2026-05-02", + }, + c4: { + n: 4, + tier: "modeled", + source: "FEMA", + title: "Preliminary Flood Insurance Rate Map, panel 36047C0207G", + docId: "FEMA-FIRM-36047C0207G", + url: "https://msc.fema.gov/portal/search", + vintage: "2024-09", + retrieved: "2026-04-28", + }, + c5: { + n: 5, + tier: "modeled", + source: "NYC DEP", + title: "Stormwater Flood Map , Moderate Stormwater Scenario", + docId: "NYCDEP-SWFM-2024", + url: "https://nyc.gov/stormwater-map", + vintage: "2024-06", + retrieved: "2026-04-28", + }, + c6: { + n: 6, + tier: "modeled", + source: "NPCC4", + title: "Sea-level rise projections, 2050 90th percentile", + docId: "NPCC4-Ch3-Tbl3.2", + url: "https://nyas.org/npcc4", + vintage: "2024-03", + retrieved: "2026-04-28", + }, + c7: { + n: 7, + tier: "proxy", + source: "NYC 311", + title: "Flooding service requests, BK CB6 2019–2025", + docId: "NYC311-FLD-CB6", + url: "https://data.cityofnewyork.us/311", + vintage: "2025-12", + retrieved: "2026-05-01", + }, + c8: { + n: 8, + tier: "proxy", + source: "FEMA NFIP", + title: "National Flood Insurance Program claims, tract 36047008500", + docId: "NFIP-T36047008500", + url: "https://www.fema.gov/openfema", + vintage: "2024-12", + retrieved: "2026-04-28", + }, + c9: { + n: 9, + tier: "synthetic", + source: "TerraMind v1.2", + title: "Synthetic SAR backscatter for 2025-09-14 (Sentinel-1 cloud-occluded)", + docId: "RIPRAP-SYN-20250914", + url: "#methodology-synthetic", + vintage: "2025-09", + retrieved: "2026-05-02", + }, + c10: { + n: 10, + tier: "modeled", + source: "NYC DCP", + title: "Waterfront Revitalization Program , Coastal Risk Area", + docId: "NYCDCP-WRP-2022", + url: "https://nyc.gov/dcp/wrp", + vintage: "2022-11", + retrieved: "2026-04-28", + }, +}; + +const Cite = ({ id, onActivate }) => { + const c = CITATIONS[id]; + if (!c) return null; + return ( + { + e.preventDefault(); + onActivate?.(id); + }} + aria-label={`Citation ${c.n}: ${c.source}, ${c.title}`} + > + [{c.n}] + + ); +}; + +const Claim = ({ tier, children }) => ( + + + + + {children} + +); + +const SectionHead = ({ n, label, tier, children }) => ( +

+ {n} + {label} + {tier && ( + + + + )} + {children && {children}} +

+); + +/* ── Sample briefing: 80 Pioneer St, Red Hook, Brooklyn ─────────── */ +const BRIEFING_BLOCKS = [ + { kind: "status", html: ` +

+ 80 Pioneer Street, Red Hook, Brooklyn 11231. + Block 597, Lot 30. Industrial Business Zone (IBZ-RH). + Queried 2026-05-05 14:22 ET. Briefing v0.4.4 · 5 Stones engaged · Keystone silent (no register joins matched) +

+ ` }, + + { kind: "head", n: "01", label: "Status", title: "Coastal-edge, post-Sandy, multi-hazard" }, + { kind: "prose", parts: [ + { tier: "empirical", text: "The address sits 380 ft inland of the Erie Basin bulkhead, at a ground elevation of 6.2 ft NAVD88", cite: "c1" }, + { text: " , within the " }, + { tier: "empirical", text: "2012 Sandy Inundation Zone, which recorded a peak storm tide of 11.4 ft NAVD88 at the Battery", cite: "c2" }, + { text: " 2.4 mi to the northwest. " }, + { tier: "modeled", text: "FEMA's preliminary FIRM places the parcel in Zone AE (BFE 11 ft NAVD88)", cite: "c4" }, + { text: ", a 4.8 ft freeboard above current grade. The site is upgradient of two FloodNet sensors and three blocks from a chronic 311 cluster." }, + ]}, + + { kind: "head", n: "02", label: "Empirical evidence", tier: "empirical" }, + { kind: "prose", parts: [ + { tier: "empirical", text: "FloodNet sensor BK-RH-002 (Coffey Park, 1,200 ft south) recorded 7 above-curb events between 2024-06 and 2026-04", cite: "c3" }, + { text: ", with a peak depth of 14.3 cm during the 2025-09-29 nor'easter. " }, + { tier: "empirical", text: "USGS post-Sandy high-water marks within 500 ft cluster between 6.8 and 8.1 ft NAVD88", cite: "c1" }, + { text: ", consistent with 0.6–1.9 ft of standing water at the queried address during the storm." }, + ]}, + + { kind: "head", n: "03", label: "Modeled scenarios", tier: "modeled" }, + { kind: "prose", parts: [ + { tier: "modeled", text: "DEP's Moderate Stormwater Scenario (2.13 in/hr design storm) shows ponding ≥4 in across the western half of the lot", cite: "c5" }, + { text: ", routed by the 1.2% slope toward Imlay St. " }, + { tier: "modeled", text: "Under NPCC4's 2050 90th-percentile sea-level rise (30 in)", cite: "c6" }, + { text: ", the parcel falls within the projected daily-tidal floodplain by mid-century. " }, + { tier: "synthetic", text: "Synthetic SAR backscatter for 2025-09-14 (Sentinel-1 cloud-occluded) was generated by TerraMind v1.2 and is presented as a prior, not an observation", cite: "c9" }, + { text: "; treat with appropriate caution." }, + ]}, + + { kind: "head", n: "04", label: "Policy context" }, + { kind: "prose", parts: [ + { tier: "proxy", text: "311 flood complaints within the surrounding census tract total 89 calls over 2019–2025, with seasonal clustering in Aug–Oct", cite: "c7" }, + { text: ". " }, + { tier: "proxy", text: "NFIP claims aggregated to tract 36047008500 total $4.1M across 47 paid losses since 2000", cite: "c8" }, + { text: ". " }, + { tier: "modeled", text: "The site lies within the NYC Waterfront Revitalization Program Coastal Risk Area; CEQR §817 review applies to any discretionary action", cite: "c10" }, + { text: "." }, + ]}, +]; + +/* ── Streaming renderer ─────────────────────────────────────────── + Uses CSS reveal (token-by-token via animation-delay) instead of + recomputing innerText, to avoid layout shift. +*/ +const StreamingBriefing = ({ onCite, replayKey }) => { + const [visibleCount, setVisibleCount] = useState(0); + const totalBlocks = BRIEFING_BLOCKS.length; + + useEffect(() => { + setVisibleCount(0); + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduce) { + setVisibleCount(totalBlocks); + return; + } + let i = 0; + const tick = () => { + i++; + setVisibleCount(i); + if (i < totalBlocks) { + setTimeout(tick, i < 2 ? 280 : 420); + } + }; + const t = setTimeout(tick, 240); + return () => clearTimeout(t); + }, [replayKey]); + + return ( +
+ {BRIEFING_BLOCKS.slice(0, visibleCount).map((b, i) => { + if (b.kind === "status") { + return
; + } + if (b.kind === "head") { + return {b.title}; + } + if (b.kind === "prose") { + return ( +

+ {b.parts.map((p, j) => { + if (p.tier) { + return ( + + {p.text} + {p.cite && } + + ); + } + return {p.text}; + })} +

+ ); + } + return null; + })} + {visibleCount < totalBlocks && ( + + )} +
+ ); +}; + +const CitationDrawer = ({ activeId, onClose }) => { + const items = Object.entries(CITATIONS); + return ( + + ); +}; + +Object.assign(window, { StreamingBriefing, CitationDrawer, CITATIONS, Cite, Claim }); diff --git a/docs/design_handoff/design_files/design-canvas.jsx b/docs/design_handoff/design_files/design-canvas.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5e685a6df0d54755daa34abafe22679ee458ecff --- /dev/null +++ b/docs/design_handoff/design_files/design-canvas.jsx @@ -0,0 +1,936 @@ + +// DesignCanvas.jsx — Figma-ish design canvas wrapper +// Warm gray grid bg + Sections + Artboards + PostIt notes. +// Artboards are reorderable (grip-drag), deletable, labels/titles are +// inline-editable, and any artboard can be opened in a fullscreen focus +// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar +// via the host bridge. No assets, no deps. +// +// Usage: +// +// +// +// +// +// + +const DC = { + bg: '#f0eee9', + grid: 'rgba(0,0,0,0.06)', + label: 'rgba(60,50,40,0.7)', + title: 'rgba(40,30,20,0.85)', + subtitle: 'rgba(60,50,40,0.6)', + postitBg: '#fef4a8', + postitText: '#5a4a2a', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +// One-time CSS injection (classes are dc-prefixed so they don't collide with +// the hosted design's own styles). +if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { + const s = document.createElement('style'); + s.id = 'dc-styles'; + s.textContent = [ + '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', + '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', + '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', + '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', + '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', + // isolation:isolate contains artboard content's z-indexes so a + // z-indexed child (sticky navbar etc.) can't paint over .dc-header or + // the .dc-menu popover that drops into the top of the card. + '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}', + '.dc-card *{scrollbar-width:none}', + '.dc-card *::-webkit-scrollbar{display:none}', + // Per-artboard header: grip + label on the left, delete/expand on the + // right. Single flex row; when the artboard's on-screen width is too + // narrow for both the label yields (ellipsis, then hidden entirely below + // ~4ch via the container query) and the buttons stay on the row. + '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', + ' display:flex;align-items:center;container-type:inline-size}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', + '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', + '.dc-grip:hover{background:rgba(0,0,0,.08)}', + '.dc-grip:active{cursor:grabbing}', + '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', + ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', + // Below ~4ch of label room: hide the label entirely, and drop the grip to + // hover-only (same reveal rule as .dc-btns) so a narrow header is clean + // until the card is moused. + '@container (max-width: 110px){', + ' .dc-labeltext{display:none}', + ' .dc-grip{opacity:0}', + ' [data-dc-slot]:hover .dc-grip{opacity:1}', + '}', + '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', + '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', + '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', + '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', + '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}', + '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', + ' font:inherit;transition:background .12s,color .12s}', + '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}', + // Slot hosting an open menu floats above later siblings (which otherwise + // paint on top — same z-index:auto, later DOM order) so the popup isn't + // clipped by the next card. + '[data-dc-slot]:has(.dc-menu){z-index:10}', + '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;', + ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}', + '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;', + ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;', + ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}', + '.dc-menu button:hover{background:rgba(0,0,0,.05)}', + '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}', + '.dc-menu .dc-danger{color:#c96442}', + '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}', + // Chrome (titles / labels / buttons) counter-scales against the viewport + // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by + // DCViewport on every transform update and inherits to all descendants — + // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use + // it the same way. + // + // The header uses transform:scale (out-of-flow, so layout impact doesn't + // matter) with its world-space width set to card-width / inv-zoom so that + // after counter-scaling its on-screen width exactly matches the card's — + // that's what lets the container query + text-overflow behave against the + // card's visible edge at every zoom level. + // + // The section head uses CSS zoom instead of transform so its layout box + // grows with the counter-scale, pushing the card row down — otherwise the + // constant-screen-size title would overflow into the (shrinking) world- + // space gap and overlap the artboard headers at low zoom. + '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', + ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', + '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', + ].join('\n'); + document.head.appendChild(s); +} + +const DCCtx = React.createContext(null); + +// ───────────────────────────────────────────────────────────── +// DesignCanvas — stateful wrapper around the pan/zoom viewport. +// Owns runtime state (per-section order, renamed titles/labels, hidden +// artboards, focused artboard). Order/titles/labels/hidden persist to a +// .design-canvas.state.json +// sidecar next to the HTML. Reads go via plain fetch() so the saved +// arrangement is visible anywhere the HTML + sidecar are served together +// (omelette preview, direct link, downloaded zip). Writes go through the +// host's window.omelette bridge — editing requires the omelette runtime. +// Focus is ephemeral. +// ───────────────────────────────────────────────────────────── +const DC_STATE_FILE = '.design-canvas.state.json'; + +function DesignCanvas({ children, minScale, maxScale, style }) { + const [state, setState] = React.useState({ sections: {}, focus: null }); + // Hold rendering until the sidecar read settles so the saved order/titles + // appear on first paint (no source-order flash). didRead gates writes until + // the read settles so the empty initial state can't clobber a slow read; + // skipNextWrite suppresses the one echo-write that would otherwise follow + // hydration. + const [ready, setReady] = React.useState(false); + const didRead = React.useRef(false); + const skipNextWrite = React.useRef(false); + + React.useEffect(() => { + let off = false; + fetch('./' + DC_STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (off || !saved || !saved.sections) return; + skipNextWrite.current = true; + setState((s) => ({ ...s, sections: saved.sections })); + }) + .catch(() => {}) + .finally(() => { didRead.current = true; if (!off) setReady(true); }); + const t = setTimeout(() => { if (!off) setReady(true); }, 150); + return () => { off = true; clearTimeout(t); }; + }, []); + + React.useEffect(() => { + if (!didRead.current) return; + if (skipNextWrite.current) { skipNextWrite.current = false; return; } + const t = setTimeout(() => { + window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); + }, 250); + return () => clearTimeout(t); + }, [state.sections]); + + // Build registries synchronously from children so FocusOverlay can read + // them in the same render. Only direct DCSection > DCArtboard children are + // walked — wrapping them in other elements opts out of focus/reorder. + const registry = {}; // slotId -> { sectionId, artboard } + const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } + const sectionOrder = []; + React.Children.forEach(children, (sec) => { + if (!sec || sec.type !== DCSection) return; + const sid = sec.props.id ?? sec.props.title; + if (!sid) return; + sectionOrder.push(sid); + const persisted = state.sections[sid] || {}; + const abs = []; + React.Children.forEach(sec.props.children, (ab) => { + if (!ab || ab.type !== DCArtboard) return; + const aid = ab.props.id ?? ab.props.label; + if (aid) abs.push([aid, ab]); + }); + // hidden is scoped to one source revision — when the agent regenerates + // (artboard-ID set changes), prior deletes don't apply to new content. + const srcKey = abs.map(([k]) => k).join('\x1f'); + const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; + const srcIds = []; + abs.forEach(([aid, ab]) => { + if (hidden.includes(aid)) return; + registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; + srcIds.push(aid); + }); + const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); + sectionMeta[sid] = { + title: persisted.title ?? sec.props.title, + subtitle: sec.props.subtitle, + slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], + }; + }); + + const api = React.useMemo(() => ({ + state, + section: (id) => state.sections[id] || {}, + patchSection: (id, p) => setState((s) => ({ + ...s, + sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, + })), + setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), + }), [state]); + + // Esc exits focus; any outside pointerdown commits an in-progress rename. + React.useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; + const onPd = (e) => { + const ae = document.activeElement; + if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onPd, true); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onPd, true); + }; + }, [api]); + + return ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// DCViewport — transform-based pan/zoom (internal) +// +// Input mapping (Figma-style): +// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) +// • trackpad scroll → pan (two-finger) +// • mouse wheel → zoom (notched; distinguished from trackpad scroll) +// • middle-drag / primary-drag-on-bg → pan +// +// Transform state lives in a ref and is written straight to the DOM +// (translate3d + will-change) so wheel ticks don't go through React — +// keeps pans at 60fps on dense canvases. +// ───────────────────────────────────────────────────────────── +function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { + const vpRef = React.useRef(null); + const worldRef = React.useRef(null); + const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + // Persist viewport across reloads so the user lands back where they were + // after an agent edit or browser refresh. The sandbox origin is already + // per-project; pathname keeps multiple canvas files in one project apart. + const tfKey = 'dc-viewport:' + location.pathname; + const saveT = React.useRef(0); + + const lastPostedScale = React.useRef(); + const apply = React.useCallback(() => { + const { x, y, scale } = tf.current; + const el = worldRef.current; + if (!el) return; + el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). + el.style.setProperty('--dc-inv-zoom', String(1 / scale)); + // Keep the host toolbar's % readout in sync with the canvas scale. Pan + // ticks leave scale unchanged — skip the cross-frame post for those. + if (lastPostedScale.current !== scale) { + lastPostedScale.current = scale; + window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); + } + clearTimeout(saveT.current); + saveT.current = setTimeout(() => { + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }, 200); + }, [tfKey]); + + React.useLayoutEffect(() => { + const flush = () => { + clearTimeout(saveT.current); + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }; + try { + const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); + if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { + tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; + apply(); + } + } catch {} + // Flush on pagehide and unmount so a reload within the 200ms debounce + // window doesn't drop the last pan/zoom. + window.addEventListener('pagehide', flush); + return () => { window.removeEventListener('pagehide', flush); flush(); }; + }, []); + + React.useEffect(() => { + const vp = vpRef.current; + if (!vp) return; + + const zoomAt = (cx, cy, factor) => { + const r = vp.getBoundingClientRect(); + const px = cx - r.left, py = cy - r.top; + const t = tf.current; + const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); + const k = next / t.scale; + // keep the world point under the cursor fixed + t.x = px - (px - t.x) * k; + t.y = py - (py - t.y) * k; + t.scale = next; + apply(); + }; + + // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends + // line-mode deltas (Firefox) or large integer pixel deltas with no X + // component (Chrome/Safari, typically multiples of 100/120). Trackpad + // two-finger scroll sends small/fractional pixel deltas, often with + // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. + const isMouseWheel = (e) => + e.deltaMode !== 0 || + (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); + + const onWheel = (e) => { + e.preventDefault(); + if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels + if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) { + // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched + // wheels fall through to the fixed-step branch below. + zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); + } else if (isMouseWheel(e)) { + // notched mouse wheel — fixed-ratio step per click + zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); + } else { + // trackpad two-finger scroll — pan + tf.current.x -= e.deltaX; + tf.current.y -= e.deltaY; + apply(); + } + }; + + // Safari sends native gesture* events for trackpad pinch with a smooth + // e.scale; preferring these over the ctrl+wheel fallback gives a much + // better feel there. No-ops on other browsers. Safari also fires + // ctrlKey wheel events during the same pinch — isGesturing makes + // onWheel drop those entirely so they neither zoom nor pan. + let gsBase = 1; + let isGesturing = false; + const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; + const onGestureChange = (e) => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); + }; + const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; + + // Drag-pan: middle button anywhere, or primary button on canvas + // background (anything that isn't an artboard or an inline editor). + let drag = null; + const onPointerDown = (e) => { + const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); + if (!(e.button === 1 || (e.button === 0 && onBg))) return; + e.preventDefault(); + vp.setPointerCapture(e.pointerId); + drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; + vp.style.cursor = 'grabbing'; + }; + const onPointerMove = (e) => { + if (!drag || e.pointerId !== drag.id) return; + tf.current.x += e.clientX - drag.lx; + tf.current.y += e.clientY - drag.ly; + drag.lx = e.clientX; drag.ly = e.clientY; + apply(); + }; + const onPointerUp = (e) => { + if (!drag || e.pointerId !== drag.id) return; + vp.releasePointerCapture(e.pointerId); + drag = null; + vp.style.cursor = ''; + }; + + // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the + // visible midpoint stays fixed — matching the host's iframe-zoom feel. + const onHostMsg = (e) => { + const d = e.data; + if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { + const r = vp.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); + } else if (d && d.type === '__dc_probe') { + // Host's [readyGen] reset asks whether a canvas is present; it + // fires on the iframe's native 'load', which for canvases with + // images/fonts is after our mount-time announce, so re-announce. + // Clear the pan-tick guard so apply() re-posts the current scale + // even if it's unchanged — the host just reset dcScale to 1. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + } + }; + window.addEventListener('message', onHostMsg); + // Announce canvas mode so the host toolbar proxies its % control here + // instead of scaling the iframe element (which would just shrink the + // viewport window of an infinite canvas). The apply() that follows emits + // the initial __dc_zoom so the toolbar % is correct before first pinch. + // lastPostedScale reset mirrors the __dc_probe handler: the layout + // effect's restore-path apply() may already have posted the restored + // scale (before __dc_present), so clear the guard to re-post it in order. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + + vp.addEventListener('wheel', onWheel, { passive: false }); + vp.addEventListener('gesturestart', onGestureStart, { passive: false }); + vp.addEventListener('gesturechange', onGestureChange, { passive: false }); + vp.addEventListener('gestureend', onGestureEnd, { passive: false }); + vp.addEventListener('pointerdown', onPointerDown); + vp.addEventListener('pointermove', onPointerMove); + vp.addEventListener('pointerup', onPointerUp); + vp.addEventListener('pointercancel', onPointerUp); + return () => { + window.removeEventListener('message', onHostMsg); + vp.removeEventListener('wheel', onWheel); + vp.removeEventListener('gesturestart', onGestureStart); + vp.removeEventListener('gesturechange', onGestureChange); + vp.removeEventListener('gestureend', onGestureEnd); + vp.removeEventListener('pointerdown', onPointerDown); + vp.removeEventListener('pointermove', onPointerMove); + vp.removeEventListener('pointerup', onPointerUp); + vp.removeEventListener('pointercancel', onPointerUp); + }; + }, [apply, minScale, maxScale]); + + const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; + return ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// DCSection — editable title + h-row of artboards in persisted order +// ───────────────────────────────────────────────────────────── +function DCSection({ id, title, subtitle, children, gap = 48 }) { + const ctx = React.useContext(DCCtx); + const sid = id ?? title; + const all = React.Children.toArray(children); + const artboards = all.filter((c) => c && c.type === DCArtboard); + const rest = all.filter((c) => !(c && c.type === DCArtboard)); + const sec = (ctx && sid && ctx.section(sid)) || {}; + // Must match DesignCanvas's srcKey computation exactly (it filters falsy + // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. + const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); + const srcKey = allIds.join('\x1f'); + const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; + const srcOrder = allIds.filter((k) => !hidden.includes(k)); + + const order = React.useMemo(() => { + const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); + return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; + }, [sec.order, srcOrder.join('|')]); + + const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + + // marginBottom counter-scales so the on-screen gap between sections stays + // constant — otherwise at low zoom the (world-space) gap collapses while + // the screen-constant sectionhead below it doesn't, and the title reads as + // belonging to the section above. paddingBottom below is just enough for + // the 24px artboard-header (abs-positioned above each card) plus ~8px, so + // the title sits tight against its own row at every zoom. + return ( +
+
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ + hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], + srcKey, + }))} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. +function DCArtboard() { return null; } + +// Per-artboard export (kind: 'png' | 'html'). Both paths share the same +// self-contained clone: computed styles baked in, @font-face / / +// inline-style background-image urls inlined as data URIs. PNG wraps the +// clone in foreignObject→canvas at 3× the artboard's natural width×height +// (same pipeline the host uses for page captures); HTML wraps it in a +// minimal standalone document. Both are independent of viewport zoom. +async function dcExport(node, w, h, name, kind) { + try { await document.fonts.ready; } catch {} + const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { + const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b); + })).catch(() => url); + + // Collect @font-face rules. ss.cssRules throws SecurityError on + // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch + // the CSS text directly (those endpoints send ACAO:*) and regex-extract + // the blocks. @import and @media/@supports are walked so nested + // @font-face rules aren't missed. + const fontRules = [], pending = [], seen = new Set(); + const scrapeCss = (href) => { + if (seen.has(href)) return; seen.add(href); + pending.push(fetch(href).then((r) => r.text()).then((css) => { + for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href }); + for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g)) + scrapeCss(new URL(m[1], href).href); + }).catch(() => {})); + }; + const walk = (rules, base) => { + for (const r of rules) { + if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base }); + else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) { + const ibase = r.styleSheet.href || base; + try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); } + } else if (r.cssRules) walk(r.cssRules, base); + } + }; + for (const ss of document.styleSheets) { + const base = ss.href || location.href; + try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); } + } + while (pending.length) await pending.shift(); + const fontCss = (await Promise.all(fontRules.map(async (rule) => { + let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g; + while ((m = re.exec(rule.css))) { + if (m[2].indexOf('data:') === 0) continue; + let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; } + out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")'); + } + return out; + }))).join('\n'); + + const cloneStyled = (src) => { + if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode(''); + const dst = src.cloneNode(false); + if (src.nodeType === 1) { + const cs = getComputedStyle(src); let txt = ''; + for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';'; + dst.setAttribute('style', txt + 'animation:none;transition:none;'); + if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {} + } + for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c)); + return dst; + }; + const clone = cloneStyled(node); + clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); + // Drop the card's own shadow/radius so the export is a flush w×h rect; + // the artboard's own background (if any) is already in the computed style. + clone.style.boxShadow = 'none'; clone.style.borderRadius = '0'; + + const jobs = []; + clone.querySelectorAll('img').forEach((el) => { + const s = el.getAttribute('src'); + if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d))); + }); + [clone, ...clone.querySelectorAll('*')].forEach((el) => { + const bg = el.style.backgroundImage; if (!bg) return; + let m; const re = /url\(["']?([^"')]+)["']?\)/g; + while ((m = re.exec(bg))) { + const tok = m[0], url = m[1]; + if (url.indexOf('data:') === 0) continue; + jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); })); + } + }); + await Promise.all(jobs); + + const xml = new XMLSerializer().serializeToString(clone); + const save = (blob, ext) => { + if (!blob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; + + if (kind === 'html') { + const html = '' + name + '' + + (fontCss ? '' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -loaded SVG rasterizes at its intrinsic size, so sizing it at 1× + // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the + // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders + // the HTML at full resolution. + const px = 3; + const svg = '' + + (fontCss ? '' : '') + xml + ''; + const img = new Image(); + await new Promise((res, rej) => { + img.onload = res; img.onerror = () => rej(new Error('svg load failed')); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); + const cv = document.createElement('canvas'); + cv.width = w * px; cv.height = h * px; + cv.getContext('2d').drawImage(img, 0, 0); + cv.toBlob((blob) => save(blob, 'png'), 'image/png'); +} + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + const cardRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirming, setConfirming] = React.useState(false); + + // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside + // the menu — first click arms the row, second commits; closing disarms. + React.useEffect(() => { + if (!menuOpen) { setConfirming(false); return; } + const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [menuOpen]); + + const doExport = (kind) => { + setMenuOpen(false); + if (!cardRef.current) return; + const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_'); + dcExport(cardRef.current, width, height, name, kind) + .catch((e) => console.error('[design-canvas] export failed:', e)); + }; + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/docs/design_handoff/design_files/evidence.jsx b/docs/design_handoff/design_files/evidence.jsx new file mode 100644 index 0000000000000000000000000000000000000000..672405671e248d89702482a20bb659d88f23a113 --- /dev/null +++ b/docs/design_handoff/design_files/evidence.jsx @@ -0,0 +1,257 @@ +/* Evidence cards: stack below the map. One card per specialist that fired. + Each card carries source label, formatted output, tier badge, doc_id, + and prominent vintage. Mobile: horizontal swipe; desktop: 2-col grid. +*/ + +const EVIDENCE = [ + { + id: "e1", citeId: "c1", tier: "empirical", + source: "USGS", + title: "Post-Sandy high-water marks within 500ft", + fmt: "table", + table: [ + ["HWM-NY-3081", "7.4 ft NAVD88", "0.18 mi"], + ["HWM-NY-3082", "8.1 ft NAVD88", "0.22 mi"], + ["HWM-NY-3105", "6.8 ft NAVD88", "0.31 mi"], + ], + docId: "USGS-OFR-2013-1234", + vintage: "2013-05", + }, + { + id: "e2", citeId: "c3", tier: "empirical", + source: "FloodNet NYC", + title: "Sensor BK-RH-002 , monthly above-curb events", + fmt: "spark", + spark: [0,0,1,0,2,1,0,0,3,0,1,0,0,0,2,1,0,0,1,0,2,4,1,1], + headline: "7 events", sub: "Jun 2024 → Apr 2026 · peak 14.3 cm", + docId: "FN-BK-RH-002", + vintage: "2026-04", + }, + { + id: "e3", citeId: "c4", tier: "modeled", + source: "FEMA", + title: "Preliminary FIRM, panel 36047C0207G", + fmt: "scalar", + scalar: { value: "Zone AE", unit: "BFE 11 ft NAVD88", aux: "freeboard +4.8 ft" }, + docId: "FEMA-FIRM-36047C0207G", + vintage: "2024-09", + }, + { + id: "e4", citeId: "c5", tier: "modeled", + source: "NYC DEP", + title: "Stormwater Flood Map , moderate scenario", + fmt: "thumb", + thumb: "stormwater", + sub: "2.13 in/hr · ponding ≥4 in W half of lot · routed toward Imlay St", + docId: "NYCDEP-SWFM-2024", + vintage: "2024-06", + }, + { + id: "e5", citeId: "c6", tier: "modeled", + source: "NPCC4", + title: "Sea-level rise projections for Lower NY Harbor", + fmt: "forecast", + forecast: [ + { year: 2030, low: 4, mid: 6, high: 9 }, + { year: 2050, low: 13, mid: 22, high: 30 }, + { year: 2080, low: 28, mid: 49, high: 75 }, + { year: 2100, low: 38, mid: 71, high: 114 }, + ], + docId: "NPCC4-Ch3-Tbl3.2", + vintage: "2024-03", + }, + { + id: "e6", citeId: "c9", tier: "synthetic", + source: "TerraMind v1.2", + title: "Synthetic SAR for 2025-09-14 (Sentinel-1 cloud-occluded)", + fmt: "thumb", + thumb: "synthetic", + sub: "Generated, not observed. Confidence 0.71. Provided as prior for downstream models; do not cite as observation.", + docId: "RIPRAP-SYN-20250914", + vintage: "2025-09", + }, + { + id: "e7", citeId: "c7", tier: "proxy", + source: "NYC 311", + title: "Flood complaints, BK CB6 (2019–2025)", + fmt: "histogram", + months: [3,2,1,0,1,4,7,12,18,11,5,3,4,2,1,0,2,3,8,9,4,2,1,0], + headline: "89 calls", sub: "seasonal cluster Aug–Oct", + docId: "NYC311-FLD-CB6", + vintage: "2025-12", + }, + { + id: "e8", citeId: "c8", tier: "proxy", + source: "FEMA NFIP", + title: "NFIP claims, tract 36047008500", + fmt: "scalar", + scalar: { value: "$4.1M", unit: "47 paid losses", aux: "since 2000-01-01" }, + docId: "NFIP-T36047008500", + vintage: "2024-12", + }, +]; + +const Spark = ({ data, color }) => { + const max = Math.max(...data, 1); + const w = 180, h = 36, n = data.length; + return ( + + ); +}; + +const Histogram = ({ data, color }) => ( + +); + +const ForecastChart = ({ data, color }) => { + const w = 220, h = 80, pad = 4; + const xs = data.map((d, i) => pad + (i / (data.length - 1)) * (w - pad * 2)); + const max = Math.max(...data.map(d => d.high)); + const y = (v) => h - pad - (v / max) * (h - pad * 2); + const path = (key) => xs.map((x, i) => `${i ? "L" : "M"} ${x} ${y(data[i][key])}`).join(" "); + const range = xs.map((x, i) => ({ x, lo: y(data[i].low), hi: y(data[i].high) })); + const areaD = `M ${range.map(r => `${r.x} ${r.lo}`).join(" L ")} L ${[...range].reverse().map(r => `${r.x} ${r.hi}`).join(" L ")} Z`; + return ( + + ); +}; + +const ThumbStripe = ({ kind }) => ( + +); + +const EvidenceCard = ({ ev, onCite }) => { + const tierColor = `var(--tier-${ev.tier})`; + return ( +
+
+
+ + {ev.source} +
+ v. {ev.vintage} +
+

{ev.title}

+ +
+ {ev.fmt === "scalar" && ( +
+
{ev.scalar.value}
+
{ev.scalar.unit}
+ {ev.scalar.aux &&
{ev.scalar.aux}
} +
+ )} + {ev.fmt === "table" && ( + + + + {ev.table.map((row, i) => ( + {row.map((c, j) => )} + ))} + +
idelev.dist.
{c}
+ )} + {ev.fmt === "spark" && ( +
+
{ev.headline}
+ +
{ev.sub}
+
+ )} + {ev.fmt === "histogram" && ( +
+
{ev.headline}
+ +
{ev.sub}
+
+ )} + {ev.fmt === "forecast" && ( +
+ +
inches MSL · 17th–83rd %ile range, median line
+
+ )} + {ev.fmt === "thumb" && ( +
+ +
{ev.sub}
+
+ )} +
+ +
+ + +
+
+ ); +}; + +const EvidenceGrid = ({ onCite }) => ( +
+
+ Evidence · 8 cards + + 3 + 3 + 2 + 1 + +
+
+ {EVIDENCE.map((ev) => ( + + ))} +
+
+); + +Object.assign(window, { EvidenceGrid, EvidenceCard }); diff --git a/docs/design_handoff/design_files/findings.jsx b/docs/design_handoff/design_files/findings.jsx new file mode 100644 index 0000000000000000000000000000000000000000..675829ca867ee7089e63dfd0ade6e5c74a073c17 --- /dev/null +++ b/docs/design_handoff/design_files/findings.jsx @@ -0,0 +1,883 @@ +/* Riprap v0.4.4 · Findings region. + Card grammar: a small set of body variants any Stone's specialists render into. + Variants: tabular, headline, visualization, raster-thumbnail, time-series, + composite-register (novel), comparison (novel · EMP vs SYN), text-headline. + Common chrome: header (source badge + tier glyph + vintage) → title → body → footer (source ID + tier badge). +*/ + +const { useState: useFi, useMemo: useFiMemo } = React; + +/* ─── Card data ─── */ + +const CARDS_BY_QUERY = { + redhook: { + cornerstone: ["fc-fema", "fc-hwm", "fc-stormwater"], + keystone: ["fc-register-rh"], + touchstone: ["fc-floodnet", "fc-311", "fc-nws", "fc-terramind-lulc", "fc-prithvi-pluvial"], + lodestone: ["fc-ttm-surge", "fc-ttm-surge-ft", "fc-npcc4"], + capstone: ["fc-mellea-meta"], + }, + bronx: { + cornerstone: ["fc-fema-x", "fc-stormwater-bx"], + keystone: ["fc-register-bx"], + touchstone: ["fc-311-bx", "fc-nws"], + lodestone: [], /* full-Stone silence: address is inland, no Battery surge relevance */ + capstone: ["fc-mellea-meta-bx"], + }, +}; + +const CARDS = { + /* ── Cornerstone ── */ + "fc-fema": { + stone: "cornerstone", tier: "modeled", variant: "headline", + source: "FEMA", agency: "Federal Emergency Management Agency", + title: "Preliminary FIRM, panel 36047C0207G", + headline: "Zone AE", subhead: "BFE 11 ft NAVD88 · freeboard +4.8 ft", + body: "Address sits within the regulatory 1% annual-chance floodplain. Base Flood Elevation 11.0 ft NAVD88; first floor must be at or above this datum for NFIP rating.", + docId: "FEMA-FIRM-36047C0207G", vintage: "2024-09", citeId: "c4", + mapKey: "fema-ae", + }, + "fc-hwm": { + stone: "cornerstone", tier: "empirical", variant: "tabular", + source: "USGS", agency: "U.S. Geological Survey", + title: "Post-Sandy high-water marks within 500 ft", + columns: ["id", "elev.", "dist."], + rows: [ + ["HWM-NY-3081", "7.4 ft NAVD88", "0.18 mi"], + ["HWM-NY-3082", "8.1 ft NAVD88", "0.22 mi"], + ["HWM-NY-3105", "6.8 ft NAVD88", "0.31 mi"], + ], + sub: "3 marks · max 8.1 ft · surveyed Nov 2012", + docId: "USGS-OFR-2013-1234", vintage: "2013-05", citeId: "c1", + mapKey: "hwm", + }, + "fc-stormwater": { + stone: "cornerstone", tier: "modeled", variant: "raster", + source: "NYC DEP", agency: "NYC Dept. of Environmental Protection", + title: "Stormwater Flood Map · moderate scenario", + rasterKind: "stormwater", + sub: "2.13 in/hr · ponding ≥4 in W half of lot · routed toward Imlay St", + docId: "NYCDEP-SWFM-2024", vintage: "2024-06", citeId: "c5", + mapKey: "stormwater", + }, + "fc-fema-x": { + stone: "cornerstone", tier: "modeled", variant: "headline", + source: "FEMA", agency: "Federal Emergency Management Agency", + title: "Preliminary FIRM, panel 36005C0152F", + headline: "Zone X", subhead: "outside the 1% annual-chance floodplain", + body: "Address is in Zone X, the unshaded 0.2% annual-chance area or higher ground. NFIP coverage optional; insurance not mandated.", + docId: "FEMA-FIRM-36005C0152F", vintage: "2024-09", citeId: "cx1", + mapKey: "fema-x", + }, + "fc-stormwater-bx": { + stone: "cornerstone", tier: "modeled", variant: "raster", + source: "NYC DEP", agency: "NYC Dept. of Environmental Protection", + title: "Stormwater Flood Map · moderate scenario", + rasterKind: "stormwater-dry", + sub: "2.13 in/hr · no ponding ≥4 in within parcel · upslope grade 3.4%", + docId: "NYCDEP-SWFM-2024", vintage: "2024-06", citeId: "cx2", + mapKey: "stormwater", + }, + + /* ── Keystone (composite register) ── */ + "fc-register-rh": { + stone: "keystone", tier: "empirical", variant: "register", + source: "NYC OpenData", agency: "NYC OpenData · multi-agency join", + title: "Nearby exposed assets", + registers: [ + { reg: "MTA", tier: "empirical", label: null, detail: null, sourceId: null, vintage: null, note: "no entrances within radius" }, + { reg: "NYCHA", tier: "empirical", label: null, detail: null, sourceId: null, vintage: null, note: "no NYCHA developments within 1.0 mi" }, + { reg: "DOE", tier: "empirical", label: null, detail: null, sourceId: null, vintage: null, note: "no DOE schools within 1.0 mi" }, + { reg: "DOH", tier: "empirical", label: null, detail: null, sourceId: null, vintage: null, note: "no acute-care hospitals within 1.0 mi" }, + { reg: "PLUTO", tier: "empirical", label: null, detail: null, sourceId: null, vintage: null, note: "PLUTO join skipped: queried address not in NYC PLUTO dataset" }, + ], + sub: "5 specialists · 5 silent_by_design · 0 cards landed (full inventory shown)", + docId: "RIPRAP-EXP-RH80", vintage: "2026-05", citeId: "c-reg-rh", + mapKey: "registers", + }, + "fc-register-bx": { + stone: "keystone", tier: "empirical", variant: "register", + source: "NYC OpenData", agency: "NYC OpenData · multi-agency join", + title: "Nearby exposed assets", + registers: [ + { reg: "MTA", tier: "empirical", label: "Pelham Pkwy 5 station", detail: "0.18 mi · 5", sourceId: "MTA-ENT-N122", vintage: "2025-11", note: null }, + { reg: "NYCHA", tier: "empirical", label: null, detail: null, sourceId: null, vintage: null, note: "no NYCHA developments within 1.0 mi (silent)" }, + { reg: "DOE", tier: "empirical", label: "PS 89 Cinco Estrellas", detail: "0.22 mi · 612 K-5", sourceId: "DOE-X089", vintage: "2024-25", note: null }, + { reg: "DOH", tier: "empirical", label: "Jacobi Medical Center", detail: "0.51 mi · 457 beds", sourceId: "DOH-JMC", vintage: "2025-Q1", note: null }, + { reg: "PLUTO", tier: "empirical", label: "Lot 36005 / 4382 / 18", detail: "BIN 2098441 · R5", sourceId: "PLUTO-2024v2", vintage: "2024-12", note: null }, + ], + sub: "4 of 5 registers fired · 1 silent · joined within 1.0 mi", + docId: "RIPRAP-EXP-BX12", vintage: "2026-05", citeId: "cx-reg", + mapKey: "registers", + }, + + /* ── Touchstone ── */ + "fc-floodnet": { + stone: "touchstone", tier: "empirical", variant: "spark", + source: "FloodNet", agency: "FloodNet NYC sensor network", + title: "Sensor BK-RH-002, monthly above-curb events", + headline: "7 events", subhead: "Jun 2024 → Apr 2026 · peak 14.3 cm", + spark: [0,0,1,0,2,1,0,0,3,0,1,0,0,0,2,1,0,0,1,0,2,4,1,1], + sparkSub: "Sensor located 0.21 mi N at Coffey & Van Brunt. Above-curb depth in cm; events ≥2 cm.", + docId: "FN-BK-RH-002", vintage: "2026-04", citeId: "c3", + mapKey: "floodnet", + }, + "fc-311": { + stone: "touchstone", tier: "proxy", variant: "histogram", + source: "NYC 311", agency: "NYC 311 service requests", + title: "Recent 311 flood complaints, BK CB6", + headline: "89 calls", subhead: "2019–2025 · seasonal cluster Aug–Oct", + histogram: [3,2,1,0,1,4,7,12,18,11,5,3,4,2,1,0,2,3,8,9,4,2,1,0], + sparkSub: "Filtered to complaint types: Sewer (Backup), Street Flooding, Catch Basin Clogged. Within 200 m of address.", + docId: "NYC311-FLD-CB6", vintage: "2025-12", citeId: "c7", + mapKey: "complaints", + }, + "fc-311-bx": { + stone: "touchstone", tier: "proxy", variant: "histogram", + source: "NYC 311", agency: "NYC 311 service requests", + title: "Recent 311 flood complaints, BX CB11", + headline: "12 calls", subhead: "2019–2025 · sparse · no seasonal cluster", + histogram: [0,0,1,0,0,1,0,1,2,0,0,0,1,0,0,0,1,0,2,1,1,0,0,1], + sparkSub: "Filtered to complaint types: Sewer (Backup), Street Flooding, Catch Basin Clogged. Within 200 m of address.", + docId: "NYC311-FLD-CB11", vintage: "2025-12", citeId: "cx7", + mapKey: "complaints", + }, + "fc-prithvi-pluvial": { + stone: "touchstone", tier: "modeled", variant: "raster-pred", + source: "Prithvi-NYC-Pluvial", agency: "NASA-IBM Prithvi v2 · NYC fine-tune", + title: "Pluvial flood prediction · Prithvi-NYC-Pluvial", + rasterKind: "prithvi", + headline: "0.3% flooded", subhead: "no flooding apparent · scene 2026-05-02", + sub: "Model interpretation of imagery, not real-time observation. Confidence-mean 0.84 across non-flooded pixels.", + docId: "PRITHVI-NYC-PLUV-V2-20260502", vintage: "2026-05-02 · Sentinel-2", + illustrative: true, citeId: "c-prithvi", + mapKey: "prithvi-pluvial", + }, + "fc-terramind-lulc": { + stone: "touchstone", tier: "synthetic", variant: "lulc", + source: "TerraMind v1.2", agency: "IBM TerraMind v1.2 · Sentinel-2 inputs", + title: "Land use / land cover · TerraMind v1.2", + rasterKind: "lulc", + classMix: [ + { k: "urban", pct: 62, color: "#C66" }, + { k: "water", pct: 18, color: "#5B7FB4" }, + { k: "vegetation", pct: 12, color: "#5B8A4A" }, + { k: "barren", pct: 6, color: "#A89A78" }, + { k: "wetland", pct: 2, color: "#D9C75A" }, + ], + sub: "Synthetic prior. LULC palette is a layer convention, not a tier signal.", + docId: "TERRAMIND-LULC-20240918", vintage: "Sentinel-2 · 2024-09-18", + citeId: "c-tm-lulc", + mapKey: "terramind-lulc", + }, + "fc-nws": { + stone: "touchstone", tier: "empirical", variant: "scalars", + source: "NWS KNYC", agency: "NOAA · National Weather Service", + title: "Current weather, station KNYC", + scalars: [ + { value: "0.02 in", label: "precip · last 24h" }, + { value: "67°F", label: "temp · current" }, + { value: "PC", label: "conditions" }, + ], + sub: "Observation timestamp 2026-05-05 14:18 ET. Central Park station; not point-of-query.", + docId: "NWS-KNYC", vintage: "2026-05-05", citeId: "c-nws", + mapKey: "nws", + }, + + /* ── Lodestone ── */ + "fc-ttm-surge": { + stone: "lodestone", tier: "modeled", variant: "timeseries", + source: "Granite TTM r2 (zero-shot)", agency: "IBM Granite-TimeSeries · regional", + title: "Storm surge nowcast at The Battery — 9.6 h horizon (regional)", + timeseries: { hours: 96, peak: { x: 38, y: 47 }, peakLabel: "+47 cm @ +38h" }, + headline: "+47 cm", subhead: "peak surge residual · 9.6h horizon · 6-min cadence", + sub: "Regional disclosure. Nowcast applies city-wide via NOAA station 8518750. Distinct from the fine-tuned Battery surge nowcast.", + docId: "ttm_battery_surge_zeroshot", vintage: "2026-05-05 12:00 ET", + spatialNote: "regional · The Battery, not point-of-query", + citeId: "c-ttm", + mapKey: null, + }, + "fc-ttm-surge-ft": { + stone: "lodestone", tier: "modeled", variant: "timeseries-ft", + source: "msradam/Granite-TTM-r2-Battery-Surge", agency: "Granite TTM r2 · NYC-specialized fine-tune", + title: "Storm surge nowcast at The Battery — 96 h horizon (NYC-specialized fine-tune)", + timeseries: { hours: 96, peak: { x: 38, y: 53 }, peakLabel: "+53 cm @ +38h" }, + headline: "+53 cm", subhead: "peak surge · 96h horizon · hourly cadence", + sub: "Fine-tuned on NYC tide-gauge history. Trained on AMD MI300X.", + docId: "ttm_battery_surge_finetune", vintage: "2026-05-05 12:00 ET", + spatialNote: "regional · The Battery, not point-of-query", + hfModelCard: "huggingface.co/msradam/Granite-TTM-r2-Battery-Surge", + rmse: "0.157 m", + skillVsPersistence: "−35% vs persistence", + hardwareBadge: "MI300X", + citeId: "c-ttm-ft", + mapKey: null, + }, + "fc-npcc4": { + stone: "lodestone", tier: "modeled", variant: "forecast", + source: "NPCC4", agency: "NYC Panel on Climate Change, 4th Assessment", + title: "Sea-level rise projections, Lower NY Harbor", + forecast: [ + { year: 2030, low: 4, mid: 6, high: 9 }, + { year: 2050, low: 13, mid: 22, high: 30 }, + { year: 2080, low: 28, mid: 49, high: 75 }, + { year: 2100, low: 38, mid: 71, high: 114 }, + ], + sub: "inches MSL · 17th–83rd %ile range, median line. Battery tide-gauge baseline.", + docId: "NPCC4-Ch3-Tbl3.2", vintage: "2024-03", citeId: "c6", + mapKey: null, + }, + + /* ── Capstone meta ── */ + "fc-mellea-meta": { + stone: "capstone", tier: "modeled", variant: "meta", + source: "Mellea", agency: "Capstone synthesis · grounding check", + title: "Briefing reconciliation", + metaRows: [ + { k: "Mellea reroll", v: "1 reroll" }, + { k: "Grounding checks", v: "4 / 4 passed" }, + { k: "Citations resolved", v: "4" }, + { k: "Wall-clock", v: "24.0 s" }, + ], + sub: "Capstone produces prose, not cards. This meta-card summarizes the reconciler chain that wrote the four-section briefing above.", + docId: "RIPRAP-CAP-RH80", vintage: "2026-05-05 14:22 ET", citeId: null, + mapKey: null, + }, + "fc-mellea-meta-bx": { + stone: "capstone", tier: "modeled", variant: "meta", + source: "Mellea", agency: "Capstone synthesis · grounding check", + title: "Briefing reconciliation", + metaRows: [ + { k: "Mellea reroll", v: "1 attempt" }, + { k: "Grounding checks", v: "4 / 4 passed" }, + { k: "Citations resolved",v: "6 / 6" }, + { k: "RAG → GLiNER", v: "5 entities · 0 unresolved" }, + ], + sub: "Capstone produces prose, not cards. This meta-card summarizes the reconciler chain.", + docId: "RIPRAP-CAP-BX12", vintage: "2026-05-05 14:24 ET", citeId: null, + mapKey: null, + }, +}; + +/* Comparison card · only included when "showComparison" is on (novel variant the brief flags as v1.1 idea). */ +const COMPARISON_CARD = { + stone: "keystone", tier: "synthetic", variant: "comparison", + source: "TerraMind × DOITT", agency: "TerraMind v1.2 Buildings × NYC DOITT footprints", + title: "Building footprint · documented vs. interpreted", + left: { tier: "empirical", label: "DOITT (documented)", value: "31.4%", aux: "112 building polygons in chip" }, + right: { tier: "synthetic", label: "TerraMind (interpreted)", value: "36.2%", aux: "126 components · Sentinel-2 2026-05-02" }, + delta: "+4.8 pp · model sees ~14 unrecorded structures", + sub: "Difference layer. v1.1 idea: surface where the foundation model sees buildings the catalogue doesn't, or vice versa. Illustrative — not part of v0.4.4 production output.", + docId: "RIPRAP-CMP-RH80-BLDG", vintage: "2026-05-02", citeId: null, + illustrative: true, mapKey: "buildings", +}; + +/* ─── Stone metadata ─── */ + +const STONE_META = { + cornerstone: { name: "Cornerstone", role: "the hazard reader", tag: "what NYC's ground remembers" }, + keystone: { name: "Keystone", role: "the asset register", tag: "what's exposed" }, + touchstone: { name: "Touchstone", role: "the live observer", tag: "what's happening now" }, + lodestone: { name: "Lodestone", role: "the projector", tag: "what's coming" }, + capstone: { name: "Capstone", role: "the synthesizer", tag: "writes it all down with citations" }, +}; + +const STONE_ORDER = ["cornerstone", "keystone", "touchstone", "lodestone", "capstone"]; + +/* ─── Tier badge (footer) ─── */ + +const FiTierBadge = ({ tier }) => { + const map = { empirical: "EMP", modeled: "MOD", proxy: "PRX", synthetic: "SYN" }; + return ( + + + {map[tier]} + + ); +}; + +/* ─── Body variants ─── */ + +const BodyHeadline = ({ c }) => ( +
+
{c.headline}
+
{c.subhead}
+ {c.body &&

{c.body}

} +
+); + +const BodyTabular = ({ c }) => ( +
+ + {c.columns.map((h, i) => )} + + {c.rows.map((row, i) => ( + {row.map((cell, j) => )} + ))} + +
{h}
{cell}
+ {c.sub &&
{c.sub}
} +
+); + +const BodySpark = ({ c }) => { + const data = c.spark || c.histogram; + const max = Math.max(...data, 1); + const w = 240, h = 38, n = data.length; + return ( +
+
{c.headline}
+
{c.subhead}
+ + {c.sparkSub &&
{c.sparkSub}
} +
+ ); +}; + +const BodyForecast = ({ c }) => { + const data = c.forecast; + const w = 240, h = 88, pad = 6; + const xs = data.map((_, i) => pad + (i / (data.length - 1)) * (w - pad * 2)); + const max = Math.max(...data.map(d => d.high)); + const y = (v) => h - pad - (v / max) * (h - pad * 2 - 12); + const path = (key) => xs.map((x, i) => `${i ? "L" : "M"} ${x} ${y(data[i][key])}`).join(" "); + const range = xs.map((x, i) => ({ x, lo: y(data[i].low), hi: y(data[i].high) })); + const areaD = `M ${range.map(r => `${r.x} ${r.lo}`).join(" L ")} L ${[...range].reverse().map(r => `${r.x} ${r.hi}`).join(" L ")} Z`; + const color = `var(--tier-${c.tier})`; + return ( +
+ + {c.sub &&
{c.sub}
} +
+ ); +}; + +const BodyTimeseries = ({ c }) => { + const w = 240, h = 84, pad = 6; + const hours = c.timeseries.hours; + /* Synthetic surge curve: harmonic baseline + storm pulse around peak */ + const points = Array.from({ length: hours + 1 }, (_, i) => { + const t = i; + const harmonic = 6 * Math.sin((t / 12.42) * Math.PI * 2); + const pulse = 38 * Math.exp(-Math.pow((t - c.timeseries.peak.x) / 12, 2)); + return { x: t, y: harmonic + pulse + 4 }; + }); + const maxY = Math.max(...points.map(p => p.y), c.timeseries.peak.y); + const minY = Math.min(...points.map(p => p.y), -10); + const sx = (t) => pad + (t / hours) * (w - pad * 2); + const sy = (v) => h - pad - 14 - ((v - minY) / (maxY - minY)) * (h - pad * 2 - 14); + const pathD = points.map((p, i) => `${i ? "L" : "M"} ${sx(p.x)} ${sy(p.y)}`).join(" "); + const color = `var(--tier-${c.tier})`; + return ( +
+
+ {c.headline} + {c.subhead} +
+ +
+ {c.spatialNote} + {c.sub} +
+
+ ); +}; + +const BodyScalars = ({ c }) => ( +
+
+ {c.scalars.map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ {c.sub &&
{c.sub}
} +
+); + +/* Raster thumbnail · hand-drawn SVG approximations using each layer's conventional palette. */ +const RasterThumb = ({ kind }) => { + const w = 240, h = 120; + if (kind === "stormwater") { + return ( + + ); + } + if (kind === "stormwater-dry") { + return ( + + ); + } + if (kind === "prithvi") { + /* Prithvi: 50% Sentinel RGB · 50% pluvial mask. Mostly dry → speckle, no flood polygons. */ + return ( + + ); + } + if (kind === "lulc") { + return ( + + ); + } + if (kind === "buildings") { + return ( + + ); + } + return
raster preview
; +}; + +const BodyRaster = ({ c }) => ( +
+
+ + {c.illustrative && illustrative} +
+ {c.headline &&
{c.headline} · {c.subhead}
} + {c.sub &&
{c.sub}
} +
+); + +const BodyRegister = ({ c, density }) => ( +
+
    + {c.registers.map((r, i) => ( +
  • + {r.reg} + {r.label ? ( + <> + {r.label} + {r.sourceId} + + ) : ( + {r.note} + )} +
  • + ))} +
+ {c.sub &&
{c.sub}
} +
+); + +/* ─── Card-grammar reference: one stub per variant ─── */ +const GRAMMAR_STUBS = [ + { variant: "headline", tier: "modeled", source: "FEMA", title: "Single big number, scenario-tagged", headline: "Zone AE", subhead: "preliminary FIRM, panel ID", sub: "Use when the answer is one categorical state.", docId: "DS-HEADLINE", vintage: "spec" }, + { variant: "tabular", tier: "empirical", source: "USGS", title: "Small table of observations", columns: ["id", "value", "dist."], rows: [["ROW-001", "1.2 m", "0.18 mi"], ["ROW-002", "0.9 m", "0.32 mi"], ["ROW-003", "0.7 m", "0.41 mi"]], sub: "Use when 3,8 records each carry the same fields.", docId: "DS-TABULAR", vintage: "spec" }, + { variant: "scalars", tier: "empirical", source: "NWS", title: "Trio of scalar readings", scalars: [{ value: "0.02 in", label: "precip · 24h" }, { value: "11 mph", label: "wind" }, { value: "63°F", label: "temp" }], sub: "Use for current-state dashboards.", docId: "DS-SCALARS", vintage: "spec" }, + { variant: "spark", tier: "empirical", source: "FloodNet", title: "Sparkline of recent events", headline: "n events", subhead: "window · peak", spark: [1,2,4,3,7,12,8,5,3,2,4,9,6], docId: "DS-SPARK", vintage: "spec" }, + { variant: "histogram", tier: "proxy", source: "NYC 311", title: "Histogram of binned counts", headline: "n calls", subhead: "window · seasonal note", histogram: [3,2,1,0,1,4,7,12,18,11,5,3,4,2,1,0,2,3,8,9,4,2,1,0], docId: "DS-HIST", vintage: "spec" }, + { variant: "timeseries", tier: "modeled", source: "Granite TTM", title: "Forecast curve with horizon", headline: "+0.41 m peak", subhead: "+38h · 90% CI", timeseries: { hours: 96, peak: { x: 38, y: 41 }, peakLabel: "+0.41 m" }, spatialNote: "regional", sub: "Spatial-index callout when station ≠ point-of-query.", docId: "DS-TS", vintage: "spec" }, + { variant: "forecast", tier: "modeled", source: "NPCC4", title: "Long-horizon scenario projections", forecast: [{ year: 2030, low: 4, mid: 6, high: 9 }, { year: 2050, low: 13, mid: 22, high: 30 }, { year: 2100, low: 38, mid: 71, high: 114 }], sub: "Use for decadal+ uncertainty cones.", docId: "DS-FCST", vintage: "spec" }, + { variant: "raster", tier: "modeled", source: "NYC DEP", title: "Raster snapshot, mapped layer", rasterKind: "stormwater", headline: "ponding", subhead: "scenario · pixel summary", sub: "Use for any 2D model output.", docId: "DS-RASTER", vintage: "spec" }, + { variant: "raster-pred", tier: "modeled", source: "Prithvi-NYC", title: "Raster prediction, illustrative", rasterKind: "prithvi", headline: "n% flooded", subhead: "model · scene id", illustrative: true, sub: "Same chrome as raster + illustrative tag.", docId: "DS-RASTERPRED", vintage: "spec" }, + { variant: "register", tier: "empirical", source: "NYC OpenData", title: "Composite register list", registers: [ + { reg: "MTA", tier: "empirical", label: "Station entrance", sourceId: "MTA-X", note: null }, + { reg: "NYCHA", tier: "empirical", label: "Development", sourceId: "NYCHA-Y", note: null }, + { reg: "DOH", tier: "empirical", label: null, sourceId: null, note: "no acute-care hospital within 1.0 mi" }, + ], sub: "Use when many specialists join into one Stone.", docId: "DS-REGISTER", vintage: "spec" }, + { variant: "comparison", tier: "synthetic", source: "EMP × SYN", title: "Documented vs. interpreted", left: { tier: "empirical", label: "documented", value: "31.4%", aux: "n polygons" }, right: { tier: "synthetic", label: "interpreted", value: "29.8%", aux: "n polygons" }, delta: "Δ = , 1.6 pp · agreement strong", sub: "Use to surface model , ground-truth deltas.", docId: "DS-CMP", vintage: "spec" }, + { variant: "meta", tier: "modeled", source: "Mellea", title: "Capstone reconciliation", metaRows: [{ k: "claims", v: "12 / 12 grounded" }, { k: "tier mix", v: "EMP 5 · MOD 4 · PRX 2 · SYN 1" }, { k: "tier-1 freshness", v: "median 38 d" }, { k: "warnings", v: "0" }], sub: "Use to expose the synthesis layer's audit.", docId: "DS-META", vintage: "spec" }, +]; + +const CardGrammarReference = ({ density }) => ( +
+
+
+ SPEC +

Card grammar

+ every body variant in the system + stubs, not findings +
+ {GRAMMAR_STUBS.length} variants +
+
+ {GRAMMAR_STUBS.map((c) => ( +
+
+
+ + {c.source} +
+ {c.variant} +
+

{c.title}

+ {renderBody(c, density)} +
+ {c.docId} + +
+
+ ))} +
+
+); + +Object.assign(window, { CardGrammarReference }); + +const BodyComparison = ({ c }) => ( +
+
+
+
+ + {c.left.label} +
+
{c.left.value}
+
{c.left.aux}
+
+ +
+
+ + {c.right.label} +
+
{c.right.value}
+
{c.right.aux}
+
+
+
{c.delta}
+ {c.sub &&
{c.sub}
} +
+); + +const BodyMeta = ({ c }) => ( +
+
+ {c.metaRows.map((r, i) => ( +
+
{r.k}
+
{r.v}
+
+ ))} +
+ {c.sub &&
{c.sub}
} +
+); + +const renderBody = (c, density) => { + switch (c.variant) { + case "headline": return ; + case "tabular": return ; + case "spark": return ; + case "histogram": return ; + case "forecast": return ; + case "timeseries": return ; + case "scalars": return ; + case "raster": return ; + case "raster-pred": return ; + case "register": return ; + case "comparison": return ; + case "meta": return ; + default: return null; + } +}; + +/* ─── Card frame ─── */ + +const FindingCard = ({ c, density, onCite, onHover, onClick, isLinked }) => { + return ( +
onHover?.(c.mapKey)} + onMouseLeave={() => onHover?.(null)} + onClick={() => onClick?.(c)} + > +
+
+ + {c.source} +
+ v. {c.vintage} +
+

{c.title}

+ {renderBody(c, density)} +
+ {c.citeId ? ( + + ) : ( + {c.docId} + )} + +
+
+ ); +}; + +/* ─── Stone region ─── */ + +const flatten = (members) => members.flatMap((m) => (m.children ? [m, ...flatten(m.children)] : [m])); + +const StoneTally44 = ({ cardCount, members }) => { + const flat = flatten(members); + const fired = flat.filter((m) => m.status === "fired" || m.status === "warned").length; + const silent = flat.filter((m) => m.status === "silent_by_design").length; + const warn = flat.filter((m) => m.status === "warned").length; + const error = flat.filter((m) => m.status === "errored").length; + const notInvoked = flat.filter((m) => m.status === "not_invoked").length; + const ms = members.reduce((acc, m) => Math.max(acc, m.ms || 0), 0); + const fmtMs = (x) => (x === 0 ? "—" : x < 1000 ? x + "ms" : (x / 1000).toFixed(1) + "s"); + return ( + + {cardCount} card{cardCount === 1 ? "" : "s"} + · + {fired} fired + {silent > 0 && <>·{silent} silent} + {warn > 0 && <>·{warn} warn} + {error > 0 && <>·{error} errored} + {notInvoked > 0 && <>·{notInvoked} not invoked} + · + {fmtMs(ms)} + + ); +}; + +const StoneRegion = ({ stone, cardIds, density, provenanceMode, onCite, onHover, linkedKey }) => { + const meta = STONE_META[stone.key]; + const cards = cardIds.map((id) => CARDS[id]).filter(Boolean); + const traceCount = flatten(stone.members).length; + const flat = flatten(stone.members); + const hasError = flat.some((m) => m.status === "errored"); + const hasWarn = flat.some((m) => m.status === "warned"); + const defaultOpen = + provenanceMode === "all-expanded" ? true : + provenanceMode === "all-collapsed" ? false : + /* smart */ hasError || hasWarn; + const [traceOpen, setTraceOpen] = useFi(defaultOpen); + /* Re-sync if user toggles tweak */ + useFiMemo(() => setTraceOpen(defaultOpen), [provenanceMode]); + + const isCapstone = stone.key === "capstone"; + + return ( +
+
+
+ {(STONE_ORDER.indexOf(stone.key) + 1).toString().padStart(2, "0")} +

{meta.name}

+ · {meta.role} + {meta.tag} +
+ +
+ + {/* Findings · primary surface */} + {cards.length === 0 ? ( +
+ silent +

+ {stone.key === "lodestone" + ? "No projection cards landed for this query. The address is inland (Pelham Pkwy, Bronx); NPCC4 SLR and TTM Battery surge are coastal projections and do not localize here. Atomic functions still ran (see provenance) and returned silence rather than confabulation." + : "No cards for this Stone on this query."} +

+
+ ) : ( +
+ {cards.map((c) => ( + onHover?.(card.mapKey, true)} + isLinked={linkedKey && c.mapKey === linkedKey} + /> + ))} +
+ )} + + {/* Provenance */} +
+ + {traceOpen && ( +
+ {stone.members.map((m) => )} +
+ )} +
+
+ ); +}; + +/* ─── Run-health strip ─── */ + +const RunHealth44 = ({ totalCards }) => { + const all = window.STONES.flatMap((s) => flatten(s.members)); + const fired = all.filter((m) => m.status === "fired" || m.status === "warned").length; + const total = all.length; + const silent = all.filter((m) => m.status === "silent_by_design").length; + const warn = all.filter((m) => m.status === "warned").length; + const error = all.filter((m) => m.status === "errored").length; + return ( +
+ 5 Stones + · + {fired}/{total} functions fired + · + {totalCards} evidence cards + · + 24.0s wall-clock + {silent > 0 && <>·{silent} silent} + {warn > 0 && <>·{warn} warned} + {error > 0 && <>·{error} errored} +
+ ); +}; + +/* ─── Findings region ─── */ + +const FindingsRegion = ({ density, provenanceMode, queryKey, showComparison, showGrammar, onCite, onHover, linkedKey }) => { + const map = CARDS_BY_QUERY[queryKey] || CARDS_BY_QUERY.redhook; + /* Inject the comparison card into Keystone after the register card if showComparison is on */ + const adjusted = useFiMemo(() => { + if (!showComparison) return map; + if (queryKey !== "redhook") return map; + /* Add a synthetic ID reference to the in-memory comparison card */ + return { ...map, keystone: [...(map.keystone || []), "__comparison__"] }; + }, [map, showComparison, queryKey]); + + const totalCards = STONE_ORDER.reduce((n, k) => n + (adjusted[k] || []).length, 0); + + return ( +
+
+

Findings · grouped by Stone

+ cards = what each Stone found · provenance collapses below +
+ + {STONE_ORDER.map((key) => { + const stone = window.STONES.find((s) => s.key === key); + const ids = adjusted[key] || []; + return ( + + ); + })} + {showGrammar && } +
+ ); +}; + +/* Fold the comparison card into CARDS lookup at module load */ +CARDS["__comparison__"] = COMPARISON_CARD; + +Object.assign(window, { FindingsRegion, CARDS, CARDS_BY_QUERY }); diff --git a/docs/design_handoff/design_files/glyphs.jsx b/docs/design_handoff/design_files/glyphs.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0d05deef0be3e746abb4a3c195270f6637e0cb10 --- /dev/null +++ b/docs/design_handoff/design_files/glyphs.jsx @@ -0,0 +1,126 @@ +/* Epistemic-tier glyphs. + 12×12 monochrome SVGs. Distinguishable by shape (filled square, + open square, filled circle, striped square) , never by color alone. + Used in: briefing prose left margin, evidence card badge, trace tier + column, PDF body, MapLibre legend. +*/ + +const TierGlyph = ({ tier, size = 12, color = "currentColor", title }) => { + const s = size; + const stroke = Math.max(1, Math.round(size / 9)); + const ariaTitle = title || ({ + empirical: "Empirical: directly measured or observed", + modeled: "Modeled: scenario-based prediction", + proxy: "Proxy: indirect indicator", + synthetic: "Synthetic prior: generated, not observed", + })[tier]; + + const patternId = `rip-stripe-${tier}-${size}`; + + return ( + + {ariaTitle} + {tier === "empirical" && ( + + )} + {tier === "modeled" && ( + + )} + {tier === "proxy" && ( + + )} + {tier === "synthetic" && ( + <> + + + + + + + + )} + + ); +}; + +const TIER_META = { + empirical: { + label: "Empirical", + short: "EMP", + desc: "Directly measured or observed", + examples: "USGS high-water marks · FloodNet sensors · Sandy Inundation Zone", + }, + modeled: { + label: "Modeled", + short: "MOD", + desc: "Scenario-based prediction", + examples: "FEMA flood zones · DEP stormwater scenarios · NPCC4 SLR", + }, + proxy: { + label: "Proxy", + short: "PRX", + desc: "Indirect indicator", + examples: "311 flood complaints · NFIP claims · terrain indices", + }, + synthetic: { + label: "Synthetic prior", + short: "SYN", + desc: "Generated, not observed", + examples: "TerraMind land-cover · synthetic SAR for occluded days", + }, +}; + +const TierBadge = ({ tier, compact = false }) => { + const meta = TIER_META[tier]; + return ( + + + {compact ? meta.short : meta.label} + + ); +}; + +Object.assign(window, { TierGlyph, TierBadge, TIER_META }); diff --git a/docs/design_handoff/design_files/landing-variants.css b/docs/design_handoff/design_files/landing-variants.css new file mode 100644 index 0000000000000000000000000000000000000000..b835c94122496b701b960aac0ec68bdcbb676979 --- /dev/null +++ b/docs/design_handoff/design_files/landing-variants.css @@ -0,0 +1,89 @@ +/* Riprap landing-page variants , shared styles */ + +/* Chrome */ +.land { background: var(--paper); color: var(--ink); font-family: var(--font-sans); display: flex; flex-direction: column; min-height: 100%; } +.land-header { display: flex; align-items: baseline; gap: 12px; padding: 20px 32px; border-bottom: 1px solid var(--rule-soft); } +.land-header .riprap-wordmark { font-family: var(--font-serif); font-weight: 600; font-size: 18px; letter-spacing: 0.02em; } +.land-header-sep { color: var(--ink-tertiary); } +.land-header-context { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-secondary); } +.land-header-nav { margin-left: auto; display: flex; gap: 18px; font-family: var(--font-mono); font-size: 12px; } +.land-header-nav a { color: var(--ink-secondary); text-decoration: none; border-bottom: 1px dotted transparent; } +.land-header-nav a:hover { border-bottom-color: var(--ink-secondary); } + +.land-footer { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; padding: 16px 32px; border-top: 1px solid var(--rule-soft); font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.02em; } +.land-footer-tiers { display: flex; gap: 16px; flex-wrap: wrap; } +.land-footer-tier { display: inline-flex; align-items: center; gap: 5px; } +.lm-sw-syn { background: rgba(42,111,168,0.25); border: 1px dashed var(--tier-modeled); } + +/* Hero common */ +.land-hero { padding: 64px 32px 48px; } +.land-hero-h1 { display: flex; flex-direction: column; gap: 18px; margin: 0 0 30px; max-width: 880px; } +.land-hero-headline { font-family: var(--font-serif); font-weight: 500; font-size: 52px; line-height: 1.08; color: var(--ink); letter-spacing: -0.015em; } +.land-hero-headline em { font-style: italic; font-weight: 500; } +.land-hero-deck { font-family: var(--font-serif); font-size: 18px; line-height: 1.55; color: var(--ink-secondary); max-width: 64ch; } + +/* Query box */ +.land-query { display: flex; align-items: stretch; gap: 0; max-width: 760px; border: 1px solid var(--ink); background: white; } +.land-query-lg { font-size: 18px; } +.land-query-md { font-size: 16px; max-width: 640px; } +.land-query-prompt { display: flex; align-items: center; padding: 0 14px; font-family: var(--font-mono); font-size: 22px; color: var(--ink-tertiary); background: var(--paper-deep); border-right: 1px solid var(--rule-soft); } +.land-query-input { flex: 1; min-width: 0; padding: 18px 16px; font: inherit; font-family: var(--font-sans); border: none; outline: none; background: white; color: var(--ink); } +.land-query-input::placeholder { color: var(--ink-tertiary); } +.land-query-submit { padding: 0 22px; font-family: var(--font-sans); font-weight: 600; font-size: 14px; background: var(--ink); color: var(--paper); border: none; cursor: pointer; white-space: nowrap; letter-spacing: 0.02em; } +.land-query-submit:hover { background: #000; } + +/* Sections */ +.land-section { padding: 48px 32px; border-top: 1px solid var(--rule-soft); } +.land-section-head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; margin-bottom: 22px; padding-bottom: 10px; border-bottom: 1px solid var(--rule-soft); } +.land-section-meta { font-family: var(--font-serif); font-style: italic; font-size: 14px; color: var(--ink-tertiary); } +.land-section-link { font-family: var(--font-mono); font-size: 12px; color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--ink); } + +/* ── v1 ── */ +.land-cycling { margin-top: 18px; display: grid; grid-template-columns: auto 1fr; align-items: baseline; column-gap: 10px; font-family: var(--font-mono); font-size: 13px; color: var(--ink-tertiary); max-width: 760px; } +.land-cycling-label { letter-spacing: 0.06em; text-transform: uppercase; font-size: 11px; line-height: 1.4em; } +.land-cycling-rail { position: relative; min-width: 0; height: 1.4em; line-height: 1.4em; } +.land-cycling-item { position: absolute; inset: 0; line-height: 1.4em; opacity: 0; transition: opacity 240ms ease; color: var(--ink); border-bottom: 1px dotted var(--rule-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.land-cycling-item.is-active { opacity: 1; } + +.land-preview { display: flex; justify-content: flex-start; } +.land-preview-frame { background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); padding: 22px 26px; max-width: 760px; } +.land-preview-eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-tertiary); margin-bottom: 12px; } +.land-preview-body { font-family: var(--font-serif); font-size: 17px; line-height: 1.65; color: var(--ink); margin: 0 0 18px; } +.land-preview-cite { background: linear-gradient(transparent 60%, rgba(11,83,148,0.14) 60%); } +.land-preview-cite sup { font-family: var(--font-mono); font-size: 10px; color: var(--tier-empirical); margin-left: 2px; vertical-align: super; } +.land-preview-cites { display: flex; flex-direction: column; gap: 6px; padding-top: 14px; border-top: 1px dashed var(--rule-soft); } +.land-preview-cite-row { display: grid; grid-template-columns: 36px 1fr 90px; gap: 10px; align-items: baseline; font-family: var(--font-mono); font-size: 12px; } +.land-preview-cite-pin { color: var(--tier-empirical); font-weight: 600; } +.land-preview-cite-src { color: var(--ink); } +.land-preview-cite-tier { color: var(--ink-tertiary); font-size: 11px; text-align: right; letter-spacing: 0.04em; } + +/* ── v2 ── */ +.land-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; } +.land-gallery-card { display: flex; flex-direction: column; gap: 6px; padding: 18px 20px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); text-decoration: none; color: inherit; transition: background 120ms; } +.land-gallery-card:hover { background: var(--paper-deep); } +.land-gallery-kind { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-tertiary); } +.land-gallery-title { font-family: var(--font-serif); font-size: 22px; font-weight: 500; margin: 0; color: var(--ink); } +.land-gallery-sub { font-family: var(--font-serif); font-size: 14px; font-style: italic; color: var(--ink-secondary); margin: 0; line-height: 1.45; } +.land-gallery-tally { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--rule-soft); font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); } +.land-gallery-arrow { margin-top: 6px; font-family: var(--font-mono); font-size: 11px; color: var(--ink); letter-spacing: 0.04em; } + +.land-section-stones { background: var(--paper-deep); } +.land-stones-strip { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; } +.land-stone-pill { padding: 14px 16px; background: white; border: 1px solid var(--rule-soft); font-family: var(--font-sans); font-size: 13px; color: var(--ink-secondary); } +.land-stone-pill strong { display: block; font-family: var(--font-sans); font-weight: 600; font-size: 15px; color: var(--ink); margin-bottom: 4px; } + +/* ── v3 ── */ +.land-hero-v3 .land-hero-h1 { margin-bottom: 36px; } +.land-frieze { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0; border: 1px solid var(--rule-soft); border-bottom: 2px solid var(--ink); background: white; margin-bottom: 36px; } +.land-frieze-stone { padding: 22px 18px 24px; border-right: 1px solid var(--rule-soft); display: flex; flex-direction: column; gap: 8px; } +.land-frieze-stone:last-child { border-right: none; } +.land-frieze-num { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.08em; } +.land-frieze-name { font-family: var(--font-serif); font-size: 22px; font-weight: 500; margin: 0; color: var(--ink); } +.land-frieze-role { font-family: var(--font-sans); font-size: 13px; color: var(--ink-secondary); } +.land-frieze-tag { font-family: var(--font-serif); font-style: italic; font-size: 14px; color: var(--ink-tertiary); margin: 0 0 6px; line-height: 1.4; } +.land-frieze-sources { margin-top: auto; padding-top: 10px; border-top: 1px dashed var(--rule-soft); font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); line-height: 1.5; } + +.land-frieze-query { display: flex; flex-direction: column; gap: 12px; } +.land-frieze-query-meta { font-family: var(--font-mono); font-size: 12px; color: var(--ink-tertiary); display: flex; gap: 8px; flex-wrap: wrap; align-items: baseline; } +.land-link { background: none; border: none; padding: 0; font: inherit; color: var(--ink); border-bottom: 1px dotted var(--ink-secondary); cursor: pointer; } +.land-link:hover { border-bottom-style: solid; } diff --git a/docs/design_handoff/design_files/landing-variants.jsx b/docs/design_handoff/design_files/landing-variants.jsx new file mode 100644 index 0000000000000000000000000000000000000000..02196689ceec8c3705bcb5d6a85f57c5b4812269 --- /dev/null +++ b/docs/design_handoff/design_files/landing-variants.jsx @@ -0,0 +1,266 @@ +/* Riprap landing-page variants — three artboards on a design canvas. + v1: Minimal pushed harder (cycling example queries + tiny grounded-output preview) + v2: Example gallery (query box + 6 pre-baked NYC archetype briefings) + v3: Methodology-forward (5 Stones frieze leads, query box below) +*/ + +const { useState: useLand, useEffect: useLandFx } = React; + +/* ═══════════════════════════════════════════════════════════════════════ + Shared chrome — wordmark + footer methodology link + ═══════════════════════════════════════════════════════════════════════ */ +const LandingChrome = ({ children, board }) => ( +
+
+ riprap + / + Flood Exposure Briefing · NYC + +
+ {children} +
+ Riprap v0.4.4 — built on NYC OpenData, FEMA NFHL, USGS, NPCC4 + Each briefing is a citation graph. Every claim links to its primary source. +
+
+); + +const QueryBox = ({ size = "md", placeholder = "Address, neighborhood, or BBL — e.g. 80 Pioneer Street, Red Hook" }) => ( +
e.preventDefault()}> + + + +
+); + +/* ═══════════════════════════════════════════════════════════════════════ + v1 · Minimal pushed harder + ═══════════════════════════════════════════════════════════════════════ */ +const SAMPLE_QUERIES = [ + "80 Pioneer Street, Red Hook", + "Coney Island Hospital", + "PS 188, Lower East Side", + "Hammels Houses, Rockaway", + "Bowling Green station", + "555 W 57th Street", +]; + +const CyclingExamples = () => { + const [i, setI] = useLand(0); + useLandFx(() => { + const t = setInterval(() => setI((x) => (x + 1) % SAMPLE_QUERIES.length), 2200); + return () => clearInterval(t); + }, []); + return ( +
+ Try: + + {SAMPLE_QUERIES.map((q, idx) => ( + + {q} + + ))} + +
+ ); +}; + +const GroundedOutputPreview = () => ( +
+
+
Excerpt — 80 Pioneer Street briefing
+

+ The lot sits inside the FEMA 1% AE flood zone [c3], + with Hurricane Sandy storm-surge high-water marks recorded + 4.7 ft above grade [c1] at the address. + FloodNet sensor FN-BK-018, two blocks north, has logged + 14 nuisance-flood events since 2023 [c2]. +

+
+
+ [c1] + USGS High-Water Mark · Sandy 2012 + empirical +
+
+ [c2] + FloodNet sensor FN-BK-018 · 2023–2026 + empirical +
+
+ [c3] + FEMA NFHL · panel 36047C0207 + modeled +
+
+
+
+); + +const LandingV1 = () => ( + +
+

+ Riprap + A flood exposure briefing for any place in New York City. + + Type an address. Get a written briefing where every numeric claim + links to its primary public-record source. + +

+ + +
+
+
+ What you'll get back + A grounded paragraph with citations — not a chatbot answer. +
+ +
+
+); + +/* ═══════════════════════════════════════════════════════════════════════ + v2 · Example gallery + ═══════════════════════════════════════════════════════════════════════ */ +const GALLERY = [ + { kind: "Address", title: "80 Pioneer Street", sub: "Red Hook · industrial loft on Sandy inundation footprint", tally: "8 cards · 1% AE zone · 4.7ft Sandy HWM" }, + { kind: "Hospital", title: "Coney Island Hospital", sub: "Brooklyn · NYC Health+Hospitals · coastal AE-zone facility", tally: "11 cards · evacuated 2012 · NPCC4 +30in by 2070" }, + { kind: "School", title: "PS 188", sub: "Lower East Side · K-5 · 1.3mi from East River shore", tally: "6 cards · 0.2% shaded-X zone · DEP CSO outfall 200ft" }, + { kind: "NYCHA", title: "Hammels Houses", sub: "Rockaway · 712 units · ocean-side public housing", tally: "13 cards · multi-event flooding · TerraMind synthetic SAR" }, + { kind: "Transit", title: "Bowling Green station", sub: "Lower Manhattan · 4/5 line · 2012 inundation, post-Sandy hardened", tally: "9 cards · MTA flood-resilience capital plan referenced" }, + { kind: "Address", title: "555 W 57th Street", sub: "Hell's Kitchen · inland · low-exposure control case", tally: "4 cards · X-zone · cited for context comparison" }, +]; + +const GalleryCard = ({ item }) => ( + e.preventDefault()}> +
{item.kind}
+

{item.title}

+

{item.sub}

+
{item.tally}
+ +
+); + +const LandingV2 = () => ( + +
+

+ Riprap · Flood Exposure Briefing + What does flood mean for this place in New York? + + Riprap reads a place across hazard, exposure, observation, and projection — + and writes it down with citations. + +

+ +
+
+
+ Or open a sample briefing + Six NYC archetypes · pre-computed · click to read +
+
+ {GALLERY.map((it) => )} +
+
+
+
+ How Riprap reads a place + See methodology → +
+
+
Cornerstone hazard reader
+
Keystone asset register
+
Touchstone live observer
+
Lodestone projector
+
Capstone synthesizer
+
+
+
+); + +/* ═══════════════════════════════════════════════════════════════════════ + v3 · Methodology-forward + ═══════════════════════════════════════════════════════════════════════ */ +const STONE_FRIEZE = [ + { name: "Cornerstone", role: "the hazard reader", tag: "what NYC's ground remembers", sources: "USGS HWMs · FEMA NFHL · DEP stormwater · Prithvi historical" }, + { name: "Keystone", role: "the asset register", tag: "what's exposed", sources: "MTA · NYCHA · DOE · DOH · PLUTO" }, + { name: "Touchstone", role: "the live observer", tag: "what's happening now", sources: "FloodNet sensors · 311 complaints · tidal gauges" }, + { name: "Lodestone", role: "the projector", tag: "what's coming", sources: "NPCC4 · TTM foundation model · TerraMind synthetic SAR · NFIP" }, + { name: "Capstone", role: "the synthesizer", tag: "writes it all down", sources: "Granite composer · Mellea grounding-check · WeasyPrint" }, +]; + +const LandingV3 = () => ( + +
+

+ Riprap · Flood Exposure Briefing + Five Stones read every place. + + Each briefing routes through a fixed taxonomy of public-record specialists. + Each Stone is a class of evidence; together they form the briefing. + +

+
+ {STONE_FRIEZE.map((s, i) => ( +
+
{String(i + 1).padStart(2, "0")}
+

{s.name}

+
{s.role}
+

{s.tag}

+
{s.sources}
+
+ ))} +
+
+ + + Try · + · + + +
+
+
+); + +/* ═══════════════════════════════════════════════════════════════════════ + Canvas mount + ═══════════════════════════════════════════════════════════════════════ */ +const App = () => ( + + + + + + + + + + + + + +); + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/docs/design_handoff/design_files/map.jsx b/docs/design_handoff/design_files/map.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6156f98679ca5eb2dd8cc8813638a1b5ce33df45 --- /dev/null +++ b/docs/design_handoff/design_files/map.jsx @@ -0,0 +1,228 @@ +/* Static SVG mock of the MapLibre map for 80 Pioneer St, Red Hook. + No network dependency. Encodes all four evidence-tier styles + per the brief: empirical solid + 0.4 fill, modeled solid + 0.25, + synthetic dashed + 0.25 with stripe, proxy graduated dots no fill. +*/ + +const RedHookMapMock = ({ activeLayers, queriedAddress }) => { + return ( + + + {/* Diagonal stripe pattern for synthetic-prior fill */} + + + + + {/* Halo for label readability */} + + + + + + + + + {/* ── Basemap: Carto Positron register ── */} + {/* Water (Erie Basin / Buttermilk Channel) */} + + + + {/* Park (Coffey Park) */} + + + {/* Parcels (reference layer #E5E5E5) */} + + {Array.from({ length: 8 }).map((_, r) => + Array.from({ length: 14 }).map((_, c) => ( + + )) + )} + + + {/* Streets */} + + + + + + + + + + + + + {/* ── Empirical layer: Sandy Inundation Zone ── */} + {activeLayers.empirical && ( + + + + )} + + {/* ── Modeled layer: FEMA AE zone (solid line, 0.25 fill) ── */} + {activeLayers.modeled && ( + + + + )} + + {/* ── Synthetic-prior: dashed line + stripe pattern ── */} + {activeLayers.synthetic && ( + + + + )} + + {/* ── Proxy: 311 flood complaints (graduated dots, no fill) ── */} + {activeLayers.proxy && ( + + {[ + [120, 320, 5], [180, 350, 8], [220, 280, 4], [280, 330, 11], + [340, 360, 6], [240, 240, 3], [380, 320, 9], [440, 350, 7], + [200, 220, 4], [160, 280, 5], [340, 240, 3], [420, 280, 4], + [500, 360, 6], [540, 400, 8], [180, 380, 5], + ].map(([x, y, r], i) => ( + + ))} + + )} + + {/* ── Asset pins for register specialists ── */} + {/* Subway entrance , square */} + + + Smith–9 St + + {/* NYCHA , open square */} + + + Red Hook Houses + + {/* School , cross */} + + + PS 15 + + {/* Hospital , circle */} + + + NYU Cobble Hill + + + {/* ── Queried-address pin (warm orange, dominant at z14+) ── */} + + + + + 80 Pioneer St + + + {/* ── Map labels (Imhof hierarchy: water italic, neighborhoods regular caps) ── */} + Buttermilk Channel + Erie Basin + RED HOOK + CARROLL GARDENS + Coffey Park + + {/* Street labels at z15+ */} + Van Brunt St + Pioneer St + Imlay St + + {/* ── Scale bar + zoom indicator (corners, like USGS quad) ── */} + + + + + + 0 + 200 + 400 ft + + + z 16 · 40.6776°N 74.0096°W + + + ); +}; + +const MapLegend = ({ activeLayers, onToggle }) => { + /* v0.4.5: restructured by Stone. Each row carries its source-Stone so the + panel visually mirrors the Findings stack. The tier swatch is unchanged. */ + const layers = [ + { key: "empirical", tier: "empirical", stone: "cornerstone", label: "Sandy Inundation Zone (2012)", source: "NYC OEM" }, + { key: "modeled", tier: "modeled", stone: "cornerstone", label: "FEMA Zone AE · preliminary FIRM", source: "FEMA" }, + { key: "proxy", tier: "proxy", stone: "touchstone", label: "311 flood complaints, 2019–25", source: "NYC 311" }, + { key: "synthetic", tier: "synthetic", stone: "touchstone", label: "Synthetic LULC (2025-09-14)", source: "TerraMind v1.2" }, + { key: "prithvi-pluvial", tier: "modeled", stone: "touchstone", label: "Prithvi pluvial prediction", source: "Prithvi-NYC v2" }, + ]; + const stoneOrder = ["cornerstone", "touchstone"]; + const stoneMeta = { + cornerstone: { name: "Cornerstone", role: "what NYC's ground remembers" }, + touchstone: { name: "Touchstone", role: "what's happening now" }, + }; + return ( +
+
+ Layers · by Stone +
+ {stoneOrder.map((sk) => ( +
+
+ + {stoneMeta[sk].name} + · {stoneMeta[sk].role} +
+ {layers.filter((l) => l.stone === sk).map((l) => ( + + ))} +
+ ))} +
+ ); +}; + +Object.assign(window, { RedHookMapMock, MapLegend }); diff --git a/docs/design_handoff/design_files/shell.jsx b/docs/design_handoff/design_files/shell.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d1557e9367347171a9f3025624df8627bde76d22 --- /dev/null +++ b/docs/design_handoff/design_files/shell.jsx @@ -0,0 +1,142 @@ +/* App shell + cold-start state + spec sections that live below the + prototype: typography spec, palette spec, glyph spec, MapLibre style.json + fragments, layout grids, PDF template preview, accessibility checklist, + design rationale, reference register sketches. +*/ + +const SAMPLE_QUERIES = [ + { mode: "address", q: "80 Pioneer Street, Red Hook, Brooklyn", + sub: "Address-mode · Sandy edge · IBZ · NYCHA proximity" }, + { mode: "neighborhood", q: "Far Rockaway flood exposure briefing", + sub: "Neighborhood-mode · chronic stormwater · 2050 SLR" }, + { mode: "development", q: "Hunts Point proposed rezoning , flood-context check", + sub: "Development-check · CEQR §817 · 311 proxy density" }, +]; + +const ColdStart = ({ onPick, onSubmit }) => { + const [v, setV] = useState(""); + return ( +
+
+

+ Riprap is a citation-grounded Flood Exposure Briefing tool for New York City. + Type an address, neighborhood, or proposed development , Riprap returns a written briefing + where every numeric claim links to its primary public-record source. +

+

+ Built for agency analysts, planners, journalists, community boards, and researchers. + Not for individual residents making personal property decisions. + {" "}For residents seeking flood guidance, see FloodHelpNY. + For real-time conditions, see FloodNet NYC. +

+
+ +
{ e.preventDefault(); onSubmit?.(v); }} + role="search" + > + +
+ setV(e.target.value)} + placeholder="address · neighborhood · proposed development" + className="cold-start-input" + autoComplete="off" + /> + +
+
+ +
+ Sample queries +
+ {SAMPLE_QUERIES.map((s, i) => ( + + ))} +
+
+ +
+ How Riprap is built +

+ Cornerstone remembers.{" "} + Keystone tallies. + {" "}Touchstone watches.{" "} + Lodestone projects. + {" "}Capstone writes it all down with citations. +

+
    +
  • Five named cognitive roles compose ~25 atomic specialists. Architecture →
  • +
  • All foundation models Apache-2.0; no commercial APIs at runtime.
  • +
  • All data from public-record federal, state, and city sources.
  • +
  • Four epistemic tiers , empirical, modeled, proxy, synthetic prior , visible in the briefing margin and the trace.
  • +
  • Sections without supporting documents are omitted entirely. Silence over confabulation.
  • +
+ Methodology paper → +
+
+ ); +}; + +const AppHeader = ({ query, onResetCold, onOpenMethodology }) => ( +
+
+
+ riprap + / + Flood Exposure Briefing +
+
+ +
+ +
+
+); + +const AppFooter = () => ( +
+
+

+ Riprap does not predict damage. + {" "}This tool is for professional analytical work, not personal property decisions. + For residents, see FloodHelpNY · FloodNet NYC. +

+

+ All foundation models Apache-2.0 · All data from public-record federal, state, and city sources · No commercial APIs contacted at runtime · Riprap v0.4.3 · build 2026-05-05 +

+
+
+); + +Object.assign(window, { ColdStart, AppHeader, AppFooter }); diff --git a/docs/design_handoff/design_files/stone-evidence.jsx b/docs/design_handoff/design_files/stone-evidence.jsx new file mode 100644 index 0000000000000000000000000000000000000000..83f7958c50f4aa238602b1d4747c317b562b7c41 --- /dev/null +++ b/docs/design_handoff/design_files/stone-evidence.jsx @@ -0,0 +1,140 @@ +/* Riprap v0.4.4 , Unified Stone bands. + Each Stone holds: header (name + aggregate) → evidence cards → collapsed trace. + This replaces the duplicated "evidence grouped by stone" + "trace grouped by stone". +*/ + +const { useState: useSEv44 } = React; + +const EVIDENCE_BY_STONE = { + cornerstone: ["e1", "e3", "e4"], // USGS HWMs · FEMA FIRM · DEP stormwater + keystone: [], // (no exposure-register cards in current EVIDENCE , Keystone-silent) + touchstone: ["e2", "e7"], // FloodNet sensor · 311 complaints + lodestone: ["e5"], // NPCC4 SLR projection + capstone: ["e6", "e8"], // synthesis-tier outputs +}; + +const STONE_LOOKUP_V44 = { + cornerstone: { name: "Cornerstone", role: "the hazard reader", tag: "what NYC's ground remembers" }, + keystone: { name: "Keystone", role: "the asset register", tag: "what's exposed" }, + touchstone: { name: "Touchstone", role: "the live observer", tag: "what's happening now" }, + lodestone: { name: "Lodestone", role: "the projector", tag: "what's coming" }, + capstone: { name: "Capstone", role: "the synthesizer", tag: "writes it all down with citations" }, +}; + +/* Walk a Stone's trace members (incl. nested children) into a flat list for tally. */ +const flattenMembers = (members) => + members.flatMap((m) => (m.children ? [m, ...flattenMembers(m.children)] : [m])); + +const StoneTally = ({ cards, members }) => { + const flat = flattenMembers(members); + const fired = flat.filter((m) => m.status === "ok").length; + const silent = flat.filter((m) => m.status === "silent").length; + const warn = flat.filter((m) => m.status === "warn").length; + const error = flat.filter((m) => m.status === "error").length; + const ms = members.reduce((acc, m) => Math.max(acc, m.ms || 0), 0); + const fmt = (x) => (x === 0 ? ", " : x < 1000 ? x + "ms" : (x / 1000).toFixed(1) + "s"); + return ( + + {cards.length} card{cards.length === 1 ? "" : "s"} + · + {fired} fired + {silent > 0 && <>{" · "}{silent} silent} + {warn > 0 && <>{" · "}{warn} warn} + {error > 0 && <>{" · "}{error} error} + · + {fmt(ms)} + + ); +}; + +const UnifiedStoneBand = ({ stone, cardIds, onCite }) => { + const meta = STONE_LOOKUP_V44[stone.key]; + const cards = cardIds.map((id) => EVIDENCE.find((e) => e.id === id)).filter(Boolean); + const traceCount = flattenMembers(stone.members).length; + const [traceOpen, setTraceOpen] = useSEv44(false); + + return ( +
+
+
+

{meta.name}

+ , {meta.role} + {meta.tag} +
+ +
+ + {/* Findings , primary surface */} + {cards.length === 0 ? ( +
+ silent +

No exposure-register cards landed for this query , Keystone's atomic functions all fired (5 joins, see trace) but none of the asset registers (MTA, NYCHA, DOE, DOH, PLUTO) returned a hit at this address.

+
+ ) : ( +
+ {cards.map((ev) => )} +
+ )} + + {/* Provenance , collapsed by default */} +
+ + {traceOpen && ( +
+ {stone.members.map((m) => )} +
+ )} +
+
+ ); +}; + +/* Global tally strip , at-a-glance run health */ +const RunHealthStrip = () => { + const allMembers = STONES.flatMap((s) => flattenMembers(s.members)); + const fired = allMembers.filter((m) => m.status === "ok").length; + const total = allMembers.length; + const silent = allMembers.filter((m) => m.status === "silent").length; + const warn = allMembers.filter((m) => m.status === "warn").length; + const error = allMembers.filter((m) => m.status === "error").length; + const totalCards = Object.values(EVIDENCE_BY_STONE).reduce((n, ids) => n + ids.length, 0); + return ( +
+ 5 Stones + · + {fired}/{total} functions fired + · + {totalCards} evidence cards + · + 14.0s wall-clock + {silent > 0 && <>·{silent} silent} + {warn > 0 && <>·{warn} warn} + {error > 0 && <>·{error} error} +
+ ); +}; + +const UnifiedStoneLayout = ({ onCite }) => ( +
+ + {STONES.map((stone) => ( + + ))} +
+); + +Object.assign(window, { UnifiedStoneLayout }); diff --git a/docs/design_handoff/design_files/stones-trace.jsx b/docs/design_handoff/design_files/stones-trace.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b147647c1dfac43458ea2efcb290396bfb983a0e --- /dev/null +++ b/docs/design_handoff/design_files/stones-trace.jsx @@ -0,0 +1,219 @@ +/* Riprap v0.4.5 · Stone-banded trace. + Status enum (v0.4.5): fired / silent_by_design / warned / errored / not_invoked. + See V0.4.5_SPEC.md §1 for the rationale. +*/ + +const { useState: useStV44, useEffect: useEfV44 } = React; + +const STONES = [ + { + key: "cornerstone", name: "Cornerstone", role: "the hazard reader", + tag: "what NYC's ground remembers", + members: [ + { id: "c1", name: "sandy_inundation.lookup", status: "fired", ms: 380, tier: "empirical" }, + { id: "c2", name: "usgs_hwm.spatial_join", status: "fired", ms: 460, tier: "empirical" }, + { id: "c3", name: "fema_firm.lookup", status: "fired", ms: 290, tier: "modeled" }, + { id: "c4", name: "dep_stormwater.lookup", status: "fired", ms: 540, tier: "modeled" }, + { id: "c5", name: "prithvi.historical_segment",status: "warned", ms: 1240, tier: "modeled", + warning: "deprecation: Prithvi-100M v1 → v2 migration scheduled 2026-Q3" }, + ], + }, + { + key: "keystone", name: "Keystone", role: "the asset register", + tag: "what's exposed", + members: [ + { id: "k1", name: "mta_entrance_exposure", status: "silent_by_design", ms: 30, tier: "empirical", + note: "no entrances within radius" }, + { id: "k2", name: "nycha.development_join", status: "silent_by_design", ms: 28, tier: "empirical", + note: "no NYCHA developments within 1.0 mi" }, + { id: "k3", name: "doe.school_join", status: "silent_by_design", ms: 24, tier: "empirical", + note: "no DOE schools within 1.0 mi" }, + { id: "k4", name: "doh.facility_join", status: "silent_by_design", ms: 22, tier: "empirical", + note: "no acute-care hospitals within 1.0 mi" }, + { id: "k5", name: "pluto.lot_lookup", status: "silent_by_design", ms: 18, tier: "empirical", + note: "PLUTO join skipped: queried address not in NYC PLUTO dataset" }, + ], + }, + { + key: "touchstone", name: "Touchstone", role: "the live observer", + tag: "what's happening now", + members: [ + { id: "t1", name: "floodnet.history", status: "fired", ms: 1240, tier: "empirical" }, + { id: "t2", name: "nyc311.flood_complaints", status: "fired", ms: 880, tier: "proxy" }, + { id: "t3", name: "noaa_coops.recent", status: "fired", ms: 410, tier: "empirical" }, + { id: "t4", name: "terramind.lulc", status: "fired", ms: 2100, tier: "synthetic" }, + { id: "t5", name: "prithvi_nyc_pluvial", status: "fired", ms: 1820, tier: "modeled" }, + ], + }, + { + key: "lodestone", name: "Lodestone", role: "the projector", + tag: "what's coming", + members: [ + { id: "l1", name: "npcc4.slr_projection", status: "fired", ms: 320, tier: "modeled" }, + { id: "l2", name: "ttm_battery_surge.zero_shot",status: "fired", ms: 1500, tier: "modeled" }, + { id: "l3", name: "ttm_battery_surge.fine_tune",status: "fired", ms: 1480, tier: "modeled" }, + { id: "l4", name: "floodnet_forecast", status: "silent_by_design", ms: 14, tier: "modeled", + note: "sensor has only 2 historical events; forecast omitted (silent-floor: 5)" }, + { id: "l5", name: "ttm_311_forecast", status: "errored", ms: 0, tier: "modeled", + error: "311 history fetch failed: HTTP 503 at NYC OpenData (3 retries)" }, + ], + }, + { + key: "capstone", name: "Capstone", role: "the synthesizer", + tag: "writes it all down with citations", + members: [ + { id: "p1", name: "granite.compose_briefing", status: "fired", ms: 3200, tier: "modeled" }, + { id: "p2", name: "mellea.grounding_check", status: "fired", ms: 480, tier: "modeled" }, + { id: "p3", name: "weasyprint.render_artifact",status: "fired", ms: 920, tier: null }, + ], + }, +]; + +const fmtMs = (ms) => ms === 0 ? "—" : ms < 1000 ? ms + "ms" : (ms / 1000).toFixed(1) + "s"; +const tierColor = (t) => t ? `var(--tier-${t})` : "var(--ink-tertiary)"; + +const flat04 = (members) => members.flatMap(m => m.children ? [m, ...flat04(m.children)] : [m]); + +const StoneAggregate = ({ stone }) => { + const all = flat04(stone.members); + const fired = all.filter(m => m.status === "fired" || m.status === "warned").length; + const silent = all.filter(m => m.status === "silent_by_design").length; + const warn = all.filter(m => m.status === "warned").length; + const error = all.filter(m => m.status === "errored").length; + const notInvoked = all.filter(m => m.status === "not_invoked").length; + const ms = stone.members.reduce((acc, m) => Math.max(acc, m.ms || 0), 0); + return ( + + {fired} fired + {silent > 0 && <> · {silent} silent} + {warn > 0 && <> · {warn} warn} + {error > 0 && <> · {error} errored} + {notInvoked > 0 && <> · {notInvoked} not invoked} + {" · "}{fmtMs(ms)} + + ); +}; + +const TraceRow = ({ m, indent = 16 }) => { + if (m.children) { + return ( +
+ + + {m.name} + {m.status} + {m.tier || ""} + {fmtMs(m.ms)} + + {m.children.map(c => )} +
+ ); + } + if (m.status === "errored") { + return ( +
+ + + {m.name} + errored + {m.tier || ""} + {fmtMs(m.ms)} + {m.error} + + +
+
error{m.error}
+
retries3
+
elapsed{fmtMs(m.ms)}
+
+
+ ); + } + if (m.status === "silent_by_design") { + return ( +
+ + {m.name} + silent + {m.tier || ""} + {fmtMs(m.ms)} + {m.note && {m.note}} +
+ ); + } + if (m.status === "not_invoked") { + return ( +
+ + {m.name} + not invoked + {m.tier || ""} + + {m.note && {m.note}} +
+ ); + } + if (m.status === "warned") { + return ( +
+ + {m.name} + warned + {m.tier || ""} + {fmtMs(m.ms)} + + {m.warning && {m.warning}} +
+ ); + } + /* fired */ + return ( +
+ + {m.name} + fired + {m.tier || ""} + {fmtMs(m.ms)} + {m.note && {m.note}} +
+ ); +}; + +const StoneBand = ({ stone }) => { + const [open, setOpen] = useStV44(true); + return ( +
+ + {open && ( +
+ {stone.members.map(m => )} +
+ )} +
+ ); +}; + +const StoneTrace = () => { + const all = STONES.flatMap(s => flat04(s.members)); + const fired = all.filter(m => m.status === "fired" || m.status === "warned").length; + const silent = all.filter(m => m.status === "silent_by_design").length; + const error = all.filter(m => m.status === "errored").length; + return ( +
+
+ Run trace · 5 Stones + {fired} fired · {silent} silent · {error} errored · 24.0s +
+ {STONES.map(s => )} +
+ ); +}; + +Object.assign(window, { StoneTrace, STONES, TraceRow, fmtMs, tierColor }); diff --git a/docs/design_handoff/design_files/styles.css b/docs/design_handoff/design_files/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..a09b0ad9d91514d54f137aa309756d4734a44e73 --- /dev/null +++ b/docs/design_handoff/design_files/styles.css @@ -0,0 +1,1274 @@ +/* Riprap component styles. Civic-tech-clean. */ + +/* ── App header ────────────────────────────────────────── */ +.app-header { + position: sticky; + top: 0; + z-index: 50; + background: var(--paper); + border-bottom: 2px solid var(--ink); +} +.app-header-inner { + max-width: 1600px; + margin: 0 auto; + padding: 14px 28px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 24px; +} +.app-header-left { display: flex; align-items: center; gap: 12px; font-family: var(--font-mono); font-size: 13px; } +.app-header-sep { color: var(--ink-tertiary); } +.app-header-context { color: var(--ink-secondary); letter-spacing: 0.04em; } +.app-header-mid { display: flex; justify-content: center; } +.app-header-query { + background: var(--paper-deep); + border: 1px solid var(--rule-soft); + padding: 8px 14px; + font-family: var(--font-mono); + font-size: 13px; + color: var(--ink); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 10px; + min-width: 360px; + text-align: left; +} +.app-header-query-icon { color: var(--ink-tertiary); } +.app-header-query-edit { + margin-left: auto; + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-tertiary); +} +.app-header-right { display: flex; align-items: center; justify-content: flex-end; gap: 18px; font-family: var(--font-mono); font-size: 12px; } +.app-header-link { color: var(--ink-secondary); text-decoration: none; border-bottom: 1px solid transparent; } +.app-header-link:hover { border-bottom-color: var(--ink); color: var(--ink); } +.app-header-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ink-tertiary); text-transform: uppercase; letter-spacing: 0.1em; font-size: 11px; } +.app-header-status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-graphical); display: inline-block; } + +/* ── Hero band (prototype) ───────────────────────────────── */ +.hero-band { background: var(--paper); border-bottom: 1px solid var(--rule-soft); } +.hero-band-inner { max-width: 1600px; margin: 0 auto; padding: 32px 28px 56px; } + +/* ── App shell layout ────────────────────────────────────── */ +.app-shell { + display: grid; + gap: 24px; +} +.app-shell-desktop { + grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); + grid-template-areas: + "brief map" + "brief cites" + "evidence evidence" + "trace trace"; +} +.app-shell-tablet { + grid-template-columns: 1fr; + grid-template-areas: "map" "brief" "cites" "evidence" "trace"; +} +.app-shell-mobile { + grid-template-columns: 1fr; + grid-template-areas: "brief" "map" "cites" "evidence" "trace"; +} +.app-region-brief { grid-area: brief; } +.app-region-map { grid-area: map; position: sticky; top: 80px; align-self: start; max-height: calc(100vh - 96px); display: flex; flex-direction: column; } +.app-region-map .map-frame { flex: 1; min-height: 0; } +.app-region-cites { grid-area: cites; } +.app-region-evidence { grid-area: evidence; } +.app-region-trace { grid-area: trace; } + +@media (max-width: 1099px) { + .app-region-map { position: static; } +} + +.region-head { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--rule-soft); + padding-bottom: 8px; + margin-bottom: 16px; +} +.region-head-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); } +.region-action { + background: transparent; + border: 1px solid var(--rule-soft); + font-family: var(--font-mono); + font-size: 11px; + padding: 4px 10px; + cursor: pointer; + color: var(--ink-secondary); +} +.region-action:hover { border-color: var(--ink); color: var(--ink); } + +.brief-h1 { + font-size: 13px; + line-height: 1.2; + font-weight: 500; + margin: 0 0 22px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-tertiary); + font-family: var(--font-mono); + display: grid; + grid-template-columns: 1fr auto; + gap: 14px 18px; + align-items: end; + padding-bottom: 14px; + border-bottom: 2px solid var(--ink); +} +.brief-h1-addr { + display: block; + grid-column: 1; + grid-row: 1; + font-family: var(--font-serif); + font-size: 32px; + font-weight: 600; + letter-spacing: -0.01em; + text-transform: none; + color: var(--ink); + line-height: 1.1; + margin-top: 4px; +} +.brief-h1-eyebrow { + grid-column: 1; + grid-row: 1; + align-self: start; + display: block; +} +.brief-h1-meta { + grid-column: 2; + grid-row: 1; + align-self: end; + display: flex; + flex-direction: column; + gap: 4px; + text-align: right; + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-tertiary); + text-transform: none; + letter-spacing: 0.04em; +} +.brief-h1-meta-row { display: flex; gap: 6px; justify-content: flex-end; } +.brief-h1-meta-key { color: var(--ink-tertiary); } +.brief-h1-meta-val { color: var(--ink); font-weight: 500; } + +/* ── Briefing prose ──────────────────────────────────────── */ +.briefing-prose { + font-size: 16px; + line-height: var(--leading-prose); + max-width: 70ch; + position: relative; +} +.briefing-status { + border-left: 2px solid var(--ink); + padding: 4px 0 4px 14px; + margin-bottom: 24px; + font-size: 14px; + color: var(--ink-secondary); +} +.briefing-deck strong { color: var(--ink); font-weight: 600; } +.briefing-meta { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); margin-top: 6px; letter-spacing: 0.04em; } + +.briefing-section-head { + display: flex; + align-items: baseline; + gap: 12px; + font-size: 22px; + font-weight: 600; + margin: 32px 0 12px; + border-top: 2px solid var(--ink); + padding-top: 16px; + flex-wrap: wrap; +} +.briefing-section-num { + font-family: var(--font-mono); + font-size: 13px; + color: var(--ink-tertiary); + letter-spacing: 0.06em; + font-weight: 500; +} +.briefing-section-label { font-family: var(--font-sans); font-weight: 600; } +.briefing-section-title { + font-family: var(--font-sans); + font-size: 14px; + font-weight: 400; + color: var(--ink-secondary); + font-style: italic; +} +.briefing-section-tier { font-size: 11px; } + +.briefing-para { + margin: 0 0 18px; + padding-left: 22px; + position: relative; + text-wrap: pretty; +} + +.claim { + position: relative; +} +.claim-glyph { + position: absolute; + left: -22px; + top: 6px; + display: inline-block; +} +.claim-empirical .claim-body { /* default */ } +.claim-modeled .claim-body { /* default */ } +.claim-proxy .claim-body { color: var(--ink-secondary); } +.claim-synthetic .claim-body { + background: linear-gradient(transparent 60%, rgba(42,111,168,0.15) 60%); + padding: 0 1px; +} + +.inline-cite { + color: var(--tier-empirical); + font-family: var(--font-sans); + font-weight: 500; + text-decoration: none; + font-size: 14px; +} +.inline-cite sup { font-size: 0.78em; } +.inline-cite:hover { background: rgba(11,83,148,0.08); } + +.is-dense .briefing-section-head { margin: 18px 0 10px; padding-top: 12px; } +.is-dense .briefing-para { margin-bottom: 12px; } + +.streaming-caret { + display: inline-block; + color: var(--accent-graphical); + animation: blink 1s steps(2) infinite; + margin-left: 2px; +} +@keyframes blink { 50% { opacity: 0; } } + +/* ── Citation drawer ─────────────────────────────────────── */ +.citation-drawer { + border-top: 1px solid var(--rule-soft); + border-bottom: 1px solid var(--rule-soft); + padding: 16px 0; + font-size: 13px; +} +.citation-drawer-head { + display: flex; + justify-content: space-between; + align-items: baseline; + border-bottom: 1px solid var(--rule-soft); + padding-bottom: 8px; + margin-bottom: 12px; +} +.citation-drawer-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); } +.citation-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; } +.citation-item { + display: grid; + grid-template-columns: 32px 1fr; + gap: 8px; + padding: 10px 12px; + border-left: 2px solid var(--rule-soft); + transition: background 200ms; +} +.citation-item.is-active { + border-left-color: var(--accent-graphical); + background: rgba(209,124,0,0.06); +} +.citation-num { font-family: var(--font-mono); color: var(--ink-tertiary); font-size: 12px; } +.citation-line-1 { display: flex; align-items: center; gap: 8px; } +.citation-source { font-weight: 600; } +.citation-vintage { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); margin-left: auto; } +.citation-title { font-size: 13px; line-height: 1.4; margin: 4px 0; color: var(--ink-secondary); } +.citation-meta { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); } +.citation-drawer-foot { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--rule-soft); } +.citation-foot-copy { font-size: 12px; color: var(--ink-tertiary); margin: 6px 0 0; max-width: 60ch; } + +/* ── Map ─────────────────────────────────────────────────── */ +.map-frame { + position: relative; + border: 1px solid var(--ink); + background: var(--paper-deep); + aspect-ratio: 8 / 5.6; + overflow: hidden; +} +.map-legend { + position: absolute; + top: 12px; + left: 12px; + background: rgba(250, 250, 247, 0.96); + border: 1px solid var(--ink); + padding: 10px 12px 12px; + width: 280px; + display: flex; + flex-direction: column; + gap: 4px; + backdrop-filter: blur(4px); +} +.map-legend-head { padding-bottom: 6px; border-bottom: 1px solid var(--rule-soft); margin-bottom: 4px; } +.map-legend-item { + display: grid; + grid-template-columns: 16px 1fr auto; + gap: 10px; + align-items: center; + background: transparent; + border: 0; + padding: 6px 4px; + text-align: left; + cursor: pointer; + font-family: var(--font-sans); + border-bottom: 1px solid transparent; + min-height: 44px; +} +.map-legend-item:hover { background: rgba(0,0,0,0.03); } +.map-legend-item.is-off { opacity: 0.45; } +.map-legend-text { display: flex; flex-direction: column; gap: 2px; } +.map-legend-label { font-size: 12px; line-height: 1.3; color: var(--ink); } +.map-legend-source { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); display: inline-flex; gap: 6px; align-items: center; } +.map-legend-toggle { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; color: var(--ink-tertiary); } +.is-on .map-legend-toggle { color: var(--accent); } + +/* ── Trace UI ────────────────────────────────────────────── */ +.trace-ui { + border: 1px solid var(--rule-soft); + background: var(--paper-deep); +} +.trace-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--rule-soft); +} +.trace-head-left { display: flex; align-items: center; gap: 14px; } +.trace-head-meta { display: flex; gap: 6px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); } +.trace-head-sep { color: var(--ink-tertiary); } +.trace-head-silent { color: var(--accent); } +.trace-collapse-btn { + background: transparent; + border: 1px solid var(--rule-soft); + font-family: var(--font-mono); + font-size: 11px; + padding: 4px 10px; + cursor: pointer; + color: var(--ink-secondary); +} +.trace-col-heads { + display: grid; + grid-template-columns: 28px 24px 1fr 80px 140px; + gap: 8px; + padding: 8px 16px 6px; + border-bottom: 1px solid var(--rule-soft); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-tertiary); +} +.trace-tree { font-family: var(--font-mono); font-size: 13px; } +.trace-row { border-bottom: 1px solid var(--rule-soft); } +.trace-row-toggle { + width: 100%; + display: grid; + grid-template-columns: 16px 24px 1fr 80px 140px; + gap: 8px; + align-items: center; + background: transparent; + border: 0; + padding: 6px 16px 6px 0; + text-align: left; + cursor: pointer; + color: var(--ink); + min-height: 36px; +} +.trace-row-toggle[disabled] { cursor: default; } +.trace-row-toggle:hover:not([disabled]) { background: rgba(0,0,0,0.025); } +.trace-tree-glyph { color: var(--ink-tertiary); } +.trace-name { color: var(--ink); } +.trace-note { color: var(--ink-tertiary); } +.trace-ms-col { color: var(--ink-secondary); } +.trace-tier-col { display: inline-flex; align-items: center; gap: 6px; } +.trace-row-silent .trace-name { color: var(--ink-tertiary); } +.trace-silent-tag { + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent); + border: 1px solid var(--accent); + padding: 1px 5px; +} +.trace-status-glyph { color: var(--ink-tertiary); font-size: 13px; } +.trace-output { + padding: 4px 16px 8px 0; + font-size: 12px; + color: var(--ink-secondary); + display: flex; + gap: 10px; + align-items: baseline; + border-top: 1px dashed var(--rule-soft); + background: rgba(0,0,0,0.015); +} +.trace-output-prefix { color: var(--ink-tertiary); } +.trace-output-claims { margin-left: auto; padding-right: 16px; font-family: var(--font-mono); font-size: 11px; color: var(--accent); } + +/* ── Evidence cards ──────────────────────────────────────── */ +.evidence-grid-head { + display: flex; + justify-content: space-between; + align-items: baseline; + border-bottom: 1px solid var(--rule-soft); + padding-bottom: 8px; + margin-bottom: 16px; +} +.evidence-grid-meta { display: flex; gap: 12px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); } +.evidence-grid-tally { display: inline-flex; align-items: center; gap: 4px; } +.evidence-grid-rail { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.evidence-card { + border: 1px solid var(--rule-soft); + background: var(--paper); + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + position: relative; + border-top: 3px solid var(--ink); +} +.evidence-card-empirical { border-top-color: var(--tier-empirical); } +.evidence-card-modeled { border-top-color: var(--tier-modeled); } +.evidence-card-proxy { border-top-color: var(--tier-proxy); } +.evidence-card-synthetic { border-top-color: var(--tier-synthetic); border-top-style: dashed; } + +.evidence-card-head { display: flex; justify-content: space-between; align-items: center; } +.evidence-card-source { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13px; } +.evidence-card-vintage { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); } +.evidence-card-title { font-size: 14px; font-weight: 600; line-height: 1.3; margin: 0; } +.evidence-card-body { padding: 4px 0; } +.evidence-scalar-value { font-size: 24px; font-weight: 600; line-height: 1.1; font-family: var(--font-serif); } +.evidence-scalar-unit { font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); margin-top: 2px; } +.evidence-scalar-aux { font-size: 12px; color: var(--ink-tertiary); margin-top: 4px; line-height: 1.4; } +.evidence-spark-headline { font-size: 18px; font-weight: 600; font-family: var(--font-serif); margin-bottom: 4px; } +.evidence-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 11px; } +.evidence-table th, .evidence-table td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--rule-soft); } +.evidence-table th { color: var(--ink-tertiary); font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; font-size: 9px; } +.evidence-thumb { display: flex; flex-direction: column; gap: 6px; } +.evidence-card-foot { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 8px; + border-top: 1px solid var(--rule-soft); +} +.evidence-card-cite { + background: transparent; + border: 0; + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-secondary); + cursor: pointer; + padding: 4px 0; + display: inline-flex; + align-items: center; + gap: 6px; +} +.evidence-card-cite:hover { color: var(--accent); } +.evidence-card-cite-arrow { color: var(--ink-tertiary); } + +/* ── Cold start ─────────────────────────────────────────── */ +.cold-start { max-width: 920px; margin: 0 auto; padding: 32px 0; } +.cold-start-band { border-top: 2px solid var(--ink); border-bottom: 1px solid var(--rule-soft); padding: 24px 0; margin-bottom: 32px; } +.cold-start-deck { font-size: 18px; line-height: 1.5; max-width: 70ch; margin: 0 0 12px; text-wrap: pretty; } +.cold-start-deck-secondary { font-size: 14px; color: var(--ink-secondary); } +.cold-start-redir { color: var(--accent); border-bottom: 1px solid var(--accent); text-decoration: none; } +.cold-start-form { margin-bottom: 32px; } +.cold-start-label { display: block; margin-bottom: 8px; } +.cold-start-input-row { display: grid; grid-template-columns: 1fr auto; gap: 0; border: 2px solid var(--ink); } +.cold-start-input { padding: 14px 16px; font-family: var(--font-mono); font-size: 14px; border: 0; background: var(--paper); color: var(--ink); } +.cold-start-input:focus { outline: 0; background: var(--paper-deep); } +.cold-start-submit { background: var(--ink); color: var(--paper); border: 0; padding: 0 20px; font-family: var(--font-mono); font-size: 13px; cursor: pointer; letter-spacing: 0.04em; } +.cold-start-submit:hover { background: var(--accent); } +.cold-start-samples { margin-bottom: 32px; } +.cold-start-samples-label { display: block; margin-bottom: 12px; } +.cold-start-samples-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; } +.cold-start-sample { + text-align: left; + background: var(--paper-deep); + border: 1px solid var(--rule-soft); + padding: 14px 16px; + font-family: var(--font-sans); + cursor: pointer; + display: grid; + gap: 4px; + position: relative; + min-height: 80px; +} +.cold-start-sample:hover { border-color: var(--ink); } +.cold-start-sample-mode { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); } +.cold-start-sample-q { font-size: 14px; font-weight: 500; line-height: 1.3; color: var(--ink); } +.cold-start-sample-sub { font-size: 11px; color: var(--ink-tertiary); font-family: var(--font-mono); } +.cold-start-sample-arrow { position: absolute; top: 12px; right: 14px; color: var(--ink-tertiary); } +.cold-start-trust { border-top: 1px solid var(--rule-soft); padding-top: 16px; } +.cold-start-trust-list { font-size: 13px; line-height: 1.5; color: var(--ink-secondary); padding-left: 18px; margin: 8px 0; } +.cold-start-method-link { font-family: var(--font-mono); font-size: 13px; color: var(--ink); border-bottom: 1px solid var(--ink); text-decoration: none; } + +/* ── Spec band ──────────────────────────────────────────── */ +.spec-band { background: var(--paper-deep); border-top: 2px solid var(--ink); } +.spec-band-inner { max-width: 1600px; margin: 0 auto; padding: 56px 28px 80px; } +.spec-band-head { max-width: 70ch; margin-bottom: 56px; } +.spec-band-title { font-size: 42px; line-height: 1.1; font-weight: 600; margin: 12px 0 16px; letter-spacing: -0.02em; font-family: var(--font-serif); } +.spec-band-deck { font-size: 17px; line-height: 1.5; color: var(--ink-secondary); } +.spec-toc { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 0; margin-top: 32px; border-top: 1px solid var(--rule-soft); border-left: 1px solid var(--rule-soft); } +.spec-toc-item { display: flex; gap: 8px; padding: 10px 14px; border-right: 1px solid var(--rule-soft); border-bottom: 1px solid var(--rule-soft); text-decoration: none; color: var(--ink); background: var(--paper); font-family: var(--font-mono); font-size: 12px; align-items: baseline; } +.spec-toc-item:hover { background: var(--paper-deep); color: var(--accent); } +.spec-toc-n { color: var(--ink-tertiary); } + +.spec-section { padding: 48px 0; border-top: 2px solid var(--ink); } +.spec-section-head { max-width: 70ch; margin-bottom: 32px; display: flex; flex-direction: column; gap: 6px; } +.spec-section-title { font-size: 32px; line-height: 1.15; font-weight: 600; margin: 4px 0 12px; font-family: var(--font-serif); } +.spec-section-deck { font-size: 15px; line-height: 1.55; color: var(--ink-secondary); margin: 0; } + +/* Overview */ +.overview-tiers { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0; border-top: 1px solid var(--rule-soft); border-left: 1px solid var(--rule-soft); } +.overview-tier { padding: 20px; border-right: 1px solid var(--rule-soft); border-bottom: 1px solid var(--rule-soft); background: var(--paper); display: flex; flex-direction: column; gap: 10px; } +.overview-tier-head { display: flex; align-items: center; gap: 10px; } +.overview-tier-name { font-size: 16px; font-weight: 600; } +.overview-tier-desc { font-size: 13px; color: var(--ink-secondary); margin: 0; line-height: 1.5; } +.overview-tier-ex { font-size: 11px; font-family: var(--font-mono); color: var(--ink-tertiary); margin: 0; line-height: 1.5; } + +/* Palette */ +.palette-grid { display: grid; gap: 0; border-top: 1px solid var(--rule-soft); } +.palette-row { + display: grid; + grid-template-columns: 60px 1.6fr 1.2fr 1.4fr 2fr; + gap: 16px; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid var(--rule-soft); + font-size: 13px; +} +.palette-swatch { width: 48px; height: 48px; border: 1px solid var(--rule-soft); } +.palette-swatch.is-syn { background-image: repeating-linear-gradient(45deg, transparent 0, transparent 3px, #2A6FA8 3px, #2A6FA8 4px); } +.palette-name { font-weight: 600; font-size: 14px; } +.palette-token { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); } +.palette-hex { font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); } +.palette-contrast-ratio { font-family: var(--font-mono); font-size: 14px; font-weight: 600; } +.palette-contrast-grade { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; } +.palette-contrast-aaa, .palette-contrast-aa { color: var(--tier-empirical); } +.palette-contrast-decorative { color: var(--ink-tertiary); } +.palette-note { font-size: 12px; color: var(--ink-secondary); line-height: 1.4; } +.cvd-strip { margin-top: 24px; padding: 16px; background: var(--paper); border: 1px solid var(--rule-soft); } +.cvd-copy { font-size: 13px; line-height: 1.55; color: var(--ink-secondary); margin: 8px 0 0; max-width: 70ch; } + +/* Type spec */ +.type-spec-table { border-top: 1px solid var(--rule-soft); margin-bottom: 32px; } +.type-spec-row { display: grid; grid-template-columns: 1.4fr 1.2fr 1.4fr 2fr; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--rule-soft); font-size: 13px; align-items: baseline; } +.type-spec-head { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-tertiary); } +.type-spec-surface { font-weight: 500; } +.type-spec-family { font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); } +.type-spec-size { font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); } +.type-spec-use { color: var(--ink-secondary); font-size: 12px; } +.type-spec-samples { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; padding: 20px; background: var(--paper); border: 1px solid var(--rule-soft); } +.type-sample { display: flex; flex-direction: column; } + +/* Glyphs */ +.glyph-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 0; border-top: 1px solid var(--rule-soft); border-left: 1px solid var(--rule-soft); } +.glyph-cell { padding: 24px; border-right: 1px solid var(--rule-soft); border-bottom: 1px solid var(--rule-soft); display: flex; flex-direction: column; gap: 14px; background: var(--paper); } +.glyph-display { width: 96px; height: 96px; background: var(--paper-deep); display: flex; align-items: center; justify-content: center; border: 1px solid var(--rule-soft); } +.glyph-anatomy { display: flex; flex-direction: column; gap: 4px; } +.glyph-anatomy-title { font-size: 16px; font-weight: 600; } +.glyph-anatomy-shape { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); } +.glyph-anatomy-desc { font-size: 13px; color: var(--ink-secondary); line-height: 1.5; } +.glyph-anatomy-ex { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); line-height: 1.5; } +.glyph-sizes, .glyph-mono { display: flex; align-items: center; gap: 10px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); border-top: 1px dashed var(--rule-soft); padding-top: 10px; } +.glyph-sizes-label { min-width: 80px; } +.glyph-sizes-mono { color: var(--ink-tertiary); margin-left: auto; } +.glyph-mono > svg:last-of-type { background: #1A1A1A; padding: 2px; border-radius: 1px; } + +/* Map spec */ +.map-spec-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 24px; align-items: stretch; } +.map-spec-preview { border: 1px solid var(--ink); aspect-ratio: 8 / 5.6; position: relative; overflow: hidden; } +.map-spec-caption { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(250,250,247,0.92); padding: 6px 12px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); border-top: 1px solid var(--rule-soft); } +.map-spec-code { font-family: var(--font-mono); font-size: 11px; line-height: 1.55; background: var(--ink); color: var(--paper); padding: 16px; margin: 0; overflow-x: auto; max-height: 560px; overflow-y: auto; } + +@media (max-width: 900px) { .map-spec-grid { grid-template-columns: 1fr; } } + +/* Layouts */ +.layout-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; } +.layout-fig { margin: 0; } +.layout-fig-cap { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 8px; border-bottom: 1px solid var(--rule-soft); margin-bottom: 12px; } +.layout-fig-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); } +.layout-canvas { background: var(--paper); border: 1px solid var(--ink); display: grid; gap: 4px; padding: 8px; } +.layout-canvas-desktop { grid-template-columns: 5fr 7fr; grid-template-rows: 220px 80px 60px; aspect-ratio: 16/10; } +.layout-canvas-tablet { grid-template-rows: 160px 120px 80px; aspect-ratio: 4/5; } +.layout-canvas-mobile { grid-template-rows: 180px 100px 100px 60px; aspect-ratio: 9/16; max-height: 540px; } +.layout-region { background: var(--paper-deep); border: 1px dashed var(--rule-soft); padding: 10px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); display: flex; flex-direction: column; justify-content: space-between; } +.layout-region-w { color: var(--ink-tertiary); font-size: 10px; } +.layout-canvas-desktop .layout-region-brief { grid-row: 1 / span 2; } +.layout-canvas-desktop .layout-region-map { grid-column: 2; } +.layout-canvas-desktop .layout-region-evidence { grid-column: 1 / span 2; } +.layout-canvas-desktop .layout-region-trace { grid-column: 1 / span 2; background: var(--paper); } +.layout-region-brief { background: rgba(11,83,148,0.08); border-color: var(--tier-empirical); } +.layout-region-map { background: rgba(42,111,168,0.08); border-color: var(--tier-modeled); } +.layout-region-evidence { background: rgba(107,107,107,0.08); } +.layout-region-tabs { background: rgba(11,83,148,0.06); } +.layout-region-trace { background: var(--paper-deep); border-style: solid; } + +/* PDF spec */ +.pdf-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px; align-items: start; } +.pdf-page { aspect-ratio: 210 / 297; background: var(--paper); border: 1px solid var(--rule-soft); box-shadow: 0 12px 32px -16px rgba(0,0,0,0.18); position: relative; overflow: hidden; } +.pdf-page-inner { position: absolute; inset: 0; padding: 28px 28px; display: flex; flex-direction: column; gap: 14px; font-size: 10px; line-height: 1.45; } +.pdf-cover-band { display: flex; justify-content: space-between; padding-bottom: 12px; border-bottom: 2px solid var(--ink); font-family: var(--font-mono); font-size: 9px; color: var(--ink-secondary); } +.pdf-cover-band-meta { letter-spacing: 0.06em; } +.pdf-cover-headline { padding: 24px 0; } +.pdf-cover-eyebrow { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-tertiary); } +.pdf-cover-title { font-family: var(--font-serif); font-size: 28px; font-weight: 600; line-height: 1.1; margin: 8px 0 6px; letter-spacing: -0.01em; } +.pdf-cover-deck { font-family: var(--font-mono); font-size: 10px; color: var(--ink-secondary); margin: 0; } +.pdf-cover-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; border-top: 1px solid var(--rule-soft); border-bottom: 1px solid var(--rule-soft); padding: 12px 0; font-size: 10px; } +.pdf-cover-meta div { display: flex; gap: 6px; } +.pdf-cover-meta dt { font-family: var(--font-mono); font-size: 9px; color: var(--ink-tertiary); text-transform: uppercase; letter-spacing: 0.08em; min-width: 88px; margin: 0; } +.pdf-cover-meta dd { margin: 0; font-size: 10px; } +.pdf-cover-models ul { margin: 6px 0 0; padding-left: 14px; font-family: var(--font-mono); font-size: 9px; line-height: 1.6; color: var(--ink-secondary); } +.pdf-cover-foot { margin-top: auto; display: flex; justify-content: space-between; padding-top: 12px; border-top: 1px solid var(--ink); font-family: var(--font-mono); font-size: 9px; color: var(--ink-secondary); } +.pdf-cover-foot a { color: var(--ink); } + +.pdf-running-head { display: flex; justify-content: space-between; padding-bottom: 8px; border-bottom: 1px solid var(--rule-soft); font-family: var(--font-mono); font-size: 9px; color: var(--ink-tertiary); } +.pdf-running-foot { margin-top: auto; padding-top: 8px; border-top: 1px solid var(--rule-soft); font-family: var(--font-mono); font-size: 8px; color: var(--ink-tertiary); } +.pdf-h2 { font-family: var(--font-sans); font-size: 14px; font-weight: 600; margin: 8px 0 6px; } +.pdf-h3 { font-family: var(--font-sans); font-size: 11px; font-weight: 600; margin: 8px 0 4px; } +.pdf-prose { font-family: var(--font-serif); font-size: 10px; line-height: 1.5; } +.pdf-prose p { margin: 0 0 8px; padding-left: 16px; position: relative; } +.pdf-margin-glyph { position: absolute; left: 0; top: 4px; } +.pdf-prose sup { color: var(--tier-empirical); font-family: var(--font-sans); font-weight: 500; } +.pdf-cite-list { font-family: var(--font-serif); font-size: 9px; line-height: 1.55; padding-left: 16px; margin: 8px 0; } +.pdf-cite-list li { margin-bottom: 6px; } +.pdf-cite-list span { font-family: var(--font-mono); font-size: 8px; color: var(--ink-tertiary); } + +/* A11y */ +.a11y-grid { display: grid; gap: 0; border-top: 1px solid var(--rule-soft); } +.a11y-row { display: grid; grid-template-columns: 200px 1fr; gap: 24px; padding: 12px 0; border-bottom: 1px solid var(--rule-soft); font-size: 13px; align-items: baseline; } +.a11y-key { padding-top: 2px; } +.a11y-val { color: var(--ink-secondary); line-height: 1.55; } + +/* Refs */ +.ref-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; } +.ref-card { background: var(--paper); border: 1px solid var(--rule-soft); display: flex; flex-direction: column; } +.ref-card-thumb { aspect-ratio: 200/140; border-bottom: 1px solid var(--rule-soft); } +.ref-card-body { padding: 14px; display: flex; flex-direction: column; gap: 6px; } +.ref-card-name { margin: 0; font-size: 14px; font-weight: 600; } +.ref-card-borrow { font-size: 12px; color: var(--ink-secondary); line-height: 1.5; margin: 0; } +.ref-card-url { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); } + +/* Rationale */ +.spec-section-quote { background: var(--paper); } +.rationale-quote { margin: 0; max-width: 70ch; border-left: 3px solid var(--ink); padding: 16px 0 16px 24px; } +.rationale-quote p { font-family: var(--font-serif); font-size: 19px; line-height: 1.55; margin: 0 0 16px; text-wrap: pretty; } +.rationale-cite { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--ink-tertiary); font-style: normal; } + +/* Footer */ +.app-footer { background: var(--ink); color: var(--paper); } +.app-footer-inner { max-width: 1600px; margin: 0 auto; padding: 32px 28px; display: flex; flex-direction: column; gap: 12px; } +.app-footer-guard { font-size: 14px; line-height: 1.5; max-width: 70ch; margin: 0; } +.app-footer-guard a { color: var(--accent-graphical); border-bottom: 1px solid var(--accent-graphical); text-decoration: none; } +.app-footer-build { font-family: var(--font-mono); font-size: 11px; color: rgba(250,250,247,0.55); margin: 0; letter-spacing: 0.04em; } + +/* Dark mode , deferred to v0.5 (see §17). Partial styles removed in v0.4.2. */ + +/* ════════════════════════════════════════════════════════════════════ + v0.4.2 APPENDIX STYLES + §11 loading · §12 errors · §13 guardian · §14 stripe · §15 register + §16 caveats · §17 dark mode · §18 print · §19 changelog + ════════════════════════════════════════════════════════════════════ */ + +/* v0.4.2 banner */ +.v042-band { background: var(--paper); border-top: 1px solid var(--rule-soft); } +.v042-banner { background: linear-gradient(180deg, #F4EFE5 0%, var(--paper) 100%); border: 1px solid var(--rule-soft); padding: 36px 32px; margin-bottom: 48px; } +.v042-banner-inner { display: grid; grid-template-columns: 1.4fr 1fr; gap: 40px; align-items: start; } +.v042-banner-title { font-family: var(--font-serif); font-size: 30px; font-weight: 600; line-height: 1.18; margin: 8px 0 12px; letter-spacing: -0.01em; } +.v042-banner-deck { font-size: 15px; line-height: 1.6; color: var(--ink-secondary); max-width: 60ch; } +.v042-toc { display: grid; grid-template-columns: 1fr 1fr; gap: 0; border: 1px solid var(--rule-soft); } +.v042-toc-item { display: flex; gap: 10px; padding: 10px 14px; border-right: 1px solid var(--rule-soft); border-bottom: 1px solid var(--rule-soft); font-size: 13px; color: var(--ink); text-decoration: none; align-items: baseline; background: white; } +.v042-toc-item:nth-child(2n) { border-right: none; } +.v042-toc-item:hover { background: #F8F4EA; } +.v042-toc-n { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.06em; min-width: 28px; } + +/* §11 Loading + skeleton + reroll */ +.loading-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 28px; } +.loading-fig { display: flex; flex-direction: column; gap: 10px; } +.loading-cap { display: flex; flex-direction: column; gap: 4px; } +.loading-cap-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.04em; } +.loading-frame { background: white; border: 1px solid var(--rule-soft); padding: 20px; min-height: 320px; } + +.skeleton-brief { display: flex; flex-direction: column; gap: 18px; } +.skeleton-status { display: flex; flex-direction: column; gap: 6px; padding-bottom: 12px; border-bottom: 1px solid var(--rule-soft); } +.skeleton-section { display: flex; flex-direction: column; gap: 8px; } +.skeleton-head { display: flex; align-items: baseline; gap: 12px; padding-bottom: 4px; } +.skeleton-num { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.08em; } +.skeleton-label { font-family: var(--font-serif); font-size: 16px; font-weight: 600; color: var(--ink); } +.skeleton-spinner { font-family: var(--font-mono); color: var(--tier-modeled); font-size: 12px; animation: skeletonBlink 1.1s ease-in-out infinite; margin-left: auto; } +.skeleton-pulse { display: block; height: 12px; background: linear-gradient(90deg, #ECE8DD 0%, #DAD4C5 50%, #ECE8DD 100%); background-size: 200% 100%; animation: skeletonShimmer 1.6s ease-in-out infinite; border-radius: 1px; } +.skeleton-pulse-meta { height: 9px; } +@keyframes skeletonShimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } } +@keyframes skeletonBlink { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } } +@media (prefers-reduced-motion: reduce) { + .skeleton-pulse { animation: none; background: #E2DCCC; } + .skeleton-spinner { animation: none; opacity: 0.6; } +} + +.reroll-banner { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: rgba(42,111,168,0.08); border-left: 3px solid var(--tier-modeled); margin-bottom: 14px; } +.reroll-body { display: flex; flex-direction: column; gap: 2px; flex: 1; } +.reroll-head { font-family: var(--font-sans); font-size: 14px; font-weight: 500; color: var(--ink); } +.reroll-sub { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.03em; } +.reroll-spinner { font-size: 16px; color: var(--tier-modeled); animation: rerollSpin 2s linear infinite; } +@keyframes rerollSpin { 100% { transform: rotate(360deg); } } +.reroll-prev { opacity: 0.4; pointer-events: none; } +.reroll-prev-line { font-family: var(--font-serif); font-size: 14px; line-height: 1.55; color: var(--ink); margin-bottom: 8px; } + +.loading-rules { padding: 18px 22px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-empirical); } +.loading-rules ul { margin: 8px 0 0 18px; } +.loading-rules li { font-size: 13px; line-height: 1.6; margin-bottom: 4px; color: var(--ink-secondary); } + +/* §12 Errors */ +.error-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +.error-card { background: white; border: 1px solid var(--rule-soft); padding: 22px 24px; display: flex; flex-direction: column; gap: 10px; } +.error-card-head { display: flex; align-items: center; gap: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--rule-soft); } +.error-card-eyebrow { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.06em; text-transform: uppercase; } +.error-card-headline { font-family: var(--font-serif); font-size: 19px; font-weight: 600; line-height: 1.3; margin: 0; } +.error-card-body { font-size: 14px; line-height: 1.55; color: var(--ink-secondary); margin: 0; } +.error-card-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; } +.error-card-action { font-family: var(--font-sans); font-size: 12px; font-weight: 500; padding: 7px 14px; border: 1px solid var(--ink); background: white; cursor: pointer; letter-spacing: 0.02em; } +.error-card-action.is-primary { background: var(--ink); color: var(--paper); border-color: var(--ink); } +.error-card-action:hover { background: #F4EFE5; } +.error-card-action.is-primary:hover { background: #2A2A2A; } +.error-card-foot { display: flex; flex-direction: column; gap: 2px; padding-top: 10px; margin-top: 4px; border-top: 1px dashed var(--rule-soft); } +.error-card-foot-copy { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.03em; } + +/* §13 Guardian */ +.guardian-tabs { display: flex; gap: 0; border: 1px solid var(--rule-soft); margin-bottom: 0; flex-wrap: wrap; } +.guardian-tab { font-family: var(--font-sans); font-size: 12px; font-weight: 500; padding: 11px 16px; background: white; border: none; border-right: 1px solid var(--rule-soft); cursor: pointer; flex: 1; min-width: 180px; text-align: left; color: var(--ink-secondary); letter-spacing: 0.01em; } +.guardian-tab:last-child { border-right: none; } +.guardian-tab.is-active { background: var(--ink); color: var(--paper); } +.guardian-tab:hover:not(.is-active) { background: #F8F4EA; color: var(--ink); } + +.guardian-card { background: white; border: 1px solid var(--rule-soft); border-top: none; padding: 32px 36px; display: flex; flex-direction: column; gap: 14px; } +.guardian-head { display: flex; align-items: baseline; gap: 8px; } +.guardian-eyebrow { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.06em; text-transform: uppercase; } +.guardian-title { font-family: var(--font-serif); font-size: 24px; font-weight: 600; line-height: 1.25; margin: 0; } +.guardian-body { font-family: var(--font-serif); font-size: 16px; line-height: 1.6; color: var(--ink); max-width: 60ch; margin: 0; } +.guardian-redirect { display: flex; flex-direction: column; gap: 4px; padding: 14px 18px; border: 1px solid var(--ink); background: #FBF8EF; text-decoration: none; color: var(--ink); margin: 6px 0; max-width: 480px; } +.guardian-redirect:hover { background: #F4EFE5; } +.guardian-redirect-label { font-family: var(--font-sans); font-size: 14px; font-weight: 600; } +.guardian-redirect-url { font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); letter-spacing: 0.02em; } +.guardian-no-redirect { padding: 14px 18px; border-left: 2px solid var(--ink-tertiary); background: #FBF8EF; max-width: 540px; } +.guardian-no-redirect p { font-size: 13px; line-height: 1.55; color: var(--ink-secondary); margin: 4px 0 0; } +.guardian-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.03em; padding-top: 12px; border-top: 1px dashed var(--rule-soft); } + +.guardian-a11y { padding: 18px 22px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-empirical); margin-top: 22px; } +.guardian-a11y p { font-size: 13px; line-height: 1.6; color: var(--ink-secondary); margin: 6px 0 0; } +.guardian-a11y em { font-family: var(--font-serif); font-style: italic; color: var(--ink); } + +/* §14 syn-stripe */ +.stripe-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 22px; } +.stripe-cell { display: flex; flex-direction: column; gap: 8px; padding: 18px; background: white; border: 1px solid var(--rule-soft); } +.stripe-preview { width: 100%; height: 180px; border: 1px solid var(--rule-soft); background-color: #FAFAF7; overflow: hidden; } +.stripe-preview > div { background-repeat: repeat; background-size: 12px 12px !important; } +.stripe-code { font-family: var(--font-mono); font-size: 10px; line-height: 1.5; color: var(--ink-secondary); background: #FBF8EF; padding: 10px 12px; border: 1px solid var(--rule-soft); white-space: pre-wrap; overflow-x: auto; max-height: 180px; overflow-y: auto; } +.stripe-code-wide { max-height: 360px; } +.stripe-reg { padding: 18px; background: white; border: 1px solid var(--rule-soft); margin-bottom: 18px; } +.stripe-reg .section-label { display: block; margin-bottom: 8px; } +.stripe-data { padding: 14px 18px; background: white; border: 1px solid var(--rule-soft); } +.stripe-data .section-label { display: block; margin-bottom: 6px; } +.stripe-data-uri { display: block; font-family: var(--font-mono); font-size: 10px; color: var(--ink-secondary); word-break: break-all; line-height: 1.5; max-height: 80px; overflow-y: auto; } + +/* §15 Register card */ +.register-frame { padding: 28px; background: var(--paper); border: 1px solid var(--rule-soft); margin-bottom: 22px; } +.register-card { background: white; border: 1px solid var(--rule-soft); padding: 22px 26px; max-width: 760px; } +.register-card-head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 10px; border-bottom: 1px solid var(--rule-soft); margin-bottom: 14px; } +.register-card-source { display: flex; align-items: center; gap: 6px; } +.register-card-source-label { font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); letter-spacing: 0.04em; } +.register-card-vintage { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.05em; } +.register-card-title { font-family: var(--font-serif); margin: 0 0 16px; display: flex; align-items: baseline; gap: 10px; line-height: 1.2; } +.register-card-count { font-size: 32px; font-weight: 600; color: var(--ink); } +.register-card-type { font-size: 16px; font-weight: 400; color: var(--ink-secondary); } +.register-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.register-table thead th { font-family: var(--font-mono); font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-tertiary); padding: 6px 8px; text-align: left; border-bottom: 1px solid var(--rule-soft); } +.register-row { border-bottom: 1px solid #ECE8DD; cursor: pointer; transition: background 0.12s; } +.register-row:hover { background: #FBF8EF; } +.register-row.is-open { background: #F4EFE5; } +.register-row td { padding: 10px 8px; vertical-align: middle; } +.register-row-glyph { display: flex; align-items: center; gap: 4px; } +.register-row-name { font-family: var(--font-sans); font-weight: 500; color: var(--ink); } +.register-yes { color: var(--tier-empirical); font-weight: 600; } +.register-no { color: var(--ink-tertiary); } +.register-detail td { padding: 0 8px 14px; background: #F4EFE5; border-bottom: 1px solid var(--rule-soft); } +.register-detail-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 14px; padding: 14px 0; } +.register-detail-grid > div { display: flex; flex-direction: column; gap: 4px; } +.register-detail-grid p { font-size: 12px; line-height: 1.45; color: var(--ink-secondary); margin: 0; } +.register-card-foot { display: flex; justify-content: space-between; align-items: center; padding-top: 12px; margin-top: 14px; border-top: 1px dashed var(--rule-soft); } +.register-foot-note { font-size: 12px; color: var(--ink-tertiary); font-style: italic; max-width: 50ch; } + +.register-rules { padding: 18px 22px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-empirical); } +.register-rules ul { margin: 8px 0 0 18px; } +.register-rules li { font-size: 13px; line-height: 1.6; margin-bottom: 4px; color: var(--ink-secondary); } + +/* §16 caveats */ +.caveat-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0; } +.caveat-list li { padding: 14px 18px; background: white; border: 1px solid var(--rule-soft); border-bottom: none; font-size: 14px; line-height: 1.55; color: var(--ink-secondary); } +.caveat-list li:last-child { border-bottom: 1px solid var(--rule-soft); } +.caveat-list strong { color: var(--ink); font-weight: 600; } + +/* §17 dark mode */ +.darkmode-rules { padding: 18px 22px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-proxy); } +.darkmode-rules ul { margin: 8px 0 0 18px; } +.darkmode-rules li { font-size: 13px; line-height: 1.6; margin-bottom: 4px; color: var(--ink-secondary); } + +/* §18 print */ +.print-css-block { font-family: var(--font-mono); font-size: 11px; line-height: 1.55; color: var(--ink); background: #FBF8EF; padding: 18px 22px; border: 1px solid var(--rule-soft); white-space: pre; overflow-x: auto; } + +/* §19 changelog */ +.spec-section-changelog .changelog-list { list-style: none; padding: 0; margin: 0; } +.changelog-row { display: grid; grid-template-columns: 60px 1fr 90px; gap: 16px; padding: 12px 16px; border-bottom: 1px solid var(--rule-soft); font-size: 13px; align-items: center; } +.changelog-row:last-child { border-bottom: none; } +.changelog-n { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.06em; } +.changelog-label { color: var(--ink); } +.changelog-status { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--tier-empirical); text-align: right; } + +/* ════════════════════════════════════════════════════════════════════ + v0.4.3 , The Five Stones UI + §20 taxonomy · §21 trace rework · §22 cold-start · §23 methodology + §24 reusability · §25 a11y · §26 CSS deltas · §27 rationale + ════════════════════════════════════════════════════════════════════ */ + +/* New tokens (Stones-band + warning status) */ +:root { + --status-warning: var(--accent-graphical); + --status-warning-soft: rgba(209, 124, 0, 0.10); + --status-error: #B8620A; + --status-error-soft: rgba(184, 98, 10, 0.08); + --stone-band-rule: var(--ink); + --stone-band-bg: #FBF8EF; + --stone-band-bg-active: #F4EFE5; +} + +/* §20 Banner + Stone strip */ +.v043-banner-frame { padding: 0 0 32px; border-top: 1px solid var(--rule-soft); } +.v043-banner { background: linear-gradient(180deg, #F4EFE5 0%, var(--paper) 100%); border: 1px solid var(--rule-soft); padding: 36px 32px; display: grid; grid-template-columns: 1.1fr 1.4fr; gap: 36px; align-items: start; } +.v043-banner-title { font-family: var(--font-serif); font-size: 30px; font-weight: 600; line-height: 1.18; margin: 8px 0 12px; letter-spacing: -0.01em; } +.v043-banner-deck { font-size: 15px; line-height: 1.62; color: var(--ink-secondary); max-width: 60ch; } +.v043-banner-deck em { font-family: var(--font-serif); font-style: italic; color: var(--ink); } + +.v043-stones-strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 0; border: 1px solid var(--rule-soft); background: white; counter-reset: stone; } +.v043-stones-strip li:nth-child(5) { grid-column: span 2; } +.v043-stone-chip { padding: 14px 16px; border-right: 1px solid var(--rule-soft); border-bottom: 1px solid var(--rule-soft); display: flex; flex-direction: column; gap: 3px; counter-increment: stone; position: relative; } +.v043-stone-chip:nth-child(2n) { border-right: none; } +.v043-stone-chip:nth-last-child(-n+1) { border-bottom: none; } +.v043-stone-chip::before { content: counter(stone, decimal-leading-zero); position: absolute; top: 12px; right: 16px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.06em; } +.v043-stone-name { font-family: var(--font-sans); font-size: 16px; font-weight: 600; color: var(--ink); } +.v043-stone-role { font-family: var(--font-sans); font-size: 12px; color: var(--ink-secondary); letter-spacing: 0.01em; } +.v043-stone-tag { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--ink-tertiary); } + +.v043-toc { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; border: 1px solid var(--rule-soft); margin-bottom: 36px; } +.v043-toc-item { display: flex; gap: 10px; padding: 10px 14px; border-right: 1px solid var(--rule-soft); border-bottom: 1px solid var(--rule-soft); font-size: 13px; color: var(--ink); text-decoration: none; align-items: baseline; background: white; } +.v043-toc-item:nth-child(4n) { border-right: none; } +.v043-toc-item:nth-last-child(-n+4):nth-child(n+5) { border-bottom: none; } +.v043-toc-item:hover { background: #F8F4EA; } +.v043-toc-n { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.06em; min-width: 28px; } + +/* §20 Stones taxonomy table */ +.stones-table { width: 100%; border-collapse: collapse; font-size: 13px; background: white; border: 1px solid var(--rule-soft); } +.stones-table thead th { font-family: var(--font-mono); font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-tertiary); padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--rule-soft); background: #FBF8EF; } +.stones-row td { padding: 14px 12px; vertical-align: top; border-bottom: 1px solid #ECE8DD; } +.stones-row:last-child td { border-bottom: none; } +.stones-row-stone { display: block; font-family: var(--font-sans); font-size: 15px; font-weight: 600; color: var(--ink); } +.stones-row-tag { display: block; font-family: var(--font-serif); font-style: italic; font-size: 12px; color: var(--ink-tertiary); margin-top: 2px; } +.stones-row-role { font-family: var(--font-sans); color: var(--ink-secondary); white-space: nowrap; } +.stones-row-posture { line-height: 1.55; color: var(--ink); } +.stones-row-count { white-space: nowrap; } +.stones-row-num { font-family: var(--font-serif); font-size: 22px; font-weight: 600; color: var(--ink); margin-right: 6px; } +.stones-row-numlabel { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); } +.stones-row-reuse { font-size: 12px; color: var(--ink-secondary); line-height: 1.5; max-width: 28ch; } +.stones-row-reuse-muted { color: var(--ink-tertiary); font-style: italic; } + +.stones-pullquote { font-family: var(--font-serif); font-style: italic; font-size: 22px; line-height: 1.45; color: var(--ink); padding: 28px 32px; margin: 28px 0 0; border-left: 3px solid var(--ink); background: #FBF8EF; max-width: 64ch; } + +/* §21 Treatment tabs */ +.treatment-tabs { display: flex; border: 1px solid var(--rule-soft); margin-bottom: 0; } +.treatment-tab { font-family: var(--font-sans); font-size: 12px; font-weight: 500; padding: 12px 18px; background: white; border: none; border-right: 1px solid var(--rule-soft); cursor: pointer; flex: 1; color: var(--ink-secondary); letter-spacing: 0.02em; text-align: left; } +.treatment-tab:last-child { border-right: none; } +.treatment-tab.is-active { background: var(--ink); color: var(--paper); } +.treatment-tab:hover:not(.is-active) { background: #F8F4EA; color: var(--ink); } + +.treatment-frame { background: white; border: 1px solid var(--rule-soft); padding: 28px 32px; display: grid; grid-template-columns: 1fr 1.4fr; gap: 32px; align-items: start; } +.treatment-frame-stacked { grid-template-columns: 1fr; gap: 24px; margin-bottom: 18px; } +.treatment-meta { display: flex; flex-direction: column; gap: 12px; } +.treatment-deck { font-size: 14px; line-height: 1.6; color: var(--ink-secondary); } +.treatment-deck code { font-family: var(--font-mono); font-size: 12px; background: #FBF8EF; padding: 1px 4px; border: 1px solid var(--rule-soft); } +.treatment-tradeoffs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; line-height: 1.55; } +.treatment-tradeoffs li { color: var(--ink-secondary); padding-left: 18px; position: relative; } +.treatment-tradeoffs strong { position: absolute; left: 0; font-family: var(--font-mono); color: var(--ink); } + +.treatment-rec { background: white; border: 1px solid var(--rule-soft); padding: 32px 36px; } +.treatment-rec-title { font-family: var(--font-serif); font-size: 26px; font-weight: 600; margin: 8px 0 14px; } +.treatment-rec p { font-family: var(--font-serif); font-size: 15px; line-height: 1.6; color: var(--ink); max-width: 64ch; margin: 0 0 12px; } +.treatment-rec p code { font-family: var(--font-mono); font-size: 12px; background: #FBF8EF; padding: 1px 5px; border: 1px solid var(--rule-soft); font-weight: 500; } +.treatment-rec p em { font-family: var(--font-serif); font-style: italic; color: var(--ink-secondary); } +.treatment-rec-rules { list-style: none; padding: 16px 20px; margin: 16px 0 0; background: #FBF8EF; border-left: 3px solid var(--ink); display: flex; flex-direction: column; gap: 8px; max-width: 64ch; } +.treatment-rec-rules li { font-size: 13px; line-height: 1.55; color: var(--ink-secondary); } + +/* §21 Stone band (Treatment A) */ +.trace-ui-v43-a { background: var(--paper); border: 1px solid var(--rule-soft); } +.trace-ui-v43-a .trace-head { padding: 14px 18px; border-bottom: 1px solid var(--rule-soft); display: flex; justify-content: space-between; align-items: center; } +.trace-head-grouping { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.06em; } +.trace-head-warning { color: var(--status-warning); } +.trace-head-error { color: var(--status-error); } + +.stone-band { border-top: 1.5px solid var(--stone-band-rule); background: white; } +.stone-band:first-of-type { border-top: none; } +.stone-band-head { display: grid; grid-template-columns: 18px minmax(180px, auto) 1fr auto; gap: 14px; padding: 14px 18px; background: var(--stone-band-bg); font-family: var(--font-sans); border: none; width: 100%; text-align: left; cursor: pointer; align-items: baseline; } +.stone-band.is-open .stone-band-head { background: var(--stone-band-bg-active); } +.stone-band-head:hover { background: #EFE9DA; } +.stone-band-toggle { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); } +.stone-band-name { display: flex; align-items: baseline; gap: 4px; } +.stone-band-stone { font-weight: 600; font-size: 15px; color: var(--ink); } +.stone-band-role { color: var(--ink-secondary); font-size: 13px; } +.stone-band-tag { font-family: var(--font-serif); font-style: italic; color: var(--ink-tertiary); font-size: 13px; } +.stone-band-agg { font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); letter-spacing: 0.04em; white-space: nowrap; } +.stone-agg-fired { color: var(--ink); } +.stone-agg-silent { color: var(--ink-tertiary); } +.stone-agg-warn { color: var(--status-warning); font-weight: 600; } +.stone-agg-error { color: var(--status-error); font-weight: 600; } +.stone-agg-ms { color: var(--ink-tertiary); } +.stone-agg-sep { color: var(--ink-tertiary); margin: 0 6px; } +.stone-band-body { padding: 8px 0 14px; } +.stone-band-body .trace-row { padding-top: 6px; padding-bottom: 6px; } + +/* shared specialist row in v0.4.3 */ +.trace-ui-v43-a .trace-row, .trace-ui-v43-b .trace-row { + display: grid; grid-template-columns: 16px 16px 1fr auto auto; gap: 10px; align-items: baseline; + font-family: var(--font-sans); font-size: 13px; +} +.trace-ui-v43-a .trace-row .trace-name, .trace-ui-v43-b .trace-row .trace-name { + font-family: var(--font-mono); font-size: 12px; color: var(--ink); +} +.trace-row-warning { background: var(--status-warning-soft); border-left: 2px solid var(--status-warning); } +.trace-row-error { background: var(--status-error-soft); border-left: 2px solid var(--status-error); } +.trace-warning-tag { color: var(--status-warning); font-weight: 600; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; } +.trace-error-tag { color: var(--status-error); font-weight: 600; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; } +.trace-warning-summary { color: var(--status-warning); font-style: italic; font-family: var(--font-sans); font-size: 12px; } +.trace-error-summary { color: var(--status-error); font-style: italic; font-family: var(--font-sans); font-size: 12px; } +.trace-error-trace { font-family: var(--font-mono); font-size: 11px; line-height: 1.55; color: var(--status-error); background: var(--status-error-soft); padding: 12px 16px; margin: 4px 18px 8px; border-left: 2px solid var(--status-error); white-space: pre; overflow-x: auto; } + +.trace-ttm-group { padding: 6px 0; } +.trace-ttm-group > summary { display: grid; grid-template-columns: 16px 16px 1fr auto; gap: 10px; align-items: baseline; cursor: pointer; padding: 4px 0; list-style: none; } +.trace-ttm-group > summary::-webkit-details-marker { display: none; } +.trace-ttm-group > summary .trace-name { font-family: var(--font-mono); font-size: 12px; } + +/* §21 Treatment B , rule-marked groups */ +.trace-ui-v43-b .trace-body-flat { padding: 14px 18px; } +.trace-ui-v43-b .trace-col-heads { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; padding: 6px 0 8px; border-bottom: 1px solid var(--rule-soft); font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.06em; } +.stone-rule-group { border-top: 2px solid var(--ink); padding-top: 10px; margin-top: 14px; } +.stone-rule-group:first-of-type { border-top: 1px solid var(--rule-soft); margin-top: 6px; padding-top: 8px; } +.stone-rule-marker { display: flex; align-items: baseline; gap: 10px; padding: 4px 0 8px; flex-wrap: wrap; } +.stone-rule-name { font-family: var(--font-sans); font-size: 14px; font-weight: 600; color: var(--ink); } +.stone-rule-role { font-family: var(--font-serif); font-style: italic; color: var(--ink-tertiary); font-size: 12px; } +.stone-rule-agg { font-family: var(--font-mono); font-size: 10px; color: var(--ink-secondary); letter-spacing: 0.04em; margin-left: auto; } + +/* §22 cold-start (in-app) thesis line */ +.cold-start-thesis { font-family: var(--font-sans); font-size: 15px; line-height: 1.55; color: var(--ink); margin: 6px 0 14px; padding-bottom: 12px; border-bottom: 1px solid var(--rule-soft); } +.cold-start-thesis strong { font-weight: 600; } + +/* §22 cold-start v43 spec mock */ +.cold-v43-frame { padding: 28px; background: var(--paper); border: 1px solid var(--rule-soft); margin-bottom: 18px; } +.cold-v43-mock { background: white; border: 1px solid var(--rule-soft); padding: 24px 28px; max-width: 600px; } +.cold-v43-thesis { font-family: var(--font-sans); font-size: 16px; line-height: 1.55; color: var(--ink); margin: 8px 0 14px; padding: 14px 0; border-top: 1px solid var(--rule-soft); border-bottom: 1px solid var(--rule-soft); } +.cold-v43-thesis strong { font-weight: 600; } +.cold-v43-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; } +.cold-v43-list li { font-size: 13px; line-height: 1.55; color: var(--ink-secondary); padding-left: 14px; position: relative; } +.cold-v43-list li::before { content: "·"; position: absolute; left: 0; color: var(--ink); font-weight: 700; } +.cold-v43-list a { color: var(--accent-graphical); text-decoration: none; border-bottom: 1px solid var(--accent-graphical); } +.cold-v43-rules { padding: 18px 22px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-empirical); } +.cold-v43-rules ul { margin: 8px 0 0 18px; } +.cold-v43-rules li { font-size: 13px; line-height: 1.6; margin-bottom: 4px; color: var(--ink-secondary); } + +/* §23 methodology + 5×4 matrix */ +.methodology-toc { list-style: none; padding: 0; margin: 0 0 36px; counter-reset: section; } +.methodology-toc li { padding: 12px 16px; background: white; border: 1px solid var(--rule-soft); border-bottom: none; font-size: 14px; line-height: 1.55; color: var(--ink-secondary); } +.methodology-toc li:last-child { border-bottom: 1px solid var(--rule-soft); } +.methodology-toc strong { color: var(--ink); } + +.tier-stone-matrix { background: white; border: 1px solid var(--rule-soft); padding: 24px 28px; margin: 0; } +.tier-stone-matrix table { width: 100%; border-collapse: collapse; } +.tier-stone-matrix thead th { font-family: var(--font-mono); font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-tertiary); padding: 12px 8px; text-align: center; border-bottom: 1px solid var(--ink); } +.tier-stone-matrix thead th:first-child { text-align: left; } +.tier-stone-matrix tbody th { text-align: left; padding: 14px 12px; border-bottom: 1px solid #ECE8DD; vertical-align: top; } +.matrix-stone-name { display: block; font-family: var(--font-sans); font-size: 14px; font-weight: 600; color: var(--ink); } +.matrix-stone-role { display: block; font-family: var(--font-serif); font-style: italic; font-size: 11px; color: var(--ink-tertiary); margin-top: 2px; } +.matrix-cell { text-align: center; padding: 14px 8px; border-bottom: 1px solid #ECE8DD; vertical-align: middle; } +.matrix-cell-mark { display: flex; align-items: center; justify-content: center; min-height: 18px; } +.matrix-cell-label { display: block; font-family: var(--font-mono); font-size: 9px; color: var(--ink-tertiary); letter-spacing: 0.06em; text-transform: uppercase; margin-top: 4px; } +.matrix-cell-empty .matrix-cell-label { color: #C9C5B6; } +.matrix-cell-passthrough { background: #FBF8EF; } +.matrix-empty { font-family: var(--font-serif); font-size: 16px; color: #C9C5B6; } +.matrix-passthrough { font-family: var(--font-mono); font-size: 14px; color: var(--ink-tertiary); } +.tier-stone-matrix-cap { font-family: var(--font-serif); font-size: 13px; line-height: 1.55; color: var(--ink-secondary); padding-top: 16px; margin-top: 16px; border-top: 1px solid var(--rule-soft); max-width: 80ch; } + +/* §24 reusability */ +.reuse-statement { font-family: var(--font-serif); font-style: italic; font-size: 18px; line-height: 1.55; color: var(--ink); padding: 24px 28px; margin: 0 0 18px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); max-width: 70ch; } +.reuse-rules { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } +.reuse-rules li { padding: 12px 16px; background: white; border: 1px solid var(--rule-soft); font-size: 13px; line-height: 1.55; color: var(--ink-secondary); } +.reuse-rules strong { color: var(--ink); } + +/* §25 a11y */ +.a11y-v43-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } +.a11y-v43-list li { padding: 14px 18px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-empirical); font-size: 13px; line-height: 1.6; color: var(--ink-secondary); } +.a11y-v43-list strong { color: var(--ink); } +.a11y-v43-list code { font-family: var(--font-mono); font-size: 11px; background: #FBF8EF; padding: 1px 4px; border: 1px solid var(--rule-soft); } +.a11y-v43-list em { font-family: var(--font-serif); font-style: italic; color: var(--ink); } + +/* §26 css deltas */ +.css-delta-block { font-family: var(--font-mono); font-size: 11px; line-height: 1.55; color: var(--ink); background: #FBF8EF; padding: 18px 22px; border: 1px solid var(--rule-soft); white-space: pre; overflow-x: auto; } + +/* §27 rationale (reuses existing if present, else light variant) */ +.spec-section-rationale .rationale-quote { font-family: var(--font-serif); font-size: 16px; line-height: 1.65; color: var(--ink); padding: 28px 32px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); margin: 0 0 12px; max-width: 70ch; } +.spec-section-rationale .rationale-quote p { margin: 0 0 14px; } +.spec-section-rationale .rationale-quote p:last-child { margin: 0; } +.spec-section-rationale .rationale-cite { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.04em; } + +/* PDF artifact link , pre-rendered, not a client export action */ +.app-header-pdf { + display: inline-flex; align-items: baseline; gap: 6px; + font-family: var(--font-mono); font-size: 11px; + color: var(--ink); padding: 4px 10px; + border: 1px solid var(--rule-soft); background: white; + letter-spacing: 0.02em; +} +.app-header-pdf-icon { font-size: 12px; color: var(--accent-graphical); } +.app-header-pdf-meta { font-size: 9px; color: var(--ink-tertiary); letter-spacing: 0.06em; text-transform: uppercase; } + +/* Mobile brief title fallback */ +@media (max-width: 720px) { + .brief-h1 { grid-template-columns: 1fr; } + .brief-h1-meta { grid-column: 1; grid-row: 3; text-align: left; align-items: flex-start; } + .brief-h1-meta-row { justify-content: flex-start; } + .brief-h1-addr { font-size: 24px; } +} + .v043-stones-strip { grid-template-columns: 1fr; } + .v043-stones-strip li:nth-child(5) { grid-column: span 1; } + .v043-stone-chip:nth-child(2n) { border-right: none; border-bottom: 1px solid var(--rule-soft); } + .v043-toc { grid-template-columns: 1fr 1fr; } + .stones-table thead { display: none; } + .stones-table tbody td { display: block; padding: 6px 12px; } + .stones-row td { border-bottom: none; } + .stones-row { display: block; padding: 14px 0; border-bottom: 1px solid #ECE8DD; } + .treatment-tabs { flex-direction: column; } + .treatment-tab { border-right: none; border-bottom: 1px solid var(--rule-soft); } + .treatment-frame { grid-template-columns: 1fr; padding: 18px; } + .stone-band-head { grid-template-columns: 16px 1fr; gap: 8px; } + .stone-band-tag { grid-column: 2; } + .stone-band-agg { grid-column: 2; } + .tier-stone-matrix { padding: 16px; } + .tier-stone-matrix thead th { font-size: 9px; padding: 6px 4px; } + .tier-stone-matrix tbody th { padding: 10px 6px; } + .matrix-cell { padding: 10px 4px; } + .matrix-stone-name { font-size: 12px; } + .stones-pullquote { font-size: 17px; padding: 18px 20px; } +} + +@media (prefers-reduced-motion: reduce) { + .stone-band, .stone-band-head, .treatment-tab { transition: none; } +} + +/* ============================================================ + v0.4.5 — Stone accent layer + new status enum rules + Hint-only treatment: a 3-px left-rule per Stone, scoped to + .stone-band[data-stone] and .f-region[data-stone]. Tier + swatches inside cards/legend rows are unchanged. + See V0.4.5_SPEC.md §2 for the full rationale. + ============================================================ */ + +/* 3-px left rule on Stone-banded sections */ +.stone-band[data-stone="cornerstone"] { border-left: 3px solid var(--stone-cornerstone); } +.stone-band[data-stone="keystone"] { border-left: 3px solid var(--stone-keystone); } +.stone-band[data-stone="touchstone"] { border-left: 3px solid var(--stone-touchstone); } +.stone-band[data-stone="lodestone"] { border-left: 3px solid var(--stone-lodestone); } +.stone-band[data-stone="capstone"] { border-left: 3px solid var(--stone-capstone); } + +.f-region[data-stone="cornerstone"] { border-left: 3px solid var(--stone-cornerstone); } +.f-region[data-stone="keystone"] { border-left: 3px solid var(--stone-keystone); } +.f-region[data-stone="touchstone"] { border-left: 3px solid var(--stone-touchstone); } +.f-region[data-stone="lodestone"] { border-left: 3px solid var(--stone-lodestone); } +.f-region[data-stone="capstone"] { border-left: 3px solid var(--stone-capstone); } + +/* Map Layers panel — Stone group dots + soft inset rule */ +.map-legend-stone { + border-left: 2px solid transparent; + padding-left: 10px; + margin-bottom: 8px; +} +.map-legend-stone-cornerstone { border-left-color: var(--stone-cornerstone); } +.map-legend-stone-keystone { border-left-color: var(--stone-keystone); } +.map-legend-stone-touchstone { border-left-color: var(--stone-touchstone); } +.map-legend-stone-lodestone { border-left-color: var(--stone-lodestone); } +.map-legend-stone-capstone { border-left-color: var(--stone-capstone); } +.map-legend-stone-head { + display: flex; align-items: baseline; gap: 6px; + margin: 8px 0 4px; + font-family: var(--font-sans); font-size: 11px; letter-spacing: 0.04em; +} +.map-legend-stone-dot { + width: 8px; height: 8px; border-radius: 50%; + display: inline-block; align-self: center; +} +.map-legend-stone-dot-cornerstone { background: var(--stone-cornerstone); } +.map-legend-stone-dot-keystone { background: var(--stone-keystone); } +.map-legend-stone-dot-touchstone { background: var(--stone-touchstone); } +.map-legend-stone-dot-lodestone { background: var(--stone-lodestone); } +.map-legend-stone-dot-capstone { background: var(--stone-capstone); } +.map-legend-stone-name { font-weight: 600; color: var(--ink); text-transform: uppercase; letter-spacing: 0.06em; } +.map-legend-stone-role { color: var(--ink-tertiary); font-size: 11px; font-style: italic; font-family: var(--font-serif); } + +/* Cold-start thesis row — colored dots beside Stone names */ +.cold-start-thesis-stone-dot { + width: 7px; height: 7px; border-radius: 50%; + display: inline-block; vertical-align: middle; + margin-right: 5px; transform: translateY(-1px); +} +.cold-start-thesis-stone-dot-cornerstone { background: var(--stone-cornerstone); } +.cold-start-thesis-stone-dot-keystone { background: var(--stone-keystone); } +.cold-start-thesis-stone-dot-touchstone { background: var(--stone-touchstone); } +.cold-start-thesis-stone-dot-lodestone { background: var(--stone-lodestone); } +.cold-start-thesis-stone-dot-capstone { background: var(--stone-capstone); } + +/* New status enum — replaces 0.4.4 anomaly/silent/warn/error rules. + v0.4.5 statuses: fired, silent_by_design, warned, errored, not_invoked. */ +.trace-row { padding: 8px 0; font-family: var(--font-mono); font-size: 12px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.trace-row .trace-name { color: var(--ink); } +.trace-row .trace-status { font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-tertiary); } +.trace-row .trace-tier { font-size: 10px; letter-spacing: 0.04em; } +.trace-row .trace-ms { color: var(--ink-tertiary); margin-left: auto; } +.trace-row .trace-bullet { width: 12px; display: inline-block; text-align: center; } + +.trace-row-fired { /* default — bullet color carries tier */ } + +.trace-row-silent-bd .trace-name { color: var(--ink-secondary); } +.trace-row-silent-bd .trace-bullet-silent { color: var(--ink-tertiary); } +.trace-row-silent-bd .trace-silent-note { color: var(--ink-tertiary); font-style: italic; font-family: var(--font-serif); font-size: 12px; } + +.trace-row-not-invoked .trace-name { color: var(--ink-tertiary); } +.trace-row-not-invoked .trace-bullet-notinvoked { color: var(--ink-tertiary); } + +.trace-row-warned { + background: var(--status-warning-soft); + /* keep stone left-rule; do NOT set border-left here */ + position: relative; + padding-left: 8px; +} +.trace-row-warned .trace-status-warn { color: var(--status-warning); font-weight: 600; } +.trace-row-warned .trace-warn-sidemark { + color: var(--status-warning); + font-weight: 700; + margin-left: 4px; +} +.trace-row-warned .trace-warn-note { color: var(--ink-secondary); font-family: var(--font-serif); font-style: italic; font-size: 12px; } + +.trace-row-errored { + background: var(--status-error-soft); + padding-left: 8px; +} +.trace-row-errored > summary { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; cursor: pointer; list-style: none; } +.trace-row-errored > summary::-webkit-details-marker { display: none; } +.trace-row-errored .trace-status-err { color: var(--status-error); font-weight: 600; } +.trace-row-errored .trace-error-summary { color: var(--ink-secondary); font-style: italic; font-family: var(--font-serif); font-size: 12px; } +.trace-row-errored .trace-error-expand { color: var(--ink-tertiary); font-size: 10px; letter-spacing: 0.04em; text-transform: uppercase; } +.trace-row-errored[open] .trace-error-expand { display: none; } +.trace-row-errored .trace-error-body { + margin-top: 6px; + padding: 8px 10px; + background: rgba(255,255,255,0.6); + border-left: 2px solid var(--status-error); + font-size: 11px; + display: grid; gap: 4px; +} +.trace-error-line { display: grid; grid-template-columns: 80px 1fr; gap: 8px; } +.trace-error-k { color: var(--ink-tertiary); } + +/* Tally text colors */ +.f-tally-warn { color: var(--status-warning); } +.f-tally-err { color: var(--status-error); } +.f-tally-notinvoked { color: var(--ink-tertiary); } + +/* Hover link — when an evidence card is "linked" to a hovered map glyph */ +.f-card.is-linked, +.finding-card.is-linked { + outline: 2px solid var(--ink); + outline-offset: 2px; +} + +/* TerraMind LULC class-mix bar (Touchstone synthetic-prior card) */ +.lulc-bar { display: flex; height: 12px; border-radius: 2px; overflow: hidden; margin-top: 6px; } +.lulc-bar-seg { height: 100%; } +.lulc-legend { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-top: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); } +.lulc-legend-swatch { display: inline-block; width: 8px; height: 8px; margin-right: 4px; border-radius: 1px; vertical-align: middle; } + +/* Fine-tuned TTM trim line + hardware badge */ +.ttm-ft-trim { border-top: 1px solid var(--rule-soft); margin-top: 10px; padding-top: 8px; display: grid; gap: 4px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); } +.ttm-ft-row { display: grid; grid-template-columns: 110px 1fr; gap: 8px; } +.ttm-ft-k { color: var(--ink-tertiary); } +.ttm-hw-badge { + display: inline-block; padding: 1px 6px; border: 1px solid var(--rule-soft); + border-radius: 2px; font-size: 10px; letter-spacing: 0.04em; + font-family: var(--font-mono); color: var(--ink-secondary); background: white; +} + +/* Print: drop accents to neutral, drop hover/error tints */ +@media print { + .stone-band[data-stone], + .f-region[data-stone], + .map-legend-stone { border-left-color: #999 !important; } + .cold-start-thesis-stone-dot, + .map-legend-stone-dot { background: #999 !important; } + .trace-row-warned, .trace-row-errored { background: transparent !important; } +} + +/* ─────────── original v0.4.2 mobile rules ────────── */ +@media (max-width: 720px) { + .app-header-inner { grid-template-columns: 1fr; gap: 8px; } + .app-header-mid, .app-header-right { justify-content: flex-start; } + .app-header-query { min-width: 0; width: 100%; } + .hero-band-inner { padding: 20px 16px 32px; } + .spec-band-inner { padding: 40px 16px 60px; } + .spec-band-title { font-size: 28px; } + .spec-section-title { font-size: 24px; } + .brief-h1 { font-size: 26px; } + .map-legend { width: calc(100% - 24px); } + .palette-row { grid-template-columns: 48px 1fr; gap: 12px; row-gap: 4px; } + .palette-row > *:nth-child(n+3) { grid-column: 2; } + .a11y-row { grid-template-columns: 1fr; gap: 4px; } + + /* v0.4.2 mobile */ + .v042-banner-inner { grid-template-columns: 1fr; gap: 24px; } + .v042-toc { grid-template-columns: 1fr; } + .v042-toc-item { border-right: none !important; } + .v042-banner-title { font-size: 24px; } + .loading-grid { grid-template-columns: 1fr; } + .error-grid { grid-template-columns: 1fr; } + .stripe-grid { grid-template-columns: 1fr; } + .guardian-tabs { flex-direction: column; } + .guardian-tab { border-right: none !important; border-bottom: 1px solid var(--rule); flex: none; } + .guardian-tab:last-child { border-bottom: none; } + .guardian-card { padding: 24px 22px; } + .guardian-title { font-size: 20px; } + .register-card { padding: 18px; } + .register-table { font-size: 11px; } + .register-table thead th { padding: 4px; font-size: 9px; } + .register-row td { padding: 8px 4px; } + .register-detail-grid { grid-template-columns: 1fr 1fr; } + .register-card-count { font-size: 26px; } + .changelog-row { grid-template-columns: 50px 1fr; } + .changelog-status { grid-column: 2; text-align: left; padding-top: 2px; } +} diff --git a/docs/design_handoff/design_files/tokens.css b/docs/design_handoff/design_files/tokens.css new file mode 100644 index 0000000000000000000000000000000000000000..6bc1d5601b29e5401721af71835180831390e642 --- /dev/null +++ b/docs/design_handoff/design_files/tokens.css @@ -0,0 +1,171 @@ +/* Riprap design tokens + Civic-tech-clean. SIL OFL / Apache fonts only. WCAG 2.2 AA verified. +*/ + +:root { + /* ── Epistemic tier colors ───────────────────────────────────────── + Refined from starter palette to meet 4.5:1 against white for body + text where the tier color is also used as inline citation/label color. + Originals: #3D85C6 = 3.04:1 (fail body), #999999 = 2.85:1 (fail). + Refined values verified via WebAIM contrast checker, deutan/protan/ + tritan colorblind-safe (tier is also encoded by glyph + label). + */ + --tier-empirical: #0B5394; /* 8.59:1 vs white ✓ AAA */ + --tier-empirical-fill: rgba(11, 83, 148, 0.40); + --tier-empirical-line: #0B5394; + + --tier-modeled: #2A6FA8; /* 5.41:1 vs white ✓ AA was #3D85C6 (3.04:1 fail) */ + --tier-modeled-fill: rgba(42, 111, 168, 0.25); + --tier-modeled-line: #2A6FA8; + + --tier-proxy: #6B6B6B; /* 5.74:1 vs white ✓ AA was #999999 (2.85:1 fail) */ + --tier-proxy-fill: transparent; + --tier-proxy-line: #6B6B6B; + + --tier-synthetic: #2A6FA8; /* same hue as modeled , pattern carries the difference */ + --tier-synthetic-fill: rgba(42, 111, 168, 0.25); + --tier-synthetic-line: #2A6FA8; + + /* ── Reference + accent ── */ + --reference-bg: #E8E8E6; + --reference-line: #C9C9C5; + --accent: #B8620A; /* 4.93:1 vs white ✓ AA adjusted from #D17C00 (3.36:1) */ + --accent-graphical: #D17C00; /* keep original for graphical use only (3.36:1 ≥ 3:1) */ + + /* ── Neutrals (paper register) ── */ + --paper: #FAFAF7; /* warm near-white, USGS report register */ + --paper-deep: #F2F2EE; + --ink: #1A1A1A; /* 18.5:1 ✓ AAA */ + --ink-secondary: #4A4A4A; /* 9.7:1 ✓ AAA */ + --ink-tertiary: #6B6B6B; /* 5.74:1 ✓ AA */ + --rule: #1A1A1A; + --rule-soft: #C9C9C5; + + /* ── Type ── */ + --font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace; + --font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif; + + /* ── Scale ── */ + --measure: 70ch; + --leading-prose: 1.55; + --leading-tight: 1.25; + + /* ── Stone accent tokens (v0.4.5) ── + Five muted hint-colors keyed to Stones. L≈45 OKLCH, chroma ≤0.04. + Hint-level decoration; never competes with the four-tier epistemic palette. + All five degrade to neutral gray in @media print (see below). */ + --stone-cornerstone: #7C6F5E; /* warm taupe */ + --stone-keystone: #5E6E7C; /* cool slate */ + --stone-touchstone: #6B7C66; /* muted sage */ + --stone-lodestone: #7C6E5E; /* softened ochre */ + --stone-capstone: #5E5E6E; /* neutral indigo-gray */ + + /* ── Spacing ── */ + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + --s-7: 48px; + --s-8: 64px; + --s-9: 96px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--font-sans); + font-size: 16px; + line-height: var(--leading-prose); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* High-contrast focus rings, brief-spec'd */ +:focus-visible { + outline: 3px solid var(--accent-graphical); + outline-offset: 2px; + border-radius: 1px; +} + +@media print { + :root { + --stone-cornerstone: #999; + --stone-keystone: #999; + --stone-touchstone: #999; + --stone-lodestone: #999; + --stone-capstone: #999; + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ── Wordmark ─────────────────────────────────────────────────────── */ +.riprap-wordmark { + font-family: var(--font-mono); + font-weight: 600; + font-size: 14px; + letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--ink); + display: inline-flex; + align-items: baseline; + gap: 0; +} +.riprap-wordmark::before { + content: "▌"; + color: var(--accent-graphical); + margin-right: 4px; + font-size: 0.85em; + position: relative; + top: 1px; +} + +/* ── Skip link ── */ +.skip-link { + position: absolute; + left: -9999px; + top: 8px; + padding: 8px 12px; + background: var(--ink); + color: var(--paper); + font-family: var(--font-mono); + font-size: 13px; + z-index: 1000; +} +.skip-link:focus { + left: 8px; +} + +/* ── Generic section labels ── */ +.section-label { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-tertiary); +} + +.rule-thin { + border: 0; + border-top: 1px solid var(--rule-soft); + margin: 0; +} +.rule-heavy { + border: 0; + border-top: 2px solid var(--ink); + margin: 0; +} diff --git a/docs/design_handoff/design_files/trace.jsx b/docs/design_handoff/design_files/trace.jsx new file mode 100644 index 0000000000000000000000000000000000000000..69f385fc7f88998d559d1940660c0a193b8e8e28 --- /dev/null +++ b/docs/design_handoff/design_files/trace.jsx @@ -0,0 +1,166 @@ +/* Trace UI:
-based tree of the Burr FSM run. + Three columns: action name · elapsed ms · epistemic-tier badge. + Parallel branches shown as sibling rows under a "fan-out" parent; + convergence step shown as a "merge" node. Reference: Postgres + EXPLAIN ANALYZE viewers, Apache Airflow DAG. +*/ + +const TRACE = { + id: "root", + name: "briefing.run", + status: "ok", + ms: 14820, + tier: null, + children: [ + { id: "n1", name: "geocode.address", status: "ok", ms: 142, tier: null, + output: { lat: 40.6776, lon: -74.0096, bbl: "3005970030" } }, + { id: "n2", name: "fan_out.stones", status: "fan", ms: 0, tier: null, + note: "5 Stones engaged in parallel", + children: [ + { id: "s1", name: "sandy_inundation.lookup", status: "ok", ms: 380, tier: "empirical", + claims: 2, output: "polygon: contains; nearest HWM 0.4mi" }, + { id: "s2", name: "floodnet.history", status: "ok", ms: 1240, tier: "empirical", + claims: 1, output: "BK-RH-002: 7 events, peak 14.3cm" }, + { id: "s3", name: "usgs.high_water_marks", status: "ok", ms: 612, tier: "empirical", + claims: 1, output: "9 marks within 500ft" }, + { id: "s4", name: "fema.firm.preliminary", status: "ok", ms: 488, tier: "modeled", + claims: 1, output: "Zone AE, BFE 11ft NAVD88" }, + { id: "s5", name: "dep.stormwater.scenario", status: "ok", ms: 2104, tier: "modeled", + claims: 1, output: "moderate: ponding ≥4in W half" }, + { id: "s6", name: "npcc4.slr.projection", status: "ok", ms: 320, tier: "modeled", + claims: 1, output: "2050 90th: +30in" }, + { id: "s7", name: "nyc311.flood_complaints", status: "ok", ms: 980, tier: "proxy", + claims: 1, output: "89 calls / tract / 2019–25" }, + { id: "s8", name: "nfip.claims_aggregate", status: "ok", ms: 540, tier: "proxy", + claims: 1, output: "$4.1M / 47 paid losses" }, + { id: "s9", name: "terramind.synthetic_sar", status: "ok", ms: 6840, tier: "synthetic", + claims: 1, output: "synthesis confidence 0.71" }, + { id: "s10", name: "tidal_gauge.range", status: "silent", ms: 18, tier: null, + claims: 0, output: "out of range: nearest gauge >2mi" }, + { id: "s11", name: "wrp.coastal_risk_area", status: "ok", ms: 210, tier: "modeled", + claims: 1, output: "within Coastal Risk Area" }, + ] + }, + { id: "n3", name: "merge.evidence", status: "merge", ms: 92, tier: null, + note: "10 cards · 1 silent · 0 errors" }, + { id: "n4", name: "compose.briefing", status: "ok", ms: 1380, tier: null, + output: "4 sections · 11 claims · 10 citations" }, + { id: "n5", name: "stream.sse", status: "ok", ms: 4940, tier: null, + output: "1812 tokens · 11 sentence chunks" }, + ] +}; + +const StatusGlyph = ({ status }) => { + const map = { + ok: { fill: "#0B5394", char: "" }, + silent: { fill: "transparent", stroke: "#6B6B6B", char: "" }, + error: { fill: "#B8620A", char: "!" }, + fan: { char: "⤳" }, + merge: { char: "⤺" }, + }; + const m = map[status]; + if (status === "fan" || status === "merge") { + return {m.char}; + } + return ( + + + + ); +}; + +const TraceRow = ({ node, depth = 0, defaultOpen = false }) => { + const hasChildren = node.children && node.children.length > 0; + const [open, setOpen] = useState(defaultOpen); + const indent = depth * 16; + + return ( + <> +
+ + {open && node.output && ( +
+ + + {typeof node.output === "string" ? node.output : JSON.stringify(node.output)} + + {node.claims != null && ( + {node.claims} claim{node.claims === 1 ? "" : "s"} cited + )} +
+ )} +
+ {open && hasChildren && node.children.map((c) => ( + + ))} + + ); +}; + +const TraceUI = ({ collapsed, onToggleCollapsed }) => { + return ( +
+
+
+ Run trace + + 14.82s total + · + 10 fired + · + 1 silent + · + 0 errors + +
+ +
+ {!collapsed && ( +
+
+ action + elapsed + tier +
+
+ +
+
+ )} +
+ ); +}; + +Object.assign(window, { TraceUI }); diff --git a/docs/design_handoff/design_files/tweaks-panel.jsx b/docs/design_handoff/design_files/tweaks-panel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5f8f95a1ece0d4a17fd85a011a92d0610abc700a --- /dev/null +++ b/docs/design_handoff/design_files/tweaks-panel.jsx @@ -0,0 +1,425 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); + const idx = Math.max(0, opts.findIndex((o) => o.value === value)); + const n = opts.length; + + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + const segAt = (clientX) => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor(((clientX - r.left - 2) / inner) * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + + const onPointerDown = (e) => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = (ev) => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + + return ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/web/main.py b/web/main.py index 159921db67b297e101bee2122aba8083831487ac..adf2dccee986a924dc803dc969050407a3647f74 100644 --- a/web/main.py +++ b/web/main.py @@ -26,12 +26,17 @@ from app.stones import capstone as _capstone_stone # noqa: E402 # nta_resolve and friends) don't open a Stone boundary — they're # orientation / policy infrastructure shared across Stones. _STEP_TO_STONE: dict[str, str] = { - # Cornerstone + # Cornerstone — single_address + polygon-aggregated (neighborhood) "sandy_inundation": "Cornerstone", "dep_stormwater": "Cornerstone", "ida_hwm_2021": "Cornerstone", "prithvi_eo_v2": "Cornerstone", "microtopo_lidar": "Cornerstone", + "sandy_nta": "Cornerstone", + "dep_extreme_2080_nta": "Cornerstone", + "dep_moderate_2050_nta": "Cornerstone", + "dep_moderate_current_nta": "Cornerstone", + "microtopo_nta": "Cornerstone", # Keystone (the chip fetch is infrastructure for the LoRA pair, but # it's logically Keystone-adjacent and we surface it under that # banner so the trace doesn't show a phantom orphan step). @@ -49,6 +54,7 @@ _STEP_TO_STONE: dict[str, str] = { "noaa_tides": "Touchstone", "prithvi_eo_live": "Touchstone", "terramind_lulc": "Touchstone", + "nyc311_nta": "Touchstone", # Lodestone "nws_alerts": "Lodestone", "ttm_forecast": "Lodestone", @@ -306,14 +312,14 @@ async def api_backend(): @app.get("/") def index(): - """SvelteKit cold-start page (the new design-system UI). Falls back to - the legacy custom-element agent.html if the SvelteKit build hasn't been - compiled yet — that lets `uvicorn` boot in a fresh checkout without a - Node toolchain present.""" + """SvelteKit landing page (the new design-system UI).""" sk = SVELTEKIT_BUILD / "index.html" if sk.exists(): return FileResponse(sk) - return FileResponse(STATIC / "agent.html") + return JSONResponse( + {"error": "sveltekit build not present — run `cd web/sveltekit && npm run build`"}, + status_code=503, + ) @app.get("/q/sample") @@ -346,39 +352,11 @@ def print_page(query_id: str): # noqa: ARG001 — captured by the SPA router return JSONResponse({"error": "sveltekit build not present"}, status_code=503) -@app.get("/legacy") -def legacy_index(): - """Original custom-element agent page, preserved for fallback / debugging.""" - return FileResponse(STATIC / "agent.html") - - -@app.get("/single") -def single_address_page(): - return FileResponse(STATIC / "index.html") - - -@app.get("/compare") -def compare_page(): - return FileResponse(STATIC / "compare.html") - - -@app.get("/agent") -def agent_page(): - return FileResponse(STATIC / "agent.html") - - -@app.get("/report") -def report_page(): - """Print-ready auditable report. Reads the prior agent run from - the browser's sessionStorage; fully client-side render.""" - return FileResponse(STATIC / "report.html") - - -@app.get("/register/{asset_class}") -def register_page(asset_class: str): - if asset_class not in ("schools", "nycha", "mta_entrances"): - return JSONResponse({"error": f"unknown asset class {asset_class!r}"}, status_code=404) - return FileResponse(STATIC / "register.html") +# Legacy custom-element bundle routes (/legacy, /single, /compare, /agent, +# /report, /register/*) were retired in v0.4.5 — the SvelteKit UI fully +# subsumes them. Static assets at /static/* still mount in case anything +# external embeds them, but the page-level routes are gone. Hitting them +# now returns the framework default 404. @app.get("/api/register/{asset_class}") diff --git a/web/sveltekit/build/200.html b/web/sveltekit/build/200.html index abf8fe481b1110d2a524a07a81f446360325a735..bedf3adb6966a95de2d236ae60ce923db17f1746 100644 --- a/web/sveltekit/build/200.html +++ b/web/sveltekit/build/200.html @@ -6,33 +6,33 @@ Riprap — flood-exposure briefing - - - - - - - - - - - + + + + + + + + + + + - +
+ +
+
+
+ SPEC +

Card grammar

+ · every body variant in the system + stubs, not findings +
+ {STUBS.length} variants +
+
+ {#each STUBS as stub (stub.id)} + + {/each} +
+
+ + diff --git a/web/sveltekit/src/lib/components/findings/FindingCard.svelte b/web/sveltekit/src/lib/components/findings/FindingCard.svelte new file mode 100644 index 0000000000000000000000000000000000000000..c08a19eb391b1ff9997b40666785152cf880bcf3 --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/FindingCard.svelte @@ -0,0 +1,235 @@ + + + +
+
+ + {card.source} +
+ v. {card.vintage} +
+ +

{card.title}

+ + + +
+ {#if card.citeId} + + {:else} + {card.docId} + {/if} + + + {tierShort} + +
+
+ + diff --git a/web/sveltekit/src/lib/components/findings/FindingsRegion.svelte b/web/sveltekit/src/lib/components/findings/FindingsRegion.svelte new file mode 100644 index 0000000000000000000000000000000000000000..b3452e95219006c9b7370291886f46ffc5ae3b4a --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/FindingsRegion.svelte @@ -0,0 +1,116 @@ + + +
+
+

Findings · grouped by Stone

+ cards = what each Stone found · provenance collapses below +
+ + + + {#each STONE_ORDER as key (key)} + + {/each} + + {#if showGrammar} + + {/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/ProvenanceTrace.svelte b/web/sveltekit/src/lib/components/findings/ProvenanceTrace.svelte new file mode 100644 index 0000000000000000000000000000000000000000..e4a6b1779d2a25b2cc1d80539b4be74c1415dc9c --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/ProvenanceTrace.svelte @@ -0,0 +1,125 @@ + + +
    + {#each members as m (m.id)} +
  • + + {m.id} + {#if m.tier} + + + + {/if} + {m.name} + {#if m.note}— {m.note}{/if} + {#if m.ms != null}{m.ms < 1000 ? `${m.ms}ms` : `${(m.ms / 1000).toFixed(1)}s`}{/if} +
  • + {#if m.children?.length} +
  • + +
  • + {/if} + {/each} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/RunHealthStrip.svelte b/web/sveltekit/src/lib/components/findings/RunHealthStrip.svelte new file mode 100644 index 0000000000000000000000000000000000000000..fc409bd31decbc9ea72d02f3c4d97d909a99bd3e --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/RunHealthStrip.svelte @@ -0,0 +1,93 @@ + + +
+ {stones.length} Stones + · + {fired} fired + {#if silent > 0} + · + {silent} silent + {/if} + {#if warn > 0} + · + {warn} warned + {/if} + {#if err > 0} + · + {err} errored + {/if} + {#if notInvoked > 0} + · + {notInvoked} not invoked + {/if} + · + {cards.length} evidence card{cards.length === 1 ? '' : 's'} + · + {wall} wall-clock + {#if cacheHit != null} + · + {Math.round(cacheHit * 100)}% cache + {/if} + · + {total} registered +
+ + diff --git a/web/sveltekit/src/lib/components/findings/StoneRegion.svelte b/web/sveltekit/src/lib/components/findings/StoneRegion.svelte new file mode 100644 index 0000000000000000000000000000000000000000..1f5c6f461e1c60e36c5100003f6c8655df298e80 --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/StoneRegion.svelte @@ -0,0 +1,252 @@ + + +
+
+
+ {stoneNum} +

{meta.name}

+ · {meta.role} + {meta.tag} +
+ +
+ + {#if cards.length === 0} +
+ silent +

+ {#if stone === 'lodestone'} + No projection cards landed for this query. Atomic functions still ran (see provenance) and returned silence rather than confabulation. + {:else} + No cards for this Stone on this query. + {/if} +

+
+ {:else} +
+ {#each cards as card (card.id)} + + {/each} +
+ {/if} + +
+ + {#if traceOpen} +
+ +
+ {/if} +
+
+ + diff --git a/web/sveltekit/src/lib/components/findings/StoneTally.svelte b/web/sveltekit/src/lib/components/findings/StoneTally.svelte new file mode 100644 index 0000000000000000000000000000000000000000..921376832b68138cdb0783a4cc11cdd18efccd0a --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/StoneTally.svelte @@ -0,0 +1,80 @@ + + + + {cardCount} card{cardCount === 1 ? '' : 's'} + · + {fired} fired + {#if silent > 0} + · + {silent} silent + {/if} + {#if warn > 0} + · + {warn} warn + {/if} + {#if err > 0} + · + {err} errored + {/if} + {#if notInvoked > 0} + · + {notInvoked} not invoked + {/if} + · + {fmtMs(ms)} + + + diff --git a/web/sveltekit/src/lib/components/findings/cards/CardBody.svelte b/web/sveltekit/src/lib/components/findings/cards/CardBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..8a3671acddef67aac3aad1f1b5bea38293fe3ae8 --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/CardBody.svelte @@ -0,0 +1,58 @@ + + +{#if card.variant === 'headline'} + +{:else if card.variant === 'tabular'} + +{:else if card.variant === 'scalars'} + +{:else if card.variant === 'spark' || card.variant === 'histogram'} + +{:else if card.variant === 'timeseries'} + +{:else if card.variant === 'timeseries-ft'} + +{:else if card.variant === 'forecast'} + +{:else if card.variant === 'raster' || card.variant === 'raster-pred'} + +{:else if card.variant === 'lulc'} + +{:else if card.variant === 'register'} + +{:else if card.variant === 'comparison'} + +{:else if card.variant === 'meta'} + +{:else} +
unknown variant: {card.variant}
+{/if} + + diff --git a/web/sveltekit/src/lib/components/findings/cards/ComparisonBody.svelte b/web/sveltekit/src/lib/components/findings/cards/ComparisonBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..b159c902aa0a9133e7b1a5020195166a730204cc --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/ComparisonBody.svelte @@ -0,0 +1,92 @@ + + +
+
+ {#if card.left} +
+
+ + {card.left.label} +
+
{card.left.value}
+ {#if card.left.aux}
{card.left.aux}
{/if} +
+ {/if} + + {#if card.right} +
+
+ + {card.right.label} +
+
{card.right.value}
+ {#if card.right.aux}
{card.right.aux}
{/if} +
+ {/if} +
+ {#if card.delta}
{card.delta}
{/if} + {#if card.sub}
{card.sub}
{/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/ForecastBody.svelte b/web/sveltekit/src/lib/components/findings/cards/ForecastBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..d7a43fe50268e9d62b3aee83e9574e4080acd621 --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/ForecastBody.svelte @@ -0,0 +1,51 @@ + + +
+ + {#if card.sub}
{card.sub}
{/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/HeadlineBody.svelte b/web/sveltekit/src/lib/components/findings/cards/HeadlineBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..2fb3eea4ba115f7fd91f330331fb5931c2226be5 --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/HeadlineBody.svelte @@ -0,0 +1,41 @@ + + +
+
{card.headline ?? ''}
+ {#if card.subhead}
{card.subhead}
{/if} + {#if card.body}

{card.body}

{/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/LulcBody.svelte b/web/sveltekit/src/lib/components/findings/cards/LulcBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..58c3f8866d2206bfca8c70e76c00b0ade1ef6def --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/LulcBody.svelte @@ -0,0 +1,117 @@ + + +
+
+ + {#if card.illustrative || card.tier === 'synthetic'} + illustrative + {/if} +
+ + {#if card.classMix?.length} + +
    + {#each card.classMix as c (c.k)} +
  • + + {c.k} + {c.pct}% +
  • + {/each} +
+ {/if} + + {#if card.sub}
{card.sub}
{/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/MetaBody.svelte b/web/sveltekit/src/lib/components/findings/cards/MetaBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..e21afb6d8a90a643c454c53f1925c96d73a627b2 --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/MetaBody.svelte @@ -0,0 +1,56 @@ + + +
+
+ {#each card.metaRows ?? [] as r} +
+
{r.k}
+
{r.v}
+
+ {/each} +
+ {#if card.sub}
{card.sub}
{/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/RasterBody.svelte b/web/sveltekit/src/lib/components/findings/cards/RasterBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..ef2bfc03c0ab64242d9b9674e33cdd5b10ef2e71 --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/RasterBody.svelte @@ -0,0 +1,56 @@ + + +
+
+ + {#if card.illustrative || card.tier === 'synthetic'} + illustrative + {/if} +
+ {#if card.headline} +
+ {card.headline} + {#if card.subhead} · {card.subhead}{/if} +
+ {/if} + {#if card.sub}
{card.sub}
{/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/RasterThumb.svelte b/web/sveltekit/src/lib/components/findings/cards/RasterThumb.svelte new file mode 100644 index 0000000000000000000000000000000000000000..612db0b732454ba89868afa83c3d1da882816092 --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/RasterThumb.svelte @@ -0,0 +1,106 @@ + + +{#if kind === 'stormwater'} + +{:else if kind === 'stormwater-dry'} + +{:else if kind === 'prithvi'} + +{:else if kind === 'lulc'} + +{:else if kind === 'buildings'} + +{:else} +
raster preview
+{/if} + + diff --git a/web/sveltekit/src/lib/components/findings/cards/RegisterBody.svelte b/web/sveltekit/src/lib/components/findings/cards/RegisterBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..a59854fe2c53932527e944b67508eb6849ac0800 --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/RegisterBody.svelte @@ -0,0 +1,93 @@ + + +
+
    + {#each card.registers ?? [] as r} +
  • + + + {r.reg} + + {#if r.label} + {r.label} + {r.sourceId ?? ''} + {:else} + {r.note} + {/if} +
  • + {/each} +
+ {#if card.sub}
{card.sub}
{/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/ScalarsBody.svelte b/web/sveltekit/src/lib/components/findings/cards/ScalarsBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..b3d9c20f403b25b82e5eea3f0f97be9dae23395c --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/ScalarsBody.svelte @@ -0,0 +1,48 @@ + + +
+
+ {#each card.scalars ?? [] as s} +
+
{s.value}
+
{s.label}
+
+ {/each} +
+ {#if card.sub}
{card.sub}
{/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/SparkBody.svelte b/web/sveltekit/src/lib/components/findings/cards/SparkBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..96c150b2160bf10acfcf084464650380b74b08cc --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/SparkBody.svelte @@ -0,0 +1,63 @@ + + +
+ {#if card.headline} +
{card.headline}
+ {/if} + {#if card.subhead}
{card.subhead}
{/if} + + {#if card.sparkSub}
{card.sparkSub}
{/if} + {#if !card.sparkSub && card.sub}
{card.sub}
{/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/TabularBody.svelte b/web/sveltekit/src/lib/components/findings/cards/TabularBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..3c630a0278e9084a863dd74c56cd815c907f82fc --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/TabularBody.svelte @@ -0,0 +1,55 @@ + + +
+ + + + {#each card.columns ?? [] as h}{/each} + + + + {#each card.rows ?? [] as row} + {#each row as cell}{/each} + {/each} + +
{h}
{cell}
+ {#if card.sub}
{card.sub}
{/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/TimeseriesBody.svelte b/web/sveltekit/src/lib/components/findings/cards/TimeseriesBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..fffc6caaeb5ac1562f00cc01c08d25a81f7929dc --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/TimeseriesBody.svelte @@ -0,0 +1,108 @@ + + +
+
+ {#if card.headline} + {card.headline} + {/if} + {#if card.subhead}{card.subhead}{/if} +
+ + {#if card.spatialNote || card.sub} +
+ {#if card.spatialNote}{card.spatialNote}{/if} + {#if card.sub}{card.sub}{/if} +
+ {/if} +
+ + diff --git a/web/sveltekit/src/lib/components/findings/cards/TimeseriesFtBody.svelte b/web/sveltekit/src/lib/components/findings/cards/TimeseriesFtBody.svelte new file mode 100644 index 0000000000000000000000000000000000000000..5ec85f353f0901fa92f66f8cabb6d1b3159fc94c --- /dev/null +++ b/web/sveltekit/src/lib/components/findings/cards/TimeseriesFtBody.svelte @@ -0,0 +1,79 @@ + + + + + + diff --git a/web/sveltekit/src/lib/components/landing/LandFooter.svelte b/web/sveltekit/src/lib/components/landing/LandFooter.svelte new file mode 100644 index 0000000000000000000000000000000000000000..5cef9764980c35af499794203c291adf2fa4daf3 --- /dev/null +++ b/web/sveltekit/src/lib/components/landing/LandFooter.svelte @@ -0,0 +1,63 @@ + + +
+ + empirical + modeled + proxy + synthetic + + Riprap v0.4.5 · NYC OpenData · FEMA NFHL · USGS · NPCC4 +
+ + diff --git a/web/sveltekit/src/lib/components/landing/LandHeader.svelte b/web/sveltekit/src/lib/components/landing/LandHeader.svelte new file mode 100644 index 0000000000000000000000000000000000000000..4a09e41fd7713e7e18d24861eeb056fa528530cd --- /dev/null +++ b/web/sveltekit/src/lib/components/landing/LandHeader.svelte @@ -0,0 +1,52 @@ + + +
+ riprap + / + Flood Exposure Briefing · NYC + +
+ + diff --git a/web/sveltekit/src/lib/components/landing/LandHero.svelte b/web/sveltekit/src/lib/components/landing/LandHero.svelte new file mode 100644 index 0000000000000000000000000000000000000000..d298c9f7cb8a21337156adf333de89cef68bf817 --- /dev/null +++ b/web/sveltekit/src/lib/components/landing/LandHero.svelte @@ -0,0 +1,191 @@ + + +
+

+ A flood exposure briefing
for any place in New York City.
+ + Type an address. Get a written briefing where every numeric claim links to its primary public-record source. + +

+ +
{ e.preventDefault(); submit(); }} role="search"> + + + +
+ +
+ Try: + +
+
+ + diff --git a/web/sveltekit/src/lib/components/landing/LandPreview.svelte b/web/sveltekit/src/lib/components/landing/LandPreview.svelte new file mode 100644 index 0000000000000000000000000000000000000000..8c7caf78ea934a7e792f0e669001f5957f7bea7f --- /dev/null +++ b/web/sveltekit/src/lib/components/landing/LandPreview.svelte @@ -0,0 +1,310 @@ + + +
+
+ + +
+ +
+ + +
+
Briefing excerpt
+

+ The lot sits inside the FEMA 1% AE flood zone [c3], + with Sandy high-water marks recorded + 4.7 ft above grade [c1]. + FloodNet FN-BK-018 has logged + 14 nuisance floods since 2023 [c2]. +

+
+
+ [c1] + USGS HWM · Sandy 2012 + empirical +
+
+ [c2] + FloodNet FN-BK-018 + empirical +
+
+ [c3] + FEMA NFHL · 36047C0207 + modeled +
+
+
+ + +
+
Evidence cards
+
+
+
+ empirical + e1 +
+
4.7 ft Sandy storm-surge HWM at address
+
USGS High-Water Mark database · 2012
+
+
+
+ empirical + e2 +
+
14 nuisance-flood events, 2023–2026
+
FloodNet FN-BK-018 · 2 blocks north
+
+
+
+ modeled + e3 +
+
FEMA 1% annual-chance (AE) flood zone
+
FEMA NFHL · panel 36047C0207
+
+
+
+ modeled + e5 +
+
+30 in MSL by 2070 (NPCC4 high)
+
NPCC4 SLR projection · 2024
+
+
+
+ + +
+
Map
+ +
Red Hook · z16 · Carto Positron
+
+ +
+
+ + diff --git a/web/sveltekit/src/lib/components/landing/LandStones.svelte b/web/sveltekit/src/lib/components/landing/LandStones.svelte new file mode 100644 index 0000000000000000000000000000000000000000..c9b18c67973346622ca4fe83972e1c0e752ec68d --- /dev/null +++ b/web/sveltekit/src/lib/components/landing/LandStones.svelte @@ -0,0 +1,147 @@ + + +
+
+
+ + +
+

+ Each briefing routes through a fixed taxonomy of public-record specialists. Each Stone is a class of evidence. + Together they form the briefing, and every claim in the output traces back to the Stone that produced it. +

+
+ {#each STONE_FRIEZE as s, i (s.name)} +
+
{String(i + 1).padStart(2, '0')}
+

{s.name}

+
{s.role}
+

{s.tag}

+
{s.sources}
+
+ {/each} +
+
+
+ + diff --git a/web/sveltekit/src/lib/components/map/MapLegend.svelte b/web/sveltekit/src/lib/components/map/MapLegend.svelte index a136e48c2bf2312d9fa6aece7a440d7e52fae629..9854ca3c9d050c5a9680b06a3cb701058aa7b68c 100644 --- a/web/sveltekit/src/lib/components/map/MapLegend.svelte +++ b/web/sveltekit/src/lib/components/map/MapLegend.svelte @@ -2,70 +2,305 @@ import type { Tier } from '$lib/types/tier'; import TierGlyph from '$lib/components/glyphs/TierGlyph.svelte'; import TierBadge from '$lib/components/glyphs/TierBadge.svelte'; + import type { StoneKey } from '$lib/types/card'; + import { STONE_META, STONE_ORDER } from '$lib/types/card'; - type LayerKey = 'empirical' | 'modeled' | 'synthetic' | 'proxy'; - interface Layer { key: LayerKey; tier: Tier; label: string; source: string; } + /** v0.4.5 §7 — LAYERS panel restructured to mirror Findings Stones. + * + * Each Stone is its own collapsed-but-visible group with one row per + * map layer keyed to that Stone. Master tier toggles (the live + * empirical / modeled / synthetic / proxy switches the map respects + * today) sit at the bottom of the panel; per-Stone rows inherit + * their tier's master state and display the resolved ON/OFF. + * + * Rows for layers that aren't yet wired into the map visibility + * pipeline render with a dimmed "off (not yet wired)" caption so the + * reader sees the catalog without thinking the toggle is broken. */ + + type MasterKey = 'empirical' | 'modeled' | 'synthetic' | 'proxy'; interface Props { - active: Record; - /** - * Number of features in each tier source. Layers with `0` (or - * undefined when the FC hasn't loaded yet) are dropped from the - * legend — silence over confabulation, same rule as briefing - * sections without supporting documents (handoff hard rule #3). - * Pass `null` to show all four (e.g. for the spec page). - */ - featureCounts?: Record | null; - onToggle: (key: LayerKey) => void; + active: Record; + /** Per-tier feature counts. Used to surface "no features" inline + * on each Stone row. `null` means caller wants the full catalog + * shown regardless of data-driven counts. */ + featureCounts?: Record | null; + onToggle: (key: MasterKey) => void; } let { active, featureCounts, onToggle }: Props = $props(); - const LAYERS: Layer[] = [ - { key: 'empirical', tier: 'empirical', label: 'Sandy Inundation Zone (2012)', source: 'NYC OEM' }, - { key: 'modeled', tier: 'modeled', label: 'FEMA / DEP scenarios', source: 'FEMA · NYC DEP' }, - { key: 'synthetic', tier: 'synthetic', label: 'Synthetic SAR (TerraMind)', source: 'TerraMind v1.2' }, - { key: 'proxy', tier: 'proxy', label: '311 flood complaints', source: 'NYC 311' } + type LayerRow = { + label: string; + source: string; + tier: Tier; + /** When false, the row is purely catalog — the master tier toggle + * doesn't yet drive a real map source. Surfaced as "not yet wired". */ + wired: boolean; + }; + + const STONE_LAYERS: Record = { + cornerstone: [ + { label: 'Sandy Inundation Zone (2012)', source: 'NYC OEM', tier: 'empirical', wired: true }, + { label: 'FEMA / DEP scenarios', source: 'FEMA · NYC DEP', tier: 'modeled', wired: true }, + { label: 'Ida HWM points (2021)', source: 'USGS STN', tier: 'empirical', wired: false }, + { label: 'Microtopography (HAND/TWI)', source: 'USGS 3DEP', tier: 'proxy', wired: false }, + ], + keystone: [ + { label: 'MTA subway entrances', source: 'MTA Open Data', tier: 'empirical', wired: true }, + { label: 'NYCHA developments', source: 'NYC OD phvi-damg', tier: 'empirical', wired: true }, + { label: 'DOE schools', source: 'NYC DOE Locations', tier: 'empirical', wired: true }, + { label: 'DOH hospitals', source: 'NYS DOH vn5v-hh5r', tier: 'empirical', wired: true }, + { label: 'TerraMind Buildings (current)', source: 'msradam/TerraMind-NYC-Adapters', tier: 'synthetic', wired: false }, + ], + touchstone: [ + { label: '311 flood complaints', source: 'NYC 311', tier: 'proxy', wired: true }, + { label: 'FloodNet sensors', source: 'FloodNet NYC', tier: 'empirical', wired: false }, + { label: 'TerraMind LULC (current)', source: 'msradam/TerraMind-NYC-Adapters', tier: 'synthetic', wired: false }, + { label: 'Prithvi-NYC-Pluvial flood pred.', source: 'msradam/Prithvi-EO-2.0-NYC-Pluvial', tier: 'modeled', wired: false }, + ], + lodestone: [], // intentional — surfaced as the explicit absence row + capstone: [], // not a map layer; surfaced as "not a map layer" + }; + + /** Resolve a row's ON state from the master tier toggle. */ + function isOn(row: LayerRow): boolean { + return !!active[row.tier]; + } + + function tally(stone: StoneKey): number { + return STONE_LAYERS[stone].length; + } + + // Active tier toggles — rendered as small chips at the bottom of the + // panel so the user can still flip the four master switches. + const MASTERS: { k: MasterKey; tier: Tier; label: string }[] = [ + { k: 'empirical', tier: 'empirical', label: 'EMP' }, + { k: 'modeled', tier: 'modeled', label: 'MOD' }, + { k: 'proxy', tier: 'proxy', label: 'PRX' }, + { k: 'synthetic', tier: 'synthetic', label: 'SYN' }, ]; - /** - * Decide which legend rows to render. `featureCounts === null` means - * the caller wants all four shown (no data-driven filtering). Any - * key with count > 0 (or `undefined` while a fetch is still in - * flight) renders; explicit zero is dropped. - */ - let visibleLayers = $derived( - featureCounts === null - ? LAYERS - : LAYERS.filter((l) => { - const n = featureCounts?.[l.key]; - return n === undefined || n > 0; - }) - ); + // featureCounts is intentionally accepted but not used in the catalog + // view — the catalog shows every row regardless of live counts. Kept + // in the prop signature so callers don't have to change. -{#if visibleLayers.length} -
-
- +
-{/if} + + +
+ +
+ {#each MASTERS as m (m.k)} + + {/each} +
+
+ + + diff --git a/web/sveltekit/src/lib/components/map/RipMap.svelte b/web/sveltekit/src/lib/components/map/RipMap.svelte index c536e80cc91ed4c1cee4b3c8083d9bb01caff542..92db6a30eb84388c7f91caf95eee030148c1c322 100644 --- a/web/sveltekit/src/lib/components/map/RipMap.svelte +++ b/web/sveltekit/src/lib/components/map/RipMap.svelte @@ -28,6 +28,11 @@ registerPoints?: GeoJSON.FeatureCollection; registerPolygons?: GeoJSON.FeatureCollection; activeLayers?: { empirical: boolean; modeled: boolean; synthetic: boolean; proxy: boolean }; + /** v0.4.5 §8 — when a Findings card is hovered/focused, its + * `mapLayer` key flows in as `linkedKey`. The map root gains + * `is-link-{key}` so existing layers can be visually emphasised + * via scoped CSS. */ + linkedKey?: string | null; } let { @@ -38,7 +43,8 @@ proxy311, registerPoints, registerPolygons, - activeLayers = { empirical: true, modeled: true, synthetic: true, proxy: true } + activeLayers = { empirical: true, modeled: true, synthetic: true, proxy: true }, + linkedKey = null, }: Props = $props(); let container: HTMLDivElement | null = $state(null); @@ -293,13 +299,16 @@ }); -
+
+ {#if linkedKey} + + {/if}
diff --git a/web/sveltekit/src/lib/components/shell/AppHeader.svelte b/web/sveltekit/src/lib/components/shell/AppHeader.svelte index 437862302ca371656a9d67da79fb02c96c17cef2..86bf1fa98dd45d13defcf41a4bf8100bee5f9e02 100644 --- a/web/sveltekit/src/lib/components/shell/AppHeader.svelte +++ b/web/sveltekit/src/lib/components/shell/AppHeader.svelte @@ -26,7 +26,7 @@
- riprap + riprap / flood-exposure briefing
diff --git a/web/sveltekit/src/lib/components/shell/ColdStart.svelte b/web/sveltekit/src/lib/components/shell/ColdStart.svelte index 5c378aa8a3637851aa88485f8d8ce4500b8754b3..9974d89216768ecdeebdd58d1936276e629f1136 100644 --- a/web/sveltekit/src/lib/components/shell/ColdStart.svelte +++ b/web/sveltekit/src/lib/components/shell/ColdStart.svelte @@ -81,6 +81,33 @@
+ + +
    +
  • + + Cornerstone remembers — what NYC's ground remembers. +
  • +
  • + + Keystone tallies — what's exposed. +
  • +
  • + + Touchstone watches — what's happening now. +
  • +
  • + + Lodestone projects — what's coming. +
  • +
  • + + Capstone writes it all down with citations. +
  • +
+
  • All foundation models Apache-2.0; no commercial APIs at runtime.
  • All data from public-record federal, state, and city sources.
  • @@ -90,3 +117,38 @@ Methodology paper →
+ + diff --git a/web/sveltekit/src/lib/data/findingsSample.ts b/web/sveltekit/src/lib/data/findingsSample.ts new file mode 100644 index 0000000000000000000000000000000000000000..49d5113bd69f2406db0daa94d8caf10f773fb81b --- /dev/null +++ b/web/sveltekit/src/lib/data/findingsSample.ts @@ -0,0 +1,281 @@ +/** + * Findings v0.4.4 sample data — Red Hook query. + * + * Mirrors the CARDS / CARDS_BY_QUERY tables in + * docs/design_handoff/design_files/findings.jsx so the SvelteKit Findings + * region can be rendered without a live FSM. Use as fixture for + * `routes/q/sample/+page.svelte` and Playwright snapshots. + * + * Stone-trace member ids match the FSM step names where possible so the + * adapter can replace this with live data without changing card copy. + */ +import type { FindingsData, StoneTrace } from '$lib/types/card'; +import { fillRosterForStone } from './stoneRegistry'; + +export const SAMPLE_FINDINGS: FindingsData = { + wallSeconds: 14.0, + cacheHit: 0.92, + cards: [ + /* ── Cornerstone ── */ + { + id: 'fc-fema', + stone: 'cornerstone', tier: 'modeled', variant: 'headline', + source: 'FEMA', agency: 'Federal Emergency Management Agency', + vintage: '2024-09', + title: 'Preliminary FIRM, panel 36047C0207G', + headline: 'Zone AE', + subhead: 'BFE 11 ft NAVD88 · freeboard +4.8 ft', + body: 'Address sits within the regulatory 1% annual-chance floodplain. Base Flood Elevation 11.0 ft NAVD88; first floor must be at or above this datum for NFIP rating.', + docId: 'FEMA-FIRM-36047C0207G', citeId: 'c4', + mapLayer: 'fema-ae', + }, + { + id: 'fc-hwm', + stone: 'cornerstone', tier: 'empirical', variant: 'tabular', + source: 'USGS', agency: 'U.S. Geological Survey', + vintage: '2013-05', + title: 'Post-Sandy high-water marks within 500 ft', + columns: ['id', 'elev.', 'dist.'], + rows: [ + ['HWM-NY-3081', '7.4 ft NAVD88', '0.18 mi'], + ['HWM-NY-3082', '8.1 ft NAVD88', '0.22 mi'], + ['HWM-NY-3105', '6.8 ft NAVD88', '0.31 mi'], + ], + sub: '3 marks · max 8.1 ft · surveyed Nov 2012', + docId: 'USGS-OFR-2013-1234', citeId: 'c1', + mapLayer: 'hwm', + }, + { + id: 'fc-stormwater', + stone: 'cornerstone', tier: 'modeled', variant: 'raster', + source: 'NYC DEP', agency: 'NYC Dept. of Environmental Protection', + vintage: '2024-06', + title: 'Stormwater Flood Map · moderate scenario', + rasterKind: 'stormwater', + sub: '2.13 in/hr · ponding ≥4 in W half of lot · routed toward Imlay St', + docId: 'NYCDEP-SWFM-2024', citeId: 'c5', + mapLayer: 'stormwater', + }, + + /* ── Keystone ── */ + { + id: 'fc-register-rh', + stone: 'keystone', tier: 'empirical', variant: 'register', + source: 'NYC OpenData', agency: 'NYC OpenData · multi-agency join', + vintage: '2026-05', + title: 'Nearby exposed assets', + registers: [ + { reg: 'MTA', tier: 'empirical', label: 'Smith–9 St subway entrance', detail: '0.34 mi · F · G', sourceId: 'MTA-ENT-N048', vintage: '2025-11', note: null }, + { reg: 'NYCHA', tier: 'empirical', label: 'Red Hook East Houses', detail: '0.41 mi · 2,878 res.', sourceId: 'NYCHA-RHE', vintage: '2025-Q3', note: null }, + { reg: 'NYCHA', tier: 'empirical', label: 'Red Hook West Houses', detail: '0.52 mi · 3,142 res.', sourceId: 'NYCHA-RHW', vintage: '2025-Q3', note: null }, + { reg: 'DOE', tier: 'empirical', label: 'PS 27 Agnes Y. Humphrey', detail: '0.29 mi · 271 K-5', sourceId: 'DOE-K027', vintage: '2024-25', note: null }, + { reg: 'DOH', tier: 'empirical', label: null, detail: null, sourceId: null, vintage: null, note: 'no acute-care hospital within 1.0 mi (silent)' }, + { reg: 'PLUTO', tier: 'empirical', label: 'Lot 36047 / 521 / 7', detail: 'BIN 3018472 · MX-1', sourceId: 'PLUTO-2024v2', vintage: '2024-12', note: null }, + ], + sub: '5 of 6 registers fired · 1 silent · joined within 1.0 mi', + docId: 'RIPRAP-EXP-RH80', citeId: 'c-reg-rh', + mapLayer: 'registers', + }, + + /* ── Touchstone ── */ + { + id: 'fc-floodnet', + stone: 'touchstone', tier: 'empirical', variant: 'spark', + source: 'FloodNet', agency: 'FloodNet NYC sensor network', + vintage: '2026-04', + title: 'Sensor BK-RH-002, monthly above-curb events', + headline: '7 events', + subhead: 'Jun 2024 → Apr 2026 · peak 14.3 cm', + spark: [0,0,1,0,2,1,0,0,3,0,1,0,0,0,2,1,0,0,1,0,2,4,1,1], + sparkSub: 'Sensor located 0.21 mi N at Coffey & Van Brunt. Above-curb depth in cm; events ≥2 cm.', + docId: 'FN-BK-RH-002', citeId: 'c3', + mapLayer: 'floodnet', + }, + { + id: 'fc-311', + stone: 'touchstone', tier: 'proxy', variant: 'histogram', + source: 'NYC 311', agency: 'NYC 311 service requests', + vintage: '2025-12', + title: 'Recent 311 flood complaints, BK CB6', + headline: '89 calls', + subhead: '2019–2025 · seasonal cluster Aug–Oct', + histogram: [3,2,1,0,1,4,7,12,18,11,5,3,4,2,1,0,2,3,8,9,4,2,1,0], + sparkSub: 'Filtered to complaint types: Sewer (Backup), Street Flooding, Catch Basin Clogged. Within 200 m of address.', + docId: 'NYC311-FLD-CB6', citeId: 'c7', + mapLayer: 'complaints', + }, + { + id: 'fc-prithvi-pluvial', + stone: 'touchstone', tier: 'modeled', variant: 'raster-pred', + source: 'Prithvi-NYC-Pluvial', agency: 'NASA-IBM Prithvi v2 · NYC fine-tune', + vintage: '2026-05-02 · Sentinel-2', + title: 'Pluvial flood prediction · Prithvi-NYC-Pluvial', + rasterKind: 'prithvi', + headline: '0.3% flooded', + subhead: 'no flooding apparent · scene 2026-05-02', + sub: 'Model interpretation of imagery, not real-time observation. Confidence-mean 0.84 across non-flooded pixels.', + docId: 'PRITHVI-NYC-PLUV-V2-20260502', citeId: 'c-prithvi', + illustrative: true, + mapLayer: 'prithvi-pluvial', + }, + { + id: 'fc-terramind-lulc', + stone: 'touchstone', tier: 'synthetic', variant: 'lulc', + source: 'TerraMind v1.2', agency: 'IBM TerraMind v1.2 · Sentinel-2 inputs', + vintage: 'Sentinel-2 · 2024-09-18', + title: 'Land use / land cover · TerraMind v1.2', + rasterKind: 'lulc', + classMix: [ + { k: 'urban', pct: 62, color: '#C66' }, + { k: 'water', pct: 18, color: '#5B7FB4' }, + { k: 'vegetation', pct: 12, color: '#5B8A4A' }, + { k: 'barren', pct: 6, color: '#A89A78' }, + { k: 'wetland', pct: 2, color: '#D9C75A' }, + ], + sub: 'Synthetic prior. LULC palette is a layer convention, not a tier signal.', + docId: 'TERRAMIND-LULC-20240918', citeId: 'c-tm-lulc', + illustrative: true, + mapLayer: 'terramind-lulc', + }, + { + id: 'fc-nws', + stone: 'touchstone', tier: 'empirical', variant: 'scalars', + source: 'NWS KNYC', agency: 'NOAA · National Weather Service', + vintage: '2026-05-05', + title: 'Current weather, station KNYC', + scalars: [ + { value: '0.02 in', label: 'precip · last 24h' }, + { value: '67°F', label: 'temp · current' }, + { value: 'PC', label: 'conditions' }, + ], + sub: 'Observation timestamp 2026-05-05 14:18 ET. Central Park station; not point-of-query.', + docId: 'NWS-KNYC', citeId: 'c-nws', + mapLayer: 'nws', + }, + + /* ── Lodestone ── */ + { + id: 'fc-ttm-surge', + stone: 'lodestone', tier: 'modeled', variant: 'timeseries', + source: 'Granite TTM r2 (zero-shot)', agency: 'IBM Granite-TimeSeries · regional', + vintage: '2026-05-05 12:00 ET', + title: 'Storm surge nowcast at The Battery — 9.6 h horizon (regional)', + timeseries: { hours: 96, peak: { x: 38, y: 47 }, peakLabel: '+47 cm @ +38h' }, + headline: '+47 cm', + subhead: 'peak surge residual · 9.6h horizon · 6-min cadence', + sub: 'Regional disclosure. Nowcast applies city-wide via NOAA station 8518750. Distinct from the fine-tuned Battery surge nowcast.', + spatialNote: 'regional · The Battery, not point-of-query', + docId: 'ttm_battery_surge_zeroshot', citeId: 'c-ttm', + mapLayer: null, + }, + { + id: 'fc-ttm-surge-ft', + stone: 'lodestone', tier: 'modeled', variant: 'timeseries-ft', + source: 'msradam/Granite-TTM-r2-Battery-Surge', agency: 'Granite TTM r2 · NYC-specialized fine-tune', + vintage: '2026-05-05 12:00 ET', + title: 'Storm surge nowcast at The Battery — 96 h horizon (NYC-specialized fine-tune)', + timeseries: { hours: 96, peak: { x: 38, y: 53 }, peakLabel: '+53 cm @ +38h' }, + headline: '+53 cm', + subhead: 'peak surge · 96h horizon · hourly cadence', + sub: 'Fine-tuned on NYC tide-gauge history. Trained on AMD MI300X.', + spatialNote: 'regional · The Battery, not point-of-query', + docId: 'ttm_battery_surge_finetune', citeId: 'c-ttm-ft', + mapLayer: null, + hfModelCard: 'huggingface.co/msradam/Granite-TTM-r2-Battery-Surge', + rmse: '0.157 m', + skillVsPersistence: '−35% vs persistence', + hardwareBadge: 'MI300X', + }, + { + id: 'fc-npcc4', + stone: 'lodestone', tier: 'modeled', variant: 'forecast', + source: 'NPCC4', agency: 'NYC Panel on Climate Change, 4th Assessment', + vintage: '2024-03', + title: 'Sea-level rise projections, Lower NY Harbor', + forecast: [ + { year: 2030, low: 4, mid: 6, high: 9 }, + { year: 2050, low: 13, mid: 22, high: 30 }, + { year: 2080, low: 28, mid: 49, high: 75 }, + { year: 2100, low: 38, mid: 71, high: 114 }, + ], + sub: 'inches MSL · 17th–83rd %ile range, median line. Battery tide-gauge baseline.', + docId: 'NPCC4-Ch3-Tbl3.2', citeId: 'c6', + mapLayer: null, + }, + + /* ── Capstone ── */ + { + id: 'fc-mellea-meta', + stone: 'capstone', tier: 'modeled', variant: 'meta', + source: 'Mellea', agency: 'Capstone synthesis · grounding check', + vintage: '2026-05-05 14:22 ET', + title: 'Briefing reconciliation', + metaRows: [ + { k: 'mellea reroll', v: '1 reroll' }, + { k: 'grounding checks', v: '4/4 passed' }, + { k: 'citations resolved', v: '4' }, + { k: 'wall-clock', v: '24.0 s' }, + ], + sub: 'Capstone produces prose, not cards. This meta-card summarizes the reconciler chain that wrote the four-section briefing above.', + docId: 'RIPRAP-CAP-RH80', citeId: null, + mapLayer: null, + }, + ], + + // Stones below carry one member per FSM step name. The registry + // projection at the bottom of this file fills in the not_invoked + // rows so each Stone renders its full intended roster. + stones: ([ + { + key: 'cornerstone', + members: [ + { id: 'CORN-001', name: 'sandy_inundation', status: 'fired', tier: 'empirical', ms: 412 }, + { id: 'CORN-002', name: 'dep_stormwater', status: 'fired', tier: 'modeled', ms: 540 }, + { id: 'CORN-003', name: 'ida_hwm_2021', status: 'fired', tier: 'empirical', ms: 612 }, + { id: 'CORN-004', name: 'prithvi_eo_v2', status: 'fired', tier: 'modeled', ms: 980 }, + { id: 'CORN-005', name: 'microtopo_lidar', status: 'fired', tier: 'proxy', ms: 1240 }, + ], + }, + { + key: 'keystone', + members: [ + { id: 'KEY-001', name: 'mta_entrance_exposure', status: 'silent_by_design', tier: 'empirical', ms: 30, note: 'no entrances within radius' }, + { id: 'KEY-002', name: 'nycha_development_exposure', status: 'silent_by_design', tier: 'empirical', ms: 28, note: 'no NYCHA developments within 1.0 mi' }, + { id: 'KEY-003', name: 'doe_school_exposure', status: 'silent_by_design', tier: 'empirical', ms: 24, note: 'no DOE schools within 1.0 mi' }, + { id: 'KEY-004', name: 'doh_hospital_exposure', status: 'silent_by_design', tier: 'empirical', ms: 22, note: 'no acute-care hospitals within 1.0 mi' }, + ], + }, + { + key: 'touchstone', + members: [ + { id: 'TCH-001', name: 'floodnet', status: 'fired', tier: 'empirical', ms: 285 }, + { id: 'TCH-002', name: 'nyc311', status: 'fired', tier: 'proxy', ms: 410 }, + { id: 'TCH-003', name: 'nws_obs', status: 'fired', tier: 'empirical', ms: 240 }, + { id: 'TCH-004', name: 'noaa_tides', status: 'fired', tier: 'empirical', ms: 196 }, + { id: 'TCH-005', name: 'prithvi_eo_live', status: 'fired', tier: 'modeled', ms: 4920 }, + { id: 'TCH-006', name: 'terramind_lulc', status: 'fired', tier: 'synthetic', ms: 2100 }, + ], + }, + { + key: 'lodestone', + members: [ + { id: 'LOD-001', name: 'nws_alerts', status: 'fired', tier: 'modeled', ms: 110 }, + { id: 'LOD-002', name: 'ttm_forecast', status: 'fired', tier: 'modeled', ms: 1500 }, + { id: 'LOD-003', name: 'ttm_battery_surge', status: 'fired', tier: 'modeled', ms: 1480 }, + { id: 'LOD-004', name: 'floodnet_forecast', status: 'silent_by_design', tier: 'modeled', ms: 14, note: 'sensor has only 2 historical events; forecast omitted (silent-floor: 5)' }, + { id: 'LOD-005', name: 'ttm_311_forecast', status: 'errored', tier: 'modeled', ms: 0, note: '311 history fetch failed: HTTP 503 at NYC OpenData (3 retries)' }, + ], + }, + { + key: 'capstone', + members: [ + { id: 'CAP-001', name: 'rag_granite_embedding', status: 'fired', tier: 'proxy', ms: 410 }, + { id: 'CAP-002', name: 'gliner_extract', status: 'fired', tier: 'proxy', ms: 280 }, + { id: 'CAP-003', name: 'reconcile_granite41', status: 'fired', tier: 'modeled', ms: 6240 }, + ], + }, + ] satisfies StoneTrace[]).map((s): StoneTrace => ({ + key: s.key, + members: fillRosterForStone(s.key, s.members), + })), +}; diff --git a/web/sveltekit/src/lib/data/stoneRegistry.ts b/web/sveltekit/src/lib/data/stoneRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..18b242aeb19c4dfe59f132ea84ae25af27412ce4 --- /dev/null +++ b/web/sveltekit/src/lib/data/stoneRegistry.ts @@ -0,0 +1,296 @@ +/** + * Stone specialist registry — the auditability contract. + * + * Each Stone declares the **complete inventory** of specialists it + * could fire on a query, along with the FSM step name (used to join + * against the run trace) and a one-line skip reason for when a + * specialist is absent from a particular run. + * + * v0.4.5 §3: every Stone's expander shows the full intended roster, + * never a filtered subset. A reader who expands a Stone sees what + * could have happened *and* what did. Specialists missing from the + * run output render as `not_invoked` with their registered skip + * reason. + * + * The display name and FSM step name often differ (the trace emits + * `mta_entrance_exposure`, the FSM action is `step_mta_entrances`, + * the Findings adapter writes state key `mta_entrances`). The + * `stepNames` list maps the registry entry to all variants the + * trace might emit so we don't double-count or miss matches. + */ +import type { StoneKey, StoneMember } from '$lib/types/card'; + +export type RegistryEntry = { + /** Stable id used when projecting a not_invoked row into the trace. */ + id: string; + /** Display name in the provenance row (italic-serif). */ + name: string; + /** All FSM step names that count as a "fire" for this entry. */ + stepNames: string[]; + /** Default tier when known; not_invoked rows render this color. */ + tier?: StoneMember['tier']; + /** One-line message rendered when the specialist is not_invoked. + * Engineering-honest voice (V0.4.5_SPEC.md §1) — describe the + * precondition that wasn't met, not "no data found". */ + skipReason: string; +}; + +export const STONE_REGISTRY: Record = { + cornerstone: [ + { + id: 'CORN-001', + name: 'sandy_inundation.lookup', + stepNames: ['sandy_inundation', 'sandy_nta'], + tier: 'empirical', + skipReason: 'Sandy 2012 inundation: query outside NYC bounds', + }, + { + id: 'CORN-002', + name: 'dep_stormwater.lookup', + stepNames: ['dep_stormwater', 'dep_extreme_2080_nta', 'dep_moderate_2050_nta', 'dep_moderate_current_nta'], + tier: 'modeled', + skipReason: 'NYC DEP stormwater scenarios: query outside NYC bounds', + }, + { + id: 'CORN-003', + name: 'usgs_hwm.spatial_join', + stepNames: ['ida_hwm_2021'], + tier: 'empirical', + skipReason: 'USGS Ida HWMs: no marks within 800 m of address', + }, + { + id: 'CORN-004', + name: 'prithvi_water.lookup', + stepNames: ['prithvi_eo_v2'], + tier: 'modeled', + skipReason: 'Prithvi-EO Ida polygons: no polygons within 500 m', + }, + { + id: 'CORN-005', + name: 'microtopo.dem_hand_twi', + stepNames: ['microtopo_lidar', 'microtopo_nta'], + tier: 'proxy', + skipReason: 'USGS 3DEP DEM: query outside NYC raster coverage', + }, + ], + keystone: [ + { + id: 'KEY-001', + name: 'mta_entrance_exposure', + stepNames: ['mta_entrance_exposure'], + tier: 'empirical', + skipReason: 'no entrances within radius', + }, + { + id: 'KEY-002', + name: 'nycha.development_join', + stepNames: ['nycha_development_exposure'], + tier: 'empirical', + skipReason: 'no NYCHA developments within 1.0 mi', + }, + { + id: 'KEY-003', + name: 'doe.school_join', + stepNames: ['doe_school_exposure'], + tier: 'empirical', + skipReason: 'no DOE schools within 1.0 mi', + }, + { + id: 'KEY-004', + name: 'doh.facility_join', + stepNames: ['doh_hospital_exposure'], + tier: 'empirical', + skipReason: 'no acute-care hospitals within 1.0 mi', + }, + { + id: 'KEY-005', + name: 'pluto.lot_lookup', + stepNames: ['pluto_lookup'], + tier: 'empirical', + skipReason: 'PLUTO join skipped: queried address not in NYC PLUTO dataset', + }, + { + id: 'KEY-006', + name: 'terramind.buildings', + stepNames: ['terramind_buildings', 'terramind_synthesis'], + tier: 'modeled', + skipReason: 'TerraMind Buildings adapter: heavy specialist disabled (RIPRAP_HEAVY_SPECIALISTS=0)', + }, + ], + touchstone: [ + { + id: 'TCH-001', + name: 'floodnet.history', + stepNames: ['floodnet'], + tier: 'empirical', + skipReason: 'FloodNet sensor: no deployments within 600 m', + }, + { + id: 'TCH-002', + name: 'nyc311.flood_complaints', + stepNames: ['nyc311', 'nyc311_nta'], + tier: 'proxy', + skipReason: 'NYC 311: no flood-relevant complaints within 200 m', + }, + { + id: 'TCH-003', + name: 'nws_obs.metar', + stepNames: ['nws_obs'], + tier: 'empirical', + skipReason: 'NWS hourly METAR: nearest ASOS reports no precipitation', + }, + { + id: 'TCH-004', + name: 'noaa_coops.recent', + stepNames: ['noaa_tides'], + tier: 'empirical', + skipReason: 'NOAA tide gauge: nearest station >25 km from address', + }, + { + id: 'TCH-005', + name: 'prithvi_nyc_pluvial', + stepNames: ['prithvi_eo_live'], + tier: 'modeled', + skipReason: 'Prithvi-NYC-Pluvial: live segmentation specialist disabled or no <30% cloud Sentinel-2 in last 120 d', + }, + { + id: 'TCH-006', + name: 'terramind.lulc', + stepNames: ['terramind_lulc'], + tier: 'synthetic', + skipReason: 'TerraMind LULC adapter: heavy specialist disabled or eo_chip fetch silent', + }, + ], + lodestone: [ + { + id: 'LOD-001', + name: 'nws_alerts.flood_relevant', + stepNames: ['nws_alerts'], + tier: 'modeled', + skipReason: 'NWS public alerts: no active flood-relevant alerts at this address', + }, + { + id: 'LOD-002', + name: 'ttm_battery_surge.zero_shot', + stepNames: ['ttm_forecast'], + tier: 'modeled', + skipReason: 'Granite TTM r2 zero-shot: forecast not interesting (peak |residual| < 0.3 ft)', + }, + { + id: 'LOD-003', + name: 'ttm_battery_surge.fine_tune', + stepNames: ['ttm_battery_surge'], + tier: 'modeled', + skipReason: 'Granite TTM Battery fine-tune: forecast not interesting (peak |residual| < 0.3 m)', + }, + { + id: 'LOD-004', + name: 'ttm_311_forecast', + stepNames: ['ttm_311_forecast'], + tier: 'modeled', + skipReason: 'NYC 311 weekly forecast: no per-address history to extrapolate', + }, + { + id: 'LOD-005', + name: 'floodnet_forecast', + stepNames: ['floodnet_forecast'], + tier: 'modeled', + skipReason: 'FloodNet sensor recurrence: sensor has < silent-floor historical events; forecast omitted', + }, + { + id: 'LOD-006', + name: 'npcc4.slr_projection', + stepNames: ['npcc4_projection'], + tier: 'modeled', + skipReason: 'NPCC4 SLR projection: not yet wired into FSM (static reference card on hold)', + }, + ], + capstone: [ + { + id: 'CAP-001', + name: 'rag.granite_embedding', + stepNames: ['rag_granite_embedding'], + tier: 'proxy', + skipReason: 'Granite Embedding RAG: no policy retrieval (out-of-NYC scope)', + }, + { + id: 'CAP-002', + name: 'gliner.typed_extraction', + stepNames: ['gliner_extract'], + tier: 'proxy', + skipReason: 'GLiNER typed extraction: no RAG hits to extract over', + }, + { + id: 'CAP-003', + name: 'granite41.compose_briefing', + stepNames: ['reconcile_granite41', 'mellea_reconcile_address', 'reconcile_neighborhood', 'reconcile_development', 'reconcile_live_now'], + tier: 'modeled', + skipReason: 'Reconciler did not run (no grounded data available)', + }, + { + id: 'CAP-004', + name: 'mellea.grounding_check', + stepNames: ['mellea_grounding'], + tier: 'modeled', + skipReason: 'Mellea grounding-check: rolled into reconcile step on this run', + }, + ], +}; + +/** Project the registry against the run's actual specialist members. + * Emits a full-roster member list per Stone — present specialists keep + * their live status; absent ones land as `not_invoked` with their + * registered skip reason. */ +export function fillRosterForStone( + stone: StoneKey, + liveMembers: StoneMember[], +): StoneMember[] { + const registry = STONE_REGISTRY[stone] ?? []; + // Index live members by every step name they could match. + const liveByStep = new Map(); + for (const m of liveMembers) { + liveByStep.set(m.name, m); + } + + const out: StoneMember[] = []; + const used = new Set(); + + for (const entry of registry) { + let live: StoneMember | undefined; + for (const sn of entry.stepNames) { + const hit = liveByStep.get(sn); + if (hit) { + live = hit; + used.add(sn); + break; + } + } + if (live) { + out.push({ + ...live, + // Override id + name with the registry's display strings so the + // provenance row reads consistently regardless of trace munging. + id: entry.id, + name: entry.name, + tier: live.tier ?? entry.tier ?? null, + }); + } else { + out.push({ + id: entry.id, + name: entry.name, + status: 'not_invoked', + tier: entry.tier ?? null, + note: entry.skipReason, + }); + } + } + + // Append any live members that weren't in the registry — they were + // emitted by the FSM but we don't know about them. Surface them + // anyway so we don't silently drop trace rows. + for (const m of liveMembers) { + if (!used.has(m.name)) out.push(m); + } + + return out; +} diff --git a/web/sveltekit/src/lib/tokens.css b/web/sveltekit/src/lib/tokens.css index 8a500aff95a924f11f471080722559d56120f8bd..d55203e6ac96bb0784d77b8a9c5038fa94e64112 100644 --- a/web/sveltekit/src/lib/tokens.css +++ b/web/sveltekit/src/lib/tokens.css @@ -26,6 +26,16 @@ --tier-synthetic-fill: rgba(42, 111, 168, 0.25); --tier-synthetic-line: #2A6FA8; + /* ── Stone accent tokens (v0.4.5) ── + Five muted hint-colors keyed to Stones. L≈45 OKLCH, chroma ≤0.04. + Hint-level decoration; never competes with the four-tier epistemic + palette. All five degrade to neutral gray in @media print. */ + --stone-cornerstone: #7C6F5E; /* warm taupe */ + --stone-keystone: #5E6E7C; /* cool slate */ + --stone-touchstone: #6B7C66; /* muted sage */ + --stone-lodestone: #7C6E5E; /* softened ochre */ + --stone-capstone: #5E5E6E; /* neutral indigo-gray */ + /* ── Reference + accent ── */ --reference-bg: #E8E8E6; --reference-line: #C9C9C5; @@ -149,3 +159,16 @@ html, body { border-top: 2px solid var(--ink); margin: 0; } + +/* v0.4.5 — Stone tints degrade to neutral gray in print so the PDF's + hierarchy is preserved by structure (Stone headings, type scale, + rules), not by color. */ +@media print { + :root { + --stone-cornerstone: #999; + --stone-keystone: #999; + --stone-touchstone: #999; + --stone-lodestone: #999; + --stone-capstone: #999; + } +} diff --git a/web/sveltekit/src/lib/types/card.ts b/web/sveltekit/src/lib/types/card.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b2f3f22c7973cc1b1f242c64f45e5c549d5a317 --- /dev/null +++ b/web/sveltekit/src/lib/types/card.ts @@ -0,0 +1,243 @@ +/** + * Findings card schema — v0.4.4. + * + * The Findings region renders a stack of cards grouped by Stone. Each card + * is one specialist's structured output, with explicit epistemic tiering + * and a citation fan-out that ties back into the briefing prose. + * + * Schema mirrors `docs/design_handoff/design_files/findings.jsx` exactly. + * Body fields are variant-specific — only the fields a given variant + * needs are populated. The renderer dispatches on `variant`. + */ +import type { Tier } from './tier'; + +/** Stone keys, fixed order. Mirrors `app/stones/__init__.py`. */ +export type StoneKey = + | 'cornerstone' + | 'keystone' + | 'touchstone' + | 'lodestone' + | 'capstone'; + +export const STONE_ORDER: StoneKey[] = [ + 'cornerstone', 'keystone', 'touchstone', 'lodestone', 'capstone', +]; + +export type StoneMeta = { name: string; role: string; tag: string }; + +export const STONE_META: Record = { + cornerstone: { name: 'Cornerstone', role: 'the hazard reader', tag: "what NYC's ground remembers" }, + keystone: { name: 'Keystone', role: 'the asset register', tag: "what's exposed" }, + touchstone: { name: 'Touchstone', role: 'the live observer', tag: "what's happening now" }, + lodestone: { name: 'Lodestone', role: 'the projector', tag: "what's coming" }, + capstone: { name: 'Capstone', role: 'the synthesizer', tag: 'writes it all down with citations' }, +}; + +/** 14 card body variants — one renderer per shape. + * + * timeseries-ft — v0.4.5 §5: timeseries + fine-tuned-model footer + * (HF model-card link, RMSE, hardware badge). + * lulc — v0.4.5 §4: raster + horizontal stacked class-mix bar + * for TerraMind LULC outputs. + */ +export type CardVariant = + | 'headline' + | 'tabular' + | 'scalars' + | 'spark' + | 'histogram' + | 'timeseries' + | 'timeseries-ft' + | 'forecast' + | 'raster' + | 'raster-pred' + | 'lulc' + | 'register' + | 'comparison' + | 'meta'; + +export type Citation = { id: string; label: string; href?: string }; + +export type RegisterRow = { + reg: string; // "MTA" | "NYCHA" | "DOE" | "DOH" | "PLUTO" | ... + tier: Tier; + /** When `label` is null the row renders as a silent — register fired but + had no hits. The note carries the silent reason. */ + label: string | null; + detail: string | null; + sourceId: string | null; + vintage?: string | null; + note?: string | null; +}; + +export type ScalarCell = { value: string; label: string; unit?: string }; + +export type ForecastBand = { + year: number; + low: number; + mid: number; + high: number; +}; + +export type ComparisonSide = { + tier: Tier; + label: string; + value: string; + aux?: string; +}; + +export type MetaRow = { k: string; v: string }; + +export type RasterKind = + | 'stormwater' + | 'stormwater-dry' + | 'fema-ae' + | 'hwm' + | 'prithvi' + | 'lulc' + | 'buildings' + | 'floodnet-density'; + +/** A single Findings card. Most fields are variant-specific. */ +export type Card = { + /** Stable id used as Svelte key + linkedKey target. */ + id: string; + stone: StoneKey; + tier: Tier; + variant: CardVariant; + + /** Header chrome — always shown. */ + source: string; // short label, e.g. "FEMA" + agency: string; // long form, e.g. "Federal Emergency Management Agency" + vintage: string; // e.g. "2024-09" or "2024-Q3" + + /** Title row. */ + title: string; + + /** Footer chrome — always shown. */ + docId: string; + citeId?: string | null; + cites?: Citation[]; + + /** Map cross-link key. Hovering this card lights up the matching map + * layer; hovering the layer outlines this card. */ + mapLayer?: string | null; + + /** Marks the card visually as illustrative (dashed top-rule on synthetic + * / preview cards). Always implied true for tier=synthetic. */ + illustrative?: boolean; + + /** Optional spatial-index callout (e.g. "regional · The Battery, not + * point-of-query") rendered next to the body sub. */ + spatialNote?: string; + + /** Variant-specific body fields. Only the relevant ones are populated. */ + headline?: string; + subhead?: string; + body?: string; + sub?: string; + sparkSub?: string; + + // tabular + columns?: string[]; + rows?: (string | number)[][]; + + // scalars + scalars?: ScalarCell[]; + + // spark / histogram + spark?: number[]; + histogram?: number[]; + + // timeseries + timeseries?: { + hours: number; + peak: { x: number; y: number }; + peakLabel: string; + }; + + // forecast + forecast?: ForecastBand[]; + + // raster / raster-pred + rasterKind?: RasterKind; + + // register + registers?: RegisterRow[]; + + // comparison (always synthetic-tier) + left?: ComparisonSide; + right?: ComparisonSide; + delta?: string; + + // meta + metaRows?: MetaRow[]; + + // timeseries-ft — fine-tuned-model footer chrome (v0.4.5 §5) + hfModelCard?: string; + rmse?: string; + skillVsPersistence?: string; + hardwareBadge?: string; + + // lulc — class-mix bar (v0.4.5 §4) + classMix?: { k: string; pct: number; color: string }[]; +}; + +/** Per-specialist run-state. v0.4.5 splits the v0.4.4 `ok|warn|error|silent` + * enum into five distinct epistemic outcomes so the run-health tally + * stops conflating "spec'd silent" with "specialist crashed": + * + * fired — completed and produced output the reconciler used + * silent_by_design — completed and correctly produced no output + * (e.g. "no entrances within radius") + * warned — output produced with a non-fatal warning + * errored — failed to complete, no usable output + * not_invoked — FSM skipped the specialist (precondition unmet + * / feature flag off / never wired) + * + * See V0.4.5_SPEC.md §1 for the full rationale and message-voice rules. + */ +export type SpecialistStatus = + | 'fired' + | 'silent_by_design' + | 'warned' + | 'errored' + | 'not_invoked'; + +/** Per-Stone provenance member (specialist) summary used by the trace. */ +export type StoneMember = { + id: string; + name: string; + status: SpecialistStatus; + tier?: Tier | null; + ms?: number; + /** One-line engineering-honest message ("no entrances within radius", + * "PLUTO join skipped: queried address not in NYC PLUTO dataset", + * "311 history fetch failed: HTTP 503 at NYC OpenData (3 retries)"). + * Match v0.4.1–v0.4.4 voice — precise, slightly understated. */ + note?: string; + children?: StoneMember[]; +}; + +/** A Stone's provenance + counts, fed by the FSM trace. */ +export type StoneTrace = { + key: StoneKey; + members: StoneMember[]; +}; + +/** What the page loader hands the FindingsRegion. */ +export type FindingsData = { + cards: Card[]; + stones: StoneTrace[]; + /** Wall-clock seconds for the run; surfaced in RunHealthStrip. */ + wallSeconds?: number; + /** Optional cache-hit ratio, dev-mode surfaced. */ + cacheHit?: number; +}; + +/** Density toggle — affects card padding + register row height. */ +export type Density = 'comfortable' | 'compact'; + +/** Provenance-trace expansion mode. Smart = collapsed if all-ok, expanded + * if any specialist warned / errored / went silent. */ +export type ProvenanceMode = 'smart' | 'all-expanded' | 'all-collapsed'; diff --git a/web/sveltekit/src/routes/+layout.svelte b/web/sveltekit/src/routes/+layout.svelte index 38c2bbb73c9d7404aa91237c3285d35207bfaafa..1e3e9d9fe5ddba57e195f2d9f1302835aeb6f763 100644 --- a/web/sveltekit/src/routes/+layout.svelte +++ b/web/sveltekit/src/routes/+layout.svelte @@ -19,17 +19,20 @@ } }); - // The /print/ route renders its own self-contained artifact (no - // header / footer / skip-links). It's a print target, not an app surface. + // The landing at / and the print artifact at /print/ both bring + // their own chrome, so the layout's AppHeader / AppFooter sit out + // for those. Briefings at /q/ still get the app chrome. let isPrint = $derived(page.url.pathname.startsWith('/print/')); + let isLanding = $derived(page.url.pathname === '/'); + let chromeFree = $derived(isPrint || isLanding); -{#if !isPrint} +{#if !chromeFree} (window.location.href = '/')} /> {/if}
{@render children()}
-{#if !isPrint} +{#if !chromeFree} {/if} diff --git a/web/sveltekit/src/routes/+page.svelte b/web/sveltekit/src/routes/+page.svelte index 870c804391de7468cb925be0627ba90929012e00..d6a56aec2a0f941ce3932b3133d17dbe9ba97d82 100644 --- a/web/sveltekit/src/routes/+page.svelte +++ b/web/sveltekit/src/routes/+page.svelte @@ -1,9 +1,47 @@ -
-
- + + Riprap — Flood Exposure Briefing for NYC + + + +
+ +
+ +
-
+ + +
+ + diff --git a/web/sveltekit/src/routes/q/[queryId]/+page.svelte b/web/sveltekit/src/routes/q/[queryId]/+page.svelte index 6ab90c915f95c401f96e56da304bfb3ab6bfe6f6..785aed457bcb9e2bbb45df720fd8bd2baa1f17ee 100644 --- a/web/sveltekit/src/routes/q/[queryId]/+page.svelte +++ b/web/sveltekit/src/routes/q/[queryId]/+page.svelte @@ -9,7 +9,9 @@ import SkeletonBriefing from '$lib/components/states/SkeletonBriefing.svelte'; import RerollBanner from '$lib/components/states/RerollBanner.svelte'; import ErrorCard from '$lib/components/states/ErrorCard.svelte'; - import RegisterCard from '$lib/components/evidence/RegisterCard.svelte'; + import FindingsRegion from '$lib/components/findings/FindingsRegion.svelte'; + import { adaptFinalToFindings, applyStepEventToLiveState } from '$lib/client/cardAdapter'; + import type { Density, ProvenanceMode, FindingsData } from '$lib/types/card'; import type { ErrorKey, RegisterData } from '$lib/types/states'; import { extractRegisters } from '$lib/client/registerAdapter'; // Mellea rejection sampling is Riprap's sole grounding mechanism. @@ -61,6 +63,51 @@ id: 'root', name: 'briefing.run', status: 'ok', ms: 0, tier: null, children: [] }); + /** Findings region state — lifted to the page so the briefing's map + * can read the linked card's mapLayer on hover. */ + let linkedKey = $state(null); + let density = $state('comfortable'); + let provenanceMode = $state('smart'); + // ?grammar=1 surfaces the dev-only card-grammar catalog. Read only on + // the client — adapter-static forbids url.searchParams at prerender time. + let showGrammar = $state(false); + $effect(() => { + if (typeof window !== 'undefined') { + showGrammar = new URL(window.location.href).searchParams.get('grammar') === '1'; + } + }); + let runStartedAt = $state(null); + let runWallSeconds = $state(undefined); + + /** Live per-specialist results, keyed by FSM state name (sandy / dep + * / floodnet / ...). Updated incrementally on every `step` event so + * cards stream into the rail as their specialists complete; the + * full final payload merges in once the reconcile event fires. */ + let liveResults = $state>({}); + /** Bumped on every step event so the $derived below recomputes even + * though Svelte doesn't deep-track plain objects. */ + let liveTick = $state(0); + + /** Compose the FindingsData payload. During streaming we feed the + * adapter from `liveResults` (slim per-step summaries). When `final` + * arrives, its richer payload supersedes — same key shape, just + * more fields populated. */ + let findingsData = $derived.by(() => { + void liveTick; + if (finalResult) { + const merged = { ...liveResults, ...finalResult } as Partial; + return adaptFinalToFindings(merged, traceRoot, runWallSeconds, true); + } + return adaptFinalToFindings(liveResults, traceRoot, runWallSeconds, false); + }); + + function handleFindingsLink(key: string | null) { linkedKey = key; } + function handleFindingsCite(citeId: string) { + const el = document.getElementById('region-cites'); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + void citeId; + } + /** Steps that share the Granite TTM r2 foundation model — grouped * under a synthetic parent in the trace UI so the architectural * story ("one foundation model, multiple data streams") is legible @@ -329,10 +376,18 @@ onMount(() => { briefingState.reset(); if (!queryText()) return; + runStartedAt = Date.now(); const stream = openAgentStream(queryText(), { onPlanToken: (d) => (planTokens += d), onPlan: (p) => (plan = p), onStep: (s) => { + // Mirror the step's slim result into liveResults so Findings cards + // can stream in as specialists complete. The card adapter is + // tolerant of partial summaries — at the end of the stream the + // richer `final` payload merges over the top. + applyStepEventToLiveState(liveResults, s.step, s.result, s.ok); + liveTick = liveTick + 1; + // address from the geocode step (single_address / live_now) if (s.step === 'geocode') { if (s.ok && s.result && typeof s.result === 'object') { @@ -471,6 +526,9 @@ }, onDone: () => { streamDone = true; + if (runStartedAt != null) { + runWallSeconds = (Date.now() - runStartedAt) / 1000; + } // v0.4.2 §12 all-silent: stream finished but no briefing emerged. if (!firstTokenSeen && !errorState && geocodeSucceeded) { errorState = 'all-silent'; @@ -582,6 +640,7 @@ proxy311={proxyFc} registerPoints={registerPointsFc} registerPolygons={registerPolygonsFc} + {linkedKey} /> -
- {#if registers.length} -
-
- - subway · NYCHA · schools · hospitals (only those with hits) -
-
- {#each registers as r, i (i)} - - {/each} -
-
- {/if} - -
- +
+