seriffic Claude Opus 4.7 (1M context) commited on
Commit
85dca58
Β·
1 Parent(s): b37e92d

docs: drop in design handoff bundle (Findings v0.4.4)

Browse files

Vendors the entire design_handoff_riprap_findings/ archive under
docs/design_handoff/ β€” README.md + CLAUDE_CODE_PROMPT.md + JSX/HTML
prototypes + tokens.css. Kept alongside the code so the JSX references
are inspectable next to the Svelte port that consumes them.

The JSX is React-based prototype, not production. Per the handoff:

> Recreate them as Svelte components. Lift visual values, copy, and
> interaction logic from the references; do not transpile JSX to
> Svelte.

This branch ships the v0.4.4 Findings region (5 Stones, 12 card
variants, run-health strip, smart provenance, hover linking, card
grammar reference) following that order of work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs/design_handoff/CLAUDE_CODE_PROMPT.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Prompt for Claude Code
2
+
3
+ Copy-paste this whole block into Claude Code as your opening message. It frames the task and points at the files in this bundle.
4
+
5
+ ---
6
+
7
+ You're implementing a high-fidelity design into the existing Riprap codebase, which is **SvelteKit**. The design is a citation-grounded Flood Exposure Briefing UI for NYC, with the new "Findings region" as the centerpiece.
8
+
9
+ The design lives in `design_handoff_riprap_findings/`. **Read `README.md` first**, then the `design_files/` directory. The HTML/JSX prototypes there are React-based references β€” your job is to **port them to Svelte 5 components** using runes (`$state`, `$derived`, `$props`) and the project's existing patterns. Do not copy JSX or React idioms; recreate the same visual + interaction grammar in idiomatic Svelte.
10
+
11
+ **Order of work:**
12
+
13
+ 1. **Tokens first.** Port `design_files/tokens.css` verbatim into the app's global stylesheet (or wherever design tokens live in the existing codebase). The four epistemic tier colors (`--tier-empirical/modeled/proxy/synthetic`), the paper-register neutrals, the IBM Plex font stack, and the spacing scale are non-negotiable β€” every component below references them.
14
+
15
+ 2. **Card grammar.** Build the 12 card-body variants documented in Β§"Card grammar" of the README as small leaf components: `<HeadlineCard>`, `<TabularCard>`, `<ScalarsCard>`, `<SparkCard>`, `<HistogramCard>`, `<TimeseriesCard>`, `<ForecastCard>`, `<RasterCard>`, `<RasterPredCard>`, `<RegisterCard>`, `<ComparisonCard>`, `<MetaCard>`. Each takes a `card` prop matching the schema in Β§"Card data schema". A wrapper `<FindingCard>` renders the chrome (header strip, tier glyph, source, agency, footer with tier badge) and slots the body. Synthetic cards get a dashed top-rule.
16
+
17
+ 3. **Stone region.** A `<StoneRegion>` component groups cards by Stone (cornerstone / keystone / touchstone / lodestone / capstone). Header has the Stone name (serif italic), role tagline, run-tally chip, and a provenance toggle. Provenance trace renders below the header per the smart-default rules in Β§"Provenance trace".
18
+
19
+ 4. **Findings region.** Composes 5 `<StoneRegion>`s in fixed order, with a `<RunHealthStrip>` above them and (optionally) `<CardGrammarReference>` below. Wire props through: `density`, `provenanceMode`, `queryKey`, `showComparison`, `showGrammar`.
20
+
21
+ 5. **Cross-component linking.** Hovering a card sets `linkedKey` (Svelte store or `$state` lifted to the page). The briefing's map frame highlights the matching layer. See Β§"Hover linking" in the README.
22
+
23
+ 6. **Briefing + map + trace.** Once Findings is solid, port `briefing.jsx`, `map.jsx`, and `trace.jsx` similarly. The briefing is a long-form text region with inline citations whose hover state lights up the corresponding map layer.
24
+
25
+ **Conventions to follow (read these before writing code):**
26
+
27
+ - **Svelte 5 runes only.** No legacy `let`-as-state, no stores unless cross-route. Lift state to `+page.svelte` and pass via `$props()`.
28
+ - **No emoji.** No icon font. Tier glyphs are inline SVG; see `glyphs.jsx` for the four shapes (filled square / open square / dotted ring / hatched square).
29
+ - **No Tailwind.** This codebase uses scoped `<style>` blocks per component. Reuse the token CSS variables from step 1 β€” don't redeclare colors or spacing.
30
+ - **No animation libraries.** All transitions are CSS, ≀200ms, and respect `prefers-reduced-motion` (already covered by the global rule in `tokens.css`).
31
+ - **Accessibility is a hard requirement.** Tier is encoded by *color + glyph + label* (never color alone). Focus rings are 3px accent. Every interactive card needs `aria-label`. Provenance toggles are buttons with `aria-expanded`.
32
+
33
+ **What to ignore in the bundle:**
34
+
35
+ - The `Riprap Landing*.html` files are exploratory marketing-page variants, not part of this handoff. Port them only if explicitly asked.
36
+ - `design-canvas.jsx`, `tweaks-panel.jsx`, `landing-variants.*` are prototype-time tooling, not product code.
37
+ - The React component files (`*.jsx`) are reference implementations. Use them to read structure, copy strings/numbers, and verify visual fidelity β€” don't transpile them.
38
+
39
+ **When in doubt, open the HTML file in a browser** (`Riprap Stone-Grouped UI v0.4.4.html`) and compare your Svelte output side-by-side. Pixel parity on the Findings region is the bar.
docs/design_handoff/README.md ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Handoff: Riprap Findings Region (v0.4.4)
2
+
3
+ ## Overview
4
+
5
+ **Riprap** is a citation-grounded Flood Exposure Briefing tool for New York City. A user enters an address, neighborhood, or proposed development; Riprap returns a written briefing where every numeric claim links to its primary public-record source (FEMA, USGS, NYC DOITT, FloodNet, NYC OpenData, etc.).
6
+
7
+ This handoff covers the **Findings region** β€” the structured-data sibling of the briefing prose. It groups model outputs into five named "Stones" (cognitive roles), each rendered as a card stack with explicit epistemic tiering, smart provenance traces, and cross-linking to the map.
8
+
9
+ The target codebase is **SvelteKit** (Svelte 5 with runes). The files in `design_files/` are React-based prototypes β€” design references, not production code. Recreate them as Svelte components.
10
+
11
+ ## About the Design Files
12
+
13
+ The files in `design_files/` are **design references created in HTML + React** β€” high-fidelity prototypes showing the intended look and behavior. They are **not production code to copy directly**.
14
+
15
+ Your task is to **recreate these designs in the existing Svelte codebase**, using its established patterns (Svelte 5 runes, scoped styles, the project's existing route structure and data layer). Lift visual values, copy, and interaction logic from the references; do not transpile JSX to Svelte.
16
+
17
+ **File pairing** (each prototype area has one or two source files):
18
+
19
+ - `Riprap Stone-Grouped UI v0.4.4.html` β€” main prototype, the v0.4.4 Findings region in context
20
+ - `findings.jsx` β€” Findings region: stones, cards, run-health, grammar reference (the centerpiece of this handoff)
21
+ - `briefing.jsx` β€” long-form briefing prose with inline citations
22
+ - `map.jsx` β€” mini-map with FEMA AE / HWM / FloodNet / 311 / address layers and link highlighting
23
+ - `trace.jsx`, `stones-trace.jsx`, `stone-evidence.jsx` β€” provenance trace variants
24
+ - `shell.jsx` β€” app header, footer, cold-start state
25
+ - `glyphs.jsx` β€” four tier glyphs as inline SVG
26
+ - `tokens.css` β€” design tokens (colors, type, spacing). Port verbatim.
27
+ - `styles.css` β€” component CSS. Reference only; rewrite as scoped Svelte styles.
28
+ - `tweaks-panel.jsx`, `design-canvas.jsx`, `landing-variants.*` β€” prototype-time tooling. Ignore.
29
+
30
+ ## Fidelity
31
+
32
+ **High-fidelity (hifi).** Pixel-perfect mockups with final colors, typography, spacing, glyphs, and interactions. Recreate the UI pixel-perfectly using the codebase's existing libraries and patterns. The tier color values, the IBM Plex font stack, the 4/8/12/16/24/32/48/64/96 spacing scale, and the four-tier glyph system are all final.
33
+
34
+ The Findings region in particular has been through several iterations (v0.4.0 β†’ v0.4.4) and is settled. Don't redesign card layouts; port them as-is.
35
+
36
+ ## Screens / Views
37
+
38
+ The product is a single-page app with two states: **cold-start** (no query) and **briefing** (query active).
39
+
40
+ ### 1. Cold-start
41
+
42
+ Empty state with the wordmark, a deck explaining what Riprap is, a query input, three sample-query buttons, and a "How Riprap is built" trust band. See `shell.jsx` β†’ `<ColdStart>`.
43
+
44
+ - **Layout**: centered single column, max-width ~720px, paper background (`--paper`)
45
+ - **Wordmark**: `riprap` lowercase, IBM Plex Mono 14px / 600, with a 0.85em accent bar `β–Œ` prefix
46
+ - **Deck**: serif paragraph, 18px, line-height 1.55, ink-secondary
47
+ - **Query input**: full width, 1px ink border, mono placeholder, 16px
48
+ - **Submit**: ink fill, paper text, mono caps, 13px
49
+ - **Sample queries**: three buttons in a column, each shows mode (caps mono) / query (sans 16px) / sub (mono 12px tertiary)
50
+ - **Trust band**: section-label heading, italic-serif "Cornerstone remembers. Keystone tallies. Touchstone watches. Lodestone projects. Capstone writes it all down with citations." then a bullet list
51
+
52
+ ### 2. Briefing (active query)
53
+
54
+ Three-region layout, vertical stack:
55
+
56
+ 1. **App header** (sticky top): wordmark Β· context Β· query pill Β· methodology / export PDF / live status
57
+ 2. **Briefing prose region**: long-form text with inline `[N]` citations, three side-by-side panes (excerpt Β· evidence cards Β· mini map)
58
+ 3. **Findings region**: the v0.4.4 Stone-grouped card stack (this handoff's focus)
59
+ 4. **Footer**: tier legend + build line
60
+
61
+ The Findings region is the substantial new surface. Everything below documents it.
62
+
63
+ ## Findings Region (v0.4.4) β€” detailed spec
64
+
65
+ ### Composition
66
+
67
+ ```
68
+ <FindingsRegion>
69
+ <RunHealthStrip /> ← 1 row, top, summarizes all 25 model calls
70
+ <StoneRegion stone="cornerstone" />
71
+ <StoneRegion stone="keystone" />
72
+ <StoneRegion stone="touchstone" />
73
+ <StoneRegion stone="lodestone" />
74
+ <StoneRegion stone="capstone" />
75
+ <CardGrammarReference /> ← optional, on by default; one stub per variant
76
+ </FindingsRegion>
77
+ ```
78
+
79
+ ### Stones
80
+
81
+ Five fixed roles, in order:
82
+
83
+ | key | name | role | tag |
84
+ |---|---|---|---|
85
+ | `cornerstone` | Cornerstone | the hazard reader | what NYC's ground remembers |
86
+ | `keystone` | Keystone | the asset register | what's exposed |
87
+ | `touchstone` | Touchstone | the present-tense witness | what's happening now |
88
+ | `lodestone` | Lodestone | the future-pointer | what's coming |
89
+ | `capstone` | Capstone | the writer | what we say, with citations |
90
+
91
+ Each `<StoneRegion>` has:
92
+
93
+ - **Header**: Stone name (IBM Plex Serif 26px italic for the name, sans for the role) Β· role tagline Β· `<StoneTally>` chip showing `N/M cards fired` Β· provenance toggle button
94
+ - **Provenance trace**: smart-default expansion (see below). Renders specialist tree with status pips.
95
+ - **Card grid**: 12-column grid, each card spans 4 cols by default (3 per row); register/timeseries/raster cards may span 6.
96
+
97
+ ### Card data schema
98
+
99
+ ```ts
100
+ type Card = {
101
+ stone: "cornerstone" | "keystone" | "touchstone" | "lodestone" | "capstone";
102
+ tier: "empirical" | "modeled" | "proxy" | "synthetic";
103
+ variant: CardVariant; // see below
104
+ source: string; // short label, e.g. "FEMA"
105
+ agency: string; // long form, e.g. "FEMA preliminary FIRM, panel 36047C..."
106
+ vintage: string; // e.g. "2024-Q3" or "2007–present"
107
+ title: string; // card title
108
+ // variant-specific fields:
109
+ headline?: string; subhead?: string;
110
+ columns?: string[]; rows?: (string | number)[][];
111
+ scalars?: { label: string; value: string; unit?: string }[];
112
+ spark?: number[]; histogram?: number[];
113
+ timeseries?: { hours: number[]; values: number[]; threshold?: number };
114
+ forecast?: { years: number[]; p10: number[]; p50: number[]; p90: number[] };
115
+ raster?: "stormwater" | "fema-ae" | "hwm" | "floodnet-density" | ...;
116
+ register?: { tag: string; label: string; sourceId: string; detail: string }[];
117
+ comparison?: { left: ScalarSet; right: ScalarSet; delta: string };
118
+ meta?: Record<string, string>;
119
+ // citation fan-out:
120
+ cites?: { id: string; label: string; href?: string }[];
121
+ // map link:
122
+ mapLayer?: "fema-ae" | "hwm" | "floodnet" | "nycha" | "address" | ...;
123
+ };
124
+ ```
125
+
126
+ See `findings.jsx` lines 12–230 for the canonical `CARDS` table populated for the Red Hook query.
127
+
128
+ ### Card grammar (12 variants)
129
+
130
+ Every card uses the same chrome β€” title row (sans 14/600), source Β· agency Β· vintage row (mono 11/tertiary), a body block, and a footer with the **tier badge** (3-letter caps mono) and a "cite" button. The body block is one of:
131
+
132
+ | variant | shape | use |
133
+ |---|---|---|
134
+ | `headline` | one big number/label, scenario-tagged subhead | single-fact cards: "Zone AE" |
135
+ | `tabular` | small NΓ—3 table, mono | observation lists: HWM marks |
136
+ | `scalars` | 2–3 labeled scalars in a row | "1.2 m Β· 0.18 mi Β· 2012" |
137
+ | `spark` | 60Γ—24 inline sparkline, no axes | trend at a glance |
138
+ | `histogram` | 8–12 bar histogram, mono labels | distributions |
139
+ | `timeseries` | 240Γ—84 line chart with threshold rule | hourly water level, 311 calls |
140
+ | `forecast` | 240Γ—88 fan chart (p10/p50/p90) | 2050/2080 SLR, surge |
141
+ | `raster` | 240Γ—120 stylized raster thumbnail | FEMA AE polygon, stormwater extent, HWM contour |
142
+ | `raster-pred` | same shape with dashed top-rule (synthetic tier) | TerraMind 2050 prediction |
143
+ | `register` | 3-col dense list (tag Β· label Β· sourceId), detail in `title=` tooltip | NYCHA buildings, schools |
144
+ | `comparison` | side-by-side scalar columns with delta | FEMA-AE vs Prithvi-2050 |
145
+ | `meta` | definition list of run metadata | model id, prompt hash, latency |
146
+
147
+ **Synthetic tier** cards (TerraMind predictions, prior-only Lodestone outputs) get a **dashed top-rule** (1px dashed `--tier-synthetic-line`) to telegraph "no observed data here." Comparison cards always render synthetic.
148
+
149
+ The `<CardGrammarReference>` region renders one stub per variant in `findings.jsx` lines 529–581 β€” a visual catalog the design team uses to spot-check fidelity. Keep it, gate it on `showGrammar` prop (default true in dev, false in prod).
150
+
151
+ ### Tier system
152
+
153
+ Four epistemic tiers, encoded redundantly:
154
+
155
+ | tier | color | glyph | badge | meaning |
156
+ |---|---|---|---|---|
157
+ | `empirical` | `#0B5394` (8.59:1) | filled square | EMP | observed, ground-truth |
158
+ | `modeled` | `#2A6FA8` (5.41:1) | open square | MOD | computed from observations |
159
+ | `proxy` | `#6B6B6B` (5.74:1) | dotted ring | PRX | indirect signal, e.g. 311 calls |
160
+ | `synthetic` | `#2A6FA8` + dashed | hatched square | SYN | model prior, no observation |
161
+
162
+ Glyphs are inline SVG, 12Γ—12, black-stroke. See `glyphs.jsx` for the four shapes.
163
+
164
+ **Accessibility**: tier is *always* encoded by color + glyph + label, never color alone. Modeled and synthetic share a hue; the dashed top-rule and glyph carry the difference.
165
+
166
+ ### Provenance trace
167
+
168
+ Every Stone has a tree of specialists ("CORN-001: pull FEMA NFHL β†’ CORN-002: parse panel index β†’ ..."). Each specialist has a status: `ok` / `warn` / `error` / `silent`.
169
+
170
+ **Smart-default rule** (`provenanceMode = "smart"`):
171
+
172
+ - All-`ok` Stone β†’ **collapsed** by default, single-line summary "12/12 specialists fired clean"
173
+ - Any `warn` or `error` β†’ **expanded**, full tree visible
174
+ - All `silent` β†’ collapsed, single-line "no firings (section omitted from briefing)"
175
+
176
+ `provenanceMode = "expanded"` forces all expanded; `"collapsed"` forces all collapsed (warn/error still get a count chip).
177
+
178
+ The trace UI: indented tree, mono ids, status pip (●/β–²/β– /β—‹) in tier-color or warn/error colors. Specialist names italic-serif. Hovering a specialist row dims the rest. See `trace.jsx` for the leaf and `stones-trace.jsx` for the per-Stone composition.
179
+
180
+ ### Run-health strip
181
+
182
+ Single row above the Stones, full-width. Shows:
183
+
184
+ - Total specialists fired / total specialists registered (e.g. `83/100`)
185
+ - Per-tier breakdown as four chips: `EMP 41 Β· MOD 28 Β· PRX 12 Β· SYN 2`
186
+ - Total runtime (e.g. `3.4s`)
187
+ - Cache-hit ratio (e.g. `92%`)
188
+
189
+ Mono throughout. Background `--paper-deep`, 1px ink-soft top + bottom rule.
190
+
191
+ ### Hover linking
192
+
193
+ Every card with a `mapLayer` is hoverable. On hover (or focus), the card's `key` becomes the page-level `linkedKey`. The briefing's map frame reads `linkedKey` and:
194
+
195
+ - Adds `is-link-{layer}` class to its root, which lights up that layer (see `map.jsx` for the CSS rules)
196
+ - Renders a small label badge bottom-right: "linked: {layer}"
197
+
198
+ The same applies in reverse: hovering a layer in the map sets `linkedKey` to the corresponding card key, which gets a 2px accent outline.
199
+
200
+ In Svelte: lift `linkedKey` to `+page.svelte` as `let linkedKey = $state(null)`, pass it down both branches, and have the cards / layers update it on `pointerenter` / `focus` / `pointerleave` / `blur`.
201
+
202
+ ### Density
203
+
204
+ `density: "compact" | "comfortable"` (default `comfortable`).
205
+
206
+ - **Comfortable**: 16px card padding, 14px line-height multiplier 1.4
207
+ - **Compact**: 10px card padding, 12px line-height multiplier 1.25, register-card row height 18px (vs 24px)
208
+
209
+ Pass through to all card bodies; only register/tabular/meta visibly change.
210
+
211
+ ## Interactions & Behavior
212
+
213
+ - **Card hover**: 200ms `background-color` transition to `--paper-deep`, 2px accent outline if linked
214
+ - **Cite button**: opens a small popover with the full citation list (`cites[]`), each row a link to the source PDF/page
215
+ - **Provenance toggle**: button with `aria-expanded`, animates the tree open/closed via `details`/`summary` or scoped `max-height` transition (≀200ms)
216
+ - **Map layer hover**: sets `linkedKey`, 100ms layer fill opacity transition
217
+ - **Reduced motion**: all transitions become 0.01ms via the global rule in `tokens.css`. Don't add motion on top.
218
+ - **Keyboard**: every card is `tabindex=0` with `aria-label="{tier} card Β· {title} Β· {source}"`. Cite button is real `<button>`. Provenance toggle is real `<button>`.
219
+ - **Loading**: cards show a 1px ink-soft skeleton (no spinner); replace with content when the specialist returns
220
+ - **Error / silent**: cards with status `error` render a 1-line "specialist failed: {reason}" in tier-error color and stay; cards with status `silent` are omitted entirely (silence over confabulation β€” design tenet)
221
+
222
+ ## State Management
223
+
224
+ Svelte 5 runes; lift to `+page.svelte`:
225
+
226
+ ```svelte
227
+ <script>
228
+ let query = $state(null);
229
+ let density = $state("comfortable");
230
+ let provenanceMode = $state("smart");
231
+ let showComparison = $state(false);
232
+ let showGrammar = $state(false); // dev-only toggle
233
+ let linkedKey = $state(null);
234
+
235
+ // Data: load once per query, hydrate from server
236
+ let cardsByStone = $derived(loadCards(query));
237
+ let runHealth = $derived(summarize(cardsByStone));
238
+ </script>
239
+ ```
240
+
241
+ Children take props via `$props()`. No Svelte stores unless cross-route.
242
+
243
+ Data fetching: assume the existing codebase has a `+page.server.ts` load function that runs the 25 specialists and returns the `Card[]` payload. This handoff doesn't change the data layer; it changes the rendering.
244
+
245
+ ## Design Tokens
246
+
247
+ Port `tokens.css` verbatim. Key values:
248
+
249
+ **Tier colors** (all WCAG AA on white):
250
+ - `--tier-empirical: #0B5394` (8.59:1)
251
+ - `--tier-modeled: #2A6FA8` (5.41:1)
252
+ - `--tier-proxy: #6B6B6B` (5.74:1)
253
+ - `--tier-synthetic: #2A6FA8` (pattern-differentiated)
254
+
255
+ **Neutrals**:
256
+ - `--paper: #FAFAF7` (warm near-white, USGS-report register)
257
+ - `--paper-deep: #F2F2EE`
258
+ - `--ink: #1A1A1A` Β· `--ink-secondary: #4A4A4A` Β· `--ink-tertiary: #6B6B6B`
259
+ - `--rule: #1A1A1A` Β· `--rule-soft: #C9C9C5`
260
+
261
+ **Accent**:
262
+ - `--accent: #B8620A` (for text, AA)
263
+ - `--accent-graphical: #D17C00` (for shapes/lines, β‰₯3:1)
264
+
265
+ **Type**:
266
+ - `--font-sans: "IBM Plex Sans"` (UI, body)
267
+ - `--font-serif: "IBM Plex Serif"` (Stone names, hero italic emphasis, oversized stone numerals)
268
+ - `--font-mono: "IBM Plex Mono"` (labels, source ids, badges, table cells)
269
+
270
+ All three are SIL OFL / Apache; self-host or load from Google Fonts. **No system fallbacks for branding** β€” always specify the Plex stack, fall through only on load failure.
271
+
272
+ **Spacing** (`--s-1` through `--s-9`): 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 / 96 px. Use these tokens; don't hand-roll values.
273
+
274
+ **Type scale** (suggested, not enforced):
275
+ - 11px mono labels (`section-label`)
276
+ - 12–13px mono row text
277
+ - 14px sans card titles (600)
278
+ - 16px sans body
279
+ - 18px sans deck text
280
+ - 26px serif italic Stone names
281
+ - 36–52px serif headlines (italic for emphasis)
282
+
283
+ **Radius**: 0 throughout (this is a civic-tech-clean, USGS-report register; no rounded corners except `1px` on focus rings).
284
+
285
+ **Shadows**: none. Differentiation by 1px rules and `--paper-deep` fills only.
286
+
287
+ ## Assets
288
+
289
+ - **Fonts**: IBM Plex Sans / Serif / Mono. Self-host woff2 or Google Fonts.
290
+ - **Wordmark**: text + accent bar `β–Œ` prefix, no logo file
291
+ - **Tier glyphs**: inline SVG, see `glyphs.jsx`
292
+ - **Map raster thumbnails**: hand-drawn SVG approximations using each layer's conventional palette. See `findings.jsx` β†’ `RasterThumb`. Replace with real raster previews if/when MapLibre tile snapshots are wired up.
293
+ - **Real map**: the production map should use **MapLibre GL** with a custom `style.json` that respects the tier palette. Style fragments are sketched in `shell.jsx` comments.
294
+ - **No icon library.** No Lucide, no Heroicons. SVG glyphs for tiers, mono characters (βŒ•, β†—, β–Œ) for chrome.
295
+ - **No emoji.**
296
+
297
+ ## Files in this bundle
298
+
299
+ ```
300
+ design_handoff_riprap_findings/
301
+ β”œβ”€β”€ CLAUDE_CODE_PROMPT.md ← paste this into Claude Code first
302
+ β”œβ”€β”€ README.md ← you are here
303
+ └── design_files/
304
+ β”œβ”€β”€ Riprap Stone-Grouped UI v0.4.4.html ← main prototype, open in browser
305
+ β”œβ”€β”€ Riprap Landing.html
306
+ β”œβ”€β”€ Riprap Landing Variants.html
307
+ β”œβ”€β”€ tokens.css ← port verbatim
308
+ β”œβ”€β”€ styles.css ← reference only
309
+ β”œβ”€β”€ findings.jsx ← Findings region (this handoff's centerpiece)
310
+ β”œβ”€β”€ briefing.jsx ← long-form prose
311
+ β”œβ”€β”€ evidence.jsx ← evidence card stack used in briefing
312
+ β”œβ”€β”€ map.jsx ← mini-map with link highlighting
313
+ β”œβ”€β”€ trace.jsx
314
+ β”œβ”€β”€ stones-trace.jsx
315
+ β”œβ”€β”€ stone-evidence.jsx
316
+ β”œβ”€β”€ shell.jsx ← header, footer, cold-start
317
+ β”œβ”€β”€ glyphs.jsx ← four tier glyphs
318
+ β”œβ”€β”€ tweaks-panel.jsx ← prototype tooling, ignore
319
+ β”œβ”€β”€ design-canvas.jsx ← prototype tooling, ignore
320
+ β”œβ”€β”€ landing-variants.css ← marketing-page exploration, ignore unless asked
321
+ └── landing-variants.jsx ← marketing-page exploration, ignore unless asked
322
+ ```
323
+
324
+ ## Scope
325
+
326
+ **In scope** for this handoff: the Findings region (5 Stones, 12 card variants, run-health strip, smart provenance, hover linking, card grammar reference). Briefing prose and map are in scope to the extent they connect to Findings via `linkedKey`.
327
+
328
+ **Out of scope**: the cold-start state, the marketing landing page, the methodology PDF, the export-PDF flow. Port these later.
329
+
330
+ ## Open questions for the design team
331
+
332
+ These are deliberately *not* resolved in the prototype; raise them with the designer before locking implementation:
333
+
334
+ 1. Should `register` cards paginate when N > 20, or stay scrollable in a fixed-height card?
335
+ 2. The `comparison` card is currently always synthetic (FEMA-AE vs Prithvi-2050). Will there be empirical-vs-empirical comparisons (e.g. FEMA-AE vs HWM observed)?
336
+ 3. Run-health cache-hit ratio β€” is this surfaced to end users, or is it dev-mode only?
337
+ 4. Provenance trace expansion: should expanded state survive across query changes, or reset?
docs/design_handoff/design_files/Riprap Landing Variants.html ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Riprap , Landing variants</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400;1,500&family=IBM+Plex+Serif:wght@400;500;600&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="tokens.css" />
11
+ <link rel="stylesheet" href="landing-variants.css" />
12
+ </head>
13
+ <body>
14
+ <template id="__bundler_thumbnail">
15
+ <svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
16
+ <rect width="100" height="100" fill="#FAFAF7"/>
17
+ <g transform="translate(20 30)" fill="#0B5394">
18
+ <rect x="0" y="0" width="60" height="6"/>
19
+ <rect x="0" y="12" width="60" height="3"/>
20
+ <rect x="0" y="20" width="42" height="3"/>
21
+ <rect x="0" y="30" width="60" height="10"/>
22
+ </g>
23
+ </svg>
24
+ </template>
25
+ <div id="root"></div>
26
+
27
+ <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
28
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
29
+ <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
30
+
31
+ <script type="text/babel" src="design-canvas.jsx"></script>
32
+ <script type="text/babel" src="landing-variants.jsx"></script>
33
+ </body>
34
+ </html>
docs/design_handoff/design_files/Riprap Landing.html ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Riprap Β· Flood Exposure Briefing for NYC</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400;1,500&family=IBM+Plex+Serif:wght@400;500;600&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="tokens.css" />
11
+ <link rel="stylesheet" href="landing-variants.css" />
12
+ <style>
13
+ .land-page { max-width: 1200px; margin: 0 auto; }
14
+ .land-section-stones-detail { background: var(--paper-deep); padding: 56px 32px; border-top: 1px solid var(--rule-soft); }
15
+ .land-stones-detail { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0; background: white; border: 1px solid var(--rule-soft); border-bottom: 2px solid var(--ink); }
16
+ .land-stones-detail-cell { position: relative; padding: 28px 18px 22px; border-right: 1px solid var(--rule-soft); display: flex; flex-direction: column; gap: 8px; overflow: hidden; }
17
+ .land-stones-detail-cell:last-child { border-right: none; }
18
+ .land-stones-detail-num { position: absolute; top: 6px; right: 10px; font-family: var(--font-serif); font-style: italic; font-weight: 400; font-size: 38px; line-height: 1; color: var(--rule-soft); letter-spacing: -0.02em; pointer-events: none; }
19
+ .land-stones-detail-name { font-family: var(--font-serif); font-size: 22px; font-weight: 500; margin: 0; color: var(--ink); }
20
+ .land-stones-detail-role { font-family: var(--font-sans); font-size: 13px; color: var(--ink-secondary); }
21
+ .land-stones-detail-tag { font-family: var(--font-serif); font-style: italic; font-size: 14px; color: var(--ink-tertiary); margin: 0 0 6px; line-height: 1.45; }
22
+ .land-stones-detail-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.55; }
23
+ .land-stones-deck { font-family: var(--font-serif); font-size: 17px; line-height: 1.6; color: var(--ink-secondary); max-width: 70ch; margin: 0 0 22px; }
24
+
25
+ /* Compact "What you'll get back" Β· three panes (excerpt + evidence + map) */
26
+ .land-preview-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 14px; align-items: stretch; }
27
+ .land-preview-pane { background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
28
+ .land-preview-pane .land-preview-eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-tertiary); margin: 0; }
29
+ .land-preview-pane-excerpt .land-preview-body { font-family: var(--font-serif); font-size: 15px; line-height: 1.55; color: var(--ink); margin: 0; }
30
+ .land-preview-pane-excerpt .land-preview-cites { display: flex; flex-direction: column; gap: 4px; padding-top: 10px; border-top: 1px dashed var(--rule-soft); }
31
+ .land-preview-pane-excerpt .land-preview-cite-row { grid-template-columns: 30px 1fr 70px; gap: 8px; font-size: 11px; }
32
+
33
+ .land-preview-pane-cards { gap: 8px; }
34
+ .land-evcard { background: var(--paper); border: 1px solid var(--rule-soft); padding: 8px 10px; display: flex; flex-direction: column; gap: 3px; }
35
+ .land-evcard-empirical { border-left: 2px solid var(--tier-empirical); }
36
+ .land-evcard-modeled { border-left: 2px solid var(--tier-modeled); }
37
+ .land-evcard-head { display: flex; justify-content: space-between; align-items: baseline; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.06em; }
38
+ .land-evcard-tier { color: var(--ink-secondary); text-transform: uppercase; }
39
+ .land-evcard-empirical .land-evcard-tier { color: var(--tier-empirical); }
40
+ .land-evcard-modeled .land-evcard-tier { color: var(--tier-modeled); }
41
+ .land-evcard-id { color: var(--ink-tertiary); }
42
+ .land-evcard-claim { font-family: var(--font-sans); font-size: 12.5px; line-height: 1.35; color: var(--ink); }
43
+ .land-evcard-source { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-tertiary); }
44
+
45
+ .land-preview-pane-map { padding: 16px 18px; }
46
+ .land-mapmini { position: relative; aspect-ratio: 6 / 5; border: 1px solid var(--rule-soft); overflow: hidden; }
47
+ .land-mapmini-legend { position: absolute; left: 6px; bottom: 6px; right: 6px; display: flex; gap: 10px; padding: 4px 6px; background: rgba(255,255,255,0.92); font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.04em; color: var(--ink-secondary); }
48
+ .land-mapmini-legend span { display: inline-flex; align-items: center; gap: 4px; }
49
+ .lm-sw { display: inline-block; width: 8px; height: 8px; }
50
+ .lm-sw-emp { background: var(--tier-empirical); }
51
+ .lm-sw-mod { background: rgba(42,111,168,0.4); border: 1px dashed var(--tier-modeled); }
52
+ .lm-sw-prx { background: transparent; border: 1px solid #6B6B6B; border-radius: 50%; }
53
+ .land-preview-mapmeta { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-tertiary); }
54
+
55
+ @media (max-width: 1000px) {
56
+ .land-preview-grid { grid-template-columns: 1fr 1fr; }
57
+ .land-preview-pane-excerpt { grid-column: 1 / -1; }
58
+ }
59
+ @media (max-width: 640px) {
60
+ .land-preview-grid { grid-template-columns: 1fr; }
61
+ .land-preview-pane-excerpt { grid-column: auto; }
62
+ }
63
+ @media (max-width: 880px) {
64
+ .land-stones-detail { grid-template-columns: 1fr; }
65
+ .land-stones-detail-cell { border-right: none; border-bottom: 1px solid var(--rule-soft); }
66
+ .land-stones-detail-cell:last-child { border-bottom: none; }
67
+ }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <template id="__bundler_thumbnail">
72
+ <svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
73
+ <rect width="100" height="100" fill="#FAFAF7"/>
74
+ <g transform="translate(20 30)" fill="#0B5394">
75
+ <rect x="0" y="0" width="60" height="6"/>
76
+ <rect x="0" y="12" width="60" height="3"/>
77
+ <rect x="0" y="20" width="42" height="3"/>
78
+ <rect x="0" y="30" width="60" height="10"/>
79
+ </g>
80
+ </svg>
81
+ </template>
82
+ <div id="root"></div>
83
+
84
+ <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
85
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
86
+ <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
87
+
88
+ <script type="text/babel">
89
+ const { useState, useEffect } = React;
90
+
91
+ const SAMPLE_QUERIES = [
92
+ "80 Pioneer Street, Red Hook",
93
+ "Coney Island Hospital",
94
+ "PS 188, Lower East Side",
95
+ "Hammels Houses, Rockaway",
96
+ "Bowling Green station",
97
+ "555 W 57th Street",
98
+ ];
99
+
100
+ const STONE_FRIEZE = [
101
+ { name: "Cornerstone", role: "the hazard reader", tag: "what NYC's ground remembers", sources: "USGS HWMs Β· FEMA NFHL Β· DEP stormwater Β· Prithvi historical" },
102
+ { name: "Keystone", role: "the asset register", tag: "what's exposed", sources: "MTA Β· NYCHA Β· DOE Β· DOH Β· PLUTO" },
103
+ { name: "Touchstone", role: "the live observer", tag: "what's happening now", sources: "FloodNet sensors Β· 311 complaints Β· tidal gauges" },
104
+ { name: "Lodestone", role: "the projector", tag: "what's coming", sources: "NPCC4 Β· TTM foundation model Β· TerraMind synthetic SAR Β· NFIP" },
105
+ { name: "Capstone", role: "the synthesizer", tag: "writes it all down", sources: "Granite composer Β· Mellea grounding-check Β· WeasyPrint" },
106
+ ];
107
+
108
+ const QueryBox = () => (
109
+ <form className="land-query land-query-lg" onSubmit={(e) => e.preventDefault()}>
110
+ <span className="land-query-prompt" aria-hidden="true">β€Ί</span>
111
+ <input
112
+ type="text"
113
+ placeholder="Address, neighborhood, or BBL. e.g. 80 Pioneer Street, Red Hook"
114
+ className="land-query-input"
115
+ aria-label="Query an address, neighborhood, or BBL"
116
+ />
117
+ <button type="submit" className="land-query-submit">Brief this place β†’</button>
118
+ </form>
119
+ );
120
+
121
+ const CyclingExamples = () => {
122
+ const [i, setI] = useState(0);
123
+ useEffect(() => {
124
+ const t = setInterval(() => setI((x) => (x + 1) % SAMPLE_QUERIES.length), 2200);
125
+ return () => clearInterval(t);
126
+ }, []);
127
+ return (
128
+ <div className="land-cycling" aria-live="polite">
129
+ <span className="land-cycling-label">Try:</span>
130
+ <span className="land-cycling-rail">
131
+ {SAMPLE_QUERIES.map((q, idx) => (
132
+ <span key={q} className={`land-cycling-item ${idx === i ? "is-active" : ""}`} aria-hidden={idx !== i}>
133
+ {q}
134
+ </span>
135
+ ))}
136
+ </span>
137
+ </div>
138
+ );
139
+ };
140
+
141
+ const GroundedOutputPreview = () => (
142
+ <div className="land-preview-grid">
143
+ {/* Pane 1 Β· Excerpt */}
144
+ <div className="land-preview-pane land-preview-pane-excerpt">
145
+ <div className="land-preview-eyebrow">Briefing excerpt</div>
146
+ <p className="land-preview-body">
147
+ The lot sits inside the FEMA <span className="land-preview-cite">1% AE flood zone <sup>[c3]</sup></span>,
148
+ with Sandy high-water marks recorded
149
+ <span className="land-preview-cite"> 4.7 ft above grade <sup>[c1]</sup></span>.
150
+ FloodNet FN-BK-018 has logged
151
+ <span className="land-preview-cite"> 14 nuisance floods since 2023 <sup>[c2]</sup></span>.
152
+ </p>
153
+ <div className="land-preview-cites">
154
+ <div className="land-preview-cite-row">
155
+ <span className="land-preview-cite-pin">[c1]</span>
156
+ <span className="land-preview-cite-src">USGS HWM Β· Sandy 2012</span>
157
+ <span className="land-preview-cite-tier">empirical</span>
158
+ </div>
159
+ <div className="land-preview-cite-row">
160
+ <span className="land-preview-cite-pin">[c2]</span>
161
+ <span className="land-preview-cite-src">FloodNet FN-BK-018</span>
162
+ <span className="land-preview-cite-tier">empirical</span>
163
+ </div>
164
+ <div className="land-preview-cite-row">
165
+ <span className="land-preview-cite-pin">[c3]</span>
166
+ <span className="land-preview-cite-src">FEMA NFHL Β· 36047C0207</span>
167
+ <span className="land-preview-cite-tier">modeled</span>
168
+ </div>
169
+ </div>
170
+ </div>
171
+
172
+ {/* Pane 2 Β· Evidence cards (2x2 grid) */}
173
+ <div className="land-preview-pane land-preview-pane-cards">
174
+ <div className="land-preview-eyebrow">Evidence cards</div>
175
+ <div className="land-evcard-grid">
176
+ <article className="land-evcard land-evcard-empirical">
177
+ <header className="land-evcard-head">
178
+ <span className="land-evcard-tier">empirical</span>
179
+ <span className="land-evcard-id">e1</span>
180
+ </header>
181
+ <div className="land-evcard-claim">4.7 ft Sandy storm-surge HWM at address</div>
182
+ <div className="land-evcard-source">USGS High-Water Mark database Β· 2012</div>
183
+ </article>
184
+ <article className="land-evcard land-evcard-empirical">
185
+ <header className="land-evcard-head">
186
+ <span className="land-evcard-tier">empirical</span>
187
+ <span className="land-evcard-id">e2</span>
188
+ </header>
189
+ <div className="land-evcard-claim">14 nuisance-flood events, 2023–2026</div>
190
+ <div className="land-evcard-source">FloodNet FN-BK-018 Β· 2 blocks north</div>
191
+ </article>
192
+ <article className="land-evcard land-evcard-modeled">
193
+ <header className="land-evcard-head">
194
+ <span className="land-evcard-tier">modeled</span>
195
+ <span className="land-evcard-id">e3</span>
196
+ </header>
197
+ <div className="land-evcard-claim">FEMA 1% annual-chance (AE) flood zone</div>
198
+ <div className="land-evcard-source">FEMA NFHL Β· panel 36047C0207</div>
199
+ </article>
200
+ <article className="land-evcard land-evcard-modeled">
201
+ <header className="land-evcard-head">
202
+ <span className="land-evcard-tier">modeled</span>
203
+ <span className="land-evcard-id">e5</span>
204
+ </header>
205
+ <div className="land-evcard-claim">+30 in MSL by 2070 (NPCC4 high)</div>
206
+ <div className="land-evcard-source">NPCC4 SLR projection Β· 2024</div>
207
+ </article>
208
+ </div>
209
+ <div className="land-preview-spacer"/>
210
+ </div>
211
+
212
+ {/* Pane 3 Β· Mini map */}
213
+ <div className="land-preview-pane land-preview-pane-map">
214
+ <div className="land-preview-eyebrow">Map</div>
215
+ <div className="land-mapmini" role="img" aria-label="Sample exposure map of Red Hook">
216
+ <svg viewBox="0 0 240 200" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid slice" style={{ display: "block", width: "100%", height: "100%" }}>
217
+ {/* base */}
218
+ <rect width="240" height="200" fill="#F2F2EE"/>
219
+ {/* water */}
220
+ <path d="M0,150 Q60,140 120,148 T240,140 L240,200 L0,200 Z" fill="#D6DDE0"/>
221
+ <path d="M0,90 Q40,82 90,88 L120,86 L120,150 L0,150 Z" fill="#D6DDE0"/>
222
+ {/* AE zone (modeled) Β· translucent fill */}
223
+ <path d="M30,80 L130,76 L160,140 L40,148 Z" fill="rgba(42,111,168,0.22)" stroke="#2A6FA8" strokeWidth="0.8" strokeDasharray="3 2"/>
224
+ {/* HWM contour (empirical) */}
225
+ <path d="M50,90 Q90,84 130,90 T200,108" fill="none" stroke="#0B5394" strokeWidth="1.2"/>
226
+ {/* streets */}
227
+ <g stroke="#B8B5AE" strokeWidth="0.5" fill="none">
228
+ <path d="M0,60 L240,52"/>
229
+ <path d="M0,110 L240,102"/>
230
+ <path d="M60,0 L72,200"/>
231
+ <path d="M120,0 L132,200"/>
232
+ <path d="M180,0 L192,200"/>
233
+ </g>
234
+ {/* FloodNet sensor (empirical Β· square) */}
235
+ <g transform="translate(108 92)">
236
+ <rect x="-3" y="-3" width="6" height="6" fill="#0B5394" stroke="white" strokeWidth="0.8"/>
237
+ </g>
238
+ {/* 311 cluster (proxy Β· open circles) */}
239
+ <g fill="none" stroke="#6B6B6B" strokeWidth="0.8">
240
+ <circle cx="80" cy="120" r="3"/>
241
+ <circle cx="86" cy="124" r="3"/>
242
+ <circle cx="92" cy="118" r="3"/>
243
+ </g>
244
+ {/* queried address pin */}
245
+ <g transform="translate(118 112)">
246
+ <circle r="8" fill="none" stroke="#1A1A1A" strokeWidth="1.2"/>
247
+ <circle r="2.4" fill="#1A1A1A"/>
248
+ </g>
249
+ </svg>
250
+ <div className="land-mapmini-legend">
251
+ <span><span className="lm-sw lm-sw-emp"/>empirical</span>
252
+ <span><span className="lm-sw lm-sw-mod"/>modeled</span>
253
+ <span><span className="lm-sw lm-sw-prx"/>proxy</span>
254
+ </div>
255
+ </div>
256
+ <div className="land-preview-mapmeta">Red Hook Β· z16 Β· Carto Positron</div>
257
+ </div>
258
+ </div>
259
+ );
260
+
261
+ const StonesSection = () => (
262
+ <section className="land-section-stones-detail" id="methodology">
263
+ <div className="land-page">
264
+ <div className="land-section-head">
265
+ <span className="section-label">How Riprap reads a place</span>
266
+ <span className="land-section-meta">Five Stones Β· one taxonomy Β· every briefing</span>
267
+ </div>
268
+ <p className="land-stones-deck">
269
+ Each briefing routes through a fixed taxonomy of public-record specialists. Each Stone is a
270
+ class of evidence. Together they form the briefing, and every claim in the output traces
271
+ back to the Stone that produced it.
272
+ </p>
273
+ <div className="land-stones-detail">
274
+ {STONE_FRIEZE.map((s, i) => (
275
+ <article key={s.name} className="land-stones-detail-cell">
276
+ <div className="land-stones-detail-num">{String(i + 1).padStart(2, "0")}</div>
277
+ <h3 className="land-stones-detail-name">{s.name}</h3>
278
+ <div className="land-stones-detail-role">{s.role}</div>
279
+ <p className="land-stones-detail-tag">{s.tag}</p>
280
+ <div className="land-stones-detail-sources">{s.sources}</div>
281
+ </article>
282
+ ))}
283
+ </div>
284
+ </div>
285
+ </section>
286
+ );
287
+
288
+ const Landing = () => (
289
+ <div className="land land-v1">
290
+ <header className="land-header">
291
+ <span className="riprap-wordmark">riprap</span>
292
+ <span className="land-header-sep">/</span>
293
+ <span className="land-header-context">Flood Exposure Briefing Β· NYC</span>
294
+ <nav className="land-header-nav">
295
+ <a href="#methodology">Methodology</a>
296
+ <a href="#sources">Sources</a>
297
+ <a href="#about">About</a>
298
+ </nav>
299
+ </header>
300
+
301
+ <div className="land-page">
302
+ <main className="land-hero land-hero-v1">
303
+ <h1 className="land-hero-h1">
304
+ <span className="land-hero-headline">A flood exposure briefing<br/>for <em>any place</em> in New York City.</span>
305
+ <span className="land-hero-deck">
306
+ Type an address. Get a written briefing where every numeric claim
307
+ links to its primary public-record source.
308
+ </span>
309
+ </h1>
310
+ <QueryBox/>
311
+ <CyclingExamples/>
312
+ </main>
313
+ <section className="land-section land-section-v1">
314
+ <div className="land-section-head">
315
+ <span className="section-label">What you'll get back</span>
316
+ <span className="land-section-meta">A grounded paragraph with citations, not a chatbot answer.</span>
317
+ </div>
318
+ <GroundedOutputPreview/>
319
+ </section>
320
+ </div>
321
+
322
+ <StonesSection/>
323
+
324
+ <footer className="land-footer">
325
+ <span className="land-footer-tiers">
326
+ <span className="land-footer-tier"><span className="lm-sw lm-sw-emp"/>empirical</span>
327
+ <span className="land-footer-tier"><span className="lm-sw lm-sw-mod"/>modeled</span>
328
+ <span className="land-footer-tier"><span className="lm-sw lm-sw-prx"/>proxy</span>
329
+ <span className="land-footer-tier"><span className="lm-sw lm-sw-syn"/>synthetic</span>
330
+ </span>
331
+ <span className="land-footer-build">Riprap v0.4.4 Β· NYC OpenData Β· FEMA NFHL Β· USGS Β· NPCC4</span>
332
+ </footer>
333
+ </div>
334
+ );
335
+
336
+ ReactDOM.createRoot(document.getElementById("root")).render(<Landing/>);
337
+ </script>
338
+ </body>
339
+ </html>
docs/design_handoff/design_files/Riprap Stone-Grouped UI v0.4.4.html ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Riprap , Stone-grouped UI mockup v0.4.4</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400;1,500&family=IBM+Plex+Serif:wght@400;500;600&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="tokens.css" />
11
+ <link rel="stylesheet" href="styles.css" />
12
+ <style>
13
+ .stone-mock-page { background: var(--paper); padding: 24px 32px 64px; }
14
+ .stone-mock-page > .stone-mock-section + .stone-mock-section { margin-top: 32px; }
15
+ .stone-mock-head { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 12px; border-bottom: 2px solid var(--ink); margin-bottom: 18px; }
16
+ .stone-mock-head h1 { font-family: var(--font-serif); font-size: 26px; font-weight: 600; margin: 0; }
17
+ .stone-mock-head .meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.06em; }
18
+
19
+ /* Stone-grouped evidence layout */
20
+ .stone-ev-layout-head { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 10px; margin-bottom: 14px; border-bottom: 1px solid var(--rule-soft); }
21
+ .stone-ev-layout-meta { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--ink-tertiary); }
22
+
23
+ .stone-ev-group { background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); margin-bottom: 14px; }
24
+ .stone-ev-head { display: grid; grid-template-columns: 1fr auto; gap: 12px; padding: 12px 18px; background: var(--stone-band-bg); border-bottom: 1px solid var(--rule-soft); align-items: baseline; }
25
+ .stone-ev-name-row { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
26
+ .stone-ev-name { font-family: var(--font-sans); font-size: 16px; font-weight: 600; margin: 0; color: var(--ink); }
27
+ .stone-ev-role { font-size: 13px; color: var(--ink-secondary); }
28
+ .stone-ev-tag { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--ink-tertiary); }
29
+ .stone-ev-meta { display: flex; gap: 10px; align-items: baseline; font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); }
30
+ .stone-ev-count { font-weight: 600; color: var(--ink); }
31
+ .stone-ev-tally { display: inline-flex; align-items: center; gap: 4px; }
32
+ .stone-ev-rail { padding: 12px 14px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; align-items: start; }
33
+ .stone-ev-rail > * { min-width: 0; }
34
+ .stone-ev-empty { padding: 16px 18px; color: var(--ink-tertiary); font-style: italic; font-size: 13px; max-width: 60ch; }
35
+ .stone-ev-empty p { margin: 6px 0 0; line-height: 1.55; }
36
+
37
+ /* Mockup-specific: kill sticky map within the long single-page v0.4.4 layout */
38
+ .stone-mock-page .app-region-map { position: static; max-height: none; }
39
+
40
+ /* Trace row primitives (used inside unified bands) */
41
+ .trace-row { display: grid; grid-template-columns: 16px 1fr 70px 90px 70px; gap: 12px; align-items: baseline; padding: 5px 18px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); }
42
+ .trace-row-group > summary { display: grid; grid-template-columns: 16px 1fr 70px 90px 70px; gap: 12px; align-items: baseline; cursor: pointer; list-style: none; }
43
+ .trace-row-group > summary::-webkit-details-marker { display: none; }
44
+ .trace-name { color: var(--ink); }
45
+ .trace-status { color: var(--ink-tertiary); font-size: 11px; }
46
+ .trace-status-err { color: var(--status-error); }
47
+ .trace-tier { font-size: 11px; }
48
+ .trace-ms { text-align: right; color: var(--ink); }
49
+ .trace-row-error { background: var(--status-error-soft); }
50
+ .trace-row-warn .trace-bullet { color: #B26500; font-weight: 700; }
51
+ .trace-row-silent { color: var(--ink-tertiary); }
52
+ .trace-warn-note, .trace-note, .trace-error-summary { grid-column: 2 / -1; font-size: 11px; color: var(--ink-tertiary); font-style: italic; padding-top: 2px; }
53
+ .trace-error-summary { color: var(--status-error); font-style: normal; }
54
+
55
+ /* Run-health strip */
56
+ .run-health { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; padding: 10px 18px; margin-bottom: 14px; background: var(--paper-deep); border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); }
57
+ .run-health-item strong { color: var(--ink); font-weight: 600; }
58
+ .run-health-sep { color: var(--ink-tertiary); }
59
+ .run-health-silent { color: var(--ink-tertiary); }
60
+ .run-health-warn { color: #B26500; }
61
+ .run-health-error { color: var(--status-error); font-weight: 600; }
62
+
63
+ /* Unified Stone band */
64
+ .stone-uni { background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); margin-bottom: 14px; }
65
+ .stone-uni-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 12px 18px; background: var(--stone-band-bg); border-bottom: 1px solid var(--rule-soft); }
66
+ .stone-uni-head-left { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
67
+ .stone-uni-name { font-family: var(--font-sans); font-size: 16px; font-weight: 600; margin: 0; color: var(--ink); }
68
+ .stone-uni-role { font-size: 13px; color: var(--ink-secondary); }
69
+ .stone-uni-tag { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--ink-tertiary); }
70
+ .stone-uni-agg { font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); display: inline-flex; gap: 4px; flex-wrap: wrap; align-items: baseline; }
71
+ .stone-uni-agg-cards { color: var(--ink); font-weight: 600; }
72
+ .stone-uni-agg-num { color: var(--ink); font-weight: 600; }
73
+ .stone-uni-agg-warn { color: #B26500; font-weight: 600; }
74
+ .stone-uni-agg-err { color: var(--status-error); font-weight: 600; }
75
+ .stone-uni-agg-ms { color: var(--ink); font-weight: 600; }
76
+ .stone-uni-agg-sep { color: var(--ink-tertiary); margin: 0 2px; }
77
+ .stone-uni-rail { padding: 12px 14px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; align-items: start; }
78
+ .stone-uni-rail > * { min-width: 0; }
79
+ .stone-uni-empty { padding: 14px 18px; color: var(--ink-tertiary); font-size: 13px; max-width: 70ch; }
80
+ .stone-uni-empty p { margin: 6px 0 0; line-height: 1.55; font-style: italic; }
81
+ .stone-uni-trace { border-top: 1px dashed var(--rule-soft); }
82
+ .stone-uni-trace-toggle { width: 100%; display: flex; align-items: baseline; gap: 8px; padding: 8px 18px; background: transparent; border: none; cursor: pointer; text-align: left; font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.04em; }
83
+ .stone-uni-trace-toggle:hover { background: var(--paper-deep); color: var(--ink-secondary); }
84
+ .stone-uni-trace-caret { display: inline-block; width: 10px; }
85
+ .stone-uni-trace-body { padding: 4px 0 8px; background: var(--paper); border-top: 1px solid var(--rule-soft); }
86
+
87
+ /* ════════════════════════════════════════════════════════════════════
88
+ v0.4.4 Β· Findings region
89
+ ════════════════════════════════════════════════════════════════════ */
90
+ .findings { background: var(--paper); }
91
+ .findings-head { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 12px; border-bottom: 2px solid var(--ink); margin-bottom: 18px; }
92
+ .findings-h2 { font-family: var(--font-serif); font-size: 26px; font-weight: 600; margin: 0; }
93
+ .findings-tagline { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.04em; }
94
+
95
+ .f-runhealth { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; padding: 10px 18px; margin-bottom: 14px; background: var(--paper-deep); border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); }
96
+ .f-rh-item strong { color: var(--ink); font-weight: 600; }
97
+ .f-rh-sep { color: var(--ink-tertiary); }
98
+ .f-rh-silent { color: var(--ink-tertiary); }
99
+ .f-rh-warn { color: #B26500; }
100
+ .f-rh-err { color: var(--status-error); font-weight: 600; }
101
+
102
+ .f-region { background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); margin-bottom: 14px; }
103
+ .f-region-head { display: flex; justify-content: space-between; gap: 12px; padding: 12px 18px; background: var(--stone-band-bg); border-bottom: 1px solid var(--rule-soft); align-items: baseline; flex-wrap: wrap; }
104
+ .f-region-head-left { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; min-width: 0; }
105
+ .f-region-num { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.06em; }
106
+ .f-region-name { font-family: var(--font-sans); font-size: 16px; font-weight: 600; margin: 0; color: var(--ink); }
107
+ .f-region-role { font-size: 13px; color: var(--ink-secondary); }
108
+ .f-region-tag { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--ink-tertiary); }
109
+
110
+ .f-tally { font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); display: inline-flex; gap: 6px; flex-wrap: wrap; align-items: baseline; }
111
+ .f-tally-strong { color: var(--ink); font-weight: 600; }
112
+ .f-tally-cards { color: var(--ink); font-weight: 600; }
113
+ .f-tally-sep { color: var(--ink-tertiary); }
114
+ .f-tally-warn { color: #B26500; }
115
+ .f-tally-err { color: var(--status-error); }
116
+ .f-tally-silent { color: var(--ink-tertiary); }
117
+
118
+ .f-rail { padding: 14px; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; align-items: start; }
119
+ .f-rail > * { min-width: 0; }
120
+ .f-rail-capstone { grid-template-columns: minmax(360px, 480px); }
121
+
122
+ .f-silent { padding: 14px 18px; display: flex; flex-direction: column; gap: 6px; max-width: 70ch; }
123
+ .f-silent-tag { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-tertiary); align-self: flex-start; padding: 2px 6px; border: 1px solid var(--rule-soft); }
124
+ .f-silent-prose { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--ink-tertiary); margin: 0; line-height: 1.55; }
125
+
126
+ .f-prov { border-top: 1px dashed var(--rule-soft); }
127
+ .f-prov-toggle { width: 100%; display: flex; align-items: baseline; gap: 8px; padding: 8px 18px; background: transparent; border: none; cursor: pointer; text-align: left; font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.04em; }
128
+ .f-prov-toggle:hover { background: var(--paper-deep); color: var(--ink-secondary); }
129
+ .f-prov-caret { display: inline-block; width: 10px; }
130
+ .f-prov-meta { color: var(--ink-tertiary); }
131
+ .f-prov-body { padding: 4px 0 8px; background: var(--paper); border-top: 1px solid var(--rule-soft); }
132
+
133
+ /* ─── Card frame ─── */
134
+ .fc { border: 1px solid var(--rule-soft); background: var(--paper); padding: 12px 14px 10px; display: flex; flex-direction: column; gap: 8px; position: relative; border-top: 2px solid var(--ink); transition: border-color 120ms, box-shadow 120ms; cursor: pointer; }
135
+ .fc-tier-empirical { border-top-color: var(--tier-empirical); }
136
+ .fc-tier-modeled { border-top-color: var(--tier-modeled); }
137
+ .fc-tier-proxy { border-top-color: var(--tier-proxy); }
138
+ .fc-tier-synthetic { border-top-color: var(--tier-synthetic); border-top-style: dashed; }
139
+ .fc:hover, .fc.is-linked { box-shadow: 0 1px 0 var(--ink), 0 0 0 1px var(--ink); }
140
+ .fc.is-compact { padding: 8px 12px 8px; gap: 6px; }
141
+
142
+ .fc-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
143
+ .fc-head-source { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-sans); font-size: 12px; font-weight: 600; color: var(--ink); }
144
+ .fc-head-source-label { letter-spacing: 0.01em; }
145
+ .fc-head-vintage { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-tertiary); letter-spacing: 0.02em; }
146
+
147
+ .fc-title { font-family: var(--font-sans); font-size: 13.5px; font-weight: 600; line-height: 1.3; margin: 0; color: var(--ink); text-wrap: pretty; }
148
+ .fc.is-compact .fc-title { font-size: 13px; }
149
+
150
+ .fc-body { display: flex; flex-direction: column; gap: 6px; padding: 2px 0; }
151
+ .fc-body-prose { font-size: 12px; line-height: 1.5; color: var(--ink-secondary); margin: 4px 0 0; }
152
+ .fc-body-sub { font-size: 11px; line-height: 1.5; color: var(--ink-tertiary); font-style: italic; }
153
+ .fc.is-compact .fc-body-prose, .fc.is-compact .fc-body-sub { font-size: 11px; }
154
+
155
+ .fc-headline { font-family: var(--font-serif); font-size: 22px; font-weight: 600; line-height: 1.1; }
156
+ .fc.is-compact .fc-headline { font-size: 19px; }
157
+ .fc-subhead { font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); letter-spacing: 0.02em; }
158
+
159
+ .fc-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 11px; }
160
+ .fc-table th, .fc-table td { text-align: left; padding: 3px 6px; border-bottom: 1px solid var(--rule-soft); }
161
+ .fc-table th { color: var(--ink-tertiary); font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; font-size: 9px; }
162
+
163
+ .fc-body-scalars .fc-scalars-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 4px 0; }
164
+ .fc-scalar-cell { display: flex; flex-direction: column; gap: 2px; padding: 6px 4px; background: var(--paper-deep); border: 1px solid var(--rule-soft); }
165
+ .fc-scalar-value { font-family: var(--font-serif); font-size: 18px; font-weight: 600; line-height: 1.05; }
166
+ .fc-scalar-label { font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-tertiary); letter-spacing: 0.04em; }
167
+
168
+ .fc-raster-frame { position: relative; border: 1px solid var(--rule-soft); }
169
+ .fc-illustrative { position: absolute; top: 4px; right: 4px; font-family: var(--font-mono); font-size: 8.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--paper); background: rgba(26,26,26,0.7); padding: 1px 5px; }
170
+ .fc-raster-headline { font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); }
171
+ .fc-raster-headline span { font-family: var(--font-serif); font-size: 14px; font-weight: 600; }
172
+
173
+ .fc-body-timeseries { gap: 4px; }
174
+ .fc-ts-header { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
175
+ .fc-spatial-note { display: inline-block; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); border: 1px solid var(--accent); padding: 1px 5px; margin-right: 6px; vertical-align: middle; }
176
+
177
+ /* Register composite, dense row layout (v0.4.4 follow-up: cut card height) */
178
+ .fc-body-register .fc-reg-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0; border: 1px solid var(--rule-soft); }
179
+ .fc-reg-row { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: 6px 10px; align-items: baseline; padding: 3px 8px; border-bottom: 1px solid var(--rule-soft); font-size: 12px; line-height: 1.35; }
180
+ .fc-reg-row:last-child { border-bottom: none; }
181
+ .fc-reg-row.is-silent { grid-template-columns: 44px minmax(0, 1fr); color: var(--ink-tertiary); font-style: italic; background: var(--paper-deep); padding: 2px 8px; }
182
+ .fc-reg-tag { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.08em; color: var(--ink-tertiary); align-self: center; }
183
+ .fc-reg-label { font-family: var(--font-sans); font-size: 12px; font-weight: 500; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
184
+ .fc-reg-detail { display: none; }
185
+ .fc-reg-tier { display: inline-flex; align-self: center; }
186
+ .fc-reg-source { font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-tertiary); align-self: center; white-space: nowrap; }
187
+ .fc-reg-silent { font-family: var(--font-serif); font-size: 11.5px; color: var(--ink-tertiary); }
188
+ .fc.is-compact .fc-reg-row { padding: 2px 8px; font-size: 11.5px; }
189
+ .fc.is-compact .fc-reg-label { font-size: 11.5px; }
190
+
191
+ /* Comparison */
192
+ .fc-body-comparison .fc-cmp-grid { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: stretch; padding: 4px 0; }
193
+ .fc-cmp-cell { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--paper-deep); border: 1px solid var(--rule-soft); }
194
+ .fc-cmp-cell-tier { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.04em; }
195
+ .fc-cmp-cell-label { color: var(--ink-secondary); }
196
+ .fc-cmp-cell-value { font-family: var(--font-serif); font-size: 22px; font-weight: 600; line-height: 1.05; }
197
+ .fc-cmp-cell-aux { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); }
198
+ .fc-cmp-divider { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--ink-tertiary); align-self: center; padding: 0 4px; }
199
+ .fc-cmp-delta { font-family: var(--font-mono); font-size: 11px; color: var(--accent); padding: 4px 0; border-top: 1px dashed var(--rule-soft); border-bottom: 1px dashed var(--rule-soft); margin: 4px 0; }
200
+
201
+ /* Meta */
202
+ .fc-body-meta .fc-meta-list { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 12px; margin: 0; }
203
+ .fc-meta-row { display: flex; flex-direction: column; gap: 1px; padding: 4px 6px; background: var(--paper-deep); border-left: 2px solid var(--rule-soft); }
204
+ .fc-meta-row dt { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-tertiary); margin: 0; }
205
+ .fc-meta-row dd { font-family: var(--font-sans); font-size: 13px; font-weight: 500; color: var(--ink); margin: 0; }
206
+
207
+ /* Card foot */
208
+ .fc-foot { display: flex; justify-content: space-between; align-items: center; padding-top: 6px; margin-top: auto; border-top: 1px solid var(--rule-soft); }
209
+ .fc-foot-cite { background: transparent; border: 0; padding: 2px 0; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; color: var(--ink-secondary); }
210
+ .fc-foot-cite:hover { color: var(--accent); }
211
+ .fc-foot-docid { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.02em; }
212
+ .fc-foot-docid-mute { color: var(--ink-tertiary); }
213
+ .fc-foot-arrow { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
214
+
215
+ .fc-tier-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.08em; padding: 2px 5px; border: 1px solid var(--rule-soft); color: var(--ink-secondary); background: var(--paper); }
216
+ .fc-tier-badge-empirical { border-color: var(--tier-empirical); color: var(--tier-empirical); }
217
+ .fc-tier-badge-modeled { border-color: var(--tier-modeled); color: var(--tier-modeled); }
218
+ .fc-tier-badge-proxy { border-color: var(--tier-proxy); color: var(--tier-proxy); }
219
+ .fc-tier-badge-synthetic { border-color: var(--tier-synthetic); color: var(--tier-synthetic); border-style: dashed; }
220
+
221
+ /* Map highlight on hover-link */
222
+ .map-frame.is-link-floodnet::after,
223
+ .map-frame.is-link-fema-ae::after,
224
+ .map-frame.is-link-hwm::after,
225
+ .map-frame.is-link-stormwater::after,
226
+ .map-frame.is-link-prithvi::after,
227
+ .map-frame.is-link-buildings::after,
228
+ .map-frame.is-link-complaints::after,
229
+ .map-frame.is-link-registers::after {
230
+ content: attr(data-link-label); position: absolute; bottom: 8px; right: 8px;
231
+ background: var(--ink); color: var(--paper); font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
232
+ padding: 4px 8px; pointer-events: none; z-index: 5;
233
+ }
234
+ .map-frame.is-linked { outline: 2px solid var(--accent-graphical); outline-offset: -2px; }
235
+
236
+ </style>
237
+ </head>
238
+ <body>
239
+ <template id="__bundler_thumbnail">
240
+ <svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
241
+ <rect width="100" height="100" fill="#FAFAF7"/>
242
+ <g transform="translate(20 28)">
243
+ <rect x="0" y="0" width="60" height="9" fill="#0B5394"/>
244
+ <rect x="0" y="13" width="48" height="9" fill="#2A6FA8"/>
245
+ <rect x="0" y="26" width="38" height="9" fill="#6B6B6B"/>
246
+ <rect x="0" y="39" width="52" height="9" fill="#D17C00"/>
247
+ </g>
248
+ </svg>
249
+ </template>
250
+ <div id="root"></div>
251
+
252
+ <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
253
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
254
+ <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
255
+
256
+ <script type="text/babel" src="tweaks-panel.jsx"></script>
257
+ <script type="text/babel" src="glyphs.jsx"></script>
258
+ <script type="text/babel" src="briefing.jsx"></script>
259
+ <script type="text/babel" src="map.jsx"></script>
260
+ <script type="text/babel" src="trace.jsx"></script>
261
+ <script type="text/babel" src="evidence.jsx"></script>
262
+ <script type="text/babel" src="shell.jsx"></script>
263
+ <script type="text/babel" src="stones-trace.jsx"></script>
264
+ <script type="text/babel" src="stone-evidence.jsx"></script>
265
+ <script type="text/babel" src="findings.jsx"></script>
266
+ <script type="text/babel">
267
+ const { useState: useM } = React;
268
+
269
+ const V44_DEFAULTS = /*EDITMODE-BEGIN*/{
270
+ "density": "comfortable",
271
+ "provenance": "smart",
272
+ "query": "redhook",
273
+ "showComparison": false,
274
+ "showGrammar": true
275
+ }/*EDITMODE-END*/;
276
+
277
+ function StoneMock() {
278
+ const [activeCite, setActiveCite] = useM(null);
279
+ const [linkedKey, setLinkedKey] = useM(null);
280
+ const [tweaks, setTweak] = window.useTweaks(V44_DEFAULTS);
281
+ const handleCite = (id) => setActiveCite(id);
282
+ const handleHover = (key) => setLinkedKey(key);
283
+ /* Reflect linkedKey into map-frame data attribute */
284
+ React.useEffect(() => {
285
+ const frame = document.querySelector(".stone-mock-page .map-frame");
286
+ if (!frame) return;
287
+ /* clear */
288
+ ["floodnet","fema-ae","hwm","stormwater","prithvi","buildings","complaints","registers"].forEach(k => frame.classList.remove("is-link-" + k));
289
+ frame.classList.toggle("is-linked", !!linkedKey);
290
+ if (linkedKey) {
291
+ frame.classList.add("is-link-" + linkedKey);
292
+ const labels = { floodnet: "FloodNet sensor", "fema-ae": "FEMA Zone AE", hwm: "USGS HWM points", stormwater: "DEP stormwater", prithvi: "Prithvi flood pred.", buildings: "TerraMind buildings", complaints: "311 complaints", registers: "Asset registers" };
293
+ frame.dataset.linkLabel = labels[linkedKey] || linkedKey;
294
+ }
295
+ }, [linkedKey]);
296
+
297
+ return (
298
+ <div className="riprap-root stone-mock-page">
299
+ <window.AppHeader query={"80 Pioneer Street, Red Hook, Brooklyn"} onResetCold={() => {}} onOpenMethodology={() => {}}/>
300
+
301
+ {/* Β§1 Β· Briefing + map */}
302
+ <section className="stone-mock-section" data-screen-label="01 Β· Briefing + map">
303
+ <div className="stone-mock-head">
304
+ <h1>Riprap Flood Exposure Briefing , 80 Pioneer Street</h1>
305
+ <span className="meta">v0.4.4 Β· Stone-grouped UI mockup</span>
306
+ </div>
307
+ <div className="app-shell app-shell-desktop">
308
+ <main id="region-briefing" className="app-region app-region-brief">
309
+ <header className="region-head"><span className="section-label">Briefing</span></header>
310
+ <h1 className="brief-h1">
311
+ <span className="brief-h1-eyebrow">Riprap Flood Exposure Briefing</span>
312
+ <span className="brief-h1-addr">80 Pioneer Street</span>
313
+ <span className="brief-h1-meta">
314
+ <span className="brief-h1-meta-row"><span className="brief-h1-meta-key">borough</span><span className="brief-h1-meta-val">Brooklyn Β· CB6</span></span>
315
+ <span className="brief-h1-meta-row"><span className="brief-h1-meta-key">tract</span><span className="brief-h1-meta-val">36047008500</span></span>
316
+ <span className="brief-h1-meta-row"><span className="brief-h1-meta-key">generated</span><span className="brief-h1-meta-val">2026-05-05 14:22 ET</span></span>
317
+ </span>
318
+ </h1>
319
+ <window.StreamingBriefing onCite={handleCite} replayKey={0}/>
320
+ </main>
321
+ <aside className="app-region app-region-map" aria-label="Map">
322
+ <header className="region-head">
323
+ <span className="section-label">Map</span>
324
+ <span className="region-head-meta">Carto Positron Β· z16</span>
325
+ </header>
326
+ <div className="map-frame">
327
+ <window.RedHookMapMock activeLayers={{ empirical: true, modeled: true, synthetic: true, proxy: true }} queriedAddress="80 Pioneer Street"/>
328
+ <window.MapLegend activeLayers={{ empirical: true, modeled: true, synthetic: true, proxy: true }} onToggle={() => {}}/>
329
+ </div>
330
+ </aside>
331
+ <aside className="app-region app-region-cites" aria-label="Citations">
332
+ <window.CitationDrawer activeId={activeCite}/>
333
+ </aside>
334
+ </div>
335
+ </section>
336
+
337
+ {/* Β§2 οΏ½οΏ½ Findings region Β· v0.4.4 */}
338
+ <section className="stone-mock-section" data-screen-label="02 Findings">
339
+ <window.FindingsRegion
340
+ density={tweaks.density}
341
+ provenanceMode={tweaks.provenance}
342
+ queryKey={tweaks.query}
343
+ showComparison={tweaks.showComparison}
344
+ showGrammar={tweaks.showGrammar}
345
+ onCite={handleCite}
346
+ onHover={handleHover}
347
+ linkedKey={linkedKey}
348
+ />
349
+ </section>
350
+
351
+ {/* Tweaks panel */}
352
+ <window.TweaksPanel title="Tweaks">
353
+ <window.TweakSection label="Display"/>
354
+ <window.TweakRadio label="Density" value={tweaks.density} onChange={(v) => setTweak("density", v)} options={["comfortable", "compact"]}/>
355
+ <window.TweakSelect label="Query" value={tweaks.query} onChange={(v) => setTweak("query", v)} options={["redhook", "bronx"]}/>
356
+ <window.TweakSection label="Provenance"/>
357
+ <window.TweakSelect label="Default" value={tweaks.provenance} onChange={(v) => setTweak("provenance", v)} options={["smart", "expanded", "collapsed"]}/>
358
+ <window.TweakSection label="Card grammar"/>
359
+ <window.TweakToggle label="Show grammar reference" value={tweaks.showGrammar} onChange={(v) => setTweak("showGrammar", v)}/>
360
+ <window.TweakToggle label="Show comparison card" value={tweaks.showComparison} onChange={(v) => setTweak("showComparison", v)}/>
361
+ </window.TweaksPanel>
362
+ </div>
363
+ );
364
+ }
365
+
366
+ ReactDOM.createRoot(document.getElementById("root")).render(<StoneMock/>);
367
+ </script>
368
+ </body>
369
+ </html>
docs/design_handoff/design_files/briefing.jsx ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Briefing prose with epistemic-tier glyph margin.
2
+ Each <Claim tier=... cite=...> renders the glyph in the left margin
3
+ and a hoverable superscript citation that scrolls the citation drawer.
4
+ */
5
+
6
+ const { useState, useRef, useEffect, useMemo } = React;
7
+
8
+ /* ── Citation registry for the sample briefing ───────────────────── */
9
+ const CITATIONS = {
10
+ c1: {
11
+ n: 1,
12
+ tier: "empirical",
13
+ source: "USGS",
14
+ title: "Hurricane Sandy storm tide elevations, NY-NJ Harbor",
15
+ docId: "USGS-OFR-2013-1234",
16
+ url: "https://pubs.usgs.gov/of/2013/1234/",
17
+ vintage: "2013-05",
18
+ retrieved: "2026-04-28",
19
+ },
20
+ c2: {
21
+ n: 2,
22
+ tier: "empirical",
23
+ source: "NYC OEM",
24
+ title: "Hurricane Sandy Inundation Zone (2012)",
25
+ docId: "NYCOEM-SIZ-2013",
26
+ url: "https://data.cityofnewyork.us/dataset/sandy-inundation-zone",
27
+ vintage: "2013-01",
28
+ retrieved: "2026-04-28",
29
+ },
30
+ c3: {
31
+ n: 3,
32
+ tier: "empirical",
33
+ source: "FloodNet NYC",
34
+ title: "Sensor BK-RH-002 , Coffey Park, monthly exceedance",
35
+ docId: "FN-BK-RH-002",
36
+ url: "https://floodnet.nyc/sensor/BK-RH-002",
37
+ vintage: "2026-04",
38
+ retrieved: "2026-05-02",
39
+ },
40
+ c4: {
41
+ n: 4,
42
+ tier: "modeled",
43
+ source: "FEMA",
44
+ title: "Preliminary Flood Insurance Rate Map, panel 36047C0207G",
45
+ docId: "FEMA-FIRM-36047C0207G",
46
+ url: "https://msc.fema.gov/portal/search",
47
+ vintage: "2024-09",
48
+ retrieved: "2026-04-28",
49
+ },
50
+ c5: {
51
+ n: 5,
52
+ tier: "modeled",
53
+ source: "NYC DEP",
54
+ title: "Stormwater Flood Map , Moderate Stormwater Scenario",
55
+ docId: "NYCDEP-SWFM-2024",
56
+ url: "https://nyc.gov/stormwater-map",
57
+ vintage: "2024-06",
58
+ retrieved: "2026-04-28",
59
+ },
60
+ c6: {
61
+ n: 6,
62
+ tier: "modeled",
63
+ source: "NPCC4",
64
+ title: "Sea-level rise projections, 2050 90th percentile",
65
+ docId: "NPCC4-Ch3-Tbl3.2",
66
+ url: "https://nyas.org/npcc4",
67
+ vintage: "2024-03",
68
+ retrieved: "2026-04-28",
69
+ },
70
+ c7: {
71
+ n: 7,
72
+ tier: "proxy",
73
+ source: "NYC 311",
74
+ title: "Flooding service requests, BK CB6 2019–2025",
75
+ docId: "NYC311-FLD-CB6",
76
+ url: "https://data.cityofnewyork.us/311",
77
+ vintage: "2025-12",
78
+ retrieved: "2026-05-01",
79
+ },
80
+ c8: {
81
+ n: 8,
82
+ tier: "proxy",
83
+ source: "FEMA NFIP",
84
+ title: "National Flood Insurance Program claims, tract 36047008500",
85
+ docId: "NFIP-T36047008500",
86
+ url: "https://www.fema.gov/openfema",
87
+ vintage: "2024-12",
88
+ retrieved: "2026-04-28",
89
+ },
90
+ c9: {
91
+ n: 9,
92
+ tier: "synthetic",
93
+ source: "TerraMind v1.2",
94
+ title: "Synthetic SAR backscatter for 2025-09-14 (Sentinel-1 cloud-occluded)",
95
+ docId: "RIPRAP-SYN-20250914",
96
+ url: "#methodology-synthetic",
97
+ vintage: "2025-09",
98
+ retrieved: "2026-05-02",
99
+ },
100
+ c10: {
101
+ n: 10,
102
+ tier: "modeled",
103
+ source: "NYC DCP",
104
+ title: "Waterfront Revitalization Program , Coastal Risk Area",
105
+ docId: "NYCDCP-WRP-2022",
106
+ url: "https://nyc.gov/dcp/wrp",
107
+ vintage: "2022-11",
108
+ retrieved: "2026-04-28",
109
+ },
110
+ };
111
+
112
+ const Cite = ({ id, onActivate }) => {
113
+ const c = CITATIONS[id];
114
+ if (!c) return null;
115
+ return (
116
+ <a
117
+ href={`#cite-${id}`}
118
+ className="inline-cite"
119
+ data-cite={id}
120
+ onClick={(e) => {
121
+ e.preventDefault();
122
+ onActivate?.(id);
123
+ }}
124
+ aria-label={`Citation ${c.n}: ${c.source}, ${c.title}`}
125
+ >
126
+ <sup>[{c.n}]</sup>
127
+ </a>
128
+ );
129
+ };
130
+
131
+ const Claim = ({ tier, children }) => (
132
+ <span className={`claim claim-${tier}`} data-tier={tier}>
133
+ <span className="claim-glyph" aria-hidden="false">
134
+ <TierGlyph tier={tier} size={11} color={`var(--tier-${tier})`} />
135
+ </span>
136
+ <span className="claim-body">{children}</span>
137
+ </span>
138
+ );
139
+
140
+ const SectionHead = ({ n, label, tier, children }) => (
141
+ <h3 className="briefing-section-head">
142
+ <span className="briefing-section-num">{n}</span>
143
+ <span className="briefing-section-label">{label}</span>
144
+ {tier && (
145
+ <span className="briefing-section-tier">
146
+ <TierBadge tier={tier} compact />
147
+ </span>
148
+ )}
149
+ {children && <span className="briefing-section-title">{children}</span>}
150
+ </h3>
151
+ );
152
+
153
+ /* ── Sample briefing: 80 Pioneer St, Red Hook, Brooklyn ─────────── */
154
+ const BRIEFING_BLOCKS = [
155
+ { kind: "status", html: `
156
+ <p class="briefing-deck">
157
+ <strong>80 Pioneer Street, Red Hook, Brooklyn 11231.</strong>
158
+ Block 597, Lot 30. Industrial Business Zone (IBZ-RH).
159
+ Queried 2026-05-05 14:22 ET. <span class="briefing-meta">Briefing v0.4.4 Β· 5 Stones engaged Β· Keystone silent (no register joins matched)</span>
160
+ </p>
161
+ ` },
162
+
163
+ { kind: "head", n: "01", label: "Status", title: "Coastal-edge, post-Sandy, multi-hazard" },
164
+ { kind: "prose", parts: [
165
+ { 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" },
166
+ { text: " , within the " },
167
+ { tier: "empirical", text: "2012 Sandy Inundation Zone, which recorded a peak storm tide of 11.4 ft NAVD88 at the Battery", cite: "c2" },
168
+ { text: " 2.4 mi to the northwest. " },
169
+ { tier: "modeled", text: "FEMA's preliminary FIRM places the parcel in Zone AE (BFE 11 ft NAVD88)", cite: "c4" },
170
+ { 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." },
171
+ ]},
172
+
173
+ { kind: "head", n: "02", label: "Empirical evidence", tier: "empirical" },
174
+ { kind: "prose", parts: [
175
+ { 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" },
176
+ { text: ", with a peak depth of 14.3 cm during the 2025-09-29 nor'easter. " },
177
+ { tier: "empirical", text: "USGS post-Sandy high-water marks within 500 ft cluster between 6.8 and 8.1 ft NAVD88", cite: "c1" },
178
+ { text: ", consistent with 0.6–1.9 ft of standing water at the queried address during the storm." },
179
+ ]},
180
+
181
+ { kind: "head", n: "03", label: "Modeled scenarios", tier: "modeled" },
182
+ { kind: "prose", parts: [
183
+ { 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" },
184
+ { text: ", routed by the 1.2% slope toward Imlay St. " },
185
+ { tier: "modeled", text: "Under NPCC4's 2050 90th-percentile sea-level rise (30 in)", cite: "c6" },
186
+ { text: ", the parcel falls within the projected daily-tidal floodplain by mid-century. " },
187
+ { 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" },
188
+ { text: "; treat with appropriate caution." },
189
+ ]},
190
+
191
+ { kind: "head", n: "04", label: "Policy context" },
192
+ { kind: "prose", parts: [
193
+ { 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" },
194
+ { text: ". " },
195
+ { tier: "proxy", text: "NFIP claims aggregated to tract 36047008500 total $4.1M across 47 paid losses since 2000", cite: "c8" },
196
+ { text: ". " },
197
+ { 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" },
198
+ { text: "." },
199
+ ]},
200
+ ];
201
+
202
+ /* ── Streaming renderer ───────────────────────────────────────────
203
+ Uses CSS reveal (token-by-token via animation-delay) instead of
204
+ recomputing innerText, to avoid layout shift.
205
+ */
206
+ const StreamingBriefing = ({ onCite, replayKey }) => {
207
+ const [visibleCount, setVisibleCount] = useState(0);
208
+ const totalBlocks = BRIEFING_BLOCKS.length;
209
+
210
+ useEffect(() => {
211
+ setVisibleCount(0);
212
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
213
+ if (reduce) {
214
+ setVisibleCount(totalBlocks);
215
+ return;
216
+ }
217
+ let i = 0;
218
+ const tick = () => {
219
+ i++;
220
+ setVisibleCount(i);
221
+ if (i < totalBlocks) {
222
+ setTimeout(tick, i < 2 ? 280 : 420);
223
+ }
224
+ };
225
+ const t = setTimeout(tick, 240);
226
+ return () => clearTimeout(t);
227
+ }, [replayKey]);
228
+
229
+ return (
230
+ <div
231
+ className="briefing-prose"
232
+ role="log"
233
+ aria-live="polite"
234
+ aria-atomic="false"
235
+ aria-label="Streaming flood-exposure briefing"
236
+ >
237
+ {BRIEFING_BLOCKS.slice(0, visibleCount).map((b, i) => {
238
+ if (b.kind === "status") {
239
+ return <div key={i} className="briefing-status" dangerouslySetInnerHTML={{ __html: b.html }} />;
240
+ }
241
+ if (b.kind === "head") {
242
+ return <SectionHead key={i} n={b.n} label={b.label} tier={b.tier}>{b.title}</SectionHead>;
243
+ }
244
+ if (b.kind === "prose") {
245
+ return (
246
+ <p key={i} className="briefing-para">
247
+ {b.parts.map((p, j) => {
248
+ if (p.tier) {
249
+ return (
250
+ <React.Fragment key={j}>
251
+ <Claim tier={p.tier}>{p.text}</Claim>
252
+ {p.cite && <Cite id={p.cite} onActivate={onCite} />}
253
+ </React.Fragment>
254
+ );
255
+ }
256
+ return <span key={j}>{p.text}</span>;
257
+ })}
258
+ </p>
259
+ );
260
+ }
261
+ return null;
262
+ })}
263
+ {visibleCount < totalBlocks && (
264
+ <span className="streaming-caret" aria-hidden="true">▍</span>
265
+ )}
266
+ </div>
267
+ );
268
+ };
269
+
270
+ const CitationDrawer = ({ activeId, onClose }) => {
271
+ const items = Object.entries(CITATIONS);
272
+ return (
273
+ <aside className="citation-drawer" aria-label="Citations">
274
+ <div className="citation-drawer-head">
275
+ <span className="section-label">Citations Β· {items.length}</span>
276
+ <span className="citation-drawer-meta">live Β· primary sources</span>
277
+ </div>
278
+ <ol className="citation-list">
279
+ {items.map(([id, c]) => (
280
+ <li
281
+ key={id}
282
+ id={`cite-${id}`}
283
+ className={`citation-item ${activeId === id ? "is-active" : ""}`}
284
+ >
285
+ <span className="citation-num">[{c.n}]</span>
286
+ <div className="citation-body">
287
+ <div className="citation-line-1">
288
+ <TierGlyph tier={c.tier} size={10} color={`var(--tier-${c.tier})`} />
289
+ <span className="citation-source">{c.source}</span>
290
+ <span className="citation-vintage">v. {c.vintage}</span>
291
+ </div>
292
+ <div className="citation-title">{c.title}</div>
293
+ <div className="citation-meta">
294
+ <span className="citation-docid">{c.docId}</span>
295
+ <span className="citation-retrieved">retr. {c.retrieved}</span>
296
+ </div>
297
+ </div>
298
+ </li>
299
+ ))}
300
+ </ol>
301
+ <div className="citation-drawer-foot">
302
+ <span className="section-label">Trust signals</span>
303
+ <p className="citation-foot-copy">
304
+ All foundation models Apache-2.0. All data from public-record federal,
305
+ state, and city sources. No commercial APIs contacted at runtime.
306
+ </p>
307
+ </div>
308
+ </aside>
309
+ );
310
+ };
311
+
312
+ Object.assign(window, { StreamingBriefing, CitationDrawer, CITATIONS, Cite, Claim });
docs/design_handoff/design_files/design-canvas.jsx ADDED
@@ -0,0 +1,936 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ // DesignCanvas.jsx β€” Figma-ish design canvas wrapper
3
+ // Warm gray grid bg + Sections + Artboards + PostIt notes.
4
+ // Artboards are reorderable (grip-drag), deletable, labels/titles are
5
+ // inline-editable, and any artboard can be opened in a fullscreen focus
6
+ // overlay (←/β†’/Esc). State persists to a .design-canvas.state.json sidecar
7
+ // via the host bridge. No assets, no deps.
8
+ //
9
+ // Usage:
10
+ // <DesignCanvas>
11
+ // <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
12
+ // <DCArtboard id="a" label="A Β· Dusk" width={260} height={480}>…</DCArtboard>
13
+ // <DCArtboard id="b" label="B Β· Minimal" width={260} height={480}>…</DCArtboard>
14
+ // </DCSection>
15
+ // </DesignCanvas>
16
+
17
+ const DC = {
18
+ bg: '#f0eee9',
19
+ grid: 'rgba(0,0,0,0.06)',
20
+ label: 'rgba(60,50,40,0.7)',
21
+ title: 'rgba(40,30,20,0.85)',
22
+ subtitle: 'rgba(60,50,40,0.6)',
23
+ postitBg: '#fef4a8',
24
+ postitText: '#5a4a2a',
25
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
26
+ };
27
+
28
+ // One-time CSS injection (classes are dc-prefixed so they don't collide with
29
+ // the hosted design's own styles).
30
+ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
31
+ const s = document.createElement('style');
32
+ s.id = 'dc-styles';
33
+ s.textContent = [
34
+ '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
35
+ '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
36
+ '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
37
+ '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
38
+ '[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)}',
39
+ // isolation:isolate contains artboard content's z-indexes so a
40
+ // z-indexed child (sticky navbar etc.) can't paint over .dc-header or
41
+ // the .dc-menu popover that drops into the top of the card.
42
+ '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
43
+ '.dc-card *{scrollbar-width:none}',
44
+ '.dc-card *::-webkit-scrollbar{display:none}',
45
+ // Per-artboard header: grip + label on the left, delete/expand on the
46
+ // right. Single flex row; when the artboard's on-screen width is too
47
+ // narrow for both the label yields (ellipsis, then hidden entirely below
48
+ // ~4ch via the container query) and the buttons stay on the row.
49
+ '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
50
+ ' display:flex;align-items:center;container-type:inline-size}',
51
+ '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
52
+ '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
53
+ '.dc-grip:hover{background:rgba(0,0,0,.08)}',
54
+ '.dc-grip:active{cursor:grabbing}',
55
+ '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
56
+ ' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
57
+ // Below ~4ch of label room: hide the label entirely, and drop the grip to
58
+ // hover-only (same reveal rule as .dc-btns) so a narrow header is clean
59
+ // until the card is moused.
60
+ '@container (max-width: 110px){',
61
+ ' .dc-labeltext{display:none}',
62
+ ' .dc-grip{opacity:0}',
63
+ ' [data-dc-slot]:hover .dc-grip{opacity:1}',
64
+ '}',
65
+ '.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
66
+ '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
67
+ '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
68
+ '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
69
+ '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
70
+ '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
71
+ ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
72
+ ' font:inherit;transition:background .12s,color .12s}',
73
+ '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
74
+ // Slot hosting an open menu floats above later siblings (which otherwise
75
+ // paint on top β€” same z-index:auto, later DOM order) so the popup isn't
76
+ // clipped by the next card.
77
+ '[data-dc-slot]:has(.dc-menu){z-index:10}',
78
+ '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
79
+ ' 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}',
80
+ '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
81
+ ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
82
+ ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
83
+ '.dc-menu button:hover{background:rgba(0,0,0,.05)}',
84
+ '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
85
+ '.dc-menu .dc-danger{color:#c96442}',
86
+ '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
87
+ // Chrome (titles / labels / buttons) counter-scales against the viewport
88
+ // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
89
+ // DCViewport on every transform update and inherits to all descendants β€”
90
+ // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
91
+ // it the same way.
92
+ //
93
+ // The header uses transform:scale (out-of-flow, so layout impact doesn't
94
+ // matter) with its world-space width set to card-width / inv-zoom so that
95
+ // after counter-scaling its on-screen width exactly matches the card's β€”
96
+ // that's what lets the container query + text-overflow behave against the
97
+ // card's visible edge at every zoom level.
98
+ //
99
+ // The section head uses CSS zoom instead of transform so its layout box
100
+ // grows with the counter-scale, pushing the card row down β€” otherwise the
101
+ // constant-screen-size title would overflow into the (shrinking) world-
102
+ // space gap and overlap the artboard headers at low zoom.
103
+ '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
104
+ ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
105
+ '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
106
+ ].join('\n');
107
+ document.head.appendChild(s);
108
+ }
109
+
110
+ const DCCtx = React.createContext(null);
111
+
112
+ // ─────────────────────────────────────────────────────────────
113
+ // DesignCanvas β€” stateful wrapper around the pan/zoom viewport.
114
+ // Owns runtime state (per-section order, renamed titles/labels, hidden
115
+ // artboards, focused artboard). Order/titles/labels/hidden persist to a
116
+ // .design-canvas.state.json
117
+ // sidecar next to the HTML. Reads go via plain fetch() so the saved
118
+ // arrangement is visible anywhere the HTML + sidecar are served together
119
+ // (omelette preview, direct link, downloaded zip). Writes go through the
120
+ // host's window.omelette bridge β€” editing requires the omelette runtime.
121
+ // Focus is ephemeral.
122
+ // ─────────────────────────────────────────────────────────────
123
+ const DC_STATE_FILE = '.design-canvas.state.json';
124
+
125
+ function DesignCanvas({ children, minScale, maxScale, style }) {
126
+ const [state, setState] = React.useState({ sections: {}, focus: null });
127
+ // Hold rendering until the sidecar read settles so the saved order/titles
128
+ // appear on first paint (no source-order flash). didRead gates writes until
129
+ // the read settles so the empty initial state can't clobber a slow read;
130
+ // skipNextWrite suppresses the one echo-write that would otherwise follow
131
+ // hydration.
132
+ const [ready, setReady] = React.useState(false);
133
+ const didRead = React.useRef(false);
134
+ const skipNextWrite = React.useRef(false);
135
+
136
+ React.useEffect(() => {
137
+ let off = false;
138
+ fetch('./' + DC_STATE_FILE)
139
+ .then((r) => (r.ok ? r.json() : null))
140
+ .then((saved) => {
141
+ if (off || !saved || !saved.sections) return;
142
+ skipNextWrite.current = true;
143
+ setState((s) => ({ ...s, sections: saved.sections }));
144
+ })
145
+ .catch(() => {})
146
+ .finally(() => { didRead.current = true; if (!off) setReady(true); });
147
+ const t = setTimeout(() => { if (!off) setReady(true); }, 150);
148
+ return () => { off = true; clearTimeout(t); };
149
+ }, []);
150
+
151
+ React.useEffect(() => {
152
+ if (!didRead.current) return;
153
+ if (skipNextWrite.current) { skipNextWrite.current = false; return; }
154
+ const t = setTimeout(() => {
155
+ window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
156
+ }, 250);
157
+ return () => clearTimeout(t);
158
+ }, [state.sections]);
159
+
160
+ // Build registries synchronously from children so FocusOverlay can read
161
+ // them in the same render. Only direct DCSection > DCArtboard children are
162
+ // walked β€” wrapping them in other elements opts out of focus/reorder.
163
+ const registry = {}; // slotId -> { sectionId, artboard }
164
+ const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
165
+ const sectionOrder = [];
166
+ React.Children.forEach(children, (sec) => {
167
+ if (!sec || sec.type !== DCSection) return;
168
+ const sid = sec.props.id ?? sec.props.title;
169
+ if (!sid) return;
170
+ sectionOrder.push(sid);
171
+ const persisted = state.sections[sid] || {};
172
+ const abs = [];
173
+ React.Children.forEach(sec.props.children, (ab) => {
174
+ if (!ab || ab.type !== DCArtboard) return;
175
+ const aid = ab.props.id ?? ab.props.label;
176
+ if (aid) abs.push([aid, ab]);
177
+ });
178
+ // hidden is scoped to one source revision β€” when the agent regenerates
179
+ // (artboard-ID set changes), prior deletes don't apply to new content.
180
+ const srcKey = abs.map(([k]) => k).join('\x1f');
181
+ const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
182
+ const srcIds = [];
183
+ abs.forEach(([aid, ab]) => {
184
+ if (hidden.includes(aid)) return;
185
+ registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
186
+ srcIds.push(aid);
187
+ });
188
+ const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
189
+ sectionMeta[sid] = {
190
+ title: persisted.title ?? sec.props.title,
191
+ subtitle: sec.props.subtitle,
192
+ slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
193
+ };
194
+ });
195
+
196
+ const api = React.useMemo(() => ({
197
+ state,
198
+ section: (id) => state.sections[id] || {},
199
+ patchSection: (id, p) => setState((s) => ({
200
+ ...s,
201
+ sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
202
+ })),
203
+ setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
204
+ }), [state]);
205
+
206
+ // Esc exits focus; any outside pointerdown commits an in-progress rename.
207
+ React.useEffect(() => {
208
+ const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
209
+ const onPd = (e) => {
210
+ const ae = document.activeElement;
211
+ if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
212
+ };
213
+ document.addEventListener('keydown', onKey);
214
+ document.addEventListener('pointerdown', onPd, true);
215
+ return () => {
216
+ document.removeEventListener('keydown', onKey);
217
+ document.removeEventListener('pointerdown', onPd, true);
218
+ };
219
+ }, [api]);
220
+
221
+ return (
222
+ <DCCtx.Provider value={api}>
223
+ <DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
224
+ {state.focus && registry[state.focus] && (
225
+ <DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
226
+ )}
227
+ </DCCtx.Provider>
228
+ );
229
+ }
230
+
231
+ // ─────────────────────────────────────────────────────────────
232
+ // DCViewport β€” transform-based pan/zoom (internal)
233
+ //
234
+ // Input mapping (Figma-style):
235
+ // β€’ trackpad pinch β†’ zoom (ctrlKey wheel; Safari gesture* events)
236
+ // β€’ trackpad scroll β†’ pan (two-finger)
237
+ // β€’ mouse wheel β†’ zoom (notched; distinguished from trackpad scroll)
238
+ // β€’ middle-drag / primary-drag-on-bg β†’ pan
239
+ //
240
+ // Transform state lives in a ref and is written straight to the DOM
241
+ // (translate3d + will-change) so wheel ticks don't go through React β€”
242
+ // keeps pans at 60fps on dense canvases.
243
+ // ─────────────────────────────────────────────────────────────
244
+ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
245
+ const vpRef = React.useRef(null);
246
+ const worldRef = React.useRef(null);
247
+ const tf = React.useRef({ x: 0, y: 0, scale: 1 });
248
+ // Persist viewport across reloads so the user lands back where they were
249
+ // after an agent edit or browser refresh. The sandbox origin is already
250
+ // per-project; pathname keeps multiple canvas files in one project apart.
251
+ const tfKey = 'dc-viewport:' + location.pathname;
252
+ const saveT = React.useRef(0);
253
+
254
+ const lastPostedScale = React.useRef();
255
+ const apply = React.useCallback(() => {
256
+ const { x, y, scale } = tf.current;
257
+ const el = worldRef.current;
258
+ if (!el) return;
259
+ el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
260
+ // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
261
+ el.style.setProperty('--dc-inv-zoom', String(1 / scale));
262
+ // Keep the host toolbar's % readout in sync with the canvas scale. Pan
263
+ // ticks leave scale unchanged β€” skip the cross-frame post for those.
264
+ if (lastPostedScale.current !== scale) {
265
+ lastPostedScale.current = scale;
266
+ window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
267
+ }
268
+ clearTimeout(saveT.current);
269
+ saveT.current = setTimeout(() => {
270
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
271
+ }, 200);
272
+ }, [tfKey]);
273
+
274
+ React.useLayoutEffect(() => {
275
+ const flush = () => {
276
+ clearTimeout(saveT.current);
277
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
278
+ };
279
+ try {
280
+ const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
281
+ if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
282
+ tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
283
+ apply();
284
+ }
285
+ } catch {}
286
+ // Flush on pagehide and unmount so a reload within the 200ms debounce
287
+ // window doesn't drop the last pan/zoom.
288
+ window.addEventListener('pagehide', flush);
289
+ return () => { window.removeEventListener('pagehide', flush); flush(); };
290
+ }, []);
291
+
292
+ React.useEffect(() => {
293
+ const vp = vpRef.current;
294
+ if (!vp) return;
295
+
296
+ const zoomAt = (cx, cy, factor) => {
297
+ const r = vp.getBoundingClientRect();
298
+ const px = cx - r.left, py = cy - r.top;
299
+ const t = tf.current;
300
+ const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
301
+ const k = next / t.scale;
302
+ // keep the world point under the cursor fixed
303
+ t.x = px - (px - t.x) * k;
304
+ t.y = py - (py - t.y) * k;
305
+ t.scale = next;
306
+ apply();
307
+ };
308
+
309
+ // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
310
+ // line-mode deltas (Firefox) or large integer pixel deltas with no X
311
+ // component (Chrome/Safari, typically multiples of 100/120). Trackpad
312
+ // two-finger scroll sends small/fractional pixel deltas, often with
313
+ // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
314
+ const isMouseWheel = (e) =>
315
+ e.deltaMode !== 0 ||
316
+ (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
317
+
318
+ const onWheel = (e) => {
319
+ e.preventDefault();
320
+ if (isGesturing) return; // Safari: gesture* owns the pinch β€” discard concurrent wheels
321
+ if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
322
+ // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
323
+ // wheels fall through to the fixed-step branch below.
324
+ zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
325
+ } else if (isMouseWheel(e)) {
326
+ // notched mouse wheel β€” fixed-ratio step per click
327
+ zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
328
+ } else {
329
+ // trackpad two-finger scroll β€” pan
330
+ tf.current.x -= e.deltaX;
331
+ tf.current.y -= e.deltaY;
332
+ apply();
333
+ }
334
+ };
335
+
336
+ // Safari sends native gesture* events for trackpad pinch with a smooth
337
+ // e.scale; preferring these over the ctrl+wheel fallback gives a much
338
+ // better feel there. No-ops on other browsers. Safari also fires
339
+ // ctrlKey wheel events during the same pinch β€” isGesturing makes
340
+ // onWheel drop those entirely so they neither zoom nor pan.
341
+ let gsBase = 1;
342
+ let isGesturing = false;
343
+ const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
344
+ const onGestureChange = (e) => {
345
+ e.preventDefault();
346
+ zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
347
+ };
348
+ const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
349
+
350
+ // Drag-pan: middle button anywhere, or primary button on canvas
351
+ // background (anything that isn't an artboard or an inline editor).
352
+ let drag = null;
353
+ const onPointerDown = (e) => {
354
+ const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
355
+ if (!(e.button === 1 || (e.button === 0 && onBg))) return;
356
+ e.preventDefault();
357
+ vp.setPointerCapture(e.pointerId);
358
+ drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
359
+ vp.style.cursor = 'grabbing';
360
+ };
361
+ const onPointerMove = (e) => {
362
+ if (!drag || e.pointerId !== drag.id) return;
363
+ tf.current.x += e.clientX - drag.lx;
364
+ tf.current.y += e.clientY - drag.ly;
365
+ drag.lx = e.clientX; drag.ly = e.clientY;
366
+ apply();
367
+ };
368
+ const onPointerUp = (e) => {
369
+ if (!drag || e.pointerId !== drag.id) return;
370
+ vp.releasePointerCapture(e.pointerId);
371
+ drag = null;
372
+ vp.style.cursor = '';
373
+ };
374
+
375
+ // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
376
+ // visible midpoint stays fixed β€” matching the host's iframe-zoom feel.
377
+ const onHostMsg = (e) => {
378
+ const d = e.data;
379
+ if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
380
+ const r = vp.getBoundingClientRect();
381
+ zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
382
+ } else if (d && d.type === '__dc_probe') {
383
+ // Host's [readyGen] reset asks whether a canvas is present; it
384
+ // fires on the iframe's native 'load', which for canvases with
385
+ // images/fonts is after our mount-time announce, so re-announce.
386
+ // Clear the pan-tick guard so apply() re-posts the current scale
387
+ // even if it's unchanged β€” the host just reset dcScale to 1.
388
+ window.parent.postMessage({ type: '__dc_present' }, '*');
389
+ lastPostedScale.current = undefined;
390
+ apply();
391
+ }
392
+ };
393
+ window.addEventListener('message', onHostMsg);
394
+ // Announce canvas mode so the host toolbar proxies its % control here
395
+ // instead of scaling the iframe element (which would just shrink the
396
+ // viewport window of an infinite canvas). The apply() that follows emits
397
+ // the initial __dc_zoom so the toolbar % is correct before first pinch.
398
+ // lastPostedScale reset mirrors the __dc_probe handler: the layout
399
+ // effect's restore-path apply() may already have posted the restored
400
+ // scale (before __dc_present), so clear the guard to re-post it in order.
401
+ window.parent.postMessage({ type: '__dc_present' }, '*');
402
+ lastPostedScale.current = undefined;
403
+ apply();
404
+
405
+ vp.addEventListener('wheel', onWheel, { passive: false });
406
+ vp.addEventListener('gesturestart', onGestureStart, { passive: false });
407
+ vp.addEventListener('gesturechange', onGestureChange, { passive: false });
408
+ vp.addEventListener('gestureend', onGestureEnd, { passive: false });
409
+ vp.addEventListener('pointerdown', onPointerDown);
410
+ vp.addEventListener('pointermove', onPointerMove);
411
+ vp.addEventListener('pointerup', onPointerUp);
412
+ vp.addEventListener('pointercancel', onPointerUp);
413
+ return () => {
414
+ window.removeEventListener('message', onHostMsg);
415
+ vp.removeEventListener('wheel', onWheel);
416
+ vp.removeEventListener('gesturestart', onGestureStart);
417
+ vp.removeEventListener('gesturechange', onGestureChange);
418
+ vp.removeEventListener('gestureend', onGestureEnd);
419
+ vp.removeEventListener('pointerdown', onPointerDown);
420
+ vp.removeEventListener('pointermove', onPointerMove);
421
+ vp.removeEventListener('pointerup', onPointerUp);
422
+ vp.removeEventListener('pointercancel', onPointerUp);
423
+ };
424
+ }, [apply, minScale, maxScale]);
425
+
426
+ 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")`;
427
+ return (
428
+ <div
429
+ ref={vpRef}
430
+ className="design-canvas"
431
+ style={{
432
+ height: '100vh', width: '100vw',
433
+ background: DC.bg,
434
+ overflow: 'hidden',
435
+ overscrollBehavior: 'none',
436
+ touchAction: 'none',
437
+ position: 'relative',
438
+ fontFamily: DC.font,
439
+ boxSizing: 'border-box',
440
+ ...style,
441
+ }}
442
+ >
443
+ <div
444
+ ref={worldRef}
445
+ style={{
446
+ position: 'absolute', top: 0, left: 0,
447
+ transformOrigin: '0 0',
448
+ willChange: 'transform',
449
+ width: 'max-content', minWidth: '100%',
450
+ minHeight: '100%',
451
+ padding: '60px 0 80px',
452
+ }}
453
+ >
454
+ <div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
455
+ {children}
456
+ </div>
457
+ </div>
458
+ );
459
+ }
460
+
461
+ // ─────────────────────────────────────────────────────────────
462
+ // DCSection β€” editable title + h-row of artboards in persisted order
463
+ // ─────────────────────────────────────────────────────────────
464
+ function DCSection({ id, title, subtitle, children, gap = 48 }) {
465
+ const ctx = React.useContext(DCCtx);
466
+ const sid = id ?? title;
467
+ const all = React.Children.toArray(children);
468
+ const artboards = all.filter((c) => c && c.type === DCArtboard);
469
+ const rest = all.filter((c) => !(c && c.type === DCArtboard));
470
+ const sec = (ctx && sid && ctx.section(sid)) || {};
471
+ // Must match DesignCanvas's srcKey computation exactly (it filters falsy
472
+ // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
473
+ const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
474
+ const srcKey = allIds.join('\x1f');
475
+ const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
476
+ const srcOrder = allIds.filter((k) => !hidden.includes(k));
477
+
478
+ const order = React.useMemo(() => {
479
+ const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
480
+ return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
481
+ }, [sec.order, srcOrder.join('|')]);
482
+
483
+ const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
484
+
485
+ // marginBottom counter-scales so the on-screen gap between sections stays
486
+ // constant β€” otherwise at low zoom the (world-space) gap collapses while
487
+ // the screen-constant sectionhead below it doesn't, and the title reads as
488
+ // belonging to the section above. paddingBottom below is just enough for
489
+ // the 24px artboard-header (abs-positioned above each card) plus ~8px, so
490
+ // the title sits tight against its own row at every zoom.
491
+ return (
492
+ <div data-dc-section={sid}
493
+ style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
494
+ <div style={{ padding: '0 60px' }}>
495
+ <div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
496
+ <DCEditable tag="div" value={sec.title ?? title}
497
+ onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
498
+ style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
499
+ {subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
500
+ </div>
501
+ </div>
502
+ <div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
503
+ {order.map((k) => (
504
+ <DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
505
+ label={(sec.labels || {})[k] ?? byId[k].props.label}
506
+ onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
507
+ onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
508
+ onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
509
+ hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
510
+ srcKey,
511
+ }))}
512
+ onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
513
+ ))}
514
+ </div>
515
+ {rest}
516
+ </div>
517
+ );
518
+ }
519
+
520
+ // DCArtboard β€” marker; rendered by DCArtboardFrame via DCSection.
521
+ function DCArtboard() { return null; }
522
+
523
+ // Per-artboard export (kind: 'png' | 'html'). Both paths share the same
524
+ // self-contained clone: computed styles baked in, @font-face / <img> /
525
+ // inline-style background-image urls inlined as data URIs. PNG wraps the
526
+ // clone in foreignObject→canvas at 3× the artboard's natural width×height
527
+ // (same pipeline the host uses for page captures); HTML wraps it in a
528
+ // minimal standalone document. Both are independent of viewport zoom.
529
+ async function dcExport(node, w, h, name, kind) {
530
+ try { await document.fonts.ready; } catch {}
531
+ const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
532
+ const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
533
+ })).catch(() => url);
534
+
535
+ // Collect @font-face rules. ss.cssRules throws SecurityError on
536
+ // cross-origin sheets (e.g. fonts.googleapis.com) β€” in that case fetch
537
+ // the CSS text directly (those endpoints send ACAO:*) and regex-extract
538
+ // the blocks. @import and @media/@supports are walked so nested
539
+ // @font-face rules aren't missed.
540
+ const fontRules = [], pending = [], seen = new Set();
541
+ const scrapeCss = (href) => {
542
+ if (seen.has(href)) return; seen.add(href);
543
+ pending.push(fetch(href).then((r) => r.text()).then((css) => {
544
+ for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
545
+ for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
546
+ scrapeCss(new URL(m[1], href).href);
547
+ }).catch(() => {}));
548
+ };
549
+ const walk = (rules, base) => {
550
+ for (const r of rules) {
551
+ if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
552
+ else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
553
+ const ibase = r.styleSheet.href || base;
554
+ try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
555
+ } else if (r.cssRules) walk(r.cssRules, base);
556
+ }
557
+ };
558
+ for (const ss of document.styleSheets) {
559
+ const base = ss.href || location.href;
560
+ try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
561
+ }
562
+ while (pending.length) await pending.shift();
563
+ const fontCss = (await Promise.all(fontRules.map(async (rule) => {
564
+ let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
565
+ while ((m = re.exec(rule.css))) {
566
+ if (m[2].indexOf('data:') === 0) continue;
567
+ let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
568
+ out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
569
+ }
570
+ return out;
571
+ }))).join('\n');
572
+
573
+ const cloneStyled = (src) => {
574
+ if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
575
+ const dst = src.cloneNode(false);
576
+ if (src.nodeType === 1) {
577
+ const cs = getComputedStyle(src); let txt = '';
578
+ for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
579
+ dst.setAttribute('style', txt + 'animation:none;transition:none;');
580
+ if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
581
+ }
582
+ for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
583
+ return dst;
584
+ };
585
+ const clone = cloneStyled(node);
586
+ clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
587
+ // Drop the card's own shadow/radius so the export is a flush wΓ—h rect;
588
+ // the artboard's own background (if any) is already in the computed style.
589
+ clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
590
+
591
+ const jobs = [];
592
+ clone.querySelectorAll('img').forEach((el) => {
593
+ const s = el.getAttribute('src');
594
+ if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
595
+ });
596
+ [clone, ...clone.querySelectorAll('*')].forEach((el) => {
597
+ const bg = el.style.backgroundImage; if (!bg) return;
598
+ let m; const re = /url\(["']?([^"')]+)["']?\)/g;
599
+ while ((m = re.exec(bg))) {
600
+ const tok = m[0], url = m[1];
601
+ if (url.indexOf('data:') === 0) continue;
602
+ jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
603
+ }
604
+ });
605
+ await Promise.all(jobs);
606
+
607
+ const xml = new XMLSerializer().serializeToString(clone);
608
+ const save = (blob, ext) => {
609
+ if (!blob) return;
610
+ const a = document.createElement('a');
611
+ a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
612
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
613
+ };
614
+
615
+ if (kind === 'html') {
616
+ const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
617
+ (fontCss ? '<style>' + fontCss + '</style>' : '') +
618
+ '</head><body style="margin:0">' + xml + '</body></html>';
619
+ return save(new Blob([html], { type: 'text/html' }), 'html');
620
+ }
621
+
622
+ // PNG: the SVG's own width/height must be the output resolution β€” an
623
+ // <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1Γ—
624
+ // and ctx.scale()-ing up would just upscale a 1Γ— bitmap. viewBox maps the
625
+ // wΓ—h foreignObject onto the pxΒ·w Γ— pxΒ·h SVG canvas so the browser renders
626
+ // the HTML at full resolution.
627
+ const px = 3;
628
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
629
+ '" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
630
+ (fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
631
+ const img = new Image();
632
+ await new Promise((res, rej) => {
633
+ img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
634
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
635
+ });
636
+ const cv = document.createElement('canvas');
637
+ cv.width = w * px; cv.height = h * px;
638
+ cv.getContext('2d').drawImage(img, 0, 0);
639
+ cv.toBlob((blob) => save(blob, 'png'), 'image/png');
640
+ }
641
+
642
+ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
643
+ const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
644
+ const id = rawId ?? rawLabel;
645
+ const ref = React.useRef(null);
646
+ const cardRef = React.useRef(null);
647
+ const menuRef = React.useRef(null);
648
+ const [menuOpen, setMenuOpen] = React.useState(false);
649
+ const [confirming, setConfirming] = React.useState(false);
650
+
651
+ // β‹― menu: close on any outside pointerdown. Two-click delete lives inside
652
+ // the menu β€” first click arms the row, second commits; closing disarms.
653
+ React.useEffect(() => {
654
+ if (!menuOpen) { setConfirming(false); return; }
655
+ const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
656
+ document.addEventListener('pointerdown', off, true);
657
+ return () => document.removeEventListener('pointerdown', off, true);
658
+ }, [menuOpen]);
659
+
660
+ const doExport = (kind) => {
661
+ setMenuOpen(false);
662
+ if (!cardRef.current) return;
663
+ const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
664
+ dcExport(cardRef.current, width, height, name, kind)
665
+ .catch((e) => console.error('[design-canvas] export failed:', e));
666
+ };
667
+
668
+ // Live drag-reorder: dragged card sticks to cursor; siblings slide into
669
+ // their would-be slots in real time via transforms. DOM order only
670
+ // changes on drop.
671
+ const onGripDown = (e) => {
672
+ e.preventDefault(); e.stopPropagation();
673
+ const me = ref.current;
674
+ // translateX is applied in local (pre-scale) space but pointer deltas and
675
+ // getBoundingClientRect().left are screen-space β€” divide by the viewport's
676
+ // current scale so the dragged card tracks the cursor at any zoom level.
677
+ const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
678
+ const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
679
+ const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
680
+ const slotXs = homes.map((h) => h.x);
681
+ const startIdx = order.indexOf(id);
682
+ const startX = e.clientX;
683
+ let liveOrder = order.slice();
684
+ me.classList.add('dc-dragging');
685
+
686
+ const layout = () => {
687
+ for (const h of homes) {
688
+ if (h.id === id) continue;
689
+ const slot = liveOrder.indexOf(h.id);
690
+ h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
691
+ }
692
+ };
693
+
694
+ const move = (ev) => {
695
+ const dx = ev.clientX - startX;
696
+ me.style.transform = `translateX(${dx / scale}px)`;
697
+ const cur = homes[startIdx].x + dx;
698
+ let nearest = 0, best = Infinity;
699
+ for (let i = 0; i < slotXs.length; i++) {
700
+ const d = Math.abs(slotXs[i] - cur);
701
+ if (d < best) { best = d; nearest = i; }
702
+ }
703
+ if (liveOrder.indexOf(id) !== nearest) {
704
+ liveOrder = order.filter((k) => k !== id);
705
+ liveOrder.splice(nearest, 0, id);
706
+ layout();
707
+ }
708
+ };
709
+
710
+ const up = () => {
711
+ document.removeEventListener('pointermove', move);
712
+ document.removeEventListener('pointerup', up);
713
+ const finalSlot = liveOrder.indexOf(id);
714
+ me.classList.remove('dc-dragging');
715
+ me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
716
+ // After the settle transition, kill transitions + clear transforms +
717
+ // commit the reorder in the same frame so there's no visual snap-back.
718
+ setTimeout(() => {
719
+ for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
720
+ if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
721
+ requestAnimationFrame(() => requestAnimationFrame(() => {
722
+ for (const h of homes) h.el.style.transition = '';
723
+ }));
724
+ }, 180);
725
+ };
726
+ document.addEventListener('pointermove', move);
727
+ document.addEventListener('pointerup', up);
728
+ };
729
+
730
+ return (
731
+ <div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
732
+ <div className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
733
+ <div className="dc-labelrow">
734
+ <div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
735
+ <svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
736
+ </div>
737
+ <div className="dc-labeltext" onClick={onFocus} title="Click to focus">
738
+ <DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
739
+ style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
740
+ </div>
741
+ </div>
742
+ <div className="dc-btns">
743
+ <div ref={menuRef} style={{ position: 'relative' }}>
744
+ <button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
745
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
746
+ </button>
747
+ {menuOpen && (
748
+ <div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
749
+ <button onClick={() => doExport('png')}>Download PNG</button>
750
+ <button onClick={() => doExport('html')}>Download HTML</button>
751
+ <hr />
752
+ <button className="dc-danger"
753
+ onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
754
+ {confirming ? 'Click again to delete' : 'Delete'}
755
+ </button>
756
+ </div>
757
+ )}
758
+ </div>
759
+ <button className="dc-expand" onClick={onFocus} title="Focus">
760
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
761
+ </button>
762
+ </div>
763
+ </div>
764
+ <div ref={cardRef} className="dc-card"
765
+ style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
766
+ {children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
767
+ </div>
768
+ </div>
769
+ );
770
+ }
771
+
772
+ // Inline rename β€” commits on blur or Enter.
773
+ function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
774
+ const T = tag;
775
+ return (
776
+ <T className="dc-editable" contentEditable suppressContentEditableWarning
777
+ onClick={onClick}
778
+ onPointerDown={(e) => e.stopPropagation()}
779
+ onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
780
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
781
+ style={style}>{value}</T>
782
+ );
783
+ }
784
+
785
+ // ─────────────────────────────────────────────────────────────
786
+ // Focus mode β€” overlay one artboard; ←/β†’ within section, ↑/↓ across
787
+ // sections, Esc or backdrop click to exit.
788
+ // ─────────────────────────────────────────────────────────────
789
+ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
790
+ const ctx = React.useContext(DCCtx);
791
+ const { sectionId, artboard } = entry;
792
+ const sec = ctx.section(sectionId);
793
+ const meta = sectionMeta[sectionId];
794
+ const peers = meta.slotIds;
795
+ const aid = artboard.props.id ?? artboard.props.label;
796
+ const idx = peers.indexOf(aid);
797
+ const secIdx = sectionOrder.indexOf(sectionId);
798
+
799
+ const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
800
+ const goSection = (d) => {
801
+ // Sections whose artboards are all deleted have slotIds:[] β€” step past
802
+ // them to the next non-empty section so ↑/↓ doesn't dead-end.
803
+ const n = sectionOrder.length;
804
+ for (let i = 1; i < n; i++) {
805
+ const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
806
+ const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
807
+ if (first) { ctx.setFocus(`${ns}/${first}`); return; }
808
+ }
809
+ };
810
+
811
+ React.useEffect(() => {
812
+ const k = (e) => {
813
+ if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
814
+ if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
815
+ if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
816
+ if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
817
+ };
818
+ document.addEventListener('keydown', k);
819
+ return () => document.removeEventListener('keydown', k);
820
+ });
821
+
822
+ const { width = 260, height = 480, children } = artboard.props;
823
+ const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
824
+ React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
825
+ const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
826
+
827
+ const [ddOpen, setDd] = React.useState(false);
828
+ const Arrow = ({ dir, onClick }) => (
829
+ <button onClick={(e) => { e.stopPropagation(); onClick(); }}
830
+ style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
831
+ border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
832
+ width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
833
+ display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
834
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
835
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
836
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
837
+ <path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
838
+ </button>
839
+ );
840
+
841
+ // Portal to body so position:fixed is the real viewport regardless of any
842
+ // transform on DesignCanvas's ancestors (including the canvas zoom itself).
843
+ return ReactDOM.createPortal(
844
+ <div onClick={() => ctx.setFocus(null)}
845
+ onWheel={(e) => e.preventDefault()}
846
+ style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
847
+ fontFamily: DC.font, color: '#fff' }}>
848
+
849
+ {/* top bar: section dropdown (left) Β· close (right) */}
850
+ <div onClick={(e) => e.stopPropagation()}
851
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
852
+ <div style={{ position: 'relative' }}>
853
+ <button onClick={() => setDd((o) => !o)}
854
+ style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
855
+ borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
856
+ <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
857
+ <span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
858
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
859
+ </span>
860
+ {meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
861
+ </button>
862
+ {ddOpen && (
863
+ <div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
864
+ boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
865
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
866
+ <button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
867
+ style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
868
+ background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
869
+ padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
870
+ {sectionMeta[sid].title}
871
+ </button>
872
+ ))}
873
+ </div>
874
+ )}
875
+ </div>
876
+ <div style={{ flex: 1 }} />
877
+ <button onClick={() => ctx.setFocus(null)}
878
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
879
+ onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
880
+ style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
881
+ borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>Γ—</button>
882
+ </div>
883
+
884
+ {/* card centered, label + index below β€” only the card itself stops
885
+ propagation so any backdrop click (including the margins around
886
+ the card) exits focus */}
887
+ <div
888
+ style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
889
+ <div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
890
+ <div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
891
+ boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
892
+ {children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
893
+ </div>
894
+ </div>
895
+ <div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
896
+ {(sec.labels || {})[aid] ?? artboard.props.label}
897
+ <span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
898
+ </div>
899
+ </div>
900
+
901
+ <Arrow dir="left" onClick={() => go(-1)} />
902
+ <Arrow dir="right" onClick={() => go(1)} />
903
+
904
+ {/* dots */}
905
+ <div onClick={(e) => e.stopPropagation()}
906
+ style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
907
+ {peers.map((p, i) => (
908
+ <button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
909
+ style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
910
+ background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
911
+ ))}
912
+ </div>
913
+ </div>,
914
+ document.body,
915
+ );
916
+ }
917
+
918
+ // ─────────────────────────────────────────────────────────────
919
+ // Post-it β€” absolute-positioned sticky note
920
+ // ─────────────────────────────────────────────────────────────
921
+ function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
922
+ return (
923
+ <div style={{
924
+ position: 'absolute', top, left, right, bottom, width,
925
+ background: DC.postitBg, padding: '14px 16px',
926
+ fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
927
+ fontSize: 14, lineHeight: 1.4, color: DC.postitText,
928
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
929
+ transform: `rotate(${rotate}deg)`,
930
+ zIndex: 5,
931
+ }}>{children}</div>
932
+ );
933
+ }
934
+
935
+ Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
936
+
docs/design_handoff/design_files/evidence.jsx ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Evidence cards: stack below the map. One card per specialist that fired.
2
+ Each card carries source label, formatted output, tier badge, doc_id,
3
+ and prominent vintage. Mobile: horizontal swipe; desktop: 2-col grid.
4
+ */
5
+
6
+ const EVIDENCE = [
7
+ {
8
+ id: "e1", citeId: "c1", tier: "empirical",
9
+ source: "USGS",
10
+ title: "Post-Sandy high-water marks within 500ft",
11
+ fmt: "table",
12
+ table: [
13
+ ["HWM-NY-3081", "7.4 ft NAVD88", "0.18 mi"],
14
+ ["HWM-NY-3082", "8.1 ft NAVD88", "0.22 mi"],
15
+ ["HWM-NY-3105", "6.8 ft NAVD88", "0.31 mi"],
16
+ ],
17
+ docId: "USGS-OFR-2013-1234",
18
+ vintage: "2013-05",
19
+ },
20
+ {
21
+ id: "e2", citeId: "c3", tier: "empirical",
22
+ source: "FloodNet NYC",
23
+ title: "Sensor BK-RH-002 , monthly above-curb events",
24
+ fmt: "spark",
25
+ 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],
26
+ headline: "7 events", sub: "Jun 2024 β†’ Apr 2026 Β· peak 14.3 cm",
27
+ docId: "FN-BK-RH-002",
28
+ vintage: "2026-04",
29
+ },
30
+ {
31
+ id: "e3", citeId: "c4", tier: "modeled",
32
+ source: "FEMA",
33
+ title: "Preliminary FIRM, panel 36047C0207G",
34
+ fmt: "scalar",
35
+ scalar: { value: "Zone AE", unit: "BFE 11 ft NAVD88", aux: "freeboard +4.8 ft" },
36
+ docId: "FEMA-FIRM-36047C0207G",
37
+ vintage: "2024-09",
38
+ },
39
+ {
40
+ id: "e4", citeId: "c5", tier: "modeled",
41
+ source: "NYC DEP",
42
+ title: "Stormwater Flood Map , moderate scenario",
43
+ fmt: "thumb",
44
+ thumb: "stormwater",
45
+ sub: "2.13 in/hr Β· ponding β‰₯4 in W half of lot Β· routed toward Imlay St",
46
+ docId: "NYCDEP-SWFM-2024",
47
+ vintage: "2024-06",
48
+ },
49
+ {
50
+ id: "e5", citeId: "c6", tier: "modeled",
51
+ source: "NPCC4",
52
+ title: "Sea-level rise projections for Lower NY Harbor",
53
+ fmt: "forecast",
54
+ forecast: [
55
+ { year: 2030, low: 4, mid: 6, high: 9 },
56
+ { year: 2050, low: 13, mid: 22, high: 30 },
57
+ { year: 2080, low: 28, mid: 49, high: 75 },
58
+ { year: 2100, low: 38, mid: 71, high: 114 },
59
+ ],
60
+ docId: "NPCC4-Ch3-Tbl3.2",
61
+ vintage: "2024-03",
62
+ },
63
+ {
64
+ id: "e6", citeId: "c9", tier: "synthetic",
65
+ source: "TerraMind v1.2",
66
+ title: "Synthetic SAR for 2025-09-14 (Sentinel-1 cloud-occluded)",
67
+ fmt: "thumb",
68
+ thumb: "synthetic",
69
+ sub: "Generated, not observed. Confidence 0.71. Provided as prior for downstream models; do not cite as observation.",
70
+ docId: "RIPRAP-SYN-20250914",
71
+ vintage: "2025-09",
72
+ },
73
+ {
74
+ id: "e7", citeId: "c7", tier: "proxy",
75
+ source: "NYC 311",
76
+ title: "Flood complaints, BK CB6 (2019–2025)",
77
+ fmt: "histogram",
78
+ 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],
79
+ headline: "89 calls", sub: "seasonal cluster Aug–Oct",
80
+ docId: "NYC311-FLD-CB6",
81
+ vintage: "2025-12",
82
+ },
83
+ {
84
+ id: "e8", citeId: "c8", tier: "proxy",
85
+ source: "FEMA NFIP",
86
+ title: "NFIP claims, tract 36047008500",
87
+ fmt: "scalar",
88
+ scalar: { value: "$4.1M", unit: "47 paid losses", aux: "since 2000-01-01" },
89
+ docId: "NFIP-T36047008500",
90
+ vintage: "2024-12",
91
+ },
92
+ ];
93
+
94
+ const Spark = ({ data, color }) => {
95
+ const max = Math.max(...data, 1);
96
+ const w = 180, h = 36, n = data.length;
97
+ return (
98
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
99
+ {data.map((v, i) => (
100
+ <rect
101
+ key={i}
102
+ x={(i / n) * w + 0.5}
103
+ y={h - (v / max) * h}
104
+ width={Math.max(2, w / n - 1.5)}
105
+ height={(v / max) * h}
106
+ fill={color}
107
+ />
108
+ ))}
109
+ </svg>
110
+ );
111
+ };
112
+
113
+ const Histogram = ({ data, color }) => (
114
+ <Spark data={data} color={color} />
115
+ );
116
+
117
+ const ForecastChart = ({ data, color }) => {
118
+ const w = 220, h = 80, pad = 4;
119
+ const xs = data.map((d, i) => pad + (i / (data.length - 1)) * (w - pad * 2));
120
+ const max = Math.max(...data.map(d => d.high));
121
+ const y = (v) => h - pad - (v / max) * (h - pad * 2);
122
+ const path = (key) => xs.map((x, i) => `${i ? "L" : "M"} ${x} ${y(data[i][key])}`).join(" ");
123
+ const range = xs.map((x, i) => ({ x, lo: y(data[i].low), hi: y(data[i].high) }));
124
+ const areaD = `M ${range.map(r => `${r.x} ${r.lo}`).join(" L ")} L ${[...range].reverse().map(r => `${r.x} ${r.hi}`).join(" L ")} Z`;
125
+ return (
126
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden="true">
127
+ <path d={areaD} fill={color} fillOpacity="0.18" />
128
+ <path d={path("mid")} fill="none" stroke={color} strokeWidth="1.5"/>
129
+ {data.map((d, i) => (
130
+ <g key={i}>
131
+ <circle cx={xs[i]} cy={y(d.mid)} r="2" fill={color}/>
132
+ <text x={xs[i]} y={h - 1} fontSize="9" fontFamily="IBM Plex Mono" textAnchor="middle" fill="#6B6B6B">{d.year}</text>
133
+ </g>
134
+ ))}
135
+ </svg>
136
+ );
137
+ };
138
+
139
+ const ThumbStripe = ({ kind }) => (
140
+ <svg viewBox="0 0 220 110" width="100%" height="110" aria-hidden="true" style={{ display: "block", background: "#F2F2EE" }}>
141
+ <defs>
142
+ <pattern id={`thumb-${kind}`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
143
+ <rect width="6" height="6" fill={kind === "synthetic" ? "rgba(42,111,168,0.18)" : "rgba(42,111,168,0.30)"}/>
144
+ <line x1="0" y1="0" x2="0" y2="6" stroke={kind === "synthetic" ? "#2A6FA8" : "#0B5394"} strokeWidth="0.8"/>
145
+ </pattern>
146
+ </defs>
147
+ {kind === "stormwater" ? (
148
+ <>
149
+ <rect x="0" y="0" width="220" height="110" fill="#FAFAF7"/>
150
+ <path d="M0 60 L60 55 L120 70 L180 80 L220 78 L220 110 L0 110 Z" fill="rgba(42,111,168,0.30)" stroke="#2A6FA8" strokeWidth="1"/>
151
+ <path d="M0 75 L60 72 L120 85 L180 92 L220 90 L220 110 L0 110 Z" fill="rgba(42,111,168,0.20)" stroke="#2A6FA8" strokeWidth="0.8"/>
152
+ <circle cx="100" cy="68" r="4" fill="#D17C00" stroke="#FAFAF7" strokeWidth="1.5"/>
153
+ </>
154
+ ) : (
155
+ <>
156
+ <rect x="0" y="0" width="220" height="110" fill={`url(#thumb-${kind})`}/>
157
+ <text x="8" y="100" fontFamily="IBM Plex Mono" fontSize="9" fill="#2A6FA8">SYN Β· 2025-09-14</text>
158
+ </>
159
+ )}
160
+ </svg>
161
+ );
162
+
163
+ const EvidenceCard = ({ ev, onCite }) => {
164
+ const tierColor = `var(--tier-${ev.tier})`;
165
+ return (
166
+ <article className={`evidence-card evidence-card-${ev.tier}`} aria-labelledby={`ec-${ev.id}-title`}>
167
+ <header className="evidence-card-head">
168
+ <div className="evidence-card-source">
169
+ <TierGlyph tier={ev.tier} size={11} color={tierColor} />
170
+ <span className="evidence-card-source-label">{ev.source}</span>
171
+ </div>
172
+ <span className="evidence-card-vintage" title="Data vintage">v. {ev.vintage}</span>
173
+ </header>
174
+ <h4 id={`ec-${ev.id}-title`} className="evidence-card-title">{ev.title}</h4>
175
+
176
+ <div className="evidence-card-body">
177
+ {ev.fmt === "scalar" && (
178
+ <div className="evidence-scalar">
179
+ <div className="evidence-scalar-value" style={{ color: tierColor }}>{ev.scalar.value}</div>
180
+ <div className="evidence-scalar-unit">{ev.scalar.unit}</div>
181
+ {ev.scalar.aux && <div className="evidence-scalar-aux">{ev.scalar.aux}</div>}
182
+ </div>
183
+ )}
184
+ {ev.fmt === "table" && (
185
+ <table className="evidence-table">
186
+ <thead><tr><th>id</th><th>elev.</th><th>dist.</th></tr></thead>
187
+ <tbody>
188
+ {ev.table.map((row, i) => (
189
+ <tr key={i}>{row.map((c, j) => <td key={j}>{c}</td>)}</tr>
190
+ ))}
191
+ </tbody>
192
+ </table>
193
+ )}
194
+ {ev.fmt === "spark" && (
195
+ <div className="evidence-spark">
196
+ <div className="evidence-spark-headline" style={{ color: tierColor }}>{ev.headline}</div>
197
+ <Spark data={ev.spark} color={tierColor}/>
198
+ <div className="evidence-scalar-aux">{ev.sub}</div>
199
+ </div>
200
+ )}
201
+ {ev.fmt === "histogram" && (
202
+ <div className="evidence-spark">
203
+ <div className="evidence-spark-headline" style={{ color: tierColor }}>{ev.headline}</div>
204
+ <Histogram data={ev.months} color={tierColor}/>
205
+ <div className="evidence-scalar-aux">{ev.sub}</div>
206
+ </div>
207
+ )}
208
+ {ev.fmt === "forecast" && (
209
+ <div className="evidence-spark">
210
+ <ForecastChart data={ev.forecast} color={tierColor}/>
211
+ <div className="evidence-scalar-aux">inches MSL Β· 17th–83rd %ile range, median line</div>
212
+ </div>
213
+ )}
214
+ {ev.fmt === "thumb" && (
215
+ <div className="evidence-thumb">
216
+ <ThumbStripe kind={ev.thumb}/>
217
+ <div className="evidence-scalar-aux">{ev.sub}</div>
218
+ </div>
219
+ )}
220
+ </div>
221
+
222
+ <footer className="evidence-card-foot">
223
+ <button
224
+ type="button"
225
+ className="evidence-card-cite"
226
+ onClick={() => onCite?.(ev.citeId)}
227
+ title={`Open citation ${ev.citeId} in drawer`}
228
+ >
229
+ <span className="evidence-card-docid">{ev.docId}</span>
230
+ <span className="evidence-card-cite-arrow" aria-hidden="true">β†’</span>
231
+ </button>
232
+ <TierBadge tier={ev.tier} compact />
233
+ </footer>
234
+ </article>
235
+ );
236
+ };
237
+
238
+ const EvidenceGrid = ({ onCite }) => (
239
+ <section className="evidence-grid" aria-label="Evidence cards">
240
+ <div className="evidence-grid-head">
241
+ <span className="section-label">Evidence Β· 8 cards</span>
242
+ <span className="evidence-grid-meta">
243
+ <span className="evidence-grid-tally"><TierGlyph tier="empirical" size={9}/> 3</span>
244
+ <span className="evidence-grid-tally"><TierGlyph tier="modeled" size={9}/> 3</span>
245
+ <span className="evidence-grid-tally"><TierGlyph tier="proxy" size={9}/> 2</span>
246
+ <span className="evidence-grid-tally"><TierGlyph tier="synthetic" size={9}/> 1</span>
247
+ </span>
248
+ </div>
249
+ <div className="evidence-grid-rail">
250
+ {EVIDENCE.map((ev) => (
251
+ <EvidenceCard key={ev.id} ev={ev} onCite={onCite}/>
252
+ ))}
253
+ </div>
254
+ </section>
255
+ );
256
+
257
+ Object.assign(window, { EvidenceGrid, EvidenceCard });
docs/design_handoff/design_files/findings.jsx ADDED
@@ -0,0 +1,848 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Riprap v0.4.4 Β· Findings region.
2
+ Card grammar: a small set of body variants any Stone's specialists render into.
3
+ Variants: tabular, headline, visualization, raster-thumbnail, time-series,
4
+ composite-register (novel), comparison (novel Β· EMP vs SYN), text-headline.
5
+ Common chrome: header (source badge + tier glyph + vintage) β†’ title β†’ body β†’ footer (source ID + tier badge).
6
+ */
7
+
8
+ const { useState: useFi, useMemo: useFiMemo } = React;
9
+
10
+ /* ─── Card data ─── */
11
+
12
+ const CARDS_BY_QUERY = {
13
+ redhook: {
14
+ cornerstone: ["fc-fema", "fc-hwm", "fc-stormwater"],
15
+ keystone: ["fc-register-rh"],
16
+ touchstone: ["fc-floodnet", "fc-311", "fc-prithvi", "fc-nws"],
17
+ lodestone: ["fc-ttm-surge", "fc-npcc4"],
18
+ capstone: ["fc-mellea-meta"],
19
+ },
20
+ bronx: {
21
+ cornerstone: ["fc-fema-x", "fc-stormwater-bx"],
22
+ keystone: ["fc-register-bx"],
23
+ touchstone: ["fc-311-bx", "fc-nws"],
24
+ lodestone: [], /* full-Stone silence: address is inland, no Battery surge relevance */
25
+ capstone: ["fc-mellea-meta-bx"],
26
+ },
27
+ };
28
+
29
+ const CARDS = {
30
+ /* ── Cornerstone ── */
31
+ "fc-fema": {
32
+ stone: "cornerstone", tier: "modeled", variant: "headline",
33
+ source: "FEMA", agency: "Federal Emergency Management Agency",
34
+ title: "Preliminary FIRM, panel 36047C0207G",
35
+ headline: "Zone AE", subhead: "BFE 11 ft NAVD88 Β· freeboard +4.8 ft",
36
+ 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.",
37
+ docId: "FEMA-FIRM-36047C0207G", vintage: "2024-09", citeId: "c4",
38
+ mapKey: "fema-ae",
39
+ },
40
+ "fc-hwm": {
41
+ stone: "cornerstone", tier: "empirical", variant: "tabular",
42
+ source: "USGS", agency: "U.S. Geological Survey",
43
+ title: "Post-Sandy high-water marks within 500 ft",
44
+ columns: ["id", "elev.", "dist."],
45
+ rows: [
46
+ ["HWM-NY-3081", "7.4 ft NAVD88", "0.18 mi"],
47
+ ["HWM-NY-3082", "8.1 ft NAVD88", "0.22 mi"],
48
+ ["HWM-NY-3105", "6.8 ft NAVD88", "0.31 mi"],
49
+ ],
50
+ sub: "3 marks Β· max 8.1 ft Β· surveyed Nov 2012",
51
+ docId: "USGS-OFR-2013-1234", vintage: "2013-05", citeId: "c1",
52
+ mapKey: "hwm",
53
+ },
54
+ "fc-stormwater": {
55
+ stone: "cornerstone", tier: "modeled", variant: "raster",
56
+ source: "NYC DEP", agency: "NYC Dept. of Environmental Protection",
57
+ title: "Stormwater Flood Map Β· moderate scenario",
58
+ rasterKind: "stormwater",
59
+ sub: "2.13 in/hr Β· ponding β‰₯4 in W half of lot Β· routed toward Imlay St",
60
+ docId: "NYCDEP-SWFM-2024", vintage: "2024-06", citeId: "c5",
61
+ mapKey: "stormwater",
62
+ },
63
+ "fc-fema-x": {
64
+ stone: "cornerstone", tier: "modeled", variant: "headline",
65
+ source: "FEMA", agency: "Federal Emergency Management Agency",
66
+ title: "Preliminary FIRM, panel 36005C0152F",
67
+ headline: "Zone X", subhead: "outside the 1% annual-chance floodplain",
68
+ body: "Address is in Zone X, the unshaded 0.2% annual-chance area or higher ground. NFIP coverage optional; insurance not mandated.",
69
+ docId: "FEMA-FIRM-36005C0152F", vintage: "2024-09", citeId: "cx1",
70
+ mapKey: "fema-x",
71
+ },
72
+ "fc-stormwater-bx": {
73
+ stone: "cornerstone", tier: "modeled", variant: "raster",
74
+ source: "NYC DEP", agency: "NYC Dept. of Environmental Protection",
75
+ title: "Stormwater Flood Map Β· moderate scenario",
76
+ rasterKind: "stormwater-dry",
77
+ sub: "2.13 in/hr Β· no ponding β‰₯4 in within parcel Β· upslope grade 3.4%",
78
+ docId: "NYCDEP-SWFM-2024", vintage: "2024-06", citeId: "cx2",
79
+ mapKey: "stormwater",
80
+ },
81
+
82
+ /* ── Keystone (composite register) ── */
83
+ "fc-register-rh": {
84
+ stone: "keystone", tier: "empirical", variant: "register",
85
+ source: "NYC OpenData", agency: "NYC OpenData Β· multi-agency join",
86
+ title: "Nearby exposed assets",
87
+ registers: [
88
+ { 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 },
89
+ { reg: "NYCHA", tier: "empirical", label: "Red Hook East Houses", detail: "0.41 mi Β· 2,878 res.", sourceId: "NYCHA-RHE", vintage: "2025-Q3", note: null },
90
+ { reg: "NYCHA", tier: "empirical", label: "Red Hook West Houses", detail: "0.52 mi Β· 3,142 res.", sourceId: "NYCHA-RHW", vintage: "2025-Q3", note: null },
91
+ { 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 },
92
+ { reg: "DOH", tier: "empirical", label: null, detail: null, sourceId: null, vintage: null, note: "no acute-care hospital within 1.0 mi (silent)" },
93
+ { reg: "PLUTO", tier: "empirical", label: "Lot 36047 / 521 / 7", detail: "BIN 3018472 Β· MX-1", sourceId: "PLUTO-2024v2", vintage: "2024-12", note: null },
94
+ ],
95
+ sub: "5 of 6 registers fired Β· 1 silent Β· joined within 1.0 mi",
96
+ docId: "RIPRAP-EXP-RH80", vintage: "2026-05", citeId: "c-reg-rh",
97
+ mapKey: "registers",
98
+ },
99
+ "fc-register-bx": {
100
+ stone: "keystone", tier: "empirical", variant: "register",
101
+ source: "NYC OpenData", agency: "NYC OpenData Β· multi-agency join",
102
+ title: "Nearby exposed assets",
103
+ registers: [
104
+ { reg: "MTA", tier: "empirical", label: "Pelham Pkwy 5 station", detail: "0.18 mi Β· 5", sourceId: "MTA-ENT-N122", vintage: "2025-11", note: null },
105
+ { reg: "NYCHA", tier: "empirical", label: null, detail: null, sourceId: null, vintage: null, note: "no NYCHA developments within 1.0 mi (silent)" },
106
+ { reg: "DOE", tier: "empirical", label: "PS 89 Cinco Estrellas", detail: "0.22 mi Β· 612 K-5", sourceId: "DOE-X089", vintage: "2024-25", note: null },
107
+ { reg: "DOH", tier: "empirical", label: "Jacobi Medical Center", detail: "0.51 mi Β· 457 beds", sourceId: "DOH-JMC", vintage: "2025-Q1", note: null },
108
+ { reg: "PLUTO", tier: "empirical", label: "Lot 36005 / 4382 / 18", detail: "BIN 2098441 Β· R5", sourceId: "PLUTO-2024v2", vintage: "2024-12", note: null },
109
+ ],
110
+ sub: "4 of 5 registers fired Β· 1 silent Β· joined within 1.0 mi",
111
+ docId: "RIPRAP-EXP-BX12", vintage: "2026-05", citeId: "cx-reg",
112
+ mapKey: "registers",
113
+ },
114
+
115
+ /* ── Touchstone ── */
116
+ "fc-floodnet": {
117
+ stone: "touchstone", tier: "empirical", variant: "spark",
118
+ source: "FloodNet", agency: "FloodNet NYC sensor network",
119
+ title: "Sensor BK-RH-002, monthly above-curb events",
120
+ headline: "7 events", subhead: "Jun 2024 β†’ Apr 2026 Β· peak 14.3 cm",
121
+ 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],
122
+ sparkSub: "Sensor located 0.21 mi N at Coffey & Van Brunt. Above-curb depth in cm; events β‰₯2 cm.",
123
+ docId: "FN-BK-RH-002", vintage: "2026-04", citeId: "c3",
124
+ mapKey: "floodnet",
125
+ },
126
+ "fc-311": {
127
+ stone: "touchstone", tier: "proxy", variant: "histogram",
128
+ source: "NYC 311", agency: "NYC 311 service requests",
129
+ title: "Recent 311 flood complaints, BK CB6",
130
+ headline: "89 calls", subhead: "2019–2025 Β· seasonal cluster Aug–Oct",
131
+ 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],
132
+ sparkSub: "Filtered to complaint types: Sewer (Backup), Street Flooding, Catch Basin Clogged. Within 200 m of address.",
133
+ docId: "NYC311-FLD-CB6", vintage: "2025-12", citeId: "c7",
134
+ mapKey: "complaints",
135
+ },
136
+ "fc-311-bx": {
137
+ stone: "touchstone", tier: "proxy", variant: "histogram",
138
+ source: "NYC 311", agency: "NYC 311 service requests",
139
+ title: "Recent 311 flood complaints, BX CB11",
140
+ headline: "12 calls", subhead: "2019–2025 Β· sparse Β· no seasonal cluster",
141
+ 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],
142
+ sparkSub: "Filtered to complaint types: Sewer (Backup), Street Flooding, Catch Basin Clogged. Within 200 m of address.",
143
+ docId: "NYC311-FLD-CB11", vintage: "2025-12", citeId: "cx7",
144
+ mapKey: "complaints",
145
+ },
146
+ "fc-prithvi": {
147
+ stone: "touchstone", tier: "modeled", variant: "raster-pred",
148
+ source: "Prithvi-NYC", agency: "Prithvi-NYC-Pluvial v2 Β· IBM/NASA Γ— Riprap",
149
+ title: "Pluvial flood prediction, current Sentinel-2 chip",
150
+ rasterKind: "prithvi",
151
+ headline: "0.3% flooded", subhead: "no flooding apparent Β· scene 2026-05-02",
152
+ sub: "Model interpretation of imagery, not real-time observation. Confidence-mean 0.84 across non-flooded pixels.",
153
+ docId: "PRITHVI-NYC-PLUV-V2-20260502", vintage: "2026-05-02",
154
+ illustrative: true, citeId: "c-prithvi",
155
+ mapKey: "prithvi",
156
+ },
157
+ "fc-nws": {
158
+ stone: "touchstone", tier: "empirical", variant: "scalars",
159
+ source: "NWS KNYC", agency: "NOAA Β· National Weather Service",
160
+ title: "Current weather, station KNYC",
161
+ scalars: [
162
+ { value: "0.02 in", label: "precip Β· last 24h" },
163
+ { value: "67Β°F", label: "temp Β· current" },
164
+ { value: "PC", label: "conditions" },
165
+ ],
166
+ sub: "Observation timestamp 2026-05-05 14:18 ET. Central Park station; not point-of-query.",
167
+ docId: "NWS-KNYC", vintage: "2026-05-05", citeId: "c-nws",
168
+ mapKey: "nws",
169
+ },
170
+
171
+ /* ── Lodestone ── */
172
+ "fc-ttm-surge": {
173
+ stone: "lodestone", tier: "modeled", variant: "timeseries",
174
+ source: "Granite TTM r2", agency: "IBM Granite-TimeSeries Β· Riprap fine-tune",
175
+ title: "Storm surge nowcast at The Battery, 96-hour horizon",
176
+ timeseries: { hours: 96, peak: { x: 38, y: 47 }, peakLabel: "+47 cm @ +38h" },
177
+ headline: "+47 cm", subhead: "peak surge residual Β· Wed 04:00 ET",
178
+ sub: "Nowcast applies city-wide via NOAA station 8518750. Not localized to query address. Residual above harmonic tide.",
179
+ docId: "ttm_battery_surge_v2", vintage: "2026-05-05 12:00 ET",
180
+ spatialNote: "regional Β· The Battery, not point-of-query",
181
+ citeId: "c-ttm",
182
+ mapKey: null, /* TTM does not render on map; lives only as Lodestone card */
183
+ },
184
+ "fc-npcc4": {
185
+ stone: "lodestone", tier: "modeled", variant: "forecast",
186
+ source: "NPCC4", agency: "NYC Panel on Climate Change, 4th Assessment",
187
+ title: "Sea-level rise projections, Lower NY Harbor",
188
+ forecast: [
189
+ { year: 2030, low: 4, mid: 6, high: 9 },
190
+ { year: 2050, low: 13, mid: 22, high: 30 },
191
+ { year: 2080, low: 28, mid: 49, high: 75 },
192
+ { year: 2100, low: 38, mid: 71, high: 114 },
193
+ ],
194
+ sub: "inches MSL Β· 17th–83rd %ile range, median line. Battery tide-gauge baseline.",
195
+ docId: "NPCC4-Ch3-Tbl3.2", vintage: "2024-03", citeId: "c6",
196
+ mapKey: null,
197
+ },
198
+
199
+ /* ── Capstone meta ── */
200
+ "fc-mellea-meta": {
201
+ stone: "capstone", tier: "modeled", variant: "meta",
202
+ source: "Mellea", agency: "Capstone synthesis Β· grounding check",
203
+ title: "Briefing reconciliation",
204
+ metaRows: [
205
+ { k: "Mellea reroll", v: "1 attempt" },
206
+ { k: "Grounding checks", v: "4 / 4 passed" },
207
+ { k: "Citations resolved",v: "11 / 11" },
208
+ { k: "RAG β†’ GLiNER", v: "9 entities Β· 0 unresolved" },
209
+ ],
210
+ sub: "Capstone produces prose, not cards. This meta-card summarizes the reconciler chain that wrote the four-section briefing above.",
211
+ docId: "RIPRAP-CAP-RH80", vintage: "2026-05-05 14:22 ET", citeId: null,
212
+ mapKey: null,
213
+ },
214
+ "fc-mellea-meta-bx": {
215
+ stone: "capstone", tier: "modeled", variant: "meta",
216
+ source: "Mellea", agency: "Capstone synthesis Β· grounding check",
217
+ title: "Briefing reconciliation",
218
+ metaRows: [
219
+ { k: "Mellea reroll", v: "1 attempt" },
220
+ { k: "Grounding checks", v: "4 / 4 passed" },
221
+ { k: "Citations resolved",v: "6 / 6" },
222
+ { k: "RAG β†’ GLiNER", v: "5 entities Β· 0 unresolved" },
223
+ ],
224
+ sub: "Capstone produces prose, not cards. This meta-card summarizes the reconciler chain.",
225
+ docId: "RIPRAP-CAP-BX12", vintage: "2026-05-05 14:24 ET", citeId: null,
226
+ mapKey: null,
227
+ },
228
+ };
229
+
230
+ /* Comparison card Β· only included when "showComparison" is on (novel variant the brief flags as v1.1 idea). */
231
+ const COMPARISON_CARD = {
232
+ stone: "keystone", tier: "synthetic", variant: "comparison",
233
+ source: "TerraMind Γ— DOITT", agency: "TerraMind v1.2 Buildings Γ— NYC DOITT footprints",
234
+ title: "Building footprint Β· documented vs. interpreted",
235
+ left: { tier: "empirical", label: "DOITT (documented)", value: "31.4%", aux: "112 building polygons in chip" },
236
+ right: { tier: "synthetic", label: "TerraMind (interpreted)", value: "36.2%", aux: "126 components Β· Sentinel-2 2026-05-02" },
237
+ delta: "+4.8 pp Β· model sees ~14 unrecorded structures",
238
+ 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.",
239
+ docId: "RIPRAP-CMP-RH80-BLDG", vintage: "2026-05-02", citeId: null,
240
+ illustrative: true, mapKey: "buildings",
241
+ };
242
+
243
+ /* ─── Stone metadata ─── */
244
+
245
+ const STONE_META = {
246
+ cornerstone: { name: "Cornerstone", role: "the hazard reader", tag: "what NYC's ground remembers" },
247
+ keystone: { name: "Keystone", role: "the asset register", tag: "what's exposed" },
248
+ touchstone: { name: "Touchstone", role: "the live observer", tag: "what's happening now" },
249
+ lodestone: { name: "Lodestone", role: "the projector", tag: "what's coming" },
250
+ capstone: { name: "Capstone", role: "the synthesizer", tag: "writes it all down with citations" },
251
+ };
252
+
253
+ const STONE_ORDER = ["cornerstone", "keystone", "touchstone", "lodestone", "capstone"];
254
+
255
+ /* ─── Tier badge (footer) ─── */
256
+
257
+ const FiTierBadge = ({ tier }) => {
258
+ const map = { empirical: "EMP", modeled: "MOD", proxy: "PRX", synthetic: "SYN" };
259
+ return (
260
+ <span className={`fc-tier-badge fc-tier-badge-${tier}`} aria-label={`epistemic tier ${map[tier]}`}>
261
+ <window.TierGlyph tier={tier} size={9}/>
262
+ <span>{map[tier]}</span>
263
+ </span>
264
+ );
265
+ };
266
+
267
+ /* ─── Body variants ─── */
268
+
269
+ const BodyHeadline = ({ c }) => (
270
+ <div className="fc-body fc-body-headline">
271
+ <div className="fc-headline" style={{ color: `var(--tier-${c.tier})` }}>{c.headline}</div>
272
+ <div className="fc-subhead">{c.subhead}</div>
273
+ {c.body && <p className="fc-body-prose">{c.body}</p>}
274
+ </div>
275
+ );
276
+
277
+ const BodyTabular = ({ c }) => (
278
+ <div className="fc-body fc-body-tabular">
279
+ <table className="fc-table">
280
+ <thead><tr>{c.columns.map((h, i) => <th key={i}>{h}</th>)}</tr></thead>
281
+ <tbody>
282
+ {c.rows.map((row, i) => (
283
+ <tr key={i}>{row.map((cell, j) => <td key={j}>{cell}</td>)}</tr>
284
+ ))}
285
+ </tbody>
286
+ </table>
287
+ {c.sub && <div className="fc-body-sub">{c.sub}</div>}
288
+ </div>
289
+ );
290
+
291
+ const BodySpark = ({ c }) => {
292
+ const data = c.spark || c.histogram;
293
+ const max = Math.max(...data, 1);
294
+ const w = 240, h = 38, n = data.length;
295
+ return (
296
+ <div className="fc-body fc-body-spark">
297
+ <div className="fc-headline" style={{ color: `var(--tier-${c.tier})` }}>{c.headline}</div>
298
+ <div className="fc-subhead">{c.subhead}</div>
299
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
300
+ {data.map((v, i) => (
301
+ <rect
302
+ key={i}
303
+ x={(i / n) * w + 0.5}
304
+ y={h - (v / max) * h}
305
+ width={Math.max(2, w / n - 1.5)}
306
+ height={(v / max) * h}
307
+ fill={`var(--tier-${c.tier})`}
308
+ />
309
+ ))}
310
+ </svg>
311
+ {c.sparkSub && <div className="fc-body-sub">{c.sparkSub}</div>}
312
+ </div>
313
+ );
314
+ };
315
+
316
+ const BodyForecast = ({ c }) => {
317
+ const data = c.forecast;
318
+ const w = 240, h = 88, pad = 6;
319
+ const xs = data.map((_, i) => pad + (i / (data.length - 1)) * (w - pad * 2));
320
+ const max = Math.max(...data.map(d => d.high));
321
+ const y = (v) => h - pad - (v / max) * (h - pad * 2 - 12);
322
+ const path = (key) => xs.map((x, i) => `${i ? "L" : "M"} ${x} ${y(data[i][key])}`).join(" ");
323
+ const range = xs.map((x, i) => ({ x, lo: y(data[i].low), hi: y(data[i].high) }));
324
+ const areaD = `M ${range.map(r => `${r.x} ${r.lo}`).join(" L ")} L ${[...range].reverse().map(r => `${r.x} ${r.hi}`).join(" L ")} Z`;
325
+ const color = `var(--tier-${c.tier})`;
326
+ return (
327
+ <div className="fc-body fc-body-forecast">
328
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden="true">
329
+ <path d={areaD} fill={color} fillOpacity="0.18"/>
330
+ <path d={path("mid")} fill="none" stroke={color} strokeWidth="1.5"/>
331
+ {data.map((d, i) => (
332
+ <g key={i}>
333
+ <circle cx={xs[i]} cy={y(d.mid)} r="2.2" fill={color}/>
334
+ <text x={xs[i]} y={h - 1} fontSize="9" fontFamily="IBM Plex Mono" textAnchor="middle" fill="#6B6B6B">{d.year}</text>
335
+ </g>
336
+ ))}
337
+ </svg>
338
+ {c.sub && <div className="fc-body-sub">{c.sub}</div>}
339
+ </div>
340
+ );
341
+ };
342
+
343
+ const BodyTimeseries = ({ c }) => {
344
+ const w = 240, h = 84, pad = 6;
345
+ const hours = c.timeseries.hours;
346
+ /* Synthetic surge curve: harmonic baseline + storm pulse around peak */
347
+ const points = Array.from({ length: hours + 1 }, (_, i) => {
348
+ const t = i;
349
+ const harmonic = 6 * Math.sin((t / 12.42) * Math.PI * 2);
350
+ const pulse = 38 * Math.exp(-Math.pow((t - c.timeseries.peak.x) / 12, 2));
351
+ return { x: t, y: harmonic + pulse + 4 };
352
+ });
353
+ const maxY = Math.max(...points.map(p => p.y), c.timeseries.peak.y);
354
+ const minY = Math.min(...points.map(p => p.y), -10);
355
+ const sx = (t) => pad + (t / hours) * (w - pad * 2);
356
+ const sy = (v) => h - pad - 14 - ((v - minY) / (maxY - minY)) * (h - pad * 2 - 14);
357
+ const pathD = points.map((p, i) => `${i ? "L" : "M"} ${sx(p.x)} ${sy(p.y)}`).join(" ");
358
+ const color = `var(--tier-${c.tier})`;
359
+ return (
360
+ <div className="fc-body fc-body-timeseries">
361
+ <div className="fc-ts-header">
362
+ <span className="fc-headline" style={{ color }}>{c.headline}</span>
363
+ <span className="fc-subhead">{c.subhead}</span>
364
+ </div>
365
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden="true">
366
+ <line x1={pad} y1={sy(0)} x2={w - pad} y2={sy(0)} stroke="#C9C9C5" strokeWidth="0.5" strokeDasharray="2 2"/>
367
+ <path d={pathD} fill="none" stroke={color} strokeWidth="1.4"/>
368
+ <circle cx={sx(c.timeseries.peak.x)} cy={sy(c.timeseries.peak.y)} r="3" fill={color}/>
369
+ <text x={sx(c.timeseries.peak.x)} y={sy(c.timeseries.peak.y) - 6} fontSize="9" fontFamily="IBM Plex Mono" textAnchor="middle" fill={color}>{c.timeseries.peakLabel}</text>
370
+ <text x={pad} y={h - 2} fontSize="8" fontFamily="IBM Plex Mono" fill="#6B6B6B">now</text>
371
+ <text x={w - pad} y={h - 2} fontSize="8" fontFamily="IBM Plex Mono" textAnchor="end" fill="#6B6B6B">+96h</text>
372
+ </svg>
373
+ <div className="fc-body-sub">
374
+ <span className="fc-spatial-note">{c.spatialNote}</span>
375
+ <span>{c.sub}</span>
376
+ </div>
377
+ </div>
378
+ );
379
+ };
380
+
381
+ const BodyScalars = ({ c }) => (
382
+ <div className="fc-body fc-body-scalars">
383
+ <div className="fc-scalars-row">
384
+ {c.scalars.map((s, i) => (
385
+ <div key={i} className="fc-scalar-cell">
386
+ <div className="fc-scalar-value" style={{ color: `var(--tier-${c.tier})` }}>{s.value}</div>
387
+ <div className="fc-scalar-label">{s.label}</div>
388
+ </div>
389
+ ))}
390
+ </div>
391
+ {c.sub && <div className="fc-body-sub">{c.sub}</div>}
392
+ </div>
393
+ );
394
+
395
+ /* Raster thumbnail Β· hand-drawn SVG approximations using each layer's conventional palette. */
396
+ const RasterThumb = ({ kind }) => {
397
+ const w = 240, h = 120;
398
+ if (kind === "stormwater") {
399
+ return (
400
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden="true" style={{ display: "block" }}>
401
+ <rect width={w} height={h} fill="#F2F2EE"/>
402
+ {/* street grid */}
403
+ <g stroke="#D9D6CC" strokeWidth="0.6">
404
+ <line x1="0" y1="40" x2={w} y2="40"/><line x1="0" y1="80" x2={w} y2="80"/>
405
+ <line x1="60" y1="0" x2="60" y2={h}/><line x1="160" y1="0" x2="160" y2={h}/>
406
+ </g>
407
+ {/* ponding */}
408
+ <path d="M20 50 Q 60 38 90 56 Q 120 76 150 64 Q 180 50 180 86 Q 130 100 70 96 Q 30 92 20 76 Z" fill="rgba(42,111,168,0.32)" stroke="#2A6FA8" strokeWidth="0.7"/>
409
+ <path d="M40 60 Q 80 54 110 70 Q 140 84 160 78 Q 165 90 130 92 Q 80 90 50 82 Z" fill="rgba(11,83,148,0.36)" stroke="#0B5394" strokeWidth="0.6"/>
410
+ <circle cx="120" cy="74" r="3.2" fill="#D17C00" stroke="#FAFAF7" strokeWidth="1.3"/>
411
+ <text x={w - 6} y={h - 5} fontSize="8" fontFamily="IBM Plex Mono" textAnchor="end" fill="#6B6B6B">2.13 in/hr Β· MOD</text>
412
+ </svg>
413
+ );
414
+ }
415
+ if (kind === "stormwater-dry") {
416
+ return (
417
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden="true" style={{ display: "block" }}>
418
+ <rect width={w} height={h} fill="#F2F2EE"/>
419
+ <g stroke="#D9D6CC" strokeWidth="0.6">
420
+ <line x1="0" y1="40" x2={w} y2="40"/><line x1="0" y1="80" x2={w} y2="80"/>
421
+ <line x1="60" y1="0" x2="60" y2={h}/><line x1="160" y1="0" x2="160" y2={h}/>
422
+ </g>
423
+ <path d="M180 92 Q 200 88 215 96 Q 220 105 200 104 Q 185 102 180 96 Z" fill="rgba(42,111,168,0.18)" stroke="#2A6FA8" strokeWidth="0.5" strokeDasharray="2 2"/>
424
+ <circle cx="120" cy="60" r="3.2" fill="#D17C00" stroke="#FAFAF7" strokeWidth="1.3"/>
425
+ <text x={w - 6} y={h - 5} fontSize="8" fontFamily="IBM Plex Mono" textAnchor="end" fill="#6B6B6B">no ponding Β· MOD</text>
426
+ </svg>
427
+ );
428
+ }
429
+ if (kind === "prithvi") {
430
+ /* Prithvi: 50% Sentinel RGB Β· 50% pluvial mask. Mostly dry β†’ speckle, no flood polygons. */
431
+ return (
432
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden="true" style={{ display: "block" }}>
433
+ <defs>
434
+ <pattern id="s2-rgb" x="0" y="0" width="6" height="6" patternUnits="userSpaceOnUse">
435
+ <rect width="6" height="6" fill="#7A8E6A"/>
436
+ <rect x="0" y="0" width="3" height="3" fill="#8D9C7A"/>
437
+ <rect x="3" y="3" width="3" height="3" fill="#69795D"/>
438
+ </pattern>
439
+ </defs>
440
+ <rect width={w} height={h} fill="url(#s2-rgb)"/>
441
+ {/* roads / impervious */}
442
+ <rect x="0" y="55" width={w} height="6" fill="#A8A496"/>
443
+ <rect x="115" y="0" width="8" height={h} fill="#A8A496"/>
444
+ {/* tiny flood blob (0.3%) */}
445
+ <ellipse cx="50" cy="92" rx="6" ry="3" fill="#2A6FA8" fillOpacity="0.65"/>
446
+ <text x="6" y="14" fontSize="9" fontFamily="IBM Plex Mono" fill="#FAFAF7">PRITHVI Β· 0.3%</text>
447
+ <text x={w - 6} y={h - 5} fontSize="8" fontFamily="IBM Plex Mono" textAnchor="end" fill="#FAFAF7">scene 2026-05-02</text>
448
+ </svg>
449
+ );
450
+ }
451
+ if (kind === "lulc") {
452
+ return (
453
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden="true" style={{ display: "block" }}>
454
+ <rect width={w} height={h} fill="#F2F2EE"/>
455
+ {/* developed (red) blocks */}
456
+ <rect x="0" y="0" width="80" height="60" fill="#C66"/>
457
+ <rect x="80" y="0" width="60" height="60" fill="#C66"/>
458
+ <rect x="140" y="0" width="100" height="38" fill="#C66"/>
459
+ {/* water */}
460
+ <rect x="140" y="38" width="100" height="22" fill="#5B7FB4"/>
461
+ {/* developed lower */}
462
+ <rect x="0" y="60" width="100" height="60" fill="#C66"/>
463
+ {/* forest patch */}
464
+ <rect x="100" y="60" width="50" height="40" fill="#5B8A4A"/>
465
+ {/* herbaceous */}
466
+ <rect x="150" y="60" width="50" height="60" fill="#D9C75A"/>
467
+ <rect x="200" y="60" width="40" height="60" fill="#C66"/>
468
+ <rect x="100" y="100" width="50" height="20" fill="#A89A78"/>
469
+ <text x="6" y="14" fontSize="9" fontFamily="IBM Plex Mono" fill="#FAFAF7">LULC Β· TerraMind</text>
470
+ <text x={w - 6} y={h - 5} fontSize="8" fontFamily="IBM Plex Mono" textAnchor="end" fill="#FAFAF7">scene 2026-05-02</text>
471
+ </svg>
472
+ );
473
+ }
474
+ if (kind === "buildings") {
475
+ return (
476
+ <svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden="true" style={{ display: "block" }}>
477
+ <rect width={w} height={h} fill="#3A3A38"/>
478
+ {/* building polygons */}
479
+ {[
480
+ [10,10,28,18],[42,10,30,16],[78,10,40,22],[124,10,32,18],[162,10,30,18],[198,10,32,18],
481
+ [10,32,28,16],[42,30,30,18],[124,32,32,16],[162,32,30,16],[198,32,32,16],
482
+ [10,55,28,18],[42,55,30,18],[78,55,40,18],[124,55,32,18],[162,55,30,18],[198,55,32,18],
483
+ [10,80,28,16],[42,80,30,16],[78,80,40,16],[124,80,32,16],[162,80,30,16],
484
+ [10,100,28,12],[42,100,30,12],[78,100,40,12],
485
+ ].map(([x,y,bw,bh], i) => (
486
+ <rect key={i} x={x} y={y} width={bw} height={bh} fill="rgba(42,111,168,0.55)" stroke="#2A6FA8" strokeWidth="0.4"/>
487
+ ))}
488
+ <text x="6" y="14" fontSize="9" fontFamily="IBM Plex Mono" fill="#FAFAF7">BLDG Β· TerraMind</text>
489
+ <text x={w - 6} y={h - 5} fontSize="8" fontFamily="IBM Plex Mono" textAnchor="end" fill="#FAFAF7">36.2% built</text>
490
+ </svg>
491
+ );
492
+ }
493
+ return <div className="fc-thumb-placeholder">raster preview</div>;
494
+ };
495
+
496
+ const BodyRaster = ({ c }) => (
497
+ <div className="fc-body fc-body-raster">
498
+ <div className="fc-raster-frame">
499
+ <RasterThumb kind={c.rasterKind}/>
500
+ {c.illustrative && <span className="fc-illustrative" title="Illustrative rendering, not source pixels">illustrative</span>}
501
+ </div>
502
+ {c.headline && <div className="fc-raster-headline"><span style={{ color: `var(--tier-${c.tier})` }}>{c.headline}</span> Β· {c.subhead}</div>}
503
+ {c.sub && <div className="fc-body-sub">{c.sub}</div>}
504
+ </div>
505
+ );
506
+
507
+ const BodyRegister = ({ c, density }) => (
508
+ <div className="fc-body fc-body-register">
509
+ <ul className="fc-reg-list">
510
+ {c.registers.map((r, i) => (
511
+ <li key={i} className={`fc-reg-row ${r.label ? "" : "is-silent"}`}>
512
+ <span className="fc-reg-tag">{r.reg}</span>
513
+ {r.label ? (
514
+ <>
515
+ <span className="fc-reg-label" title={r.detail ? `${r.label} , ${r.detail}` : r.label}>{r.label}</span>
516
+ <span className="fc-reg-source">{r.sourceId}</span>
517
+ </>
518
+ ) : (
519
+ <span className="fc-reg-silent">{r.note}</span>
520
+ )}
521
+ </li>
522
+ ))}
523
+ </ul>
524
+ {c.sub && <div className="fc-body-sub">{c.sub}</div>}
525
+ </div>
526
+ );
527
+
528
+ /* ─── Card-grammar reference: one stub per variant ─── */
529
+ const GRAMMAR_STUBS = [
530
+ { 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" },
531
+ { 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" },
532
+ { 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" },
533
+ { 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" },
534
+ { 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" },
535
+ { 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" },
536
+ { 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" },
537
+ { 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" },
538
+ { 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" },
539
+ { variant: "register", tier: "empirical", source: "NYC OpenData", title: "Composite register list", registers: [
540
+ { reg: "MTA", tier: "empirical", label: "Station entrance", sourceId: "MTA-X", note: null },
541
+ { reg: "NYCHA", tier: "empirical", label: "Development", sourceId: "NYCHA-Y", note: null },
542
+ { reg: "DOH", tier: "empirical", label: null, sourceId: null, note: "no acute-care hospital within 1.0 mi" },
543
+ ], sub: "Use when many specialists join into one Stone.", docId: "DS-REGISTER", vintage: "spec" },
544
+ { 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" },
545
+ { 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" },
546
+ ];
547
+
548
+ const CardGrammarReference = ({ density }) => (
549
+ <section className="f-region f-region-grammar" aria-label="Card grammar reference">
550
+ <header className="f-region-head">
551
+ <div className="f-region-head-left">
552
+ <span className="f-region-num">SPEC</span>
553
+ <h3 className="f-region-name">Card grammar</h3>
554
+ <span className="f-region-role">every body variant in the system</span>
555
+ <span className="f-region-tag">stubs, not findings</span>
556
+ </div>
557
+ <span className="f-tally"><span className="f-tally-strong">{GRAMMAR_STUBS.length}</span> variants</span>
558
+ </header>
559
+ <div className="f-rail">
560
+ {GRAMMAR_STUBS.map((c) => (
561
+ <article key={c.variant} className={`fc fc-${c.variant} fc-tier-${c.tier} ${density === "compact" ? "is-compact" : ""}`}>
562
+ <header className="fc-head">
563
+ <div className="fc-head-source">
564
+ <window.TierGlyph tier={c.tier} size={11}/>
565
+ <span className="fc-head-source-label">{c.source}</span>
566
+ </div>
567
+ <span className="fc-head-vintage">{c.variant}</span>
568
+ </header>
569
+ <h4 className="fc-title">{c.title}</h4>
570
+ {renderBody(c, density)}
571
+ <footer className="fc-foot">
572
+ <span className="fc-foot-docid fc-foot-docid-mute">{c.docId}</span>
573
+ <FiTierBadge tier={c.tier}/>
574
+ </footer>
575
+ </article>
576
+ ))}
577
+ </div>
578
+ </section>
579
+ );
580
+
581
+ Object.assign(window, { CardGrammarReference });
582
+
583
+ const BodyComparison = ({ c }) => (
584
+ <div className="fc-body fc-body-comparison">
585
+ <div className="fc-cmp-grid">
586
+ <div className="fc-cmp-cell">
587
+ <div className="fc-cmp-cell-tier">
588
+ <window.TierGlyph tier={c.left.tier} size={10}/>
589
+ <span className="fc-cmp-cell-label">{c.left.label}</span>
590
+ </div>
591
+ <div className="fc-cmp-cell-value" style={{ color: `var(--tier-${c.left.tier})` }}>{c.left.value}</div>
592
+ <div className="fc-cmp-cell-aux">{c.left.aux}</div>
593
+ </div>
594
+ <div className="fc-cmp-divider" aria-hidden="true">vs</div>
595
+ <div className="fc-cmp-cell">
596
+ <div className="fc-cmp-cell-tier">
597
+ <window.TierGlyph tier={c.right.tier} size={10}/>
598
+ <span className="fc-cmp-cell-label">{c.right.label}</span>
599
+ </div>
600
+ <div className="fc-cmp-cell-value" style={{ color: `var(--tier-${c.right.tier})` }}>{c.right.value}</div>
601
+ <div className="fc-cmp-cell-aux">{c.right.aux}</div>
602
+ </div>
603
+ </div>
604
+ <div className="fc-cmp-delta">{c.delta}</div>
605
+ {c.sub && <div className="fc-body-sub">{c.sub}</div>}
606
+ </div>
607
+ );
608
+
609
+ const BodyMeta = ({ c }) => (
610
+ <div className="fc-body fc-body-meta">
611
+ <dl className="fc-meta-list">
612
+ {c.metaRows.map((r, i) => (
613
+ <div key={i} className="fc-meta-row">
614
+ <dt>{r.k}</dt>
615
+ <dd>{r.v}</dd>
616
+ </div>
617
+ ))}
618
+ </dl>
619
+ {c.sub && <div className="fc-body-sub">{c.sub}</div>}
620
+ </div>
621
+ );
622
+
623
+ const renderBody = (c, density) => {
624
+ switch (c.variant) {
625
+ case "headline": return <BodyHeadline c={c}/>;
626
+ case "tabular": return <BodyTabular c={c}/>;
627
+ case "spark": return <BodySpark c={c}/>;
628
+ case "histogram": return <BodySpark c={c}/>;
629
+ case "forecast": return <BodyForecast c={c}/>;
630
+ case "timeseries": return <BodyTimeseries c={c}/>;
631
+ case "scalars": return <BodyScalars c={c}/>;
632
+ case "raster": return <BodyRaster c={c}/>;
633
+ case "raster-pred": return <BodyRaster c={c}/>;
634
+ case "register": return <BodyRegister c={c} density={density}/>;
635
+ case "comparison": return <BodyComparison c={c}/>;
636
+ case "meta": return <BodyMeta c={c}/>;
637
+ default: return null;
638
+ }
639
+ };
640
+
641
+ /* ─── Card frame ─── */
642
+
643
+ const FindingCard = ({ c, density, onCite, onHover, onClick, isLinked }) => {
644
+ return (
645
+ <article
646
+ className={`fc fc-${c.variant} fc-tier-${c.tier} ${density === "compact" ? "is-compact" : ""} ${isLinked ? "is-linked" : ""}`}
647
+ aria-labelledby={`fc-${c.docId}-title`}
648
+ onMouseEnter={() => onHover?.(c.mapKey)}
649
+ onMouseLeave={() => onHover?.(null)}
650
+ onClick={() => onClick?.(c)}
651
+ >
652
+ <header className="fc-head">
653
+ <div className="fc-head-source">
654
+ <window.TierGlyph tier={c.tier} size={11}/>
655
+ <span className="fc-head-source-label" title={c.agency}>{c.source}</span>
656
+ </div>
657
+ <span className="fc-head-vintage">v. {c.vintage}</span>
658
+ </header>
659
+ <h4 id={`fc-${c.docId}-title`} className="fc-title">{c.title}</h4>
660
+ {renderBody(c, density)}
661
+ <footer className="fc-foot">
662
+ {c.citeId ? (
663
+ <button type="button" className="fc-foot-cite" onClick={(e) => { e.stopPropagation(); onCite?.(c.citeId); }} title={`Open ${c.docId} in citation drawer`}>
664
+ <span className="fc-foot-docid">{c.docId}</span>
665
+ <span className="fc-foot-arrow" aria-hidden="true">β†’</span>
666
+ </button>
667
+ ) : (
668
+ <span className="fc-foot-docid fc-foot-docid-mute">{c.docId}</span>
669
+ )}
670
+ <FiTierBadge tier={c.tier}/>
671
+ </footer>
672
+ </article>
673
+ );
674
+ };
675
+
676
+ /* ─── Stone region ─── */
677
+
678
+ const flatten = (members) => members.flatMap((m) => (m.children ? [m, ...flatten(m.children)] : [m]));
679
+
680
+ const StoneTally44 = ({ cardCount, members }) => {
681
+ const flat = flatten(members);
682
+ const fired = flat.filter((m) => m.status === "ok").length;
683
+ const silent = flat.filter((m) => m.status === "silent").length;
684
+ const warn = flat.filter((m) => m.status === "warn").length;
685
+ const error = flat.filter((m) => m.status === "error").length;
686
+ const ms = members.reduce((acc, m) => Math.max(acc, m.ms || 0), 0);
687
+ const fmtMs = (x) => (x === 0 ? "β€”" : x < 1000 ? x + "ms" : (x / 1000).toFixed(1) + "s");
688
+ return (
689
+ <span className="f-tally">
690
+ <span className="f-tally-cards">{cardCount} card{cardCount === 1 ? "" : "s"}</span>
691
+ <span className="f-tally-sep">Β·</span>
692
+ <span className="f-tally-fired"><span className="f-tally-strong">{fired}</span> fired</span>
693
+ {silent > 0 && <><span className="f-tally-sep">Β·</span><span className="f-tally-silent"><span className="f-tally-strong">{silent}</span> silent</span></>}
694
+ {warn > 0 && <><span className="f-tally-sep">Β·</span><span className="f-tally-warn"><span className="f-tally-strong">{warn}</span> warn</span></>}
695
+ {error > 0 && <><span className="f-tally-sep">Β·</span><span className="f-tally-err"><span className="f-tally-strong">{error}</span> error</span></>}
696
+ <span className="f-tally-sep">Β·</span>
697
+ <span className="f-tally-ms"><span className="f-tally-strong">{fmtMs(ms)}</span></span>
698
+ </span>
699
+ );
700
+ };
701
+
702
+ const StoneRegion = ({ stone, cardIds, density, provenanceMode, onCite, onHover, linkedKey }) => {
703
+ const meta = STONE_META[stone.key];
704
+ const cards = cardIds.map((id) => CARDS[id]).filter(Boolean);
705
+ const traceCount = flatten(stone.members).length;
706
+ const flat = flatten(stone.members);
707
+ const hasAnomaly = flat.some((m) => m.status === "warn" || m.status === "error" || m.status === "silent");
708
+ const defaultOpen =
709
+ provenanceMode === "all-expanded" ? true :
710
+ provenanceMode === "all-collapsed" ? false :
711
+ /* smart */ hasAnomaly;
712
+ const [traceOpen, setTraceOpen] = useFi(defaultOpen);
713
+ /* Re-sync if user toggles tweak */
714
+ useFiMemo(() => setTraceOpen(defaultOpen), [provenanceMode]);
715
+
716
+ const isCapstone = stone.key === "capstone";
717
+
718
+ return (
719
+ <section className={`f-region f-region-${stone.key}`} aria-labelledby={`f-h-${stone.key}`} data-stone={stone.key}>
720
+ <header className="f-region-head">
721
+ <div className="f-region-head-left">
722
+ <span className="f-region-num">{(STONE_ORDER.indexOf(stone.key) + 1).toString().padStart(2, "0")}</span>
723
+ <h3 id={`f-h-${stone.key}`} className="f-region-name">{meta.name}</h3>
724
+ <span className="f-region-role">Β· {meta.role}</span>
725
+ <span className="f-region-tag">{meta.tag}</span>
726
+ </div>
727
+ <StoneTally44 cardCount={cards.length} members={stone.members}/>
728
+ </header>
729
+
730
+ {/* Findings Β· primary surface */}
731
+ {cards.length === 0 ? (
732
+ <div className="f-silent">
733
+ <span className="f-silent-tag">silent</span>
734
+ <p className="f-silent-prose">
735
+ {stone.key === "lodestone"
736
+ ? "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."
737
+ : "No cards for this Stone on this query."}
738
+ </p>
739
+ </div>
740
+ ) : (
741
+ <div className={`f-rail ${isCapstone ? "f-rail-capstone" : ""}`}>
742
+ {cards.map((c) => (
743
+ <FindingCard
744
+ key={c.docId}
745
+ c={c}
746
+ density={density}
747
+ onCite={onCite}
748
+ onHover={onHover}
749
+ onClick={(card) => onHover?.(card.mapKey, true)}
750
+ isLinked={linkedKey && c.mapKey === linkedKey}
751
+ />
752
+ ))}
753
+ </div>
754
+ )}
755
+
756
+ {/* Provenance */}
757
+ <div className="f-prov">
758
+ <button
759
+ type="button"
760
+ className="f-prov-toggle"
761
+ aria-expanded={traceOpen}
762
+ onClick={() => setTraceOpen((o) => !o)}
763
+ >
764
+ <span className="f-prov-caret" aria-hidden="true">{traceOpen ? "β–Ύ" : "β–Έ"}</span>
765
+ <span className="f-prov-label">{traceOpen ? "Hide" : "Show"} provenance</span>
766
+ <span className="f-prov-meta">Β· {traceCount} function{traceCount === 1 ? "" : "s"}{hasAnomaly ? " Β· anomaly" : ""}</span>
767
+ </button>
768
+ {traceOpen && (
769
+ <div className="f-prov-body">
770
+ {stone.members.map((m) => <window.TraceRow key={m.id} m={m}/>)}
771
+ </div>
772
+ )}
773
+ </div>
774
+ </section>
775
+ );
776
+ };
777
+
778
+ /* ─── Run-health strip ─── */
779
+
780
+ const RunHealth44 = ({ totalCards }) => {
781
+ const all = window.STONES.flatMap((s) => flatten(s.members));
782
+ const fired = all.filter((m) => m.status === "ok").length;
783
+ const total = all.length;
784
+ const silent = all.filter((m) => m.status === "silent").length;
785
+ const warn = all.filter((m) => m.status === "warn").length;
786
+ const error = all.filter((m) => m.status === "error").length;
787
+ return (
788
+ <div className="f-runhealth">
789
+ <span className="f-rh-item"><strong>5</strong> Stones</span>
790
+ <span className="f-rh-sep">Β·</span>
791
+ <span className="f-rh-item"><strong>{fired}/{total}</strong> functions fired</span>
792
+ <span className="f-rh-sep">Β·</span>
793
+ <span className="f-rh-item"><strong>{totalCards}</strong> evidence cards</span>
794
+ <span className="f-rh-sep">Β·</span>
795
+ <span className="f-rh-item"><strong>14.0s</strong> wall-clock</span>
796
+ {silent > 0 && <><span className="f-rh-sep">Β·</span><span className="f-rh-item f-rh-silent">{silent} silent</span></>}
797
+ {warn > 0 && <><span className="f-rh-sep">Β·</span><span className="f-rh-item f-rh-warn">{warn} warn</span></>}
798
+ {error > 0 && <><span className="f-rh-sep">Β·</span><span className="f-rh-item f-rh-err">{error} error</span></>}
799
+ </div>
800
+ );
801
+ };
802
+
803
+ /* ─── Findings region ─── */
804
+
805
+ const FindingsRegion = ({ density, provenanceMode, queryKey, showComparison, showGrammar, onCite, onHover, linkedKey }) => {
806
+ const map = CARDS_BY_QUERY[queryKey] || CARDS_BY_QUERY.redhook;
807
+ /* Inject the comparison card into Keystone after the register card if showComparison is on */
808
+ const adjusted = useFiMemo(() => {
809
+ if (!showComparison) return map;
810
+ if (queryKey !== "redhook") return map;
811
+ /* Add a synthetic ID reference to the in-memory comparison card */
812
+ return { ...map, keystone: [...(map.keystone || []), "__comparison__"] };
813
+ }, [map, showComparison, queryKey]);
814
+
815
+ const totalCards = STONE_ORDER.reduce((n, k) => n + (adjusted[k] || []).length, 0);
816
+
817
+ return (
818
+ <section className="findings" aria-label="Findings, grouped by Stone">
819
+ <header className="findings-head">
820
+ <h2 className="findings-h2">Findings Β· grouped by Stone</h2>
821
+ <span className="findings-tagline">cards = what each Stone found Β· provenance collapses below</span>
822
+ </header>
823
+ <RunHealth44 totalCards={totalCards}/>
824
+ {STONE_ORDER.map((key) => {
825
+ const stone = window.STONES.find((s) => s.key === key);
826
+ const ids = adjusted[key] || [];
827
+ return (
828
+ <StoneRegion
829
+ key={key}
830
+ stone={stone}
831
+ cardIds={ids}
832
+ density={density}
833
+ provenanceMode={provenanceMode}
834
+ onCite={onCite}
835
+ onHover={onHover}
836
+ linkedKey={linkedKey}
837
+ />
838
+ );
839
+ })}
840
+ {showGrammar && <CardGrammarReference density={density}/>}
841
+ </section>
842
+ );
843
+ };
844
+
845
+ /* Fold the comparison card into CARDS lookup at module load */
846
+ CARDS["__comparison__"] = COMPARISON_CARD;
847
+
848
+ Object.assign(window, { FindingsRegion, CARDS, CARDS_BY_QUERY });
docs/design_handoff/design_files/glyphs.jsx ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Epistemic-tier glyphs.
2
+ 12Γ—12 monochrome SVGs. Distinguishable by shape (filled square,
3
+ open square, filled circle, striped square) , never by color alone.
4
+ Used in: briefing prose left margin, evidence card badge, trace tier
5
+ column, PDF body, MapLibre legend.
6
+ */
7
+
8
+ const TierGlyph = ({ tier, size = 12, color = "currentColor", title }) => {
9
+ const s = size;
10
+ const stroke = Math.max(1, Math.round(size / 9));
11
+ const ariaTitle = title || ({
12
+ empirical: "Empirical: directly measured or observed",
13
+ modeled: "Modeled: scenario-based prediction",
14
+ proxy: "Proxy: indirect indicator",
15
+ synthetic: "Synthetic prior: generated, not observed",
16
+ })[tier];
17
+
18
+ const patternId = `rip-stripe-${tier}-${size}`;
19
+
20
+ return (
21
+ <svg
22
+ width={s}
23
+ height={s}
24
+ viewBox={`0 0 ${s} ${s}`}
25
+ role="img"
26
+ aria-label={ariaTitle}
27
+ style={{ flex: "none", display: "inline-block", verticalAlign: "-0.12em" }}
28
+ >
29
+ <title>{ariaTitle}</title>
30
+ {tier === "empirical" && (
31
+ <rect x="0" y="0" width={s} height={s} fill={color} />
32
+ )}
33
+ {tier === "modeled" && (
34
+ <rect
35
+ x={stroke / 2}
36
+ y={stroke / 2}
37
+ width={s - stroke}
38
+ height={s - stroke}
39
+ fill="none"
40
+ stroke={color}
41
+ strokeWidth={stroke}
42
+ />
43
+ )}
44
+ {tier === "proxy" && (
45
+ <circle cx={s / 2} cy={s / 2} r={s / 2 - 0.5} fill={color} />
46
+ )}
47
+ {tier === "synthetic" && (
48
+ <>
49
+ <defs>
50
+ <pattern
51
+ id={patternId}
52
+ width="3"
53
+ height="3"
54
+ patternUnits="userSpaceOnUse"
55
+ patternTransform="rotate(45)"
56
+ >
57
+ <line x1="0" y1="0" x2="0" y2="3" stroke={color} strokeWidth="1.5" />
58
+ </pattern>
59
+ </defs>
60
+ <rect
61
+ x={stroke / 2}
62
+ y={stroke / 2}
63
+ width={s - stroke}
64
+ height={s - stroke}
65
+ fill={`url(#${patternId})`}
66
+ stroke={color}
67
+ strokeWidth={stroke}
68
+ />
69
+ </>
70
+ )}
71
+ </svg>
72
+ );
73
+ };
74
+
75
+ const TIER_META = {
76
+ empirical: {
77
+ label: "Empirical",
78
+ short: "EMP",
79
+ desc: "Directly measured or observed",
80
+ examples: "USGS high-water marks Β· FloodNet sensors Β· Sandy Inundation Zone",
81
+ },
82
+ modeled: {
83
+ label: "Modeled",
84
+ short: "MOD",
85
+ desc: "Scenario-based prediction",
86
+ examples: "FEMA flood zones Β· DEP stormwater scenarios Β· NPCC4 SLR",
87
+ },
88
+ proxy: {
89
+ label: "Proxy",
90
+ short: "PRX",
91
+ desc: "Indirect indicator",
92
+ examples: "311 flood complaints Β· NFIP claims Β· terrain indices",
93
+ },
94
+ synthetic: {
95
+ label: "Synthetic prior",
96
+ short: "SYN",
97
+ desc: "Generated, not observed",
98
+ examples: "TerraMind land-cover Β· synthetic SAR for occluded days",
99
+ },
100
+ };
101
+
102
+ const TierBadge = ({ tier, compact = false }) => {
103
+ const meta = TIER_META[tier];
104
+ return (
105
+ <span
106
+ className={`tier-badge tier-badge-${tier}`}
107
+ style={{
108
+ display: "inline-flex",
109
+ alignItems: "center",
110
+ gap: 6,
111
+ fontFamily: "var(--font-mono)",
112
+ fontSize: 11,
113
+ letterSpacing: "0.08em",
114
+ textTransform: "uppercase",
115
+ color: `var(--tier-${tier})`,
116
+ fontWeight: 500,
117
+ }}
118
+ title={meta.desc}
119
+ >
120
+ <TierGlyph tier={tier} size={10} color={`var(--tier-${tier})`} />
121
+ {compact ? meta.short : meta.label}
122
+ </span>
123
+ );
124
+ };
125
+
126
+ Object.assign(window, { TierGlyph, TierBadge, TIER_META });
docs/design_handoff/design_files/landing-variants.css ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Riprap landing-page variants , shared styles */
2
+
3
+ /* Chrome */
4
+ .land { background: var(--paper); color: var(--ink); font-family: var(--font-sans); display: flex; flex-direction: column; min-height: 100%; }
5
+ .land-header { display: flex; align-items: baseline; gap: 12px; padding: 20px 32px; border-bottom: 1px solid var(--rule-soft); }
6
+ .land-header .riprap-wordmark { font-family: var(--font-serif); font-weight: 600; font-size: 18px; letter-spacing: 0.02em; }
7
+ .land-header-sep { color: var(--ink-tertiary); }
8
+ .land-header-context { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-secondary); }
9
+ .land-header-nav { margin-left: auto; display: flex; gap: 18px; font-family: var(--font-mono); font-size: 12px; }
10
+ .land-header-nav a { color: var(--ink-secondary); text-decoration: none; border-bottom: 1px dotted transparent; }
11
+ .land-header-nav a:hover { border-bottom-color: var(--ink-secondary); }
12
+
13
+ .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; }
14
+ .land-footer-tiers { display: flex; gap: 16px; flex-wrap: wrap; }
15
+ .land-footer-tier { display: inline-flex; align-items: center; gap: 5px; }
16
+ .lm-sw-syn { background: rgba(42,111,168,0.25); border: 1px dashed var(--tier-modeled); }
17
+
18
+ /* Hero common */
19
+ .land-hero { padding: 64px 32px 48px; }
20
+ .land-hero-h1 { display: flex; flex-direction: column; gap: 18px; margin: 0 0 30px; max-width: 880px; }
21
+ .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; }
22
+ .land-hero-headline em { font-style: italic; font-weight: 500; }
23
+ .land-hero-deck { font-family: var(--font-serif); font-size: 18px; line-height: 1.55; color: var(--ink-secondary); max-width: 64ch; }
24
+
25
+ /* Query box */
26
+ .land-query { display: flex; align-items: stretch; gap: 0; max-width: 760px; border: 1px solid var(--ink); background: white; }
27
+ .land-query-lg { font-size: 18px; }
28
+ .land-query-md { font-size: 16px; max-width: 640px; }
29
+ .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); }
30
+ .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); }
31
+ .land-query-input::placeholder { color: var(--ink-tertiary); }
32
+ .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; }
33
+ .land-query-submit:hover { background: #000; }
34
+
35
+ /* Sections */
36
+ .land-section { padding: 48px 32px; border-top: 1px solid var(--rule-soft); }
37
+ .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); }
38
+ .land-section-meta { font-family: var(--font-serif); font-style: italic; font-size: 14px; color: var(--ink-tertiary); }
39
+ .land-section-link { font-family: var(--font-mono); font-size: 12px; color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--ink); }
40
+
41
+ /* ── v1 ── */
42
+ .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; }
43
+ .land-cycling-label { letter-spacing: 0.06em; text-transform: uppercase; font-size: 11px; line-height: 1.4em; }
44
+ .land-cycling-rail { position: relative; min-width: 0; height: 1.4em; line-height: 1.4em; }
45
+ .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; }
46
+ .land-cycling-item.is-active { opacity: 1; }
47
+
48
+ .land-preview { display: flex; justify-content: flex-start; }
49
+ .land-preview-frame { background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--ink); padding: 22px 26px; max-width: 760px; }
50
+ .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; }
51
+ .land-preview-body { font-family: var(--font-serif); font-size: 17px; line-height: 1.65; color: var(--ink); margin: 0 0 18px; }
52
+ .land-preview-cite { background: linear-gradient(transparent 60%, rgba(11,83,148,0.14) 60%); }
53
+ .land-preview-cite sup { font-family: var(--font-mono); font-size: 10px; color: var(--tier-empirical); margin-left: 2px; vertical-align: super; }
54
+ .land-preview-cites { display: flex; flex-direction: column; gap: 6px; padding-top: 14px; border-top: 1px dashed var(--rule-soft); }
55
+ .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; }
56
+ .land-preview-cite-pin { color: var(--tier-empirical); font-weight: 600; }
57
+ .land-preview-cite-src { color: var(--ink); }
58
+ .land-preview-cite-tier { color: var(--ink-tertiary); font-size: 11px; text-align: right; letter-spacing: 0.04em; }
59
+
60
+ /* ── v2 ── */
61
+ .land-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
62
+ .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; }
63
+ .land-gallery-card:hover { background: var(--paper-deep); }
64
+ .land-gallery-kind { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-tertiary); }
65
+ .land-gallery-title { font-family: var(--font-serif); font-size: 22px; font-weight: 500; margin: 0; color: var(--ink); }
66
+ .land-gallery-sub { font-family: var(--font-serif); font-size: 14px; font-style: italic; color: var(--ink-secondary); margin: 0; line-height: 1.45; }
67
+ .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); }
68
+ .land-gallery-arrow { margin-top: 6px; font-family: var(--font-mono); font-size: 11px; color: var(--ink); letter-spacing: 0.04em; }
69
+
70
+ .land-section-stones { background: var(--paper-deep); }
71
+ .land-stones-strip { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
72
+ .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); }
73
+ .land-stone-pill strong { display: block; font-family: var(--font-sans); font-weight: 600; font-size: 15px; color: var(--ink); margin-bottom: 4px; }
74
+
75
+ /* ── v3 ── */
76
+ .land-hero-v3 .land-hero-h1 { margin-bottom: 36px; }
77
+ .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; }
78
+ .land-frieze-stone { padding: 22px 18px 24px; border-right: 1px solid var(--rule-soft); display: flex; flex-direction: column; gap: 8px; }
79
+ .land-frieze-stone:last-child { border-right: none; }
80
+ .land-frieze-num { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.08em; }
81
+ .land-frieze-name { font-family: var(--font-serif); font-size: 22px; font-weight: 500; margin: 0; color: var(--ink); }
82
+ .land-frieze-role { font-family: var(--font-sans); font-size: 13px; color: var(--ink-secondary); }
83
+ .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; }
84
+ .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; }
85
+
86
+ .land-frieze-query { display: flex; flex-direction: column; gap: 12px; }
87
+ .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; }
88
+ .land-link { background: none; border: none; padding: 0; font: inherit; color: var(--ink); border-bottom: 1px dotted var(--ink-secondary); cursor: pointer; }
89
+ .land-link:hover { border-bottom-style: solid; }
docs/design_handoff/design_files/landing-variants.jsx ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Riprap landing-page variants β€” three artboards on a design canvas.
2
+ v1: Minimal pushed harder (cycling example queries + tiny grounded-output preview)
3
+ v2: Example gallery (query box + 6 pre-baked NYC archetype briefings)
4
+ v3: Methodology-forward (5 Stones frieze leads, query box below)
5
+ */
6
+
7
+ const { useState: useLand, useEffect: useLandFx } = React;
8
+
9
+ /* ═══════════════════════════════════════════════════════════════════════
10
+ Shared chrome β€” wordmark + footer methodology link
11
+ ═══════════════════════════════════════════════════════════════════════ */
12
+ const LandingChrome = ({ children, board }) => (
13
+ <div className={`land land-${board}`}>
14
+ <header className="land-header">
15
+ <span className="riprap-wordmark">riprap</span>
16
+ <span className="land-header-sep">/</span>
17
+ <span className="land-header-context">Flood Exposure Briefing Β· NYC</span>
18
+ <nav className="land-header-nav">
19
+ <a href="#methodology">Methodology</a>
20
+ <a href="#sources">Sources</a>
21
+ <a href="#about">About</a>
22
+ </nav>
23
+ </header>
24
+ {children}
25
+ <footer className="land-footer">
26
+ <span>Riprap v0.4.4 β€” built on NYC OpenData, FEMA NFHL, USGS, NPCC4</span>
27
+ <span>Each briefing is a citation graph. Every claim links to its primary source.</span>
28
+ </footer>
29
+ </div>
30
+ );
31
+
32
+ const QueryBox = ({ size = "md", placeholder = "Address, neighborhood, or BBL β€” e.g. 80 Pioneer Street, Red Hook" }) => (
33
+ <form className={`land-query land-query-${size}`} onSubmit={(e) => e.preventDefault()}>
34
+ <span className="land-query-prompt" aria-hidden="true">β€Ί</span>
35
+ <input
36
+ type="text"
37
+ placeholder={placeholder}
38
+ className="land-query-input"
39
+ aria-label="Query an address, neighborhood, or BBL"
40
+ />
41
+ <button type="submit" className="land-query-submit">
42
+ Brief this place β†’
43
+ </button>
44
+ </form>
45
+ );
46
+
47
+ /* ═══════════════════════════════════════════════════════════════════════
48
+ v1 Β· Minimal pushed harder
49
+ ═══════════════════════════════════════════════════════════════════════ */
50
+ const SAMPLE_QUERIES = [
51
+ "80 Pioneer Street, Red Hook",
52
+ "Coney Island Hospital",
53
+ "PS 188, Lower East Side",
54
+ "Hammels Houses, Rockaway",
55
+ "Bowling Green station",
56
+ "555 W 57th Street",
57
+ ];
58
+
59
+ const CyclingExamples = () => {
60
+ const [i, setI] = useLand(0);
61
+ useLandFx(() => {
62
+ const t = setInterval(() => setI((x) => (x + 1) % SAMPLE_QUERIES.length), 2200);
63
+ return () => clearInterval(t);
64
+ }, []);
65
+ return (
66
+ <div className="land-cycling" aria-live="polite">
67
+ <span className="land-cycling-label">Try:</span>
68
+ <span className="land-cycling-rail">
69
+ {SAMPLE_QUERIES.map((q, idx) => (
70
+ <span
71
+ key={q}
72
+ className={`land-cycling-item ${idx === i ? "is-active" : ""}`}
73
+ aria-hidden={idx !== i}
74
+ >
75
+ {q}
76
+ </span>
77
+ ))}
78
+ </span>
79
+ </div>
80
+ );
81
+ };
82
+
83
+ const GroundedOutputPreview = () => (
84
+ <div className="land-preview" aria-label="Sample of grounded output">
85
+ <div className="land-preview-frame">
86
+ <div className="land-preview-eyebrow">Excerpt β€” 80 Pioneer Street briefing</div>
87
+ <p className="land-preview-body">
88
+ The lot sits inside the FEMA <span className="land-preview-cite">1% AE flood zone <sup>[c3]</sup></span>,
89
+ with Hurricane Sandy storm-surge high-water marks recorded
90
+ <span className="land-preview-cite"> 4.7 ft above grade <sup>[c1]</sup></span> at the address.
91
+ FloodNet sensor FN-BK-018, two blocks north, has logged
92
+ <span className="land-preview-cite"> 14 nuisance-flood events since 2023 <sup>[c2]</sup></span>.
93
+ </p>
94
+ <div className="land-preview-cites">
95
+ <div className="land-preview-cite-row">
96
+ <span className="land-preview-cite-pin">[c1]</span>
97
+ <span className="land-preview-cite-src">USGS High-Water Mark Β· Sandy 2012</span>
98
+ <span className="land-preview-cite-tier">empirical</span>
99
+ </div>
100
+ <div className="land-preview-cite-row">
101
+ <span className="land-preview-cite-pin">[c2]</span>
102
+ <span className="land-preview-cite-src">FloodNet sensor FN-BK-018 Β· 2023–2026</span>
103
+ <span className="land-preview-cite-tier">empirical</span>
104
+ </div>
105
+ <div className="land-preview-cite-row">
106
+ <span className="land-preview-cite-pin">[c3]</span>
107
+ <span className="land-preview-cite-src">FEMA NFHL Β· panel 36047C0207</span>
108
+ <span className="land-preview-cite-tier">modeled</span>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ );
114
+
115
+ const LandingV1 = () => (
116
+ <LandingChrome board="v1">
117
+ <main className="land-hero land-hero-v1">
118
+ <h1 className="land-hero-h1">
119
+ <span className="land-hero-eyebrow">Riprap</span>
120
+ <span className="land-hero-headline">A flood exposure briefing for any place in New York City.</span>
121
+ <span className="land-hero-deck">
122
+ Type an address. Get a written briefing where every numeric claim
123
+ links to its primary public-record source.
124
+ </span>
125
+ </h1>
126
+ <QueryBox size="lg"/>
127
+ <CyclingExamples/>
128
+ </main>
129
+ <section className="land-section land-section-v1">
130
+ <div className="land-section-head">
131
+ <span className="section-label">What you'll get back</span>
132
+ <span className="land-section-meta">A grounded paragraph with citations β€” not a chatbot answer.</span>
133
+ </div>
134
+ <GroundedOutputPreview/>
135
+ </section>
136
+ </LandingChrome>
137
+ );
138
+
139
+ /* ═══════════════════════════════════════════════════════════════════════
140
+ v2 Β· Example gallery
141
+ ═══════════════════════════════════════════════════════════════════════ */
142
+ const GALLERY = [
143
+ { 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" },
144
+ { kind: "Hospital", title: "Coney Island Hospital", sub: "Brooklyn Β· NYC Health+Hospitals Β· coastal AE-zone facility", tally: "11 cards Β· evacuated 2012 Β· NPCC4 +30in by 2070" },
145
+ { 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" },
146
+ { kind: "NYCHA", title: "Hammels Houses", sub: "Rockaway Β· 712 units Β· ocean-side public housing", tally: "13 cards Β· multi-event flooding Β· TerraMind synthetic SAR" },
147
+ { 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" },
148
+ { 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" },
149
+ ];
150
+
151
+ const GalleryCard = ({ item }) => (
152
+ <a className="land-gallery-card" href="#" onClick={(e) => e.preventDefault()}>
153
+ <div className="land-gallery-kind">{item.kind}</div>
154
+ <h3 className="land-gallery-title">{item.title}</h3>
155
+ <p className="land-gallery-sub">{item.sub}</p>
156
+ <div className="land-gallery-tally">{item.tally}</div>
157
+ <span className="land-gallery-arrow" aria-hidden="true">Open briefing β†’</span>
158
+ </a>
159
+ );
160
+
161
+ const LandingV2 = () => (
162
+ <LandingChrome board="v2">
163
+ <main className="land-hero land-hero-v2">
164
+ <h1 className="land-hero-h1">
165
+ <span className="land-hero-eyebrow">Riprap Β· Flood Exposure Briefing</span>
166
+ <span className="land-hero-headline">What does flood mean for this place in New York?</span>
167
+ <span className="land-hero-deck">
168
+ Riprap reads a place across hazard, exposure, observation, and projection β€”
169
+ and writes it down with citations.
170
+ </span>
171
+ </h1>
172
+ <QueryBox size="lg"/>
173
+ </main>
174
+ <section className="land-section land-section-v2">
175
+ <div className="land-section-head">
176
+ <span className="section-label">Or open a sample briefing</span>
177
+ <span className="land-section-meta">Six NYC archetypes Β· pre-computed Β· click to read</span>
178
+ </div>
179
+ <div className="land-gallery">
180
+ {GALLERY.map((it) => <GalleryCard key={it.title} item={it}/>)}
181
+ </div>
182
+ </section>
183
+ <section className="land-section land-section-stones">
184
+ <div className="land-section-head">
185
+ <span className="section-label">How Riprap reads a place</span>
186
+ <a className="land-section-link" href="#methodology">See methodology β†’</a>
187
+ </div>
188
+ <div className="land-stones-strip">
189
+ <div className="land-stone-pill"><strong>Cornerstone</strong> hazard reader</div>
190
+ <div className="land-stone-pill"><strong>Keystone</strong> asset register</div>
191
+ <div className="land-stone-pill"><strong>Touchstone</strong> live observer</div>
192
+ <div className="land-stone-pill"><strong>Lodestone</strong> projector</div>
193
+ <div className="land-stone-pill"><strong>Capstone</strong> synthesizer</div>
194
+ </div>
195
+ </section>
196
+ </LandingChrome>
197
+ );
198
+
199
+ /* ══════════��════════════════════════════════════════════════════════════
200
+ v3 Β· Methodology-forward
201
+ ═══════════════════════════════════════════════════════════════════════ */
202
+ const STONE_FRIEZE = [
203
+ { name: "Cornerstone", role: "the hazard reader", tag: "what NYC's ground remembers", sources: "USGS HWMs Β· FEMA NFHL Β· DEP stormwater Β· Prithvi historical" },
204
+ { name: "Keystone", role: "the asset register", tag: "what's exposed", sources: "MTA Β· NYCHA Β· DOE Β· DOH Β· PLUTO" },
205
+ { name: "Touchstone", role: "the live observer", tag: "what's happening now", sources: "FloodNet sensors Β· 311 complaints Β· tidal gauges" },
206
+ { name: "Lodestone", role: "the projector", tag: "what's coming", sources: "NPCC4 Β· TTM foundation model Β· TerraMind synthetic SAR Β· NFIP" },
207
+ { name: "Capstone", role: "the synthesizer", tag: "writes it all down", sources: "Granite composer Β· Mellea grounding-check Β· WeasyPrint" },
208
+ ];
209
+
210
+ const LandingV3 = () => (
211
+ <LandingChrome board="v3">
212
+ <main className="land-hero land-hero-v3">
213
+ <h1 className="land-hero-h1">
214
+ <span className="land-hero-eyebrow">Riprap Β· Flood Exposure Briefing</span>
215
+ <span className="land-hero-headline">Five Stones read every place.</span>
216
+ <span className="land-hero-deck">
217
+ Each briefing routes through a fixed taxonomy of public-record specialists.
218
+ Each Stone is a class of evidence; together they form the briefing.
219
+ </span>
220
+ </h1>
221
+ <div className="land-frieze">
222
+ {STONE_FRIEZE.map((s, i) => (
223
+ <article key={s.name} className="land-frieze-stone">
224
+ <div className="land-frieze-num">{String(i + 1).padStart(2, "0")}</div>
225
+ <h3 className="land-frieze-name">{s.name}</h3>
226
+ <div className="land-frieze-role">{s.role}</div>
227
+ <p className="land-frieze-tag">{s.tag}</p>
228
+ <div className="land-frieze-sources">{s.sources}</div>
229
+ </article>
230
+ ))}
231
+ </div>
232
+ <div className="land-frieze-query">
233
+ <QueryBox size="lg"/>
234
+ <span className="land-frieze-query-meta">
235
+ Try <button className="land-link" type="button">80 Pioneer Street, Red Hook</button> Β·
236
+ <button className="land-link" type="button"> Coney Island Hospital</button> Β·
237
+ <button className="land-link" type="button"> Hammels Houses</button>
238
+ </span>
239
+ </div>
240
+ </main>
241
+ </LandingChrome>
242
+ );
243
+
244
+ /* ═══════════════════════════════════════════════════════════════════════
245
+ Canvas mount
246
+ ═══════════════════════════════════════════════════════════════════════ */
247
+ const App = () => (
248
+ <DesignCanvas
249
+ title="Riprap landing β€” three directions"
250
+ subtitle="Compare side-by-side. Click any artboard to focus."
251
+ >
252
+ <DCSection id="landings" title="Landing page Β· directions">
253
+ <DCArtboard id="v1" label="v1 Β· Minimal pushed harder" width={1200} height={1500}>
254
+ <LandingV1/>
255
+ </DCArtboard>
256
+ <DCArtboard id="v2" label="v2 Β· Example gallery" width={1200} height={1700}>
257
+ <LandingV2/>
258
+ </DCArtboard>
259
+ <DCArtboard id="v3" label="v3 Β· Methodology-forward" width={1200} height={1500}>
260
+ <LandingV3/>
261
+ </DCArtboard>
262
+ </DCSection>
263
+ </DesignCanvas>
264
+ );
265
+
266
+ ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
docs/design_handoff/design_files/map.jsx ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Static SVG mock of the MapLibre map for 80 Pioneer St, Red Hook.
2
+ No network dependency. Encodes all four evidence-tier styles
3
+ per the brief: empirical solid + 0.4 fill, modeled solid + 0.25,
4
+ synthetic dashed + 0.25 with stripe, proxy graduated dots no fill.
5
+ */
6
+
7
+ const RedHookMapMock = ({ activeLayers, queriedAddress }) => {
8
+ return (
9
+ <svg
10
+ viewBox="0 0 800 560"
11
+ width="100%"
12
+ height="100%"
13
+ role="application"
14
+ aria-label={`NYC flood-exposure map for ${queriedAddress}`}
15
+ style={{ display: "block", background: "#F2F2EE" }}
16
+ preserveAspectRatio="xMidYMid slice"
17
+ >
18
+ <defs>
19
+ {/* Diagonal stripe pattern for synthetic-prior fill */}
20
+ <pattern id="syn-stripe" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
21
+ <rect width="6" height="6" fill="rgba(42,111,168,0.18)"/>
22
+ <line x1="0" y1="0" x2="0" y2="6" stroke="#2A6FA8" strokeWidth="1" />
23
+ </pattern>
24
+ {/* Halo for label readability */}
25
+ <filter id="label-halo" x="-10%" y="-10%" width="120%" height="120%">
26
+ <feMorphology in="SourceAlpha" radius="1.5" operator="dilate" result="halo"/>
27
+ <feFlood floodColor="#FAFAF7"/>
28
+ <feComposite in2="halo" operator="in"/>
29
+ <feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
30
+ </filter>
31
+ </defs>
32
+
33
+ {/* ── Basemap: Carto Positron register ── */}
34
+ {/* Water (Erie Basin / Buttermilk Channel) */}
35
+ <path d="M 0 380 L 220 360 L 360 410 L 520 470 L 800 500 L 800 560 L 0 560 Z" fill="#DCE6EC"/>
36
+ <path d="M 540 0 L 580 0 L 600 90 L 640 180 L 700 240 L 800 280 L 800 0 Z" fill="#DCE6EC"/>
37
+
38
+ {/* Park (Coffey Park) */}
39
+ <rect x="380" y="240" width="90" height="60" fill="#E2E8DA"/>
40
+
41
+ {/* Parcels (reference layer #E5E5E5) */}
42
+ <g stroke="#C9C9C5" strokeWidth="0.5" fill="#FAFAF7">
43
+ {Array.from({ length: 8 }).map((_, r) =>
44
+ Array.from({ length: 14 }).map((_, c) => (
45
+ <rect key={`p-${r}-${c}`} x={60 + c * 50} y={60 + r * 38} width="46" height="34" />
46
+ ))
47
+ )}
48
+ </g>
49
+
50
+ {/* Streets */}
51
+ <g stroke="#FAFAF7" strokeWidth="6" fill="none">
52
+ <path d="M 0 100 L 800 90"/>
53
+ <path d="M 0 200 L 800 190"/>
54
+ <path d="M 0 300 L 800 290"/>
55
+ <path d="M 60 0 L 50 380"/>
56
+ <path d="M 200 0 L 190 380"/>
57
+ <path d="M 340 0 L 330 380"/>
58
+ <path d="M 480 0 L 470 380"/>
59
+ <path d="M 620 0 L 610 380"/>
60
+ </g>
61
+ <g stroke="#C9C9C5" strokeWidth="6.5" fill="none" opacity="0.0"/>
62
+
63
+ {/* ── Empirical layer: Sandy Inundation Zone ── */}
64
+ {activeLayers.empirical && (
65
+ <g aria-label="Sandy Inundation Zone">
66
+ <path
67
+ d="M 0 380 L 220 360 L 360 410 L 520 470 L 800 500 L 800 560 L 0 560 Z
68
+ M 0 360 L 240 340 L 380 390 L 540 450 L 800 480
69
+ L 800 380 L 600 360 L 420 320 L 240 300 L 0 320 Z"
70
+ fill="rgba(11,83,148,0.40)"
71
+ stroke="#0B5394"
72
+ strokeWidth="1.5"
73
+ fillRule="evenodd"
74
+ />
75
+ </g>
76
+ )}
77
+
78
+ {/* ── Modeled layer: FEMA AE zone (solid line, 0.25 fill) ── */}
79
+ {activeLayers.modeled && (
80
+ <g aria-label="FEMA Zone AE">
81
+ <path
82
+ d="M 40 340 L 280 320 L 440 360 L 600 420 L 800 440 L 800 560 L 0 560 L 0 350 Z"
83
+ fill="rgba(42,111,168,0.25)"
84
+ stroke="#2A6FA8"
85
+ strokeWidth="1.5"
86
+ strokeDasharray="0"
87
+ />
88
+ </g>
89
+ )}
90
+
91
+ {/* ── Synthetic-prior: dashed line + stripe pattern ── */}
92
+ {activeLayers.synthetic && (
93
+ <g aria-label="Synthetic SAR backscatter (TerraMind, 2025-09-14)">
94
+ <path
95
+ d="M 100 380 L 260 360 L 380 390 L 480 420 L 600 440 L 720 460 L 720 500 L 100 500 Z"
96
+ fill="url(#syn-stripe)"
97
+ stroke="#2A6FA8"
98
+ strokeWidth="1.5"
99
+ strokeDasharray="4 3"
100
+ />
101
+ </g>
102
+ )}
103
+
104
+ {/* ── Proxy: 311 flood complaints (graduated dots, no fill) ── */}
105
+ {activeLayers.proxy && (
106
+ <g aria-label="311 flood complaints, 2019-2025">
107
+ {[
108
+ [120, 320, 5], [180, 350, 8], [220, 280, 4], [280, 330, 11],
109
+ [340, 360, 6], [240, 240, 3], [380, 320, 9], [440, 350, 7],
110
+ [200, 220, 4], [160, 280, 5], [340, 240, 3], [420, 280, 4],
111
+ [500, 360, 6], [540, 400, 8], [180, 380, 5],
112
+ ].map(([x, y, r], i) => (
113
+ <circle key={i} cx={x} cy={y} r={r} fill="none" stroke="#6B6B6B" strokeWidth="1.25" />
114
+ ))}
115
+ </g>
116
+ )}
117
+
118
+ {/* ── Asset pins for register specialists ── */}
119
+ {/* Subway entrance , square */}
120
+ <g transform="translate(580 260)">
121
+ <rect x="-5" y="-5" width="10" height="10" fill="#1A1A1A" />
122
+ <text x="0" y="-9" fontSize="9" fontFamily="IBM Plex Sans" textAnchor="middle" fill="#1A1A1A" filter="url(#label-halo)">Smith–9 St</text>
123
+ </g>
124
+ {/* NYCHA , open square */}
125
+ <g transform="translate(420 200)">
126
+ <rect x="-5" y="-5" width="10" height="10" fill="none" stroke="#1A1A1A" strokeWidth="1.5"/>
127
+ <text x="0" y="-9" fontSize="9" fontFamily="IBM Plex Sans" textAnchor="middle" fill="#1A1A1A" filter="url(#label-halo)">Red Hook Houses</text>
128
+ </g>
129
+ {/* School , cross */}
130
+ <g transform="translate(360 280)" stroke="#1A1A1A" strokeWidth="1.75" fill="none">
131
+ <line x1="-5" y1="0" x2="5" y2="0"/><line x1="0" y1="-5" x2="0" y2="5"/>
132
+ <text x="8" y="3" fontSize="9" fontFamily="IBM Plex Sans" fill="#1A1A1A" filter="url(#label-halo)">PS 15</text>
133
+ </g>
134
+ {/* Hospital , circle */}
135
+ <g transform="translate(680 160)">
136
+ <circle r="5" fill="#1A1A1A"/>
137
+ <text x="8" y="3" fontSize="9" fontFamily="IBM Plex Sans" fill="#1A1A1A" filter="url(#label-halo)">NYU Cobble Hill</text>
138
+ </g>
139
+
140
+ {/* ── Queried-address pin (warm orange, dominant at z14+) ── */}
141
+ <g transform="translate(300 320)">
142
+ <circle r="14" fill="rgba(209,124,0,0.20)"/>
143
+ <circle r="6" fill="#D17C00" stroke="#FAFAF7" strokeWidth="2"/>
144
+ <line x1="0" y1="6" x2="0" y2="22" stroke="#D17C00" strokeWidth="2"/>
145
+ <text x="0" y="-12" fontSize="11" fontWeight="600" fontFamily="IBM Plex Sans" textAnchor="middle" fill="#1A1A1A" filter="url(#label-halo)">80 Pioneer St</text>
146
+ </g>
147
+
148
+ {/* ── Map labels (Imhof hierarchy: water italic, neighborhoods regular caps) ── */}
149
+ <text x="640" y="490" fontSize="13" fontStyle="italic" fontFamily="IBM Plex Sans" fill="#5A7B8A" filter="url(#label-halo)">Buttermilk Channel</text>
150
+ <text x="120" y="450" fontSize="13" fontStyle="italic" fontFamily="IBM Plex Sans" fill="#5A7B8A" filter="url(#label-halo)">Erie Basin</text>
151
+ <text x="180" y="40" fontSize="14" fontFamily="IBM Plex Sans" fontWeight="500" letterSpacing="0.18em" fill="#4A4A4A" filter="url(#label-halo)">RED HOOK</text>
152
+ <text x="600" y="40" fontSize="14" fontFamily="IBM Plex Sans" fontWeight="500" letterSpacing="0.18em" fill="#4A4A4A" filter="url(#label-halo)">CARROLL GARDENS</text>
153
+ <text x="425" y="265" fontSize="11" fontFamily="IBM Plex Sans" fill="#4A6B4A" filter="url(#label-halo)">Coffey Park</text>
154
+
155
+ {/* Street labels at z15+ */}
156
+ <text x="120" y="195" fontSize="10" fontFamily="IBM Plex Sans" fill="#6B6B6B" filter="url(#label-halo)">Van Brunt St</text>
157
+ <text x="120" y="295" fontSize="10" fontFamily="IBM Plex Sans" fill="#6B6B6B" filter="url(#label-halo)">Pioneer St</text>
158
+ <text x="120" y="345" fontSize="10" fontFamily="IBM Plex Sans" fill="#6B6B6B" filter="url(#label-halo)">Imlay St</text>
159
+
160
+ {/* ── Scale bar + zoom indicator (corners, like USGS quad) ── */}
161
+ <g transform="translate(20 530)" fontFamily="IBM Plex Mono" fontSize="10" fill="#4A4A4A">
162
+ <line x1="0" y1="-2" x2="80" y2="-2" stroke="#1A1A1A" strokeWidth="1.5"/>
163
+ <line x1="0" y1="-5" x2="0" y2="1" stroke="#1A1A1A" strokeWidth="1"/>
164
+ <line x1="40" y1="-5" x2="40" y2="1" stroke="#1A1A1A" strokeWidth="1"/>
165
+ <line x1="80" y1="-5" x2="80" y2="1" stroke="#1A1A1A" strokeWidth="1"/>
166
+ <text x="0" y="14">0</text>
167
+ <text x="40" y="14" textAnchor="middle">200</text>
168
+ <text x="80" y="14" textAnchor="middle">400 ft</text>
169
+ </g>
170
+ <g transform="translate(720 28)" fontFamily="IBM Plex Mono" fontSize="10" fill="#4A4A4A">
171
+ <text x="0" y="0">z 16 Β· 40.6776Β°N 74.0096Β°W</text>
172
+ </g>
173
+ </svg>
174
+ );
175
+ };
176
+
177
+ const MapLegend = ({ activeLayers, onToggle }) => {
178
+ const layers = [
179
+ { key: "empirical", tier: "empirical", label: "Sandy Inundation Zone (2012)", source: "NYC OEM" },
180
+ { key: "modeled", tier: "modeled", label: "FEMA Zone AE , preliminary FIRM", source: "FEMA" },
181
+ { key: "synthetic", tier: "synthetic", label: "Synthetic SAR (2025-09-14)", source: "TerraMind v1.2" },
182
+ { key: "proxy", tier: "proxy", label: "311 flood complaints, 2019–25", source: "NYC 311" },
183
+ ];
184
+ return (
185
+ <div className="map-legend" role="group" aria-label="Map layer toggles">
186
+ <div className="map-legend-head">
187
+ <span className="section-label">Layers</span>
188
+ </div>
189
+ {layers.map((l) => (
190
+ <button
191
+ key={l.key}
192
+ type="button"
193
+ className={`map-legend-item ${activeLayers[l.key] ? "is-on" : "is-off"}`}
194
+ onClick={() => onToggle(l.key)}
195
+ aria-pressed={activeLayers[l.key]}
196
+ >
197
+ <span className="map-legend-swatch" aria-hidden="true">
198
+ <TierGlyph tier={l.tier} size={11} color={`var(--tier-${l.tier})`} />
199
+ </span>
200
+ <span className="map-legend-text">
201
+ <span className="map-legend-label">{l.label}</span>
202
+ <span className="map-legend-source">{l.source} Β· <TierBadge tier={l.tier} compact /></span>
203
+ </span>
204
+ <span className="map-legend-toggle" aria-hidden="true">{activeLayers[l.key] ? "ON" : "OFF"}</span>
205
+ </button>
206
+ ))}
207
+ </div>
208
+ );
209
+ };
210
+
211
+ Object.assign(window, { RedHookMapMock, MapLegend });
docs/design_handoff/design_files/shell.jsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* App shell + cold-start state + spec sections that live below the
2
+ prototype: typography spec, palette spec, glyph spec, MapLibre style.json
3
+ fragments, layout grids, PDF template preview, accessibility checklist,
4
+ design rationale, reference register sketches.
5
+ */
6
+
7
+ const SAMPLE_QUERIES = [
8
+ { mode: "address", q: "80 Pioneer Street, Red Hook, Brooklyn",
9
+ sub: "Address-mode Β· Sandy edge Β· IBZ Β· NYCHA proximity" },
10
+ { mode: "neighborhood", q: "Far Rockaway flood exposure briefing",
11
+ sub: "Neighborhood-mode Β· chronic stormwater Β· 2050 SLR" },
12
+ { mode: "development", q: "Hunts Point proposed rezoning , flood-context check",
13
+ sub: "Development-check Β· CEQR Β§817 Β· 311 proxy density" },
14
+ ];
15
+
16
+ const ColdStart = ({ onPick, onSubmit }) => {
17
+ const [v, setV] = useState("");
18
+ return (
19
+ <section className="cold-start" aria-label="Empty query state">
20
+ <div className="cold-start-band">
21
+ <p className="cold-start-deck">
22
+ <strong>Riprap</strong> is a citation-grounded Flood Exposure Briefing tool for New York City.
23
+ Type an address, neighborhood, or proposed development , Riprap returns a written briefing
24
+ where every numeric claim links to its primary public-record source.
25
+ </p>
26
+ <p className="cold-start-deck cold-start-deck-secondary">
27
+ Built for agency analysts, planners, journalists, community boards, and researchers.
28
+ <strong> Not for individual residents making personal property decisions.</strong>
29
+ {" "}For residents seeking flood guidance, see <a href="https://www.floodhelpny.org" className="cold-start-redir">FloodHelpNY</a>.
30
+ For real-time conditions, see <a href="https://www.floodnet.nyc" className="cold-start-redir">FloodNet NYC</a>.
31
+ </p>
32
+ </div>
33
+
34
+ <form
35
+ className="cold-start-form"
36
+ onSubmit={(e) => { e.preventDefault(); onSubmit?.(v); }}
37
+ role="search"
38
+ >
39
+ <label htmlFor="riprap-query" className="cold-start-label section-label">Query</label>
40
+ <div className="cold-start-input-row">
41
+ <input
42
+ id="riprap-query"
43
+ type="text"
44
+ value={v}
45
+ onChange={(e) => setV(e.target.value)}
46
+ placeholder="address Β· neighborhood Β· proposed development"
47
+ className="cold-start-input"
48
+ autoComplete="off"
49
+ />
50
+ <button type="submit" className="cold-start-submit">Generate briefing β†’</button>
51
+ </div>
52
+ </form>
53
+
54
+ <div className="cold-start-samples">
55
+ <span className="section-label cold-start-samples-label">Sample queries</span>
56
+ <div className="cold-start-samples-grid">
57
+ {SAMPLE_QUERIES.map((s, i) => (
58
+ <button
59
+ key={i}
60
+ type="button"
61
+ className="cold-start-sample"
62
+ onClick={() => onPick?.(s)}
63
+ >
64
+ <span className="cold-start-sample-mode">{s.mode}</span>
65
+ <span className="cold-start-sample-q">{s.q}</span>
66
+ <span className="cold-start-sample-sub">{s.sub}</span>
67
+ <span className="cold-start-sample-arrow" aria-hidden="true">β†—</span>
68
+ </button>
69
+ ))}
70
+ </div>
71
+ </div>
72
+
73
+ <div className="cold-start-trust">
74
+ <span className="section-label">How Riprap is built</span>
75
+ <p className="cold-start-thesis">
76
+ <strong>Cornerstone</strong> remembers. <strong>Keystone</strong> tallies.
77
+ {" "}<strong>Touchstone</strong> watches. <strong>Lodestone</strong> projects.
78
+ {" "}<strong>Capstone</strong> writes it all down with citations.
79
+ </p>
80
+ <ul className="cold-start-trust-list">
81
+ <li>Five named cognitive roles compose ~25 atomic specialists. <a href="#spec-stones">Architecture β†’</a></li>
82
+ <li>All foundation models <strong>Apache-2.0</strong>; no commercial APIs at runtime.</li>
83
+ <li>All data from public-record federal, state, and city sources.</li>
84
+ <li>Four epistemic tiers , empirical, modeled, proxy, synthetic prior , visible in the briefing margin and the trace.</li>
85
+ <li>Sections without supporting documents are omitted entirely. Silence over confabulation.</li>
86
+ </ul>
87
+ <a href="#methodology" className="cold-start-method-link">Methodology paper β†’</a>
88
+ </div>
89
+ </section>
90
+ );
91
+ };
92
+
93
+ const AppHeader = ({ query, onResetCold, onOpenMethodology }) => (
94
+ <header className="app-header" data-screen-label="App header">
95
+ <div className="app-header-inner">
96
+ <div className="app-header-left">
97
+ <span className="riprap-wordmark" aria-label="Riprap">riprap</span>
98
+ <span className="app-header-sep">/</span>
99
+ <span className="app-header-context">Flood Exposure Briefing</span>
100
+ </div>
101
+ <div className="app-header-mid">
102
+ <button
103
+ type="button"
104
+ className="app-header-query"
105
+ onClick={onResetCold}
106
+ aria-label="Edit query"
107
+ >
108
+ <span className="app-header-query-icon" aria-hidden="true">βŒ•</span>
109
+ <span className="app-header-query-text">{query}</span>
110
+ <span className="app-header-query-edit">edit</span>
111
+ </button>
112
+ </div>
113
+ <div className="app-header-right">
114
+ <a className="app-header-link" href="#methodology" onClick={(e) => { e.preventDefault(); onOpenMethodology?.(); }}>methodology</a>
115
+ <a className="app-header-link" href="#export-pdf">export PDF</a>
116
+ <span className="app-header-status" aria-live="polite">
117
+ <span className="app-header-status-dot" aria-hidden="true"></span>
118
+ live
119
+ </span>
120
+ </div>
121
+ </div>
122
+ </header>
123
+ );
124
+
125
+ const AppFooter = () => (
126
+ <footer className="app-footer">
127
+ <div className="app-footer-inner">
128
+ <p className="app-footer-guard">
129
+ <strong>Riprap does not predict damage.</strong>
130
+ {" "}This tool is for professional analytical work, not personal property decisions.
131
+ For residents, see <a href="https://www.floodhelpny.org">FloodHelpNY</a> Β· <a href="https://www.floodnet.nyc">FloodNet NYC</a>.
132
+ </p>
133
+ <p className="app-footer-build">
134
+ 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
135
+ </p>
136
+ </div>
137
+ </footer>
138
+ );
139
+
140
+ Object.assign(window, { ColdStart, AppHeader, AppFooter });
docs/design_handoff/design_files/stone-evidence.jsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Riprap v0.4.4 , Unified Stone bands.
2
+ Each Stone holds: header (name + aggregate) β†’ evidence cards β†’ collapsed trace.
3
+ This replaces the duplicated "evidence grouped by stone" + "trace grouped by stone".
4
+ */
5
+
6
+ const { useState: useSEv44 } = React;
7
+
8
+ const EVIDENCE_BY_STONE = {
9
+ cornerstone: ["e1", "e3", "e4"], // USGS HWMs Β· FEMA FIRM Β· DEP stormwater
10
+ keystone: [], // (no exposure-register cards in current EVIDENCE , Keystone-silent)
11
+ touchstone: ["e2", "e7"], // FloodNet sensor Β· 311 complaints
12
+ lodestone: ["e5"], // NPCC4 SLR projection
13
+ capstone: ["e6", "e8"], // synthesis-tier outputs
14
+ };
15
+
16
+ const STONE_LOOKUP_V44 = {
17
+ cornerstone: { name: "Cornerstone", role: "the hazard reader", tag: "what NYC's ground remembers" },
18
+ keystone: { name: "Keystone", role: "the asset register", tag: "what's exposed" },
19
+ touchstone: { name: "Touchstone", role: "the live observer", tag: "what's happening now" },
20
+ lodestone: { name: "Lodestone", role: "the projector", tag: "what's coming" },
21
+ capstone: { name: "Capstone", role: "the synthesizer", tag: "writes it all down with citations" },
22
+ };
23
+
24
+ /* Walk a Stone's trace members (incl. nested children) into a flat list for tally. */
25
+ const flattenMembers = (members) =>
26
+ members.flatMap((m) => (m.children ? [m, ...flattenMembers(m.children)] : [m]));
27
+
28
+ const StoneTally = ({ cards, members }) => {
29
+ const flat = flattenMembers(members);
30
+ const fired = flat.filter((m) => m.status === "ok").length;
31
+ const silent = flat.filter((m) => m.status === "silent").length;
32
+ const warn = flat.filter((m) => m.status === "warn").length;
33
+ const error = flat.filter((m) => m.status === "error").length;
34
+ const ms = members.reduce((acc, m) => Math.max(acc, m.ms || 0), 0);
35
+ const fmt = (x) => (x === 0 ? ", " : x < 1000 ? x + "ms" : (x / 1000).toFixed(1) + "s");
36
+ return (
37
+ <span className="stone-uni-agg">
38
+ <span className="stone-uni-agg-cards">{cards.length} card{cards.length === 1 ? "" : "s"}</span>
39
+ <span className="stone-uni-agg-sep">Β·</span>
40
+ <span className="stone-uni-agg-num">{fired}</span> fired
41
+ {silent > 0 && <>{" Β· "}<span className="stone-uni-agg-num">{silent}</span> silent</>}
42
+ {warn > 0 && <>{" Β· "}<span className="stone-uni-agg-warn">{warn} warn</span></>}
43
+ {error > 0 && <>{" Β· "}<span className="stone-uni-agg-err">{error} error</span></>}
44
+ <span className="stone-uni-agg-sep">Β·</span>
45
+ <span className="stone-uni-agg-ms">{fmt(ms)}</span>
46
+ </span>
47
+ );
48
+ };
49
+
50
+ const UnifiedStoneBand = ({ stone, cardIds, onCite }) => {
51
+ const meta = STONE_LOOKUP_V44[stone.key];
52
+ const cards = cardIds.map((id) => EVIDENCE.find((e) => e.id === id)).filter(Boolean);
53
+ const traceCount = flattenMembers(stone.members).length;
54
+ const [traceOpen, setTraceOpen] = useSEv44(false);
55
+
56
+ return (
57
+ <section className={`stone-uni stone-uni-${stone.key}`} aria-labelledby={`stone-uni-h-${stone.key}`}>
58
+ <header className="stone-uni-head">
59
+ <div className="stone-uni-head-left">
60
+ <h3 id={`stone-uni-h-${stone.key}`} className="stone-uni-name">{meta.name}</h3>
61
+ <span className="stone-uni-role">, {meta.role}</span>
62
+ <span className="stone-uni-tag">{meta.tag}</span>
63
+ </div>
64
+ <StoneTally cards={cards} members={stone.members}/>
65
+ </header>
66
+
67
+ {/* Findings , primary surface */}
68
+ {cards.length === 0 ? (
69
+ <div className="stone-uni-empty">
70
+ <span className="section-label">silent</span>
71
+ <p>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.</p>
72
+ </div>
73
+ ) : (
74
+ <div className="stone-uni-rail">
75
+ {cards.map((ev) => <EvidenceCard key={ev.id} ev={ev} onCite={onCite}/>)}
76
+ </div>
77
+ )}
78
+
79
+ {/* Provenance , collapsed by default */}
80
+ <div className="stone-uni-trace">
81
+ <button
82
+ className="stone-uni-trace-toggle"
83
+ aria-expanded={traceOpen}
84
+ onClick={() => setTraceOpen((o) => !o)}
85
+ >
86
+ <span className="stone-uni-trace-caret" aria-hidden="true">{traceOpen ? "β–Ύ" : "β–Έ"}</span>
87
+ <span className="stone-uni-trace-label">
88
+ {traceOpen ? "Hide" : "Show"} provenance Β· {traceCount} function{traceCount === 1 ? "" : "s"}
89
+ </span>
90
+ </button>
91
+ {traceOpen && (
92
+ <div className="stone-uni-trace-body">
93
+ {stone.members.map((m) => <window.TraceRow key={m.id} m={m}/>)}
94
+ </div>
95
+ )}
96
+ </div>
97
+ </section>
98
+ );
99
+ };
100
+
101
+ /* Global tally strip , at-a-glance run health */
102
+ const RunHealthStrip = () => {
103
+ const allMembers = STONES.flatMap((s) => flattenMembers(s.members));
104
+ const fired = allMembers.filter((m) => m.status === "ok").length;
105
+ const total = allMembers.length;
106
+ const silent = allMembers.filter((m) => m.status === "silent").length;
107
+ const warn = allMembers.filter((m) => m.status === "warn").length;
108
+ const error = allMembers.filter((m) => m.status === "error").length;
109
+ const totalCards = Object.values(EVIDENCE_BY_STONE).reduce((n, ids) => n + ids.length, 0);
110
+ return (
111
+ <div className="run-health">
112
+ <span className="run-health-item"><strong>5</strong> Stones</span>
113
+ <span className="run-health-sep">Β·</span>
114
+ <span className="run-health-item"><strong>{fired}/{total}</strong> functions fired</span>
115
+ <span className="run-health-sep">Β·</span>
116
+ <span className="run-health-item"><strong>{totalCards}</strong> evidence cards</span>
117
+ <span className="run-health-sep">Β·</span>
118
+ <span className="run-health-item run-health-time"><strong>14.0s</strong> wall-clock</span>
119
+ {silent > 0 && <><span className="run-health-sep">Β·</span><span className="run-health-item run-health-silent">{silent} silent</span></>}
120
+ {warn > 0 && <><span className="run-health-sep">Β·</span><span className="run-health-item run-health-warn">{warn} warn</span></>}
121
+ {error > 0 && <><span className="run-health-sep">Β·</span><span className="run-health-item run-health-error">{error} error</span></>}
122
+ </div>
123
+ );
124
+ };
125
+
126
+ const UnifiedStoneLayout = ({ onCite }) => (
127
+ <section className="stone-uni-layout" aria-label="Findings and provenance, grouped by Stone">
128
+ <RunHealthStrip/>
129
+ {STONES.map((stone) => (
130
+ <UnifiedStoneBand
131
+ key={stone.key}
132
+ stone={stone}
133
+ cardIds={EVIDENCE_BY_STONE[stone.key] || []}
134
+ onCite={onCite}
135
+ />
136
+ ))}
137
+ </section>
138
+ );
139
+
140
+ Object.assign(window, { UnifiedStoneLayout });
docs/design_handoff/design_files/stones-trace.jsx ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Riprap v0.4.4 , Stone-banded trace (Treatment A).
2
+ Pulled out of the deleted spec-v043.jsx; this is what the mockup uses.
3
+ */
4
+
5
+ const { useState: useStV44, useEffect: useEfV44 } = React;
6
+
7
+ const STONES = [
8
+ {
9
+ key: "cornerstone", name: "Cornerstone", role: "the hazard reader",
10
+ tag: "what NYC's ground remembers",
11
+ members: [
12
+ { id: "c1", name: "sandy_inundation.lookup", status: "ok", ms: 380, tier: "empirical" },
13
+ { id: "c2", name: "usgs_hwm.spatial_join", status: "ok", ms: 460, tier: "empirical" },
14
+ { id: "c3", name: "fema_firm.lookup", status: "ok", ms: 290, tier: "modeled" },
15
+ { id: "c4", name: "dep_stormwater.lookup", status: "ok", ms: 540, tier: "modeled" },
16
+ { id: "c5", name: "prithvi.historical_segment",status: "warn", ms: 1240, tier: "modeled",
17
+ warning: "deprecation: Prithvi-100M v1 β†’ v2 migration scheduled 2026-Q3" },
18
+ ],
19
+ },
20
+ {
21
+ key: "keystone", name: "Keystone", role: "the asset register",
22
+ tag: "what's exposed",
23
+ members: [
24
+ { id: "k1", name: "mta.entrance_join", status: "ok", ms: 220, tier: "empirical" },
25
+ { id: "k2", name: "nycha.development_join", status: "ok", ms: 538, tier: "empirical" },
26
+ { id: "k3", name: "doe.school_join", status: "ok", ms: 180, tier: "empirical" },
27
+ { id: "k4", name: "doh.facility_join", status: "ok", ms: 210, tier: "empirical" },
28
+ { id: "k5", name: "pluto.lot_lookup", status: "ok", ms: 142, tier: "empirical" },
29
+ ],
30
+ },
31
+ {
32
+ key: "touchstone", name: "Touchstone", role: "the live observer",
33
+ tag: "what's happening now",
34
+ members: [
35
+ { id: "t1", name: "floodnet.history", status: "ok", ms: 1240, tier: "empirical" },
36
+ { id: "t2", name: "nyc311.flood_complaints", status: "ok", ms: 880, tier: "proxy" },
37
+ { id: "t3", name: "tidalgauge.recent", status: "silent", ms: 0, tier: "empirical",
38
+ note: "out of range (gauge >2km from address)" },
39
+ ],
40
+ },
41
+ {
42
+ key: "lodestone", name: "Lodestone", role: "the projector",
43
+ tag: "what's coming",
44
+ members: [
45
+ { id: "l1", name: "npcc4.slr_projection", status: "ok", ms: 320, tier: "modeled" },
46
+ { id: "l2", name: "ttm.foundation_run", status: "ok", ms: 14000, tier: "modeled",
47
+ children: [
48
+ { id: "l2a", name: "ttm.zarr_load", status: "ok", ms: 2400, tier: "modeled" },
49
+ { id: "l2b", name: "ttm.checkpoint", status: "error", ms: 10750, tier: "modeled",
50
+ error: "checkpoint architecture mismatch (ttm-r2 vs ttm-r1 weights)" },
51
+ { id: "l2c", name: "ttm.cpu_inference", status: "ok", ms: 850, tier: "modeled" },
52
+ ],
53
+ },
54
+ { id: "l3", name: "terramind.synthetic_sar", status: "ok", ms: 8200, tier: "synthetic" },
55
+ { id: "l4", name: "nfip.claims_aggregation", status: "ok", ms: 460, tier: "proxy" },
56
+ ],
57
+ },
58
+ {
59
+ key: "capstone", name: "Capstone", role: "the synthesizer",
60
+ tag: "writes it all down with citations",
61
+ members: [
62
+ { id: "p1", name: "granite.compose_briefing", status: "ok", ms: 3200, tier: "modeled" },
63
+ { id: "p2", name: "mellea.grounding_check", status: "ok", ms: 480, tier: "modeled" },
64
+ { id: "p3", name: "weasyprint.render_artifact",status: "ok", ms: 920, tier: null },
65
+ ],
66
+ },
67
+ ];
68
+
69
+ const fmtMs = (ms) => ms === 0 ? ", " : ms < 1000 ? ms + "ms" : (ms / 1000).toFixed(1) + "s";
70
+ const tierColor = (t) => t ? `var(--tier-${t})` : "var(--ink-tertiary)";
71
+
72
+ const StoneAggregate = ({ stone }) => {
73
+ const flat = (members) => members.flatMap(m => m.children ? [m, ...flat(m.children)] : [m]);
74
+ const all = flat(stone.members);
75
+ const fired = all.filter(m => m.status === "ok").length;
76
+ const silent = all.filter(m => m.status === "silent").length;
77
+ const warn = all.filter(m => m.status === "warn").length;
78
+ const error = all.filter(m => m.status === "error").length;
79
+ const ms = stone.members.reduce((acc, m) => Math.max(acc, m.ms || 0), 0);
80
+ return (
81
+ <span className="stone-band-agg">
82
+ <span className="stone-band-agg-num">{fired}</span> fired
83
+ {silent > 0 && <> Β· <span className="stone-band-agg-num">{silent}</span> silent</>}
84
+ {warn > 0 && <> Β· <span className="stone-band-agg-warn">{warn} warn</span></>}
85
+ {error > 0 && <> Β· <span className="stone-band-agg-err">{error} error</span></>}
86
+ {" Β· "}<span className="stone-band-agg-ms">{fmtMs(ms)}</span>
87
+ </span>
88
+ );
89
+ };
90
+
91
+ const TraceRow = ({ m, indent = 16 }) => {
92
+ if (m.children) {
93
+ return (
94
+ <details className="trace-row trace-row-group" style={{ paddingLeft: indent }} open>
95
+ <summary>
96
+ <span className="trace-bullet">β–Έ</span>
97
+ <span className="trace-name">{m.name}</span>
98
+ <span className="trace-status">{m.status}</span>
99
+ <span className="trace-tier" style={{ color: tierColor(m.tier) }}>{m.tier || ""}</span>
100
+ <span className="trace-ms">{fmtMs(m.ms)}</span>
101
+ </summary>
102
+ {m.children.map(c => <TraceRow key={c.id} m={c} indent={indent + 16}/>)}
103
+ </details>
104
+ );
105
+ }
106
+ if (m.status === "error") {
107
+ return (
108
+ <div className="trace-row trace-row-error" style={{ paddingLeft: indent }}>
109
+ <span className="trace-bullet">●</span>
110
+ <span className="trace-name">{m.name}</span>
111
+ <span className="trace-status trace-status-err">error</span>
112
+ <span className="trace-tier" style={{ color: tierColor(m.tier) }}>{m.tier || ""}</span>
113
+ <span className="trace-ms">{fmtMs(m.ms)}</span>
114
+ <span className="trace-error-summary">{m.error}</span>
115
+ </div>
116
+ );
117
+ }
118
+ return (
119
+ <div className={`trace-row trace-row-${m.status}`} style={{ paddingLeft: indent }}>
120
+ <span className="trace-bullet">{m.status === "silent" ? "β–‘" : m.status === "warn" ? "!" : "Β·"}</span>
121
+ <span className="trace-name">{m.name}</span>
122
+ <span className="trace-status">{m.status}</span>
123
+ <span className="trace-tier" style={{ color: tierColor(m.tier) }}>{m.tier || ""}</span>
124
+ <span className="trace-ms">{fmtMs(m.ms)}</span>
125
+ {m.warning && <span className="trace-warn-note">{m.warning}</span>}
126
+ {m.note && <span className="trace-note">{m.note}</span>}
127
+ </div>
128
+ );
129
+ };
130
+
131
+ const StoneBand = ({ stone }) => {
132
+ const [open, setOpen] = useStV44(true);
133
+ return (
134
+ <section className={`stone-band stone-band-${stone.key}`} aria-labelledby={`band-h-${stone.key}`}>
135
+ <button className="stone-band-head" aria-expanded={open} onClick={() => setOpen(o => !o)}>
136
+ <span className="stone-band-head-left">
137
+ <span id={`band-h-${stone.key}`} className="stone-band-name">{stone.name}</span>
138
+ <span className="stone-band-role">, {stone.role}</span>
139
+ <span className="stone-band-tag">{stone.tag}</span>
140
+ </span>
141
+ <StoneAggregate stone={stone}/>
142
+ </button>
143
+ {open && (
144
+ <div className="stone-band-body">
145
+ {stone.members.map(m => <TraceRow key={m.id} m={m}/>)}
146
+ </div>
147
+ )}
148
+ </section>
149
+ );
150
+ };
151
+
152
+ const StoneTrace = () => {
153
+ return (
154
+ <div className="trace-ui-v44">
155
+ <header className="stone-trace-head">
156
+ <span className="section-label">Run trace Β· 5 Stones</span>
157
+ <span className="stone-trace-tally">17 fired Β· 1 silent Β· 1 warn Β· 1 error Β· 14.0s</span>
158
+ </header>
159
+ {STONES.map(s => <StoneBand key={s.key} stone={s}/>)}
160
+ </div>
161
+ );
162
+ };
163
+
164
+ Object.assign(window, { StoneTrace, STONES, TraceRow, fmtMs, tierColor });
docs/design_handoff/design_files/styles.css ADDED
@@ -0,0 +1,1122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Riprap component styles. Civic-tech-clean. */
2
+
3
+ /* ── App header ────────────────────────────────────────── */
4
+ .app-header {
5
+ position: sticky;
6
+ top: 0;
7
+ z-index: 50;
8
+ background: var(--paper);
9
+ border-bottom: 2px solid var(--ink);
10
+ }
11
+ .app-header-inner {
12
+ max-width: 1600px;
13
+ margin: 0 auto;
14
+ padding: 14px 28px;
15
+ display: grid;
16
+ grid-template-columns: 1fr auto 1fr;
17
+ align-items: center;
18
+ gap: 24px;
19
+ }
20
+ .app-header-left { display: flex; align-items: center; gap: 12px; font-family: var(--font-mono); font-size: 13px; }
21
+ .app-header-sep { color: var(--ink-tertiary); }
22
+ .app-header-context { color: var(--ink-secondary); letter-spacing: 0.04em; }
23
+ .app-header-mid { display: flex; justify-content: center; }
24
+ .app-header-query {
25
+ background: var(--paper-deep);
26
+ border: 1px solid var(--rule-soft);
27
+ padding: 8px 14px;
28
+ font-family: var(--font-mono);
29
+ font-size: 13px;
30
+ color: var(--ink);
31
+ cursor: pointer;
32
+ display: inline-flex;
33
+ align-items: center;
34
+ gap: 10px;
35
+ min-width: 360px;
36
+ text-align: left;
37
+ }
38
+ .app-header-query-icon { color: var(--ink-tertiary); }
39
+ .app-header-query-edit {
40
+ margin-left: auto;
41
+ font-size: 11px;
42
+ letter-spacing: 0.1em;
43
+ text-transform: uppercase;
44
+ color: var(--ink-tertiary);
45
+ }
46
+ .app-header-right { display: flex; align-items: center; justify-content: flex-end; gap: 18px; font-family: var(--font-mono); font-size: 12px; }
47
+ .app-header-link { color: var(--ink-secondary); text-decoration: none; border-bottom: 1px solid transparent; }
48
+ .app-header-link:hover { border-bottom-color: var(--ink); color: var(--ink); }
49
+ .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; }
50
+ .app-header-status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-graphical); display: inline-block; }
51
+
52
+ /* ── Hero band (prototype) ───────────────────────────────── */
53
+ .hero-band { background: var(--paper); border-bottom: 1px solid var(--rule-soft); }
54
+ .hero-band-inner { max-width: 1600px; margin: 0 auto; padding: 32px 28px 56px; }
55
+
56
+ /* ── App shell layout ────────────────────────────────────── */
57
+ .app-shell {
58
+ display: grid;
59
+ gap: 24px;
60
+ }
61
+ .app-shell-desktop {
62
+ grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
63
+ grid-template-areas:
64
+ "brief map"
65
+ "brief cites"
66
+ "evidence evidence"
67
+ "trace trace";
68
+ }
69
+ .app-shell-tablet {
70
+ grid-template-columns: 1fr;
71
+ grid-template-areas: "map" "brief" "cites" "evidence" "trace";
72
+ }
73
+ .app-shell-mobile {
74
+ grid-template-columns: 1fr;
75
+ grid-template-areas: "brief" "map" "cites" "evidence" "trace";
76
+ }
77
+ .app-region-brief { grid-area: brief; }
78
+ .app-region-map { grid-area: map; position: sticky; top: 80px; align-self: start; max-height: calc(100vh - 96px); display: flex; flex-direction: column; }
79
+ .app-region-map .map-frame { flex: 1; min-height: 0; }
80
+ .app-region-cites { grid-area: cites; }
81
+ .app-region-evidence { grid-area: evidence; }
82
+ .app-region-trace { grid-area: trace; }
83
+
84
+ @media (max-width: 1099px) {
85
+ .app-region-map { position: static; }
86
+ }
87
+
88
+ .region-head {
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: space-between;
92
+ border-bottom: 1px solid var(--rule-soft);
93
+ padding-bottom: 8px;
94
+ margin-bottom: 16px;
95
+ }
96
+ .region-head-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
97
+ .region-action {
98
+ background: transparent;
99
+ border: 1px solid var(--rule-soft);
100
+ font-family: var(--font-mono);
101
+ font-size: 11px;
102
+ padding: 4px 10px;
103
+ cursor: pointer;
104
+ color: var(--ink-secondary);
105
+ }
106
+ .region-action:hover { border-color: var(--ink); color: var(--ink); }
107
+
108
+ .brief-h1 {
109
+ font-size: 13px;
110
+ line-height: 1.2;
111
+ font-weight: 500;
112
+ margin: 0 0 22px;
113
+ letter-spacing: 0.06em;
114
+ text-transform: uppercase;
115
+ color: var(--ink-tertiary);
116
+ font-family: var(--font-mono);
117
+ display: grid;
118
+ grid-template-columns: 1fr auto;
119
+ gap: 14px 18px;
120
+ align-items: end;
121
+ padding-bottom: 14px;
122
+ border-bottom: 2px solid var(--ink);
123
+ }
124
+ .brief-h1-addr {
125
+ display: block;
126
+ grid-column: 1;
127
+ grid-row: 1;
128
+ font-family: var(--font-serif);
129
+ font-size: 32px;
130
+ font-weight: 600;
131
+ letter-spacing: -0.01em;
132
+ text-transform: none;
133
+ color: var(--ink);
134
+ line-height: 1.1;
135
+ margin-top: 4px;
136
+ }
137
+ .brief-h1-eyebrow {
138
+ grid-column: 1;
139
+ grid-row: 1;
140
+ align-self: start;
141
+ display: block;
142
+ }
143
+ .brief-h1-meta {
144
+ grid-column: 2;
145
+ grid-row: 1;
146
+ align-self: end;
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: 4px;
150
+ text-align: right;
151
+ font-family: var(--font-mono);
152
+ font-size: 11px;
153
+ color: var(--ink-tertiary);
154
+ text-transform: none;
155
+ letter-spacing: 0.04em;
156
+ }
157
+ .brief-h1-meta-row { display: flex; gap: 6px; justify-content: flex-end; }
158
+ .brief-h1-meta-key { color: var(--ink-tertiary); }
159
+ .brief-h1-meta-val { color: var(--ink); font-weight: 500; }
160
+
161
+ /* ── Briefing prose ──────────────────────────────────────── */
162
+ .briefing-prose {
163
+ font-size: 16px;
164
+ line-height: var(--leading-prose);
165
+ max-width: 70ch;
166
+ position: relative;
167
+ }
168
+ .briefing-status {
169
+ border-left: 2px solid var(--ink);
170
+ padding: 4px 0 4px 14px;
171
+ margin-bottom: 24px;
172
+ font-size: 14px;
173
+ color: var(--ink-secondary);
174
+ }
175
+ .briefing-deck strong { color: var(--ink); font-weight: 600; }
176
+ .briefing-meta { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); margin-top: 6px; letter-spacing: 0.04em; }
177
+
178
+ .briefing-section-head {
179
+ display: flex;
180
+ align-items: baseline;
181
+ gap: 12px;
182
+ font-size: 22px;
183
+ font-weight: 600;
184
+ margin: 32px 0 12px;
185
+ border-top: 2px solid var(--ink);
186
+ padding-top: 16px;
187
+ flex-wrap: wrap;
188
+ }
189
+ .briefing-section-num {
190
+ font-family: var(--font-mono);
191
+ font-size: 13px;
192
+ color: var(--ink-tertiary);
193
+ letter-spacing: 0.06em;
194
+ font-weight: 500;
195
+ }
196
+ .briefing-section-label { font-family: var(--font-sans); font-weight: 600; }
197
+ .briefing-section-title {
198
+ font-family: var(--font-sans);
199
+ font-size: 14px;
200
+ font-weight: 400;
201
+ color: var(--ink-secondary);
202
+ font-style: italic;
203
+ }
204
+ .briefing-section-tier { font-size: 11px; }
205
+
206
+ .briefing-para {
207
+ margin: 0 0 18px;
208
+ padding-left: 22px;
209
+ position: relative;
210
+ text-wrap: pretty;
211
+ }
212
+
213
+ .claim {
214
+ position: relative;
215
+ }
216
+ .claim-glyph {
217
+ position: absolute;
218
+ left: -22px;
219
+ top: 6px;
220
+ display: inline-block;
221
+ }
222
+ .claim-empirical .claim-body { /* default */ }
223
+ .claim-modeled .claim-body { /* default */ }
224
+ .claim-proxy .claim-body { color: var(--ink-secondary); }
225
+ .claim-synthetic .claim-body {
226
+ background: linear-gradient(transparent 60%, rgba(42,111,168,0.15) 60%);
227
+ padding: 0 1px;
228
+ }
229
+
230
+ .inline-cite {
231
+ color: var(--tier-empirical);
232
+ font-family: var(--font-sans);
233
+ font-weight: 500;
234
+ text-decoration: none;
235
+ font-size: 14px;
236
+ }
237
+ .inline-cite sup { font-size: 0.78em; }
238
+ .inline-cite:hover { background: rgba(11,83,148,0.08); }
239
+
240
+ .is-dense .briefing-section-head { margin: 18px 0 10px; padding-top: 12px; }
241
+ .is-dense .briefing-para { margin-bottom: 12px; }
242
+
243
+ .streaming-caret {
244
+ display: inline-block;
245
+ color: var(--accent-graphical);
246
+ animation: blink 1s steps(2) infinite;
247
+ margin-left: 2px;
248
+ }
249
+ @keyframes blink { 50% { opacity: 0; } }
250
+
251
+ /* ── Citation drawer ─────────────────────────────────────── */
252
+ .citation-drawer {
253
+ border-top: 1px solid var(--rule-soft);
254
+ border-bottom: 1px solid var(--rule-soft);
255
+ padding: 16px 0;
256
+ font-size: 13px;
257
+ }
258
+ .citation-drawer-head {
259
+ display: flex;
260
+ justify-content: space-between;
261
+ align-items: baseline;
262
+ border-bottom: 1px solid var(--rule-soft);
263
+ padding-bottom: 8px;
264
+ margin-bottom: 12px;
265
+ }
266
+ .citation-drawer-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
267
+ .citation-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
268
+ .citation-item {
269
+ display: grid;
270
+ grid-template-columns: 32px 1fr;
271
+ gap: 8px;
272
+ padding: 10px 12px;
273
+ border-left: 2px solid var(--rule-soft);
274
+ transition: background 200ms;
275
+ }
276
+ .citation-item.is-active {
277
+ border-left-color: var(--accent-graphical);
278
+ background: rgba(209,124,0,0.06);
279
+ }
280
+ .citation-num { font-family: var(--font-mono); color: var(--ink-tertiary); font-size: 12px; }
281
+ .citation-line-1 { display: flex; align-items: center; gap: 8px; }
282
+ .citation-source { font-weight: 600; }
283
+ .citation-vintage { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); margin-left: auto; }
284
+ .citation-title { font-size: 13px; line-height: 1.4; margin: 4px 0; color: var(--ink-secondary); }
285
+ .citation-meta { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
286
+ .citation-drawer-foot { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--rule-soft); }
287
+ .citation-foot-copy { font-size: 12px; color: var(--ink-tertiary); margin: 6px 0 0; max-width: 60ch; }
288
+
289
+ /* ── Map ─────────────────────────────────────────────────── */
290
+ .map-frame {
291
+ position: relative;
292
+ border: 1px solid var(--ink);
293
+ background: var(--paper-deep);
294
+ aspect-ratio: 8 / 5.6;
295
+ overflow: hidden;
296
+ }
297
+ .map-legend {
298
+ position: absolute;
299
+ top: 12px;
300
+ left: 12px;
301
+ background: rgba(250, 250, 247, 0.96);
302
+ border: 1px solid var(--ink);
303
+ padding: 10px 12px 12px;
304
+ width: 280px;
305
+ display: flex;
306
+ flex-direction: column;
307
+ gap: 4px;
308
+ backdrop-filter: blur(4px);
309
+ }
310
+ .map-legend-head { padding-bottom: 6px; border-bottom: 1px solid var(--rule-soft); margin-bottom: 4px; }
311
+ .map-legend-item {
312
+ display: grid;
313
+ grid-template-columns: 16px 1fr auto;
314
+ gap: 10px;
315
+ align-items: center;
316
+ background: transparent;
317
+ border: 0;
318
+ padding: 6px 4px;
319
+ text-align: left;
320
+ cursor: pointer;
321
+ font-family: var(--font-sans);
322
+ border-bottom: 1px solid transparent;
323
+ min-height: 44px;
324
+ }
325
+ .map-legend-item:hover { background: rgba(0,0,0,0.03); }
326
+ .map-legend-item.is-off { opacity: 0.45; }
327
+ .map-legend-text { display: flex; flex-direction: column; gap: 2px; }
328
+ .map-legend-label { font-size: 12px; line-height: 1.3; color: var(--ink); }
329
+ .map-legend-source { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); display: inline-flex; gap: 6px; align-items: center; }
330
+ .map-legend-toggle { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; color: var(--ink-tertiary); }
331
+ .is-on .map-legend-toggle { color: var(--accent); }
332
+
333
+ /* ── Trace UI ────────────────────────────────────────────── */
334
+ .trace-ui {
335
+ border: 1px solid var(--rule-soft);
336
+ background: var(--paper-deep);
337
+ }
338
+ .trace-head {
339
+ display: flex;
340
+ justify-content: space-between;
341
+ align-items: center;
342
+ padding: 10px 16px;
343
+ border-bottom: 1px solid var(--rule-soft);
344
+ }
345
+ .trace-head-left { display: flex; align-items: center; gap: 14px; }
346
+ .trace-head-meta { display: flex; gap: 6px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); }
347
+ .trace-head-sep { color: var(--ink-tertiary); }
348
+ .trace-head-silent { color: var(--accent); }
349
+ .trace-collapse-btn {
350
+ background: transparent;
351
+ border: 1px solid var(--rule-soft);
352
+ font-family: var(--font-mono);
353
+ font-size: 11px;
354
+ padding: 4px 10px;
355
+ cursor: pointer;
356
+ color: var(--ink-secondary);
357
+ }
358
+ .trace-col-heads {
359
+ display: grid;
360
+ grid-template-columns: 28px 24px 1fr 80px 140px;
361
+ gap: 8px;
362
+ padding: 8px 16px 6px;
363
+ border-bottom: 1px solid var(--rule-soft);
364
+ font-family: var(--font-mono);
365
+ font-size: 10px;
366
+ letter-spacing: 0.12em;
367
+ text-transform: uppercase;
368
+ color: var(--ink-tertiary);
369
+ }
370
+ .trace-tree { font-family: var(--font-mono); font-size: 13px; }
371
+ .trace-row { border-bottom: 1px solid var(--rule-soft); }
372
+ .trace-row-toggle {
373
+ width: 100%;
374
+ display: grid;
375
+ grid-template-columns: 16px 24px 1fr 80px 140px;
376
+ gap: 8px;
377
+ align-items: center;
378
+ background: transparent;
379
+ border: 0;
380
+ padding: 6px 16px 6px 0;
381
+ text-align: left;
382
+ cursor: pointer;
383
+ color: var(--ink);
384
+ min-height: 36px;
385
+ }
386
+ .trace-row-toggle[disabled] { cursor: default; }
387
+ .trace-row-toggle:hover:not([disabled]) { background: rgba(0,0,0,0.025); }
388
+ .trace-tree-glyph { color: var(--ink-tertiary); }
389
+ .trace-name { color: var(--ink); }
390
+ .trace-note { color: var(--ink-tertiary); }
391
+ .trace-ms-col { color: var(--ink-secondary); }
392
+ .trace-tier-col { display: inline-flex; align-items: center; gap: 6px; }
393
+ .trace-row-silent .trace-name { color: var(--ink-tertiary); }
394
+ .trace-silent-tag {
395
+ font-size: 10px;
396
+ letter-spacing: 0.1em;
397
+ text-transform: uppercase;
398
+ color: var(--accent);
399
+ border: 1px solid var(--accent);
400
+ padding: 1px 5px;
401
+ }
402
+ .trace-status-glyph { color: var(--ink-tertiary); font-size: 13px; }
403
+ .trace-output {
404
+ padding: 4px 16px 8px 0;
405
+ font-size: 12px;
406
+ color: var(--ink-secondary);
407
+ display: flex;
408
+ gap: 10px;
409
+ align-items: baseline;
410
+ border-top: 1px dashed var(--rule-soft);
411
+ background: rgba(0,0,0,0.015);
412
+ }
413
+ .trace-output-prefix { color: var(--ink-tertiary); }
414
+ .trace-output-claims { margin-left: auto; padding-right: 16px; font-family: var(--font-mono); font-size: 11px; color: var(--accent); }
415
+
416
+ /* ── Evidence cards ──────────────────────────────────────── */
417
+ .evidence-grid-head {
418
+ display: flex;
419
+ justify-content: space-between;
420
+ align-items: baseline;
421
+ border-bottom: 1px solid var(--rule-soft);
422
+ padding-bottom: 8px;
423
+ margin-bottom: 16px;
424
+ }
425
+ .evidence-grid-meta { display: flex; gap: 12px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); }
426
+ .evidence-grid-tally { display: inline-flex; align-items: center; gap: 4px; }
427
+ .evidence-grid-rail {
428
+ display: grid;
429
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
430
+ gap: 16px;
431
+ }
432
+
433
+ .evidence-card {
434
+ border: 1px solid var(--rule-soft);
435
+ background: var(--paper);
436
+ padding: 14px;
437
+ display: flex;
438
+ flex-direction: column;
439
+ gap: 10px;
440
+ position: relative;
441
+ border-top: 3px solid var(--ink);
442
+ }
443
+ .evidence-card-empirical { border-top-color: var(--tier-empirical); }
444
+ .evidence-card-modeled { border-top-color: var(--tier-modeled); }
445
+ .evidence-card-proxy { border-top-color: var(--tier-proxy); }
446
+ .evidence-card-synthetic { border-top-color: var(--tier-synthetic); border-top-style: dashed; }
447
+
448
+ .evidence-card-head { display: flex; justify-content: space-between; align-items: center; }
449
+ .evidence-card-source { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13px; }
450
+ .evidence-card-vintage { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
451
+ .evidence-card-title { font-size: 14px; font-weight: 600; line-height: 1.3; margin: 0; }
452
+ .evidence-card-body { padding: 4px 0; }
453
+ .evidence-scalar-value { font-size: 24px; font-weight: 600; line-height: 1.1; font-family: var(--font-serif); }
454
+ .evidence-scalar-unit { font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); margin-top: 2px; }
455
+ .evidence-scalar-aux { font-size: 12px; color: var(--ink-tertiary); margin-top: 4px; line-height: 1.4; }
456
+ .evidence-spark-headline { font-size: 18px; font-weight: 600; font-family: var(--font-serif); margin-bottom: 4px; }
457
+ .evidence-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 11px; }
458
+ .evidence-table th, .evidence-table td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--rule-soft); }
459
+ .evidence-table th { color: var(--ink-tertiary); font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; font-size: 9px; }
460
+ .evidence-thumb { display: flex; flex-direction: column; gap: 6px; }
461
+ .evidence-card-foot {
462
+ display: flex;
463
+ justify-content: space-between;
464
+ align-items: center;
465
+ padding-top: 8px;
466
+ border-top: 1px solid var(--rule-soft);
467
+ }
468
+ .evidence-card-cite {
469
+ background: transparent;
470
+ border: 0;
471
+ font-family: var(--font-mono);
472
+ font-size: 11px;
473
+ color: var(--ink-secondary);
474
+ cursor: pointer;
475
+ padding: 4px 0;
476
+ display: inline-flex;
477
+ align-items: center;
478
+ gap: 6px;
479
+ }
480
+ .evidence-card-cite:hover { color: var(--accent); }
481
+ .evidence-card-cite-arrow { color: var(--ink-tertiary); }
482
+
483
+ /* ── Cold start ─────────────────────────────────────────── */
484
+ .cold-start { max-width: 920px; margin: 0 auto; padding: 32px 0; }
485
+ .cold-start-band { border-top: 2px solid var(--ink); border-bottom: 1px solid var(--rule-soft); padding: 24px 0; margin-bottom: 32px; }
486
+ .cold-start-deck { font-size: 18px; line-height: 1.5; max-width: 70ch; margin: 0 0 12px; text-wrap: pretty; }
487
+ .cold-start-deck-secondary { font-size: 14px; color: var(--ink-secondary); }
488
+ .cold-start-redir { color: var(--accent); border-bottom: 1px solid var(--accent); text-decoration: none; }
489
+ .cold-start-form { margin-bottom: 32px; }
490
+ .cold-start-label { display: block; margin-bottom: 8px; }
491
+ .cold-start-input-row { display: grid; grid-template-columns: 1fr auto; gap: 0; border: 2px solid var(--ink); }
492
+ .cold-start-input { padding: 14px 16px; font-family: var(--font-mono); font-size: 14px; border: 0; background: var(--paper); color: var(--ink); }
493
+ .cold-start-input:focus { outline: 0; background: var(--paper-deep); }
494
+ .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; }
495
+ .cold-start-submit:hover { background: var(--accent); }
496
+ .cold-start-samples { margin-bottom: 32px; }
497
+ .cold-start-samples-label { display: block; margin-bottom: 12px; }
498
+ .cold-start-samples-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
499
+ .cold-start-sample {
500
+ text-align: left;
501
+ background: var(--paper-deep);
502
+ border: 1px solid var(--rule-soft);
503
+ padding: 14px 16px;
504
+ font-family: var(--font-sans);
505
+ cursor: pointer;
506
+ display: grid;
507
+ gap: 4px;
508
+ position: relative;
509
+ min-height: 80px;
510
+ }
511
+ .cold-start-sample:hover { border-color: var(--ink); }
512
+ .cold-start-sample-mode { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); }
513
+ .cold-start-sample-q { font-size: 14px; font-weight: 500; line-height: 1.3; color: var(--ink); }
514
+ .cold-start-sample-sub { font-size: 11px; color: var(--ink-tertiary); font-family: var(--font-mono); }
515
+ .cold-start-sample-arrow { position: absolute; top: 12px; right: 14px; color: var(--ink-tertiary); }
516
+ .cold-start-trust { border-top: 1px solid var(--rule-soft); padding-top: 16px; }
517
+ .cold-start-trust-list { font-size: 13px; line-height: 1.5; color: var(--ink-secondary); padding-left: 18px; margin: 8px 0; }
518
+ .cold-start-method-link { font-family: var(--font-mono); font-size: 13px; color: var(--ink); border-bottom: 1px solid var(--ink); text-decoration: none; }
519
+
520
+ /* ── Spec band ──────────────────────────────────────────── */
521
+ .spec-band { background: var(--paper-deep); border-top: 2px solid var(--ink); }
522
+ .spec-band-inner { max-width: 1600px; margin: 0 auto; padding: 56px 28px 80px; }
523
+ .spec-band-head { max-width: 70ch; margin-bottom: 56px; }
524
+ .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); }
525
+ .spec-band-deck { font-size: 17px; line-height: 1.5; color: var(--ink-secondary); }
526
+ .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); }
527
+ .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; }
528
+ .spec-toc-item:hover { background: var(--paper-deep); color: var(--accent); }
529
+ .spec-toc-n { color: var(--ink-tertiary); }
530
+
531
+ .spec-section { padding: 48px 0; border-top: 2px solid var(--ink); }
532
+ .spec-section-head { max-width: 70ch; margin-bottom: 32px; display: flex; flex-direction: column; gap: 6px; }
533
+ .spec-section-title { font-size: 32px; line-height: 1.15; font-weight: 600; margin: 4px 0 12px; font-family: var(--font-serif); }
534
+ .spec-section-deck { font-size: 15px; line-height: 1.55; color: var(--ink-secondary); margin: 0; }
535
+
536
+ /* Overview */
537
+ .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); }
538
+ .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; }
539
+ .overview-tier-head { display: flex; align-items: center; gap: 10px; }
540
+ .overview-tier-name { font-size: 16px; font-weight: 600; }
541
+ .overview-tier-desc { font-size: 13px; color: var(--ink-secondary); margin: 0; line-height: 1.5; }
542
+ .overview-tier-ex { font-size: 11px; font-family: var(--font-mono); color: var(--ink-tertiary); margin: 0; line-height: 1.5; }
543
+
544
+ /* Palette */
545
+ .palette-grid { display: grid; gap: 0; border-top: 1px solid var(--rule-soft); }
546
+ .palette-row {
547
+ display: grid;
548
+ grid-template-columns: 60px 1.6fr 1.2fr 1.4fr 2fr;
549
+ gap: 16px;
550
+ align-items: center;
551
+ padding: 14px 0;
552
+ border-bottom: 1px solid var(--rule-soft);
553
+ font-size: 13px;
554
+ }
555
+ .palette-swatch { width: 48px; height: 48px; border: 1px solid var(--rule-soft); }
556
+ .palette-swatch.is-syn { background-image: repeating-linear-gradient(45deg, transparent 0, transparent 3px, #2A6FA8 3px, #2A6FA8 4px); }
557
+ .palette-name { font-weight: 600; font-size: 14px; }
558
+ .palette-token { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
559
+ .palette-hex { font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); }
560
+ .palette-contrast-ratio { font-family: var(--font-mono); font-size: 14px; font-weight: 600; }
561
+ .palette-contrast-grade { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; }
562
+ .palette-contrast-aaa, .palette-contrast-aa { color: var(--tier-empirical); }
563
+ .palette-contrast-decorative { color: var(--ink-tertiary); }
564
+ .palette-note { font-size: 12px; color: var(--ink-secondary); line-height: 1.4; }
565
+ .cvd-strip { margin-top: 24px; padding: 16px; background: var(--paper); border: 1px solid var(--rule-soft); }
566
+ .cvd-copy { font-size: 13px; line-height: 1.55; color: var(--ink-secondary); margin: 8px 0 0; max-width: 70ch; }
567
+
568
+ /* Type spec */
569
+ .type-spec-table { border-top: 1px solid var(--rule-soft); margin-bottom: 32px; }
570
+ .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; }
571
+ .type-spec-head { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-tertiary); }
572
+ .type-spec-surface { font-weight: 500; }
573
+ .type-spec-family { font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); }
574
+ .type-spec-size { font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); }
575
+ .type-spec-use { color: var(--ink-secondary); font-size: 12px; }
576
+ .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); }
577
+ .type-sample { display: flex; flex-direction: column; }
578
+
579
+ /* Glyphs */
580
+ .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); }
581
+ .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); }
582
+ .glyph-display { width: 96px; height: 96px; background: var(--paper-deep); display: flex; align-items: center; justify-content: center; border: 1px solid var(--rule-soft); }
583
+ .glyph-anatomy { display: flex; flex-direction: column; gap: 4px; }
584
+ .glyph-anatomy-title { font-size: 16px; font-weight: 600; }
585
+ .glyph-anatomy-shape { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
586
+ .glyph-anatomy-desc { font-size: 13px; color: var(--ink-secondary); line-height: 1.5; }
587
+ .glyph-anatomy-ex { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); line-height: 1.5; }
588
+ .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; }
589
+ .glyph-sizes-label { min-width: 80px; }
590
+ .glyph-sizes-mono { color: var(--ink-tertiary); margin-left: auto; }
591
+ .glyph-mono > svg:last-of-type { background: #1A1A1A; padding: 2px; border-radius: 1px; }
592
+
593
+ /* Map spec */
594
+ .map-spec-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 24px; align-items: stretch; }
595
+ .map-spec-preview { border: 1px solid var(--ink); aspect-ratio: 8 / 5.6; position: relative; overflow: hidden; }
596
+ .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); }
597
+ .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; }
598
+
599
+ @media (max-width: 900px) { .map-spec-grid { grid-template-columns: 1fr; } }
600
+
601
+ /* Layouts */
602
+ .layout-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; }
603
+ .layout-fig { margin: 0; }
604
+ .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; }
605
+ .layout-fig-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
606
+ .layout-canvas { background: var(--paper); border: 1px solid var(--ink); display: grid; gap: 4px; padding: 8px; }
607
+ .layout-canvas-desktop { grid-template-columns: 5fr 7fr; grid-template-rows: 220px 80px 60px; aspect-ratio: 16/10; }
608
+ .layout-canvas-tablet { grid-template-rows: 160px 120px 80px; aspect-ratio: 4/5; }
609
+ .layout-canvas-mobile { grid-template-rows: 180px 100px 100px 60px; aspect-ratio: 9/16; max-height: 540px; }
610
+ .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; }
611
+ .layout-region-w { color: var(--ink-tertiary); font-size: 10px; }
612
+ .layout-canvas-desktop .layout-region-brief { grid-row: 1 / span 2; }
613
+ .layout-canvas-desktop .layout-region-map { grid-column: 2; }
614
+ .layout-canvas-desktop .layout-region-evidence { grid-column: 1 / span 2; }
615
+ .layout-canvas-desktop .layout-region-trace { grid-column: 1 / span 2; background: var(--paper); }
616
+ .layout-region-brief { background: rgba(11,83,148,0.08); border-color: var(--tier-empirical); }
617
+ .layout-region-map { background: rgba(42,111,168,0.08); border-color: var(--tier-modeled); }
618
+ .layout-region-evidence { background: rgba(107,107,107,0.08); }
619
+ .layout-region-tabs { background: rgba(11,83,148,0.06); }
620
+ .layout-region-trace { background: var(--paper-deep); border-style: solid; }
621
+
622
+ /* PDF spec */
623
+ .pdf-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 24px; align-items: start; }
624
+ .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; }
625
+ .pdf-page-inner { position: absolute; inset: 0; padding: 28px 28px; display: flex; flex-direction: column; gap: 14px; font-size: 10px; line-height: 1.45; }
626
+ .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); }
627
+ .pdf-cover-band-meta { letter-spacing: 0.06em; }
628
+ .pdf-cover-headline { padding: 24px 0; }
629
+ .pdf-cover-eyebrow { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-tertiary); }
630
+ .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; }
631
+ .pdf-cover-deck { font-family: var(--font-mono); font-size: 10px; color: var(--ink-secondary); margin: 0; }
632
+ .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; }
633
+ .pdf-cover-meta div { display: flex; gap: 6px; }
634
+ .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; }
635
+ .pdf-cover-meta dd { margin: 0; font-size: 10px; }
636
+ .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); }
637
+ .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); }
638
+ .pdf-cover-foot a { color: var(--ink); }
639
+
640
+ .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); }
641
+ .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); }
642
+ .pdf-h2 { font-family: var(--font-sans); font-size: 14px; font-weight: 600; margin: 8px 0 6px; }
643
+ .pdf-h3 { font-family: var(--font-sans); font-size: 11px; font-weight: 600; margin: 8px 0 4px; }
644
+ .pdf-prose { font-family: var(--font-serif); font-size: 10px; line-height: 1.5; }
645
+ .pdf-prose p { margin: 0 0 8px; padding-left: 16px; position: relative; }
646
+ .pdf-margin-glyph { position: absolute; left: 0; top: 4px; }
647
+ .pdf-prose sup { color: var(--tier-empirical); font-family: var(--font-sans); font-weight: 500; }
648
+ .pdf-cite-list { font-family: var(--font-serif); font-size: 9px; line-height: 1.55; padding-left: 16px; margin: 8px 0; }
649
+ .pdf-cite-list li { margin-bottom: 6px; }
650
+ .pdf-cite-list span { font-family: var(--font-mono); font-size: 8px; color: var(--ink-tertiary); }
651
+
652
+ /* A11y */
653
+ .a11y-grid { display: grid; gap: 0; border-top: 1px solid var(--rule-soft); }
654
+ .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; }
655
+ .a11y-key { padding-top: 2px; }
656
+ .a11y-val { color: var(--ink-secondary); line-height: 1.55; }
657
+
658
+ /* Refs */
659
+ .ref-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
660
+ .ref-card { background: var(--paper); border: 1px solid var(--rule-soft); display: flex; flex-direction: column; }
661
+ .ref-card-thumb { aspect-ratio: 200/140; border-bottom: 1px solid var(--rule-soft); }
662
+ .ref-card-body { padding: 14px; display: flex; flex-direction: column; gap: 6px; }
663
+ .ref-card-name { margin: 0; font-size: 14px; font-weight: 600; }
664
+ .ref-card-borrow { font-size: 12px; color: var(--ink-secondary); line-height: 1.5; margin: 0; }
665
+ .ref-card-url { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
666
+
667
+ /* Rationale */
668
+ .spec-section-quote { background: var(--paper); }
669
+ .rationale-quote { margin: 0; max-width: 70ch; border-left: 3px solid var(--ink); padding: 16px 0 16px 24px; }
670
+ .rationale-quote p { font-family: var(--font-serif); font-size: 19px; line-height: 1.55; margin: 0 0 16px; text-wrap: pretty; }
671
+ .rationale-cite { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--ink-tertiary); font-style: normal; }
672
+
673
+ /* Footer */
674
+ .app-footer { background: var(--ink); color: var(--paper); }
675
+ .app-footer-inner { max-width: 1600px; margin: 0 auto; padding: 32px 28px; display: flex; flex-direction: column; gap: 12px; }
676
+ .app-footer-guard { font-size: 14px; line-height: 1.5; max-width: 70ch; margin: 0; }
677
+ .app-footer-guard a { color: var(--accent-graphical); border-bottom: 1px solid var(--accent-graphical); text-decoration: none; }
678
+ .app-footer-build { font-family: var(--font-mono); font-size: 11px; color: rgba(250,250,247,0.55); margin: 0; letter-spacing: 0.04em; }
679
+
680
+ /* Dark mode , deferred to v0.5 (see Β§17). Partial styles removed in v0.4.2. */
681
+
682
+ /* ════════════════════════════════════════════════════════════════════
683
+ v0.4.2 APPENDIX STYLES
684
+ Β§11 loading Β· Β§12 errors Β· Β§13 guardian Β· Β§14 stripe Β· Β§15 register
685
+ Β§16 caveats Β· Β§17 dark mode Β· Β§18 print Β· Β§19 changelog
686
+ ════════════════════════════════════════════════════════════════════ */
687
+
688
+ /* v0.4.2 banner */
689
+ .v042-band { background: var(--paper); border-top: 1px solid var(--rule-soft); }
690
+ .v042-banner { background: linear-gradient(180deg, #F4EFE5 0%, var(--paper) 100%); border: 1px solid var(--rule-soft); padding: 36px 32px; margin-bottom: 48px; }
691
+ .v042-banner-inner { display: grid; grid-template-columns: 1.4fr 1fr; gap: 40px; align-items: start; }
692
+ .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; }
693
+ .v042-banner-deck { font-size: 15px; line-height: 1.6; color: var(--ink-secondary); max-width: 60ch; }
694
+ .v042-toc { display: grid; grid-template-columns: 1fr 1fr; gap: 0; border: 1px solid var(--rule-soft); }
695
+ .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; }
696
+ .v042-toc-item:nth-child(2n) { border-right: none; }
697
+ .v042-toc-item:hover { background: #F8F4EA; }
698
+ .v042-toc-n { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.06em; min-width: 28px; }
699
+
700
+ /* Β§11 Loading + skeleton + reroll */
701
+ .loading-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 28px; }
702
+ .loading-fig { display: flex; flex-direction: column; gap: 10px; }
703
+ .loading-cap { display: flex; flex-direction: column; gap: 4px; }
704
+ .loading-cap-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.04em; }
705
+ .loading-frame { background: white; border: 1px solid var(--rule-soft); padding: 20px; min-height: 320px; }
706
+
707
+ .skeleton-brief { display: flex; flex-direction: column; gap: 18px; }
708
+ .skeleton-status { display: flex; flex-direction: column; gap: 6px; padding-bottom: 12px; border-bottom: 1px solid var(--rule-soft); }
709
+ .skeleton-section { display: flex; flex-direction: column; gap: 8px; }
710
+ .skeleton-head { display: flex; align-items: baseline; gap: 12px; padding-bottom: 4px; }
711
+ .skeleton-num { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.08em; }
712
+ .skeleton-label { font-family: var(--font-serif); font-size: 16px; font-weight: 600; color: var(--ink); }
713
+ .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; }
714
+ .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; }
715
+ .skeleton-pulse-meta { height: 9px; }
716
+ @keyframes skeletonShimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
717
+ @keyframes skeletonBlink { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
718
+ @media (prefers-reduced-motion: reduce) {
719
+ .skeleton-pulse { animation: none; background: #E2DCCC; }
720
+ .skeleton-spinner { animation: none; opacity: 0.6; }
721
+ }
722
+
723
+ .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; }
724
+ .reroll-body { display: flex; flex-direction: column; gap: 2px; flex: 1; }
725
+ .reroll-head { font-family: var(--font-sans); font-size: 14px; font-weight: 500; color: var(--ink); }
726
+ .reroll-sub { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.03em; }
727
+ .reroll-spinner { font-size: 16px; color: var(--tier-modeled); animation: rerollSpin 2s linear infinite; }
728
+ @keyframes rerollSpin { 100% { transform: rotate(360deg); } }
729
+ .reroll-prev { opacity: 0.4; pointer-events: none; }
730
+ .reroll-prev-line { font-family: var(--font-serif); font-size: 14px; line-height: 1.55; color: var(--ink); margin-bottom: 8px; }
731
+
732
+ .loading-rules { padding: 18px 22px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-empirical); }
733
+ .loading-rules ul { margin: 8px 0 0 18px; }
734
+ .loading-rules li { font-size: 13px; line-height: 1.6; margin-bottom: 4px; color: var(--ink-secondary); }
735
+
736
+ /* Β§12 Errors */
737
+ .error-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
738
+ .error-card { background: white; border: 1px solid var(--rule-soft); padding: 22px 24px; display: flex; flex-direction: column; gap: 10px; }
739
+ .error-card-head { display: flex; align-items: center; gap: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--rule-soft); }
740
+ .error-card-eyebrow { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.06em; text-transform: uppercase; }
741
+ .error-card-headline { font-family: var(--font-serif); font-size: 19px; font-weight: 600; line-height: 1.3; margin: 0; }
742
+ .error-card-body { font-size: 14px; line-height: 1.55; color: var(--ink-secondary); margin: 0; }
743
+ .error-card-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
744
+ .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; }
745
+ .error-card-action.is-primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
746
+ .error-card-action:hover { background: #F4EFE5; }
747
+ .error-card-action.is-primary:hover { background: #2A2A2A; }
748
+ .error-card-foot { display: flex; flex-direction: column; gap: 2px; padding-top: 10px; margin-top: 4px; border-top: 1px dashed var(--rule-soft); }
749
+ .error-card-foot-copy { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.03em; }
750
+
751
+ /* Β§13 Guardian */
752
+ .guardian-tabs { display: flex; gap: 0; border: 1px solid var(--rule-soft); margin-bottom: 0; flex-wrap: wrap; }
753
+ .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; }
754
+ .guardian-tab:last-child { border-right: none; }
755
+ .guardian-tab.is-active { background: var(--ink); color: var(--paper); }
756
+ .guardian-tab:hover:not(.is-active) { background: #F8F4EA; color: var(--ink); }
757
+
758
+ .guardian-card { background: white; border: 1px solid var(--rule-soft); border-top: none; padding: 32px 36px; display: flex; flex-direction: column; gap: 14px; }
759
+ .guardian-head { display: flex; align-items: baseline; gap: 8px; }
760
+ .guardian-eyebrow { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.06em; text-transform: uppercase; }
761
+ .guardian-title { font-family: var(--font-serif); font-size: 24px; font-weight: 600; line-height: 1.25; margin: 0; }
762
+ .guardian-body { font-family: var(--font-serif); font-size: 16px; line-height: 1.6; color: var(--ink); max-width: 60ch; margin: 0; }
763
+ .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; }
764
+ .guardian-redirect:hover { background: #F4EFE5; }
765
+ .guardian-redirect-label { font-family: var(--font-sans); font-size: 14px; font-weight: 600; }
766
+ .guardian-redirect-url { font-family: var(--font-mono); font-size: 12px; color: var(--ink-secondary); letter-spacing: 0.02em; }
767
+ .guardian-no-redirect { padding: 14px 18px; border-left: 2px solid var(--ink-tertiary); background: #FBF8EF; max-width: 540px; }
768
+ .guardian-no-redirect p { font-size: 13px; line-height: 1.55; color: var(--ink-secondary); margin: 4px 0 0; }
769
+ .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); }
770
+
771
+ .guardian-a11y { padding: 18px 22px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-empirical); margin-top: 22px; }
772
+ .guardian-a11y p { font-size: 13px; line-height: 1.6; color: var(--ink-secondary); margin: 6px 0 0; }
773
+ .guardian-a11y em { font-family: var(--font-serif); font-style: italic; color: var(--ink); }
774
+
775
+ /* Β§14 syn-stripe */
776
+ .stripe-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 22px; }
777
+ .stripe-cell { display: flex; flex-direction: column; gap: 8px; padding: 18px; background: white; border: 1px solid var(--rule-soft); }
778
+ .stripe-preview { width: 100%; height: 180px; border: 1px solid var(--rule-soft); background-color: #FAFAF7; overflow: hidden; }
779
+ .stripe-preview > div { background-repeat: repeat; background-size: 12px 12px !important; }
780
+ .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; }
781
+ .stripe-code-wide { max-height: 360px; }
782
+ .stripe-reg { padding: 18px; background: white; border: 1px solid var(--rule-soft); margin-bottom: 18px; }
783
+ .stripe-reg .section-label { display: block; margin-bottom: 8px; }
784
+ .stripe-data { padding: 14px 18px; background: white; border: 1px solid var(--rule-soft); }
785
+ .stripe-data .section-label { display: block; margin-bottom: 6px; }
786
+ .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; }
787
+
788
+ /* Β§15 Register card */
789
+ .register-frame { padding: 28px; background: var(--paper); border: 1px solid var(--rule-soft); margin-bottom: 22px; }
790
+ .register-card { background: white; border: 1px solid var(--rule-soft); padding: 22px 26px; max-width: 760px; }
791
+ .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; }
792
+ .register-card-source { display: flex; align-items: center; gap: 6px; }
793
+ .register-card-source-label { font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); letter-spacing: 0.04em; }
794
+ .register-card-vintage { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.05em; }
795
+ .register-card-title { font-family: var(--font-serif); margin: 0 0 16px; display: flex; align-items: baseline; gap: 10px; line-height: 1.2; }
796
+ .register-card-count { font-size: 32px; font-weight: 600; color: var(--ink); }
797
+ .register-card-type { font-size: 16px; font-weight: 400; color: var(--ink-secondary); }
798
+ .register-table { width: 100%; border-collapse: collapse; font-size: 13px; }
799
+ .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); }
800
+ .register-row { border-bottom: 1px solid #ECE8DD; cursor: pointer; transition: background 0.12s; }
801
+ .register-row:hover { background: #FBF8EF; }
802
+ .register-row.is-open { background: #F4EFE5; }
803
+ .register-row td { padding: 10px 8px; vertical-align: middle; }
804
+ .register-row-glyph { display: flex; align-items: center; gap: 4px; }
805
+ .register-row-name { font-family: var(--font-sans); font-weight: 500; color: var(--ink); }
806
+ .register-yes { color: var(--tier-empirical); font-weight: 600; }
807
+ .register-no { color: var(--ink-tertiary); }
808
+ .register-detail td { padding: 0 8px 14px; background: #F4EFE5; border-bottom: 1px solid var(--rule-soft); }
809
+ .register-detail-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 14px; padding: 14px 0; }
810
+ .register-detail-grid > div { display: flex; flex-direction: column; gap: 4px; }
811
+ .register-detail-grid p { font-size: 12px; line-height: 1.45; color: var(--ink-secondary); margin: 0; }
812
+ .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); }
813
+ .register-foot-note { font-size: 12px; color: var(--ink-tertiary); font-style: italic; max-width: 50ch; }
814
+
815
+ .register-rules { padding: 18px 22px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-empirical); }
816
+ .register-rules ul { margin: 8px 0 0 18px; }
817
+ .register-rules li { font-size: 13px; line-height: 1.6; margin-bottom: 4px; color: var(--ink-secondary); }
818
+
819
+ /* Β§16 caveats */
820
+ .caveat-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0; }
821
+ .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); }
822
+ .caveat-list li:last-child { border-bottom: 1px solid var(--rule-soft); }
823
+ .caveat-list strong { color: var(--ink); font-weight: 600; }
824
+
825
+ /* Β§17 dark mode */
826
+ .darkmode-rules { padding: 18px 22px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-proxy); }
827
+ .darkmode-rules ul { margin: 8px 0 0 18px; }
828
+ .darkmode-rules li { font-size: 13px; line-height: 1.6; margin-bottom: 4px; color: var(--ink-secondary); }
829
+
830
+ /* Β§18 print */
831
+ .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; }
832
+
833
+ /* Β§19 changelog */
834
+ .spec-section-changelog .changelog-list { list-style: none; padding: 0; margin: 0; }
835
+ .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; }
836
+ .changelog-row:last-child { border-bottom: none; }
837
+ .changelog-n { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.06em; }
838
+ .changelog-label { color: var(--ink); }
839
+ .changelog-status { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--tier-empirical); text-align: right; }
840
+
841
+ /* ════════════════════════════════════════════════════════════════════
842
+ v0.4.3 , The Five Stones UI
843
+ Β§20 taxonomy Β· Β§21 trace rework Β· Β§22 cold-start Β· Β§23 methodology
844
+ Β§24 reusability Β· Β§25 a11y Β· Β§26 CSS deltas Β· Β§27 rationale
845
+ ════════════════════════════════════════════════════════════════════ */
846
+
847
+ /* New tokens (Stones-band + warning status) */
848
+ :root {
849
+ --status-warning: var(--accent-graphical);
850
+ --status-warning-soft: rgba(209, 124, 0, 0.10);
851
+ --status-error: #B8620A;
852
+ --status-error-soft: rgba(184, 98, 10, 0.08);
853
+ --stone-band-rule: var(--ink);
854
+ --stone-band-bg: #FBF8EF;
855
+ --stone-band-bg-active: #F4EFE5;
856
+ }
857
+
858
+ /* Β§20 Banner + Stone strip */
859
+ .v043-banner-frame { padding: 0 0 32px; border-top: 1px solid var(--rule-soft); }
860
+ .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; }
861
+ .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; }
862
+ .v043-banner-deck { font-size: 15px; line-height: 1.62; color: var(--ink-secondary); max-width: 60ch; }
863
+ .v043-banner-deck em { font-family: var(--font-serif); font-style: italic; color: var(--ink); }
864
+
865
+ .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; }
866
+ .v043-stones-strip li:nth-child(5) { grid-column: span 2; }
867
+ .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; }
868
+ .v043-stone-chip:nth-child(2n) { border-right: none; }
869
+ .v043-stone-chip:nth-last-child(-n+1) { border-bottom: none; }
870
+ .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; }
871
+ .v043-stone-name { font-family: var(--font-sans); font-size: 16px; font-weight: 600; color: var(--ink); }
872
+ .v043-stone-role { font-family: var(--font-sans); font-size: 12px; color: var(--ink-secondary); letter-spacing: 0.01em; }
873
+ .v043-stone-tag { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--ink-tertiary); }
874
+
875
+ .v043-toc { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; border: 1px solid var(--rule-soft); margin-bottom: 36px; }
876
+ .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; }
877
+ .v043-toc-item:nth-child(4n) { border-right: none; }
878
+ .v043-toc-item:nth-last-child(-n+4):nth-child(n+5) { border-bottom: none; }
879
+ .v043-toc-item:hover { background: #F8F4EA; }
880
+ .v043-toc-n { font-family: var(--font-mono); font-size: 10px; color: var(--ink-tertiary); letter-spacing: 0.06em; min-width: 28px; }
881
+
882
+ /* Β§20 Stones taxonomy table */
883
+ .stones-table { width: 100%; border-collapse: collapse; font-size: 13px; background: white; border: 1px solid var(--rule-soft); }
884
+ .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; }
885
+ .stones-row td { padding: 14px 12px; vertical-align: top; border-bottom: 1px solid #ECE8DD; }
886
+ .stones-row:last-child td { border-bottom: none; }
887
+ .stones-row-stone { display: block; font-family: var(--font-sans); font-size: 15px; font-weight: 600; color: var(--ink); }
888
+ .stones-row-tag { display: block; font-family: var(--font-serif); font-style: italic; font-size: 12px; color: var(--ink-tertiary); margin-top: 2px; }
889
+ .stones-row-role { font-family: var(--font-sans); color: var(--ink-secondary); white-space: nowrap; }
890
+ .stones-row-posture { line-height: 1.55; color: var(--ink); }
891
+ .stones-row-count { white-space: nowrap; }
892
+ .stones-row-num { font-family: var(--font-serif); font-size: 22px; font-weight: 600; color: var(--ink); margin-right: 6px; }
893
+ .stones-row-numlabel { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
894
+ .stones-row-reuse { font-size: 12px; color: var(--ink-secondary); line-height: 1.5; max-width: 28ch; }
895
+ .stones-row-reuse-muted { color: var(--ink-tertiary); font-style: italic; }
896
+
897
+ .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; }
898
+
899
+ /* Β§21 Treatment tabs */
900
+ .treatment-tabs { display: flex; border: 1px solid var(--rule-soft); margin-bottom: 0; }
901
+ .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; }
902
+ .treatment-tab:last-child { border-right: none; }
903
+ .treatment-tab.is-active { background: var(--ink); color: var(--paper); }
904
+ .treatment-tab:hover:not(.is-active) { background: #F8F4EA; color: var(--ink); }
905
+
906
+ .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; }
907
+ .treatment-frame-stacked { grid-template-columns: 1fr; gap: 24px; margin-bottom: 18px; }
908
+ .treatment-meta { display: flex; flex-direction: column; gap: 12px; }
909
+ .treatment-deck { font-size: 14px; line-height: 1.6; color: var(--ink-secondary); }
910
+ .treatment-deck code { font-family: var(--font-mono); font-size: 12px; background: #FBF8EF; padding: 1px 4px; border: 1px solid var(--rule-soft); }
911
+ .treatment-tradeoffs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; line-height: 1.55; }
912
+ .treatment-tradeoffs li { color: var(--ink-secondary); padding-left: 18px; position: relative; }
913
+ .treatment-tradeoffs strong { position: absolute; left: 0; font-family: var(--font-mono); color: var(--ink); }
914
+
915
+ .treatment-rec { background: white; border: 1px solid var(--rule-soft); padding: 32px 36px; }
916
+ .treatment-rec-title { font-family: var(--font-serif); font-size: 26px; font-weight: 600; margin: 8px 0 14px; }
917
+ .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; }
918
+ .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; }
919
+ .treatment-rec p em { font-family: var(--font-serif); font-style: italic; color: var(--ink-secondary); }
920
+ .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; }
921
+ .treatment-rec-rules li { font-size: 13px; line-height: 1.55; color: var(--ink-secondary); }
922
+
923
+ /* Β§21 Stone band (Treatment A) */
924
+ .trace-ui-v43-a { background: var(--paper); border: 1px solid var(--rule-soft); }
925
+ .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; }
926
+ .trace-head-grouping { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.06em; }
927
+ .trace-head-warning { color: var(--status-warning); }
928
+ .trace-head-error { color: var(--status-error); }
929
+
930
+ .stone-band { border-top: 1.5px solid var(--stone-band-rule); background: white; }
931
+ .stone-band:first-of-type { border-top: none; }
932
+ .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; }
933
+ .stone-band.is-open .stone-band-head { background: var(--stone-band-bg-active); }
934
+ .stone-band-head:hover { background: #EFE9DA; }
935
+ .stone-band-toggle { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); }
936
+ .stone-band-name { display: flex; align-items: baseline; gap: 4px; }
937
+ .stone-band-stone { font-weight: 600; font-size: 15px; color: var(--ink); }
938
+ .stone-band-role { color: var(--ink-secondary); font-size: 13px; }
939
+ .stone-band-tag { font-family: var(--font-serif); font-style: italic; color: var(--ink-tertiary); font-size: 13px; }
940
+ .stone-band-agg { font-family: var(--font-mono); font-size: 11px; color: var(--ink-secondary); letter-spacing: 0.04em; white-space: nowrap; }
941
+ .stone-agg-fired { color: var(--ink); }
942
+ .stone-agg-silent { color: var(--ink-tertiary); }
943
+ .stone-agg-warn { color: var(--status-warning); font-weight: 600; }
944
+ .stone-agg-error { color: var(--status-error); font-weight: 600; }
945
+ .stone-agg-ms { color: var(--ink-tertiary); }
946
+ .stone-agg-sep { color: var(--ink-tertiary); margin: 0 6px; }
947
+ .stone-band-body { padding: 8px 0 14px; }
948
+ .stone-band-body .trace-row { padding-top: 6px; padding-bottom: 6px; }
949
+
950
+ /* shared specialist row in v0.4.3 */
951
+ .trace-ui-v43-a .trace-row, .trace-ui-v43-b .trace-row {
952
+ display: grid; grid-template-columns: 16px 16px 1fr auto auto; gap: 10px; align-items: baseline;
953
+ font-family: var(--font-sans); font-size: 13px;
954
+ }
955
+ .trace-ui-v43-a .trace-row .trace-name, .trace-ui-v43-b .trace-row .trace-name {
956
+ font-family: var(--font-mono); font-size: 12px; color: var(--ink);
957
+ }
958
+ .trace-row-warning { background: var(--status-warning-soft); border-left: 2px solid var(--status-warning); }
959
+ .trace-row-error { background: var(--status-error-soft); border-left: 2px solid var(--status-error); }
960
+ .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; }
961
+ .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; }
962
+ .trace-warning-summary { color: var(--status-warning); font-style: italic; font-family: var(--font-sans); font-size: 12px; }
963
+ .trace-error-summary { color: var(--status-error); font-style: italic; font-family: var(--font-sans); font-size: 12px; }
964
+ .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; }
965
+
966
+ .trace-ttm-group { padding: 6px 0; }
967
+ .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; }
968
+ .trace-ttm-group > summary::-webkit-details-marker { display: none; }
969
+ .trace-ttm-group > summary .trace-name { font-family: var(--font-mono); font-size: 12px; }
970
+
971
+ /* Β§21 Treatment B , rule-marked groups */
972
+ .trace-ui-v43-b .trace-body-flat { padding: 14px 18px; }
973
+ .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; }
974
+ .stone-rule-group { border-top: 2px solid var(--ink); padding-top: 10px; margin-top: 14px; }
975
+ .stone-rule-group:first-of-type { border-top: 1px solid var(--rule-soft); margin-top: 6px; padding-top: 8px; }
976
+ .stone-rule-marker { display: flex; align-items: baseline; gap: 10px; padding: 4px 0 8px; flex-wrap: wrap; }
977
+ .stone-rule-name { font-family: var(--font-sans); font-size: 14px; font-weight: 600; color: var(--ink); }
978
+ .stone-rule-role { font-family: var(--font-serif); font-style: italic; color: var(--ink-tertiary); font-size: 12px; }
979
+ .stone-rule-agg { font-family: var(--font-mono); font-size: 10px; color: var(--ink-secondary); letter-spacing: 0.04em; margin-left: auto; }
980
+
981
+ /* Β§22 cold-start (in-app) thesis line */
982
+ .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); }
983
+ .cold-start-thesis strong { font-weight: 600; }
984
+
985
+ /* Β§22 cold-start v43 spec mock */
986
+ .cold-v43-frame { padding: 28px; background: var(--paper); border: 1px solid var(--rule-soft); margin-bottom: 18px; }
987
+ .cold-v43-mock { background: white; border: 1px solid var(--rule-soft); padding: 24px 28px; max-width: 600px; }
988
+ .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); }
989
+ .cold-v43-thesis strong { font-weight: 600; }
990
+ .cold-v43-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
991
+ .cold-v43-list li { font-size: 13px; line-height: 1.55; color: var(--ink-secondary); padding-left: 14px; position: relative; }
992
+ .cold-v43-list li::before { content: "Β·"; position: absolute; left: 0; color: var(--ink); font-weight: 700; }
993
+ .cold-v43-list a { color: var(--accent-graphical); text-decoration: none; border-bottom: 1px solid var(--accent-graphical); }
994
+ .cold-v43-rules { padding: 18px 22px; background: white; border: 1px solid var(--rule-soft); border-left: 3px solid var(--tier-empirical); }
995
+ .cold-v43-rules ul { margin: 8px 0 0 18px; }
996
+ .cold-v43-rules li { font-size: 13px; line-height: 1.6; margin-bottom: 4px; color: var(--ink-secondary); }
997
+
998
+ /* Β§23 methodology + 5Γ—4 matrix */
999
+ .methodology-toc { list-style: none; padding: 0; margin: 0 0 36px; counter-reset: section; }
1000
+ .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); }
1001
+ .methodology-toc li:last-child { border-bottom: 1px solid var(--rule-soft); }
1002
+ .methodology-toc strong { color: var(--ink); }
1003
+
1004
+ .tier-stone-matrix { background: white; border: 1px solid var(--rule-soft); padding: 24px 28px; margin: 0; }
1005
+ .tier-stone-matrix table { width: 100%; border-collapse: collapse; }
1006
+ .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); }
1007
+ .tier-stone-matrix thead th:first-child { text-align: left; }
1008
+ .tier-stone-matrix tbody th { text-align: left; padding: 14px 12px; border-bottom: 1px solid #ECE8DD; vertical-align: top; }
1009
+ .matrix-stone-name { display: block; font-family: var(--font-sans); font-size: 14px; font-weight: 600; color: var(--ink); }
1010
+ .matrix-stone-role { display: block; font-family: var(--font-serif); font-style: italic; font-size: 11px; color: var(--ink-tertiary); margin-top: 2px; }
1011
+ .matrix-cell { text-align: center; padding: 14px 8px; border-bottom: 1px solid #ECE8DD; vertical-align: middle; }
1012
+ .matrix-cell-mark { display: flex; align-items: center; justify-content: center; min-height: 18px; }
1013
+ .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; }
1014
+ .matrix-cell-empty .matrix-cell-label { color: #C9C5B6; }
1015
+ .matrix-cell-passthrough { background: #FBF8EF; }
1016
+ .matrix-empty { font-family: var(--font-serif); font-size: 16px; color: #C9C5B6; }
1017
+ .matrix-passthrough { font-family: var(--font-mono); font-size: 14px; color: var(--ink-tertiary); }
1018
+ .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; }
1019
+
1020
+ /* Β§24 reusability */
1021
+ .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; }
1022
+ .reuse-rules { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
1023
+ .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); }
1024
+ .reuse-rules strong { color: var(--ink); }
1025
+
1026
+ /* Β§25 a11y */
1027
+ .a11y-v43-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
1028
+ .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); }
1029
+ .a11y-v43-list strong { color: var(--ink); }
1030
+ .a11y-v43-list code { font-family: var(--font-mono); font-size: 11px; background: #FBF8EF; padding: 1px 4px; border: 1px solid var(--rule-soft); }
1031
+ .a11y-v43-list em { font-family: var(--font-serif); font-style: italic; color: var(--ink); }
1032
+
1033
+ /* Β§26 css deltas */
1034
+ .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; }
1035
+
1036
+ /* Β§27 rationale (reuses existing if present, else light variant) */
1037
+ .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; }
1038
+ .spec-section-rationale .rationale-quote p { margin: 0 0 14px; }
1039
+ .spec-section-rationale .rationale-quote p:last-child { margin: 0; }
1040
+ .spec-section-rationale .rationale-cite { font-family: var(--font-mono); font-size: 11px; color: var(--ink-tertiary); letter-spacing: 0.04em; }
1041
+
1042
+ /* PDF artifact link , pre-rendered, not a client export action */
1043
+ .app-header-pdf {
1044
+ display: inline-flex; align-items: baseline; gap: 6px;
1045
+ font-family: var(--font-mono); font-size: 11px;
1046
+ color: var(--ink); padding: 4px 10px;
1047
+ border: 1px solid var(--rule-soft); background: white;
1048
+ letter-spacing: 0.02em;
1049
+ }
1050
+ .app-header-pdf-icon { font-size: 12px; color: var(--accent-graphical); }
1051
+ .app-header-pdf-meta { font-size: 9px; color: var(--ink-tertiary); letter-spacing: 0.06em; text-transform: uppercase; }
1052
+
1053
+ /* Mobile brief title fallback */
1054
+ @media (max-width: 720px) {
1055
+ .brief-h1 { grid-template-columns: 1fr; }
1056
+ .brief-h1-meta { grid-column: 1; grid-row: 3; text-align: left; align-items: flex-start; }
1057
+ .brief-h1-meta-row { justify-content: flex-start; }
1058
+ .brief-h1-addr { font-size: 24px; }
1059
+ }
1060
+ .v043-stones-strip { grid-template-columns: 1fr; }
1061
+ .v043-stones-strip li:nth-child(5) { grid-column: span 1; }
1062
+ .v043-stone-chip:nth-child(2n) { border-right: none; border-bottom: 1px solid var(--rule-soft); }
1063
+ .v043-toc { grid-template-columns: 1fr 1fr; }
1064
+ .stones-table thead { display: none; }
1065
+ .stones-table tbody td { display: block; padding: 6px 12px; }
1066
+ .stones-row td { border-bottom: none; }
1067
+ .stones-row { display: block; padding: 14px 0; border-bottom: 1px solid #ECE8DD; }
1068
+ .treatment-tabs { flex-direction: column; }
1069
+ .treatment-tab { border-right: none; border-bottom: 1px solid var(--rule-soft); }
1070
+ .treatment-frame { grid-template-columns: 1fr; padding: 18px; }
1071
+ .stone-band-head { grid-template-columns: 16px 1fr; gap: 8px; }
1072
+ .stone-band-tag { grid-column: 2; }
1073
+ .stone-band-agg { grid-column: 2; }
1074
+ .tier-stone-matrix { padding: 16px; }
1075
+ .tier-stone-matrix thead th { font-size: 9px; padding: 6px 4px; }
1076
+ .tier-stone-matrix tbody th { padding: 10px 6px; }
1077
+ .matrix-cell { padding: 10px 4px; }
1078
+ .matrix-stone-name { font-size: 12px; }
1079
+ .stones-pullquote { font-size: 17px; padding: 18px 20px; }
1080
+ }
1081
+
1082
+ @media (prefers-reduced-motion: reduce) {
1083
+ .stone-band, .stone-band-head, .treatment-tab { transition: none; }
1084
+ }
1085
+
1086
+ /* ─────────── original v0.4.2 mobile rules ────────── */
1087
+ @media (max-width: 720px) {
1088
+ .app-header-inner { grid-template-columns: 1fr; gap: 8px; }
1089
+ .app-header-mid, .app-header-right { justify-content: flex-start; }
1090
+ .app-header-query { min-width: 0; width: 100%; }
1091
+ .hero-band-inner { padding: 20px 16px 32px; }
1092
+ .spec-band-inner { padding: 40px 16px 60px; }
1093
+ .spec-band-title { font-size: 28px; }
1094
+ .spec-section-title { font-size: 24px; }
1095
+ .brief-h1 { font-size: 26px; }
1096
+ .map-legend { width: calc(100% - 24px); }
1097
+ .palette-row { grid-template-columns: 48px 1fr; gap: 12px; row-gap: 4px; }
1098
+ .palette-row > *:nth-child(n+3) { grid-column: 2; }
1099
+ .a11y-row { grid-template-columns: 1fr; gap: 4px; }
1100
+
1101
+ /* v0.4.2 mobile */
1102
+ .v042-banner-inner { grid-template-columns: 1fr; gap: 24px; }
1103
+ .v042-toc { grid-template-columns: 1fr; }
1104
+ .v042-toc-item { border-right: none !important; }
1105
+ .v042-banner-title { font-size: 24px; }
1106
+ .loading-grid { grid-template-columns: 1fr; }
1107
+ .error-grid { grid-template-columns: 1fr; }
1108
+ .stripe-grid { grid-template-columns: 1fr; }
1109
+ .guardian-tabs { flex-direction: column; }
1110
+ .guardian-tab { border-right: none !important; border-bottom: 1px solid var(--rule); flex: none; }
1111
+ .guardian-tab:last-child { border-bottom: none; }
1112
+ .guardian-card { padding: 24px 22px; }
1113
+ .guardian-title { font-size: 20px; }
1114
+ .register-card { padding: 18px; }
1115
+ .register-table { font-size: 11px; }
1116
+ .register-table thead th { padding: 4px; font-size: 9px; }
1117
+ .register-row td { padding: 8px 4px; }
1118
+ .register-detail-grid { grid-template-columns: 1fr 1fr; }
1119
+ .register-card-count { font-size: 26px; }
1120
+ .changelog-row { grid-template-columns: 50px 1fr; }
1121
+ .changelog-status { grid-column: 2; text-align: left; padding-top: 2px; }
1122
+ }
docs/design_handoff/design_files/tokens.css ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Riprap design tokens
2
+ Civic-tech-clean. SIL OFL / Apache fonts only. WCAG 2.2 AA verified.
3
+ */
4
+
5
+ :root {
6
+ /* ── Epistemic tier colors ─────────────────────────────────────────
7
+ Refined from starter palette to meet 4.5:1 against white for body
8
+ text where the tier color is also used as inline citation/label color.
9
+ Originals: #3D85C6 = 3.04:1 (fail body), #999999 = 2.85:1 (fail).
10
+ Refined values verified via WebAIM contrast checker, deutan/protan/
11
+ tritan colorblind-safe (tier is also encoded by glyph + label).
12
+ */
13
+ --tier-empirical: #0B5394; /* 8.59:1 vs white βœ“ AAA */
14
+ --tier-empirical-fill: rgba(11, 83, 148, 0.40);
15
+ --tier-empirical-line: #0B5394;
16
+
17
+ --tier-modeled: #2A6FA8; /* 5.41:1 vs white βœ“ AA was #3D85C6 (3.04:1 fail) */
18
+ --tier-modeled-fill: rgba(42, 111, 168, 0.25);
19
+ --tier-modeled-line: #2A6FA8;
20
+
21
+ --tier-proxy: #6B6B6B; /* 5.74:1 vs white βœ“ AA was #999999 (2.85:1 fail) */
22
+ --tier-proxy-fill: transparent;
23
+ --tier-proxy-line: #6B6B6B;
24
+
25
+ --tier-synthetic: #2A6FA8; /* same hue as modeled , pattern carries the difference */
26
+ --tier-synthetic-fill: rgba(42, 111, 168, 0.25);
27
+ --tier-synthetic-line: #2A6FA8;
28
+
29
+ /* ── Reference + accent ── */
30
+ --reference-bg: #E8E8E6;
31
+ --reference-line: #C9C9C5;
32
+ --accent: #B8620A; /* 4.93:1 vs white βœ“ AA adjusted from #D17C00 (3.36:1) */
33
+ --accent-graphical: #D17C00; /* keep original for graphical use only (3.36:1 β‰₯ 3:1) */
34
+
35
+ /* ── Neutrals (paper register) ── */
36
+ --paper: #FAFAF7; /* warm near-white, USGS report register */
37
+ --paper-deep: #F2F2EE;
38
+ --ink: #1A1A1A; /* 18.5:1 βœ“ AAA */
39
+ --ink-secondary: #4A4A4A; /* 9.7:1 βœ“ AAA */
40
+ --ink-tertiary: #6B6B6B; /* 5.74:1 βœ“ AA */
41
+ --rule: #1A1A1A;
42
+ --rule-soft: #C9C9C5;
43
+
44
+ /* ── Type ── */
45
+ --font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
46
+ --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
47
+ --font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif;
48
+
49
+ /* ── Scale ── */
50
+ --measure: 70ch;
51
+ --leading-prose: 1.55;
52
+ --leading-tight: 1.25;
53
+
54
+ /* ── Spacing ── */
55
+ --s-1: 4px;
56
+ --s-2: 8px;
57
+ --s-3: 12px;
58
+ --s-4: 16px;
59
+ --s-5: 24px;
60
+ --s-6: 32px;
61
+ --s-7: 48px;
62
+ --s-8: 64px;
63
+ --s-9: 96px;
64
+ }
65
+
66
+ * { box-sizing: border-box; }
67
+
68
+ html, body {
69
+ margin: 0;
70
+ padding: 0;
71
+ background: var(--paper);
72
+ color: var(--ink);
73
+ font-family: var(--font-sans);
74
+ font-size: 16px;
75
+ line-height: var(--leading-prose);
76
+ -webkit-font-smoothing: antialiased;
77
+ text-rendering: optimizeLegibility;
78
+ }
79
+
80
+ /* High-contrast focus rings, brief-spec'd */
81
+ :focus-visible {
82
+ outline: 3px solid var(--accent-graphical);
83
+ outline-offset: 2px;
84
+ border-radius: 1px;
85
+ }
86
+
87
+ @media (prefers-reduced-motion: reduce) {
88
+ *, *::before, *::after {
89
+ animation-duration: 0.01ms !important;
90
+ animation-iteration-count: 1 !important;
91
+ transition-duration: 0.01ms !important;
92
+ }
93
+ }
94
+
95
+ /* ── Wordmark ─────────────────────────────────────────────────────── */
96
+ .riprap-wordmark {
97
+ font-family: var(--font-mono);
98
+ font-weight: 600;
99
+ font-size: 14px;
100
+ letter-spacing: 0.06em;
101
+ text-transform: lowercase;
102
+ color: var(--ink);
103
+ display: inline-flex;
104
+ align-items: baseline;
105
+ gap: 0;
106
+ }
107
+ .riprap-wordmark::before {
108
+ content: "β–Œ";
109
+ color: var(--accent-graphical);
110
+ margin-right: 4px;
111
+ font-size: 0.85em;
112
+ position: relative;
113
+ top: 1px;
114
+ }
115
+
116
+ /* ── Skip link ── */
117
+ .skip-link {
118
+ position: absolute;
119
+ left: -9999px;
120
+ top: 8px;
121
+ padding: 8px 12px;
122
+ background: var(--ink);
123
+ color: var(--paper);
124
+ font-family: var(--font-mono);
125
+ font-size: 13px;
126
+ z-index: 1000;
127
+ }
128
+ .skip-link:focus {
129
+ left: 8px;
130
+ }
131
+
132
+ /* ── Generic section labels ── */
133
+ .section-label {
134
+ font-family: var(--font-mono);
135
+ font-size: 11px;
136
+ font-weight: 500;
137
+ letter-spacing: 0.12em;
138
+ text-transform: uppercase;
139
+ color: var(--ink-tertiary);
140
+ }
141
+
142
+ .rule-thin {
143
+ border: 0;
144
+ border-top: 1px solid var(--rule-soft);
145
+ margin: 0;
146
+ }
147
+ .rule-heavy {
148
+ border: 0;
149
+ border-top: 2px solid var(--ink);
150
+ margin: 0;
151
+ }
docs/design_handoff/design_files/trace.jsx ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Trace UI: <details>-based tree of the Burr FSM run.
2
+ Three columns: action name Β· elapsed ms Β· epistemic-tier badge.
3
+ Parallel branches shown as sibling rows under a "fan-out" parent;
4
+ convergence step shown as a "merge" node. Reference: Postgres
5
+ EXPLAIN ANALYZE viewers, Apache Airflow DAG.
6
+ */
7
+
8
+ const TRACE = {
9
+ id: "root",
10
+ name: "briefing.run",
11
+ status: "ok",
12
+ ms: 14820,
13
+ tier: null,
14
+ children: [
15
+ { id: "n1", name: "geocode.address", status: "ok", ms: 142, tier: null,
16
+ output: { lat: 40.6776, lon: -74.0096, bbl: "3005970030" } },
17
+ { id: "n2", name: "fan_out.stones", status: "fan", ms: 0, tier: null,
18
+ note: "5 Stones engaged in parallel",
19
+ children: [
20
+ { id: "s1", name: "sandy_inundation.lookup", status: "ok", ms: 380, tier: "empirical",
21
+ claims: 2, output: "polygon: contains; nearest HWM 0.4mi" },
22
+ { id: "s2", name: "floodnet.history", status: "ok", ms: 1240, tier: "empirical",
23
+ claims: 1, output: "BK-RH-002: 7 events, peak 14.3cm" },
24
+ { id: "s3", name: "usgs.high_water_marks", status: "ok", ms: 612, tier: "empirical",
25
+ claims: 1, output: "9 marks within 500ft" },
26
+ { id: "s4", name: "fema.firm.preliminary", status: "ok", ms: 488, tier: "modeled",
27
+ claims: 1, output: "Zone AE, BFE 11ft NAVD88" },
28
+ { id: "s5", name: "dep.stormwater.scenario", status: "ok", ms: 2104, tier: "modeled",
29
+ claims: 1, output: "moderate: ponding β‰₯4in W half" },
30
+ { id: "s6", name: "npcc4.slr.projection", status: "ok", ms: 320, tier: "modeled",
31
+ claims: 1, output: "2050 90th: +30in" },
32
+ { id: "s7", name: "nyc311.flood_complaints", status: "ok", ms: 980, tier: "proxy",
33
+ claims: 1, output: "89 calls / tract / 2019–25" },
34
+ { id: "s8", name: "nfip.claims_aggregate", status: "ok", ms: 540, tier: "proxy",
35
+ claims: 1, output: "$4.1M / 47 paid losses" },
36
+ { id: "s9", name: "terramind.synthetic_sar", status: "ok", ms: 6840, tier: "synthetic",
37
+ claims: 1, output: "synthesis confidence 0.71" },
38
+ { id: "s10", name: "tidal_gauge.range", status: "silent", ms: 18, tier: null,
39
+ claims: 0, output: "out of range: nearest gauge >2mi" },
40
+ { id: "s11", name: "wrp.coastal_risk_area", status: "ok", ms: 210, tier: "modeled",
41
+ claims: 1, output: "within Coastal Risk Area" },
42
+ ]
43
+ },
44
+ { id: "n3", name: "merge.evidence", status: "merge", ms: 92, tier: null,
45
+ note: "10 cards Β· 1 silent Β· 0 errors" },
46
+ { id: "n4", name: "compose.briefing", status: "ok", ms: 1380, tier: null,
47
+ output: "4 sections Β· 11 claims Β· 10 citations" },
48
+ { id: "n5", name: "stream.sse", status: "ok", ms: 4940, tier: null,
49
+ output: "1812 tokens Β· 11 sentence chunks" },
50
+ ]
51
+ };
52
+
53
+ const StatusGlyph = ({ status }) => {
54
+ const map = {
55
+ ok: { fill: "#0B5394", char: "" },
56
+ silent: { fill: "transparent", stroke: "#6B6B6B", char: "" },
57
+ error: { fill: "#B8620A", char: "!" },
58
+ fan: { char: "β€³" },
59
+ merge: { char: "β€Ί" },
60
+ };
61
+ const m = map[status];
62
+ if (status === "fan" || status === "merge") {
63
+ return <span className="trace-status-glyph" aria-label={status}>{m.char}</span>;
64
+ }
65
+ return (
66
+ <svg width="9" height="9" viewBox="0 0 9 9" aria-label={status}>
67
+ <rect x="0.75" y="0.75" width="7.5" height="7.5" fill={m.fill} stroke={m.stroke || m.fill} strokeWidth="1.5"/>
68
+ </svg>
69
+ );
70
+ };
71
+
72
+ const TraceRow = ({ node, depth = 0, defaultOpen = false }) => {
73
+ const hasChildren = node.children && node.children.length > 0;
74
+ const [open, setOpen] = useState(defaultOpen);
75
+ const indent = depth * 16;
76
+
77
+ return (
78
+ <>
79
+ <div className={`trace-row trace-row-${node.status}`} style={{ paddingLeft: indent + 12 }}>
80
+ <button
81
+ type="button"
82
+ className="trace-row-toggle"
83
+ onClick={() => hasChildren && setOpen(!open)}
84
+ aria-expanded={hasChildren ? open : undefined}
85
+ aria-label={`${node.name}, ${node.ms}ms, ${node.status}`}
86
+ disabled={!hasChildren}
87
+ >
88
+ <span className="trace-tree-glyph" aria-hidden="true">
89
+ {hasChildren ? (open ? "β–Ύ" : "β–Έ") : "Β·"}
90
+ </span>
91
+ <span className="trace-status-col">
92
+ <StatusGlyph status={node.status} />
93
+ </span>
94
+ <span className="trace-name-col">
95
+ <span className="trace-name">{node.name}</span>
96
+ {node.note && <span className="trace-note"> Β· {node.note}</span>}
97
+ </span>
98
+ <span className="trace-ms-col">{node.ms}ms</span>
99
+ <span className="trace-tier-col">
100
+ {node.tier && <TierBadge tier={node.tier} compact />}
101
+ {node.status === "silent" && (
102
+ <span className="trace-silent-tag">silent</span>
103
+ )}
104
+ </span>
105
+ </button>
106
+ {open && node.output && (
107
+ <div className="trace-output" style={{ paddingLeft: indent + 44 }}>
108
+ <span className="trace-output-prefix">β†’</span>
109
+ <span className="trace-output-text">
110
+ {typeof node.output === "string" ? node.output : JSON.stringify(node.output)}
111
+ </span>
112
+ {node.claims != null && (
113
+ <span className="trace-output-claims">{node.claims} claim{node.claims === 1 ? "" : "s"} cited</span>
114
+ )}
115
+ </div>
116
+ )}
117
+ </div>
118
+ {open && hasChildren && node.children.map((c) => (
119
+ <TraceRow key={c.id} node={c} depth={depth + 1} defaultOpen={c.status === "fan"} />
120
+ ))}
121
+ </>
122
+ );
123
+ };
124
+
125
+ const TraceUI = ({ collapsed, onToggleCollapsed }) => {
126
+ return (
127
+ <section className={`trace-ui ${collapsed ? "is-collapsed" : ""}`} aria-label="Run trace">
128
+ <header className="trace-head">
129
+ <div className="trace-head-left">
130
+ <span className="section-label">Run trace</span>
131
+ <span className="trace-head-meta">
132
+ <span className="trace-head-stat">14.82s total</span>
133
+ <span className="trace-head-sep">Β·</span>
134
+ <span className="trace-head-stat">10 fired</span>
135
+ <span className="trace-head-sep">Β·</span>
136
+ <span className="trace-head-stat trace-head-silent">1 silent</span>
137
+ <span className="trace-head-sep">Β·</span>
138
+ <span className="trace-head-stat">0 errors</span>
139
+ </span>
140
+ </div>
141
+ <button
142
+ type="button"
143
+ className="trace-collapse-btn"
144
+ onClick={onToggleCollapsed}
145
+ aria-expanded={!collapsed}
146
+ >
147
+ {collapsed ? "Expand β–Ύ" : "Collapse β–΄"}
148
+ </button>
149
+ </header>
150
+ {!collapsed && (
151
+ <div className="trace-body">
152
+ <div className="trace-col-heads">
153
+ <span className="trace-col-head trace-col-head-name">action</span>
154
+ <span className="trace-col-head trace-col-head-ms">elapsed</span>
155
+ <span className="trace-col-head trace-col-head-tier">tier</span>
156
+ </div>
157
+ <div className="trace-tree" role="tree">
158
+ <TraceRow node={TRACE} defaultOpen={true} />
159
+ </div>
160
+ </div>
161
+ )}
162
+ </section>
163
+ );
164
+ };
165
+
166
+ Object.assign(window, { TraceUI });
docs/design_handoff/design_files/tweaks-panel.jsx ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ // tweaks-panel.jsx
3
+ // Reusable Tweaks shell + form-control helpers.
4
+ //
5
+ // Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
6
+ // posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
7
+ // individual prototypes don't re-roll it. Ships a consistent set of controls so you
8
+ // don't hand-draw <input type="range">, segmented radios, steppers, etc.
9
+ //
10
+ // Usage (in an HTML file that loads React + Babel):
11
+ //
12
+ // const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
13
+ // "primaryColor": "#D97757",
14
+ // "fontSize": 16,
15
+ // "density": "regular",
16
+ // "dark": false
17
+ // }/*EDITMODE-END*/;
18
+ //
19
+ // function App() {
20
+ // const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
21
+ // return (
22
+ // <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
23
+ // Hello
24
+ // <TweaksPanel>
25
+ // <TweakSection label="Typography" />
26
+ // <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
27
+ // onChange={(v) => setTweak('fontSize', v)} />
28
+ // <TweakRadio label="Density" value={t.density}
29
+ // options={['compact', 'regular', 'comfy']}
30
+ // onChange={(v) => setTweak('density', v)} />
31
+ // <TweakSection label="Theme" />
32
+ // <TweakColor label="Primary" value={t.primaryColor}
33
+ // onChange={(v) => setTweak('primaryColor', v)} />
34
+ // <TweakToggle label="Dark mode" value={t.dark}
35
+ // onChange={(v) => setTweak('dark', v)} />
36
+ // </TweaksPanel>
37
+ // </div>
38
+ // );
39
+ // }
40
+ //
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ const __TWEAKS_STYLE = `
44
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
45
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
46
+ background:rgba(250,249,247,.78);color:#29261b;
47
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
48
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
49
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
50
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
51
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
52
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
53
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
54
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
55
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
56
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
57
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
58
+ overflow-y:auto;overflow-x:hidden;min-height:0;
59
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
60
+ .twk-body::-webkit-scrollbar{width:8px}
61
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
62
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
63
+ border:2px solid transparent;background-clip:content-box}
64
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
65
+ border:2px solid transparent;background-clip:content-box}
66
+ .twk-row{display:flex;flex-direction:column;gap:5px}
67
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
68
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
69
+ color:rgba(41,38,27,.72)}
70
+ .twk-lbl>span:first-child{font-weight:500}
71
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
72
+
73
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
74
+ color:rgba(41,38,27,.45);padding:10px 0 0}
75
+ .twk-sect:first-child{padding-top:0}
76
+
77
+ .twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
78
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
79
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
80
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
81
+ select.twk-field{padding-right:22px;
82
+ background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
83
+ background-repeat:no-repeat;background-position:right 8px center}
84
+
85
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
86
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
87
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
88
+ width:14px;height:14px;border-radius:50%;background:#fff;
89
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
90
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
91
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
92
+
93
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
94
+ background:rgba(0,0,0,.06);user-select:none}
95
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
96
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
97
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
98
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
99
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
100
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
101
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
102
+ overflow-wrap:anywhere}
103
+
104
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
105
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
106
+ .twk-toggle[data-on="1"]{background:#34c759}
107
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
108
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
109
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
110
+
111
+ .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
112
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
113
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
114
+ user-select:none;padding-right:8px}
115
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
116
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
117
+ outline:none;color:inherit;-moz-appearance:textfield}
118
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
119
+ -webkit-appearance:none;margin:0}
120
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
121
+
122
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
123
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
124
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
125
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
126
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
127
+
128
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
129
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
130
+ background:transparent;flex-shrink:0}
131
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
132
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
133
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
134
+ `;
135
+
136
+ // ── useTweaks ───────────────────────────────────────────────────────────────
137
+ // Single source of truth for tweak values. setTweak persists via the host
138
+ // (__edit_mode_set_keys β†’ host rewrites the EDITMODE block on disk).
139
+ function useTweaks(defaults) {
140
+ const [values, setValues] = React.useState(defaults);
141
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
142
+ // useState-style call doesn't write a "[object Object]" key into the persisted
143
+ // JSON block.
144
+ const setTweak = React.useCallback((keyOrEdits, val) => {
145
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
146
+ ? keyOrEdits : { [keyOrEdits]: val };
147
+ setValues((prev) => ({ ...prev, ...edits }));
148
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
149
+ }, []);
150
+ return [values, setTweak];
151
+ }
152
+
153
+ // ── TweaksPanel ─────────────────────────────────────────────────────────────
154
+ // Floating shell. Registers the protocol listener BEFORE announcing
155
+ // availability β€” if the announce ran first, the host's activate could land
156
+ // before our handler exists and the toolbar toggle would silently no-op.
157
+ // The close button posts __edit_mode_dismissed so the host's toolbar toggle
158
+ // flips off in lockstep; the host echoes __deactivate_edit_mode back which
159
+ // is what actually hides the panel.
160
+ function TweaksPanel({ title = 'Tweaks', children }) {
161
+ const [open, setOpen] = React.useState(false);
162
+ const dragRef = React.useRef(null);
163
+ const offsetRef = React.useRef({ x: 16, y: 16 });
164
+ const PAD = 16;
165
+
166
+ const clampToViewport = React.useCallback(() => {
167
+ const panel = dragRef.current;
168
+ if (!panel) return;
169
+ const w = panel.offsetWidth, h = panel.offsetHeight;
170
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
171
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
172
+ offsetRef.current = {
173
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
174
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
175
+ };
176
+ panel.style.right = offsetRef.current.x + 'px';
177
+ panel.style.bottom = offsetRef.current.y + 'px';
178
+ }, []);
179
+
180
+ React.useEffect(() => {
181
+ if (!open) return;
182
+ clampToViewport();
183
+ if (typeof ResizeObserver === 'undefined') {
184
+ window.addEventListener('resize', clampToViewport);
185
+ return () => window.removeEventListener('resize', clampToViewport);
186
+ }
187
+ const ro = new ResizeObserver(clampToViewport);
188
+ ro.observe(document.documentElement);
189
+ return () => ro.disconnect();
190
+ }, [open, clampToViewport]);
191
+
192
+ React.useEffect(() => {
193
+ const onMsg = (e) => {
194
+ const t = e?.data?.type;
195
+ if (t === '__activate_edit_mode') setOpen(true);
196
+ else if (t === '__deactivate_edit_mode') setOpen(false);
197
+ };
198
+ window.addEventListener('message', onMsg);
199
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
200
+ return () => window.removeEventListener('message', onMsg);
201
+ }, []);
202
+
203
+ const dismiss = () => {
204
+ setOpen(false);
205
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
206
+ };
207
+
208
+ const onDragStart = (e) => {
209
+ const panel = dragRef.current;
210
+ if (!panel) return;
211
+ const r = panel.getBoundingClientRect();
212
+ const sx = e.clientX, sy = e.clientY;
213
+ const startRight = window.innerWidth - r.right;
214
+ const startBottom = window.innerHeight - r.bottom;
215
+ const move = (ev) => {
216
+ offsetRef.current = {
217
+ x: startRight - (ev.clientX - sx),
218
+ y: startBottom - (ev.clientY - sy),
219
+ };
220
+ clampToViewport();
221
+ };
222
+ const up = () => {
223
+ window.removeEventListener('mousemove', move);
224
+ window.removeEventListener('mouseup', up);
225
+ };
226
+ window.addEventListener('mousemove', move);
227
+ window.addEventListener('mouseup', up);
228
+ };
229
+
230
+ if (!open) return null;
231
+ return (
232
+ <>
233
+ <style>{__TWEAKS_STYLE}</style>
234
+ <div ref={dragRef} className="twk-panel" data-noncommentable=""
235
+ style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
236
+ <div className="twk-hd" onMouseDown={onDragStart}>
237
+ <b>{title}</b>
238
+ <button className="twk-x" aria-label="Close tweaks"
239
+ onMouseDown={(e) => e.stopPropagation()}
240
+ onClick={dismiss}>βœ•</button>
241
+ </div>
242
+ <div className="twk-body">{children}</div>
243
+ </div>
244
+ </>
245
+ );
246
+ }
247
+
248
+ // ── Layout helpers ──────────────────────────────────────────────────────────
249
+
250
+ function TweakSection({ label, children }) {
251
+ return (
252
+ <>
253
+ <div className="twk-sect">{label}</div>
254
+ {children}
255
+ </>
256
+ );
257
+ }
258
+
259
+ function TweakRow({ label, value, children, inline = false }) {
260
+ return (
261
+ <div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
262
+ <div className="twk-lbl">
263
+ <span>{label}</span>
264
+ {value != null && <span className="twk-val">{value}</span>}
265
+ </div>
266
+ {children}
267
+ </div>
268
+ );
269
+ }
270
+
271
+ // ── Controls ────────────────────────────────────────────────────────────────
272
+
273
+ function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
274
+ return (
275
+ <TweakRow label={label} value={`${value}${unit}`}>
276
+ <input type="range" className="twk-slider" min={min} max={max} step={step}
277
+ value={value} onChange={(e) => onChange(Number(e.target.value))} />
278
+ </TweakRow>
279
+ );
280
+ }
281
+
282
+ function TweakToggle({ label, value, onChange }) {
283
+ return (
284
+ <div className="twk-row twk-row-h">
285
+ <div className="twk-lbl"><span>{label}</span></div>
286
+ <button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
287
+ role="switch" aria-checked={!!value}
288
+ onClick={() => onChange(!value)}><i /></button>
289
+ </div>
290
+ );
291
+ }
292
+
293
+ function TweakRadio({ label, value, options, onChange }) {
294
+ const trackRef = React.useRef(null);
295
+ const [dragging, setDragging] = React.useState(false);
296
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
297
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
298
+ const n = opts.length;
299
+
300
+ // The active value is read by pointer-move handlers attached for the lifetime
301
+ // of a drag β€” ref it so a stale closure doesn't fire onChange for every move.
302
+ const valueRef = React.useRef(value);
303
+ valueRef.current = value;
304
+
305
+ const segAt = (clientX) => {
306
+ const r = trackRef.current.getBoundingClientRect();
307
+ const inner = r.width - 4;
308
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
309
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
310
+ };
311
+
312
+ const onPointerDown = (e) => {
313
+ setDragging(true);
314
+ const v0 = segAt(e.clientX);
315
+ if (v0 !== valueRef.current) onChange(v0);
316
+ const move = (ev) => {
317
+ if (!trackRef.current) return;
318
+ const v = segAt(ev.clientX);
319
+ if (v !== valueRef.current) onChange(v);
320
+ };
321
+ const up = () => {
322
+ setDragging(false);
323
+ window.removeEventListener('pointermove', move);
324
+ window.removeEventListener('pointerup', up);
325
+ };
326
+ window.addEventListener('pointermove', move);
327
+ window.addEventListener('pointerup', up);
328
+ };
329
+
330
+ return (
331
+ <TweakRow label={label}>
332
+ <div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
333
+ className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
334
+ <div className="twk-seg-thumb"
335
+ style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
336
+ width: `calc((100% - 4px) / ${n})` }} />
337
+ {opts.map((o) => (
338
+ <button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
339
+ {o.label}
340
+ </button>
341
+ ))}
342
+ </div>
343
+ </TweakRow>
344
+ );
345
+ }
346
+
347
+ function TweakSelect({ label, value, options, onChange }) {
348
+ return (
349
+ <TweakRow label={label}>
350
+ <select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
351
+ {options.map((o) => {
352
+ const v = typeof o === 'object' ? o.value : o;
353
+ const l = typeof o === 'object' ? o.label : o;
354
+ return <option key={v} value={v}>{l}</option>;
355
+ })}
356
+ </select>
357
+ </TweakRow>
358
+ );
359
+ }
360
+
361
+ function TweakText({ label, value, placeholder, onChange }) {
362
+ return (
363
+ <TweakRow label={label}>
364
+ <input className="twk-field" type="text" value={value} placeholder={placeholder}
365
+ onChange={(e) => onChange(e.target.value)} />
366
+ </TweakRow>
367
+ );
368
+ }
369
+
370
+ function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
371
+ const clamp = (n) => {
372
+ if (min != null && n < min) return min;
373
+ if (max != null && n > max) return max;
374
+ return n;
375
+ };
376
+ const startRef = React.useRef({ x: 0, val: 0 });
377
+ const onScrubStart = (e) => {
378
+ e.preventDefault();
379
+ startRef.current = { x: e.clientX, val: value };
380
+ const decimals = (String(step).split('.')[1] || '').length;
381
+ const move = (ev) => {
382
+ const dx = ev.clientX - startRef.current.x;
383
+ const raw = startRef.current.val + dx * step;
384
+ const snapped = Math.round(raw / step) * step;
385
+ onChange(clamp(Number(snapped.toFixed(decimals))));
386
+ };
387
+ const up = () => {
388
+ window.removeEventListener('pointermove', move);
389
+ window.removeEventListener('pointerup', up);
390
+ };
391
+ window.addEventListener('pointermove', move);
392
+ window.addEventListener('pointerup', up);
393
+ };
394
+ return (
395
+ <div className="twk-num">
396
+ <span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
397
+ <input type="number" value={value} min={min} max={max} step={step}
398
+ onChange={(e) => onChange(clamp(Number(e.target.value)))} />
399
+ {unit && <span className="twk-num-unit">{unit}</span>}
400
+ </div>
401
+ );
402
+ }
403
+
404
+ function TweakColor({ label, value, onChange }) {
405
+ return (
406
+ <div className="twk-row twk-row-h">
407
+ <div className="twk-lbl"><span>{label}</span></div>
408
+ <input type="color" className="twk-swatch" value={value}
409
+ onChange={(e) => onChange(e.target.value)} />
410
+ </div>
411
+ );
412
+ }
413
+
414
+ function TweakButton({ label, onClick, secondary = false }) {
415
+ return (
416
+ <button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
417
+ onClick={onClick}>{label}</button>
418
+ );
419
+ }
420
+
421
+ Object.assign(window, {
422
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
423
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
424
+ TweakText, TweakNumber, TweakColor, TweakButton,
425
+ });