diff --git a/narratives/.dockerignore b/narratives/.dockerignore
new file mode 100644
index 00000000..00e5f585
--- /dev/null
+++ b/narratives/.dockerignore
@@ -0,0 +1,13 @@
+node_modules
+dist
+.git
+.env
+.env.local
+.env.*.local
+.DS_Store
+Archive.zip
+*.log
+screenshots
+figma_*
+.vite
+coverage
diff --git a/narratives/.gitignore b/narratives/.gitignore
new file mode 100644
index 00000000..4fb71959
--- /dev/null
+++ b/narratives/.gitignore
@@ -0,0 +1,46 @@
+# Dependencies
+node_modules
+
+# Build output
+dist
+*.tsbuildinfo
+
+# Local env (per-developer; never commit)
+# Holds BACKEND_URL / AGENT_URL pointing at a specific deployed instance.
+# Each developer creates their own.
+.env
+.env.local
+.env.*.local
+
+# Logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Vite cache
+.vite
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Editor / IDE
+.vscode
+.idea
+*.swp
+*.swo
+
+# Coverage
+coverage
+
+# Project-specific (preserved from prior .gitignore)
+figma_*
+screenshots
+.gemini
+.figma_pat
+*.py
+sample.js
+
+# Redundant snapshot of the original AI Studio scaffold
+Archive.zip
diff --git a/narratives/index.html b/narratives/index.html
new file mode 100644
index 00000000..55c4c872
--- /dev/null
+++ b/narratives/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Custom DC
+
+
+
+
+
+
+
+
+
diff --git a/narratives/package.json b/narratives/package.json
new file mode 100644
index 00000000..c8b121c0
--- /dev/null
+++ b/narratives/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "react-example",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port=3000 --host=0.0.0.0",
+ "build": "vite build",
+ "preview": "vite preview",
+ "clean": "rm -rf dist",
+ "lint": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@google/genai": "^1.29.0",
+ "@tailwindcss/vite": "^4.1.14",
+ "@vitejs/plugin-react": "^5.0.4",
+ "dotenv": "^17.2.3",
+ "express": "^4.21.2",
+ "lucide-react": "^0.546.0",
+ "motion": "^12.23.24",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-markdown": "^10.1.0",
+ "recharts": "^3.8.1",
+ "remark-gfm": "^4.0.1",
+ "vite": "^6.2.0"
+ },
+ "devDependencies": {
+ "@types/express": "^4.17.21",
+ "@types/node": "^22.14.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "autoprefixer": "^10.4.21",
+ "tailwindcss": "^4.1.14",
+ "tsx": "^4.21.0",
+ "typescript": "~5.8.2",
+ "vite": "^6.2.0"
+ }
+}
diff --git a/narratives/src/App.tsx b/narratives/src/App.tsx
new file mode 100644
index 00000000..8270694c
--- /dev/null
+++ b/narratives/src/App.tsx
@@ -0,0 +1,38 @@
+import Sidebar from "./components/Sidebar";
+import Header from "./components/Header";
+import DataAgent from "./components/DataAgent";
+import MetricsPage from "./components/MetricsPage";
+import DataDownloadTool from "./components/DataDownloadTool";
+import StatVarExplorer from "./components/StatVarExplorer";
+import SessionDrawer from "./components/SessionDrawer";
+import { BrandingProvider } from "./hooks/BrandingContext";
+import { ChatSessionProvider } from "./hooks/ChatSessionContext";
+import { useHashRoute } from "./hooks/useHashRoute";
+
+export default function App() {
+ const [route] = useHashRoute();
+
+ return (
+
+
+
+
+
+
+
+ {route === "metrics" ? (
+
+ ) : route === "download" ? (
+
+ ) : route === "statvar" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
diff --git a/narratives/src/components/Header.tsx b/narratives/src/components/Header.tsx
new file mode 100644
index 00000000..38bb92ae
--- /dev/null
+++ b/narratives/src/components/Header.tsx
@@ -0,0 +1,45 @@
+import { ChevronDown } from "lucide-react";
+import { navConfig } from "../config/navConfig";
+import { useHashRoute } from "../hooks/useHashRoute";
+import { useBrand } from "../hooks/BrandingContext";
+
+export default function Header() {
+ const [route] = useHashRoute();
+ const { logo_url, instance_name } = useBrand();
+ // Empty route → Agent (the default landing view).
+ const activeId = route || "agent";
+
+ return (
+
+ {/* Logo Section */}
+
+

+
+
+ {/* Right Navigation */}
+
+
+ );
+}
diff --git a/narratives/src/components/Sidebar.tsx b/narratives/src/components/Sidebar.tsx
new file mode 100644
index 00000000..00819841
--- /dev/null
+++ b/narratives/src/components/Sidebar.tsx
@@ -0,0 +1,49 @@
+import { Menu, SquarePen } from "lucide-react";
+import { useHashRoute } from "../hooks/useHashRoute";
+import { useChatSession } from "../hooks/ChatSessionContext";
+
+// The left rail itself stays visible across all tabs (same width, same fill)
+// so the page layout doesn't jump as the user navigates — per Adriana's
+// 23 Apr ¶17 direction. Only the two icons (the "accordion" hamburger and
+// the "start new chat" pencil) are tied to the Agent tab; on every other
+// tab the rail renders empty.
+export default function Sidebar() {
+ const [route] = useHashRoute();
+ const { newSession, toggleDrawer, isDrawerOpen } = useChatSession();
+ const isAgent = route === "" || route === "agent";
+
+ return (
+
+ );
+}
diff --git a/narratives/src/config/navConfig.ts b/narratives/src/config/navConfig.ts
new file mode 100644
index 00000000..c20daf17
--- /dev/null
+++ b/narratives/src/config/navConfig.ts
@@ -0,0 +1,21 @@
+export interface NavItem {
+ // Route id — matches the hash route in App.tsx. Empty id is the default
+ // (Agent) view.
+ id: string;
+ label: string;
+ href: string;
+}
+
+// Nav matches Figma node 3427:16789 (`AppbarDataAgent`) of file
+// kQtUhlVo9eCBoeqvdfAwpz — all four tabs in Figma order:
+// Data Agent → Key metrics dashboard → Data Download Tool → Statistical Variable Explorer
+// April 30 ¶52 originally deferred Data Download Tool for MVP. Reinstated
+// per later direction to keep the UI aligned with the Figma source.
+// "Statistical Variable Explorer" replaces the April 30 transcript's
+// auto-caption mis-hear "Start Work Explorer" — Figma is authoritative.
+export const navConfig: NavItem[] = [
+ { id: "agent", label: "Data Agent", href: "#/agent" },
+ { id: "metrics", label: "Key metrics dashboard", href: "#/metrics" },
+ { id: "download", label: "Data Download Tool", href: "#/download" },
+ { id: "statvar", label: "Statistical Variable Explorer", href: "#/statvar" },
+];
diff --git a/narratives/src/hooks/BrandingContext.tsx b/narratives/src/hooks/BrandingContext.tsx
new file mode 100644
index 00000000..9f041d59
--- /dev/null
+++ b/narratives/src/hooks/BrandingContext.tsx
@@ -0,0 +1,17 @@
+import { createContext, useContext, type ReactNode } from "react";
+import { useBranding, DEFAULT_BRAND, type Branding } from "./useBranding";
+
+const BrandingContext = createContext(DEFAULT_BRAND);
+
+export function BrandingProvider({ children }: { children: ReactNode }) {
+ const { branding } = useBranding();
+ return (
+
+ {children}
+
+ );
+}
+
+export function useBrand(): Branding {
+ return useContext(BrandingContext);
+}
diff --git a/narratives/src/hooks/useBranding.ts b/narratives/src/hooks/useBranding.ts
new file mode 100644
index 00000000..988918f2
--- /dev/null
+++ b/narratives/src/hooks/useBranding.ts
@@ -0,0 +1,190 @@
+import { useEffect, useState } from "react";
+
+// ---------------------------------------------------------------------------
+// Metrics composition — config-driven (M-5..M-12 in architecture/ui-tracker.csv)
+// ---------------------------------------------------------------------------
+
+export type MetricsTileType =
+ | "line"
+ | "bar"
+ | "map"
+ | "ranking"
+ | "highlight"
+ | "scatter"
+ | "pie"
+ | "gauge"
+ | "slider";
+
+// A tile maps 1:1 onto a web component. The MetricsPage
+// wrapper shows `title` as its chrome header; everything else passes
+// through as attributes to the DC component. See
+// website/packages/web-components/docs/components/*.md for the per-type
+// required/optional attribute set.
+//
+// Highlight cards are just `type: "highlight"` tiles — they render with the
+// DC component's native light-blue chip styling (no custom gradient wrapper).
+export interface MetricsTile {
+ type: MetricsTileType;
+ title: string;
+ header?: string;
+ variable?: string;
+ variables?: string;
+ place?: string;
+ places?: string;
+ parentPlace?: string;
+ childPlaceType?: string;
+ date?: string;
+ rankingCount?: number;
+ showHighestLowest?: boolean;
+ showLowest?: boolean;
+ showPlaceLabels?: boolean;
+ sort?:
+ | "ascending"
+ | "descending"
+ | "ascendingPopulation"
+ | "descendingPopulation";
+ colors?: string;
+ unit?: string;
+ min?: number;
+ max?: number;
+ startDate?: string;
+ endDate?: string;
+}
+
+export interface MetricsTab {
+ id: string;
+ label: string;
+ tiles: MetricsTile[];
+}
+
+export interface MetricsConfig {
+ tabs: MetricsTab[];
+}
+
+// ---------------------------------------------------------------------------
+// Branding — per-instance config fetched from the bucket at page mount
+// ---------------------------------------------------------------------------
+
+export interface Branding {
+ schema_version?: string;
+ instance_name?: string;
+ logo_url?: string;
+ primary_color?: string;
+ accent_color?: string;
+ font_family?: string;
+ suggestions?: string[];
+ navigation?: Array<{ label: string; href: string }>;
+ footer_text?: string;
+ // When absent, MetricsPage falls back to DEFAULT_METRICS_TABS — see
+ // src/components/MetricsPage.tsx.
+ metrics?: MetricsConfig;
+}
+
+// Bundled fallback used if the agent's /agent/brand endpoint returns an
+// empty URL, or the fetch of branding.json fails. Keep in sync with
+// brands/default/branding.json (the canonical copy lives in the deployment
+// repo so per-instance uploads have a reference schema to copy from).
+export const DEFAULT_BRAND: Branding = {
+ schema_version: "1",
+ instance_name: "Custom Data Commons",
+ logo_url: "",
+ primary_color: "#1A73E8",
+ accent_color: "#34A853",
+ font_family: "Google Sans",
+ suggestions: [
+ "How has average annual wage changed over time in the United States?",
+ "Compare GDP growth across G7 countries",
+ "What is the gender wage gap in OECD countries?",
+ ],
+ navigation: [{ label: "Data Agent", href: "/" }],
+ footer_text: "Powered by Data Commons, an initiative from Google.",
+};
+
+interface BrandConfigResponse {
+ brand_config_url?: string;
+}
+
+// Apply branding values as CSS custom properties so the existing index.css
+// @theme block can pick them up at render time.
+function applyCssVars(b: Branding) {
+ const root = document.documentElement;
+ if (b.primary_color) {
+ root.style.setProperty("--brand-primary", b.primary_color);
+ }
+ if (b.accent_color) {
+ root.style.setProperty("--brand-accent", b.accent_color);
+ }
+ if (b.font_family) {
+ root.style.setProperty("--brand-font", b.font_family);
+ }
+}
+
+export function useBranding(): { branding: Branding; loaded: boolean; error: string | null } {
+ const [branding, setBranding] = useState(DEFAULT_BRAND);
+ const [loaded, setLoaded] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function load() {
+ try {
+ // 1. Ask the agent sidecar where this instance's branding lives.
+ const brandResp = await fetch("/agent/brand", {
+ // 15s: cross-origin GCS fetches on a cold connection can take
+ // 5-10s — earlier 5s timeout was firing falsely, falling back to
+ // DEFAULT_BRAND even when the bucket eventually responded 200.
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!brandResp.ok) {
+ throw new Error(`/agent/brand HTTP ${brandResp.status}`);
+ }
+ const { brand_config_url } =
+ (await brandResp.json()) as BrandConfigResponse;
+ if (!brand_config_url) {
+ // Configured intentionally empty => use DEFAULT_BRAND.
+ if (!cancelled) {
+ applyCssVars(DEFAULT_BRAND);
+ setLoaded(true);
+ }
+ return;
+ }
+
+ // 2. Fetch branding.json from the per-instance directory.
+ const bResp = await fetch(`${brand_config_url}/branding.json`, {
+ // 15s: cross-origin GCS fetches on a cold connection can take
+ // 5-10s — earlier 5s timeout was firing falsely, falling back to
+ // DEFAULT_BRAND even when the bucket eventually responded 200.
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!bResp.ok) {
+ throw new Error(
+ `branding.json HTTP ${bResp.status} from ${brand_config_url}`,
+ );
+ }
+ const fetched = (await bResp.json()) as Branding;
+ const merged = { ...DEFAULT_BRAND, ...fetched };
+ if (!cancelled) {
+ setBranding(merged);
+ applyCssVars(merged);
+ setLoaded(true);
+ }
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ if (!cancelled) {
+ console.warn(`[branding] falling back to DEFAULT_BRAND: ${msg}`);
+ applyCssVars(DEFAULT_BRAND);
+ setError(msg);
+ setLoaded(true);
+ }
+ }
+ }
+
+ void load();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return { branding, loaded, error };
+}
diff --git a/narratives/src/hooks/useHashRoute.ts b/narratives/src/hooks/useHashRoute.ts
new file mode 100644
index 00000000..dde39ef7
--- /dev/null
+++ b/narratives/src/hooks/useHashRoute.ts
@@ -0,0 +1,32 @@
+import { useEffect, useState } from "react";
+
+// Lightweight hash-based router. We deliberately avoid react-router-dom
+// (the codebase intentionally has no router — see implementation-reference.md
+// §7.3). Hash navigation works without server-side rewrites and survives
+// reloads, while the nginx SPA fallback already handles deep paths.
+//
+// Usage:
+// const [route] = useHashRoute(); // "" | "metrics" | "explorer" | ...
+// Metrics // browsers handle the navigation
+//
+// Empty hash (`""` / `"#"` / `"#/"`) → "" (treat as default / agent).
+
+function readRoute(): string {
+ if (typeof window === "undefined") {
+ return "";
+ }
+ const path = window.location.hash.replace(/^#\/?/, "");
+ return path.split("?")[0];
+}
+
+export function useHashRoute(): [string] {
+ const [route, setRoute] = useState(readRoute);
+
+ useEffect(() => {
+ const onChange = () => setRoute(readRoute());
+ window.addEventListener("hashchange", onChange);
+ return () => window.removeEventListener("hashchange", onChange);
+ }, []);
+
+ return [route];
+}
diff --git a/narratives/src/index.css b/narratives/src/index.css
new file mode 100644
index 00000000..e79baf78
--- /dev/null
+++ b/narratives/src/index.css
@@ -0,0 +1,225 @@
+@import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Text:wght@400;500;700&family=Roboto:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap');
+@import "tailwindcss";
+
+/*
+ * Single source of truth for the design system.
+ *
+ * Colors registered here as `--color-*` automatically generate Tailwind v4
+ * utility classes (e.g. `--color-teal` → `bg-teal`, `text-teal`, `border-teal`,
+ * `hover:bg-teal`, etc.) AND are available as CSS custom properties for use
+ * inside `var()`. Edit a value here and every consumer updates.
+ *
+ * Names mirror the Figma GM3 token vocabulary where possible.
+ */
+@theme {
+ --font-sans: "Google Sans Text", "Inter", system-ui, sans-serif;
+ --font-display: "Google Sans", "Roboto", system-ui, sans-serif;
+
+ /* Brand */
+ --color-teal: #175C75;
+ --color-brand-green: #65A782;
+ --color-brand-teal: #6FAEC0;
+ --color-google-green: #0f9d58;
+ --color-brand-primary: var(--brand-primary, #1A73E8);
+ --color-brand-accent: var(--brand-accent, #34A853);
+
+ /* Surfaces */
+ --color-surface-soft: #F9F9F9;
+ --color-surface-blue: #f0f4f9;
+ --color-user-msg: #E8F2F4;
+ --color-button-hover: #e1e5eb;
+
+ /* Lines */
+ --color-line: #C4C7C5;
+
+ /* Text — primary */
+ --color-on-surface: #1B1C1D;
+ --color-on-surface-variant: #444746;
+ --color-muted: #575B5F;
+ --color-subtle: #747775;
+ --color-placeholder: #8E918F;
+
+ /* Text — table */
+ --color-table-data: #191C1B;
+ --color-table-header: #3F4946;
+ --color-table-body: #212529;
+}
+
+:root {
+ /* Typography tokens — sourced from Figma GM3/Static + Type scale globals.
+ Consumed by the .text-* utility classes below. */
+ --Static-Display-Small-Font: var(--brand-font, "Google Sans");
+ --Static-Display-Small-Size: 32px;
+ --Static-Display-Small-Line-Height: 36px;
+
+ --Static-Title-Large-Font: var(--brand-font, "Google Sans");
+ --Static-Title-Large-Size: 22px;
+ --Static-Title-Large-Line-Height: 28px;
+
+ --Static-Body-Large-Font: var(--brand-font, "Google Sans Text");
+ --Static-Body-Large-Size: 16px;
+ --Static-Body-Large-Line-Height: 24px;
+
+ --Static-Body-Medium-Font: var(--brand-font, "Google Sans Text");
+ --Static-Body-Medium-Size: 14px;
+ --Static-Body-Medium-Line-Height: 20px;
+
+ --Static-Label-Large-Font: var(--brand-font, "Google Sans Text");
+ --Static-Label-Large-Size: 14px;
+ --Static-Label-Large-Line-Height: 20px;
+
+ --Static-Label-Small-Font: var(--brand-font, "Google Sans Text");
+ --Static-Label-Small-Size: 11px;
+ --Static-Label-Small-Line-Height: 16px;
+
+ --Static-Caption-Font: var(--brand-font, "Google Sans Text");
+ --Static-Caption-Size: 12px;
+ --Static-Caption-Line-Height: 16px;
+}
+
+body {
+ font-family: var(--font-sans);
+ background-color: var(--color-white);
+ color: var(--color-on-surface);
+ margin: 0;
+ padding: 0;
+}
+
+.no-scrollbar::-webkit-scrollbar {
+ display: none;
+}
+.no-scrollbar {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+}
+
+.text-display-small {
+ font-family: var(--Static-Display-Small-Font, "Google Sans");
+ font-size: var(--Static-Display-Small-Size, 32px);
+ font-style: normal;
+ font-weight: 500;
+ line-height: var(--Static-Display-Small-Line-Height, 36px);
+ letter-spacing: 0;
+}
+
+.text-title-large-emphasized {
+ font-family: var(--Static-Title-Large-Font, "Google Sans");
+ font-size: var(--Static-Title-Large-Size, 22px);
+ font-style: normal;
+ font-weight: 500;
+ line-height: var(--Static-Title-Large-Line-Height, 28px);
+ letter-spacing: 0;
+}
+
+.text-body-large {
+ font-family: var(--Static-Body-Large-Font, "Google Sans Text");
+ font-size: var(--Static-Body-Large-Size, 16px);
+ font-style: normal;
+ font-weight: 400;
+ line-height: var(--Static-Body-Large-Line-Height, 24px);
+ letter-spacing: 0;
+}
+
+.text-body-large-emphasized {
+ font-family: var(--Static-Body-Large-Font, "Google Sans Text");
+ font-size: var(--Static-Body-Large-Size, 16px);
+ font-style: normal;
+ font-weight: 500;
+ line-height: var(--Static-Body-Large-Line-Height, 24px);
+ letter-spacing: 0;
+}
+
+.text-body-medium {
+ font-family: var(--Static-Body-Medium-Font, "Google Sans Text");
+ font-size: var(--Static-Body-Medium-Size, 14px);
+ font-style: normal;
+ font-weight: 400;
+ line-height: var(--Static-Body-Medium-Line-Height, 20px);
+ letter-spacing: 0;
+}
+
+.text-label-large {
+ font-family: var(--Static-Label-Large-Font, "Google Sans Text");
+ font-size: var(--Static-Label-Large-Size, 14px);
+ font-style: normal;
+ font-weight: 500;
+ line-height: var(--Static-Label-Large-Line-Height, 20px);
+ letter-spacing: 0;
+}
+
+.text-label-large-emphasized {
+ font-family: var(--Static-Label-Large-Font, "Google Sans Text");
+ font-size: var(--Static-Label-Large-Size, 14px);
+ font-style: normal;
+ font-weight: 700;
+ line-height: var(--Static-Label-Large-Line-Height, 20px);
+ letter-spacing: 0;
+}
+
+.text-label-small {
+ font-family: var(--Static-Label-Small-Font, "Google Sans Text");
+ font-size: var(--Static-Label-Small-Size, 11px);
+ font-style: normal;
+ font-weight: 500;
+ line-height: var(--Static-Label-Small-Line-Height, 16px);
+ letter-spacing: 0;
+}
+
+.text-caption {
+ font-family: var(--Static-Caption-Font, "Google Sans Text");
+ font-size: var(--Static-Caption-Size, 12px);
+ font-style: normal;
+ font-weight: 400;
+ line-height: var(--Static-Caption-Line-Height, 16px);
+ letter-spacing: 0;
+}
+
+.text-display-small-gradient {
+ font-family: var(--Static-Display-Small-Font, "Google Sans");
+ font-size: var(--Static-Display-Small-Size, 32px);
+ font-style: normal;
+ font-weight: 500;
+ line-height: var(--Static-Display-Small-Line-Height, 36px);
+ letter-spacing: 0;
+ background: linear-gradient(4deg, var(--color-brand-green) 0%, var(--color-brand-teal) 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ color: transparent;
+}
+
+/* ────────────────────────────────────────────────────────────────────
+ * Print stylesheet: when ExportPdfButton triggers window.print(),
+ * `body[data-print-target]` is set; the AnswerPanel keeps rendering
+ * while everything else (sidebar, header, chat input) is hidden.
+ * ──────────────────────────────────────────────────────────────────── */
+@media print {
+ body[data-print-target] aside,
+ body[data-print-target] header,
+ body[data-print-target] nav,
+ body[data-print-target] [data-non-print="true"] {
+ display: none !important;
+ }
+ body[data-print-target] {
+ background: #ffffff !important;
+ }
+ body[data-print-target] #answer-panel {
+ box-shadow: none !important;
+ border: 0 !important;
+ background: #ffffff !important;
+ }
+ /* Avoid splitting a chart card or table across pages where possible */
+ .dc-chart-card,
+ .markdown-body table,
+ .markdown-body h1,
+ .markdown-body h2,
+ .markdown-body h3 {
+ break-inside: avoid;
+ }
+}
+
+@keyframes reasoning-spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
diff --git a/narratives/src/main.tsx b/narratives/src/main.tsx
new file mode 100644
index 00000000..080dac37
--- /dev/null
+++ b/narratives/src/main.tsx
@@ -0,0 +1,10 @@
+import {StrictMode} from 'react';
+import {createRoot} from 'react-dom/client';
+import App from './App.tsx';
+import './index.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/narratives/src/types/data-commons-elements.d.ts b/narratives/src/types/data-commons-elements.d.ts
new file mode 100644
index 00000000..13829d8b
--- /dev/null
+++ b/narratives/src/types/data-commons-elements.d.ts
@@ -0,0 +1,70 @@
+// Type declarations for Data Commons web components loaded via
+// https://datacommons.org/datacommons.js.
+//
+// At runtime these are plain HTMLElements. We augment React's existing JSX
+// namespace so TypeScript allows the documented attribute set without
+// stripping the standard HTML elements (div/span/p/etc.).
+//
+// Attribute names mirror what the upstream VM template
+// (server/templates/custom_dc/custom/homepage.html, renderDCComponent)
+// passes to each element.
+
+import type { DetailedHTMLProps, HTMLAttributes } from "react";
+
+type DCAttrs = DetailedHTMLProps, HTMLElement> & {
+ // Common
+ apiroot?: string;
+ header?: string;
+ date?: string;
+ colors?: string;
+
+ // Plural (line / bar / pie / scatter / etc.)
+ variables?: string;
+ places?: string;
+
+ // Singular (map / ranking / gauge / highlight / slider)
+ variable?: string;
+ place?: string;
+
+ // Geographic child enumeration
+ parentPlace?: string;
+ childPlaceType?: string;
+
+ // Bar
+ sort?: "ascending" | "descending";
+
+ // Map / slider
+ allowZoom?: boolean | "" | "true" | "false";
+
+ // Pie
+ donut?: boolean | "" | "true" | "false";
+
+ // Ranking
+ rankingCount?: string | number;
+ showHighestLowest?: boolean | "" | "true" | "false";
+
+ // Gauge
+ min?: string | number;
+ max?: string | number;
+
+ // Scatter
+ showPlaceLabels?: boolean | "" | "true" | "false";
+};
+
+declare module "react" {
+ namespace JSX {
+ interface IntrinsicElements {
+ "datacommons-line": DCAttrs;
+ "datacommons-bar": DCAttrs;
+ "datacommons-pie": DCAttrs;
+ "datacommons-map": DCAttrs;
+ "datacommons-highlight": DCAttrs;
+ "datacommons-ranking": DCAttrs;
+ "datacommons-gauge": DCAttrs;
+ "datacommons-scatter": DCAttrs;
+ "datacommons-slider": DCAttrs;
+ }
+ }
+}
+
+export {};
diff --git a/narratives/tsconfig.json b/narratives/tsconfig.json
new file mode 100644
index 00000000..ed75ba5a
--- /dev/null
+++ b/narratives/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "experimentalDecorators": true,
+ "useDefineForClassFields": false,
+ "module": "ESNext",
+ "lib": [
+ "ES2022",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "allowJs": true,
+ "jsx": "react-jsx",
+ "paths": {
+ "@/*": [
+ "./*"
+ ]
+ },
+ "allowImportingTsExtensions": true,
+ "strict": true,
+ "noEmit": true
+ }
+}
diff --git a/narratives/vite.config.ts b/narratives/vite.config.ts
new file mode 100644
index 00000000..4fc5db7d
--- /dev/null
+++ b/narratives/vite.config.ts
@@ -0,0 +1,48 @@
+import tailwindcss from '@tailwindcss/vite';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+import {defineConfig, loadEnv} from 'vite';
+
+export default defineConfig(({mode}) => {
+ const env = loadEnv(mode, '.', '');
+ return {
+ plugins: [react(), tailwindcss()],
+ define: {},
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, '.'),
+ },
+ },
+ server: {
+ port: 3000,
+ host: '0.0.0.0',
+ // HMR is disabled in AI Studio via DISABLE_HMR env var.
+ // Do not modify - file watching is disabled to prevent flickering during agent edits.
+ hmr: process.env.DISABLE_HMR !== 'true',
+ // Path A (UI + remote backend): set BACKEND_URL and AGENT_URL in .env.local.
+ // Path B (full local stack): leave them unset; defaults target localhost.
+ // server.proxy is dev-only - `vite build` ignores it, so production is unaffected.
+ proxy: {
+ '/api': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/mcp': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/place': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/browser': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/explore': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/core': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/agent': { target: env.AGENT_URL || 'http://localhost:5001', changeOrigin: true },
+ // /tools/* paths host upstream Flask-rendered pages we iframe — currently
+ // /tools/download. /css, /custom_dc, /queryStore.js, /base.js, /download.js
+ // and friends are the absolute-path assets those pages reference. All
+ // routed to BACKEND_URL so the iframe behaves identically in dev and in
+ // the baked production image (where nginx covers the same paths).
+ '/tools': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/css': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/custom_dc': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/queryStore.js': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/base.js': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/download.js': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ '/stat_var.js': { target: env.BACKEND_URL || 'http://localhost:8080', changeOrigin: true },
+ },
+ },
+ };
+});