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 */} +
+ {`${instance_name +
+ + {/* 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 }, + }, + }, + }; +});