diff --git a/app/next.config.ts b/app/next.config.ts
new file mode 100644
index 0000000..18eb3fc
--- /dev/null
+++ b/app/next.config.ts
@@ -0,0 +1,11 @@
+import type { NextConfig } from "next";
+
+// Proxy /api/* to the local Bun/Node backend (slice 1f) so the SPA and backend share an
+// origin — session cookies work without cross-origin/CORS gymnastics (DESIGN.md §2.1).
+const nextConfig: NextConfig = {
+ rewrites: async () => [
+ { source: "/api/:path*", destination: "http://localhost:3001/api/:path*" },
+ ],
+};
+
+export default nextConfig;
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 0000000..32ba107
--- /dev/null
+++ b/app/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "investor-flow",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint",
+ "test": "node --test --experimental-strip-types \"src/**/*.test.ts\""
+ },
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.17",
+ "@radix-ui/react-dropdown-menu": "^2.1.18",
+ "@radix-ui/react-label": "^2.1.10",
+ "@radix-ui/react-select": "^2.3.1",
+ "@radix-ui/react-switch": "^1.3.1",
+ "@radix-ui/react-tabs": "^1.1.15",
+ "@radix-ui/react-toast": "^1.2.17",
+ "lucide-react": "^1.21.0",
+ "next": "16.2.9",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "recharts": "^3.9.0",
+ "zustand": "^5.0.14"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "eslint": "^9",
+ "eslint-config-next": "16.2.9",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ }
+}
diff --git a/app/src/__tests__/primary-rule-lint.test.ts b/app/src/__tests__/primary-rule-lint.test.ts
new file mode 100644
index 0000000..35a4fc8
--- /dev/null
+++ b/app/src/__tests__/primary-rule-lint.test.ts
@@ -0,0 +1,48 @@
+import { test } from "node:test";
+import { strict as assert } from "node:assert";
+import { readFileSync } from "node:fs";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { UI_STRING_LIST } from "../lib/strings.ts";
+
+// ADR-0007 golden master (DESIGN.md §7.3): no imperative trade directive in any curated UI
+// string or panel/page/client source. Stoplist of imperatives; "buy-zone estimate" (a noun) is allowed.
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const STOPWORDS = ["buy", "sell", "you should", "add to your", "rotate into", "action needed"];
+const ALLOWED_PHRASES = ["buy-zone"];
+
+function imperativeHits(text: string): string[] {
+ const hits: string[] = [];
+ for (const w of STOPWORDS) {
+ const re = new RegExp("\\b" + w.replace(/\s+/g, "\\s+") + "\\b", "gi");
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(text)) !== null) {
+ const ctx = text.slice(Math.max(0, m.index - 14), m.index + w.length + 14).replace(/\s+/g, " ").trim();
+ if (ALLOWED_PHRASES.some((a) => ctx.toLowerCase().includes(a))) continue;
+ hits.push(`"${w}" near: …${ctx}…`);
+ }
+ }
+ return hits;
+}
+
+test("curated UI strings pass the Primary-Rule lint", () => {
+ for (const s of UI_STRING_LIST) {
+ assert.deepEqual(imperativeHits(s), [], `Primary-Rule violation in string: "${s}"`);
+ }
+});
+
+test("panel/page/client source pass the Primary-Rule lint", () => {
+ const files = [
+ "../components/OverviewPanel.tsx",
+ "../app/page.tsx",
+ "../app/layout.tsx",
+ "../lib/strings.ts",
+ "../lib/trpc.ts",
+ "../stores/active-symbol-store.ts",
+ ];
+ for (const f of files) {
+ const src = readFileSync(join(__dirname, f), "utf8");
+ const hits = imperativeHits(src);
+ assert.deepEqual(hits, [], `Primary-Rule violation in ${f}: ${hits.join("; ")}`);
+ }
+});
diff --git a/app/src/app/globals.css b/app/src/app/globals.css
new file mode 100644
index 0000000..7bc0b5d
--- /dev/null
+++ b/app/src/app/globals.css
@@ -0,0 +1,150 @@
+@import "tailwindcss";
+
+:root {
+ --bg-primary: #0a0b0f;
+ --bg-secondary: #121318;
+ --bg-card: #1a1b24;
+ --bg-input: #22232f;
+ --border-color: #2a2b3a;
+ --border-focus: #4f8cff;
+ --text-primary: #e6e7ec;
+ --text-secondary: #8a8b9a;
+ --text-muted: #5a5b6a;
+ --accent-blue: #4f8cff;
+ --accent-green: #34d399;
+ --accent-red: #ef4444;
+ --accent-yellow: #fbbf24;
+ --accent-purple: #a78bfa;
+}
+
+* { box-sizing: border-box; }
+
+html, body, #__next {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+::-webkit-scrollbar { width: 6px; }
+::-webkit-scrollbar-track { background: var(--bg-secondary); }
+::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
+
+/* Focus ring for accessibility */
+:focus-visible {
+ outline: 2px solid var(--border-focus);
+ outline-offset: 2px;
+}
+
+/* Utility: card */
+.card {
+ background-color: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 16px;
+}
+
+/* Utility: button */
+.btn-primary {
+ background-color: var(--accent-blue);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ padding: 8px 16px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+.btn-primary:hover { opacity: 0.85; }
+
+.btn-secondary {
+ background-color: var(--bg-input);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 8px 16px;
+ font-size: 13px;
+ cursor: pointer;
+}
+.btn-secondary:hover { background-color: var(--border-color); }
+
+.btn-danger {
+ background-color: transparent;
+ color: var(--accent-red);
+ border: 1px solid var(--accent-red);
+ border-radius: 6px;
+ padding: 4px 10px;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+/* Input */
+.input {
+ background-color: var(--bg-input);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ color: var(--text-primary);
+ padding: 8px 12px;
+ font-size: 13px;
+ width: 100%;
+}
+.input:focus {
+ border-color: var(--border-focus);
+ outline: none;
+}
+
+/* Badge */
+.badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+/* Layout helper */
+.page-section {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.section-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.label {
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 6px;
+}
+
+/* Tier colors */
+.tier-A_STAR { background: #064e3b; color: #34d399; }
+.tier-A { background: #065f46; color: #6ee7b7; }
+.tier-B { background: #3730a3; color: #a5b4fc; }
+.tier-C { background: #78350f; color: #fcd34d; }
diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx
new file mode 100644
index 0000000..b02d753
--- /dev/null
+++ b/app/src/app/layout.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from "next";
+import "./globals.css";
+import { UI_STRINGS } from "@/lib/strings";
+
+export const metadata: Metadata = {
+ title: `${UI_STRINGS.appTitle} — ${UI_STRINGS.appSubtitle}`,
+ description: UI_STRINGS.appSubtitle,
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {UI_STRINGS.appTitle}
+ {UI_STRINGS.appSubtitle}
+
+ {children}
+
+
+ );
+}
diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx
new file mode 100644
index 0000000..5a40493
--- /dev/null
+++ b/app/src/app/page.tsx
@@ -0,0 +1,78 @@
+"use client";
+import { useEffect, useState } from "react";
+import { OverviewPanel } from "@/components/OverviewPanel";
+import { useActiveSymbol } from "@/stores/active-symbol-store";
+import { api, type AuthUser } from "@/lib/trpc";
+import { UI_STRINGS } from "@/lib/strings";
+
+export default function Page() {
+ const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
+ const setActive = useActiveSymbol((s) => s.setActive);
+ const [draft, setDraft] = useState(activeSymbol);
+
+ return (
+
+ );
+}
+
+function AuthSection() {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [user, setUser] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []);
+
+ const submit = async (mode: "signup" | "login") => {
+ setBusy(true); setError(null);
+ try {
+ await api.auth[mode](email, password);
+ setUser(await api.auth.me());
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "request failed");
+ } finally { setBusy(false); }
+ };
+
+ if (user) {
+ return (
+
+ Signed in as {user.email}
+ Complexity: {user.complexity} · Risk tolerance: {user.riskTolerance}
+
+
+ );
+ }
+ return (
+
+ );
+}
+
+function PrimaryRuleFooter() {
+ return (
+
+ );
+}
diff --git a/app/src/components/OverviewPanel.tsx b/app/src/components/OverviewPanel.tsx
new file mode 100644
index 0000000..70a26d6
--- /dev/null
+++ b/app/src/components/OverviewPanel.tsx
@@ -0,0 +1,96 @@
+"use client";
+import { useEffect, useState } from "react";
+import { useActiveSymbol } from "@/stores/active-symbol-store";
+import { api, type Snapshot } from "@/lib/trpc";
+import { UI_STRINGS } from "@/lib/strings";
+import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
+
+// M1 Symbol Overview (DESIGN.md §4). Reads market.snapshot from cache; polls so the
+// stale-while-revalidate background refresh shows up once the cache fills. P7: the day-change
+// uses shape (▲/▼) + color + a text label — never color alone (G3). No imperative verbs (ADR-0007).
+export function OverviewPanel() {
+ const symbol = useActiveSymbol((s) => s.activeSymbol);
+ const [snap, setSnap] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true); setError(null);
+ const load = () => api.market.snapshot(symbol)
+ .then((s) => { if (!cancelled) { setSnap(s); setLoading(false); } })
+ .catch((e) => { if (!cancelled) { setError(e instanceof Error ? e.message : "request failed"); setLoading(false); } });
+ load();
+ const t = setInterval(load, 5000); // poll for SWR refresh
+ return () => { cancelled = true; clearInterval(t); };
+ }, [symbol]);
+
+ const q = snap?.quote ?? null;
+ const sector = snap?.sector ?? null;
+ const candles = snap?.candles ?? null;
+ const isCrypto = sector?.tickerKind === "crypto";
+ const up = (q?.change ?? 0) >= 0;
+ const spark = (candles ?? []).map((c, i) => ({ i, c: c.c }));
+
+ return (
+
+
+
+ {loading && !snap ? (
+ {UI_STRINGS.loadingSnapshot}
+ ) : error ? (
+ {error}
+ ) : !q ? (
+ {UI_STRINGS.noData}
+ ) : (
+
+
+
+
{UI_STRINGS.priceLabel}
+
${q.price?.toFixed(2)}
+
+
+
{UI_STRINGS.dayChangeLabel}
+
+ {up ? "▲" : "▼"}
+ {up ? "+" : ""}{q.change?.toFixed(2)} ({up ? "+" : ""}{(q.changePercent ?? 0).toFixed(2)}%)
+
+
+
+
+ {spark.length > 1 && (
+
+
+
+
+
+
+
+
+
+ {UI_STRINGS.sparklineCaption}
+
+ )}
+
+ {sector && (
+
+ - {UI_STRINGS.sectorLabel}
- {sector.sector ?? "—"}
+ - {UI_STRINGS.industryLabel}
- {sector.industry ?? "—"}
+ - {UI_STRINGS.tickerKindLabel}
- {sector.tickerKind}
+
+ )}
+
+ {isCrypto &&
{UI_STRINGS.cryptoNotice}
}
+
+ )}
+
+ );
+}
diff --git a/app/src/lib/strings.ts b/app/src/lib/strings.ts
new file mode 100644
index 0000000..5233c30
--- /dev/null
+++ b/app/src/lib/strings.ts
@@ -0,0 +1,29 @@
+// Curated user-facing strings (P6 plain English; ADR-0007 Primary-Rule compliant).
+// The Primary-Rule lint test (src/__tests__/primary-rule-lint.test.ts) scans these and the
+// panel/page source for imperative trade verbs (a stoplist of directives). Approved noun
+// phrases such as the buy-zone estimate are allowed.
+export const UI_STRINGS = {
+ appTitle: "Investor Flow",
+ appSubtitle: "Educational investment research terminal",
+ symbolPlaceholder: "Enter a symbol (e.g. NVDA)",
+ loadingSnapshot: "Loading cached snapshot…",
+ noData: "No cached data yet — the backend is fetching this in the background.",
+ priceLabel: "Last price",
+ dayChangeLabel: "Day change",
+ sectorLabel: "Sector",
+ industryLabel: "Industry",
+ tickerKindLabel: "Type",
+ sparklineCaption: "Recent daily close — context for where price has been, not a signal to act on.",
+ cryptoNotice: "Crypto — limited research module (SEC-derived ownership and filings do not apply).",
+ staleBadge: "cached — refreshing",
+ primaryRuleFooter:
+ "Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.",
+ authEmailLabel: "Email",
+ authPasswordLabel: "Password",
+ signupButton: "Create account",
+ loginButton: "Sign in",
+ logoutButton: "Sign out",
+ signupPrompt: "Create an account to track symbols and keep your research in one place.",
+} as const;
+
+export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS);
diff --git a/app/src/lib/trpc.ts b/app/src/lib/trpc.ts
new file mode 100644
index 0000000..3ac190a
--- /dev/null
+++ b/app/src/lib/trpc.ts
@@ -0,0 +1,83 @@
+// Thin typed client over the tRPC backend (DESIGN.md §2). Slice 1 uses a fetch wrapper
+// rather than @trpc/client to keep the Next app decoupled from the server's Node-native
+// type graph; the server is a real tRPC server and full end-to-end client types can be
+// layered later without changing this surface.
+export interface Quote {
+ symbol: string;
+ price: number;
+ bid?: number | null;
+ ask?: number | null;
+ change?: number | null;
+ changePercent?: number | null;
+ iv?: number | null;
+}
+export interface PriceCandle {
+ ts: string;
+ o: number;
+ h: number;
+ l: number;
+ c: number;
+ v: number;
+ adjClose?: number | null;
+}
+export interface SymbolMeta {
+ symbol: string;
+ name?: string | null;
+ sector?: string | null;
+ industry?: string | null;
+ exchange?: string | null;
+ tickerKind: string;
+ peers?: string[] | null;
+}
+export interface Snapshot {
+ symbol: string;
+ quote: Quote | null;
+ candles: PriceCandle[] | null;
+ sector: SymbolMeta | null;
+ stale: { quote: boolean; candles: boolean; sector: boolean };
+}
+export interface AuthUser {
+ userId: string;
+ email?: string;
+ complexity?: string;
+ riskTolerance?: string;
+ convexityPosture?: string;
+}
+
+async function parse(res: Response): Promise {
+ const json = await res.json();
+ if (json?.error) {
+ const msg = json.error.message ?? `tRPC error ${json.error.data?.code ?? ""}`;
+ throw new Error(typeof msg === "string" ? msg : "tRPC error");
+ }
+ return json.result.data as T;
+}
+
+async function trpcQuery(procedure: string, input?: unknown): Promise {
+ const search = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : "";
+ const res = await fetch(`/api/trpc/${procedure}${search}`, {
+ headers: { "content-type": "application/json" },
+ credentials: "include",
+ });
+ return parse(res);
+}
+
+async function trpcMutate(procedure: string, input: unknown): Promise {
+ const res = await fetch(`/api/trpc/${procedure}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify(input),
+ });
+ return parse(res);
+}
+
+export const api = {
+ market: { snapshot: (symbol: string) => trpcQuery("market.snapshot", { symbol }) },
+ auth: {
+ signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }),
+ login: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.login", { email, password }),
+ logout: () => trpcMutate<{ ok: boolean }>("auth.logout", {}),
+ me: () => trpcQuery("auth.me"),
+ },
+};
diff --git a/app/src/stores/active-symbol-store.ts b/app/src/stores/active-symbol-store.ts
new file mode 100644
index 0000000..f2c48b1
--- /dev/null
+++ b/app/src/stores/active-symbol-store.ts
@@ -0,0 +1,13 @@
+import { create } from "zustand";
+
+// Single-page active-symbol signal (DESIGN.md §4): the shell holds the active symbol in
+// Zustand; switching it rehydrates panels from cache with no route change.
+interface ActiveSymbolState {
+ activeSymbol: string; // slice 1 default hardcoded to NVDA
+ setActive: (s: string) => void;
+}
+
+export const useActiveSymbol = create((set) => ({
+ activeSymbol: "NVDA",
+ setActive: (s) => set({ activeSymbol: s.toUpperCase().trim() || "NVDA" }),
+}));