docs: drop in design handoff bundle (Findings v0.4.4)
Browse filesVendors 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 +39 -0
- docs/design_handoff/README.md +337 -0
- docs/design_handoff/design_files/Riprap Landing Variants.html +34 -0
- docs/design_handoff/design_files/Riprap Landing.html +339 -0
- docs/design_handoff/design_files/Riprap Stone-Grouped UI v0.4.4.html +369 -0
- docs/design_handoff/design_files/briefing.jsx +312 -0
- docs/design_handoff/design_files/design-canvas.jsx +936 -0
- docs/design_handoff/design_files/evidence.jsx +257 -0
- docs/design_handoff/design_files/findings.jsx +848 -0
- docs/design_handoff/design_files/glyphs.jsx +126 -0
- docs/design_handoff/design_files/landing-variants.css +89 -0
- docs/design_handoff/design_files/landing-variants.jsx +266 -0
- docs/design_handoff/design_files/map.jsx +211 -0
- docs/design_handoff/design_files/shell.jsx +140 -0
- docs/design_handoff/design_files/stone-evidence.jsx +140 -0
- docs/design_handoff/design_files/stones-trace.jsx +164 -0
- docs/design_handoff/design_files/styles.css +1122 -0
- docs/design_handoff/design_files/tokens.css +151 -0
- docs/design_handoff/design_files/trace.jsx +166 -0
- docs/design_handoff/design_files/tweaks-panel.jsx +425 -0
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 |
+
});
|