Cukinator commited on
Commit
bc1328f
Β·
1 Parent(s): db163ae

Deploy Frontend2 to Space

Browse files
Meridian.html ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Meridian β€” Project Manager</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=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;1,9..144,400&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="styles.css" />
11
+ <link rel="stylesheet" href="vanguard.css" />
12
+ </head>
13
+ <body>
14
+ <div id="root"></div>
15
+
16
+ <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
17
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
18
+ <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
19
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
20
+
21
+ <script type="text/babel" src="icons.jsx"></script>
22
+ <script type="text/babel" src="data.jsx"></script>
23
+ <script src="mock-api.jsx"></script>
24
+ <script type="text/babel" src="api-client.jsx"></script>
25
+ <script type="text/babel" src="ai-engine.jsx"></script>
26
+ <script type="text/babel" src="interactions.jsx"></script>
27
+ <script type="text/babel" src="detail-modals.jsx"></script>
28
+ <script type="text/babel" src="shell.jsx"></script>
29
+ <script type="text/babel" src="views-main.jsx"></script>
30
+ <script type="text/babel" src="views-detail.jsx"></script>
31
+ <script type="text/babel" src="views-more.jsx"></script>
32
+ <script type="text/babel" src="views-settings.jsx"></script>
33
+ <script type="text/babel" src="views-chat.jsx"></script>
34
+ <script type="text/babel" src="views-code.jsx"></script>
35
+ <script type="text/babel" src="views-compute.jsx"></script>
36
+ <script type="text/babel" src="views-ai-marketplace.jsx"></script>
37
+ <script type="text/babel" src="views-nexus.jsx"></script>
38
+
39
+ <script type="text/babel">
40
+ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
41
+ "accent": "rose",
42
+ "theme": "dark",
43
+ "density": "relaxed",
44
+ "sidebar": "expanded",
45
+ "cardStyle": "detailed"
46
+ }/*EDITMODE-END*/;
47
+
48
+ const ACCENT_MAP = {
49
+ lime: { c: "oklch(0.85 0.17 145)", dim: "oklch(0.40 0.10 145)", soft: "oklch(0.30 0.06 145)", fg: "oklch(0.20 0.05 145)" },
50
+ cyan: { c: "oklch(0.78 0.13 220)", dim: "oklch(0.40 0.09 220)", soft: "oklch(0.30 0.06 220)", fg: "oklch(0.18 0.04 220)" },
51
+ violet: { c: "oklch(0.72 0.18 300)", dim: "oklch(0.40 0.10 300)", soft: "oklch(0.30 0.07 300)", fg: "oklch(0.98 0.03 300)" },
52
+ amber: { c: "oklch(0.80 0.14 75)", dim: "oklch(0.44 0.09 75)", soft: "oklch(0.30 0.06 75)", fg: "oklch(0.20 0.04 75)" },
53
+ rose: { c: "oklch(0.72 0.17 20)", dim: "oklch(0.40 0.10 20)", soft: "oklch(0.30 0.07 20)", fg: "oklch(0.98 0.03 20)" },
54
+ };
55
+
56
+ function App() {
57
+ const [settings, setSettings] = React.useState(() => {
58
+ try {
59
+ const saved = JSON.parse(localStorage.getItem("meridian-settings"));
60
+ return { ...TWEAK_DEFAULTS, ...(saved || {}) };
61
+ } catch { return TWEAK_DEFAULTS; }
62
+ });
63
+ const [view, setView] = React.useState(() => localStorage.getItem("meridian-view") || "home");
64
+ const [issueId, setIssueId] = React.useState("AUR-412");
65
+ const [paletteOpen, setPaletteOpen] = React.useState(false);
66
+ const [inboxOpen, setInboxOpen] = React.useState(false);
67
+ const [tweaksOpen, setTweaksOpen] = React.useState(false);
68
+
69
+ // Apply theme + accent to :root
70
+ React.useEffect(() => {
71
+ document.documentElement.dataset.theme = settings.theme;
72
+ document.documentElement.dataset.density = settings.density;
73
+ const a = ACCENT_MAP[settings.accent] || ACCENT_MAP.lime;
74
+ document.documentElement.style.setProperty("--accent", a.c);
75
+ document.documentElement.style.setProperty("--accent-dim", a.dim);
76
+ document.documentElement.style.setProperty("--accent-soft", a.soft);
77
+ document.documentElement.style.setProperty("--accent-fg", a.fg);
78
+ localStorage.setItem("meridian-settings", JSON.stringify(settings));
79
+ }, [settings]);
80
+
81
+ React.useEffect(() => {
82
+ localStorage.setItem("meridian-view", view);
83
+ }, [view]);
84
+
85
+ // Keyboard shortcuts
86
+ React.useEffect(() => {
87
+ const onKey = (e) => {
88
+ const tag = e.target.tagName;
89
+ const typing = tag === "INPUT" || tag === "TEXTAREA";
90
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
91
+ e.preventDefault();
92
+ setPaletteOpen(true);
93
+ } else if (e.key === "Escape") {
94
+ setPaletteOpen(false);
95
+ setInboxOpen(false);
96
+ } else if (!typing && !e.metaKey && !e.ctrlKey) {
97
+ if (e.key === "g") {
98
+ window.__g = true;
99
+ setTimeout(() => { window.__g = false; }, 900);
100
+ } else if (window.__g) {
101
+ const map = { h: "home", i: "inbox", s: "issues", d: "docs", r: "roadmap", p: "prs", t: "team" };
102
+ if (map[e.key]) { setView(map[e.key]); window.__g = false; }
103
+ } else if (e.key === "c") {
104
+ e.preventDefault();
105
+ window.openNewIssue();
106
+ } else if (e.key === "?") {
107
+ window.toast("Shortcuts: ⌘K · C new issue · G+letter to jump");
108
+ }
109
+ }
110
+ };
111
+ window.addEventListener("keydown", onKey);
112
+ return () => window.removeEventListener("keydown", onKey);
113
+ }, []);
114
+
115
+ // Edit-mode integration (Tweaks toolbar)
116
+ React.useEffect(() => {
117
+ const onMsg = (e) => {
118
+ if (!e.data || typeof e.data !== "object") return;
119
+ if (e.data.type === "__activate_edit_mode") setTweaksOpen(true);
120
+ if (e.data.type === "__deactivate_edit_mode") setTweaksOpen(false);
121
+ if (e.data.type === "meridian:vscode-commit") {
122
+ const { fileName, commitMessage, branch, additions } = e.data;
123
+ const issueId = `AUR-${Math.floor(700 + Math.random() * 100)}`;
124
+ const prId = `#${Math.floor(2000 + Math.random() * 1000)}`;
125
+
126
+ // 1. Add PR
127
+ PRS.unshift({
128
+ id: prId, title: `feat: add ${fileName} via AI codegen`, status: "open",
129
+ author: "you", branch: branch || "feat/ai-generated", base: "main", issue: issueId,
130
+ project: "Aurora", additions: additions || 0, deletions: 0,
131
+ checks: { passed: 0, failed: 0, running: 1 }, reviewers: [], updated: "just now",
132
+ });
133
+
134
+ // 2. Add Issue
135
+ ISSUES.unshift({
136
+ id: issueId, title: `Implement ${fileName}`, status: "progress", priority: "high",
137
+ project: "aurora", assignees: ["u1"], labels: ["feature"], due: "Today",
138
+ created: "just now", estimate: 3, commentCount: 0, sprint: "Iter 42", branch: branch || "feat/ai-generated"
139
+ });
140
+
141
+ // 3. Add to Docs
142
+ DOCS[0].children.push({
143
+ id: `d${Date.now()}`, title: `RFC β€” ${fileName} architecture`, updated: "just now", author: "you"
144
+ });
145
+
146
+ // 4. Add to Roadmap
147
+ ROADMAP.unshift({
148
+ id: `r${Date.now()}`, title: `Launch ${fileName} capabilities`, project: "aurora",
149
+ q: "Q2", startCol: 0, span: 2, progress: 10, status: "progress"
150
+ });
151
+
152
+ // 5. Update Sprint
153
+ const activeSprint = SPRINTS.find(s => s.active);
154
+ if (activeSprint) {
155
+ activeSprint.scope += 1;
156
+ }
157
+
158
+ // 6. Add Inbox Notification
159
+ INBOX.unshift({
160
+ id: `inbox-${Date.now()}`, type: "pr", unread: true,
161
+ title: `Your PR is open: feat: add ${fileName} via AI codegen`,
162
+ body: commitMessage || "AI-generated code committed and pushed.",
163
+ time: "just now", actor: "you", target: prId, kind: "pr"
164
+ });
165
+
166
+ window.toast(`PR ${prId} created and linked to ${issueId}. Docs and Roadmap updated.`);
167
+ window.meridianRefresh && window.meridianRefresh();
168
+ }
169
+ };
170
+ window.addEventListener("message", onMsg);
171
+ window.parent.postMessage({ type: "__edit_mode_available" }, "*");
172
+ return () => window.removeEventListener("message", onMsg);
173
+ }, []);
174
+
175
+ // Persist settings to host
176
+ const setSettingsPersist = React.useCallback((updater) => {
177
+ setSettings(prev => {
178
+ const next = typeof updater === "function" ? updater(prev) : updater;
179
+ window.parent.postMessage({ type: "__edit_mode_set_keys", edits: next }, "*");
180
+ return next;
181
+ });
182
+ }, []);
183
+
184
+ const renderView = () => {
185
+ switch (view) {
186
+ case "home": return <HomeView setView={setView} setIssueId={setIssueId} cardStyle={settings.cardStyle} />;
187
+ case "inbox": return <InboxView />;
188
+ case "issues": return <IssuesView setView={setView} setIssueId={setIssueId} cardStyle={settings.cardStyle} />;
189
+ case "issue": return <IssueDetail issueId={issueId} setView={setView} />;
190
+ case "sprints": return <SprintsView setView={setView} setIssueId={setIssueId} />;
191
+ case "roadmap": return <RoadmapView />;
192
+ case "docs": return <DocsView />;
193
+ case "prs": return <PRsView />;
194
+ case "team": return <TeamView />;
195
+ case "settings": return <SettingsView settings={settings} setSettings={setSettingsPersist} />;
196
+ case "chat": return <ChatView />;
197
+ case "code": return <CodeEditorView />;
198
+ case "compute": return <ComputeView />;
199
+ case "aimarket": return <AIMarketplaceView />;
200
+ case "nexus": return <NexusView />;
201
+ default: return <HomeView setView={setView} setIssueId={setIssueId} />;
202
+ }
203
+ };
204
+
205
+ return (
206
+ <div className="app" data-sidebar={settings.sidebar} data-screen-label={view}>
207
+ <Sidebar view={view} setView={setView} collapsed={settings.sidebar === "collapsed"} />
208
+ <Topbar
209
+ view={view}
210
+ theme={settings.theme}
211
+ setTheme={(t) => setSettingsPersist(s => ({ ...s, theme: t }))}
212
+ onToggleSidebar={() => setSettingsPersist(s => ({ ...s, sidebar: s.sidebar === "collapsed" ? "expanded" : "collapsed" }))}
213
+ onOpenPalette={() => setPaletteOpen(true)}
214
+ onOpenInbox={() => setInboxOpen(o => !o)}
215
+ onOpenTweaks={() => setTweaksOpen(o => !o)}
216
+ />
217
+ <main className="main">{renderView()}</main>
218
+
219
+ <CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} setView={setView} />
220
+ <InboxPanel open={inboxOpen} onClose={() => setInboxOpen(false)} />
221
+ <TweaksPanel open={tweaksOpen} onClose={() => setTweaksOpen(false)} settings={settings} setSettings={setSettingsPersist} />
222
+ <HintBar onOpenPalette={() => setPaletteOpen(true)} />
223
+ <ModalHost />
224
+ <Toaster />
225
+ </div>
226
+ );
227
+ }
228
+
229
+ ReactDOM.createRoot(document.getElementById("root")).render(
230
+ <AIPanelProvider>
231
+ <App />
232
+ </AIPanelProvider>
233
+ );
234
+ </script>
235
+ </body>
236
+ </html>
ai-engine.jsx ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ── Meridian AI Engine ─────────────────────────────────────────────────────
2
+ // Single shared AI backed by an AMD vLLM Endpoint
3
+ // Each view passes a `role` so the system prompt is context-aware.
4
+ //
5
+ // La URL del endpoint se configura en Settings β†’ AI & Integrations
6
+ // y se persiste en localStorage bajo la clave 'meridian-amd-endpoint'.
7
+
8
+ const AMD_MODEL = "llama-3.3-70b-versatile";
9
+ const LS_AMD_ENDPOINT = 'meridian-amd-endpoint';
10
+
11
+ const getAmdUrl = () => (localStorage.getItem(LS_AMD_ENDPOINT) || '').trim();
12
+ const setAmdUrl = (url) => localStorage.setItem(LS_AMD_ENDPOINT, url.trim());
13
+
14
+ // Exponer globalmente para que todos los views puedan acceder
15
+ window.getAmdUrl = getAmdUrl;
16
+ window.setAmdUrl = setAmdUrl;
17
+
18
+ // ── System prompts per role ────────────────────────────────────────────────
19
+ const ROLE_PROMPTS = {
20
+ general: `You are VaultMind AI, the intelligent assistant embedded in Meridian β€” a modern project management platform for engineering teams. You have awareness of the team's issues, PRs, sprints, roadmap, docs, and compute jobs. Be concise, insightful, and proactive. Respond in plain text or markdown.`,
21
+
22
+ code: `You are the Meridian Code Agent embedded in the Code Editor. Your job is to help users create, edit, and run web files (HTML, CSS, JavaScript). When asked to create a file, output only clean, complete, functional code. Always suggest running the file after creating it. Mention the run command in terminal syntax (e.g. "open index.html" for HTML, "node app.js" for JS). Keep explanations short β€” the user wants working code fast.`,
23
+
24
+ pr: `You are the Meridian PR Analyst. You analyze pull requests for engineering teams. When given PR data, evaluate:
25
+ - Merge readiness (checks, reviewers, conflicts)
26
+ - Risk level (High / Medium / Low) based on size, scope, and affected areas
27
+ - Suggested reviewers or missing context
28
+ - Potential issues: missing tests, breaking changes, security concerns
29
+ - Concrete action items for the author
30
+
31
+ Format your response with clear sections. Be direct β€” teams are busy.`,
32
+
33
+ sprint: `You are the Meridian Sprint Coach. You analyze sprint health for engineering teams. When given sprint data (issues, burndown, velocity), provide:
34
+ - Sprint health score and trend
35
+ - At-risk items that may not complete
36
+ - Blocking issues and suggested unblocking actions
37
+ - Retrospective themes and talking points
38
+ - Velocity comparison with previous sprints
39
+
40
+ Be concise and actionable. Teams read this during standups.`,
41
+
42
+ issues: `You are the Meridian Issue Triage Assistant. You help engineering teams manage their backlog. When given a list of issues, identify:
43
+ - Duplicates or related issues that should be linked
44
+ - Priority mismatches (high priority with no assignee, etc.)
45
+ - Issues blocking other issues
46
+ - Stale issues that need attention
47
+ - Recommended sprint candidates
48
+
49
+ Output a structured triage report.`,
50
+
51
+ roadmap: `You are the Meridian Roadmap Analyst. You analyze quarterly roadmaps for engineering teams. When given roadmap items and their progress, provide:
52
+ - Delivery confidence per milestone (%)
53
+ - Items at risk of slipping to next quarter
54
+ - Dependency bottlenecks
55
+ - Suggested reprioritization
56
+ - Executive summary paragraph
57
+
58
+ Be direct. Skip the caveats.`,
59
+
60
+ team: `You are the Meridian Team Intelligence assistant. You analyze team workload and capacity. When given team data, provide:
61
+ - Overloaded team members (load > 80%)
62
+ - Under-allocated capacity available for new work
63
+ - Suggested task reassignments for balance
64
+ - Who to pull in for specific tasks based on skills/current load
65
+ - Team health observations
66
+
67
+ Keep it practical and respectful.`,
68
+
69
+ docs: `You are the Meridian Docs Writer. You help engineering teams write, summarize, and improve technical documentation. You can:
70
+ - Summarize long documents into bullet points
71
+ - Expand brief notes into full docs
72
+ - Improve clarity and structure of existing content
73
+ - Generate ADRs (Architecture Decision Records) from descriptions
74
+ - Write meeting notes, retrospectives, and engineering specs
75
+
76
+ Match the tone of the existing content.`,
77
+
78
+ compute: `You are the Meridian Compute Analyst. You analyze GPU compute jobs and infrastructure usage. When given job and quota data, provide:
79
+ - Current spend efficiency analysis
80
+ - Jobs that can be optimized (region, GPU tier, duration)
81
+ - Spot instance opportunities for cost savings
82
+ - Queue wait time predictions
83
+ - Recommendations for GPU selection based on workload type
84
+
85
+ Output cost estimates where possible.`,
86
+ };
87
+
88
+ // ── Core call function ─────────────────────────────────────────────────────
89
+ async function amdChat({ role = "general", messages, contextData = null, onChunk = null }) {
90
+ const url = getAmdUrl();
91
+ if (!url) throw new Error("AMD_URL_NOT_SET");
92
+
93
+ const systemPrompt = ROLE_PROMPTS[role] || ROLE_PROMPTS.general;
94
+ const contextBlock = contextData
95
+ ? `\n\n--- LIVE WORKSPACE DATA ---\n${JSON.stringify(contextData, null, 2)}\n--- END DATA ---`
96
+ : "";
97
+
98
+ const body = {
99
+ model: AMD_MODEL,
100
+ max_tokens: 1024,
101
+ stream: !!onChunk,
102
+ messages: [
103
+ { role: "system", content: systemPrompt + contextBlock },
104
+ ...messages,
105
+ ],
106
+ };
107
+
108
+ const res = await fetch(url, {
109
+ method: "POST",
110
+ headers: {
111
+ "Content-Type": "application/json",
112
+ "Authorization": `Bearer dummy-key`,
113
+ },
114
+ body: JSON.stringify(body),
115
+ });
116
+
117
+ if (!res.ok) {
118
+ const err = await res.json().catch(() => ({}));
119
+ throw new Error(err.error?.message || `AMD API HTTP ${res.status}`);
120
+ }
121
+
122
+ // Streaming
123
+ if (onChunk) {
124
+ const reader = res.body.getReader();
125
+ const decoder = new TextDecoder();
126
+ let fullText = "";
127
+ while (true) {
128
+ const { done, value } = await reader.read();
129
+ if (done) break;
130
+ const chunk = decoder.decode(value);
131
+ const lines = chunk.split("\n").filter(l => l.startsWith("data: ") && l !== "data: [DONE]");
132
+ for (const line of lines) {
133
+ try {
134
+ const data = JSON.parse(line.slice(6));
135
+ const delta = data.choices?.[0]?.delta?.content || "";
136
+ if (delta) { fullText += delta; onChunk(delta, fullText); }
137
+ } catch (_) { }
138
+ }
139
+ }
140
+ return fullText;
141
+ }
142
+
143
+ // Non-streaming
144
+ const data = await res.json();
145
+ return data.choices?.[0]?.message?.content || "";
146
+ }
147
+
148
+ // ── Floating AI Panel ──────────────────────────────────────────────────────
149
+ const AIPanelContext = React.createContext(null);
150
+
151
+ const AIPanelProvider = ({ children }) => {
152
+ const [open, setOpen] = React.useState(false);
153
+ const [role, setRole] = React.useState("general");
154
+ const [ctxData, setCtxData] = React.useState(null);
155
+ const [messages, setMessages] = React.useState([]);
156
+ const [loading, setLoading] = React.useState(false);
157
+ const [streamText, setStreamText] = React.useState("");
158
+ const scrollRef = React.useRef(null);
159
+
160
+ const openAI = React.useCallback((initialPrompt, aiRole = "general", data = null) => {
161
+ setRole(aiRole);
162
+ setCtxData(data);
163
+ setOpen(true);
164
+ if (initialPrompt) {
165
+ // fire off immediately
166
+ setTimeout(() => sendAI(initialPrompt, aiRole, data, []), 80);
167
+ }
168
+ }, []);
169
+
170
+ React.useEffect(() => {
171
+ window.openAI = openAI;
172
+ }, [openAI]);
173
+
174
+ React.useEffect(() => {
175
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
176
+ }, [messages, streamText]);
177
+
178
+ const sendAI = async (text, overrideRole, overrideData, prevMessages) => {
179
+ const r = overrideRole ?? role;
180
+ const ctx = overrideData ?? ctxData;
181
+ const hist = prevMessages ?? messages;
182
+ if (!text.trim() || loading) return;
183
+ setLoading(true);
184
+ setStreamText("");
185
+
186
+ const userMsg = { role: "user", content: text };
187
+ const newHist = [...hist, userMsg];
188
+ setMessages(newHist);
189
+
190
+ try {
191
+ let accumulated = "";
192
+ await amdChat({
193
+ role: r,
194
+ messages: newHist,
195
+ contextData: ctx,
196
+ onChunk: (delta, full) => {
197
+ accumulated = full;
198
+ setStreamText(full);
199
+ },
200
+ });
201
+ setMessages(m => [...m, { role: "assistant", content: accumulated }]);
202
+ setStreamText("");
203
+ } catch (e) {
204
+ const msg = `⚠ API Error: ${e?.message || String(e)}`;
205
+ setMessages(m => [...m, { role: "assistant", content: msg }]);
206
+ setStreamText("");
207
+ } finally {
208
+ setLoading(false);
209
+ }
210
+ };
211
+
212
+ const handleSend = (e) => {
213
+ e.preventDefault();
214
+ const el = document.getElementById("ai-panel-input");
215
+ if (!el) return;
216
+ const text = el.value.trim();
217
+ if (!text) return;
218
+ el.value = "";
219
+ sendAI(text);
220
+ };
221
+
222
+ const ROLE_LABELS = {
223
+ general: "VaultMind AI", code: "Code Agent", pr: "PR Analyst",
224
+ sprint: "Sprint Coach", issues: "Issue Triage", roadmap: "Roadmap Analyst",
225
+ team: "Team Intel", docs: "Docs Writer", compute: "Compute Analyst",
226
+ };
227
+
228
+ const renderMd = (text) => {
229
+ if (!text) return "";
230
+ // Simple markdown: bold, code, line breaks
231
+ return text
232
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
233
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
234
+ .replace(/`([^`]+)`/g, "<code style='background:var(--bg-3);padding:1px 4px;border-radius:3px;font-family:var(--font-mono);font-size:0.9em'>$1</code>")
235
+ .replace(/\n/g, "<br>");
236
+ };
237
+
238
+ if (!open) return (
239
+ <AIPanelContext.Provider value={{ openAI }}>
240
+ {children}
241
+ </AIPanelContext.Provider>
242
+ );
243
+
244
+ return (
245
+ <AIPanelContext.Provider value={{ openAI }}>
246
+ {children}
247
+ {/* Backdrop */}
248
+ <div onClick={() => setOpen(false)} style={{
249
+ position: "fixed", inset: 0, zIndex: 299,
250
+ background: "oklch(0 0 0 / 0.3)", backdropFilter: "blur(2px)",
251
+ }} />
252
+ {/* Panel */}
253
+ <div style={{
254
+ position: "fixed", top: 0, right: 0, bottom: 0, width: 440, zIndex: 300,
255
+ background: "var(--bg-0)", borderLeft: "1px solid var(--border)",
256
+ display: "flex", flexDirection: "column",
257
+ boxShadow: "-20px 0 60px oklch(0 0 0 / 0.35)",
258
+ animation: "slideInRight 200ms cubic-bezier(0.2,0.7,0.2,1)",
259
+ }}>
260
+ {/* Header */}
261
+ <div style={{
262
+ padding: "14px 16px", borderBottom: "1px solid var(--border)",
263
+ display: "flex", alignItems: "center", gap: 10, flexShrink: 0,
264
+ }}>
265
+ <Icon name="sparkle" size={16} style={{ color: "var(--accent)" }} />
266
+ <div style={{ flex: 1 }}>
267
+ <div style={{ fontSize: 13.5, fontWeight: 600 }}>{ROLE_LABELS[role] || "AI"}</div>
268
+ <div style={{ fontSize: 10.5, color: "var(--fg-3)", fontFamily: "var(--font-mono)", textTransform: "uppercase", letterSpacing: "0.08em" }}>{role} mode</div>
269
+ </div>
270
+ <button className="segmented" style={{ gap: 4, background: "transparent", border: "none" }}>
271
+ {Object.keys(ROLE_LABELS).slice(0, 5).map(r2 => (
272
+ <button key={r2} className={role === r2 ? "on" : ""} style={{ fontSize: 10, padding: "3px 7px", textTransform: "capitalize" }}
273
+ onClick={() => setRole(r2)}>{r2}</button>
274
+ ))}
275
+ </button>
276
+ <button className="icon-btn" onClick={() => { setMessages([]); setStreamText(""); }}>
277
+ <Icon name="plus" size={13} style={{ transform: "rotate(45deg)" }} />
278
+ </button>
279
+ <button className="icon-btn" onClick={() => setOpen(false)}>
280
+ <Icon name="x" size={14} />
281
+ </button>
282
+ </div>
283
+
284
+ {/* Messages */}
285
+ <div ref={scrollRef} style={{ flex: 1, overflowY: "auto", padding: "16px", display: "flex", flexDirection: "column", gap: 14 }}>
286
+ {messages.length === 0 && !loading && (
287
+ <div style={{ textAlign: "center", marginTop: 40, color: "var(--fg-3)" }}>
288
+ <Icon name="sparkle" size={28} style={{ opacity: 0.4, marginBottom: 10, color: "var(--accent)" }} />
289
+ <div style={{ fontSize: 13, color: "var(--fg-2)", marginBottom: 20 }}>Ask me anything about your {role === "general" ? "workspace" : role}.</div>
290
+ {[
291
+ role === "pr" && "Analyze all open PRs",
292
+ role === "sprint" && "Summarize sprint 42 health",
293
+ role === "issues" && "Which issues are blocking the sprint?",
294
+ role === "team" && "Who has capacity for a new task?",
295
+ role === "code" && "Create a landing page with a hero section",
296
+ role === "compute" && "How can I reduce my compute costs?",
297
+ role === "general" && "Give me today's standup brief",
298
+ ].filter(Boolean).map((s, i) => (
299
+ <button key={i} onClick={() => { sendAI(s); }}
300
+ style={{ display: "block", width: "100%", textAlign: "left", padding: "9px 12px", margin: "4px 0", borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg-1)", cursor: "pointer", fontSize: 12.5, color: "var(--fg-1)", transition: "border-color 0.15s" }}
301
+ onMouseEnter={e => e.currentTarget.style.borderColor = "var(--accent-dim)"}
302
+ onMouseLeave={e => e.currentTarget.style.borderColor = "var(--border)"}>
303
+ {s}
304
+ </button>
305
+ ))}
306
+ </div>
307
+ )}
308
+ {messages.map((m, i) => (
309
+ <div key={i} style={{ display: "flex", gap: 10, flexDirection: m.role === "user" ? "row-reverse" : "row" }}>
310
+ <div style={{
311
+ width: 28, height: 28, borderRadius: 14, flexShrink: 0,
312
+ background: m.role === "user" ? "var(--accent)" : "var(--bg-2)",
313
+ display: "flex", alignItems: "center", justifyContent: "center",
314
+ }}>
315
+ {m.role === "user"
316
+ ? <Icon name="team" size={14} style={{ color: "var(--accent-fg)" }} />
317
+ : <Icon name="sparkle" size={14} style={{ color: "var(--accent)" }} />}
318
+ </div>
319
+ <div style={{
320
+ maxWidth: "85%", padding: "10px 13px", borderRadius: 10, fontSize: 13, lineHeight: 1.55,
321
+ background: m.role === "user" ? "var(--accent-dim)" : "var(--bg-1)",
322
+ border: m.role === "user" ? "none" : "1px solid var(--border)",
323
+ borderTopRightRadius: m.role === "user" ? 2 : 10,
324
+ borderTopLeftRadius: m.role === "user" ? 10 : 2,
325
+ color: "var(--fg-1)",
326
+ }} dangerouslySetInnerHTML={{ __html: renderMd(m.content) }} />
327
+ </div>
328
+ ))}
329
+ {/* Streaming */}
330
+ {loading && (
331
+ <div style={{ display: "flex", gap: 10 }}>
332
+ <div style={{ width: 28, height: 28, borderRadius: 14, flexShrink: 0, background: "var(--bg-2)", display: "flex", alignItems: "center", justifyContent: "center" }}>
333
+ <Icon name="sparkle" size={14} style={{ color: "var(--accent)" }} />
334
+ </div>
335
+ <div style={{ maxWidth: "85%", padding: "10px 13px", borderRadius: 10, fontSize: 13, lineHeight: 1.55, background: "var(--bg-1)", border: "1px solid var(--border)", borderTopLeftRadius: 2, color: "var(--fg-1)" }}>
336
+ {streamText
337
+ ? <span dangerouslySetInnerHTML={{ __html: renderMd(streamText) }} />
338
+ : <span style={{ display: "flex", gap: 4, alignItems: "center" }}>
339
+ <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)", animation: "pulse 1s ease-in-out infinite" }} />
340
+ <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)", animation: "pulse 1s ease-in-out 0.2s infinite" }} />
341
+ <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)", animation: "pulse 1s ease-in-out 0.4s infinite" }} />
342
+ </span>}
343
+ </div>
344
+ </div>
345
+ )}
346
+ </div>
347
+
348
+ {/* Input */}
349
+ <div style={{ padding: "12px 16px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
350
+ <form onSubmit={handleSend} style={{ display: "flex", gap: 8 }}>
351
+ <input id="ai-panel-input" placeholder={`Ask ${ROLE_LABELS[role] || "AI"}…`}
352
+ style={{ flex: 1, padding: "10px 12px", borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg-1)", color: "var(--fg-0)", outline: "none", fontSize: 13 }} />
353
+ <button type="submit" disabled={loading} className="btn primary"
354
+ style={{ padding: "0 14px", height: 40, opacity: loading ? 0.5 : 1 }}>
355
+ <Icon name="arrow-up" size={14} />
356
+ </button>
357
+ </form>
358
+ </div>
359
+ </div>
360
+ <style>{`@keyframes slideInRight { from { transform: translateX(60px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }`}</style>
361
+ </AIPanelContext.Provider>
362
+ );
363
+ };
364
+
365
+ window.amdChat = amdChat;
366
+ window.AIPanelProvider = AIPanelProvider;
api-client.jsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // api-client.jsx β€” fetch wrapper for CRUD operations
2
+ // Reads from /backend/data/*.json (static files on Azure).
3
+ // Writes fall back to in-memory ISSUES/PRS/DOCS arrays + localStorage delta.
4
+ // mock-api.jsx intercepts /api/* and handles mutations in-memory.
5
+
6
+ // Pre-load JSON data from static backend files on first load
7
+ (async function seedFromBackend() {
8
+ const tryLoad = async (url, globalName) => {
9
+ try {
10
+ const r = await window._realFetch(url);
11
+ if (!r.ok) return;
12
+ const data = await r.json();
13
+ if (Array.isArray(data) && typeof window[globalName] !== 'undefined') {
14
+ // Merge: backend is source of truth; localStorage deltas layered on top
15
+ const delta = JSON.parse(localStorage.getItem('meridian-delta-' + globalName) || '{}');
16
+ window[globalName].length = 0;
17
+ data.forEach(item => {
18
+ window[globalName].push(delta[item.id] ? { ...item, ...delta[item.id] } : item);
19
+ });
20
+ // Append any newly created items stored only in delta
21
+ Object.values(delta).forEach(d => {
22
+ if (d._new && !window[globalName].find(i => i.id === d.id)) {
23
+ window[globalName].push(d);
24
+ }
25
+ });
26
+ console.log(`[api-client] Loaded ${window[globalName].length} items for ${globalName} from backend`);
27
+ }
28
+ } catch(e) {
29
+ console.log(`[api-client] Backend not available for ${globalName}, using built-in data`);
30
+ }
31
+ };
32
+
33
+ // Stash real fetch before mock-api overrides it
34
+ window._realFetch = window._realFetch || window.fetch.bind(window);
35
+
36
+ await Promise.allSettled([
37
+ tryLoad('/backend/data/issues.json', 'ISSUES'),
38
+ tryLoad('/backend/data/prs.json', 'PRS'),
39
+ tryLoad('/backend/data/docs.json', 'DOCS'),
40
+ tryLoad('/backend/data/projects.json', 'PROJECTS'),
41
+ tryLoad('/backend/data/people.json', 'PEOPLE'),
42
+ tryLoad('/backend/data/inbox.json', 'INBOX'),
43
+ ]);
44
+ })();
45
+
46
+ window.apiFetch = async (method, path, body) => {
47
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
48
+ if (body !== undefined) opts.body = JSON.stringify(body);
49
+ const res = await fetch(path, opts); // mock-api intercepts /api/* mutations
50
+ if (!res.ok) throw new Error(res.status);
51
+ return res.json();
52
+ };
53
+
54
+ // Persist a mutation delta to localStorage so it survives refresh
55
+ window.persistDelta = (collection, item) => {
56
+ const key = 'meridian-delta-' + collection;
57
+ const delta = JSON.parse(localStorage.getItem(key) || '{}');
58
+ delta[item.id] = item;
59
+ localStorage.setItem(key, JSON.stringify(delta));
60
+ };
61
+
62
+ window.meridianRefresh = () =>
63
+ document.dispatchEvent(new CustomEvent('meridian:refresh'));
backend/data/inbox.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ { "id": "n1", "type": "mention", "from": "u1", "target": "AUR-412", "text": "mentioned you in a comment", "snippet": "@you β€” can you weigh in on the tile-size heuristic? I think 256 is…", "time": "12m", "unread": true, "kind": "issue" },
3
+ { "id": "n2", "type": "review", "from": "u4", "target": "#2339", "text": "requested your review on", "snippet": "feat(tokens): v3 semantic layer", "time": "34m", "unread": true, "kind": "pr" },
4
+ { "id": "n3", "type": "assign", "from": "u5", "target": "NIM-110", "text": "assigned you", "snippet": "Regional failover runbook for EU-West primary", "time": "1h", "unread": true, "kind": "issue" },
5
+ { "id": "n4", "type": "status", "from": "u2", "target": "#2341", "text": "marked checks failing on", "snippet": "e2e-canvas: 'cursor-reconnect' flaked on retry", "time": "2h", "unread": true, "kind": "pr" },
6
+ { "id": "n5", "type": "comment", "from": "u6", "target": "EMB-523", "text": "replied in a thread", "snippet": "The snapshot approach feels right, but we should bound…", "time": "3h", "unread": false, "kind": "issue" },
7
+ { "id": "n6", "type": "doc", "from": "u1", "target": "d12", "text": "updated the PRD", "snippet": "Aurora: collaborative canvas β€” v0.4 with multi-select changes", "time": "6h", "unread": false, "kind": "doc" },
8
+ { "id": "n7", "type": "merged", "from": "u6", "target": "#2334", "text": "merged pull request", "snippet": "feat(comments): resolve threads with context snapshot", "time": "1d", "unread": false, "kind": "pr" },
9
+ { "id": "n8", "type": "due", "target": "HAL-033", "text": "is due tomorrow", "snippet": "Rotate SSO signing keys for SAML federation", "time": "1d", "unread": false, "kind": "issue" }
10
+ ]
backend/data/issues.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ { "id": "AUR-412", "title": "Rebuild canvas-rendering pipeline for 10k+ node graphs", "status": "progress", "priority": "urgent", "project": "aurora", "assignees": ["u2","u3"], "labels": ["perf","infra"], "due": "Apr 29", "created": "Apr 02", "estimate": 8, "commentCount": 14, "sprint": "Iter 42", "branch": "perf/canvas-pipeline" },
3
+ { "id": "AUR-418", "title": "Keyboard navigation for multi-select in tree view", "status": "todo", "priority": "high", "project": "aurora", "assignees": ["u4"], "labels": ["a11y"], "due": "May 02", "created": "Apr 11", "estimate": 3, "commentCount": 4, "sprint": "Iter 42" },
4
+ { "id": "TES-207", "title": "Design tokens v3: unify semantic layer across platforms", "status": "review", "priority": "high", "project": "tessera", "assignees": ["u1","u4"], "labels": ["design","docs"], "due": "Apr 25", "created": "Mar 28", "estimate": 5, "commentCount": 22, "sprint": "Iter 42", "branch": "design/tokens-v3" },
5
+ { "id": "NIM-091", "title": "Migrate ingestion workers from AWS Batch to Kubernetes Jobs", "status": "progress", "priority": "high", "project": "nimbus", "assignees": ["u7","u5"], "labels": ["infra"], "due": "May 10", "created": "Mar 20", "estimate": 13, "commentCount": 31, "sprint": "Iter 42", "branch": "infra/k8s-migration" },
6
+ { "id": "HAL-033", "title": "Rotate SSO signing keys for SAML federation", "status": "todo", "priority": "urgent", "project": "halcyon", "assignees": ["u8"], "labels": ["security"], "due": "Apr 22", "created": "Apr 15", "estimate": 2, "commentCount": 3, "sprint": "Iter 42" },
7
+ { "id": "EMB-514", "title": "Fix drag-preview offset on trackpad in Safari 17", "status": "backlog", "priority": "med", "project": "ember", "assignees": ["u6"], "labels": ["bug"], "due": null, "created": "Apr 18", "estimate": 1, "commentCount": 2 },
8
+ { "id": "AUR-401", "title": "Collaborative cursors: presence reconciliation on reconnect", "status": "progress", "priority": "med", "project": "aurora", "assignees": ["u2"], "labels": ["feature"], "due": "May 05", "created": "Apr 07", "estimate": 5, "commentCount": 18, "sprint": "Iter 42", "branch": "feat/presence-reconnect" },
9
+ { "id": "TES-214", "title": "Docs site: MDX components for live previews", "status": "todo", "priority": "low", "project": "tessera", "assignees": ["u1"], "labels": ["docs","feature"], "due": "May 14", "created": "Apr 10", "estimate": 3, "commentCount": 6 },
10
+ { "id": "NIM-103", "title": "Rate-limit public search endpoint per workspace", "status": "review", "priority": "high", "project": "nimbus", "assignees": ["u5","u9"], "labels": ["security","perf"], "due": "Apr 26", "created": "Apr 08", "estimate": 3, "commentCount": 9, "sprint": "Iter 42", "branch": "sec/search-ratelimit" },
11
+ { "id": "HAL-035", "title": "Audit log export as Parquet for BigQuery ingestion", "status": "backlog", "priority": "med", "project": "halcyon", "assignees": ["u8","u9"], "labels": ["infra","docs"], "due": null, "created": "Apr 16", "estimate": 5, "commentCount": 1 },
12
+ { "id": "EMB-520", "title": "Inline previews of Figma frames in comment threads", "status": "done", "priority": "med", "project": "ember", "assignees": ["u6","u4"], "labels": ["feature","design"], "due": "Apr 12", "created": "Mar 30", "estimate": 3, "commentCount": 11 },
13
+ { "id": "AUR-395", "title": "Telemetry: sampled traces for canvas interactions", "status": "done", "priority": "low", "project": "aurora", "assignees": ["u7"], "labels": ["infra","perf"], "due": "Apr 08", "created": "Mar 22", "estimate": 2, "commentCount": 5 },
14
+ { "id": "TES-199", "title": "Color contrast warnings inline in token editor", "status": "done", "priority": "high", "project": "tessera", "assignees": ["u1"], "labels": ["a11y","design"], "due": "Apr 14", "created": "Apr 01", "estimate": 2, "commentCount": 7 },
15
+ { "id": "NIM-110", "title": "Regional failover runbook for EU-West primary", "status": "todo", "priority": "urgent", "project": "nimbus", "assignees": ["u7"], "labels": ["infra","docs","security"], "due": "Apr 30", "created": "Apr 17", "estimate": 5, "commentCount": 2, "sprint": "Iter 42" },
16
+ { "id": "HAL-041", "title": "SCIM provisioning: nested group mapping", "status": "progress", "priority": "med", "project": "halcyon", "assignees": ["u8"], "labels": ["feature","security"], "due": "May 09", "created": "Apr 09", "estimate": 8, "commentCount": 14, "sprint": "Iter 42", "branch": "feat/scim-groups" },
17
+ { "id": "EMB-523", "title": "Comments: resolve threads without losing context", "status": "review", "priority": "med", "project": "ember", "assignees": ["u6","u3"], "labels": ["feature"], "due": "Apr 27", "created": "Apr 13", "estimate": 3, "commentCount": 8, "sprint": "Iter 42", "branch": "feat/resolve-threads" },
18
+ { "id": "AUR-422", "title": "Export canvas β†’ SVG with embedded fonts", "status": "todo", "priority": "low", "project": "aurora", "assignees": ["u3"], "labels": ["feature"], "due": "May 20", "created": "Apr 18", "estimate": 5, "commentCount": 0 },
19
+ { "id": "TES-220", "title": "Motion tokens: cubic-bezier presets", "status": "backlog", "priority": "low", "project": "tessera", "assignees": ["u4"], "labels": ["design"], "due": null, "created": "Apr 19", "estimate": 2, "commentCount": 1 }
20
+ ]
backend/data/people.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ { "id": "u1", "name": "Amara Okafor", "handle": "amara", "hue": 300 },
3
+ { "id": "u2", "name": "Rohan Mehta", "handle": "rohan", "hue": 220 },
4
+ { "id": "u3", "name": "Kenji Ito", "handle": "kenji", "hue": 145 },
5
+ { "id": "u4", "name": "SofΓ­a Ruiz", "handle": "sofia", "hue": 20 },
6
+ { "id": "u5", "name": "Lior Shapira", "handle": "lior", "hue": 75 },
7
+ { "id": "u6", "name": "Priya Raman", "handle": "priya", "hue": 340 },
8
+ { "id": "u7", "name": "Noah BergstrΓΆm", "handle": "noah", "hue": 180 },
9
+ { "id": "u8", "name": "Aiyana Redcloud", "handle": "aiyana", "hue": 40 },
10
+ { "id": "u9", "name": "TomΓ‘s Velasco", "handle": "tomas", "hue": 260 }
11
+ ]
backend/data/projects.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [
2
+ { "id": "aurora", "name": "Aurora", "code": "AUR", "color": "oklch(0.78 0.14 220)" },
3
+ { "id": "tessera", "name": "Tessera", "code": "TES", "color": "oklch(0.72 0.17 300)" },
4
+ { "id": "nimbus", "name": "Nimbus", "code": "NIM", "color": "oklch(0.80 0.14 75)" },
5
+ { "id": "halcyon", "name": "Halcyon", "code": "HAL", "color": "oklch(0.80 0.16 145)" },
6
+ { "id": "ember", "name": "Ember", "code": "EMB", "color": "oklch(0.72 0.17 20)" }
7
+ ]
backend/data/prs.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ { "id": "#2341", "title": "perf(canvas): tile-based rendering with offscreen canvas", "author": "u2", "status": "open", "branch": "perf/canvas-pipeline", "base": "main", "issue": "AUR-412", "additions": 842, "deletions": 217, "reviewers": ["u3","u7"], "checks": { "passed": 11, "failed": 1, "running": 2 }, "updated": "2h ago" },
3
+ { "id": "#2339", "title": "feat(tokens): v3 semantic layer", "author": "u1", "status": "review", "branch": "design/tokens-v3", "base": "main", "issue": "TES-207", "additions": 1204, "deletions": 890, "reviewers": ["u4"], "checks": { "passed": 14, "failed": 0, "running": 0 }, "updated": "5h ago" },
4
+ { "id": "#2338", "title": "infra(k8s): batch β†’ jobs migration phase 2", "author": "u7", "status": "draft", "branch": "infra/k8s-migration", "base": "main", "issue": "NIM-091", "additions": 421, "deletions": 180, "reviewers": ["u5"], "checks": { "passed": 8, "failed": 0, "running": 3 }, "updated": "1d ago" },
5
+ { "id": "#2337", "title": "sec(search): per-workspace rate limiting", "author": "u5", "status": "review", "branch": "sec/search-ratelimit", "base": "main", "issue": "NIM-103", "additions": 312, "deletions": 44, "reviewers": ["u9","u7"], "checks": { "passed": 12, "failed": 0, "running": 0 }, "updated": "3h ago" },
6
+ { "id": "#2335", "title": "feat(scim): nested group mapping", "author": "u8", "status": "open", "branch": "feat/scim-groups", "base": "main", "issue": "HAL-041", "additions": 678, "deletions": 121, "reviewers": ["u9"], "checks": { "passed": 9, "failed": 2, "running": 0 }, "updated": "6h ago" },
7
+ { "id": "#2334", "title": "feat(comments): resolve threads with context snapshot", "author": "u6", "status": "merged", "branch": "feat/resolve-threads", "base": "main", "issue": "EMB-523", "additions": 298, "deletions": 76, "reviewers": ["u3"], "checks": { "passed": 15, "failed": 0, "running": 0 }, "updated": "1d ago" },
8
+ { "id": "#2332", "title": "feat(presence): reconcile on websocket reconnect", "author": "u2", "status": "open", "branch": "feat/presence-reconnect", "base": "main", "issue": "AUR-401", "additions": 184, "deletions": 52, "reviewers": ["u7"], "checks": { "passed": 13, "failed": 0, "running": 1 }, "updated": "8h ago" }
9
+ ]
data.jsx ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Mock data for Meridian
2
+
3
+ const PEOPLE = [
4
+ { id: "u1", name: "Amara Okafor", handle: "amara", hue: 300 },
5
+ { id: "u2", name: "Rohan Mehta", handle: "rohan", hue: 220 },
6
+ { id: "u3", name: "Kenji Ito", handle: "kenji", hue: 145 },
7
+ { id: "u4", name: "SofΓ­a Ruiz", handle: "sofia", hue: 20 },
8
+ { id: "u5", name: "Lior Shapira", handle: "lior", hue: 75 },
9
+ { id: "u6", name: "Priya Raman", handle: "priya", hue: 340 },
10
+ { id: "u7", name: "Noah BergstrΓΆm", handle: "noah", hue: 180 },
11
+ { id: "u8", name: "Aiyana Redcloud", handle: "aiyana", hue: 40 },
12
+ { id: "u9", name: "TomΓ‘s Velasco", handle: "tomas", hue: 260 },
13
+ ];
14
+
15
+ const initials = (name) => name.split(" ").map(w => w[0]).slice(0,2).join("").toUpperCase();
16
+
17
+ const Avatar = ({ user, size = "sm" }) => {
18
+ if (!user) return null;
19
+ const s = { "--h": user.hue };
20
+ return (
21
+ <span className={`avatar ${size}`} style={{ background: `linear-gradient(135deg, oklch(0.62 0.16 ${user.hue}), oklch(0.52 0.18 ${(user.hue+60)%360}))`}} title={user.name}>
22
+ {initials(user.name)}
23
+ </span>
24
+ );
25
+ };
26
+
27
+ const AvatarStack = ({ users, max = 3, size = "xs" }) => {
28
+ const shown = users.slice(0, max);
29
+ const extra = users.length - shown.length;
30
+ return (
31
+ <span style={{ display: "inline-flex" }}>
32
+ {shown.map((u, i) => (
33
+ <span key={u.id} style={{ marginLeft: i === 0 ? 0 : -6, border: "1.5px solid var(--bg-1)", borderRadius: "50%", display: "inline-block" }}>
34
+ <Avatar user={u} size={size} />
35
+ </span>
36
+ ))}
37
+ {extra > 0 && (
38
+ <span className={`avatar ${size}`} style={{ marginLeft: -6, background: "var(--bg-3)", color: "var(--fg-1)", border: "1.5px solid var(--bg-1)" }}>
39
+ +{extra}
40
+ </span>
41
+ )}
42
+ </span>
43
+ );
44
+ };
45
+
46
+ const PROJECTS = [
47
+ { id: "aurora", name: "Aurora", code: "AUR", color: "oklch(0.78 0.14 220)" },
48
+ { id: "tessera", name: "Tessera", code: "TES", color: "oklch(0.72 0.17 300)" },
49
+ { id: "nimbus", name: "Nimbus", code: "NIM", color: "oklch(0.80 0.14 75)" },
50
+ { id: "halcyon", name: "Halcyon", code: "HAL", color: "oklch(0.80 0.16 145)" },
51
+ { id: "ember", name: "Ember", code: "EMB", color: "oklch(0.72 0.17 20)" },
52
+ ];
53
+
54
+ const LABELS = [
55
+ { id: "bug", name: "bug", color: "oklch(0.70 0.17 20)" },
56
+ { id: "feature", name: "feature", color: "oklch(0.78 0.14 220)" },
57
+ { id: "infra", name: "infra", color: "oklch(0.68 0.12 260)" },
58
+ { id: "design", name: "design", color: "oklch(0.72 0.17 300)" },
59
+ { id: "perf", name: "perf", color: "oklch(0.80 0.14 75)" },
60
+ { id: "a11y", name: "a11y", color: "oklch(0.80 0.16 145)" },
61
+ { id: "docs", name: "docs", color: "oklch(0.65 0.02 250)" },
62
+ { id: "security", name: "security", color: "oklch(0.70 0.17 0)" },
63
+ ];
64
+
65
+ // Status enum
66
+ const STATUS = ["backlog", "todo", "progress", "review", "done"];
67
+ const STATUS_META = {
68
+ backlog: { label: "Backlog", klass: "todo" },
69
+ todo: { label: "Todo", klass: "todo" },
70
+ progress: { label: "In progress", klass: "progress" },
71
+ review: { label: "In review", klass: "review" },
72
+ done: { label: "Done", klass: "done" },
73
+ blocked: { label: "Blocked", klass: "blocked" },
74
+ };
75
+
76
+ const PRIORITY = {
77
+ urgent: { label: "Urgent", klass: "priority-high", icon: "πŸ”Ί", dots: 4 },
78
+ high: { label: "High", klass: "priority-high", dots: 3 },
79
+ med: { label: "Medium", klass: "priority-med", dots: 2 },
80
+ low: { label: "Low", klass: "priority-low", dots: 1 },
81
+ none: { label: "No priority", klass: "priority-low", dots: 0 },
82
+ };
83
+
84
+ const PriorityGlyph = ({ level }) => {
85
+ const n = PRIORITY[level]?.dots ?? 0;
86
+ const heights = [4, 7, 10, 13];
87
+ const color = level === "urgent" || level === "high" ? "var(--rose)"
88
+ : level === "med" ? "var(--amber)" : "var(--fg-3)";
89
+ return (
90
+ <span style={{ display: "inline-flex", alignItems: "flex-end", gap: 1.5, height: 14 }} title={PRIORITY[level]?.label}>
91
+ {[0,1,2,3].map(i => (
92
+ <span key={i} style={{
93
+ width: 2.5,
94
+ height: heights[i],
95
+ background: i < n ? color : "var(--border-strong)",
96
+ borderRadius: 1
97
+ }} />
98
+ ))}
99
+ </span>
100
+ );
101
+ };
102
+
103
+ const pick = (arr, n) => {
104
+ const out = [];
105
+ const used = new Set();
106
+ while (out.length < n && out.length < arr.length) {
107
+ const i = Math.floor((Math.sin((out.length+1) * 9301 + n * 49297) * 0.5 + 0.5) * arr.length) % arr.length;
108
+ if (!used.has(i)) { used.add(i); out.push(arr[i]); }
109
+ }
110
+ return out;
111
+ };
112
+
113
+ const ISSUES = [
114
+ { id: "AUR-412", title: "Rebuild canvas-rendering pipeline for 10k+ node graphs", status: "progress", priority: "urgent", project: "aurora", assignees: ["u2","u3"], labels: ["perf","infra"], due: "Apr 29", created: "Apr 02", estimate: 8, commentCount: 14, sprint: "Iter 42", branch: "perf/canvas-pipeline" },
115
+ { id: "AUR-418", title: "Keyboard navigation for multi-select in tree view", status: "todo", priority: "high", project: "aurora", assignees: ["u4"], labels: ["a11y"], due: "May 02", created: "Apr 11", estimate: 3, commentCount: 4, sprint: "Iter 42" },
116
+ { id: "TES-207", title: "Design tokens v3: unify semantic layer across platforms", status: "review", priority: "high", project: "tessera", assignees: ["u1","u4"], labels: ["design","docs"], due: "Apr 25", created: "Mar 28", estimate: 5, commentCount: 22, sprint: "Iter 42", branch: "design/tokens-v3" },
117
+ { id: "NIM-091", title: "Migrate ingestion workers from AWS Batch to Kubernetes Jobs", status: "progress", priority: "high", project: "nimbus", assignees: ["u7","u5"], labels: ["infra"], due: "May 10", created: "Mar 20", estimate: 13, commentCount: 31, sprint: "Iter 42", branch: "infra/k8s-migration" },
118
+ { id: "HAL-033", title: "Rotate SSO signing keys for SAML federation", status: "todo", priority: "urgent", project: "halcyon", assignees: ["u8"], labels: ["security"], due: "Apr 22", created: "Apr 15", estimate: 2, commentCount: 3, sprint: "Iter 42" },
119
+ { id: "EMB-514", title: "Fix drag-preview offset on trackpad in Safari 17", status: "backlog", priority: "med", project: "ember", assignees: ["u6"], labels: ["bug"], due: null, created: "Apr 18", estimate: 1, commentCount: 2 },
120
+ { id: "AUR-401", title: "Collaborative cursors: presence reconciliation on reconnect", status: "progress", priority: "med", project: "aurora", assignees: ["u2"], labels: ["feature"], due: "May 05", created: "Apr 07", estimate: 5, commentCount: 18, sprint: "Iter 42", branch: "feat/presence-reconnect" },
121
+ { id: "TES-214", title: "Docs site: MDX components for live previews", status: "todo", priority: "low", project: "tessera", assignees: ["u1"], labels: ["docs","feature"], due: "May 14", created: "Apr 10", estimate: 3, commentCount: 6 },
122
+ { id: "NIM-103", title: "Rate-limit public search endpoint per workspace", status: "review", priority: "high", project: "nimbus", assignees: ["u5","u9"], labels: ["security","perf"], due: "Apr 26", created: "Apr 08", estimate: 3, commentCount: 9, sprint: "Iter 42", branch: "sec/search-ratelimit" },
123
+ { id: "HAL-035", title: "Audit log export as Parquet for BigQuery ingestion", status: "backlog", priority: "med", project: "halcyon", assignees: ["u8","u9"], labels: ["infra","docs"], due: null, created: "Apr 16", estimate: 5, commentCount: 1 },
124
+ { id: "EMB-520", title: "Inline previews of Figma frames in comment threads", status: "done", priority: "med", project: "ember", assignees: ["u6","u4"], labels: ["feature","design"], due: "Apr 12", created: "Mar 30", estimate: 3, commentCount: 11 },
125
+ { id: "AUR-395", title: "Telemetry: sampled traces for canvas interactions", status: "done", priority: "low", project: "aurora", assignees: ["u7"], labels: ["infra","perf"], due: "Apr 08", created: "Mar 22", estimate: 2, commentCount: 5 },
126
+ { id: "TES-199", title: "Color contrast warnings inline in token editor", status: "done", priority: "high", project: "tessera", assignees: ["u1"], labels: ["a11y","design"], due: "Apr 14", created: "Apr 01", estimate: 2, commentCount: 7 },
127
+ { id: "NIM-110", title: "Regional failover runbook for EU-West primary", status: "todo", priority: "urgent", project: "nimbus", assignees: ["u7"], labels: ["infra","docs","security"], due: "Apr 30", created: "Apr 17", estimate: 5, commentCount: 2, sprint: "Iter 42" },
128
+ { id: "HAL-041", title: "SCIM provisioning: nested group mapping", status: "progress", priority: "med", project: "halcyon", assignees: ["u8"], labels: ["feature","security"], due: "May 09", created: "Apr 09", estimate: 8, commentCount: 14, sprint: "Iter 42", branch: "feat/scim-groups" },
129
+ { id: "EMB-523", title: "Comments: resolve threads without losing context", status: "review", priority: "med", project: "ember", assignees: ["u6","u3"], labels: ["feature"], due: "Apr 27", created: "Apr 13", estimate: 3, commentCount: 8, sprint: "Iter 42", branch: "feat/resolve-threads" },
130
+ { id: "AUR-422", title: "Export canvas β†’ SVG with embedded fonts", status: "todo", priority: "low", project: "aurora", assignees: ["u3"], labels: ["feature"], due: "May 20", created: "Apr 18", estimate: 5, commentCount: 0 },
131
+ { id: "TES-220", title: "Motion tokens: cubic-bezier presets", status: "backlog", priority: "low", project: "tessera", assignees: ["u4"], labels: ["design"], due: null, created: "Apr 19", estimate: 2, commentCount: 1 },
132
+ ];
133
+
134
+ // Pull requests
135
+ const PRS = [
136
+ { id: "#2341", title: "perf(canvas): tile-based rendering with offscreen canvas", author: "u2", status: "open", branch: "perf/canvas-pipeline", base: "main", issue: "AUR-412", additions: 842, deletions: 217, reviewers: ["u3","u7"], checks: { passed: 11, failed: 1, running: 2 }, updated: "2h ago" },
137
+ { id: "#2339", title: "feat(tokens): v3 semantic layer", author: "u1", status: "review", branch: "design/tokens-v3", base: "main", issue: "TES-207", additions: 1204, deletions: 890, reviewers: ["u4"], checks: { passed: 14, failed: 0, running: 0 }, updated: "5h ago" },
138
+ { id: "#2338", title: "infra(k8s): batch β†’ jobs migration phase 2", author: "u7", status: "draft", branch: "infra/k8s-migration", base: "main", issue: "NIM-091", additions: 421, deletions: 180, reviewers: ["u5"], checks: { passed: 8, failed: 0, running: 3 }, updated: "1d ago" },
139
+ { id: "#2337", title: "sec(search): per-workspace rate limiting", author: "u5", status: "review", branch: "sec/search-ratelimit", base: "main", issue: "NIM-103", additions: 312, deletions: 44, reviewers: ["u9","u7"], checks: { passed: 12, failed: 0, running: 0 }, updated: "3h ago" },
140
+ { id: "#2335", title: "feat(scim): nested group mapping", author: "u8", status: "open", branch: "feat/scim-groups", base: "main", issue: "HAL-041", additions: 678, deletions: 121, reviewers: ["u9"], checks: { passed: 9, failed: 2, running: 0 }, updated: "6h ago" },
141
+ { id: "#2334", title: "feat(comments): resolve threads with context snapshot", author: "u6", status: "merged", branch: "feat/resolve-threads", base: "main", issue: "EMB-523", additions: 298, deletions: 76, reviewers: ["u3"], checks: { passed: 15, failed: 0, running: 0 }, updated: "1d ago" },
142
+ { id: "#2332", title: "feat(presence): reconcile on websocket reconnect", author: "u2", status: "open", branch: "feat/presence-reconnect", base: "main", issue: "AUR-401", additions: 184, deletions: 52, reviewers: ["u7"], checks: { passed: 13, failed: 0, running: 1 }, updated: "8h ago" },
143
+ ];
144
+
145
+ // Docs tree
146
+ const DOCS = [
147
+ { id: "d1", title: "Engineering handbook", emoji: "◐", section: "space", children: [
148
+ { id: "d2", title: "Architecture decisions", emoji: "β—‡", children: [
149
+ { id: "d3", title: "ADR 041 β€” Canvas rendering pipeline", updated: "2d ago", author: "u2" },
150
+ { id: "d4", title: "ADR 042 β€” Multi-region data residency", updated: "5d ago", author: "u7" },
151
+ { id: "d5", title: "ADR 043 β€” Typed RPC between services", updated: "1w ago", author: "u5" },
152
+ ]},
153
+ { id: "d6", title: "On-call runbooks", emoji: "β—ˆ", children: [
154
+ { id: "d7", title: "Ingestion pipeline saturation", updated: "3d ago", author: "u7" },
155
+ { id: "d8", title: "SAML SSO outage procedure", updated: "1w ago", author: "u8" },
156
+ ]},
157
+ { id: "d9", title: "Release process", updated: "4d ago", author: "u5" },
158
+ ]},
159
+ { id: "d10", title: "Product briefs", emoji: "β—‘", section: "space", children: [
160
+ { id: "d11", title: "Q2 planning β€” themes & bets", updated: "1d ago", author: "u1" },
161
+ { id: "d12", title: "Aurora: collaborative canvas PRD", updated: "2d ago", author: "u1" },
162
+ { id: "d13", title: "Tessera: design system v3 vision", updated: "6h ago", author: "u4" },
163
+ ]},
164
+ { id: "d14", title: "Meeting notes", emoji: "β—Ž", section: "space", children: [
165
+ { id: "d15", title: "Apr 18 β€” Platform weekly", updated: "2d ago", author: "u5" },
166
+ { id: "d16", title: "Apr 17 β€” Design review: Aurora", updated: "3d ago", author: "u4" },
167
+ { id: "d17", title: "Apr 15 β€” Incident review: EU-West", updated: "5d ago", author: "u7" },
168
+ ]},
169
+ ];
170
+
171
+ // Activity / notifications
172
+ const INBOX = [
173
+ { id: "n1", type: "mention", from: "u1", target: "AUR-412", text: "mentioned you in a comment", snippet: "@you β€” can you weigh in on the tile-size heuristic? I think 256 is…", time: "12m", unread: true, kind: "issue" },
174
+ { id: "n2", type: "review", from: "u4", target: "#2339", text: "requested your review on", snippet: "feat(tokens): v3 semantic layer", time: "34m", unread: true, kind: "pr" },
175
+ { id: "n3", type: "assign", from: "u5", target: "NIM-110", text: "assigned you", snippet: "Regional failover runbook for EU-West primary", time: "1h", unread: true, kind: "issue" },
176
+ { id: "n4", type: "status", from: "u2", target: "#2341", text: "marked checks failing on", snippet: "e2e-canvas: 'cursor-reconnect' flaked on retry", time: "2h", unread: true, kind: "pr" },
177
+ { id: "n5", type: "comment", from: "u6", target: "EMB-523", text: "replied in a thread", snippet: "The snapshot approach feels right, but we should bound…", time: "3h", unread: false, kind: "issue" },
178
+ { id: "n6", type: "doc", from: "u1", target: "d12", text: "updated the PRD", snippet: "Aurora: collaborative canvas β€” v0.4 with multi-select changes", time: "6h", unread: false, kind: "doc" },
179
+ { id: "n7", type: "merged", from: "u6", target: "#2334", text: "merged pull request", snippet: "feat(comments): resolve threads with context snapshot", time: "1d", unread: false, kind: "pr" },
180
+ { id: "n8", type: "due", target: "HAL-033", text: "is due tomorrow", snippet: "Rotate SSO signing keys for SAML federation", time: "1d", unread: false, kind: "issue" },
181
+ ];
182
+
183
+ // Sprints
184
+ const SPRINTS = [
185
+ { id: "i42", name: "Iteration 42", range: "Apr 14 – Apr 28", active: true, done: 12, scope: 34, velocity: 47 },
186
+ { id: "i41", name: "Iteration 41", range: "Mar 31 – Apr 13", active: false, done: 29, scope: 29, velocity: 52 },
187
+ { id: "i40", name: "Iteration 40", range: "Mar 17 – Mar 30", active: false, done: 27, scope: 31, velocity: 45 },
188
+ { id: "i43", name: "Iteration 43", range: "Apr 28 – May 12", active: false, done: 0, scope: 18, velocity: 0 },
189
+ ];
190
+
191
+ // Roadmap items
192
+ const ROADMAP = [
193
+ { id: "r1", title: "Aurora 2.0 β€” collaborative canvas", project: "aurora", q: "Q2", startCol: 0, span: 9, progress: 62, status: "progress" },
194
+ { id: "r2", title: "Tessera design tokens v3", project: "tessera", q: "Q2", startCol: 1, span: 5, progress: 85, status: "review" },
195
+ { id: "r3", title: "Nimbus EU-West multi-region", project: "nimbus", q: "Q2", startCol: 2, span: 10, progress: 40, status: "progress" },
196
+ { id: "r4", title: "Halcyon β€” SSO hardening", project: "halcyon", q: "Q2", startCol: 0, span: 4, progress: 58, status: "progress" },
197
+ { id: "r5", title: "Ember β€” comment system overhaul", project: "ember", q: "Q2", startCol: 4, span: 6, progress: 30, status: "progress" },
198
+ { id: "r6", title: "Aurora plugins SDK preview", project: "aurora", q: "Q3", startCol: 9, span: 6, progress: 5, status: "todo" },
199
+ { id: "r7", title: "Tessera β€” motion system", project: "tessera", q: "Q3", startCol: 6, span: 4, progress: 0, status: "todo" },
200
+ { id: "r8", title: "Nimbus β€” query cost insights", project: "nimbus", q: "Q3", startCol: 12, span: 6, progress: 0, status: "todo" },
201
+ { id: "r9", title: "Halcyon β€” audit log streaming", project: "halcyon", q: "Q3", startCol: 4, span: 7, progress: 0, status: "todo" },
202
+ ];
203
+
204
+ Object.assign(window, {
205
+ PEOPLE, PROJECTS, LABELS, STATUS, STATUS_META, PRIORITY,
206
+ ISSUES, PRS, DOCS, INBOX, SPRINTS, ROADMAP,
207
+ Avatar, AvatarStack, PriorityGlyph, initials
208
+ });
detail-modals.jsx ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Rich detail modals β€” real content, not empty toasts.
2
+ // Each export is a component that renders inside ModalShell.
3
+
4
+ const WeekModal = ({ onClose }) => {
5
+ const days = [
6
+ { day: "Mon", date: "Apr 20", items: [
7
+ { time: "09:30", title: "Aurora canvas perf sync", kind: "meeting", people: ["u1","u2","u3"] },
8
+ { time: "11:00", title: "AUR-412 due", kind: "due", priority: "urgent" },
9
+ { time: "14:00", title: "1:1 with Rohan", kind: "meeting", people: ["u1","u2"] },
10
+ ]},
11
+ { day: "Tue", date: "Apr 21", items: [
12
+ { time: "10:00", title: "Iteration 42 mid-point review", kind: "meeting", people: ["u1","u2","u5","u7"] },
13
+ { time: "16:30", title: "Tessera token migration demo", kind: "demo" },
14
+ ]},
15
+ { day: "Wed", date: "Apr 22", items: [
16
+ { time: "09:00", title: "Design systems working group", kind: "meeting", people: ["u1","u4"] },
17
+ { time: "15:00", title: "TSR-88 due", kind: "due", priority: "high" },
18
+ ]},
19
+ { day: "Thu", date: "Apr 23", items: [
20
+ { time: "all day", title: "Focus day β€” no meetings", kind: "focus" },
21
+ ]},
22
+ { day: "Fri", date: "Apr 24", items: [
23
+ { time: "10:30", title: "Sprint retro Iter 42", kind: "meeting", people: ["u1","u2","u5","u7","u9"] },
24
+ { time: "14:00", title: "Release cut", kind: "milestone" },
25
+ ]},
26
+ ];
27
+ const kindColor = { meeting: "var(--violet)", due: "var(--rose)", demo: "var(--amber)", focus: "var(--accent)", milestone: "var(--cyan)" };
28
+ return (
29
+ <ModalShell title="This week" subtitle="Apr 20 – Apr 26 Β· 7 meetings Β· 2 due dates" onClose={onClose} width={620}>
30
+ <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
31
+ {days.map((d, i) => (
32
+ <div key={i} style={{ padding: "10px 0", borderBottom: i < days.length - 1 ? "1px solid var(--border-subtle)" : "none" }}>
33
+ <div className="flex items-center gap-10" style={{ marginBottom: 6 }}>
34
+ <div style={{ width: 56 }}>
35
+ <div className="mono" style={{ fontSize: 11, color: "var(--fg-3)", textTransform: "uppercase", letterSpacing: "0.08em" }}>{d.day}</div>
36
+ <div className="mono" style={{ fontSize: 13, color: i === 0 ? "var(--accent)" : "var(--fg-1)" }}>{d.date}</div>
37
+ </div>
38
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 4 }}>
39
+ {d.items.map((it, j) => (
40
+ <div key={j} className="flex items-center gap-10" style={{ fontSize: 12.5, padding: "4px 0" }}>
41
+ <span className="mono muted-2" style={{ fontSize: 11, width: 52 }}>{it.time}</span>
42
+ <span style={{ width: 4, height: 14, background: kindColor[it.kind], borderRadius: 2 }} />
43
+ <span style={{ flex: 1 }}>{it.title}</span>
44
+ {it.people && <AvatarStack users={it.people.map(id => PEOPLE.find(p => p.id === id))} size="xs" />}
45
+ {it.priority && <span className="tag" style={{ color: "var(--rose)" }}>{it.priority}</span>}
46
+ </div>
47
+ ))}
48
+ </div>
49
+ </div>
50
+ </div>
51
+ ))}
52
+ </div>
53
+ </ModalShell>
54
+ );
55
+ };
56
+
57
+ const DigestModal = ({ onClose }) => (
58
+ <ModalShell title="Daily digest" subtitle="Monday Β· 20 April 2026 Β· auto-generated 07:30" onClose={onClose} width={620}
59
+ footer={<>
60
+ <button className="btn ghost sm" onClick={onClose}>Close</button>
61
+ <button className="btn sm" onClick={() => { window.toast("Digest sent to #team-platform"); onClose(); }}><Icon name="sparkle" size={12} /> Send to Slack</button>
62
+ </>}>
63
+ <div style={{ fontSize: 13.5, lineHeight: 1.65, color: "var(--fg-1)" }}>
64
+ <div style={{ marginBottom: 18 }}>
65
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>At a glance</div>
66
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
67
+ <div className="card" style={{ padding: 10 }}>
68
+ <div className="muted" style={{ fontSize: 11 }}>Iteration progress</div>
69
+ <div className="mono" style={{ fontSize: 18, marginTop: 2 }}>12<span className="muted-2" style={{ fontSize: 11 }}>/34 pts</span></div>
70
+ </div>
71
+ <div className="card" style={{ padding: 10 }}>
72
+ <div className="muted" style={{ fontSize: 11 }}>Your review queue</div>
73
+ <div className="mono" style={{ fontSize: 18, marginTop: 2, color: "var(--amber)" }}>3 <span className="muted-2" style={{ fontSize: 11 }}>PRs waiting</span></div>
74
+ </div>
75
+ <div className="card" style={{ padding: 10 }}>
76
+ <div className="muted" style={{ fontSize: 11 }}>Blocked</div>
77
+ <div className="mono" style={{ fontSize: 18, marginTop: 2, color: "var(--rose)" }}>1 <span className="muted-2" style={{ fontSize: 11 }}>SSO audit</span></div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+
82
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>What changed overnight</div>
83
+ <ul style={{ paddingLeft: 18, marginBottom: 18 }}>
84
+ <li>Kenji pushed 4 commits to <span className="mono">perf/canvas-pipeline</span>; checks green.</li>
85
+ <li>Priya closed <span className="mono">TSR-88</span> (token migration β€” Android).</li>
86
+ <li>Security audit freeze pushed to Apr 29 β€” unblocks <span className="mono">SEC-12</span>.</li>
87
+ <li>Sara asked for your review on <span className="mono">#2341</span> 12m ago.</li>
88
+ </ul>
89
+
90
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Suggested focus today</div>
91
+ <ol style={{ paddingLeft: 18 }}>
92
+ <li>Review <span className="mono">#2341</span> β€” on Iteration 42 critical path.</li>
93
+ <li>Decide tile-size heuristic (Kenji's question on AUR-412).</li>
94
+ <li>30m to unblock SSO audit freeze before standup.</li>
95
+ </ol>
96
+ </div>
97
+ </ModalShell>
98
+ );
99
+
100
+ const FilterModal = ({ scope, onClose }) => {
101
+ const [status, setStatus] = React.useState(new Set());
102
+ const [priority, setPriority] = React.useState(new Set());
103
+ const [assignee, setAssignee] = React.useState(new Set());
104
+ const [labelSet, setLabelSet] = React.useState(new Set());
105
+ const toggle = (setFn, v) => setFn(prev => { const n = new Set(prev); n.has(v) ? n.delete(v) : n.add(v); return n; });
106
+ const count = status.size + priority.size + assignee.size + labelSet.size;
107
+
108
+ const Chip = ({ on, onClick, children, color }) => (
109
+ <button onClick={onClick} className="chip" style={{
110
+ background: on ? "var(--accent-soft)" : "transparent",
111
+ borderColor: on ? "var(--accent-dim)" : "var(--border)",
112
+ color: on ? "var(--fg-0)" : (color || "var(--fg-1)"),
113
+ cursor: "pointer"
114
+ }}>{on && <Icon name="check" size={10} strokeWidth={2.5} />} {children}</button>
115
+ );
116
+
117
+ return (
118
+ <ModalShell title={`Filter ${scope || "issues"}`} subtitle={`${count} filter${count === 1 ? "" : "s"} active`} onClose={onClose} width={520}
119
+ footer={<>
120
+ <button className="btn ghost sm" onClick={() => { setStatus(new Set()); setPriority(new Set()); setAssignee(new Set()); setLabelSet(new Set()); }}>Clear</button>
121
+ <button className="btn sm primary" onClick={() => { window.toast(count ? `${count} filters applied` : "No filters"); onClose(); }}>Apply</button>
122
+ </>}>
123
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Status</div>
124
+ <div className="flex items-center gap-6" style={{ flexWrap: "wrap", marginBottom: 14 }}>
125
+ {["backlog","todo","progress","review","done"].map(s => (
126
+ <Chip key={s} on={status.has(s)} onClick={() => toggle(setStatus, s)}>{s}</Chip>
127
+ ))}
128
+ </div>
129
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Priority</div>
130
+ <div className="flex items-center gap-6" style={{ flexWrap: "wrap", marginBottom: 14 }}>
131
+ {["urgent","high","medium","low"].map(p => (
132
+ <Chip key={p} on={priority.has(p)} onClick={() => toggle(setPriority, p)}>{p}</Chip>
133
+ ))}
134
+ </div>
135
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Assignee</div>
136
+ <div className="flex items-center gap-6" style={{ flexWrap: "wrap", marginBottom: 14 }}>
137
+ {PEOPLE.slice(0, 6).map(u => (
138
+ <button key={u.id} onClick={() => toggle(setAssignee, u.id)} className="chip" style={{
139
+ background: assignee.has(u.id) ? "var(--accent-soft)" : "transparent",
140
+ borderColor: assignee.has(u.id) ? "var(--accent-dim)" : "var(--border)",
141
+ cursor: "pointer", gap: 6
142
+ }}>
143
+ <Avatar user={u} size="xs" /> {u.name.split(" ")[0]}
144
+ </button>
145
+ ))}
146
+ </div>
147
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Labels</div>
148
+ <div className="flex items-center gap-6" style={{ flexWrap: "wrap" }}>
149
+ {LABELS.slice(0, 8).map(l => (
150
+ <Chip key={l.id} on={labelSet.has(l.id)} onClick={() => toggle(setLabelSet, l.id)} color={l.color}>#{l.name}</Chip>
151
+ ))}
152
+ </div>
153
+ </ModalShell>
154
+ );
155
+ };
156
+
157
+ const PRDetailModal = ({ pr, onClose }) => {
158
+ const author = PEOPLE.find(p => p.id === pr.author);
159
+ const reviewers = pr.reviewers.map(id => PEOPLE.find(p => p.id === id));
160
+ const files = [
161
+ { path: "src/renderer/tile.ts", add: 142, del: 18 },
162
+ { path: "src/renderer/composite.ts", add: 88, del: 34 },
163
+ { path: "src/hit-test/rtree.ts", add: 214, del: 0 },
164
+ { path: "test/renderer.bench.ts", add: 96, del: 12 },
165
+ ];
166
+ const commits = [
167
+ { sha: "a3f21e", msg: "refactor: extract tile from renderer", by: author, time: "6h" },
168
+ { sha: "b7c044", msg: "perf: adaptive tile sizing (256/128 split)", by: author, time: "4h" },
169
+ { sha: "e9d881", msg: "test: scene benchmarks at 1k/5k/10k", by: author, time: "2h" },
170
+ ];
171
+ return (
172
+ <ModalShell title={pr.title} subtitle={`${pr.id} Β· ${pr.branch} β†’ ${pr.base}`} onClose={onClose} width={720}
173
+ footer={<>
174
+ <button className="btn ghost sm" onClick={onClose}>Close</button>
175
+ <button className="btn ghost sm" onClick={() => { window.toast("Requested changes"); onClose(); }}>Request changes</button>
176
+ <button className="btn sm primary" onClick={() => { window.toast(`Approved ${pr.id}`); onClose(); }}><Icon name="check" size={12} /> Approve</button>
177
+ </>}>
178
+ <div className="flex items-center gap-10" style={{ marginBottom: 14 }}>
179
+ <Avatar user={author} size="sm" />
180
+ <div>
181
+ <div style={{ fontSize: 13 }}><strong>{author.name}</strong> <span className="muted">opened this PR Β· {pr.updated}</span></div>
182
+ <div className="muted-2 mono" style={{ fontSize: 11 }}>@{author.handle} Β· {pr.base}</div>
183
+ </div>
184
+ <div style={{ flex: 1 }} />
185
+ <span className="chip" style={{ color: "var(--accent)", textTransform: "capitalize" }}>{pr.status}</span>
186
+ </div>
187
+
188
+ <div className="card" style={{ padding: 12, marginBottom: 14 }}>
189
+ <div className="flex items-center gap-10" style={{ fontSize: 12 }}>
190
+ <div>
191
+ <div className="muted-2 mono" style={{ fontSize: 10 }}>CHECKS</div>
192
+ <div className="flex items-center gap-6 mono" style={{ marginTop: 2 }}>
193
+ {pr.checks.passed > 0 && <span style={{ color: "var(--status-done)" }}><Icon name="check" size={11} /> {pr.checks.passed}</span>}
194
+ {pr.checks.failed > 0 && <span style={{ color: "var(--rose)" }}><Icon name="x" size={11} /> {pr.checks.failed}</span>}
195
+ {pr.checks.running > 0 && <span style={{ color: "var(--amber)" }}><Icon name="clock" size={11} /> {pr.checks.running}</span>}
196
+ </div>
197
+ </div>
198
+ <div style={{ width: 1, height: 30, background: "var(--border)" }} />
199
+ <div>
200
+ <div className="muted-2 mono" style={{ fontSize: 10 }}>DIFF</div>
201
+ <div className="mono" style={{ marginTop: 2 }}><span style={{ color: "var(--status-done)" }}>+{pr.additions}</span> <span style={{ color: "var(--rose)" }}>βˆ’{pr.deletions}</span></div>
202
+ </div>
203
+ <div style={{ width: 1, height: 30, background: "var(--border)" }} />
204
+ <div>
205
+ <div className="muted-2 mono" style={{ fontSize: 10 }}>REVIEWERS</div>
206
+ <div style={{ marginTop: 2 }}><AvatarStack users={reviewers} size="xs" /></div>
207
+ </div>
208
+ {pr.issue && <>
209
+ <div style={{ width: 1, height: 30, background: "var(--border)" }} />
210
+ <div>
211
+ <div className="muted-2 mono" style={{ fontSize: 10 }}>LINKED</div>
212
+ <div className="mono" style={{ marginTop: 2 }}>{pr.issue}</div>
213
+ </div>
214
+ </>}
215
+ </div>
216
+ </div>
217
+
218
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Files changed</div>
219
+ <div style={{ border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden", marginBottom: 14 }}>
220
+ {files.map((f, i) => (
221
+ <div key={i} className="flex items-center gap-10" style={{ padding: "8px 12px", borderBottom: i < files.length - 1 ? "1px solid var(--border-subtle)" : "none", fontSize: 12 }}>
222
+ <Icon name="hash" size={11} style={{ color: "var(--fg-3)" }} />
223
+ <span className="mono flex-1 truncate">{f.path}</span>
224
+ <span className="mono" style={{ color: "var(--status-done)" }}>+{f.add}</span>
225
+ <span className="mono" style={{ color: "var(--rose)" }}>βˆ’{f.del}</span>
226
+ </div>
227
+ ))}
228
+ </div>
229
+
230
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Commits</div>
231
+ <div>
232
+ {commits.map((c, i) => (
233
+ <div key={i} className="flex items-center gap-10" style={{ padding: "6px 0", borderTop: i > 0 ? "1px solid var(--border-subtle)" : "none", fontSize: 12 }}>
234
+ <Avatar user={c.by} size="xs" />
235
+ <span className="mono muted-2" style={{ fontSize: 10.5, width: 56 }}>{c.sha}</span>
236
+ <span className="flex-1">{c.msg}</span>
237
+ <span className="muted-2 mono" style={{ fontSize: 11 }}>{c.time} ago</span>
238
+ </div>
239
+ ))}
240
+ </div>
241
+ </ModalShell>
242
+ );
243
+ };
244
+
245
+ const ProjectDetailModal = ({ project, onClose }) => {
246
+ const issues = ISSUES.filter(i => i.project === project.id);
247
+ const roadmap = ROADMAP.filter(r => r.project === project.id);
248
+ const byStatus = { backlog: 0, todo: 0, progress: 0, review: 0, done: 0 };
249
+ issues.forEach(i => { byStatus[i.status] = (byStatus[i.status] || 0) + 1; });
250
+ const team = [...new Set(issues.flatMap(i => i.assignees))].map(id => PEOPLE.find(p => p.id === id)).filter(Boolean);
251
+ const total = issues.length || 1;
252
+
253
+ return (
254
+ <ModalShell
255
+ title={<span className="flex items-center gap-8"><span style={{ width: 10, height: 10, borderRadius: 3, background: project.color }} />{project.name}</span>}
256
+ subtitle={`${project.code} Β· ${issues.length} issues Β· ${team.length} contributors`}
257
+ onClose={onClose} width={680}
258
+ footer={<>
259
+ <button className="btn ghost sm" onClick={onClose}>Close</button>
260
+ <button className="btn sm primary" onClick={() => { onClose(); window.__goIssues && window.__goIssues(); }}>Open project β†’</button>
261
+ </>}>
262
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 14 }}>
263
+ <div className="card" style={{ padding: 12 }}>
264
+ <div className="muted mono" style={{ fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.08em", marginBottom: 8 }}>Issue breakdown</div>
265
+ <div style={{ display: "flex", height: 6, borderRadius: 3, overflow: "hidden", background: "var(--bg-3)", marginBottom: 8 }}>
266
+ <div style={{ flex: byStatus.done, background: "var(--status-done)" }} />
267
+ <div style={{ flex: byStatus.review, background: "var(--status-review)" }} />
268
+ <div style={{ flex: byStatus.progress, background: "var(--status-progress)" }} />
269
+ <div style={{ flex: byStatus.todo + byStatus.backlog, background: "var(--fg-3)" }} />
270
+ </div>
271
+ {[
272
+ { k: "done", l: "Done", c: "var(--status-done)" },
273
+ { k: "review", l: "In review", c: "var(--status-review)" },
274
+ { k: "progress", l: "In progress", c: "var(--status-progress)" },
275
+ { k: "todo", l: "Todo", c: "var(--fg-3)" },
276
+ ].map(r => (
277
+ <div key={r.k} className="flex items-center gap-6" style={{ fontSize: 11.5, padding: "2px 0" }}>
278
+ <span style={{ width: 6, height: 6, borderRadius: 2, background: r.c }} />
279
+ <span style={{ flex: 1 }}>{r.l}</span>
280
+ <span className="mono muted-2">{byStatus[r.k]} Β· {Math.round((byStatus[r.k] / total) * 100)}%</span>
281
+ </div>
282
+ ))}
283
+ </div>
284
+ <div className="card" style={{ padding: 12 }}>
285
+ <div className="muted mono" style={{ fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.08em", marginBottom: 8 }}>Contributors</div>
286
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
287
+ {team.slice(0, 5).map(u => (
288
+ <div key={u.id} className="flex items-center gap-8" style={{ fontSize: 12 }}>
289
+ <Avatar user={u} size="xs" />
290
+ <span>{u.name}</span>
291
+ <span className="mono muted-2" style={{ marginLeft: "auto", fontSize: 11 }}>{issues.filter(i => i.assignees.includes(u.id)).length}</span>
292
+ </div>
293
+ ))}
294
+ </div>
295
+ </div>
296
+ </div>
297
+
298
+ {roadmap.length > 0 && <>
299
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Roadmap</div>
300
+ <div style={{ display: "flex", flexDirection: "column", gap: 4, marginBottom: 14 }}>
301
+ {roadmap.map(r => (
302
+ <div key={r.id} className="flex items-center gap-8" style={{ fontSize: 12.5, padding: "6px 0", borderBottom: "1px solid var(--border-subtle)" }}>
303
+ <Icon name="bolt" size={11} style={{ color: project.color }} />
304
+ <span style={{ flex: 1 }}>{r.title}</span>
305
+ <div style={{ width: 60, height: 3, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden" }}>
306
+ <div style={{ width: `${r.progress}%`, height: "100%", background: project.color }} />
307
+ </div>
308
+ <span className="mono muted-2" style={{ fontSize: 11 }}>{r.progress}%</span>
309
+ </div>
310
+ ))}
311
+ </div>
312
+ </>}
313
+
314
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Recent issues</div>
315
+ <div>
316
+ {issues.slice(0, 5).map(is => (
317
+ <div key={is.id} className="flex items-center gap-8" style={{ padding: "6px 0", fontSize: 12, borderBottom: "1px solid var(--border-subtle)" }}>
318
+ <PriorityGlyph level={is.priority} />
319
+ <span className="mono muted-2" style={{ fontSize: 11, width: 64 }}>{is.id}</span>
320
+ <span className="flex-1 truncate">{is.title}</span>
321
+ <AvatarStack users={is.assignees.map(id => PEOPLE.find(p => p.id === id))} size="xs" />
322
+ </div>
323
+ ))}
324
+ </div>
325
+ </ModalShell>
326
+ );
327
+ };
328
+
329
+ const SprintReportModal = ({ onClose }) => (
330
+ <ModalShell title="Iteration 42 β€” Report" subtitle="Apr 14 – Apr 28 Β· day 6 of 14" onClose={onClose} width={620}
331
+ footer={<>
332
+ <button className="btn ghost sm" onClick={onClose}>Close</button>
333
+ <button className="btn sm" onClick={() => { window.toast("Report exported to PDF"); onClose(); }}><Icon name="download" size={12} /> Export PDF</button>
334
+ </>}>
335
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 10, marginBottom: 16 }}>
336
+ {[
337
+ { l: "Scope", v: "34 pts" },
338
+ { l: "Completed", v: "12 pts", c: "var(--status-done)" },
339
+ { l: "Remaining", v: "22 pts" },
340
+ { l: "Confidence", v: "82%", c: "var(--status-done)" },
341
+ ].map((s, i) => (
342
+ <div key={i} className="card" style={{ padding: 10 }}>
343
+ <div className="muted mono" style={{ fontSize: 10.5 }}>{s.l}</div>
344
+ <div className="mono" style={{ fontSize: 18, color: s.c || "var(--fg-0)", marginTop: 2 }}>{s.v}</div>
345
+ </div>
346
+ ))}
347
+ </div>
348
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Highlights</div>
349
+ <ul style={{ paddingLeft: 18, fontSize: 13, lineHeight: 1.7, color: "var(--fg-1)", marginBottom: 14 }}>
350
+ <li>Canvas tile-based pipeline on track β€” p95 paint down from 18ms β†’ 9ms.</li>
351
+ <li>Token migration closed for iOS + Android (TSR-88).</li>
352
+ <li>SSO audit blocked by Infra review β€” est. +3d slip.</li>
353
+ </ul>
354
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Risks</div>
355
+ <ul style={{ paddingLeft: 18, fontSize: 13, lineHeight: 1.7, color: "var(--fg-1)" }}>
356
+ <li><span style={{ color: "var(--rose)" }}>High</span> Β· SSO audit freeze β€” coordination with Security eng.</li>
357
+ <li><span style={{ color: "var(--amber)" }}>Medium</span> Β· Plugin shim timing β€” needs decision by day 10.</li>
358
+ </ul>
359
+ </ModalShell>
360
+ );
361
+
362
+ const RetroModal = ({ onClose }) => (
363
+ <ModalShell title="Retrospective draft" subtitle="Auto-generated by Meridian AI from Iteration 42 activity" onClose={onClose} width={620}
364
+ footer={<>
365
+ <button className="btn ghost sm" onClick={onClose}>Close</button>
366
+ <button className="btn sm" onClick={() => { window.toast("Retro saved to Docs β†’ Retros"); onClose(); }}>Save to docs</button>
367
+ </>}>
368
+ <div style={{ fontSize: 13.5, lineHeight: 1.7, color: "var(--fg-1)" }}>
369
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 4 }}>What went well</div>
370
+ <ul style={{ paddingLeft: 18, marginBottom: 14 }}>
371
+ <li>Tile-based pipeline PR landed with zero rework β€” good prior spec.</li>
372
+ <li>Kenji + Sara's pairing on hit-test rtree halved original estimate.</li>
373
+ </ul>
374
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 4 }}>What didn't</div>
375
+ <ul style={{ paddingLeft: 18, marginBottom: 14 }}>
376
+ <li>SSO audit blockers discovered day 4, not day 0 β€” slipped scope.</li>
377
+ <li>Design review for token migration queued behind launch prep.</li>
378
+ </ul>
379
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 4 }}>Action items</div>
380
+ <ul style={{ paddingLeft: 18 }}>
381
+ <li>Add dependency mapping to kickoff template (owner: Amara).</li>
382
+ <li>Reserve 2 design review slots per week (owner: Priya).</li>
383
+ </ul>
384
+ </div>
385
+ </ModalShell>
386
+ );
387
+
388
+ const AIAnswerModal = ({ question, onClose }) => {
389
+ const answers = {
390
+ "Summarize Iteration 42 progress": {
391
+ tldr: "On track β€” 12/34 pts done at day 6/14, 82% confidence to close by Apr 26.",
392
+ body: [
393
+ "Canvas rebuild (AUR-412) is the iteration's critical path and it's shipping under budget. Kenji's pairing with Sara compressed the hit-test rtree work from 8pts to 5pts.",
394
+ "Token migration (TSR-88) closed yesterday. Design systems team now unblocked on cascading work.",
395
+ "SSO infra freeze (SEC-12) is the one red flag β€” blocked waiting on Security eng review. Audit deadline is firm; consider escalating.",
396
+ ],
397
+ sources: ["AUR-412", "TSR-88", "SEC-12", "Iter 42 board"],
398
+ },
399
+ "What's blocking Aurora?": {
400
+ tldr: "One live blocker: tile-size heuristic decision on AUR-412. Kenji is waiting on a call.",
401
+ body: [
402
+ "Kenji's question (12m ago): 'Are we going 256px tiles or adaptive? Worried about memory on large documents.'",
403
+ "Sara's prototype in the branch uses adaptive β€” base 256, halves if point count > 2k. Working well in benchmarks (9ms p95 at 10k nodes).",
404
+ "Recommend: approve adaptive approach, park the fixed-tile alternative for post-ship measurement.",
405
+ ],
406
+ sources: ["AUR-412", "#2341", "Kenji comment"],
407
+ },
408
+ "Draft release notes for last sprint": {
409
+ tldr: "Iteration 41 shipped 3 user-facing changes + 2 infra improvements.",
410
+ body: [
411
+ "**Canvas performance** β€” multi-select drag is now smooth up to 10k nodes (was ~2k).",
412
+ "**Tokens** β€” iOS and Android token libraries now regenerate nightly from the single source.",
413
+ "**Infra** β€” CI perf budget guard (fails builds that regress p95 paint > 8ms).",
414
+ ],
415
+ sources: ["Iter 41 board", "#2298", "#2312"],
416
+ },
417
+ "What are the top blocking issues right now?": {
418
+ tldr: "3 active blockers across Aurora and Tessera. SEC-12 is highest severity β€” audit deadline is firm.",
419
+ body: [
420
+ "**SEC-12** (Blocker) β€” SSO infra freeze pending Security eng review. Audit deadline Jun 1 is immovable. Escalation recommended today.",
421
+ "**AUR-401** (High) β€” Canvas hit-test regression under 10k nodes. Kenji identified root cause; fix in review. ETA: tomorrow.",
422
+ "**TSR-92** (Medium) β€” Tessera token sync failing on Windows CI. Intermittent; likely a path separator bug. Assigned to Priya.",
423
+ ],
424
+ sources: ["SEC-12", "AUR-401", "TSR-92", "Iter 42 board"],
425
+ },
426
+ "What's the status of open pull requests?": {
427
+ tldr: "7 open PRs: 2 ready to merge, 3 in review, 2 drafts. Oldest is 4 days stale.",
428
+ body: [
429
+ "**Ready to merge**: #2341 (token sync fix) and #2338 (CI timeout bump) β€” both approved, checks green.",
430
+ "**In review**: #2345 (canvas resize), #2347 (SSO middleware), #2349 (notifier service) β€” all awaiting 1 more approval.",
431
+ "**Drafts**: #2350 (Tessera dark mode), #2352 (perf dashboard) β€” not ready for review.",
432
+ "Recommend merging #2341 first β€” it unblocks Tessera iOS and Android nightly builds.",
433
+ ],
434
+ sources: ["#2341", "#2338", "#2345", "PR board"],
435
+ },
436
+ "Who has the highest workload this sprint?": {
437
+ tldr: "Kenji is over-allocated at 18pts assigned vs 12pt sprint capacity. Sara and Priya are on track.",
438
+ body: [
439
+ "**Kenji** (18pts / 12pt cap) β€” carrying AUR-412 canvas rebuild solo since Diego moved to security audit. Recommend pulling TSR-92 off his plate.",
440
+ "**Sara** (11pts) β€” on track. Leading design systems token migration; pairing with Kenji 2h/day this week.",
441
+ "**Priya** (9pts) β€” slightly under-allocated. Available to absorb TSR-92 (3pts) if Kenji needs relief.",
442
+ "**Diego** (8pts) β€” pulled into SEC-12 mid-sprint. His original 6pts of canvas work is now unassigned.",
443
+ ],
444
+ sources: ["Iter 42 board", "Team capacity", "AUR-412", "SEC-12"],
445
+ },
446
+ "What milestones are at risk this quarter?": {
447
+ tldr: "Aurora launch (Jun 12) is amber. SSO audit (Jul 1) is red. Tessera 2.0 (Aug 22) is green.",
448
+ body: [
449
+ "**Aurora launch Jun 12** (Amber) β€” canvas rebuild on track, but SEC-12 SSO blocker creates a 5-day slip risk if not resolved by May 15. Mitigation: ship without SSO, add in hotfix.",
450
+ "**SSO audit Jul 1** (Red) β€” Security eng review not started. Requires 3-week turnaround minimum. Must begin by Jun 9 to hit deadline. Escalate to VP Eng.",
451
+ "**Tessera 2.0 Aug 22** (Green) β€” token migration complete, dark mode in progress. 6-week buffer remaining.",
452
+ ],
453
+ sources: ["Roadmap Q2–Q3", "SEC-12", "AUR-412", "TSR-88"],
454
+ },
455
+ };
456
+ const a = answers[question] || { tldr: "Meridian AI is thinking…", body: [], sources: [] };
457
+ return (
458
+ <ModalShell
459
+ title={<span className="flex items-center gap-8"><Icon name="sparkle" size={14} style={{ color: "var(--accent)" }} />{question}</span>}
460
+ subtitle="Meridian AI Β· drawing from issues, PRs, and recent activity"
461
+ onClose={onClose} width={600}
462
+ footer={<>
463
+ <button className="btn ghost sm" onClick={onClose}>Close</button>
464
+ <button className="btn sm" onClick={() => { window.toast("Answer copied"); onClose(); }}><Icon name="link" size={12} /> Copy answer</button>
465
+ </>}>
466
+ <div className="card" style={{ padding: 12, marginBottom: 14, borderColor: "var(--accent-dim)", background: "var(--accent-soft)" }}>
467
+ <div className="mono muted-2" style={{ fontSize: 10.5, marginBottom: 4, letterSpacing: "0.08em", textTransform: "uppercase" }}>TL;DR</div>
468
+ <div style={{ fontSize: 13.5 }}>{a.tldr}</div>
469
+ </div>
470
+ <div style={{ fontSize: 13.5, lineHeight: 1.7, color: "var(--fg-1)", marginBottom: 14 }}>
471
+ {a.body.map((p, i) => <p key={i} style={{ marginBottom: 8 }}>{p}</p>)}
472
+ </div>
473
+ {a.sources.length > 0 && (
474
+ <div>
475
+ <div className="mono muted-2" style={{ fontSize: 10.5, marginBottom: 4, letterSpacing: "0.08em", textTransform: "uppercase" }}>Sources</div>
476
+ <div className="flex items-center gap-6" style={{ flexWrap: "wrap" }}>
477
+ {a.sources.map((s, i) => <span key={i} className="chip mono">{s}</span>)}
478
+ </div>
479
+ </div>
480
+ )}
481
+ </ModalShell>
482
+ );
483
+ };
484
+
485
+ const TeammateModal = ({ user, onClose }) => {
486
+ const assigned = ISSUES.filter(i => i.assignees.includes(user.id));
487
+ const authored = PRS.filter(p => p.author === user.id);
488
+ return (
489
+ <ModalShell
490
+ title={<span className="flex items-center gap-10"><Avatar user={user} size="sm" />{user.name}</span>}
491
+ subtitle={`@${user.handle} Β· Helix workspace`}
492
+ onClose={onClose} width={560}
493
+ footer={<>
494
+ <button className="btn ghost sm" onClick={onClose}>Close</button>
495
+ <button className="btn sm" onClick={() => { window.toast(`Messaged ${user.name.split(" ")[0]}`); onClose(); }}><Icon name="message" size={12} /> Message</button>
496
+ </>}>
497
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: 14 }}>
498
+ <div className="card" style={{ padding: 10 }}>
499
+ <div className="muted mono" style={{ fontSize: 10.5 }}>Open issues</div>
500
+ <div className="mono" style={{ fontSize: 20, marginTop: 2 }}>{assigned.length}</div>
501
+ </div>
502
+ <div className="card" style={{ padding: 10 }}>
503
+ <div className="muted mono" style={{ fontSize: 10.5 }}>PRs authored</div>
504
+ <div className="mono" style={{ fontSize: 20, marginTop: 2 }}>{authored.length}</div>
505
+ </div>
506
+ <div className="card" style={{ padding: 10 }}>
507
+ <div className="muted mono" style={{ fontSize: 10.5 }}>Timezone</div>
508
+ <div className="mono" style={{ fontSize: 13, marginTop: 4 }}>Europe/Berlin</div>
509
+ </div>
510
+ </div>
511
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Currently working on</div>
512
+ {assigned.slice(0, 4).map(is => (
513
+ <div key={is.id} className="flex items-center gap-8" style={{ padding: "6px 0", fontSize: 12, borderBottom: "1px solid var(--border-subtle)" }}>
514
+ <PriorityGlyph level={is.priority} />
515
+ <span className="mono muted-2" style={{ fontSize: 11, width: 64 }}>{is.id}</span>
516
+ <span className="flex-1 truncate">{is.title}</span>
517
+ <span className={`status ${is.status === "backlog" ? "todo" : is.status}`}><span className="s-dot" /></span>
518
+ </div>
519
+ ))}
520
+ </ModalShell>
521
+ );
522
+ };
523
+
524
+ const AttachModal = ({ onClose }) => {
525
+ const [stage, setStage] = React.useState("picker");
526
+ const files = [
527
+ { name: "paint-trace-apr18.json", size: "842 KB", icon: "hash" },
528
+ { name: "benchmark-10k.pdf", size: "1.2 MB", icon: "docs" },
529
+ { name: "tile-size-sketch.png", size: "210 KB", icon: "image" },
530
+ ];
531
+ return (
532
+ <ModalShell title="Attach" onClose={onClose} width={480}
533
+ footer={<>
534
+ <button className="btn ghost sm" onClick={onClose}>Cancel</button>
535
+ </>}>
536
+ <div className="card" style={{ padding: 18, textAlign: "center", border: "2px dashed var(--border-strong)", marginBottom: 14, cursor: "pointer" }}
537
+ onClick={() => { window.toast("File picker opened (demo)"); }}>
538
+ <Icon name="attach" size={22} style={{ color: "var(--fg-3)" }} />
539
+ <div style={{ fontSize: 13, marginTop: 8, fontWeight: 500 }}>Drop files or click to browse</div>
540
+ <div className="muted" style={{ fontSize: 11.5, marginTop: 2 }}>Up to 100 MB per file</div>
541
+ </div>
542
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 6 }}>Recent in this workspace</div>
543
+ <div>
544
+ {files.map((f, i) => (
545
+ <button key={i} className="flex items-center gap-10" style={{ width: "100%", padding: "8px 6px", textAlign: "left", borderRadius: 6, borderBottom: i < files.length - 1 ? "1px solid var(--border-subtle)" : "none" }}
546
+ onClick={() => { window.toast(`Attached ${f.name}`); onClose(); }}>
547
+ <Icon name={f.icon} size={14} style={{ color: "var(--fg-3)" }} />
548
+ <span style={{ flex: 1, fontSize: 12.5 }}>{f.name}</span>
549
+ <span className="muted-2 mono" style={{ fontSize: 11 }}>{f.size}</span>
550
+ </button>
551
+ ))}
552
+ </div>
553
+ </ModalShell>
554
+ );
555
+ };
556
+
557
+ Object.assign(window, { WeekModal, DigestModal, FilterModal, PRDetailModal, ProjectDetailModal, SprintReportModal, RetroModal, AIAnswerModal, TeammateModal, AttachModal });
icons.jsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Minimal stroke icon set β€” lucide-style, 16px default
2
+ const Icon = ({ name, size = 16, className = "", strokeWidth = 1.6, ...rest }) => {
3
+ const common = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth, strokeLinecap: "round", strokeLinejoin: "round", className, ...rest };
4
+ switch (name) {
5
+ case "search": return <svg {...common}><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>;
6
+ case "inbox": return <svg {...common}><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5.5 3 12v6a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-6l-2.5-6.5A2 2 0 0 0 16.6 4H7.4a2 2 0 0 0-1.9 1.5Z"/></svg>;
7
+ case "home": return <svg {...common}><path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2h-4v-7h-6v7H5a2 2 0 0 1-2-2Z"/></svg>;
8
+ case "dash": return <svg {...common}><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>;
9
+ case "issues": return <svg {...common}><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>;
10
+ case "roadmap": return <svg {...common}><path d="M3 6h4M17 6h4M3 12h4M17 12h4M3 18h4M17 18h4"/><path d="M7 6h10M7 12h6M7 18h8"/></svg>;
11
+ case "sprint": return <svg {...common}><path d="M13 2 3 14h7l-1 8 10-12h-7z"/></svg>;
12
+ case "docs": return <svg {...common}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h6"/></svg>;
13
+ case "git": return <svg {...common}><circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M18 8.5v3a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4v-3"/><path d="M12 12.5v3"/></svg>;
14
+ case "team": return <svg {...common}><circle cx="9" cy="8" r="4"/><path d="M1 21a8 8 0 0 1 16 0"/><circle cx="17" cy="6" r="3"/><path d="M23 17a6 6 0 0 0-6-6"/></svg>;
15
+ case "settings": return <svg {...common}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.7l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.7-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 0 1-4 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.7.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.7 1.6 1.6 0 0 0-1.5-1H3a2 2 0 0 1 0-4h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.7l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.7.3h.1a1.6 1.6 0 0 0 1-1.5V3a2 2 0 0 1 4 0v.1a1.6 1.6 0 0 0 1 1.5h.1a1.6 1.6 0 0 0 1.7-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.7v.1a1.6 1.6 0 0 0 1.5 1H21a2 2 0 0 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"/></svg>;
16
+ case "plus": return <svg {...common}><path d="M12 5v14M5 12h14"/></svg>;
17
+ case "filter": return <svg {...common}><path d="M3 5h18l-7 9v6l-4-2v-4L3 5z"/></svg>;
18
+ case "chevron-down": return <svg {...common}><path d="m6 9 6 6 6-6"/></svg>;
19
+ case "chevron-right": return <svg {...common}><path d="m9 6 6 6-6 6"/></svg>;
20
+ case "chevron-left": return <svg {...common}><path d="m15 6-6 6 6 6"/></svg>;
21
+ case "more": return <svg {...common}><circle cx="12" cy="12" r="1.2"/><circle cx="5" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/></svg>;
22
+ case "star": return <svg {...common}><path d="m12 3 2.9 6 6.6.6-5 4.5 1.5 6.5L12 17.7 6 20.6l1.5-6.5-5-4.5 6.6-.6z"/></svg>;
23
+ case "calendar": return <svg {...common}><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>;
24
+ case "clock": return <svg {...common}><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>;
25
+ case "check": return <svg {...common}><path d="m5 12 5 5 10-12"/></svg>;
26
+ case "x": return <svg {...common}><path d="M6 6l12 12M18 6 6 18"/></svg>;
27
+ case "link": return <svg {...common}><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 1 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 1 0 7 7l1-1"/></svg>;
28
+ case "branch": return <svg {...common}><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>;
29
+ case "pr": return <svg {...common}><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M6 8.5v7"/><path d="M11 6h3a4 4 0 0 1 4 4v5.5"/></svg>;
30
+ case "commit": return <svg {...common}><circle cx="12" cy="12" r="3.5"/><path d="M3 12h5.5M15.5 12H21"/></svg>;
31
+ case "message": return <svg {...common}><path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>;
32
+ case "bell": return <svg {...common}><path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>;
33
+ case "tag": return <svg {...common}><path d="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8Z"/><circle cx="7.5" cy="7.5" r="1.2"/></svg>;
34
+ case "flag": return <svg {...common}><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><path d="M4 22v-7"/></svg>;
35
+ case "sidebar": return <svg {...common}><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg>;
36
+ case "expand": return <svg {...common}><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>;
37
+ case "sparkle": return <svg {...common}><path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2 2M16 16l2 2M6 18l2-2M16 8l2-2"/></svg>;
38
+ case "doc-add": return <svg {...common}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M12 13v5M9.5 15.5h5"/></svg>;
39
+ case "lock": return <svg {...common}><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 1 1 8 0v4"/></svg>;
40
+ case "globe": return <svg {...common}><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18Z"/></svg>;
41
+ case "bolt": return <svg {...common}><path d="M13 2 3 14h7l-1 8 10-12h-7z"/></svg>;
42
+ case "play": return <svg {...common}><path d="M6 3v18l15-9z"/></svg>;
43
+ case "list": return <svg {...common}><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>;
44
+ case "board": return <svg {...common}><rect x="3" y="3" width="7" height="14" rx="1"/><rect x="14" y="3" width="7" height="10" rx="1"/></svg>;
45
+ case "timeline": return <svg {...common}><path d="M3 7h9M3 12h14M3 17h6"/><circle cx="13" cy="7" r="2"/><circle cx="18" cy="12" r="2"/><circle cx="10" cy="17" r="2"/></svg>;
46
+ case "download": return <svg {...common}><path d="M12 3v12M6 11l6 6 6-6M4 21h16"/></svg>;
47
+ case "eye": return <svg {...common}><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12Z"/><circle cx="12" cy="12" r="3"/></svg>;
48
+ case "attach": return <svg {...common}><path d="m21.4 11.1-9.2 9.2a6 6 0 0 1-8.5-8.4l9.2-9.3a4 4 0 1 1 5.7 5.7l-9.3 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>;
49
+ case "arrow-up": return <svg {...common}><path d="M12 19V5M5 12l7-7 7 7"/></svg>;
50
+ case "arrow-right": return <svg {...common}><path d="M5 12h14M13 5l7 7-7 7"/></svg>;
51
+ case "trend": return <svg {...common}><path d="m3 17 6-6 4 4 8-8M14 7h7v7"/></svg>;
52
+ case "database": return <svg {...common}><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v6c0 1.7 4 3 9 3s9-1.3 9-3V5M3 11v6c0 1.7 4 3 9 3s9-1.3 9-3v-6"/></svg>;
53
+ case "component": return <svg {...common}><path d="M12 2 4 6l8 4 8-4zM4 12l8 4 8-4M4 18l8 4 8-4"/></svg>;
54
+ case "moon": return <svg {...common}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>;
55
+ case "sun": return <svg {...common}><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>;
56
+ case "code": return <svg {...common}><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg>;
57
+ case "hash": return <svg {...common}><path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/></svg>;
58
+ case "at": return <svg {...common}><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/></svg>;
59
+ default: return <svg {...common}><rect x="4" y="4" width="16" height="16" rx="2"/></svg>;
60
+ }
61
+ };
62
+
63
+ window.Icon = Icon;
index.html CHANGED
Binary files a/index.html and b/index.html differ
 
interactions.jsx ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Global interaction helpers: toast, modal, project selection, copy link.
2
+ // Exposes window.toast(msg), window.confirmAction(msg, cb), window.openNewIssue(),
3
+ // window.openShareModal(title), and a <Toaster /> component mounted by App.
4
+
5
+ (function () {
6
+ const listeners = new Set();
7
+ let nextId = 1;
8
+ const state = { toasts: [], modal: null };
9
+
10
+ const emit = () => listeners.forEach(l => l());
11
+
12
+ window.__ui = {
13
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
14
+ get state() { return state; },
15
+ toast(msg, opts = {}) {
16
+ const id = nextId++;
17
+ state.toasts = [...state.toasts, { id, msg, kind: opts.kind || "info" }];
18
+ emit();
19
+ setTimeout(() => {
20
+ state.toasts = state.toasts.filter(t => t.id !== id);
21
+ emit();
22
+ }, opts.duration || 2800);
23
+ },
24
+ openModal(modal) { state.modal = modal; emit(); },
25
+ closeModal() { state.modal = null; emit(); },
26
+ };
27
+
28
+ // Convenience
29
+ window.toast = (msg, opts) => window.__ui.toast(msg, opts);
30
+ window.copyLink = (label) => {
31
+ const url = `https://meridian.app/${(label || "link").toLowerCase().replace(/\s+/g, "-")}`;
32
+ try {
33
+ navigator.clipboard?.writeText(url);
34
+ } catch (e) { /* noop */ }
35
+ window.toast(`Link copied β€” ${url}`);
36
+ };
37
+ window.openNewIssue = (prefill) => window.__ui.openModal({ kind: "new-issue", prefill: prefill || {} });
38
+ window.openNewDoc = () => window.__ui.openModal({ kind: "new-doc" });
39
+ window.openNewPR = () => window.__ui.openModal({ kind: "new-pr" });
40
+ window.openInvite = () => window.__ui.openModal({ kind: "invite" });
41
+ window.openShare = (title) => window.__ui.openModal({ kind: "share", title });
42
+ window.openPicker = (opts) => window.__ui.openModal({ kind: "picker", ...opts });
43
+ window.openWeek = () => window.__ui.openModal({ kind: "week" });
44
+ window.openDigest = () => window.openAI("Give me a daily digest of what happened, what I need to focus on, and any risks.", "general", { inbox: typeof INBOX !== 'undefined' ? INBOX : [], issues: typeof ISSUES !== 'undefined' ? ISSUES : [] });
45
+ window.openFilter = (scope) => window.__ui.openModal({ kind: "filter", scope });
46
+ window.openPR = (pr) => window.__ui.openModal({ kind: "pr-detail", pr });
47
+ window.openProject = (project) => window.__ui.openModal({ kind: "project", project });
48
+ window.openSprintReport = () => window.openAI("Generate a sprint report for Iteration 42. Summarize completed work, carryover, and team velocity.", "sprint", { issues: typeof ISSUES !== 'undefined' ? ISSUES : [] });
49
+ window.openRetro = () => window.openAI("Draft a retrospective for the current sprint. Identify what went well, what didn't, and action items.", "sprint", { issues: typeof ISSUES !== 'undefined' ? ISSUES : [] });
50
+ window.openTeammate = (user) => window.__ui.openModal({ kind: "teammate", user });
51
+ window.openAttach = () => window.__ui.openModal({ kind: "attach" });
52
+ })();
53
+
54
+ // ---- React bits ----
55
+ function useUIState() {
56
+ const [, force] = React.useReducer(x => x + 1, 0);
57
+ React.useEffect(() => window.__ui.subscribe(force), []);
58
+ return window.__ui.state;
59
+ }
60
+
61
+ const Toaster = () => {
62
+ const { toasts } = useUIState();
63
+ return (
64
+ <div style={{
65
+ position: "fixed", bottom: 20, left: "50%", transform: "translateX(-50%)",
66
+ display: "flex", flexDirection: "column", gap: 6, zIndex: 500, pointerEvents: "none"
67
+ }}>
68
+ {toasts.map(t => (
69
+ <div key={t.id} className="mono" style={{
70
+ background: "var(--bg-2)", border: "1px solid var(--border-strong)",
71
+ color: "var(--fg-0)", padding: "8px 14px", borderRadius: 8, fontSize: 12,
72
+ boxShadow: "0 8px 24px -8px rgba(0,0,0,0.5)", pointerEvents: "auto",
73
+ animation: "toastIn 180ms ease",
74
+ }}>{t.msg}</div>
75
+ ))}
76
+ </div>
77
+ );
78
+ };
79
+
80
+ const ModalShell = ({ title, subtitle, onClose, children, footer, width = 480 }) => (
81
+ <div className="overlay" onClick={onClose} style={{ alignItems: "center" }}>
82
+ <div onClick={e => e.stopPropagation()} style={{
83
+ width, maxWidth: "calc(100vw - 40px)", background: "var(--bg-1)",
84
+ border: "1px solid var(--border-strong)", borderRadius: 12,
85
+ boxShadow: "0 30px 80px -20px rgba(0,0,0,0.6)", overflow: "hidden",
86
+ display: "flex", flexDirection: "column", maxHeight: "80vh"
87
+ }}>
88
+ <div style={{ padding: "14px 18px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 10 }}>
89
+ <div style={{ flex: 1, minWidth: 0 }}>
90
+ <div style={{ fontSize: 13.5, fontWeight: 600 }}>{title}</div>
91
+ {subtitle && <div className="muted mono" style={{ fontSize: 11, marginTop: 2 }}>{subtitle}</div>}
92
+ </div>
93
+ <button className="icon-btn" onClick={onClose}><Icon name="x" size={14} /></button>
94
+ </div>
95
+ <div style={{ flex: 1, overflow: "auto", padding: 18 }}>{children}</div>
96
+ {footer && <div style={{ padding: "10px 14px", borderTop: "1px solid var(--border)", display: "flex", gap: 8, justifyContent: "flex-end", background: "var(--bg-0)" }}>{footer}</div>}
97
+ </div>
98
+ </div>
99
+ );
100
+
101
+ const NewIssueModal = ({ prefill, onClose }) => {
102
+ const [title, setTitle] = React.useState(prefill.title || "");
103
+ const [description, setDescription] = React.useState("");
104
+ const [priority, setPriority] = React.useState(prefill.priority || "med");
105
+ const [project, setProject] = React.useState(prefill.project || PROJECTS[0].id);
106
+ const submit = async () => {
107
+ if (!title.trim()) { window.toast("Add a title first"); return; }
108
+ const proj = PROJECTS.find(p => p.id === project);
109
+ try {
110
+ await window.apiFetch('POST', '/api/issues', { title: title.trim(), description: description.trim(), priority, project, status: prefill.status || 'backlog' });
111
+ window.meridianRefresh();
112
+ window.toast(`Created issue in ${proj.code}`);
113
+ onClose();
114
+ } catch (e) {
115
+ window.toast("Failed to create issue");
116
+ }
117
+ };
118
+ return (
119
+ <ModalShell title="New issue" subtitle="βŒ˜β†΅ to create Β· ESC to dismiss" onClose={onClose} width={560}
120
+ footer={<>
121
+ <button className="btn ghost sm" onClick={onClose}>Cancel</button>
122
+ <button className="btn sm primary" onClick={submit}>Create issue</button>
123
+ </>}>
124
+ <input autoFocus placeholder="Issue title" value={title} onChange={e => setTitle(e.target.value)}
125
+ onKeyDown={e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) submit(); }}
126
+ style={{ width: "100%", background: "transparent", border: "none", outline: "none", fontSize: 18, padding: "4px 2px", color: "var(--fg-0)", marginBottom: 10 }} />
127
+ <textarea placeholder="Add description… (optional)" rows={4} value={description} onChange={e => setDescription(e.target.value)} style={{
128
+ width: "100%", background: "var(--bg-0)", border: "1px solid var(--border)",
129
+ outline: "none", borderRadius: 8, padding: 10, fontSize: 13, color: "var(--fg-0)",
130
+ fontFamily: "inherit", resize: "vertical", marginBottom: 14
131
+ }} />
132
+ <div className="flex items-center gap-8" style={{ flexWrap: "wrap" }}>
133
+ <select value={project} onChange={e => setProject(e.target.value)} className="btn ghost sm">
134
+ {PROJECTS.map(p => <option key={p.id} value={p.id}>{p.code} β€” {p.name}</option>)}
135
+ </select>
136
+ <select value={priority} onChange={e => setPriority(e.target.value)} className="btn ghost sm">
137
+ {[["urgent","Urgent"],["high","High"],["med","Medium"],["low","Low"],["none","None"]].map(([v,l]) => <option key={v} value={v}>{l}</option>)}
138
+ </select>
139
+ <button className="btn ghost sm"><Icon name="at" size={12} /> Assignee</button>
140
+ <button className="btn ghost sm"><Icon name="calendar" size={12} /> Due date</button>
141
+ </div>
142
+ </ModalShell>
143
+ );
144
+ };
145
+
146
+ const NewDocModal = ({ onClose }) => {
147
+ const [title, setTitle] = React.useState("");
148
+ const submit = async () => {
149
+ if (!title.trim()) { window.toast("Add a title"); return; }
150
+ try {
151
+ await window.apiFetch('POST', '/api/docs', { title: title.trim() });
152
+ window.meridianRefresh();
153
+ window.toast(`Draft created Β· ${title}`);
154
+ onClose();
155
+ } catch (e) {
156
+ window.toast("Failed to create doc");
157
+ }
158
+ };
159
+ return (
160
+ <ModalShell title="New document" onClose={onClose} width={520}
161
+ footer={<>
162
+ <button className="btn ghost sm" onClick={onClose}>Cancel</button>
163
+ <button className="btn sm primary" onClick={submit}>Create</button>
164
+ </>}>
165
+ <input autoFocus placeholder="Untitled document" value={title} onChange={e => setTitle(e.target.value)}
166
+ style={{ width: "100%", background: "transparent", border: "none", outline: "none", fontSize: 20, padding: "4px 2px", color: "var(--fg-0)", fontFamily: "var(--font-editorial, inherit)" }} />
167
+ <div className="muted" style={{ fontSize: 12, marginTop: 16 }}>A private draft will appear in Docs β†’ Drafts. You can move or share it later.</div>
168
+ </ModalShell>
169
+ );
170
+ };
171
+
172
+ const NewPRModal = ({ onClose }) => {
173
+ const [title, setTitle] = React.useState("");
174
+ const [branch, setBranch] = React.useState("");
175
+ const submit = async () => {
176
+ if (!title || !branch) { window.toast("Title and branch required"); return; }
177
+ try {
178
+ await window.apiFetch('POST', '/api/prs', { title, branch, base: 'main', status: 'open' });
179
+ window.meridianRefresh();
180
+ window.toast(`Opened PR Β· ${branch} β†’ main`);
181
+ onClose();
182
+ } catch (e) {
183
+ window.toast("Failed to open PR");
184
+ }
185
+ };
186
+ return (
187
+ <ModalShell title="Open pull request" onClose={onClose} width={560}
188
+ footer={<>
189
+ <button className="btn ghost sm" onClick={onClose}>Cancel</button>
190
+ <button className="btn sm primary" onClick={submit}>Open PR</button>
191
+ </>}>
192
+ <input autoFocus placeholder="PR title" value={title} onChange={e => setTitle(e.target.value)}
193
+ style={{ width: "100%", background: "var(--bg-0)", border: "1px solid var(--border)", outline: "none", fontSize: 14, padding: "8px 10px", borderRadius: 8, color: "var(--fg-0)", marginBottom: 10 }} />
194
+ <div className="flex items-center gap-8">
195
+ <input placeholder="feature/branch-name" value={branch} onChange={e => setBranch(e.target.value)} className="mono"
196
+ style={{ flex: 1, background: "var(--bg-0)", border: "1px solid var(--border)", outline: "none", fontSize: 13, padding: "8px 10px", borderRadius: 8, color: "var(--fg-0)" }} />
197
+ <span className="mono muted-2">β†’</span>
198
+ <div className="mono" style={{ padding: "8px 10px", border: "1px solid var(--border)", borderRadius: 8, background: "var(--bg-0)", fontSize: 13 }}>main</div>
199
+ </div>
200
+ </ModalShell>
201
+ );
202
+ };
203
+
204
+ const InviteModal = ({ onClose }) => {
205
+ const [email, setEmail] = React.useState("");
206
+ const submit = () => {
207
+ if (!email.includes("@")) { window.toast("Enter a valid email"); return; }
208
+ window.toast(`Invite sent to ${email}`);
209
+ onClose();
210
+ };
211
+ return (
212
+ <ModalShell title="Invite to workspace" subtitle="Helix Β· enterprise" onClose={onClose} width={480}
213
+ footer={<>
214
+ <button className="btn ghost sm" onClick={onClose}>Cancel</button>
215
+ <button className="btn sm primary" onClick={submit}>Send invite</button>
216
+ </>}>
217
+ <input autoFocus placeholder="name@company.com" value={email} onChange={e => setEmail(e.target.value)}
218
+ onKeyDown={e => e.key === "Enter" && submit()}
219
+ style={{ width: "100%", background: "var(--bg-0)", border: "1px solid var(--border)", outline: "none", fontSize: 14, padding: "10px 12px", borderRadius: 8, color: "var(--fg-0)" }} />
220
+ <div className="muted" style={{ fontSize: 11.5, marginTop: 10 }}>They'll receive an email with a workspace join link valid for 7 days.</div>
221
+ </ModalShell>
222
+ );
223
+ };
224
+
225
+ const ShareModal = ({ title, onClose }) => {
226
+ const url = `https://meridian.app/docs/${(title || "doc").toLowerCase().replace(/\s+/g, "-")}`;
227
+ const copy = () => {
228
+ try { navigator.clipboard?.writeText(url); } catch {}
229
+ window.toast("Link copied");
230
+ onClose();
231
+ };
232
+ return (
233
+ <ModalShell title="Share" subtitle={title} onClose={onClose} width={480}
234
+ footer={<>
235
+ <button className="btn ghost sm" onClick={onClose}>Done</button>
236
+ <button className="btn sm primary" onClick={copy}><Icon name="link" size={12} /> Copy link</button>
237
+ </>}>
238
+ <div className="mono" style={{ padding: "10px 12px", background: "var(--bg-0)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 12, color: "var(--fg-1)", marginBottom: 14, wordBreak: "break-all" }}>{url}</div>
239
+ <div style={{ fontSize: 12.5, marginBottom: 6, fontWeight: 500 }}>People with access</div>
240
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
241
+ {PEOPLE.slice(0, 3).map(u => (
242
+ <div key={u.id} className="flex items-center gap-8" style={{ fontSize: 12.5 }}>
243
+ <Avatar user={u} size="xs" />
244
+ <span>{u.name}</span>
245
+ <span className="mono muted-2" style={{ marginLeft: "auto", fontSize: 11 }}>Editor</span>
246
+ </div>
247
+ ))}
248
+ </div>
249
+ </ModalShell>
250
+ );
251
+ };
252
+
253
+ const PickerModal = ({ title, options, onChoose, onClose }) => (
254
+ <ModalShell title={title} onClose={onClose} width={400}>
255
+ <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
256
+ {options.map(o => (
257
+ <button key={o.value} className="palette-item" onClick={() => { onChoose(o); onClose(); }}>
258
+ {o.icon && <Icon name={o.icon} size={14} style={{ color: o.color || "var(--fg-2)" }} />}
259
+ {o.swatch && <span style={{ width: 10, height: 10, borderRadius: 3, background: o.swatch, flexShrink: 0 }} />}
260
+ <span style={{ flex: 1, textAlign: "left" }}>{o.label}</span>
261
+ {o.hint && <span className="mono muted-2" style={{ fontSize: 11 }}>{o.hint}</span>}
262
+ </button>
263
+ ))}
264
+ </div>
265
+ </ModalShell>
266
+ );
267
+
268
+ const ModalHost = () => {
269
+ const { modal } = useUIState();
270
+ if (!modal) return null;
271
+ const close = () => window.__ui.closeModal();
272
+ switch (modal.kind) {
273
+ case "new-issue": return <NewIssueModal prefill={modal.prefill} onClose={close} />;
274
+ case "new-doc": return <NewDocModal onClose={close} />;
275
+ case "new-pr": return <NewPRModal onClose={close} />;
276
+ case "invite": return <InviteModal onClose={close} />;
277
+ case "share": return <ShareModal title={modal.title} onClose={close} />;
278
+ case "picker": return <PickerModal title={modal.title} options={modal.options} onChoose={modal.onChoose} onClose={close} />;
279
+ case "week": return <WeekModal onClose={close} />;
280
+ case "digest": return <DigestModal onClose={close} />;
281
+ case "filter": return <FilterModal scope={modal.scope} onClose={close} />;
282
+ case "pr-detail": return <PRDetailModal pr={modal.pr} onClose={close} />;
283
+ case "project": return <ProjectDetailModal project={modal.project} onClose={close} />;
284
+ case "sprint-report": return <SprintReportModal onClose={close} />;
285
+ case "retro": return <RetroModal onClose={close} />;
286
+ case "ai": return <AIAnswerModal question={modal.question} onClose={close} />;
287
+ case "teammate": return <TeammateModal user={modal.user} onClose={close} />;
288
+ case "attach": return <AttachModal onClose={close} />;
289
+ default: return null;
290
+ }
291
+ };
292
+
293
+ Object.assign(window, { Toaster, ModalHost });
landing.css ADDED
@@ -0,0 +1,897 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Meridian β€” Landing
2
+ Dark, vanguardista, inspirado en el sistema interno */
3
+
4
+ :root {
5
+ --bg-0: oklch(0.16 0.008 250);
6
+ --bg-1: oklch(0.20 0.009 250);
7
+ --bg-2: oklch(0.24 0.010 250);
8
+ --bg-3: oklch(0.28 0.012 250);
9
+ --bg-elev: oklch(0.22 0.010 250);
10
+
11
+ --border: oklch(0.30 0.012 250);
12
+ --border-strong: oklch(0.40 0.015 250);
13
+ --border-subtle: oklch(0.24 0.010 250);
14
+
15
+ --fg-0: oklch(0.97 0.005 250);
16
+ --fg-1: oklch(0.82 0.010 250);
17
+ --fg-2: oklch(0.62 0.012 250);
18
+ --fg-3: oklch(0.45 0.012 250);
19
+
20
+ --accent: oklch(0.85 0.17 145); /* lime */
21
+ --accent-fg: oklch(0.20 0.05 145);
22
+ --accent-dim: oklch(0.40 0.10 145);
23
+ --accent-soft: oklch(0.30 0.06 145);
24
+
25
+ --violet: oklch(0.72 0.18 300);
26
+ --amber: oklch(0.80 0.14 75);
27
+ --rose: oklch(0.72 0.17 20);
28
+ --cyan: oklch(0.78 0.13 220);
29
+
30
+ --font-ui: "Inter Tight", "Inter", system-ui, -apple-system, sans-serif;
31
+ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
32
+ --font-editorial: "Fraunces", "Tiempos", Georgia, serif;
33
+
34
+ --radius-sm: 6px;
35
+ --radius-md: 10px;
36
+ --radius-lg: 16px;
37
+ --radius-xl: 22px;
38
+
39
+ --max: 1240px;
40
+ }
41
+
42
+ * { box-sizing: border-box; }
43
+ html, body {
44
+ margin: 0; padding: 0;
45
+ background: var(--bg-0);
46
+ color: var(--fg-0);
47
+ font-family: var(--font-ui);
48
+ font-feature-settings: "cv11", "ss01", "ss03";
49
+ -webkit-font-smoothing: antialiased;
50
+ font-size: 15px;
51
+ line-height: 1.5;
52
+ }
53
+ body {
54
+ overflow-x: hidden;
55
+ }
56
+ a { color: inherit; text-decoration: none; }
57
+ button { font: inherit; color: inherit; cursor: pointer; background: none; border: none; padding: 0; }
58
+
59
+ .mono { font-family: var(--font-mono); }
60
+ .editorial { font-family: var(--font-editorial); font-style: italic; font-weight: 400; }
61
+
62
+ /* ===== Background field ===== */
63
+ .bg-field {
64
+ position: fixed; inset: 0; z-index: 0; pointer-events: none;
65
+ background:
66
+ radial-gradient(80vw 60vh at 50% -10%, oklch(0.28 0.10 145 / 0.18), transparent 60%),
67
+ radial-gradient(60vw 60vh at 100% 30%, oklch(0.30 0.14 300 / 0.12), transparent 65%),
68
+ radial-gradient(60vw 60vh at 0% 70%, oklch(0.30 0.10 220 / 0.10), transparent 65%);
69
+ }
70
+ .bg-grid {
71
+ position: fixed; inset: 0; z-index: 0; pointer-events: none;
72
+ background-image:
73
+ linear-gradient(to right, oklch(0.30 0.012 250 / 0.45) 1px, transparent 1px),
74
+ linear-gradient(to bottom, oklch(0.30 0.012 250 / 0.45) 1px, transparent 1px);
75
+ background-size: 64px 64px;
76
+ mask-image: radial-gradient(ellipse at 50% 30%, black 0%, transparent 75%);
77
+ opacity: 0.5;
78
+ }
79
+ .bg-noise {
80
+ position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.04;
81
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.6'/></svg>");
82
+ mix-blend-mode: overlay;
83
+ }
84
+
85
+ /* ===== Nav ===== */
86
+ .nav {
87
+ position: fixed; top: 0; left: 0; right: 0; z-index: 100;
88
+ display: flex; align-items: center; justify-content: space-between;
89
+ padding: 14px 28px;
90
+ backdrop-filter: blur(14px) saturate(140%);
91
+ -webkit-backdrop-filter: blur(14px) saturate(140%);
92
+ background: oklch(0.16 0.008 250 / 0.55);
93
+ border-bottom: 1px solid oklch(0.30 0.012 250 / 0.5);
94
+ }
95
+ .brand {
96
+ display: inline-flex; align-items: center; gap: 10px;
97
+ font-weight: 600; letter-spacing: -0.01em;
98
+ }
99
+ .brand .logo {
100
+ width: 26px; height: 26px; border-radius: 7px;
101
+ background:
102
+ conic-gradient(from 200deg, var(--accent), var(--violet), var(--cyan), var(--accent));
103
+ position: relative;
104
+ box-shadow: 0 0 30px oklch(0.85 0.17 145 / 0.45);
105
+ }
106
+ .brand .logo::after {
107
+ content: ""; position: absolute; inset: 5px;
108
+ border-radius: 3px;
109
+ background: var(--bg-0);
110
+ mask: radial-gradient(circle at 30% 30%, #000 40%, transparent 41%);
111
+ }
112
+ .brand .name { font-size: 15px; }
113
+ .brand .ws { font-family: var(--font-mono); font-size: 11px; color: var(--fg-2); margin-left: 4px; }
114
+
115
+ .nav-links { display: flex; gap: 4px; }
116
+ .nav-links a {
117
+ padding: 7px 12px; border-radius: 8px;
118
+ font-size: 13.5px; color: var(--fg-1);
119
+ transition: background 120ms, color 120ms;
120
+ }
121
+ .nav-links a:hover { background: oklch(0.28 0.012 250 / 0.6); color: var(--fg-0); }
122
+
123
+ .nav-actions { display: flex; gap: 8px; align-items: center; }
124
+
125
+ /* ===== Buttons ===== */
126
+ .btn {
127
+ display: inline-flex; align-items: center; gap: 8px;
128
+ padding: 9px 16px; border-radius: 9px;
129
+ font-size: 13.5px; font-weight: 500;
130
+ border: 1px solid var(--border);
131
+ background: var(--bg-1);
132
+ color: var(--fg-0);
133
+ transition: all 140ms;
134
+ white-space: nowrap;
135
+ }
136
+ .btn:hover { background: var(--bg-2); border-color: var(--border-strong); transform: translateY(-1px); }
137
+ .btn.primary {
138
+ background: var(--accent); color: var(--accent-fg);
139
+ border-color: transparent;
140
+ box-shadow: 0 6px 24px oklch(0.85 0.17 145 / 0.30), inset 0 1px 0 oklch(1 0 0 / 0.30);
141
+ }
142
+ .btn.primary:hover { filter: brightness(1.06); transform: translateY(-1px); }
143
+ .btn.ghost { background: transparent; border-color: transparent; color: var(--fg-1); }
144
+ .btn.ghost:hover { background: oklch(0.28 0.012 250 / 0.6); color: var(--fg-0); }
145
+ .btn.lg { padding: 12px 20px; font-size: 14.5px; border-radius: 11px; }
146
+
147
+ .kbd { font-family: var(--font-mono); font-size: 10.5px; padding: 2px 6px; border: 1px solid var(--border); border-radius: 4px; color: var(--fg-2); background: oklch(0.20 0.009 250 / 0.6); }
148
+
149
+ /* ===== Section base ===== */
150
+ .section { position: relative; z-index: 1; padding: 120px 28px; }
151
+ .wrap { max-width: var(--max); margin: 0 auto; }
152
+ .eyebrow {
153
+ display: inline-flex; align-items: center; gap: 8px;
154
+ font-family: var(--font-mono); font-size: 11.5px;
155
+ text-transform: uppercase; letter-spacing: 0.14em;
156
+ color: var(--fg-2);
157
+ padding: 5px 10px; border: 1px solid var(--border); border-radius: 999px;
158
+ background: oklch(0.20 0.009 250 / 0.6);
159
+ }
160
+ .eyebrow .dot {
161
+ width: 6px; height: 6px; border-radius: 50%; background: var(--accent);
162
+ box-shadow: 0 0 10px var(--accent);
163
+ animation: pulse 1.8s ease-in-out infinite;
164
+ }
165
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
166
+
167
+ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -0.025em; }
168
+ h1 { font-size: clamp(48px, 7.2vw, 96px); line-height: 1.02; }
169
+ h2 { font-size: clamp(36px, 4.8vw, 60px); line-height: 1.05; }
170
+ h3 { font-size: 22px; line-height: 1.2; }
171
+
172
+ .lead { font-size: 19px; color: var(--fg-2); max-width: 60ch; line-height: 1.5; }
173
+
174
+ /* ===== Hero ===== */
175
+ .hero { padding-top: 160px; padding-bottom: 80px; }
176
+ .hero-grid {
177
+ display: grid; grid-template-columns: 1.05fr 1fr; gap: 60px; align-items: center;
178
+ }
179
+ .hero h1 .cursor {
180
+ display: inline-block; width: 0.55ch; height: 0.9em;
181
+ background: var(--accent); margin-left: 0.05em; vertical-align: -0.06em;
182
+ animation: blink 1.05s steps(2,end) infinite;
183
+ }
184
+ @keyframes blink { 50% { opacity: 0; } }
185
+
186
+ .hero .badge-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 26px; }
187
+ .hero h1 { margin-bottom: 24px; }
188
+ .hero h1 .word-shimmer {
189
+ background: linear-gradient(120deg, var(--fg-0) 30%, var(--accent) 50%, var(--fg-0) 70%);
190
+ background-size: 200% 100%;
191
+ -webkit-background-clip: text; background-clip: text; color: transparent;
192
+ animation: shimmer 5s linear infinite;
193
+ }
194
+ @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
195
+ .hero p.lead { margin-bottom: 32px; }
196
+ .hero-cta { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
197
+ .hero-meta { display: flex; gap: 20px; margin-top: 36px; color: var(--fg-2); font-size: 12.5px; flex-wrap: wrap; }
198
+ .hero-meta .d { display: inline-flex; align-items: center; gap: 6px; }
199
+ .hero-meta .d .ind { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 8px var(--accent); }
200
+
201
+ /* ===== Hero "video" β€” animated UI mock ===== */
202
+ .hero-video {
203
+ position: relative;
204
+ border-radius: 18px;
205
+ overflow: hidden;
206
+ border: 1px solid var(--border-strong);
207
+ background: var(--bg-1);
208
+ box-shadow:
209
+ 0 40px 100px oklch(0 0 0 / 0.6),
210
+ 0 0 0 1px oklch(0.85 0.17 145 / 0.15),
211
+ inset 0 1px 0 oklch(1 0 0 / 0.05);
212
+ aspect-ratio: 16 / 11;
213
+ transform: perspective(1400px) rotateY(-7deg) rotateX(4deg);
214
+ transition: transform 600ms cubic-bezier(0.2, 0.7, 0.2, 1);
215
+ }
216
+ .hero-video:hover { transform: perspective(1400px) rotateY(-3deg) rotateX(2deg); }
217
+ .hv-chrome {
218
+ height: 32px; display: flex; align-items: center; gap: 8px;
219
+ padding: 0 12px; border-bottom: 1px solid var(--border);
220
+ background: var(--bg-2);
221
+ }
222
+ .hv-chrome .dot { width: 9px; height: 9px; border-radius: 50%; }
223
+ .hv-chrome .url {
224
+ margin-left: 12px; font-family: var(--font-mono); font-size: 11px;
225
+ color: var(--fg-2); padding: 2px 10px; border-radius: 6px;
226
+ background: var(--bg-1); border: 1px solid var(--border);
227
+ flex: 1;
228
+ }
229
+ .hv-stage { position: absolute; inset: 32px 0 0 0; overflow: hidden; display: flex; flex-direction: column; }
230
+
231
+ /* fake toolbar */
232
+ .hv-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 14px; border-bottom: 1px solid var(--border-subtle); background: var(--bg-1); flex-shrink: 0; }
233
+ .hv-tabs { display: inline-flex; gap: 2px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 7px; padding: 2px; }
234
+ .hv-tabs span { padding: 3px 10px; font-size: 11px; color: var(--fg-2); border-radius: 5px; }
235
+ .hv-tabs span.on { background: var(--bg-0); color: var(--fg-0); box-shadow: var(--shadow-sm); }
236
+ .hv-presence { display: inline-flex; align-items: center; gap: 8px; }
237
+ .hv-presence .pa { width: 22px; height: 22px; border-radius: 50%; font-size: 9.5px; color: white; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; border: 2px solid var(--bg-1); margin-left: -8px; }
238
+ .hv-presence .pa-1 { background: linear-gradient(135deg, var(--violet), var(--cyan)); margin-left: 0; }
239
+ .hv-presence .pa-2 { background: linear-gradient(135deg, var(--rose), var(--amber)); }
240
+ .hv-presence .pa-3 { background: linear-gradient(135deg, var(--cyan), var(--accent)); }
241
+ .hv-presence .pn { font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-2); display: inline-flex; align-items: center; gap: 6px; margin-left: 6px; }
242
+ .hv-presence .pn-d { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 8px var(--accent); animation: pulse 1.4s infinite; }
243
+
244
+ /* sub-anim: live kanban */
245
+ .kanban {
246
+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
247
+ padding: 12px; flex: 1; min-height: 0;
248
+ }
249
+ .kcol { background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; padding: 10px; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
250
+ .kcol h5 {
251
+ margin: 0; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em;
252
+ color: var(--fg-2); display: flex; align-items: center; gap: 6px;
253
+ }
254
+ .kcol h5 .ct { margin-left: auto; font-family: var(--font-mono); color: var(--fg-3); }
255
+ .kcol h5 .sd { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
256
+ .kcol.todo h5 { color: oklch(0.65 0.012 250); }
257
+ .kcol.prog h5 { color: var(--amber); }
258
+ .kcol.done h5 { color: var(--accent); }
259
+ .kcol.is-target { background: oklch(0.30 0.06 145 / 0.18); border-color: var(--accent-soft); transition: background 200ms, border-color 200ms; }
260
+
261
+ .kcard {
262
+ background: var(--bg-1); border: 1px solid var(--border); border-radius: 8px;
263
+ padding: 8px 9px; font-size: 11px; color: var(--fg-1);
264
+ display: flex; flex-direction: column; gap: 5px;
265
+ transition: opacity 220ms;
266
+ }
267
+ .kcard .id { font-family: var(--font-mono); font-size: 9.5px; color: var(--fg-3); }
268
+ .kcard .title { color: var(--fg-0); font-weight: 500; line-height: 1.3; }
269
+ .kcard .meta { display: flex; align-items: center; gap: 6px; font-size: 10px; color: var(--fg-2); }
270
+ .kcard .meta .av { width: 14px; height: 14px; border-radius: 50%; background: linear-gradient(135deg, var(--violet), var(--cyan)); flex-shrink: 0; }
271
+ .kcard .tag { display: inline-flex; padding: 1px 5px; border-radius: 3px; background: var(--bg-3); font-family: var(--font-mono); font-size: 9px; color: var(--fg-2); }
272
+ .kcard.k-target.is-ghost { opacity: 0.18; }
273
+
274
+ /* moving card β€” JS-driven, smooth grab+drop */
275
+ .kcard.moving {
276
+ position: absolute; z-index: 7;
277
+ width: 28%; left: 0; top: 0;
278
+ pointer-events: none;
279
+ background: var(--bg-elev);
280
+ box-shadow: 0 18px 40px oklch(0 0 0 / 0.55), 0 0 0 1.5px var(--accent);
281
+ opacity: 0;
282
+ transition: left 1100ms cubic-bezier(0.5, 0, 0.3, 1),
283
+ top 1100ms cubic-bezier(0.5, 0, 0.3, 1),
284
+ transform 350ms cubic-bezier(0.2, 0.7, 0.2, 1),
285
+ opacity 250ms,
286
+ box-shadow 250ms;
287
+ }
288
+ .kcard.moving.is-grabbed {
289
+ transform: rotate(2.5deg) scale(1.04);
290
+ box-shadow: 0 24px 50px oklch(0 0 0 / 0.6), 0 0 0 1.5px var(--accent), 0 0 30px oklch(0.85 0.17 145 / 0.4);
291
+ }
292
+ .kcard.moving.is-visible { opacity: 1; }
293
+
294
+ /* live cursor */
295
+ /* live cursor β€” JS-driven, follows the card */
296
+ .cursor-fly {
297
+ position: absolute; width: 18px; height: 18px; z-index: 8;
298
+ left: 0; top: 0;
299
+ pointer-events: none; opacity: 0;
300
+ transition: left 1100ms cubic-bezier(0.5, 0, 0.3, 1),
301
+ top 1100ms cubic-bezier(0.5, 0, 0.3, 1),
302
+ opacity 250ms;
303
+ }
304
+ .cursor-fly.is-visible { opacity: 1; }
305
+ .cursor-fly svg { width: 100%; height: 100%; filter: drop-shadow(0 2px 4px oklch(0 0 0 / 0.6)); }
306
+ .cursor-fly .label {
307
+ position: absolute; top: 16px; left: 16px;
308
+ font-size: 9.5px; font-family: var(--font-mono);
309
+ background: var(--accent); color: var(--accent-fg);
310
+ padding: 2px 6px; border-radius: 4px;
311
+ white-space: nowrap; font-weight: 600;
312
+ }
313
+
314
+ /* bottom stats strip (replaces ticker + floating badges) */
315
+ .hv-stats {
316
+ display: grid; grid-template-columns: repeat(4, 1fr);
317
+ border-top: 1px solid var(--border-subtle);
318
+ background: var(--bg-1);
319
+ flex-shrink: 0;
320
+ }
321
+ .hv-stat { padding: 8px 12px; border-right: 1px solid var(--border-subtle); display: flex; flex-direction: column; gap: 3px; min-width: 0; overflow: hidden; }
322
+ .hv-stat:last-child { border-right: none; }
323
+ .hv-k { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--fg-3); }
324
+ .hv-v { font-size: 11.5px; color: var(--fg-0); font-family: var(--font-mono); display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
325
+ .hv-d { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
326
+ .hv-d-ok { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
327
+ .hv-bar { display: block !important; width: 100%; height: 6px; border-radius: 3px; background: var(--bg-3); overflow: hidden; padding: 0; }
328
+ .hv-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent), var(--violet)); border-radius: 3px; transition: width 1.2s ease-out; }
329
+
330
+ /* Floating badges (deprecated, hidden) */
331
+ .float-badge { display: none !important; }
332
+
333
+ /* ===== Logo strip ===== */
334
+ .logos {
335
+ padding: 20px 28px 60px;
336
+ position: relative; z-index: 1;
337
+ }
338
+ .logos .row {
339
+ display: flex; align-items: center; justify-content: center; gap: 60px;
340
+ flex-wrap: wrap;
341
+ color: var(--fg-2); font-size: 13px;
342
+ font-family: var(--font-mono);
343
+ opacity: 0.7;
344
+ }
345
+ .logos .row span { letter-spacing: 0.05em; }
346
+ .logos .label {
347
+ text-align: center; font-family: var(--font-mono); font-size: 11px;
348
+ color: var(--fg-3); text-transform: uppercase; letter-spacing: 0.18em;
349
+ margin-bottom: 24px;
350
+ }
351
+
352
+ /* ===== Features grid ===== */
353
+ .feature-head { text-align: center; max-width: 760px; margin: 0 auto 64px; }
354
+ .feature-head h2 { margin-top: 16px; margin-bottom: 16px; }
355
+ .feature-head p { color: var(--fg-2); font-size: 18px; margin: 0; }
356
+
357
+ .features {
358
+ display: grid;
359
+ grid-template-columns: repeat(6, 1fr);
360
+ gap: 16px;
361
+ }
362
+ .f-card {
363
+ border: 1px solid var(--border);
364
+ border-radius: 18px;
365
+ background: linear-gradient(180deg, var(--bg-1), oklch(0.18 0.008 250));
366
+ padding: 22px;
367
+ position: relative; overflow: hidden;
368
+ display: flex; flex-direction: column;
369
+ min-height: 320px;
370
+ transition: border-color 200ms, transform 300ms;
371
+ }
372
+ .f-card:hover { border-color: var(--border-strong); transform: translateY(-4px); }
373
+ .f-card .label {
374
+ font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3);
375
+ text-transform: uppercase; letter-spacing: 0.16em; margin-bottom: 12px;
376
+ display: flex; align-items: center; gap: 6px;
377
+ }
378
+ .f-card .label .n { color: var(--accent); }
379
+ .f-card h3 { font-size: 22px; margin-bottom: 10px; letter-spacing: -0.02em; }
380
+ .f-card p { color: var(--fg-2); margin: 0 0 16px 0; font-size: 14px; }
381
+ .f-card .canvas { flex: 1; position: relative; margin-top: auto; min-height: 130px; border-radius: 12px; overflow: hidden; background: oklch(0.16 0.008 250); border: 1px solid var(--border-subtle); }
382
+
383
+ .f-wide-3 { grid-column: span 3; }
384
+ .f-wide-4 { grid-column: span 4; }
385
+ .f-wide-2 { grid-column: span 2; }
386
+
387
+ /* feature canvases β€” Sparkline / orbit / stream / type */
388
+ .spark-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
389
+ .spark-svg .ln { fill: none; stroke: var(--accent); stroke-width: 2.2; stroke-dasharray: 600; stroke-dashoffset: 600; animation: dashIn 3.2s cubic-bezier(0.6,0.05,0.2,1) infinite; }
390
+ .spark-svg .area { fill: url(#g-area); opacity: 0; animation: areaIn 3.2s cubic-bezier(0.6,0.05,0.2,1) infinite; }
391
+ .spark-svg .pt { fill: var(--accent); }
392
+ @keyframes dashIn { 0% { stroke-dashoffset: 600; } 60%,100% { stroke-dashoffset: 0; } }
393
+ @keyframes areaIn { 0%,40% { opacity: 0; } 70%,100% { opacity: 1; } }
394
+
395
+ /* orbit (collab) */
396
+ .orbit { position: absolute; inset: 0; }
397
+ .orbit .ring {
398
+ position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
399
+ border: 1px dashed var(--border-strong); border-radius: 50%;
400
+ }
401
+ .orbit .ring.r1 { width: 90px; height: 90px; animation: spin 18s linear infinite; }
402
+ .orbit .ring.r2 { width: 150px; height: 150px; animation: spin 30s linear infinite reverse; }
403
+ .orbit .ring.r3 { width: 220px; height: 220px; animation: spin 50s linear infinite; }
404
+ @keyframes spin { to { transform: translate(-50%,-50%) rotate(360deg); } }
405
+ .orbit .core {
406
+ position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
407
+ width: 36px; height: 36px; border-radius: 50%;
408
+ background: radial-gradient(circle at 35% 30%, var(--accent), var(--accent-dim));
409
+ box-shadow: 0 0 30px oklch(0.85 0.17 145 / 0.5);
410
+ }
411
+ .orbit .av {
412
+ position: absolute; width: 22px; height: 22px; border-radius: 50%;
413
+ background: linear-gradient(135deg, var(--violet), var(--cyan)); border: 2px solid var(--bg-1);
414
+ font-size: 9px; color: #fff; display: inline-flex; align-items: center; justify-content: center; font-weight: 700;
415
+ }
416
+ .orbit .av.a1 { left: 50%; top: 50%; transform: translate(-50%,-50%) translateX(45px); animation: orbita 18s linear infinite; }
417
+ .orbit .av.a2 { left: 50%; top: 50%; background: linear-gradient(135deg, var(--rose), var(--amber)); transform: translate(-50%,-50%) translateX(75px); animation: orbita 30s linear infinite reverse; }
418
+ .orbit .av.a3 { left: 50%; top: 50%; background: linear-gradient(135deg, var(--cyan), var(--accent)); transform: translate(-50%,-50%) translateX(110px); animation: orbita 50s linear infinite; }
419
+ @keyframes orbita {
420
+ from { transform: translate(-50%,-50%) rotate(0deg) translateX(var(--r, 45px)) rotate(0deg); }
421
+ to { transform: translate(-50%,-50%) rotate(360deg) translateX(var(--r, 45px)) rotate(-360deg); }
422
+ }
423
+ .orbit .av.a1 { --r: 45px; }
424
+ .orbit .av.a2 { --r: 75px; }
425
+ .orbit .av.a3 { --r: 110px; }
426
+
427
+ /* terminal-typing card */
428
+ .term { padding: 14px; height: 100%; font-family: var(--font-mono); font-size: 11px; color: var(--fg-1); display: flex; flex-direction: column; gap: 4px; }
429
+ .term .l { white-space: nowrap; overflow: hidden; }
430
+ .term .l .pr { color: var(--accent); }
431
+ .term .l .key { color: var(--cyan); }
432
+ .term .l .str { color: var(--amber); }
433
+ .term .l .com { color: var(--fg-3); }
434
+ .term .l.l1 { animation: typing 4s steps(40, end) infinite; }
435
+ .term .blink { display: inline-block; width: 6px; height: 11px; background: var(--accent); margin-left: 2px; vertical-align: -2px; animation: blink 1s steps(2) infinite; }
436
+
437
+ /* timeline canvas */
438
+ .tl { position: absolute; inset: 12px; display: flex; flex-direction: column; gap: 6px; }
439
+ .tl .row { display: flex; align-items: center; gap: 8px; height: 16px; font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); }
440
+ .tl .row .lab { width: 60px; }
441
+ .tl .bar { height: 9px; border-radius: 3px; background: var(--accent-soft); position: relative; }
442
+ .tl .bar .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); border-radius: 3px; animation: fill 4s ease-out infinite; }
443
+ .tl .bar.b1 .fill { width: 0; animation-delay: 0s; }
444
+ .tl .bar.b2 { background: oklch(0.30 0.07 300); }
445
+ .tl .bar.b2 .fill { background: var(--violet); width: 0; animation-delay: 0.4s; }
446
+ .tl .bar.b3 { background: oklch(0.30 0.06 75); }
447
+ .tl .bar.b3 .fill { background: var(--amber); width: 0; animation-delay: 0.8s; }
448
+ .tl .bar.b4 { background: oklch(0.30 0.07 220); }
449
+ .tl .bar.b4 .fill { background: var(--cyan); width: 0; animation-delay: 1.2s; }
450
+ @keyframes fill {
451
+ 0% { width: 0; }
452
+ 60%,100% { width: var(--w, 70%); }
453
+ }
454
+ .tl .bar.b1 .fill { --w: 80%; }
455
+ .tl .bar.b2 .fill { --w: 55%; }
456
+ .tl .bar.b3 .fill { --w: 70%; }
457
+ .tl .bar.b4 .fill { --w: 30%; }
458
+ .tl .marker { position: absolute; right: 16%; top: 0; bottom: 0; width: 1px; background: var(--accent); }
459
+ .tl .marker::before { content: "today"; position: absolute; top: -14px; right: -16px; font-size: 9px; color: var(--accent); font-family: var(--font-mono); }
460
+
461
+ /* AI inbox triage */
462
+ .ai-stream { position: absolute; inset: 12px; display: flex; flex-direction: column; gap: 6px; overflow: hidden; }
463
+ .ai-row {
464
+ display: flex; align-items: center; gap: 8px; padding: 6px 8px;
465
+ border: 1px solid var(--border); border-radius: 6px;
466
+ background: oklch(0.20 0.009 250 / 0.7);
467
+ font-size: 10.5px;
468
+ animation: aiSlide 6s ease-in-out infinite;
469
+ }
470
+ .ai-row .av { width: 14px; height: 14px; border-radius: 50%; background: linear-gradient(135deg, var(--violet), var(--cyan)); flex-shrink: 0; }
471
+ .ai-row .t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-1); }
472
+ .ai-row .tag { font-family: var(--font-mono); font-size: 9px; padding: 1px 5px; border-radius: 3px; background: var(--accent-soft); color: var(--accent); }
473
+ .ai-row.r2 { animation-delay: 1s; }
474
+ .ai-row.r3 { animation-delay: 2s; }
475
+ .ai-row.r4 { animation-delay: 3s; }
476
+ @keyframes aiSlide {
477
+ 0% { opacity: 0; transform: translateX(-10px); }
478
+ 10%,80% { opacity: 1; transform: translateX(0); }
479
+ 100% { opacity: 0; transform: translateX(0); }
480
+ }
481
+
482
+ /* counter */
483
+ .counter-canvas { display: flex; align-items: center; justify-content: center; height: 100%; flex-direction: column; gap: 6px; }
484
+ .counter-canvas .num { font-family: var(--font-editorial); font-size: 48px; line-height: 1; color: var(--fg-0); }
485
+ .counter-canvas .num em { font-style: normal; color: var(--accent); }
486
+ .counter-canvas .lab { font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); text-transform: uppercase; letter-spacing: 0.16em; }
487
+
488
+ /* ===== Big stage section ===== */
489
+ .stage-section {
490
+ padding: 100px 28px 140px; position: relative; z-index: 1;
491
+ }
492
+ .stage-shell {
493
+ max-width: 1180px; margin: 0 auto;
494
+ border-radius: 24px;
495
+ border: 1px solid var(--border-strong);
496
+ background: linear-gradient(180deg, var(--bg-1), oklch(0.16 0.008 250));
497
+ overflow: hidden;
498
+ box-shadow: 0 60px 120px oklch(0 0 0 / 0.55);
499
+ position: relative;
500
+ }
501
+ .stage-tabs {
502
+ display: flex; gap: 4px; padding: 16px;
503
+ border-bottom: 1px solid var(--border);
504
+ background: var(--bg-2);
505
+ }
506
+ .stage-tabs button {
507
+ padding: 8px 14px; border-radius: 8px; font-size: 13px; font-weight: 500;
508
+ color: var(--fg-2); transition: all 140ms;
509
+ border: 1px solid transparent;
510
+ }
511
+ .stage-tabs button:hover { color: var(--fg-0); background: var(--bg-3); }
512
+ .stage-tabs button.on { background: var(--bg-0); color: var(--fg-0); border-color: var(--border); }
513
+ .stage-content { aspect-ratio: 16 / 8; position: relative; }
514
+ .stage-pane { position: absolute; inset: 0; opacity: 0; transition: opacity 320ms; pointer-events: none; }
515
+ .stage-pane.on { opacity: 1; pointer-events: auto; }
516
+
517
+ /* Pane: gantt */
518
+ .gantt { padding: 24px; height: 100%; display: flex; flex-direction: column; gap: 10px; }
519
+ .gantt .head { display: grid; grid-template-columns: 180px 1fr; gap: 12px; font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); text-transform: uppercase; letter-spacing: 0.1em; }
520
+ .gantt .head .weeks { display: grid; grid-template-columns: repeat(8, 1fr); gap: 8px; }
521
+ .gantt .grow { display: flex; flex-direction: column; gap: 8px; flex: 1; min-height: 0; }
522
+ .g-row { display: grid; grid-template-columns: 180px 1fr; gap: 12px; align-items: center; font-size: 12px; }
523
+ .g-row .nm { color: var(--fg-1); display: flex; align-items: center; gap: 8px; }
524
+ .g-row .nm .d { width: 8px; height: 8px; border-radius: 2px; }
525
+ .g-row .gtrack { position: relative; height: 22px; background: var(--bg-2); border-radius: 6px; }
526
+ .g-row .gtrack .b { position: absolute; top: 3px; bottom: 3px; border-radius: 4px; box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.15); }
527
+
528
+ /* Pane: kanban big */
529
+ .bk { padding: 16px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; height: 100%; }
530
+ .bk-col { border: 1px solid var(--border); border-radius: 12px; background: var(--bg-2); padding: 12px; display: flex; flex-direction: column; gap: 10px; min-height: 0; overflow: hidden; }
531
+ .bk-col h5 { margin: 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--fg-2); display: flex; align-items: center; gap: 6px; justify-content: space-between; }
532
+ .bk-col h5 .ct { font-family: var(--font-mono); color: var(--fg-3); }
533
+ .bk-card { background: var(--bg-1); border: 1px solid var(--border); border-radius: 9px; padding: 9px; font-size: 11.5px; display: flex; flex-direction: column; gap: 6px; cursor: grab; user-select: none; transition: transform .35s cubic-bezier(.2,.8,.2,1), opacity .25s, box-shadow .25s, border-color .25s; will-change: transform; position: relative; }
534
+ .bk-card:hover { border-color: var(--fg-3); box-shadow: 0 4px 14px rgba(0,0,0,.25); transform: translateY(-1px); }
535
+ .bk-card.is-dragging { cursor: grabbing; opacity: .92; transform: rotate(-2deg) scale(1.04); box-shadow: 0 18px 40px rgba(0,0,0,.5), 0 0 0 1px var(--accent-soft); z-index: 50; pointer-events: none; }
536
+ .bk-card.is-floating { position: fixed; z-index: 9999; pointer-events: none; transition: none; }
537
+ .bk-card.just-landed { animation: cardPop .5s cubic-bezier(.2,.8,.2,1); }
538
+ @keyframes cardPop { 0% { transform: scale(.92); box-shadow: 0 0 0 2px var(--accent); } 60% { transform: scale(1.02); } 100% { transform: scale(1); } }
539
+ .bk-col.is-drop-target { background: color-mix(in oklch, var(--accent) 8%, var(--bg-2)); border-color: var(--accent-soft); }
540
+ .bk-col .drop-zone { min-height: 36px; border: 1.5px dashed var(--border); border-radius: 8px; transition: all .2s; opacity: 0; height: 0; padding: 0; margin: 0; pointer-events: none; }
541
+ .bk-col.is-drop-target .drop-zone { opacity: 1; height: 32px; padding: 4px; border-color: var(--accent-soft); background: color-mix(in oklch, var(--accent) 6%, transparent); }
542
+ .bk-card .id { font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); }
543
+ .bk-card .t { color: var(--fg-0); font-weight: 500; line-height: 1.3; font-size: 12px; }
544
+ .bk-card .ft { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); }
545
+ .bk-card .pri { width: 6px; height: 6px; border-radius: 50%; }
546
+
547
+ /* Pane: docs */
548
+ .docs-pane { display: grid; grid-template-columns: 220px 1fr; height: 100%; }
549
+ .docs-side { border-right: 1px solid var(--border); padding: 16px 12px; background: var(--bg-2); overflow-y: auto; }
550
+ .docs-side .gr { font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); text-transform: uppercase; letter-spacing: 0.12em; padding: 10px 8px 6px; }
551
+ .docs-side .item { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 5px; font-size: 12px; color: var(--fg-1); }
552
+ .docs-side .item:hover, .docs-side .item.on { background: var(--bg-3); color: var(--fg-0); }
553
+ .docs-side .item .di { width: 12px; height: 12px; opacity: 0.6; }
554
+ .docs-body { padding: 32px 40px; overflow-y: auto; }
555
+ .docs-body h3 { font-family: var(--font-editorial); font-style: italic; font-weight: 400; font-size: 36px; letter-spacing: -0.02em; color: var(--fg-0); margin-bottom: 12px; }
556
+ .docs-body .meta { font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); margin-bottom: 24px; text-transform: uppercase; letter-spacing: 0.12em; }
557
+ .docs-body p { color: var(--fg-1); margin: 0 0 14px; max-width: 60ch; line-height: 1.6; font-size: 13.5px; }
558
+ .docs-body p .hl { background: oklch(0.30 0.07 75 / 0.4); padding: 0 3px; border-radius: 2px; }
559
+ .docs-body .embed {
560
+ border: 1px solid var(--border); border-radius: 10px; padding: 14px;
561
+ background: var(--bg-2); display: flex; gap: 12px; align-items: center;
562
+ font-size: 12px; color: var(--fg-1); margin: 18px 0;
563
+ }
564
+ .docs-body .embed .iv {
565
+ width: 48px; height: 48px; border-radius: 8px; flex-shrink: 0;
566
+ background: linear-gradient(135deg, var(--violet), var(--cyan));
567
+ display: inline-flex; align-items: center; justify-content: center;
568
+ color: white; font-weight: 600;
569
+ }
570
+
571
+ /* ===== Quote block ===== */
572
+ .quote-section { padding: 80px 28px; position: relative; z-index: 1; }
573
+ .quote {
574
+ max-width: 920px; margin: 0 auto; text-align: center;
575
+ font-family: var(--font-editorial); font-style: italic; font-weight: 300;
576
+ font-size: clamp(28px, 3vw, 42px); line-height: 1.25; color: var(--fg-0);
577
+ letter-spacing: -0.02em;
578
+ }
579
+ .quote .who {
580
+ margin-top: 32px;
581
+ font-family: var(--font-ui); font-style: normal; font-weight: 500;
582
+ font-size: 13px; color: var(--fg-2); letter-spacing: 0;
583
+ display: flex; gap: 12px; align-items: center; justify-content: center;
584
+ }
585
+ .quote .who .av { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, var(--rose), var(--amber)); }
586
+
587
+ /* ===== Pricing ===== */
588
+ .pricing-head { text-align: center; max-width: 720px; margin: 0 auto 60px; }
589
+ .pricing-head h2 { margin: 16px 0; }
590
+ .pricing-head p { color: var(--fg-2); font-size: 18px; margin: 0 0 24px; }
591
+ .bill-toggle {
592
+ display: inline-flex; padding: 4px;
593
+ background: var(--bg-1); border: 1px solid var(--border);
594
+ border-radius: 999px;
595
+ position: relative;
596
+ }
597
+ .bill-toggle button {
598
+ padding: 8px 16px; font-size: 13px; font-weight: 500;
599
+ border-radius: 999px; color: var(--fg-2);
600
+ transition: color 200ms; position: relative; z-index: 2;
601
+ display: inline-flex; align-items: center; gap: 8px;
602
+ }
603
+ .bill-toggle button.on { color: var(--accent-fg); }
604
+ .bill-toggle button .save { font-family: var(--font-mono); font-size: 10px; padding: 2px 6px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); }
605
+ .bill-toggle button.on .save { background: oklch(1 0 0 / 0.15); color: var(--accent-fg); }
606
+ .bill-toggle .pill {
607
+ position: absolute; top: 4px; left: 4px;
608
+ height: calc(100% - 8px);
609
+ background: var(--accent); border-radius: 999px;
610
+ transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1), width 280ms;
611
+ z-index: 1;
612
+ }
613
+
614
+ .tiers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
615
+ .tier {
616
+ border: 1px solid var(--border);
617
+ border-radius: 18px;
618
+ background: linear-gradient(180deg, var(--bg-1), oklch(0.18 0.008 250));
619
+ padding: 28px;
620
+ display: flex; flex-direction: column; gap: 18px;
621
+ transition: border-color 200ms, transform 300ms;
622
+ position: relative;
623
+ overflow: hidden;
624
+ }
625
+ .tier:hover { border-color: var(--border-strong); transform: translateY(-3px); }
626
+ .tier.featured {
627
+ border-color: var(--accent);
628
+ background:
629
+ radial-gradient(60% 50% at 80% 0%, oklch(0.30 0.10 145 / 0.35), transparent 70%),
630
+ linear-gradient(180deg, oklch(0.22 0.012 250), oklch(0.18 0.008 250));
631
+ box-shadow: 0 30px 60px oklch(0.85 0.17 145 / 0.10);
632
+ }
633
+ .tier .badge {
634
+ position: absolute; top: 18px; right: 18px;
635
+ font-family: var(--font-mono); font-size: 10px;
636
+ padding: 3px 8px; border-radius: 999px;
637
+ background: var(--accent); color: var(--accent-fg);
638
+ text-transform: uppercase; letter-spacing: 0.1em;
639
+ }
640
+ .tier h3 { font-size: 18px; }
641
+ .tier .desc { color: var(--fg-2); font-size: 13.5px; margin-top: 4px; min-height: 40px; }
642
+ .tier .price-row { display: flex; align-items: baseline; gap: 6px; }
643
+ .tier .price {
644
+ font-family: var(--font-editorial); font-style: normal; font-weight: 400;
645
+ font-size: 56px; line-height: 1; letter-spacing: -0.03em;
646
+ }
647
+ .tier .per { color: var(--fg-2); font-size: 12.5px; font-family: var(--font-mono); }
648
+ .tier .feats { display: flex; flex-direction: column; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle); }
649
+ .tier .feat { display: flex; gap: 10px; align-items: flex-start; font-size: 13px; color: var(--fg-1); line-height: 1.45; }
650
+ .tier .feat .ck { width: 16px; height: 16px; flex-shrink: 0; color: var(--accent); margin-top: 1px; }
651
+ .tier .btn { width: 100%; justify-content: center; }
652
+
653
+ /* ===== FAQ ===== */
654
+ .faq { max-width: 820px; margin: 0 auto; }
655
+ .faq-item {
656
+ border-bottom: 1px solid var(--border);
657
+ padding: 22px 4px;
658
+ }
659
+ .faq-q {
660
+ display: flex; align-items: center; justify-content: space-between;
661
+ font-size: 17px; font-weight: 500; color: var(--fg-0);
662
+ width: 100%; text-align: left; gap: 16px;
663
+ letter-spacing: -0.01em;
664
+ }
665
+ .faq-q .pl {
666
+ width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--border-strong);
667
+ display: inline-flex; align-items: center; justify-content: center;
668
+ font-family: var(--font-mono); transition: transform 200ms, background 200ms;
669
+ flex-shrink: 0; color: var(--fg-2);
670
+ }
671
+ .faq-item.open .faq-q .pl { transform: rotate(45deg); background: var(--accent); color: var(--accent-fg); border-color: transparent; }
672
+ .faq-a {
673
+ max-height: 0; overflow: hidden;
674
+ transition: max-height 280ms cubic-bezier(0.4, 0, 0.2, 1);
675
+ }
676
+ .faq-item.open .faq-a { max-height: 280px; }
677
+ .faq-a p { color: var(--fg-2); font-size: 14.5px; margin: 12px 0 0; max-width: 60ch; line-height: 1.6; }
678
+
679
+ /* ===== CTA ===== */
680
+ .cta-section { padding: 80px 28px 140px; position: relative; z-index: 1; }
681
+ .cta-card {
682
+ max-width: var(--max); margin: 0 auto;
683
+ border-radius: 28px;
684
+ background:
685
+ radial-gradient(60% 80% at 50% 0%, oklch(0.30 0.12 145 / 0.4), transparent 70%),
686
+ linear-gradient(180deg, var(--bg-1), oklch(0.16 0.008 250));
687
+ border: 1px solid var(--border-strong);
688
+ padding: 80px 40px;
689
+ text-align: center;
690
+ position: relative; overflow: hidden;
691
+ box-shadow: 0 50px 120px oklch(0 0 0 / 0.5);
692
+ }
693
+ .cta-card::before {
694
+ content: ""; position: absolute; inset: 0; pointer-events: none;
695
+ background-image:
696
+ linear-gradient(to right, oklch(0.30 0.012 250 / 0.4) 1px, transparent 1px),
697
+ linear-gradient(to bottom, oklch(0.30 0.012 250 / 0.4) 1px, transparent 1px);
698
+ background-size: 36px 36px;
699
+ mask-image: radial-gradient(ellipse at 50% 100%, black 0%, transparent 70%);
700
+ }
701
+ .cta-card h2 { margin: 16px 0 14px; max-width: 740px; margin-left: auto; margin-right: auto; }
702
+ .cta-card .lead { margin: 0 auto 32px; }
703
+ .cta-card .row { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; align-items: center; position: relative; z-index: 2; }
704
+
705
+ /* ===== Footer ===== */
706
+ footer.foot {
707
+ position: relative; z-index: 1;
708
+ border-top: 1px solid var(--border);
709
+ padding: 40px 28px 30px;
710
+ font-size: 13px; color: var(--fg-2);
711
+ }
712
+ .foot-grid { max-width: var(--max); margin: 0 auto; display: grid; grid-template-columns: 2fr repeat(3, 1fr); gap: 40px; }
713
+ .foot-grid h5 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--fg-3); margin: 0 0 12px; font-family: var(--font-mono); font-weight: 500; }
714
+ .foot-grid ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
715
+ .foot-grid a:hover { color: var(--fg-0); }
716
+ .foot-bot { max-width: var(--max); margin: 36px auto 0; padding-top: 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--fg-3); flex-wrap: wrap; gap: 12px; }
717
+
718
+ /* ===== Login modal ===== */
719
+ .lg-overlay {
720
+ position: fixed; inset: 0; z-index: 200;
721
+ background: oklch(0 0 0 / 0.6);
722
+ backdrop-filter: blur(10px);
723
+ display: flex; align-items: center; justify-content: center;
724
+ padding: 20px;
725
+ opacity: 0; pointer-events: none;
726
+ transition: opacity 220ms;
727
+ }
728
+ .lg-overlay.open { opacity: 1; pointer-events: auto; }
729
+ .lg-modal {
730
+ width: 440px; max-width: 100%;
731
+ background: var(--bg-elev);
732
+ border: 1px solid var(--border-strong);
733
+ border-radius: 18px;
734
+ padding: 36px 32px;
735
+ box-shadow: 0 60px 120px oklch(0 0 0 / 0.6);
736
+ transform: translateY(12px) scale(0.98);
737
+ transition: transform 240ms cubic-bezier(0.2, 0.7, 0.2, 1);
738
+ position: relative;
739
+ }
740
+ .lg-overlay.open .lg-modal { transform: translateY(0) scale(1); }
741
+ .lg-x { position: absolute; top: 14px; right: 14px; width: 30px; height: 30px; border-radius: 8px; color: var(--fg-2); display: inline-flex; align-items: center; justify-content: center; }
742
+ .lg-x:hover { background: var(--bg-2); color: var(--fg-0); }
743
+ .lg-modal h3 { font-size: 22px; margin-bottom: 4px; letter-spacing: -0.02em; }
744
+ .lg-modal p.sub { color: var(--fg-2); font-size: 13.5px; margin: 0 0 22px; }
745
+ .lg-field { margin-bottom: 12px; }
746
+ .lg-field label { display: block; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--fg-3); margin-bottom: 6px; font-family: var(--font-mono); }
747
+ .lg-field input {
748
+ width: 100%; padding: 10px 12px; font-size: 14px;
749
+ background: var(--bg-1); border: 1px solid var(--border);
750
+ border-radius: 9px; color: var(--fg-0); outline: none;
751
+ transition: border-color 140ms;
752
+ font-family: inherit;
753
+ }
754
+ .lg-field input:focus { border-color: var(--accent); }
755
+ .lg-row { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; margin-bottom: 16px; font-size: 12.5px; color: var(--fg-2); }
756
+ .lg-row a { color: var(--accent); }
757
+ .lg-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; color: var(--fg-3); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; }
758
+ .lg-divider::before, .lg-divider::after { content: ""; flex: 1; height: 1px; background: var(--border); }
759
+ .lg-sso { display: flex; flex-direction: column; gap: 8px; }
760
+ .lg-sso .btn { justify-content: center; }
761
+ .lg-foot { margin-top: 22px; text-align: center; font-size: 12.5px; color: var(--fg-2); }
762
+ .lg-foot a { color: var(--accent); }
763
+
764
+ /* Reveal */
765
+ .rv { opacity: 0; transform: translateY(20px); transition: opacity 700ms, transform 700ms cubic-bezier(0.2, 0.7, 0.2, 1); }
766
+ .rv.in { opacity: 1; transform: translateY(0); }
767
+
768
+ /* === Scroll progress bar === */
769
+ .scroll-prog {
770
+ position: fixed; top: 0; left: 0; height: 2px; width: 0;
771
+ background: linear-gradient(90deg, var(--accent), var(--violet));
772
+ box-shadow: 0 0 10px var(--accent);
773
+ z-index: 200; transition: width 80ms linear;
774
+ }
775
+
776
+ /* === Hero floating particles === */
777
+ .particles { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
778
+ .particles span {
779
+ position: absolute; bottom: -20px; width: 3px; height: 3px;
780
+ border-radius: 50%; background: var(--accent);
781
+ box-shadow: 0 0 6px var(--accent);
782
+ opacity: 0;
783
+ animation: rise linear infinite;
784
+ }
785
+ .particles span:nth-child(1) { left: 8%; animation-duration: 14s; animation-delay: 0s; }
786
+ .particles span:nth-child(2) { left: 18%; animation-duration: 18s; animation-delay: 2s; background: var(--violet); box-shadow: 0 0 6px var(--violet); }
787
+ .particles span:nth-child(3) { left: 28%; animation-duration: 22s; animation-delay: 4s; }
788
+ .particles span:nth-child(4) { left: 42%; animation-duration: 16s; animation-delay: 1s; background: var(--cyan); box-shadow: 0 0 6px var(--cyan); }
789
+ .particles span:nth-child(5) { left: 56%; animation-duration: 20s; animation-delay: 3s; }
790
+ .particles span:nth-child(6) { left: 68%; animation-duration: 24s; animation-delay: 5s; background: var(--amber); box-shadow: 0 0 6px var(--amber); }
791
+ .particles span:nth-child(7) { left: 82%; animation-duration: 17s; animation-delay: 0.5s; }
792
+ .particles span:nth-child(8) { left: 92%; animation-duration: 19s; animation-delay: 2.5s; background: var(--rose); box-shadow: 0 0 6px var(--rose); }
793
+ @keyframes rise {
794
+ 0% { transform: translateY(0) translateX(0); opacity: 0; }
795
+ 10% { opacity: 1; }
796
+ 100% { transform: translateY(-110vh) translateX(60px); opacity: 0; }
797
+ }
798
+
799
+ /* === Mouse spotlight === */
800
+ .spotlight {
801
+ position: fixed; pointer-events: none; z-index: 1;
802
+ width: 600px; height: 600px; border-radius: 50%;
803
+ background: radial-gradient(circle, oklch(0.85 0.17 145 / 0.10), transparent 60%);
804
+ transform: translate(-50%, -50%);
805
+ transition: opacity 300ms;
806
+ mix-blend-mode: screen;
807
+ }
808
+
809
+ /* === Magnetic CTA glow === */
810
+ .btn.primary { position: relative; overflow: hidden; }
811
+ .btn.primary::after {
812
+ content: ""; position: absolute; inset: 0;
813
+ background: linear-gradient(120deg, transparent 30%, oklch(1 0 0 / 0.35) 50%, transparent 70%);
814
+ transform: translateX(-100%);
815
+ transition: transform 800ms cubic-bezier(0.2, 0.7, 0.2, 1);
816
+ }
817
+ .btn.primary:hover::after { transform: translateX(100%); }
818
+
819
+ /* === Logos marquee === */
820
+ .logos-marquee {
821
+ overflow: hidden; mask-image: linear-gradient(90deg, transparent, black 12%, black 88%, transparent);
822
+ -webkit-mask-image: linear-gradient(90deg, transparent, black 12%, black 88%, transparent);
823
+ }
824
+ .logos-marquee .track {
825
+ display: inline-flex; gap: 80px; align-items: center;
826
+ animation: marquee 32s linear infinite;
827
+ white-space: nowrap;
828
+ padding-right: 80px;
829
+ }
830
+ .logos-marquee .track span { color: var(--fg-2); font-family: var(--font-mono); font-size: 14px; letter-spacing: 0.05em; opacity: 0.7; transition: opacity 300ms, color 300ms; }
831
+ .logos-marquee .track span:hover { opacity: 1; color: var(--fg-0); }
832
+ @keyframes marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
833
+
834
+ /* === Hero h1 letter rise === */
835
+ .hero h1 .ww { display: inline-block; }
836
+ .hero h1 .ww > span {
837
+ display: inline-block;
838
+ opacity: 0; transform: translateY(20px) rotateX(-30deg);
839
+ animation: rise-letter 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both;
840
+ }
841
+ @keyframes rise-letter { to { opacity: 1; transform: translateY(0) rotateX(0); } }
842
+
843
+ /* === Big number counters === */
844
+ .stats-strip {
845
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 0;
846
+ border: 1px solid var(--border); border-radius: 18px;
847
+ background: linear-gradient(180deg, var(--bg-1), oklch(0.18 0.008 250));
848
+ overflow: hidden;
849
+ }
850
+ .stats-strip .st { padding: 28px 24px; border-right: 1px solid var(--border-subtle); position: relative; }
851
+ .stats-strip .st:last-child { border-right: none; }
852
+ .stats-strip .st .num {
853
+ font-family: var(--font-editorial); font-style: normal; font-weight: 400;
854
+ font-size: 52px; line-height: 1; letter-spacing: -0.03em;
855
+ color: var(--fg-0);
856
+ }
857
+ .stats-strip .st .num em { color: var(--accent); font-style: normal; }
858
+ .stats-strip .st .lab { font-family: var(--font-mono); font-size: 11px; color: var(--fg-3); text-transform: uppercase; letter-spacing: 0.14em; margin-top: 8px; }
859
+ .stats-strip .st .sub { font-size: 12.5px; color: var(--fg-2); margin-top: 4px; }
860
+ @media (max-width: 900px) {
861
+ .stats-strip { grid-template-columns: repeat(2, 1fr); }
862
+ .stats-strip .st:nth-child(2) { border-right: none; }
863
+ .stats-strip .st:nth-child(1), .stats-strip .st:nth-child(2) { border-bottom: 1px solid var(--border-subtle); }
864
+ }
865
+
866
+ /* === Glow pulse around hero card === */
867
+ .hero-video::before {
868
+ content: ""; position: absolute; inset: -2px; border-radius: 19px;
869
+ background: conic-gradient(from 0deg, transparent 0%, var(--accent) 25%, transparent 50%, var(--violet) 75%, transparent 100%);
870
+ opacity: 0.4; z-index: -1;
871
+ animation: spin 8s linear infinite;
872
+ filter: blur(8px);
873
+ }
874
+
875
+ /* Responsive */
876
+ @media (max-width: 980px) {
877
+ .hero-grid { grid-template-columns: 1fr; }
878
+ .hero-video { transform: none; aspect-ratio: 16/10; }
879
+ .hero-video:hover { transform: none; }
880
+ .features { grid-template-columns: repeat(2, 1fr); }
881
+ .f-wide-3, .f-wide-4 { grid-column: span 2; }
882
+ .tiers { grid-template-columns: 1fr; }
883
+ .foot-grid { grid-template-columns: 1fr 1fr; }
884
+ .float-badge { display: none; }
885
+ .nav-links { display: none; }
886
+ .stage-content { aspect-ratio: 16/12; }
887
+ .docs-pane { grid-template-columns: 1fr; }
888
+ .docs-side { display: none; }
889
+ }
890
+ @media (max-width: 600px) {
891
+ .features { grid-template-columns: 1fr; }
892
+ .f-wide-3, .f-wide-4 { grid-column: span 1; }
893
+ .foot-grid { grid-template-columns: 1fr; }
894
+ .section { padding: 80px 20px; }
895
+ .nav { padding: 12px 16px; }
896
+ .bk { grid-template-columns: repeat(2, 1fr); }
897
+ }
landing.html ADDED
@@ -0,0 +1,1147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Meridian β€” Where work finds its rhythm</title>
7
+ <meta name="description" content="Meridian es el sistema operativo del equipo: issues, sprints, docs y roadmap en una sola pieza." />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;1,9..144,300;1,9..144,400&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="landing.css" />
12
+
13
+ <script>
14
+ (function() {
15
+ try {
16
+ const saved = JSON.parse(localStorage.getItem('meridian-settings'));
17
+ if (saved) {
18
+ const ACCENT_MAP = {
19
+ lime: { c: "oklch(0.85 0.17 145)", dim: "oklch(0.40 0.10 145)", soft: "oklch(0.30 0.06 145)", fg: "oklch(0.20 0.05 145)" },
20
+ cyan: { c: "oklch(0.78 0.13 220)", dim: "oklch(0.40 0.09 220)", soft: "oklch(0.30 0.06 220)", fg: "oklch(0.18 0.04 220)" },
21
+ violet: { c: "oklch(0.72 0.18 300)", dim: "oklch(0.40 0.10 300)", soft: "oklch(0.30 0.07 300)", fg: "oklch(0.98 0.03 300)" },
22
+ amber: { c: "oklch(0.80 0.14 75)", dim: "oklch(0.44 0.09 75)", soft: "oklch(0.30 0.06 75)", fg: "oklch(0.20 0.04 75)" },
23
+ rose: { c: "oklch(0.72 0.17 20)", dim: "oklch(0.40 0.10 20)", soft: "oklch(0.30 0.07 20)", fg: "oklch(0.98 0.03 20)" },
24
+ };
25
+ if (saved.theme) document.documentElement.dataset.theme = saved.theme;
26
+ if (saved.density) document.documentElement.dataset.density = saved.density;
27
+ const a = ACCENT_MAP[saved.accent] || ACCENT_MAP.lime;
28
+ document.documentElement.style.setProperty('--accent', a.c);
29
+ document.documentElement.style.setProperty('--accent-dim', a.dim);
30
+ document.documentElement.style.setProperty('--accent-soft', a.soft);
31
+ document.documentElement.style.setProperty('--accent-fg', a.fg);
32
+ }
33
+ } catch (e) {}
34
+ })();
35
+ </script>
36
+ </head>
37
+ <body>
38
+ <div class="scroll-prog" id="scroll-prog"></div>
39
+ <div class="bg-field"></div>
40
+ <div class="bg-grid"></div>
41
+ <div class="bg-noise"></div>
42
+ <div class="spotlight" id="spotlight"></div>
43
+ <div class="particles">
44
+ <span></span><span></span><span></span><span></span>
45
+ <span></span><span></span><span></span><span></span>
46
+ </div>
47
+
48
+ <!-- ========= NAV ========= -->
49
+ <nav class="nav">
50
+ <a class="brand" href="#">
51
+ <span class="logo"></span>
52
+ <span class="name">Meridian</span>
53
+ <span class="ws">/ aurora</span>
54
+ </a>
55
+ <div class="nav-links">
56
+ <a href="#features">Product</a>
57
+ <a href="#stage">Demo</a>
58
+ <a href="#pricing">Pricing</a>
59
+ <a href="#faq">FAQ</a>
60
+ <a href="#" id="changelog-link">Changelog</a>
61
+ </div>
62
+ <div class="nav-actions">
63
+ <button class="btn ghost" id="open-login">Log in</button>
64
+ <button class="btn primary" id="open-signup">Start for free β†’</button>
65
+ </div>
66
+ </nav>
67
+
68
+ <!-- ========= HERO ========= -->
69
+ <section class="section hero">
70
+ <div class="wrap hero-grid">
71
+ <div>
72
+ <div class="badge-row rv">
73
+ <span class="eyebrow"><span class="dot"></span>v3.4 β€” Vector AI triage</span>
74
+ <span class="eyebrow mono">amd hackaton at lablab.ai</span>
75
+ </div>
76
+ <h1 class="rv">
77
+ <span class="ww">Where</span><br/>
78
+ <span class="ww">work</span><br/>
79
+ <span class="ww">finds its </span><span class="word-shimmer">rhythm</span><span class="cursor"></span>
80
+ </h1>
81
+ <p class="lead rv">
82
+ Issues, sprints, docs, roadmap y PRs en una sola pieza, hecha a la
83
+ velocity del teclado. Sin tabs perdidos. Sin standups eternos.
84
+ </p>
85
+ <div class="hero-cta rv">
86
+ <button class="btn primary lg" id="cta-hero">Start for free</button>
87
+ <button class="btn lg" id="cta-demo">
88
+ See live demo
89
+ <span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--rose); box-shadow:0 0 8px var(--rose); animation: pulse 1.4s infinite"></span>
90
+ </button>
91
+ </div>
92
+ <div class="hero-meta rv">
93
+ <span class="d"><span class="ind"></span>14.231 equipos activos</span>
94
+ <span class="d mono">SOC 2 Β· ISO 27001</span>
95
+ <span class="d mono">99.99% uptime</span>
96
+ </div>
97
+ </div>
98
+
99
+ <!-- Hero "video" -->
100
+ <div class="hero-video rv" aria-label="Demo en vivo de Meridian">
101
+ <div class="hv-chrome">
102
+ <span class="dot" style="background: var(--rose)"></span>
103
+ <span class="dot" style="background: var(--amber)"></span>
104
+ <span class="dot" style="background: var(--accent)"></span>
105
+ <span class="url">meridian.app/aurora/sprint-22</span>
106
+ </div>
107
+
108
+ <div class="hv-stage">
109
+ <!-- toolbar fake -->
110
+ <div class="hv-toolbar">
111
+ <div class="hv-tabs">
112
+ <span class="on">Board</span>
113
+ <span>List</span>
114
+ <span>Timeline</span>
115
+ </div>
116
+ <div class="hv-presence">
117
+ <span class="pa pa-1">JL</span>
118
+ <span class="pa pa-2">MK</span>
119
+ <span class="pa pa-3">RT</span>
120
+ <span class="pn"><span class="pn-d"></span>3 en lΓ­nea</span>
121
+ </div>
122
+ </div>
123
+
124
+ <div class="kanban">
125
+ <div class="kcol todo" data-col="todo">
126
+ <h5><span class="sd"></span>Backlog<span class="ct">3</span></h5>
127
+ <div class="kcard"><span class="id">AUR-411</span><span class="title">Onboarding: empty state</span><span class="meta"><span class="av"></span>JL Β· <span class="tag">design</span></span></div>
128
+ <div class="kcard"><span class="id">AUR-413</span><span class="title">Sentry tracing webhooks</span><span class="meta"><span class="av" style="background: linear-gradient(135deg, var(--rose), var(--amber))"></span>MK Β· <span class="tag">infra</span></span></div>
129
+ <div class="kcard"><span class="id">AUR-419</span><span class="title">Bulk re-prioritize</span><span class="meta"><span class="av" style="background: linear-gradient(135deg, var(--cyan), var(--accent))"></span>SP</span></div>
130
+ </div>
131
+ <div class="kcol prog" data-col="prog">
132
+ <h5><span class="sd"></span>In progress<span class="ct">2</span></h5>
133
+ <div class="kcard k-target"><span class="id">AUR-412</span><span class="title">Realtime cursors</span><span class="meta"><span class="av"></span>JL Β· <span class="tag">edit</span></span></div>
134
+ <div class="kcard"><span class="id">AUR-407</span><span class="title">Vector triage digest</span><span class="meta"><span class="av" style="background: linear-gradient(135deg, var(--rose), var(--amber))"></span>RT Β· <span class="tag">ml</span></span></div>
135
+ </div>
136
+ <div class="kcol done" data-col="done">
137
+ <h5><span class="sd"></span>Shipped<span class="ct">2</span></h5>
138
+ <div class="kcard"><span class="id">AUR-401</span><span class="title">Cmd-K fuzzy search</span><span class="meta"><span class="av"></span>RT Β· <span class="tag">ship</span></span></div>
139
+ <div class="kcard"><span class="id">AUR-403</span><span class="title">Relative dates</span><span class="meta"><span class="av" style="background: linear-gradient(135deg, var(--cyan), var(--violet))"></span>JL</span></div>
140
+ </div>
141
+ </div>
142
+
143
+ <!-- single moving card (the "live" demo) -->
144
+ <div class="kcard moving" id="moving-card">
145
+ <span class="id">AUR-405</span>
146
+ <span class="title">Embed Figma en docs</span>
147
+ <span class="meta"><span class="av" style="background: linear-gradient(135deg, var(--violet), var(--cyan))"></span>julia Β· <span class="tag">ship</span></span>
148
+ </div>
149
+
150
+ <!-- cursor that "drags" the card -->
151
+ <div class="cursor-fly">
152
+ <svg viewBox="0 0 16 16">
153
+ <path d="M2 2 L13 7 L8 8.5 L7 14 Z" fill="oklch(0.85 0.17 145)" stroke="oklch(0.20 0.05 145)" stroke-width="1"/>
154
+ </svg>
155
+ <span class="label">julia</span>
156
+ </div>
157
+
158
+ <!-- bottom live stats -->
159
+ <div class="hv-stats">
160
+ <div class="hv-stat"><span class="hv-k">Cycle</span><span class="hv-v">3.2d</span></div>
161
+ <div class="hv-stat"><span class="hv-k">Burn</span><span class="hv-v hv-bar"><i style="width:64%"></i></span></div>
162
+ <div class="hv-stat"><span class="hv-k">Build</span><span class="hv-v"><span class="hv-d hv-d-ok"></span>passed Β· 1m 04s</span></div>
163
+ <div class="hv-stat"><span class="hv-k">PR</span><span class="hv-v">#882 merged</span></div>
164
+ </div>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </section>
169
+
170
+ <!-- ========= LOGOS ========= -->
171
+ <section class="logos">
172
+ <div class="wrap">
173
+ <div class="label">Construido con equipos en</div>
174
+ <div class="logos-marquee">
175
+ <div class="track">
176
+ <span>β—Œ ARCWAVE</span>
177
+ <span>HELIOSΒ·CO</span>
178
+ <span>FRAME / SHIFT</span>
179
+ <span>NORTH BUREAU</span>
180
+ <span>nimbus<sup>Γ—</sup></span>
181
+ <span>β€”β€” ECHO LABS</span>
182
+ <span>VANGUARD&nbsp;OPS</span>
183
+ <span>β—‡ KINETIC</span>
184
+ <span>POLARIS&nbsp;/CO</span>
185
+ <span>β€» SUBSTRATE</span>
186
+ <span>β—Œ ARCWAVE</span>
187
+ <span>HELIOSΒ·CO</span>
188
+ <span>FRAME / SHIFT</span>
189
+ <span>NORTH BUREAU</span>
190
+ <span>nimbus<sup>Γ—</sup></span>
191
+ <span>β€”β€” ECHO LABS</span>
192
+ <span>VANGUARD&nbsp;OPS</span>
193
+ <span>β—‡ KINETIC</span>
194
+ <span>POLARIS&nbsp;/CO</span>
195
+ <span>β€» SUBSTRATE</span>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ </section>
200
+
201
+ <!-- ========= STATS STRIP ========= -->
202
+ <section class="section" style="padding-top:40px; padding-bottom:40px">
203
+ <div class="wrap">
204
+ <div class="stats-strip rv">
205
+ <div class="st">
206
+ <div class="num"><em data-stat="14231">0</em></div>
207
+ <div class="lab">Active teams</div>
208
+ <div class="sub">+18% this quarter</div>
209
+ </div>
210
+ <div class="st">
211
+ <div class="num"><em data-stat="3.2" data-decimals="1">0.0</em><span style="font-size:28px;color:var(--fg-2)">d</span></div>
212
+ <div class="lab">Cycle time medio</div>
213
+ <div class="sub">vs 5.7d en herramientas tradicionales</div>
214
+ </div>
215
+ <div class="st">
216
+ <div class="num"><em data-stat="42">0</em><span style="font-size:28px;color:var(--fg-2)">ms</span></div>
217
+ <div class="lab">Latencia p50</div>
218
+ <div class="sub">acciΓ³n β†’ render</div>
219
+ </div>
220
+ <div class="st">
221
+ <div class="num"><em data-stat="99.99" data-decimals="2">0</em><span style="font-size:28px;color:var(--fg-2)">%</span></div>
222
+ <div class="lab">Uptime SLA</div>
223
+ <div class="sub">ΓΊltimos 12 meses</div>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </section>
228
+
229
+ <!-- ========= FEATURES ========= -->
230
+ <section class="section" id="features">
231
+ <div class="wrap">
232
+ <div class="feature-head rv">
233
+ <span class="eyebrow"><span class="dot"></span>The product</span>
234
+ <h2>One tool. <span class="editorial">Zero friction.</span></h2>
235
+ <p>Cada pieza de Meridian estΓ‘ pensada para que el work se mueva sin que tengas que recordarlo.</p>
236
+ </div>
237
+
238
+ <div class="features rv">
239
+ <!-- Velocity -->
240
+ <div class="f-card f-wide-3">
241
+ <div class="label"><span class="n">01</span> Β· velocity</div>
242
+ <h3>Shortcuts everywhere. Mouse optional.</h3>
243
+ <p>Cmd-K abre la paleta. C crea issue. G+S va a sprints. AprendΓ©s en una tarde, no lo soltΓ‘s mΓ‘s.</p>
244
+ <div class="canvas" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;padding:18px">
245
+ <span class="kbd" style="font-size:12px;padding:6px 10px">⌘K</span>
246
+ <span class="kbd" style="font-size:12px;padding:6px 10px">C</span>
247
+ <span class="kbd" style="font-size:12px;padding:6px 10px">G</span><span style="color:var(--fg-3)">+</span>
248
+ <span class="kbd" style="font-size:12px;padding:6px 10px">S</span>
249
+ <span class="kbd" style="font-size:12px;padding:6px 10px">⌘ ↡</span>
250
+ <span class="kbd" style="font-size:12px;padding:6px 10px">/</span>
251
+ <span class="kbd" style="font-size:12px;padding:6px 10px">⇧?</span>
252
+ <div style="flex-basis: 100%; height: 0"></div>
253
+ <div class="term" style="background:var(--bg-1);border:1px solid var(--border);border-radius:8px;padding:10px 12px;width:100%;max-width:340px;margin-top:10px">
254
+ <div class="l mono"><span class="pr">β€Ί</span> &nbsp;assign aur-412 julia<span class="blink"></span></div>
255
+ </div>
256
+ </div>
257
+ </div>
258
+
259
+ <!-- AI triage -->
260
+ <div class="f-card f-wide-3">
261
+ <div class="label"><span class="n">02</span> Β· vector ai</div>
262
+ <h3>Triage automΓ‘tico con embeddings.</h3>
263
+ <p>Tu inbox se ordena solo: duplicados, prioridad, owner sugerido. Vos solo confirmΓ‘s.</p>
264
+ <div class="canvas">
265
+ <div class="ai-stream">
266
+ <div class="ai-row">
267
+ <span class="av"></span>
268
+ <span class="t">"Login no funciona en Safari iOS 17"</span>
269
+ <span class="tag">β†’ AUR-402 dup</span>
270
+ </div>
271
+ <div class="ai-row r2">
272
+ <span class="av" style="background:linear-gradient(135deg,var(--rose),var(--amber))"></span>
273
+ <span class="t">"Notif push se pierden"</span>
274
+ <span class="tag">β†’ infra Β· alta</span>
275
+ </div>
276
+ <div class="ai-row r3">
277
+ <span class="av" style="background:linear-gradient(135deg,var(--cyan),var(--violet))"></span>
278
+ <span class="t">"ΒΏCΓ³mo invito a un externo?"</span>
279
+ <span class="tag">β†’ docs/sharing</span>
280
+ </div>
281
+ <div class="ai-row r4">
282
+ <span class="av"></span>
283
+ <span class="t">"Roadmap de Q2 disponible?"</span>
284
+ <span class="tag">β†’ raΓΊl</span>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ </div>
289
+
290
+ <!-- Realtime collab -->
291
+ <div class="f-card f-wide-2">
292
+ <div class="label"><span class="n">03</span> Β· collaboration</div>
293
+ <h3>Realtime, no saving.</h3>
294
+ <p>Los cursores, las decisiones y los cambios se mueven con el equipo.</p>
295
+ <div class="canvas">
296
+ <div class="orbit">
297
+ <div class="ring r1"></div>
298
+ <div class="ring r2"></div>
299
+ <div class="ring r3"></div>
300
+ <div class="core"></div>
301
+ <div class="av a1">JL</div>
302
+ <div class="av a2">MK</div>
303
+ <div class="av a3">RT</div>
304
+ </div>
305
+ </div>
306
+ </div>
307
+
308
+ <!-- Velocity counter -->
309
+ <div class="f-card f-wide-2">
310
+ <div class="label"><span class="n">04</span> Β· numbers</div>
311
+ <h3>Measured velocity.</h3>
312
+ <p>Cycle time, throughput y burn β€” actualizados en vivo.</p>
313
+ <div class="canvas">
314
+ <div class="counter-canvas">
315
+ <div class="num"><em data-count="3.2">0.0</em>d</div>
316
+ <div class="lab">cycle time medio</div>
317
+ </div>
318
+ </div>
319
+ </div>
320
+
321
+ <!-- Roadmap -->
322
+ <div class="f-card f-wide-2">
323
+ <div class="label"><span class="n">05</span> Β· roadmap</div>
324
+ <h3>Quarters, no fantasy.</h3>
325
+ <p>Iniciativas con dependencias y riesgo, en una sola lΓ­nea.</p>
326
+ <div class="canvas">
327
+ <div class="tl">
328
+ <div class="row"><span class="lab">Auth v2</span><div class="bar b1"><div class="fill"></div></div></div>
329
+ <div class="row"><span class="lab">Vector AI</span><div class="bar b2"><div class="fill"></div></div></div>
330
+ <div class="row"><span class="lab">Docs sync</span><div class="bar b3"><div class="fill"></div></div></div>
331
+ <div class="row"><span class="lab">SAML SSO</span><div class="bar b4"><div class="fill"></div></div></div>
332
+ </div>
333
+ </div>
334
+ </div>
335
+
336
+ <!-- Velocity full -->
337
+ <div class="f-card f-wide-3">
338
+ <div class="label"><span class="n">06</span> Β· analysis</div>
339
+ <h3>Trend that is understood without charts.</h3>
340
+ <p>Burndown semanal con anotaciones, no dashboards huΓ©rfanos.</p>
341
+ <div class="canvas">
342
+ <svg class="spark-svg" viewBox="0 0 600 160" preserveAspectRatio="none">
343
+ <defs>
344
+ <linearGradient id="g-area" x1="0" x2="0" y1="0" y2="1">
345
+ <stop offset="0%" stop-color="oklch(0.85 0.17 145)" stop-opacity="0.45"/>
346
+ <stop offset="100%" stop-color="oklch(0.85 0.17 145)" stop-opacity="0"/>
347
+ </linearGradient>
348
+ </defs>
349
+ <path class="area" d="M0,140 L0,110 C 60,100 90,60 150,70 S 240,120 300,90 S 420,40 480,55 S 580,30 600,20 L600,160 L0,160 Z"/>
350
+ <path class="ln" d="M0,110 C 60,100 90,60 150,70 S 240,120 300,90 S 420,40 480,55 S 580,30 600,20"/>
351
+ <circle class="pt" cx="600" cy="20" r="4"/>
352
+ </svg>
353
+ </div>
354
+ </div>
355
+
356
+ <!-- Editor -->
357
+ <div class="f-card f-wide-3">
358
+ <div class="label"><span class="n">07</span> Β· docs</div>
359
+ <h3>Notion for humans, Markdown for machines.</h3>
360
+ <p>Documentos que viven al lado del cΓ³digo, con embeds y backlinks de issues.</p>
361
+ <div class="canvas" style="padding:14px;font-family:var(--font-editorial);font-style:italic;font-size:24px;line-height:1.3;letter-spacing:-0.02em;color:var(--fg-1)">
362
+ "El feedback negativo se dispara cuando el cycle time supera 4 dΓ­as"
363
+ <div style="font-family:var(--font-mono);font-style:normal;font-size:10.5px;color:var(--fg-3);text-transform:uppercase;letter-spacing:0.12em;margin-top:14px">β€” retro Β· sprint 21</div>
364
+ </div>
365
+ </div>
366
+
367
+ </div>
368
+ </div>
369
+ </section>
370
+
371
+ <!-- ========= STAGE (interactive demo) ========= -->
372
+ <section class="section stage-section" id="stage">
373
+ <div class="wrap">
374
+ <div class="feature-head rv">
375
+ <span class="eyebrow"><span class="dot"></span>Interactive tour</span>
376
+ <h2>See Meridian <span class="editorial">from the inside</span>.</h2>
377
+ <p>CambiΓ‘ de vista. Como en la app real.</p>
378
+ </div>
379
+
380
+ <div class="stage-shell rv">
381
+ <div class="stage-tabs" role="tablist">
382
+ <button data-tab="kanban" class="on">Sprint board</button>
383
+ <button data-tab="gantt">Roadmap</button>
384
+ <button data-tab="docs">Docs</button>
385
+ </div>
386
+ <div class="stage-content">
387
+ <!-- Kanban pane -->
388
+ <div class="stage-pane on" data-pane="kanban">
389
+ <div class="bk">
390
+ <div class="bk-col" data-col="backlog">
391
+ <h5>Backlog <span class="ct">12</span></h5>
392
+ <div class="drop-zone"></div>
393
+ <div class="bk-card" data-id="AUR-419"><span class="id">AUR-419</span><span class="t">Bulk re-prioritize</span><span class="ft"><span class="pri" style="background:var(--fg-3)"></span>P3 Β· SP</span></div>
394
+ <div class="bk-card" data-id="AUR-422"><span class="id">AUR-422</span><span class="t">CSV export en issues</span><span class="ft"><span class="pri" style="background:var(--amber)"></span>P2 Β· MK</span></div>
395
+ <div class="bk-card" data-id="AUR-424"><span class="id">AUR-424</span><span class="t">Filtro por label combinado</span><span class="ft"><span class="pri" style="background:var(--fg-3)"></span>P3 Β· β€”</span></div>
396
+ </div>
397
+ <div class="bk-col" data-col="todo">
398
+ <h5>Todo <span class="ct">7</span></h5>
399
+ <div class="drop-zone"></div>
400
+ <div class="bk-card" data-id="AUR-409"><span class="id">AUR-409</span><span class="t">Auth: rotaciΓ³n de tokens</span><span class="ft"><span class="pri" style="background:var(--rose)"></span>P0 Β· NM</span></div>
401
+ <div class="bk-card" data-id="AUR-410"><span class="id">AUR-410</span><span class="t">Sprint board: keyboard reorder</span><span class="ft"><span class="pri" style="background:var(--amber)"></span>P2 Β· EH</span></div>
402
+ <div class="bk-card" data-id="AUR-411"><span class="id">AUR-411</span><span class="t">Empty state en proyecto nuevo</span><span class="ft"><span class="pri" style="background:var(--fg-3)"></span>P3 Β· JL</span></div>
403
+ </div>
404
+ <div class="bk-col" data-col="progress">
405
+ <h5>In progress <span class="ct">4</span></h5>
406
+ <div class="drop-zone"></div>
407
+ <div class="bk-card" data-id="AUR-407"><span class="id">AUR-407</span><span class="t">Vector triage: digest semanal</span><span class="ft"><span class="pri" style="background:var(--rose)"></span>P0 Β· RT</span></div>
408
+ <div class="bk-card" data-id="AUR-408"><span class="id">AUR-408</span><span class="t">A11y: drag handle del board</span><span class="ft"><span class="pri" style="background:var(--amber)"></span>P2 Β· EH</span></div>
409
+ </div>
410
+ <div class="bk-col" data-col="shipped">
411
+ <h5>Shipped <span class="ct">23</span></h5>
412
+ <div class="drop-zone"></div>
413
+ <div class="bk-card" data-id="AUR-401"><span class="id">AUR-401</span><span class="t">Cmd-K Β· fuzzy en body</span><span class="ft"><span class="pri" style="background:var(--accent)"></span>done Β· RT</span></div>
414
+ <div class="bk-card" data-id="AUR-403"><span class="id">AUR-403</span><span class="t">Fechas relativas en todo</span><span class="ft"><span class="pri" style="background:var(--accent)"></span>done Β· JL</span></div>
415
+ <div class="bk-card" data-id="AUR-405"><span class="id">AUR-405</span><span class="t">Embed de Figma en docs</span><span class="ft"><span class="pri" style="background:var(--accent)"></span>done Β· MK</span></div>
416
+ </div>
417
+ </div>
418
+ </div>
419
+
420
+ <!-- Gantt pane -->
421
+ <div class="stage-pane" data-pane="gantt">
422
+ <div class="gantt">
423
+ <div class="head">
424
+ <span>Iniciativa</span>
425
+ <div class="weeks">
426
+ <span>W23</span><span>W24</span><span>W25</span><span>W26</span><span>W27</span><span>W28</span><span>W29</span><span>W30</span>
427
+ </div>
428
+ </div>
429
+ <div class="grow">
430
+ <div class="g-row">
431
+ <span class="nm"><span class="d" style="background:var(--accent)"></span>Auth v2 Β· rotaciΓ³n</span>
432
+ <div class="gtrack"><div class="b" style="left:5%;width:30%;background:var(--accent)"></div></div>
433
+ </div>
434
+ <div class="g-row">
435
+ <span class="nm"><span class="d" style="background:var(--violet)"></span>Vector AI Β· embeddings</span>
436
+ <div class="gtrack"><div class="b" style="left:18%;width:42%;background:var(--violet)"></div></div>
437
+ </div>
438
+ <div class="g-row">
439
+ <span class="nm"><span class="d" style="background:var(--amber)"></span>Docs sync (Notion-like)</span>
440
+ <div class="gtrack"><div class="b" style="left:30%;width:38%;background:var(--amber)"></div></div>
441
+ </div>
442
+ <div class="g-row">
443
+ <span class="nm"><span class="d" style="background:var(--cyan)"></span>SAML / SSO Enterprise</span>
444
+ <div class="gtrack"><div class="b" style="left:55%;width:28%;background:var(--cyan)"></div></div>
445
+ </div>
446
+ <div class="g-row">
447
+ <span class="nm"><span class="d" style="background:var(--rose)"></span>Mobile Β· PWA offline</span>
448
+ <div class="gtrack"><div class="b" style="left:65%;width:30%;background:var(--rose)"></div></div>
449
+ </div>
450
+ <div class="g-row">
451
+ <span class="nm"><span class="d" style="background:var(--fg-2)"></span>API v2 pΓΊblico</span>
452
+ <div class="gtrack"><div class="b" style="left:80%;width:18%;background:var(--fg-2)"></div></div>
453
+ </div>
454
+ </div>
455
+ </div>
456
+ </div>
457
+
458
+ <!-- Docs pane -->
459
+ <div class="stage-pane" data-pane="docs">
460
+ <div class="docs-pane">
461
+ <div class="docs-side">
462
+ <div class="gr">Aurora</div>
463
+ <div class="item"><svg class="di" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Plan trimestral</div>
464
+ <div class="item on"><svg class="di" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Retro sprint 21</div>
465
+ <div class="item"><svg class="di" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>DecisiΓ³n: vector store</div>
466
+ <div class="gr">Engineering</div>
467
+ <div class="item"><svg class="di" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Postmortem Β· cache</div>
468
+ <div class="item"><svg class="di" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>RFC Β· auth rotation</div>
469
+ </div>
470
+ <div class="docs-body">
471
+ <div class="meta">retro Β· sprint 21 Β· 4 jun</div>
472
+ <h3>Lo que aprendimos del ΓΊltimo ciclo</h3>
473
+ <p>Cerramos sprint 21 con un <span class="hl">cycle time de 3.2 dΓ­as</span> β€” una mejora de 18% sobre el anterior. La hipΓ³tesis: el triage automΓ‘tico con vector AI nos sacΓ³ la fricciΓ³n del lunes.</p>
474
+ <p>El feedback negativo se dispara cuando el cycle time supera 4 dΓ­as. Mantenerlo abajo de eso es probablemente el indicador mΓ‘s correlacionado con NPS interno.</p>
475
+ <div class="embed">
476
+ <span class="iv">πŸ“Š</span>
477
+ <div>
478
+ <div style="font-weight:500;color:var(--fg-0)">Burndown Β· Sprint 21</div>
479
+ <div style="font-size:11px;color:var(--fg-3);font-family:var(--font-mono);margin-top:2px">embed Β· meridian/charts</div>
480
+ </div>
481
+ </div>
482
+ <p>PrΓ³ximo paso: medir si el digest semanal del viernes mantiene la inercia o la rompe en transiciΓ³n de fin de semana.</p>
483
+ </div>
484
+ </div>
485
+ </div>
486
+ </div>
487
+ </div>
488
+ </div>
489
+ </section>
490
+
491
+ <!-- ========= QUOTE ========= -->
492
+ <section class="quote-section">
493
+ <div class="wrap">
494
+ <p class="quote rv">
495
+ "Probamos cinco herramientas en seis meses. Meridian fue la primera que el equipo
496
+ <span style="color:var(--accent);font-style:normal;font-weight:500">no quiso reemplazar</span>.
497
+ Las cosas se mueven."
498
+ <span class="who">
499
+ <span class="av"></span>
500
+ LucΓ­a BermΓΊdez Β· Head of Eng, HeliosΒ·co
501
+ </span>
502
+ </p>
503
+ </div>
504
+ </section>
505
+
506
+ <!-- ========= PRICING ========= -->
507
+ <section class="section" id="pricing">
508
+ <div class="wrap">
509
+ <div class="pricing-head rv">
510
+ <span class="eyebrow"><span class="dot"></span>Pricing</span>
511
+ <h2>You pay for what you use. <span class="editorial">Nothing more.</span></h2>
512
+ <p>EmpezΓ‘ gratis. CrecΓ© sin sorpresas. CancelΓ‘ con un click.</p>
513
+ <div class="bill-toggle" id="bill-toggle" role="tablist">
514
+ <span class="pill" id="bill-pill"></span>
515
+ <button class="on" data-bill="month">Monthly</button>
516
+ <button data-bill="year">Yearly <span class="save">-20%</span></button>
517
+ </div>
518
+ </div>
519
+
520
+ <div class="tiers rv">
521
+ <!-- Free -->
522
+ <div class="tier">
523
+ <div>
524
+ <h3>Solo</h3>
525
+ <div class="desc">For a person or a side project.</div>
526
+ </div>
527
+ <div class="price-row">
528
+ <span class="price">$0</span>
529
+ <span class="per">per user / month</span>
530
+ </div>
531
+ <div class="feats">
532
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>1 workspace Β· hasta 3 usuarios</div>
533
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>Issues, sprints y docs ilimitados</div>
534
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>Cmd-K, atajos, shortcuts</div>
535
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>API y CLI bΓ‘sicos</div>
536
+ </div>
537
+ <button class="btn login-trigger">Start for free</button>
538
+ </div>
539
+
540
+ <!-- Team featured -->
541
+ <div class="tier featured">
542
+ <span class="badge">popular</span>
543
+ <div>
544
+ <h3>Team</h3>
545
+ <div class="desc">Para equipos que envΓ­an cosas todas las semanas.</div>
546
+ </div>
547
+ <div class="price-row">
548
+ <span class="price" data-month="$12" data-year="$10">$12</span>
549
+ <span class="per">per user / month</span>
550
+ </div>
551
+ <div class="feats">
552
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>Unlimited workspaces</div>
553
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>Vector AI triage incluido</div>
554
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>Roadmap, mΓ©tricas y burndown</div>
555
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>GitHub, Slack, Linear sync</div>
556
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>Soporte priority β€” &lt; 4h</div>
557
+ </div>
558
+ <button class="btn primary login-trigger">Try 14 days free</button>
559
+ </div>
560
+
561
+ <!-- Enterprise -->
562
+ <div class="tier">
563
+ <div>
564
+ <h3>Enterprise</h3>
565
+ <div class="desc">Para organizaciones con cumplimiento serio.</div>
566
+ </div>
567
+ <div class="price-row">
568
+ <span class="price" style="font-size:38px">A medida</span>
569
+ </div>
570
+ <div class="feats">
571
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>SSO/SAML, SCIM, audit log</div>
572
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>SOC 2 type II, ISO 27001</div>
573
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>Despliegue en tu nube (BYOC)</div>
574
+ <div class="feat"><svg class="ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>SLA 99.99% Β· soporte dedicado</div>
575
+ </div>
576
+ <button class="btn">Talk to sales</button>
577
+ </div>
578
+ </div>
579
+ </div>
580
+ </section>
581
+
582
+ <!-- ========= FAQ ========= -->
583
+ <section class="section" id="faq">
584
+ <div class="wrap">
585
+ <div class="feature-head rv">
586
+ <span class="eyebrow"><span class="dot"></span>FAQ</span>
587
+ <h2>Reasonable <span class="editorial">questions</span>.</h2>
588
+ </div>
589
+ <div class="faq rv">
590
+ <div class="faq-item open">
591
+ <button class="faq-q">ΒΏPuedo migrar desde Linear, Jira o Notion? <span class="pl">+</span></button>
592
+ <div class="faq-a"><p>SΓ­. Tenemos importadores con un click para Linear y Jira (issues, comentarios, attachments, sprints). Para Notion importamos pΓ‘ginas como docs, conservando la jerarquΓ­a.</p></div>
593
+ </div>
594
+ <div class="faq-item">
595
+ <button class="faq-q">ΒΏCΓ³mo se cobran los usuarios invitados? <span class="pl">+</span></button>
596
+ <div class="faq-a"><p>Los guests con permiso de solo-comentario son gratis. Solo cobramos por usuarios con acceso de ediciΓ³n a issues o docs.</p></div>
597
+ </div>
598
+ <div class="faq-item">
599
+ <button class="faq-q">ΒΏMis datos son mΓ­os? <span class="pl">+</span></button>
600
+ <div class="faq-a"><p>Siempre. ExportΓ‘s todo en JSON o Markdown desde Settings β†’ Data. No usamos tus issues para entrenar modelos. Punto.</p></div>
601
+ </div>
602
+ <div class="faq-item">
603
+ <button class="faq-q">ΒΏFunciona offline? <span class="pl">+</span></button>
604
+ <div class="faq-a"><p>El web app cachea tus ΓΊltimos 30 dΓ­as localmente. La PWA mobile (Q3) lo hace nativo, con sync al volver online.</p></div>
605
+ </div>
606
+ <div class="faq-item">
607
+ <button class="faq-q">ΒΏHay descuento para startups/educaciΓ³n/OSS? <span class="pl">+</span></button>
608
+ <div class="faq-a"><p>SΓ­, tres programas: Startups (50% por 1 aΓ±o si tenΓ©s &lt; $5M raised), educaciΓ³n (gratis para .edu) y OSS (gratis para repos pΓΊblicos verificados).</p></div>
609
+ </div>
610
+ </div>
611
+ </div>
612
+ </section>
613
+
614
+ <!-- ========= CTA ========= -->
615
+ <section class="cta-section">
616
+ <div class="cta-card rv">
617
+ <span class="eyebrow"><span class="dot"></span>Empezar</span>
618
+ <h2>El work no se hace solo.<br/><span class="editorial">But almost.</span></h2>
619
+ <p class="lead">14 dΓ­as gratis en Team. Sin tarjeta. 90 segundos de setup.</p>
620
+ <div class="row">
621
+ <button class="btn primary lg login-trigger">Start for free β†’</button>
622
+ <button class="btn lg" id="cta-demo-2">See live demo</button>
623
+ </div>
624
+ </div>
625
+ </section>
626
+
627
+ <!-- ========= FOOTER ========= -->
628
+ <footer class="foot">
629
+ <div class="foot-grid">
630
+ <div>
631
+ <a class="brand" href="#" style="margin-bottom:14px">
632
+ <span class="logo"></span>
633
+ <span class="name">Meridian</span>
634
+ </a>
635
+ <p style="color:var(--fg-2);font-size:13px;max-width:36ch;margin:14px 0 0">El sistema operativo del equipo.<br/>Hecho en Buenos Aires, San Francisco y Lisboa.</p>
636
+ </div>
637
+ <div>
638
+ <h5>Product</h5>
639
+ <ul>
640
+ <li><a href="#features">Funcionalidades</a></li>
641
+ <li><a href="#pricing">Pricing</a></li>
642
+ <li><a href="#">Integraciones</a></li>
643
+ <li><a href="#">Changelog</a></li>
644
+ <li><a href="#">Roadmap</a></li>
645
+ </ul>
646
+ </div>
647
+ <div>
648
+ <h5>Company</h5>
649
+ <ul>
650
+ <li><a href="#">Sobre nosotros</a></li>
651
+ <li><a href="#">Blog</a></li>
652
+ <li><a href="#">Trabajos</a></li>
653
+ <li><a href="#">Prensa</a></li>
654
+ </ul>
655
+ </div>
656
+ <div>
657
+ <h5>Legal</h5>
658
+ <ul>
659
+ <li><a href="#">Privacy</a></li>
660
+ <li><a href="#">Terms</a></li>
661
+ <li><a href="#">Security</a></li>
662
+ <li><a href="#">Status</a></li>
663
+ </ul>
664
+ </div>
665
+ </div>
666
+ <div class="foot-bot">
667
+ <span class="mono">Β© 2026 Meridian Software, Inc.</span>
668
+ <span class="mono">v3.4.2 Β· BUE / SFO / LIS</span>
669
+ </div>
670
+ </footer>
671
+
672
+ <!-- ========= LOGIN MODAL ========= -->
673
+ <div class="lg-overlay" id="login-overlay" role="dialog" aria-modal="true" aria-labelledby="lg-title">
674
+ <div class="lg-modal">
675
+ <button class="lg-x" id="lg-close" aria-label="Cerrar">
676
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>
677
+ </button>
678
+ <h3 id="lg-title">Welcome back</h3>
679
+ <p class="sub">IngresΓ‘ a tu workspace.</p>
680
+
681
+ <div style="display:flex;align-items:center;gap:8px;background:oklch(0.25 0.06 145/0.35);border:1px solid oklch(0.85 0.17 145/0.3);border-radius:8px;padding:8px 12px;margin-bottom:14px;font-size:12px;color:var(--accent)">
682
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg>
683
+ Cuenta demo β€” entrΓ‘ con un click
684
+ </div>
685
+ <form id="lg-form">
686
+ <div class="lg-field">
687
+ <label>Email de work</label>
688
+ <input type="email" id="lg-email" value="demo@meridian.app" autocomplete="email" />
689
+ </div>
690
+ <div class="lg-field">
691
+ <label>Password</label>
692
+ <input type="password" id="lg-password" value="demo1234" autocomplete="current-password" />
693
+ </div>
694
+ <div class="lg-row">
695
+ <label style="display:inline-flex;align-items:center;gap:6px"><input type="checkbox" checked /> Recordar 30 dΓ­as</label>
696
+ <a href="#">ΒΏOlvidaste?</a>
697
+ </div>
698
+ <button type="submit" class="btn primary" style="width:100%;justify-content:center">Enter Meridian β†’</button>
699
+ </form>
700
+
701
+ <div class="lg-divider">or continue with</div>
702
+ <div class="lg-sso">
703
+ <button class="btn login-sso">
704
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21.35 11.1H12v3.9h5.35c-.5 2.5-2.6 4.3-5.35 4.3-3.25 0-5.9-2.65-5.9-5.9s2.65-5.9 5.9-5.9c1.6 0 3 .65 4 1.55l2.75-2.75C16.95 4.7 14.65 3.7 12 3.7 6.85 3.7 2.7 7.85 2.7 13s4.15 9.3 9.3 9.3c5.35 0 8.9-3.75 8.9-9.05 0-.6-.05-1.15-.15-1.65z"/></svg>
705
+ Continue with Google
706
+ </button>
707
+ <button class="btn login-sso">
708
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 .3c-6.6 0-12 5.4-12 12 0 5.3 3.4 9.8 8.2 11.4.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.3 3.6 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.4 1.3-3.2-.1-.3-.6-1.6.1-3.4 0 0 1-.3 3.4 1.2 1-.3 2-.4 3-.4s2 .1 3 .4c2.3-1.6 3.4-1.2 3.4-1.2.7 1.7.2 3 .1 3.4.8.9 1.3 2 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6 4.7-1.6 8.1-6.1 8.1-11.4 0-6.6-5.4-12-12-12z"/></svg>
709
+ Continue with GitHub
710
+ </button>
711
+ <button class="btn login-sso">
712
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 8l9 6 9-6"/></svg>
713
+ SSO / SAML
714
+ </button>
715
+ </div>
716
+ <div class="lg-foot">No account? <a href="#" id="lg-signup">Crear workspace</a></div>
717
+ </div>
718
+ </div>
719
+
720
+ <script>
721
+ // === Spotlight follow ===
722
+ const spot = document.getElementById('spotlight');
723
+ document.addEventListener('mousemove', (e) => {
724
+ spot.style.left = e.clientX + 'px';
725
+ spot.style.top = e.clientY + 'px';
726
+ });
727
+
728
+ // === Scroll progress ===
729
+ const prog = document.getElementById('scroll-prog');
730
+ window.addEventListener('scroll', () => {
731
+ const h = document.documentElement;
732
+ const sc = h.scrollTop / (h.scrollHeight - h.clientHeight);
733
+ prog.style.width = (sc * 100) + '%';
734
+ });
735
+
736
+ // === Stats counters ===
737
+ const statObs = new IntersectionObserver((es) => {
738
+ es.forEach(e => {
739
+ if (!e.isIntersecting) return;
740
+ const el = e.target;
741
+ const target = parseFloat(el.dataset.stat);
742
+ const dec = parseInt(el.dataset.decimals || '0');
743
+ const dur = 1400; const t0 = performance.now();
744
+ const tick = (t) => {
745
+ const p = Math.min(1, (t - t0) / dur);
746
+ const ease = 1 - Math.pow(1 - p, 3);
747
+ const v = target * ease;
748
+ el.textContent = dec ? v.toFixed(dec) : Math.round(v).toLocaleString('es-AR');
749
+ if (p < 1) requestAnimationFrame(tick);
750
+ else el.textContent = dec ? target.toFixed(dec) : target.toLocaleString('es-AR');
751
+ };
752
+ requestAnimationFrame(tick);
753
+ statObs.unobserve(el);
754
+ });
755
+ }, { threshold: 0.4 });
756
+ document.querySelectorAll('em[data-stat]').forEach(el => statObs.observe(el));
757
+
758
+ // === Magnetic buttons ===
759
+ document.querySelectorAll('.btn.primary').forEach(b => {
760
+ b.addEventListener('mousemove', (e) => {
761
+ const r = b.getBoundingClientRect();
762
+ const x = e.clientX - r.left - r.width/2;
763
+ const y = e.clientY - r.top - r.height/2;
764
+ b.style.transform = `translate(${x*0.15}px, ${y*0.20}px)`;
765
+ });
766
+ b.addEventListener('mouseleave', () => { b.style.transform = ''; });
767
+ });
768
+
769
+ // === Hero h1 letter rise ===
770
+ document.querySelectorAll('.hero h1 .ww').forEach((wrap, wi) => {
771
+ const txt = wrap.textContent;
772
+ wrap.textContent = '';
773
+ [...txt].forEach((ch, i) => {
774
+ const s = document.createElement('span');
775
+ s.textContent = ch === ' ' ? '\u00A0' : ch;
776
+ s.style.animationDelay = (wi * 200 + i * 30) + 'ms';
777
+ wrap.appendChild(s);
778
+ });
779
+ });
780
+
781
+ // === Kanban live drag animation β€” cycles across all columns ===
782
+ (function kanbanLive() {
783
+ const stage = document.querySelector('.hv-stage');
784
+ const moving = document.getElementById('moving-card');
785
+ const cursor = document.querySelector('.cursor-fly');
786
+ const cols = [...document.querySelectorAll('.kcol')]; // [backlog, prog, shipped]
787
+ if (!stage || !moving || !cursor || !cols.length) return;
788
+
789
+ const counts = cols.map(c => c.querySelector('.ct'));
790
+ const titleEl = moving.querySelector('.title');
791
+ const idEl = moving.querySelector('.id');
792
+ const tagEl = moving.querySelector('.tag');
793
+
794
+ // Card identities to rotate (id, title, tag, priority color)
795
+ const items = [
796
+ { id: 'AUR-405', title: 'Embed Figma en docs', tag: 'docs', pri: 'var(--accent)' },
797
+ { id: 'AUR-412', title: 'Realtime cursors', tag: 'edit', pri: 'var(--violet)' },
798
+ { id: 'AUR-407', title: 'Vector triage digest', tag: 'ml', pri: 'var(--rose)' },
799
+ { id: 'AUR-419', title: 'Bulk re-prioritize', tag: 'ux', pri: 'var(--amber)' },
800
+ { id: 'AUR-413', title: 'Sentry tracing webhooks', tag: 'infra', pri: 'var(--cyan)' },
801
+ ];
802
+
803
+ const placeOver = (el) => {
804
+ const sr = stage.getBoundingClientRect();
805
+ const tr = el.getBoundingClientRect();
806
+ return { x: tr.left - sr.left, y: tr.top - sr.top };
807
+ };
808
+ const colSlotPos = (col) => {
809
+ // position above first real card in col
810
+ const sr = stage.getBoundingClientRect();
811
+ const cr = col.getBoundingClientRect();
812
+ const firstCard = col.querySelector('.kcard');
813
+ if (firstCard) {
814
+ const fr = firstCard.getBoundingClientRect();
815
+ return { x: fr.left - sr.left, y: fr.top - sr.top };
816
+ }
817
+ return { x: cr.left - sr.left + 12, y: cr.top - sr.top + 40 };
818
+ };
819
+ const moveTo = (x, y) => {
820
+ moving.style.left = x + 'px';
821
+ moving.style.top = y + 'px';
822
+ cursor.style.left = (x + moving.offsetWidth - 16) + 'px';
823
+ cursor.style.top = (y + 14) + 'px';
824
+ };
825
+ const wait = (ms) => new Promise(r => setTimeout(r, ms));
826
+
827
+ function setMovingContent(item) {
828
+ idEl.textContent = item.id;
829
+ titleEl.textContent = item.title;
830
+ if (tagEl) tagEl.textContent = item.tag;
831
+ }
832
+
833
+ // Track count per col (start matching DOM)
834
+ const colCounts = counts.map(c => parseInt(c.textContent, 10) || 0);
835
+ const updateCounts = () => counts.forEach((c, i) => c.textContent = colCounts[i]);
836
+
837
+ let fromIdx = 1; // start: In progress
838
+ let itemIdx = 0;
839
+
840
+ async function loop() {
841
+ await wait(700);
842
+ while (true) {
843
+ const item = items[itemIdx % items.length];
844
+ setMovingContent(item);
845
+ // pick next column (different from current)
846
+ let toIdx;
847
+ do { toIdx = Math.floor(Math.random() * cols.length); } while (toIdx === fromIdx);
848
+
849
+ const fromCol = cols[fromIdx];
850
+ const toCol = cols[toIdx];
851
+
852
+ // 1) appear over a card in fromCol (use first card as anchor)
853
+ const anchor = fromCol.querySelector('.kcard') || fromCol;
854
+ const start = placeOver(anchor);
855
+ moveTo(start.x, start.y);
856
+ moving.classList.add('is-visible');
857
+ cursor.classList.add('is-visible');
858
+ await wait(420);
859
+
860
+ // 2) grab/lift
861
+ moving.classList.add('is-grabbed');
862
+ await wait(280);
863
+
864
+ // decrement source
865
+ colCounts[fromIdx] = Math.max(0, colCounts[fromIdx] - 1);
866
+ updateCounts();
867
+
868
+ // 3) drag to target col
869
+ toCol.classList.add('is-target');
870
+ const dest = colSlotPos(toCol);
871
+ moveTo(dest.x, dest.y);
872
+ await wait(950);
873
+
874
+ // 4) drop
875
+ moving.classList.remove('is-grabbed');
876
+ colCounts[toIdx] += 1;
877
+ updateCounts();
878
+ await wait(380);
879
+
880
+ // 5) fade out
881
+ moving.classList.remove('is-visible');
882
+ cursor.classList.remove('is-visible');
883
+ toCol.classList.remove('is-target');
884
+ await wait(900);
885
+
886
+ fromIdx = toIdx;
887
+ itemIdx++;
888
+ }
889
+ }
890
+ loop();
891
+ })();
892
+
893
+ // === Reveal on scroll ===
894
+ const io = new IntersectionObserver((entries) => {
895
+ entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('in'); });
896
+ }, { threshold: 0.12 });
897
+ document.querySelectorAll('.rv').forEach(el => io.observe(el));
898
+
899
+ // === Stage tabs ===
900
+ const tabs = document.querySelectorAll('.stage-tabs button');
901
+ const panes = document.querySelectorAll('.stage-pane');
902
+ tabs.forEach(t => t.addEventListener('click', () => {
903
+ tabs.forEach(x => x.classList.remove('on'));
904
+ panes.forEach(x => x.classList.remove('on'));
905
+ t.classList.add('on');
906
+ document.querySelector(`.stage-pane[data-pane="${t.dataset.tab}"]`).classList.add('on');
907
+ }));
908
+
909
+ // Auto-rotate tabs (disabled β€” let user drive it)
910
+ // let tabIdx = 0;
911
+ // setInterval(() => { tabIdx = (tabIdx + 1) % tabs.length; tabs[tabIdx].click(); }, 6000);
912
+
913
+ // === Demo: interactive drag + auto-shuffle ===
914
+ (function demoBoard() {
915
+ const board = document.querySelector('.stage-pane[data-pane="kanban"] .bk');
916
+ if (!board) return;
917
+ const cols = board.querySelectorAll('.bk-col');
918
+ let dragging = null, ghost = null, offsetX = 0, offsetY = 0;
919
+ let userActive = false, lastUser = 0;
920
+
921
+ function updateCounts() {
922
+ cols.forEach(c => {
923
+ const ct = c.querySelector('h5 .ct');
924
+ const n = c.querySelectorAll('.bk-card').length;
925
+ if (ct) ct.textContent = n;
926
+ });
927
+ }
928
+
929
+ function getCol(el) {
930
+ while (el && !el.classList?.contains('bk-col')) el = el.parentElement;
931
+ return el;
932
+ }
933
+
934
+ function clearTargets() {
935
+ cols.forEach(c => c.classList.remove('is-drop-target'));
936
+ }
937
+
938
+ function startDrag(card, ev) {
939
+ const rect = card.getBoundingClientRect();
940
+ offsetX = ev.clientX - rect.left;
941
+ offsetY = ev.clientY - rect.top;
942
+ dragging = card;
943
+ userActive = true;
944
+ lastUser = Date.now();
945
+
946
+ // create floating ghost
947
+ ghost = card.cloneNode(true);
948
+ ghost.classList.add('is-floating', 'is-dragging');
949
+ ghost.style.width = rect.width + 'px';
950
+ ghost.style.left = rect.left + 'px';
951
+ ghost.style.top = rect.top + 'px';
952
+ document.body.appendChild(ghost);
953
+
954
+ card.style.opacity = '0.25';
955
+ document.body.style.cursor = 'grabbing';
956
+ ev.preventDefault();
957
+ }
958
+
959
+ function moveDrag(ev) {
960
+ if (!ghost) return;
961
+ ghost.style.left = (ev.clientX - offsetX) + 'px';
962
+ ghost.style.top = (ev.clientY - offsetY) + 'px';
963
+
964
+ // detect column under cursor
965
+ clearTargets();
966
+ const elUnder = document.elementFromPoint(ev.clientX, ev.clientY);
967
+ const col = getCol(elUnder);
968
+ if (col && board.contains(col)) col.classList.add('is-drop-target');
969
+ }
970
+
971
+ function endDrag(ev) {
972
+ if (!ghost || !dragging) return;
973
+ const elUnder = document.elementFromPoint(ev.clientX, ev.clientY);
974
+ const targetCol = getCol(elUnder);
975
+ const card = dragging;
976
+
977
+ if (targetCol && board.contains(targetCol) && targetCol !== card.parentElement) {
978
+ targetCol.appendChild(card);
979
+ card.classList.add('just-landed');
980
+ setTimeout(() => card.classList.remove('just-landed'), 600);
981
+ }
982
+ card.style.opacity = '';
983
+ ghost.remove();
984
+ ghost = null;
985
+ dragging = null;
986
+ document.body.style.cursor = '';
987
+ clearTargets();
988
+ updateCounts();
989
+ }
990
+
991
+ board.addEventListener('mousedown', (ev) => {
992
+ const card = ev.target.closest('.bk-card');
993
+ if (!card || !board.contains(card)) return;
994
+ startDrag(card, ev);
995
+ });
996
+ window.addEventListener('mousemove', moveDrag);
997
+ window.addEventListener('mouseup', endDrag);
998
+
999
+ // touch support
1000
+ board.addEventListener('touchstart', (ev) => {
1001
+ const card = ev.target.closest('.bk-card');
1002
+ if (!card) return;
1003
+ const t = ev.touches[0];
1004
+ startDrag(card, { clientX: t.clientX, clientY: t.clientY, preventDefault: () => ev.preventDefault() });
1005
+ }, { passive: false });
1006
+ window.addEventListener('touchmove', (ev) => {
1007
+ if (!ghost) return;
1008
+ const t = ev.touches[0];
1009
+ moveDrag({ clientX: t.clientX, clientY: t.clientY });
1010
+ ev.preventDefault();
1011
+ }, { passive: false });
1012
+ window.addEventListener('touchend', (ev) => {
1013
+ if (!ghost) return;
1014
+ const t = ev.changedTouches[0];
1015
+ endDrag({ clientX: t.clientX, clientY: t.clientY });
1016
+ });
1017
+
1018
+ // === Auto-shuffle: pick a random card, FLIP-animate to a different col ===
1019
+ function autoMove() {
1020
+ if (Date.now() - lastUser < 8000) return; // pause if user just played
1021
+ if (dragging) return;
1022
+
1023
+ const allCards = [...board.querySelectorAll('.bk-card')];
1024
+ if (!allCards.length) return;
1025
+ const card = allCards[Math.floor(Math.random() * allCards.length)];
1026
+ const fromCol = card.parentElement;
1027
+ const otherCols = [...cols].filter(c => c !== fromCol);
1028
+ const toCol = otherCols[Math.floor(Math.random() * otherCols.length)];
1029
+
1030
+ // FLIP: capture from
1031
+ const r1 = card.getBoundingClientRect();
1032
+ toCol.classList.add('is-drop-target');
1033
+ toCol.appendChild(card);
1034
+ // capture to
1035
+ const r2 = card.getBoundingClientRect();
1036
+ const dx = r1.left - r2.left;
1037
+ const dy = r1.top - r2.top;
1038
+ // animate
1039
+ card.style.transition = 'none';
1040
+ card.style.transform = `translate(${dx}px, ${dy}px) rotate(-1deg) scale(1.04)`;
1041
+ card.style.boxShadow = '0 12px 30px rgba(0,0,0,.4)';
1042
+ card.style.zIndex = '10';
1043
+ requestAnimationFrame(() => {
1044
+ card.style.transition = 'transform .65s cubic-bezier(.2,.8,.2,1), box-shadow .65s';
1045
+ card.style.transform = '';
1046
+ card.style.boxShadow = '';
1047
+ });
1048
+ setTimeout(() => {
1049
+ card.style.zIndex = '';
1050
+ card.style.transition = '';
1051
+ toCol.classList.remove('is-drop-target');
1052
+ card.classList.add('just-landed');
1053
+ setTimeout(() => card.classList.remove('just-landed'), 600);
1054
+ updateCounts();
1055
+ }, 700);
1056
+ }
1057
+ setInterval(autoMove, 3500);
1058
+
1059
+ // pause auto on hover
1060
+ board.addEventListener('mouseenter', () => { lastUser = Date.now(); });
1061
+ })();
1062
+
1063
+ // === Billing toggle ===
1064
+ const billBtns = document.querySelectorAll('#bill-toggle button');
1065
+ const pill = document.getElementById('bill-pill');
1066
+ function setPill(btn) {
1067
+ const r = btn.getBoundingClientRect();
1068
+ const pr = btn.parentElement.getBoundingClientRect();
1069
+ pill.style.width = r.width + 'px';
1070
+ pill.style.transform = `translateX(${r.left - pr.left - 4}px)`;
1071
+ }
1072
+ requestAnimationFrame(() => setPill(document.querySelector('#bill-toggle button.on')));
1073
+ window.addEventListener('resize', () => setPill(document.querySelector('#bill-toggle button.on')));
1074
+ billBtns.forEach(b => b.addEventListener('click', () => {
1075
+ billBtns.forEach(x => x.classList.remove('on'));
1076
+ b.classList.add('on');
1077
+ setPill(b);
1078
+ const mode = b.dataset.bill;
1079
+ document.querySelectorAll('.tier .price[data-month]').forEach(p => {
1080
+ p.textContent = mode === 'year' ? p.dataset.year : p.dataset.month;
1081
+ });
1082
+ }));
1083
+
1084
+ // === FAQ ===
1085
+ document.querySelectorAll('.faq-item').forEach(item => {
1086
+ item.querySelector('.faq-q').addEventListener('click', () => {
1087
+ item.classList.toggle('open');
1088
+ });
1089
+ });
1090
+
1091
+ // === Counter ===
1092
+ const ce = document.querySelector('.counter-canvas .num em');
1093
+ if (ce) {
1094
+ const target = parseFloat(ce.dataset.count);
1095
+ const co = new IntersectionObserver((entries) => {
1096
+ entries.forEach(e => {
1097
+ if (e.isIntersecting) {
1098
+ let v = 0;
1099
+ const step = () => {
1100
+ v += target / 50;
1101
+ if (v >= target) { ce.textContent = target.toFixed(1); return; }
1102
+ ce.textContent = v.toFixed(1);
1103
+ requestAnimationFrame(step);
1104
+ };
1105
+ step();
1106
+ co.unobserve(e.target);
1107
+ }
1108
+ });
1109
+ }, { threshold: 0.4 });
1110
+ co.observe(ce);
1111
+ }
1112
+
1113
+ // === Login modal ===
1114
+ const overlay = document.getElementById('login-overlay');
1115
+ const openLogin = () => overlay.classList.add('open');
1116
+ const closeLogin = () => overlay.classList.remove('open');
1117
+
1118
+ document.getElementById('open-login').addEventListener('click', openLogin);
1119
+ document.getElementById('open-signup').addEventListener('click', openLogin);
1120
+ document.getElementById('cta-hero').addEventListener('click', openLogin);
1121
+ document.getElementById('cta-demo').addEventListener('click', () => {
1122
+ document.getElementById('stage').scrollIntoView({ behavior: 'smooth', block: 'start' });
1123
+ });
1124
+ document.getElementById('cta-demo-2').addEventListener('click', () => {
1125
+ document.getElementById('stage').scrollIntoView({ behavior: 'smooth', block: 'start' });
1126
+ });
1127
+ document.querySelectorAll('.login-trigger').forEach(b => b.addEventListener('click', openLogin));
1128
+ document.getElementById('lg-close').addEventListener('click', closeLogin);
1129
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) closeLogin(); });
1130
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeLogin(); });
1131
+
1132
+ // Demo login β€” always succeeds, no backend required
1133
+ function handleLogin(e) {
1134
+ e.preventDefault();
1135
+ const btn = document.querySelector('#lg-form button[type=submit]');
1136
+ if (btn) { btn.textContent = 'Entrando…'; btn.style.pointerEvents = 'none'; }
1137
+ localStorage.setItem('meridian-token', 'demo-token');
1138
+ localStorage.setItem('meridian-user', JSON.stringify({ email: 'demo@meridian.app', name: 'Demo User' }));
1139
+ setTimeout(() => { window.location.href = 'Meridian.html'; }, 400);
1140
+ }
1141
+
1142
+ document.getElementById('lg-form').addEventListener('submit', handleLogin);
1143
+ // document.querySelectorAll('.login-sso').forEach(b => b.addEventListener('click', goToApp));
1144
+ // document.getElementById('lg-signup').addEventListener('click', (e) => { e.preventDefault(); goToApp(); });
1145
+ </script>
1146
+ </body>
1147
+ </html>
mock-api.jsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // mock-api.jsx β€” simulated CRUD backend for demo mode (no real server needed)
2
+ // Intercepts /api/issues, /api/prs, /api/docs fetch calls.
3
+ // AI chat is handled by ai-engine.jsx β†’ AMD directly (no mock needed).
4
+ // Must load after data.jsx.
5
+
6
+ (function () {
7
+ const delay = (ms) => new Promise(r => setTimeout(r, ms));
8
+
9
+ const handleRequest = async (path, method, body) => {
10
+ await delay(180 + Math.random() * 250);
11
+
12
+ // Issues list
13
+ if (path === '/api/issues' && method === 'GET') {
14
+ return typeof ISSUES !== 'undefined' ? [...ISSUES] : [];
15
+ }
16
+
17
+ // Single issue
18
+ const issueMatch = path.match(/^\/api\/issues\/([^/]+)$/);
19
+ if (issueMatch) {
20
+ const id = issueMatch[1];
21
+ if (method === 'GET') {
22
+ return typeof ISSUES !== 'undefined' ? ISSUES.find(i => i.id === id) || null : null;
23
+ }
24
+ if (method === 'PATCH' && typeof ISSUES !== 'undefined') {
25
+ const idx = ISSUES.findIndex(i => i.id === id);
26
+ if (idx !== -1) {
27
+ Object.assign(ISSUES[idx], body || {});
28
+ return { ...ISSUES[idx] };
29
+ }
30
+ return null;
31
+ }
32
+ }
33
+
34
+ // Create issue
35
+ if (path === '/api/issues' && method === 'POST' && typeof ISSUES !== 'undefined') {
36
+ const proj = (typeof PROJECTS !== 'undefined' ? PROJECTS : []).find(p => p.id === (body && body.project)) || { code: 'AUR' };
37
+ const num = 400 + Math.floor(Math.random() * 200);
38
+ const newIssue = {
39
+ id: `${proj.code}-${num}`,
40
+ title: body.title || 'Untitled',
41
+ priority: body.priority || 'med',
42
+ status: body.status || 'backlog',
43
+ project: body.project || (PROJECTS[0] && PROJECTS[0].id),
44
+ assignees: body.assignees || [],
45
+ labels: body.labels || [],
46
+ estimate: null,
47
+ created: 'just now',
48
+ updated: 'just now',
49
+ commentCount: 0,
50
+ };
51
+ ISSUES.unshift(newIssue);
52
+ return newIssue;
53
+ }
54
+
55
+ // PRs list
56
+ if (path === '/api/prs' && method === 'GET') {
57
+ return typeof PRS !== 'undefined' ? [...PRS] : [];
58
+ }
59
+
60
+ // Create PR
61
+ if (path === '/api/prs' && method === 'POST' && typeof PRS !== 'undefined') {
62
+ const newPR = {
63
+ id: `#${2360 + Math.floor(Math.random() * 40)}`,
64
+ title: body.title || 'Untitled PR',
65
+ branch: body.branch || 'feature/new',
66
+ base: body.base || 'main',
67
+ status: body.status || 'open',
68
+ author: 'u1',
69
+ project: body.project || 'Aurora',
70
+ additions: 0,
71
+ deletions: 0,
72
+ checks: { passed: 0, failed: 0, running: 1 },
73
+ reviewers: [],
74
+ comments: 0,
75
+ draft: false,
76
+ updated: 'just now',
77
+ };
78
+ PRS.unshift(newPR);
79
+ return newPR;
80
+ }
81
+
82
+ // Docs list
83
+ if (path === '/api/docs' && method === 'GET') {
84
+ return typeof DOCS !== 'undefined' ? [...DOCS] : [];
85
+ }
86
+
87
+ // Create doc
88
+ if (path === '/api/docs' && method === 'POST' && typeof DOCS !== 'undefined') {
89
+ const newDoc = {
90
+ id: `doc-${Date.now()}`,
91
+ title: (body && body.title) || 'Untitled',
92
+ icon: 'doc-add',
93
+ section: 'Drafts',
94
+ children: [],
95
+ };
96
+ DOCS.push(newDoc);
97
+ return newDoc;
98
+ }
99
+
100
+ // Auth / login (stub β€” always succeeds in demo mode)
101
+ if (path === '/api/auth/login' && method === 'POST') {
102
+ return { token: 'demo-token-' + Date.now(), user: { id: 'u1', name: 'Amara Osei' } };
103
+ }
104
+
105
+ // 404 fallback
106
+ return { error: 'Not found', path, method };
107
+ };
108
+
109
+ // --- Fetch interceptor ---
110
+
111
+ const originalFetch = window.fetch.bind(window);
112
+
113
+ window.fetch = async (input, init = {}) => {
114
+ const url = typeof input === 'string' ? input : (input instanceof URL ? input.toString() : input.url);
115
+ if (!url || !url.startsWith('/api/')) {
116
+ return originalFetch(input, init);
117
+ }
118
+
119
+ const method = ((init && init.method) || 'GET').toUpperCase();
120
+ let body = null;
121
+ if (init && init.body) {
122
+ try { body = JSON.parse(init.body); } catch { body = init.body; }
123
+ }
124
+
125
+ try {
126
+ const data = await handleRequest(url, method, body);
127
+ return new Response(JSON.stringify(data), {
128
+ status: data === null ? 404 : 200,
129
+ headers: { 'Content-Type': 'application/json' }
130
+ });
131
+ } catch (err) {
132
+ console.error('[mock-api] Error handling', method, url, err);
133
+ return new Response(JSON.stringify({ error: err.message }), {
134
+ status: 500,
135
+ headers: { 'Content-Type': 'application/json' }
136
+ });
137
+ }
138
+ };
139
+
140
+ // Also cover window.apiFetch so mutations reach the mock
141
+ // (apiFetch is defined later in api-client.jsx, so we patch it after DOMContentLoaded)
142
+ document.addEventListener('DOMContentLoaded', () => {
143
+ // Nothing needed: apiFetch already calls window.fetch which is now intercepted
144
+ });
145
+
146
+ console.log('[mock-api] Demo mode active β€” all /api/* requests are simulated');
147
+ })();
shell.jsx ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shell: sidebar + topbar + inbox panel + command palette
2
+
3
+ const Sidebar = ({ view, setView, collapsed }) => {
4
+ const nav = [
5
+ { id: "home", label: "Home", icon: "home" },
6
+ { id: "inbox", label: "Inbox", icon: "inbox", badge: 4 },
7
+ { id: "issues", label: "Issues", icon: "issues" },
8
+ { id: "sprints", label: "Sprints", icon: "sprint" },
9
+ { id: "roadmap", label: "Roadmap", icon: "roadmap" },
10
+ { id: "docs", label: "Docs", icon: "docs" },
11
+ { id: "chat", label: "VaultMind AI", icon: "sparkle" },
12
+ { id: "code", label: "Code Editor", icon: "code" },
13
+ { id: "compute", label: "Compute", icon: "sprint" },
14
+ { id: "aimarket", label: "AI Infrastructure", icon: "bolt" },
15
+ { id: "nexus", label: "Nexus", icon: "sparkle" },
16
+ { id: "prs", label: "Pull requests", icon: "pr" },
17
+ { id: "team", label: "Team", icon: "team" },
18
+ { id: "settings", label: "Settings", icon: "settings" },
19
+ ];
20
+
21
+ return (
22
+ <nav className="sidebar">
23
+ <div className="sb-brand">
24
+ <span className="sb-logo" aria-hidden />
25
+ <div style={{ lineHeight: 1.15 }}>
26
+ <div className="name">Meridian</div>
27
+ <div className="ws mono">helix Β· enterprise</div>
28
+ </div>
29
+ <span style={{ marginLeft: "auto" }} className="mono muted-2" title="workspace shortcut">⌘</span>
30
+ </div>
31
+
32
+ <div className="sb-section">
33
+ {nav.map(n => (
34
+ <button key={n.id} className={`sb-item ${view === n.id ? "active" : ""}`} onClick={() => setView(n.id)}>
35
+ <span className="sb-ind" />
36
+ <Icon name={n.icon} className="sb-icon" />
37
+ <span>{n.label}</span>
38
+ {n.badge && <span className="sb-badge">{n.badge}</span>}
39
+ </button>
40
+ ))}
41
+ </div>
42
+
43
+ <div className="sb-section">
44
+ <div className="sb-label"><span>Projects</span><span className="count">{PROJECTS.length}</span></div>
45
+ {PROJECTS.map(p => (
46
+ <button key={p.id} className="sb-proj" onClick={() => window.openProject(p)}>
47
+ <span className="dot" style={{ background: p.color }} />
48
+ <span className="truncate">{p.name}</span>
49
+ <span className="sb-badge mono">{p.code}</span>
50
+ </button>
51
+ ))}
52
+ </div>
53
+
54
+ <div className="sb-section">
55
+ <div className="sb-label"><span>Views</span><Icon name="plus" size={12} /></div>
56
+ {[
57
+ { id: "assigned", label: "Assigned to me", count: 7 },
58
+ { id: "review", label: "Needs review", count: 3 },
59
+ { id: "urgent", label: "Urgent", count: 2 },
60
+ { id: "recent", label: "Recently updated", count: 12 },
61
+ ].map(v => (
62
+ <button key={v.id} className="sb-proj" onClick={() => { setView("issues"); window.openFilter(v.label); }}>
63
+ <Icon name="filter" size={14} style={{ color: "var(--fg-3)" }} />
64
+ <span className="truncate">{v.label}</span>
65
+ <span className="sb-badge mono">{v.count}</span>
66
+ </button>
67
+ ))}
68
+ </div>
69
+
70
+ <div style={{ flex: 1 }} />
71
+ <div className="sb-foot">
72
+ <Avatar user={PEOPLE[0]} size="sm" />
73
+ <div className="who" style={{ lineHeight: 1.2, flex: 1, minWidth: 0 }}>
74
+ <div style={{ fontSize: 12.5, fontWeight: 500 }}>{PEOPLE[0].name}</div>
75
+ <div className="mono muted-2" style={{ fontSize: 10.5 }}>online Β· EU</div>
76
+ </div>
77
+ <button className="icon-btn" title="Account menu" onClick={() => window.openTeammate(PEOPLE[0])}><Icon name="more" size={14} /></button>
78
+ </div>
79
+ </nav>
80
+ );
81
+ };
82
+
83
+ const Topbar = ({ view, onToggleSidebar, onOpenPalette, onOpenInbox, onOpenTweaks, theme, setTheme }) => {
84
+ const crumb = {
85
+ home: ["Home"],
86
+ inbox: ["Inbox", "All activity"],
87
+ issues: ["Projects", "Aurora", "Issues"],
88
+ sprints: ["Sprints", "Iteration 42"],
89
+ roadmap: ["Roadmap", "Q2 β€” Q3 2026"],
90
+ docs: ["Docs", "Engineering handbook", "ADR 041 β€” Canvas rendering pipeline"],
91
+ prs: ["Code", "Pull requests"],
92
+ team: ["Team", "Members"],
93
+ issue: ["Projects", "Aurora", "AUR-412"],
94
+ chat: ["VaultMind AI", "Chat"],
95
+ compute: ["Compute", "GPU Instances"],
96
+ aimarket: ["AI Infrastructure", "Studios & Models"],
97
+ nexus: ["Nexus", "Cloud AI Platform"],
98
+ settings: ["Settings", "Workspace"],
99
+ }[view] || ["Home"];
100
+
101
+ return (
102
+ <header className="topbar">
103
+ <button className="icon-btn" onClick={onToggleSidebar} title="Toggle sidebar"><Icon name="sidebar" size={16} /></button>
104
+ <div className="breadcrumbs">
105
+ {crumb.map((c, i) => (
106
+ <React.Fragment key={i}>
107
+ {i > 0 && <span className="sep">/</span>}
108
+ <span className={i === crumb.length - 1 ? "current" : ""}>{c}</span>
109
+ </React.Fragment>
110
+ ))}
111
+ </div>
112
+ <div className="topbar-spacer" />
113
+ <button className="search" onClick={onOpenPalette} style={{ cursor: "pointer" }}>
114
+ <Icon name="search" size={13} />
115
+ <span style={{ flex: 1, textAlign: "left", color: "var(--fg-3)" }}>Search, jump to, or ask…</span>
116
+ <span className="kbd-hint mono">⌘K</span>
117
+ </button>
118
+ <div className="topbar-actions">
119
+ <button className="icon-btn" onClick={() => setTheme(theme === "dark" ? "light" : "dark")} title="Toggle theme">
120
+ <Icon name={theme === "dark" ? "sun" : "moon"} size={15} />
121
+ </button>
122
+ <button className="icon-btn" title="Notifications" onClick={onOpenInbox}>
123
+ <Icon name="bell" size={15} /><span className="dot" />
124
+ </button>
125
+ <button className="btn sm" onClick={() => window.openPicker({
126
+ title: "Create",
127
+ options: [
128
+ { value: "issue", label: "New issue", icon: "issues", hint: "C" },
129
+ { value: "doc", label: "New document", icon: "docs", hint: "βŒ˜β‡§D" },
130
+ { value: "pr", label: "Open pull request", icon: "pr" },
131
+ { value: "invite", label: "Invite teammate", icon: "team" },
132
+ ],
133
+ onChoose: (o) => {
134
+ if (o.value === "issue") window.openNewIssue();
135
+ else if (o.value === "doc") window.openNewDoc();
136
+ else if (o.value === "pr") window.openNewPR();
137
+ else if (o.value === "invite") window.openInvite();
138
+ },
139
+ })}>
140
+ <Icon name="plus" size={13} /> New
141
+ </button>
142
+ </div>
143
+ </header>
144
+ );
145
+ };
146
+
147
+ // ---- Command palette ----
148
+ const PALETTE_ITEMS = [
149
+ { group: "Jump to", items: [
150
+ { id: "p-home", label: "Home", icon: "home", kbd: "G H", action: (setView) => setView("home") },
151
+ { id: "p-inbox", label: "Inbox", icon: "inbox", kbd: "G I", action: (setView) => setView("inbox") },
152
+ { id: "p-issues", label: "Issues", icon: "issues", kbd: "G S", action: (setView) => setView("issues") },
153
+ { id: "p-docs", label: "Docs", icon: "docs", kbd: "G D", action: (setView) => setView("docs") },
154
+ { id: "p-roadmap", label: "Roadmap", icon: "roadmap", kbd: "G R", action: (setView) => setView("roadmap") },
155
+ { id: "p-prs", label: "Pull requests", icon: "pr", kbd: "G P", action: (setView) => setView("prs") },
156
+ { id: "p-sprints", label: "Sprints", icon: "sprint", action: (setView) => setView("sprints") },
157
+ ]},
158
+ { group: "Create", items: [
159
+ { id: "c-issue", label: "Create new issue…", icon: "plus", kbd: "C", action: () => window.openNewIssue() },
160
+ { id: "c-doc", label: "Create new document…", icon: "doc-add", kbd: "βŒ˜β‡§D", action: () => window.openNewDoc() },
161
+ { id: "c-pr", label: "Open pull request…", icon: "pr", action: () => window.openNewPR() },
162
+ ]},
163
+ { group: "Recent", items: [
164
+ { id: "r-1", label: "AUR-412 β€” Rebuild canvas-rendering pipeline", icon: "issues", action: (setView) => setView("issue") },
165
+ { id: "r-2", label: "Aurora collaborative canvas PRD", icon: "docs", action: (setView) => setView("docs") },
166
+ { id: "r-3", label: "#2341 perf(canvas): tile-based rendering", icon: "pr", action: (setView) => setView("prs") },
167
+ { id: "r-4", label: "Iteration 42 β€” Apr 14 – Apr 28", icon: "sprint", action: (setView) => setView("sprints") },
168
+ ]},
169
+ { group: "Ask Meridian AI", items: [
170
+ { id: "ai-1", label: "Summarize Iteration 42 progress", icon: "sparkle", action: (setView) => { window.chatInitialQuery = "Summarize Iteration 42 progress"; setView("chat"); } },
171
+ { id: "ai-2", label: "What's blocking Aurora?", icon: "sparkle", action: (setView) => { window.chatInitialQuery = "What's blocking Aurora?"; setView("chat"); } },
172
+ { id: "ai-3", label: "Draft release notes for last sprint", icon: "sparkle", action: (setView) => { window.chatInitialQuery = "Draft release notes for last sprint"; setView("chat"); } },
173
+ ]},
174
+ ];
175
+
176
+ const CommandPalette = ({ open, onClose, setView }) => {
177
+ const [q, setQ] = React.useState("");
178
+ const [sel, setSel] = React.useState(0);
179
+
180
+ React.useEffect(() => {
181
+ if (!open) { setQ(""); setSel(0); }
182
+ }, [open]);
183
+
184
+ const all = React.useMemo(() => {
185
+ const query = q.trim().toLowerCase();
186
+ return PALETTE_ITEMS.map(g => ({
187
+ ...g,
188
+ items: g.items.filter(it => !query || it.label.toLowerCase().includes(query))
189
+ })).filter(g => g.items.length);
190
+ }, [q]);
191
+
192
+ const flat = all.flatMap(g => g.items);
193
+
194
+ const go = (it) => {
195
+ if (it.action) it.action(setView);
196
+ onClose();
197
+ };
198
+
199
+ const onKey = (e) => {
200
+ if (e.key === "ArrowDown") { e.preventDefault(); setSel(s => Math.min(s + 1, flat.length - 1)); }
201
+ else if (e.key === "ArrowUp") { e.preventDefault(); setSel(s => Math.max(s - 1, 0)); }
202
+ else if (e.key === "Enter") { const it = flat[sel]; if (it) go(it); }
203
+ else if (e.key === "Escape") onClose();
204
+ };
205
+
206
+ if (!open) return null;
207
+ return (
208
+ <div className="overlay" onClick={onClose}>
209
+ <div className="palette" onClick={e => e.stopPropagation()}>
210
+ <div className="palette-input">
211
+ <Icon name="search" size={16} style={{ color: "var(--fg-2)" }} />
212
+ <input
213
+ autoFocus
214
+ placeholder="Search, jump to, ask…"
215
+ value={q}
216
+ onChange={e => { setQ(e.target.value); setSel(0); }}
217
+ onKeyDown={onKey}
218
+ />
219
+ <span className="kbd-hint mono">ESC</span>
220
+ </div>
221
+ <div className="palette-list">
222
+ {all.map((g, gi) => (
223
+ <div key={gi}>
224
+ <div className="palette-group">{g.group}</div>
225
+ {g.items.map((it) => {
226
+ const idx = flat.indexOf(it);
227
+ return (
228
+ <button
229
+ key={it.id}
230
+ className={`palette-item ${idx === sel ? "sel" : ""}`}
231
+ onMouseEnter={() => setSel(idx)}
232
+ onClick={() => go(it)}
233
+ >
234
+ <Icon name={it.icon} size={15} style={{ color: "var(--fg-2)" }} />
235
+ <span className="truncate">{it.label}</span>
236
+ {it.kbd && <span className="k mono">{it.kbd}</span>}
237
+ </button>
238
+ );
239
+ })}
240
+ </div>
241
+ ))}
242
+ </div>
243
+ </div>
244
+ </div>
245
+ );
246
+ };
247
+
248
+ // ---- Inbox panel ----
249
+ const InboxPanel = ({ open, onClose }) => {
250
+ const [filter, setFilter] = React.useState("all");
251
+ const list = INBOX.filter(n => filter === "all" || (filter === "unread" && n.unread));
252
+ const kindIcon = { issue: "issues", pr: "pr", doc: "docs" };
253
+
254
+ return (
255
+ <aside className={`inbox-panel ${open ? "open" : ""}`}>
256
+ <div style={{ padding: "10px 14px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 8 }}>
257
+ <Icon name="inbox" size={15} />
258
+ <strong style={{ fontSize: 13.5 }}>Inbox</strong>
259
+ <span className="mono muted-2" style={{ fontSize: 11 }}>{INBOX.filter(n => n.unread).length} unread</span>
260
+ <div style={{ flex: 1 }} />
261
+ <div className="segmented">
262
+ <button className={filter === "all" ? "on" : ""} onClick={() => setFilter("all")}>All</button>
263
+ <button className={filter === "unread" ? "on" : ""} onClick={() => setFilter("unread")}>Unread</button>
264
+ </div>
265
+ <button className="icon-btn" onClick={onClose}><Icon name="x" size={14} /></button>
266
+ </div>
267
+ <div className="scroll-y" style={{ flex: 1 }}>
268
+ {list.map(n => {
269
+ const user = PEOPLE.find(p => p.id === n.from);
270
+ return (
271
+ <button key={n.id} onClick={() => { window.toast(`Opening ${n.target}`); onClose(); }} className="flex items-center gap-12" style={{
272
+ width: "100%", padding: "10px 14px", borderBottom: "1px solid var(--border-subtle)",
273
+ textAlign: "left", background: n.unread ? "transparent" : "var(--bg-0)", opacity: n.unread ? 1 : 0.75
274
+ }}>
275
+ {n.unread && <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)", flexShrink: 0 }} />}
276
+ {!n.unread && <span style={{ width: 6, flexShrink: 0 }} />}
277
+ {user ? <Avatar user={user} size="sm" /> : <span className="avatar sm" style={{ background: "var(--bg-3)" }}><Icon name={kindIcon[n.kind]} size={10} /></span>}
278
+ <div style={{ flex: 1, minWidth: 0 }}>
279
+ <div style={{ fontSize: 12.5, color: "var(--fg-0)", lineHeight: 1.35 }}>
280
+ {user && <strong style={{ fontWeight: 600 }}>{user.name.split(" ")[0]} </strong>}
281
+ <span className="muted">{n.text} </span>
282
+ <span className="mono" style={{ color: "var(--accent)" }}>{n.target}</span>
283
+ </div>
284
+ <div className="truncate muted" style={{ fontSize: 11.5, marginTop: 2 }}>{n.snippet}</div>
285
+ </div>
286
+ <span className="mono muted-2" style={{ fontSize: 10.5, flexShrink: 0 }}>{n.time}</span>
287
+ </button>
288
+ );
289
+ })}
290
+ </div>
291
+ </aside>
292
+ );
293
+ };
294
+
295
+ // ---- Tweaks panel ----
296
+ const TweaksPanel = ({ open, onClose, settings, setSettings }) => {
297
+ const accents = [
298
+ { id: "lime", value: "oklch(0.85 0.17 145)" },
299
+ { id: "cyan", value: "oklch(0.78 0.13 220)" },
300
+ { id: "violet", value: "oklch(0.72 0.18 300)" },
301
+ { id: "amber", value: "oklch(0.80 0.14 75)" },
302
+ { id: "rose", value: "oklch(0.72 0.17 20)" },
303
+ ];
304
+
305
+ return (
306
+ <div className={`tweaks ${open ? "open" : ""}`}>
307
+ <div className="flex items-center justify-between">
308
+ <h4>Tweaks</h4>
309
+ <button className="icon-btn" onClick={onClose}><Icon name="x" size={12} /></button>
310
+ </div>
311
+
312
+ <div className="tweak-row">
313
+ <label>Accent</label>
314
+ <div className="swatches">
315
+ {accents.map(a => (
316
+ <button
317
+ key={a.id}
318
+ className={`swatch ${settings.accent === a.id ? "sel" : ""}`}
319
+ style={{ background: a.value }}
320
+ onClick={() => setSettings(s => ({ ...s, accent: a.id }))}
321
+ />
322
+ ))}
323
+ </div>
324
+ </div>
325
+
326
+ <div className="tweak-row">
327
+ <label>Theme</label>
328
+ <div className="segmented">
329
+ {["dark","light"].map(t => (
330
+ <button key={t} className={settings.theme === t ? "on" : ""} onClick={() => setSettings(s => ({...s, theme: t}))}>{t}</button>
331
+ ))}
332
+ </div>
333
+ </div>
334
+
335
+ <div className="tweak-row">
336
+ <label>Density</label>
337
+ <div className="segmented">
338
+ {["compact","default","relaxed"].map(d => (
339
+ <button key={d} className={settings.density === d ? "on" : ""} onClick={() => setSettings(s => ({...s, density: d}))}>{d}</button>
340
+ ))}
341
+ </div>
342
+ </div>
343
+
344
+ <div className="tweak-row">
345
+ <label>Sidebar</label>
346
+ <div className="segmented">
347
+ {["expanded","collapsed"].map(d => (
348
+ <button key={d} className={settings.sidebar === d ? "on" : ""} onClick={() => setSettings(s => ({...s, sidebar: d}))}>{d}</button>
349
+ ))}
350
+ </div>
351
+ </div>
352
+
353
+ <div className="tweak-row">
354
+ <label>Card style</label>
355
+ <div className="segmented">
356
+ {["detailed","minimal"].map(d => (
357
+ <button key={d} className={settings.cardStyle === d ? "on" : ""} onClick={() => setSettings(s => ({...s, cardStyle: d}))}>{d}</button>
358
+ ))}
359
+ </div>
360
+ </div>
361
+ </div>
362
+ );
363
+ };
364
+
365
+ const HintBar = ({ onOpenPalette }) => (
366
+ <div className="hint-bar">
367
+ <span><kbd>⌘K</kbd> search</span>
368
+ <span><kbd>C</kbd> new issue</span>
369
+ <span><kbd>G</kbd>+<kbd>I</kbd> inbox</span>
370
+ <span><kbd>?</kbd> help</span>
371
+ </div>
372
+ );
373
+
374
+ Object.assign(window, { Sidebar, Topbar, CommandPalette, InboxPanel, TweaksPanel, HintBar });
staticwebapp.config.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "navigationFallback": {
3
+ "rewrite": "/index.html",
4
+ "exclude": ["/api/*", "*.{css,jsx,js,json,png,jpg,svg,ico,html,gif,woff,woff2}"]
5
+ },
6
+ "mimeTypes": {
7
+ ".jsx": "application/javascript",
8
+ ".json": "application/json"
9
+ },
10
+ "globalHeaders": {
11
+ "Cache-Control": "no-cache",
12
+ "Access-Control-Allow-Origin": "*"
13
+ }
14
+ }
styles.css ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Meridian β€” Project Manager
2
+ Design tokens + base styles */
3
+
4
+ :root {
5
+ /* Dark (default) */
6
+ --bg-0: oklch(0.18 0.008 250);
7
+ --bg-1: oklch(0.21 0.009 250);
8
+ --bg-2: oklch(0.24 0.010 250);
9
+ --bg-3: oklch(0.28 0.012 250);
10
+ --bg-hover: oklch(0.26 0.012 250);
11
+ --bg-elev: oklch(0.23 0.010 250);
12
+
13
+ --border: oklch(0.32 0.012 250);
14
+ --border-strong: oklch(0.40 0.015 250);
15
+ --border-subtle: oklch(0.26 0.010 250);
16
+
17
+ --fg-0: oklch(0.97 0.005 250);
18
+ --fg-1: oklch(0.82 0.010 250);
19
+ --fg-2: oklch(0.65 0.012 250);
20
+ --fg-3: oklch(0.48 0.012 250);
21
+
22
+ --accent: oklch(0.85 0.17 145);
23
+ --accent-fg: oklch(0.20 0.05 145);
24
+ --accent-dim: oklch(0.40 0.10 145);
25
+ --accent-soft: oklch(0.30 0.06 145);
26
+
27
+ --violet: oklch(0.70 0.18 300);
28
+ --violet-soft: oklch(0.32 0.08 300);
29
+ --amber: oklch(0.80 0.14 75);
30
+ --amber-soft: oklch(0.32 0.06 75);
31
+ --rose: oklch(0.72 0.17 20);
32
+ --rose-soft: oklch(0.32 0.08 20);
33
+ --cyan: oklch(0.78 0.12 220);
34
+ --cyan-soft: oklch(0.32 0.06 220);
35
+
36
+ --status-todo: oklch(0.55 0.010 250);
37
+ --status-progress: oklch(0.75 0.14 75);
38
+ --status-review: oklch(0.72 0.17 300);
39
+ --status-done: oklch(0.80 0.16 145);
40
+ --status-blocked: oklch(0.68 0.18 20);
41
+
42
+ --font-ui: "Inter Tight", "Inter", system-ui, -apple-system, sans-serif;
43
+ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
44
+ --font-editorial: "Fraunces", "Tiempos", Georgia, serif;
45
+
46
+ --radius-xs: 4px;
47
+ --radius-sm: 6px;
48
+ --radius-md: 8px;
49
+ --radius-lg: 12px;
50
+ --radius-xl: 16px;
51
+
52
+ --sidebar-w: 240px;
53
+ --sidebar-collapsed-w: 56px;
54
+ --context-w: 280px;
55
+ --topbar-h: 44px;
56
+
57
+ --shadow-sm: 0 1px 2px oklch(0 0 0 / 0.2);
58
+ --shadow-md: 0 4px 12px oklch(0 0 0 / 0.25);
59
+ --shadow-lg: 0 16px 40px oklch(0 0 0 / 0.35);
60
+
61
+ --density-row: 36px;
62
+ --density-pad: 12px;
63
+ }
64
+
65
+ [data-theme="light"] {
66
+ --bg-0: oklch(0.985 0.003 250);
67
+ --bg-1: oklch(0.975 0.004 250);
68
+ --bg-2: oklch(0.96 0.005 250);
69
+ --bg-3: oklch(0.93 0.006 250);
70
+ --bg-hover: oklch(0.95 0.005 250);
71
+ --bg-elev: oklch(1 0 0);
72
+
73
+ --border: oklch(0.90 0.007 250);
74
+ --border-strong: oklch(0.82 0.010 250);
75
+ --border-subtle: oklch(0.93 0.005 250);
76
+
77
+ --fg-0: oklch(0.18 0.015 250);
78
+ --fg-1: oklch(0.30 0.015 250);
79
+ --fg-2: oklch(0.48 0.012 250);
80
+ --fg-3: oklch(0.62 0.010 250);
81
+
82
+ --accent: oklch(0.62 0.17 145);
83
+ --accent-fg: oklch(0.98 0.03 145);
84
+ --accent-dim: oklch(0.80 0.10 145);
85
+ --accent-soft: oklch(0.92 0.06 145);
86
+
87
+ --violet-soft: oklch(0.93 0.04 300);
88
+ --amber-soft: oklch(0.94 0.06 75);
89
+ --rose-soft: oklch(0.94 0.05 20);
90
+ --cyan-soft: oklch(0.93 0.04 220);
91
+
92
+ --shadow-sm: 0 1px 2px oklch(0.5 0.01 250 / 0.08);
93
+ --shadow-md: 0 4px 12px oklch(0.5 0.01 250 / 0.10);
94
+ --shadow-lg: 0 16px 40px oklch(0.5 0.01 250 / 0.14);
95
+ }
96
+
97
+ [data-density="compact"] {
98
+ --density-row: 30px;
99
+ --density-pad: 8px;
100
+ }
101
+ [data-density="relaxed"] {
102
+ --density-row: 44px;
103
+ --density-pad: 16px;
104
+ }
105
+
106
+ * { box-sizing: border-box; }
107
+ html, body {
108
+ margin: 0; padding: 0;
109
+ height: 100%;
110
+ font-family: var(--font-ui);
111
+ font-feature-settings: "cv11", "ss01", "ss03";
112
+ background: var(--bg-0);
113
+ color: var(--fg-0);
114
+ font-size: 13px;
115
+ line-height: 1.45;
116
+ -webkit-font-smoothing: antialiased;
117
+ overflow: hidden;
118
+ }
119
+
120
+ button, input, textarea, select {
121
+ font: inherit;
122
+ color: inherit;
123
+ }
124
+
125
+ button { cursor: pointer; background: none; border: none; padding: 0; }
126
+
127
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
128
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; border: 2px solid var(--bg-0); }
129
+ ::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
130
+ ::-webkit-scrollbar-track { background: transparent; }
131
+
132
+ .mono { font-family: var(--font-mono); font-feature-settings: "ss01"; }
133
+ .editorial { font-family: var(--font-editorial); }
134
+
135
+ /* ==== App shell ==== */
136
+ .app {
137
+ display: grid;
138
+ grid-template-columns: var(--sidebar-w) 1fr;
139
+ grid-template-rows: var(--topbar-h) 1fr;
140
+ grid-template-areas:
141
+ "sidebar topbar"
142
+ "sidebar main";
143
+ height: 100vh;
144
+ width: 100vw;
145
+ }
146
+ .app[data-sidebar="collapsed"] {
147
+ grid-template-columns: var(--sidebar-collapsed-w) 1fr;
148
+ }
149
+
150
+ .sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--bg-1); overflow: hidden; display: flex; flex-direction: column; }
151
+ .topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--bg-0); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
152
+ .main { grid-area: main; overflow: hidden; display: flex; min-width: 0; }
153
+
154
+ /* Topbar */
155
+ .breadcrumbs { display: flex; align-items: center; gap: 8px; color: var(--fg-2); font-size: 12.5px; }
156
+ .breadcrumbs .sep { color: var(--fg-3); }
157
+ .breadcrumbs .current { color: var(--fg-0); font-weight: 500; }
158
+ .topbar-spacer { flex: 1; }
159
+ .topbar-actions { display: flex; align-items: center; gap: 6px; }
160
+
161
+ .kbd-hint {
162
+ display: inline-flex; align-items: center; gap: 4px;
163
+ font-family: var(--font-mono); font-size: 10.5px;
164
+ padding: 2px 6px; border: 1px solid var(--border); border-radius: 4px;
165
+ color: var(--fg-2); background: var(--bg-1);
166
+ }
167
+
168
+ .icon-btn {
169
+ width: 28px; height: 28px; border-radius: 6px;
170
+ display: inline-flex; align-items: center; justify-content: center;
171
+ color: var(--fg-1); transition: background 120ms, color 120ms;
172
+ position: relative;
173
+ }
174
+ .icon-btn:hover { background: var(--bg-2); color: var(--fg-0); }
175
+ .icon-btn .dot { position: absolute; top: 5px; right: 5px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
176
+
177
+ /* Sidebar */
178
+ .sb-brand {
179
+ display: flex; align-items: center; gap: 10px;
180
+ padding: 10px 14px; height: var(--topbar-h); border-bottom: 1px solid var(--border);
181
+ flex-shrink: 0;
182
+ }
183
+ .sb-logo {
184
+ width: 24px; height: 24px; border-radius: 6px;
185
+ background: linear-gradient(135deg, var(--accent), var(--violet));
186
+ position: relative; overflow: hidden; flex-shrink: 0;
187
+ }
188
+ .sb-logo::after {
189
+ content: ""; position: absolute; inset: 4px;
190
+ border-radius: 3px;
191
+ background: var(--bg-1);
192
+ mask: radial-gradient(circle at 30% 30%, #000 40%, transparent 41%);
193
+ }
194
+ .sb-brand .name { font-weight: 600; font-size: 13.5px; letter-spacing: -0.01em; }
195
+ .sb-brand .ws { font-size: 11px; color: var(--fg-2); font-family: var(--font-mono); }
196
+
197
+ .sb-section { padding: 10px 8px; }
198
+ .sb-label { padding: 6px 10px; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--fg-3); font-weight: 500; display: flex; align-items: center; justify-content: space-between; }
199
+ .sb-label .count { font-family: var(--font-mono); }
200
+
201
+ .sb-item {
202
+ display: flex; align-items: center; gap: 10px;
203
+ padding: 6px 10px; border-radius: 6px;
204
+ color: var(--fg-1); font-size: 13px;
205
+ width: 100%; text-align: left;
206
+ transition: background 100ms, color 100ms;
207
+ }
208
+ .sb-item:hover { background: var(--bg-2); color: var(--fg-0); }
209
+ .sb-item.active { background: var(--bg-2); color: var(--fg-0); }
210
+ .sb-item.active .sb-ind { background: var(--accent); }
211
+ .sb-item .sb-icon { width: 16px; height: 16px; color: var(--fg-2); flex-shrink: 0; }
212
+ .sb-item.active .sb-icon { color: var(--accent); }
213
+ .sb-item .sb-badge { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); }
214
+ .sb-item .sb-ind { width: 2px; height: 14px; border-radius: 2px; background: transparent; margin-left: -10px; margin-right: 8px; }
215
+
216
+ .sb-proj {
217
+ display: flex; align-items: center; gap: 10px;
218
+ padding: 5px 10px; border-radius: 6px;
219
+ color: var(--fg-1); font-size: 12.5px;
220
+ width: 100%; text-align: left;
221
+ }
222
+ .sb-proj:hover { background: var(--bg-2); }
223
+ .sb-proj .dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
224
+
225
+ .sb-foot { margin-top: auto; border-top: 1px solid var(--border); padding: 10px; display: flex; align-items: center; gap: 10px; }
226
+ .avatar { width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, var(--violet), var(--cyan)); color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; flex-shrink: 0; }
227
+ .avatar.sm { width: 20px; height: 20px; font-size: 9.5px; }
228
+ .avatar.xs { width: 16px; height: 16px; font-size: 8.5px; }
229
+ .avatar.lg { width: 32px; height: 32px; font-size: 12.5px; }
230
+
231
+ /* Collapsed sidebar */
232
+ .app[data-sidebar="collapsed"] .sb-item span,
233
+ .app[data-sidebar="collapsed"] .sb-item .sb-badge,
234
+ .app[data-sidebar="collapsed"] .sb-label,
235
+ .app[data-sidebar="collapsed"] .sb-proj span,
236
+ .app[data-sidebar="collapsed"] .sb-brand .name,
237
+ .app[data-sidebar="collapsed"] .sb-brand .ws,
238
+ .app[data-sidebar="collapsed"] .sb-foot .who { display: none; }
239
+ .app[data-sidebar="collapsed"] .sb-item { justify-content: center; padding: 6px; }
240
+
241
+ /* Buttons */
242
+ .btn {
243
+ display: inline-flex; align-items: center; gap: 6px;
244
+ padding: 4px 10px; border-radius: 6px;
245
+ border: 1px solid var(--border);
246
+ background: var(--bg-1); color: var(--fg-0);
247
+ font-size: 12.5px; font-weight: 500;
248
+ transition: all 120ms;
249
+ white-space: nowrap;
250
+ }
251
+ .btn:hover { background: var(--bg-2); border-color: var(--border-strong); }
252
+ .btn.primary {
253
+ background: var(--accent); color: var(--accent-fg); border-color: transparent;
254
+ }
255
+ .btn.primary:hover { filter: brightness(1.05); }
256
+ .btn.ghost { background: transparent; border-color: transparent; color: var(--fg-1); }
257
+ .btn.ghost:hover { background: var(--bg-2); color: var(--fg-0); }
258
+ .btn.sm { padding: 2px 8px; font-size: 11.5px; height: 24px; }
259
+
260
+ /* Segmented */
261
+ .segmented { display: inline-flex; background: var(--bg-1); border: 1px solid var(--border); border-radius: 7px; padding: 2px; gap: 2px; }
262
+ .segmented button { padding: 3px 9px; font-size: 11.5px; color: var(--fg-2); border-radius: 5px; font-weight: 500; display: inline-flex; align-items: center; gap: 5px; }
263
+ .segmented button:hover { color: var(--fg-0); }
264
+ .segmented button.on { background: var(--bg-3); color: var(--fg-0); box-shadow: var(--shadow-sm); }
265
+
266
+ /* Chips / pills / tags */
267
+ .chip {
268
+ display: inline-flex; align-items: center; gap: 4px;
269
+ padding: 2px 7px; border-radius: 4px;
270
+ font-size: 11px; font-weight: 500;
271
+ font-family: var(--font-mono);
272
+ background: var(--bg-2); color: var(--fg-1);
273
+ border: 1px solid var(--border);
274
+ height: 20px;
275
+ white-space: nowrap;
276
+ }
277
+ .chip.solid { border-color: transparent; }
278
+ .chip .d { width: 6px; height: 6px; border-radius: 50%; }
279
+ .chip.priority-high { color: var(--rose); }
280
+ .chip.priority-med { color: var(--amber); }
281
+ .chip.priority-low { color: var(--fg-2); }
282
+
283
+ .tag { display: inline-flex; align-items: center; padding: 1px 6px; font-size: 10.5px; border-radius: 3px; background: var(--bg-2); color: var(--fg-1); border: 1px solid var(--border); font-family: var(--font-mono); height: 18px; }
284
+
285
+ /* Status badge with dot */
286
+ .status {
287
+ display: inline-flex; align-items: center; gap: 6px;
288
+ font-size: 11.5px; color: var(--fg-1);
289
+ }
290
+ .status .s-dot { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; box-sizing: border-box; }
291
+ .status.todo { color: var(--status-todo); }
292
+ .status.todo .s-dot { background: transparent; }
293
+ .status.progress { color: var(--status-progress); }
294
+ .status.progress .s-dot { background: conic-gradient(currentColor 0 50%, transparent 50%); border-color: currentColor; }
295
+ .status.review { color: var(--status-review); }
296
+ .status.review .s-dot { background: conic-gradient(currentColor 0 75%, transparent 75%); border-color: currentColor; }
297
+ .status.done { color: var(--status-done); }
298
+ .status.done .s-dot { background: currentColor; }
299
+ .status.blocked { color: var(--status-blocked); }
300
+ .status.blocked .s-dot { background: currentColor; border-color: currentColor; }
301
+
302
+ /* Card */
303
+ .card { background: var(--bg-1); border: 1px solid var(--border); border-radius: 10px; }
304
+
305
+ /* Scrollable content panes */
306
+ .scroll-y { overflow-y: auto; }
307
+
308
+ /* Page header (inside main) */
309
+ .page-header {
310
+ display: flex; align-items: center; padding: 10px 20px;
311
+ border-bottom: 1px solid var(--border); gap: 12px; flex-wrap: wrap;
312
+ min-height: 48px;
313
+ background: var(--bg-0);
314
+ }
315
+ .page-title { font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
316
+ .page-title .eyebrow { font-family: var(--font-mono); font-size: 11px; color: var(--fg-3); font-weight: 500; }
317
+
318
+ /* Search input */
319
+ .search {
320
+ display: flex; align-items: center; gap: 6px;
321
+ background: var(--bg-1); border: 1px solid var(--border);
322
+ border-radius: 6px; padding: 3px 8px; color: var(--fg-2);
323
+ font-size: 12px; min-width: 280px;
324
+ }
325
+ .search input { background: transparent; border: none; outline: none; flex: 1; color: var(--fg-0); }
326
+ .search input::placeholder { color: var(--fg-3); }
327
+
328
+ /* Table-ish lists */
329
+ .row {
330
+ display: flex; align-items: center; gap: 12px;
331
+ padding: 0 12px; height: var(--density-row);
332
+ border-bottom: 1px solid var(--border-subtle);
333
+ font-size: 12.5px;
334
+ }
335
+ .row:hover { background: var(--bg-1); }
336
+
337
+ /* Focus ring */
338
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
339
+
340
+ /* Tweaks panel */
341
+ .tweaks {
342
+ position: fixed; bottom: 16px; right: 16px;
343
+ width: 260px; background: var(--bg-elev);
344
+ border: 1px solid var(--border-strong);
345
+ border-radius: 12px;
346
+ padding: 12px; z-index: 100;
347
+ box-shadow: var(--shadow-lg);
348
+ display: none; flex-direction: column; gap: 10px;
349
+ }
350
+ .tweaks.open { display: flex; }
351
+ .tweaks h4 { margin: 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--fg-3); font-weight: 600; }
352
+ .tweak-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12.5px; }
353
+ .tweak-row label { color: var(--fg-1); }
354
+ .swatches { display: flex; gap: 5px; }
355
+ .swatch { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--border-strong); cursor: pointer; }
356
+ .swatch.sel { border-color: var(--fg-0); }
357
+
358
+ /* Shortcut hint bar */
359
+ .hint-bar {
360
+ position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%);
361
+ display: flex; gap: 16px; padding: 6px 14px;
362
+ background: var(--bg-elev); border: 1px solid var(--border-strong);
363
+ border-radius: 999px; box-shadow: var(--shadow-md);
364
+ font-size: 11.5px; color: var(--fg-2); z-index: 50;
365
+ pointer-events: none;
366
+ }
367
+ .hint-bar kbd { font-family: var(--font-mono); color: var(--fg-0); font-size: 10.5px; padding: 1px 5px; border-radius: 3px; background: var(--bg-2); border: 1px solid var(--border); }
368
+
369
+ /* Modal / palette */
370
+ .overlay {
371
+ position: fixed; inset: 0; background: oklch(0 0 0 / 0.5);
372
+ backdrop-filter: blur(3px);
373
+ display: flex; align-items: flex-start; justify-content: center;
374
+ padding-top: 12vh; z-index: 200;
375
+ }
376
+ .palette {
377
+ width: 620px; max-width: 92vw;
378
+ background: var(--bg-elev);
379
+ border: 1px solid var(--border-strong);
380
+ border-radius: 14px;
381
+ box-shadow: var(--shadow-lg);
382
+ overflow: hidden;
383
+ }
384
+ .palette-input {
385
+ display: flex; align-items: center; gap: 10px;
386
+ padding: 14px 16px; border-bottom: 1px solid var(--border);
387
+ }
388
+ .palette-input input {
389
+ flex: 1; background: transparent; border: none; outline: none;
390
+ color: var(--fg-0); font-size: 15px;
391
+ }
392
+ .palette-list { max-height: 420px; overflow-y: auto; padding: 6px; }
393
+ .palette-group { padding: 8px 10px 4px; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--fg-3); font-weight: 600; }
394
+ .palette-item {
395
+ display: flex; align-items: center; gap: 10px;
396
+ padding: 7px 10px; border-radius: 6px; font-size: 13px; color: var(--fg-1);
397
+ width: 100%; text-align: left;
398
+ }
399
+ .palette-item:hover, .palette-item.sel { background: var(--bg-2); color: var(--fg-0); }
400
+ .palette-item .k { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); }
401
+
402
+ @keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
403
+ select.btn { appearance: none; cursor: pointer; padding-right: 20px; background-image: linear-gradient(45deg, transparent 50%, var(--fg-2) 50%), linear-gradient(135deg, var(--fg-2) 50%, transparent 50%); background-position: calc(100% - 10px) 50%, calc(100% - 6px) 50%; background-size: 4px 4px, 4px 4px; background-repeat: no-repeat; }
404
+
405
+ /* Inbox overlay */
406
+ .inbox-panel {
407
+ position: fixed; top: var(--topbar-h); right: 0; bottom: 0;
408
+ width: 420px; background: var(--bg-1); border-left: 1px solid var(--border);
409
+ z-index: 150; display: flex; flex-direction: column;
410
+ transform: translateX(100%); transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1);
411
+ box-shadow: var(--shadow-lg);
412
+ }
413
+ .inbox-panel.open { transform: translateX(0); }
414
+
415
+ /* Utility */
416
+ .flex { display: flex; }
417
+ .col { flex-direction: column; }
418
+ .gap-4 { gap: 4px; } .gap-6 { gap: 6px; } .gap-8 { gap: 8px; } .gap-12 { gap: 12px; } .gap-16 { gap: 16px; } .gap-20 { gap: 20px; } .gap-24 { gap: 24px; }
419
+ .items-center { align-items: center; }
420
+ .justify-between { justify-content: space-between; }
421
+ .flex-1 { flex: 1; min-width: 0; }
422
+ .min-w-0 { min-width: 0; }
423
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
424
+ .muted { color: var(--fg-2); }
425
+ .muted-2 { color: var(--fg-3); }
426
+ .hidden { display: none; }
427
+
428
+ .divider { height: 1px; background: var(--border); margin: 8px 0; }
429
+ .vdivider { width: 1px; background: var(--border); align-self: stretch; }
430
+
431
+ /* Icon sizes */
432
+ .i-14 { width: 14px; height: 14px; }
433
+ .i-16 { width: 16px; height: 16px; }
434
+ .i-18 { width: 18px; height: 18px; }
435
+
436
+ /* Subtle ruling */
437
+ .ruled > * + * { border-top: 1px solid var(--border-subtle); }
vanguard.css ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Vanguard layer β€” gradients, glow, animations on top of base styles.css */
2
+
3
+ /* Animated aurora backdrop */
4
+ .app {
5
+ position: relative;
6
+ isolation: isolate;
7
+ }
8
+ .app::before {
9
+ content: "";
10
+ position: fixed; inset: 0;
11
+ background:
12
+ radial-gradient(ellipse 60% 50% at 15% 0%, color-mix(in oklch, var(--accent) 18%, transparent) 0%, transparent 60%),
13
+ radial-gradient(ellipse 50% 40% at 85% 10%, color-mix(in oklch, var(--violet) 16%, transparent) 0%, transparent 60%),
14
+ radial-gradient(ellipse 40% 30% at 50% 100%, color-mix(in oklch, var(--cyan) 12%, transparent) 0%, transparent 60%);
15
+ filter: blur(40px);
16
+ z-index: -1;
17
+ pointer-events: none;
18
+ animation: aurora 24s ease-in-out infinite alternate;
19
+ }
20
+ [data-theme="light"] .app::before {
21
+ opacity: 0.45;
22
+ }
23
+ @keyframes aurora {
24
+ 0% { transform: translate(0, 0) scale(1); }
25
+ 33% { transform: translate(3%, -2%) scale(1.08); }
26
+ 66% { transform: translate(-2%, 3%) scale(0.95); }
27
+ 100% { transform: translate(2%, 2%) scale(1.05); }
28
+ }
29
+
30
+ /* Sidebar + topbar become slightly translucent over aurora */
31
+ .sidebar, .topbar {
32
+ background: color-mix(in oklch, var(--bg-1) 82%, transparent);
33
+ backdrop-filter: blur(14px);
34
+ -webkit-backdrop-filter: blur(14px);
35
+ }
36
+ .topbar { background: color-mix(in oklch, var(--bg-0) 72%, transparent); }
37
+
38
+ /* Gradient brand logo β€” conic, rotating */
39
+ .sb-logo {
40
+ background: conic-gradient(from 0deg,
41
+ var(--accent),
42
+ var(--violet),
43
+ var(--cyan),
44
+ var(--amber),
45
+ var(--accent));
46
+ animation: logo-spin 18s linear infinite;
47
+ box-shadow:
48
+ 0 0 0 1px color-mix(in oklch, var(--accent) 40%, transparent),
49
+ 0 0 24px -4px color-mix(in oklch, var(--accent) 50%, transparent);
50
+ }
51
+ .sb-logo::after {
52
+ mask: radial-gradient(circle at 30% 30%, #000 35%, transparent 36%);
53
+ }
54
+ @keyframes logo-spin { to { transform: rotate(360deg); } }
55
+
56
+ /* Accent primary button β€” gradient + glow */
57
+ .btn.primary {
58
+ background: linear-gradient(135deg,
59
+ var(--accent) 0%,
60
+ color-mix(in oklch, var(--accent) 60%, var(--cyan)) 100%);
61
+ box-shadow:
62
+ 0 0 0 1px color-mix(in oklch, var(--accent) 30%, transparent),
63
+ 0 6px 20px -8px color-mix(in oklch, var(--accent) 80%, transparent),
64
+ inset 0 1px 0 color-mix(in oklch, white 20%, transparent);
65
+ transition: transform 180ms cubic-bezier(0.2, 0, 0, 1), box-shadow 180ms;
66
+ }
67
+ .btn.primary:hover {
68
+ transform: translateY(-1px);
69
+ box-shadow:
70
+ 0 0 0 1px color-mix(in oklch, var(--accent) 40%, transparent),
71
+ 0 10px 28px -8px color-mix(in oklch, var(--accent) 80%, transparent),
72
+ inset 0 1px 0 color-mix(in oklch, white 30%, transparent);
73
+ filter: none;
74
+ }
75
+
76
+ /* Card shimmer on hover */
77
+ .card {
78
+ position: relative;
79
+ transition: transform 200ms cubic-bezier(0.2, 0, 0, 1), border-color 200ms, box-shadow 240ms;
80
+ overflow: hidden;
81
+ }
82
+ .card::after {
83
+ content: "";
84
+ position: absolute; inset: 0;
85
+ background: linear-gradient(135deg,
86
+ transparent 40%,
87
+ color-mix(in oklch, var(--accent) 6%, transparent) 55%,
88
+ transparent 70%);
89
+ opacity: 0;
90
+ transition: opacity 400ms;
91
+ pointer-events: none;
92
+ }
93
+ .card:hover {
94
+ border-color: var(--border-strong);
95
+ box-shadow: 0 12px 32px -16px oklch(0 0 0 / 0.4);
96
+ }
97
+ .card:hover::after { opacity: 1; }
98
+
99
+ /* Kanban cards: lift + subtle gradient border on hover */
100
+ button.card { cursor: pointer; }
101
+ button.card:hover {
102
+ transform: translateY(-2px);
103
+ }
104
+
105
+ /* Sidebar item β€” animated gradient indicator */
106
+ .sb-item { position: relative; transition: color 140ms; }
107
+ .sb-item.active .sb-ind {
108
+ background: linear-gradient(180deg, var(--accent), color-mix(in oklch, var(--accent) 50%, var(--violet)));
109
+ box-shadow: 0 0 10px -2px var(--accent);
110
+ }
111
+ .sb-item.active {
112
+ background: linear-gradient(90deg,
113
+ color-mix(in oklch, var(--accent) 10%, transparent) 0%,
114
+ transparent 80%);
115
+ }
116
+
117
+ /* Topbar search β€” inner glow on focus */
118
+ .search { transition: border-color 180ms, box-shadow 180ms; }
119
+ .search:hover { border-color: var(--border-strong); }
120
+ .search:focus-within {
121
+ border-color: color-mix(in oklch, var(--accent) 50%, var(--border));
122
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 15%, transparent);
123
+ }
124
+
125
+ /* Status dots glow by state */
126
+ .status.progress .s-dot { box-shadow: 0 0 8px -1px var(--status-progress); }
127
+ .status.done .s-dot { box-shadow: 0 0 8px -1px var(--status-done); }
128
+ .status.review .s-dot { box-shadow: 0 0 8px -1px var(--status-review); }
129
+ .status.blocked .s-dot { box-shadow: 0 0 8px -1px var(--status-blocked); }
130
+
131
+ /* Chips β€” gradient fills for states */
132
+ .chip { backdrop-filter: blur(6px); }
133
+
134
+ /* Priority urgent β€” gentle pulse */
135
+ .priority-pulse {
136
+ animation: pulse-urgent 1.8s ease-in-out infinite;
137
+ }
138
+ @keyframes pulse-urgent {
139
+ 0%, 100% { opacity: 1; }
140
+ 50% { opacity: 0.55; }
141
+ }
142
+
143
+ /* Avatar β€” gradient ring for online */
144
+ .avatar { position: relative; box-shadow: inset 0 0 0 1px color-mix(in oklch, white 10%, transparent); }
145
+
146
+ /* Page transitions */
147
+ .main > * {
148
+ animation: fade-slide 260ms cubic-bezier(0.2, 0, 0, 1);
149
+ }
150
+ @keyframes fade-slide {
151
+ from { opacity: 0; transform: translateY(4px); }
152
+ to { opacity: 1; transform: translateY(0); }
153
+ }
154
+
155
+ /* Staggered list items */
156
+ .stagger > * { animation: fade-slide 320ms cubic-bezier(0.2, 0, 0, 1) both; }
157
+ .stagger > *:nth-child(1) { animation-delay: 20ms; }
158
+ .stagger > *:nth-child(2) { animation-delay: 50ms; }
159
+ .stagger > *:nth-child(3) { animation-delay: 80ms; }
160
+ .stagger > *:nth-child(4) { animation-delay: 110ms; }
161
+ .stagger > *:nth-child(5) { animation-delay: 140ms; }
162
+ .stagger > *:nth-child(6) { animation-delay: 170ms; }
163
+ .stagger > *:nth-child(7) { animation-delay: 200ms; }
164
+ .stagger > *:nth-child(8) { animation-delay: 230ms; }
165
+
166
+ /* Hero greeting β€” gradient text */
167
+ .hero-title {
168
+ background: linear-gradient(120deg,
169
+ var(--fg-0) 0%,
170
+ var(--fg-0) 40%,
171
+ color-mix(in oklch, var(--accent) 70%, var(--fg-0)) 65%,
172
+ color-mix(in oklch, var(--violet) 80%, var(--fg-0)) 100%);
173
+ background-size: 200% 100%;
174
+ -webkit-background-clip: text;
175
+ background-clip: text;
176
+ color: transparent;
177
+ animation: shimmer 8s ease-in-out infinite;
178
+ }
179
+ @keyframes shimmer {
180
+ 0%, 100% { background-position: 0% 50%; }
181
+ 50% { background-position: 100% 50%; }
182
+ }
183
+
184
+ /* Stat counters β€” editorial numerals */
185
+ .stat-num {
186
+ font-family: var(--font-editorial);
187
+ font-weight: 400;
188
+ letter-spacing: -0.03em;
189
+ background: linear-gradient(180deg, var(--fg-0) 0%, color-mix(in oklch, var(--fg-0) 70%, var(--accent)) 100%);
190
+ -webkit-background-clip: text;
191
+ background-clip: text;
192
+ color: transparent;
193
+ }
194
+
195
+ /* Segmented pill β€” animated selection glow */
196
+ .segmented button.on {
197
+ background: linear-gradient(135deg,
198
+ color-mix(in oklch, var(--bg-3) 100%, transparent),
199
+ color-mix(in oklch, var(--accent) 12%, var(--bg-3)));
200
+ box-shadow:
201
+ 0 1px 2px oklch(0 0 0 / 0.2),
202
+ inset 0 1px 0 color-mix(in oklch, white 6%, transparent);
203
+ }
204
+
205
+ /* Icon button β€” ripple on click (CSS-only pulse) */
206
+ .icon-btn { transition: background 140ms, color 140ms, transform 120ms; }
207
+ .icon-btn:active { transform: scale(0.92); }
208
+
209
+ /* Kanban column header counter β€” subtle badge */
210
+ .col-badge {
211
+ background: color-mix(in oklch, var(--accent) 16%, var(--bg-2));
212
+ color: var(--accent);
213
+ padding: 1px 6px;
214
+ border-radius: 4px;
215
+ font-family: var(--font-mono);
216
+ font-size: 10.5px;
217
+ }
218
+
219
+ /* Burndown actual line β€” glow */
220
+ .burndown-actual { filter: drop-shadow(0 0 6px color-mix(in oklch, var(--accent) 60%, transparent)); }
221
+
222
+ /* Velocity bars β€” gradient */
223
+ .velo-bar {
224
+ background: linear-gradient(180deg,
225
+ color-mix(in oklch, var(--accent) 90%, transparent) 0%,
226
+ color-mix(in oklch, var(--accent) 30%, var(--bg-3)) 100%);
227
+ box-shadow: 0 0 12px -3px var(--accent);
228
+ }
229
+ .velo-bar.dim {
230
+ background: linear-gradient(180deg, var(--bg-3), var(--bg-2));
231
+ box-shadow: none;
232
+ }
233
+
234
+ /* Roadmap bar β€” gradient fill with progress wipe */
235
+ .roadmap-bar {
236
+ position: relative;
237
+ overflow: hidden;
238
+ backdrop-filter: blur(4px);
239
+ }
240
+ .roadmap-bar::before {
241
+ content: "";
242
+ position: absolute; inset: 0;
243
+ background: linear-gradient(90deg,
244
+ color-mix(in oklch, var(--proj, var(--accent)) 55%, transparent) 0%,
245
+ color-mix(in oklch, var(--proj, var(--accent)) 18%, transparent) 100%);
246
+ opacity: 0.9;
247
+ mix-blend-mode: screen;
248
+ }
249
+
250
+ /* Gradient divider */
251
+ .grad-divider {
252
+ height: 1px;
253
+ background: linear-gradient(90deg,
254
+ transparent 0%,
255
+ var(--border-strong) 20%,
256
+ var(--border-strong) 80%,
257
+ transparent 100%);
258
+ }
259
+
260
+ /* Command palette β€” glassmorphism */
261
+ .palette {
262
+ background: color-mix(in oklch, var(--bg-elev) 85%, transparent);
263
+ backdrop-filter: blur(24px) saturate(140%);
264
+ -webkit-backdrop-filter: blur(24px) saturate(140%);
265
+ border: 1px solid color-mix(in oklch, var(--accent) 20%, var(--border-strong));
266
+ box-shadow:
267
+ 0 24px 80px -20px oklch(0 0 0 / 0.6),
268
+ 0 0 0 1px color-mix(in oklch, var(--accent) 10%, transparent),
269
+ inset 0 1px 0 color-mix(in oklch, white 6%, transparent);
270
+ animation: palette-in 240ms cubic-bezier(0.2, 0, 0, 1);
271
+ }
272
+ @keyframes palette-in {
273
+ from { opacity: 0; transform: translateY(-8px) scale(0.98); }
274
+ to { opacity: 1; transform: translateY(0) scale(1); }
275
+ }
276
+ .palette-item { transition: background 120ms, color 120ms, transform 100ms; }
277
+ .palette-item.sel {
278
+ background: linear-gradient(90deg,
279
+ color-mix(in oklch, var(--accent) 14%, transparent),
280
+ transparent);
281
+ }
282
+
283
+ /* Inbox panel β€” glass */
284
+ .inbox-panel {
285
+ background: color-mix(in oklch, var(--bg-1) 82%, transparent);
286
+ backdrop-filter: blur(18px);
287
+ }
288
+
289
+ /* Tweaks panel β€” glass */
290
+ .tweaks {
291
+ background: color-mix(in oklch, var(--bg-elev) 78%, transparent);
292
+ backdrop-filter: blur(20px) saturate(140%);
293
+ -webkit-backdrop-filter: blur(20px) saturate(140%);
294
+ border: 1px solid color-mix(in oklch, var(--accent) 25%, var(--border-strong));
295
+ box-shadow:
296
+ 0 20px 60px -20px oklch(0 0 0 / 0.5),
297
+ 0 0 0 1px color-mix(in oklch, var(--accent) 10%, transparent);
298
+ }
299
+
300
+ /* Hint bar β€” glass */
301
+ .hint-bar {
302
+ background: color-mix(in oklch, var(--bg-elev) 80%, transparent);
303
+ backdrop-filter: blur(16px);
304
+ }
305
+
306
+ /* Row hover β€” subtle gradient sweep */
307
+ .row { transition: background 160ms; position: relative; }
308
+ .row:hover {
309
+ background: linear-gradient(90deg,
310
+ color-mix(in oklch, var(--accent) 6%, transparent) 0%,
311
+ var(--bg-1) 20%);
312
+ }
313
+
314
+ /* Avatar stack β€” gradient bg (override flat) */
315
+ .avatar {
316
+ transition: transform 140ms;
317
+ }
318
+ .avatar:hover { transform: translateY(-1px) scale(1.06); z-index: 2; }
319
+
320
+ /* Notification dot pulse */
321
+ .icon-btn .dot {
322
+ animation: dot-pulse 2.4s ease-in-out infinite;
323
+ box-shadow: 0 0 8px -1px var(--accent);
324
+ }
325
+ @keyframes dot-pulse {
326
+ 0%, 100% { transform: scale(1); opacity: 1; }
327
+ 50% { transform: scale(1.3); opacity: 0.65; }
328
+ }
329
+
330
+ /* Gradient progress track */
331
+ .grad-progress {
332
+ background: linear-gradient(90deg, var(--accent) 0%, color-mix(in oklch, var(--accent) 60%, var(--violet)) 100%);
333
+ box-shadow: 0 0 10px -3px var(--accent);
334
+ }
335
+
336
+ /* Subtle scan line on data cards */
337
+ .scan::before {
338
+ content: "";
339
+ position: absolute;
340
+ top: 0; left: -50%;
341
+ width: 50%; height: 100%;
342
+ background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--accent) 8%, transparent), transparent);
343
+ animation: scan 6s linear infinite;
344
+ pointer-events: none;
345
+ }
346
+ @keyframes scan {
347
+ to { left: 150%; }
348
+ }
349
+
350
+ /* Issue detail hero β€” spotlight */
351
+ .issue-spotlight {
352
+ position: relative;
353
+ }
354
+ .issue-spotlight::before {
355
+ content: "";
356
+ position: absolute;
357
+ top: -40px; left: -40px; right: -40px; height: 200px;
358
+ background: radial-gradient(ellipse 60% 100% at 20% 50%,
359
+ color-mix(in oklch, var(--accent) 10%, transparent) 0%,
360
+ transparent 60%);
361
+ z-index: -1;
362
+ pointer-events: none;
363
+ }
364
+
365
+ /* Tag / chip gradient on hover */
366
+ .chip:hover, .tag:hover {
367
+ border-color: var(--border-strong);
368
+ background: color-mix(in oklch, var(--bg-3) 100%, transparent);
369
+ }
370
+
371
+ /* Kanban column boundary glow on drop target sim */
372
+ .kanban-col {
373
+ position: relative;
374
+ }
375
+
376
+ /* Make topbar breadcrumb current accent on active */
377
+ .breadcrumbs .current {
378
+ background: linear-gradient(90deg, var(--fg-0), color-mix(in oklch, var(--accent) 50%, var(--fg-0)));
379
+ -webkit-background-clip: text;
380
+ background-clip: text;
381
+ color: transparent;
382
+ }
383
+
384
+ /* reduced motion */
385
+ @media (prefers-reduced-motion: reduce) {
386
+ *, *::before, *::after { animation: none !important; transition: none !important; }
387
+ }
views-ai-marketplace.jsx ADDED
@@ -0,0 +1,686 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // AI Marketplace β€” Studios, Model APIs, Storage, Templates
2
+
3
+ const STUDIO_GPU_TABLE = [
4
+ { id: "v520", name: "V520", speed: 18, mem: 8, cpus: 8, priceHr: 0.29, spot: 0.15, wait: 2, avail: true },
5
+ { id: "v620", name: "V620", speed: 105, mem: 32, cpus: 8, priceHr: 0.53, spot: 0.27, wait: 2, avail: true },
6
+ { id: "w7800", name: "W7800", speed: 89, mem: 32, cpus: 16, priceHr: 1.67, spot: 0.84, wait: 2, avail: true },
7
+ { id: "w7900", name: "W7900", speed: 123, mem: 48, cpus: 48, priceHr: 1.43, spot: 0.72, wait: 2, avail: true },
8
+ { id: "mi210", name: "MI210", speed: 181, mem: 64, cpus: 30, priceHr: 3.06, spot: 1.53, wait: 2, avail: true },
9
+ { id: "mi250x",name: "MI250X", speed: 383, mem: 128, cpus: 26, priceHr: 5.17, spot: 2.59, wait: 2, avail: true },
10
+ { id: "mi300a",name: "MI300A", speed: 3800, mem: 128, cpus: 16, priceHr: 6.99, spot: null, wait: 3, avail: false },
11
+ { id: "mi300x",name: "MI300X", speed: 10496,mem: 192, cpus: 224, priceHr: 35.91, spot: null, wait: 4, avail: true },
12
+ ];
13
+
14
+ const STUDIO_TYPES = [
15
+ { id: "ai-dev", label: "AI development", icon: "code", desc: "Jupyter + VS Code + GPU access" },
16
+ { id: "python", label: "Python", icon: "sprint", desc: "Pure compute environment" },
17
+ { id: "comfy", label: "ComfyUI", icon: "component", desc: "Image & video gen workflows" },
18
+ { id: "training", label: "Training run", icon: "bolt", desc: "Distributed model training" },
19
+ { id: "inference",label: "Inference server",icon: "play", desc: "vLLM / TGI serving endpoint" },
20
+ ];
21
+
22
+ const GPU_QTY = [1, 2, 4, 8];
23
+
24
+ const LLM_MODELS = [
25
+ { id: "nexacore-72b", name: "NexaCore-72B", provider: "VaultMind Labs", category: "reasoning", ctx: "128k", inputPrice: 8.00, outputPrice: 24.00, badge: "FLAGSHIP", badgeC: "var(--accent)", latency: "~2.1s", features: ["Chain-of-thought","Code interpreter","Function calling","JSON mode"] },
26
+ { id: "vaultmind-34b", name: "VaultMind-34B", provider: "VaultMind Labs", category: "general", ctx: "64k", inputPrice: 2.00, outputPrice: 6.00, badge: "POPULAR", badgeC: "oklch(0.78 0.13 220)", latency: "~0.8s", features: ["Function calling","JSON mode","Streaming"] },
27
+ { id: "arclight-7b", name: "ArcLight-7B", provider: "VaultMind Labs", category: "fast", ctx: "32k", inputPrice: 0.20, outputPrice: 0.40, badge: "FAST", badgeC: "oklch(0.80 0.14 75)", latency: "~0.15s", features: ["Ultra-low latency","High throughput"] },
28
+ { id: "deepchroma-vis", name: "DeepChroma Vision", provider: "ChromaAI", category: "multimodal", ctx: "32k", inputPrice: 3.50, outputPrice: 10.50, badge: "VISION", badgeC: "oklch(0.72 0.18 300)", latency: "~1.4s", features: ["Image input","Document parsing","OCR","Chart analysis"] },
29
+ { id: "sonartext-embed", name: "SonarText Embed", provider: "SonarAI", category: "embedding", ctx: "8k", inputPrice: 0.02, outputPrice: null, badge: "EMBED", badgeC: "oklch(0.78 0.13 220)", latency: "~40ms", features: ["1536-dim vectors","Batch API","MTEB top-5"] },
30
+ { id: "novasynth-audio", name: "NovaSynth Audio", provider: "NovaSynth", category: "audio", ctx: "β€”", inputPrice: 0.006, outputPrice: null, badge: "AUDIO", badgeC: "oklch(0.72 0.17 20)", latency: "~200ms", features: ["ASR","Diarization","Translation","Timestamps"] },
31
+ ];
32
+
33
+ const STORAGE_PLANS = [
34
+ { id: "obj", name: "Object Storage", priceGb: 0.022, iops: "β€”", throughput: "Up to 2 GB/s", features: ["S3-compatible API","Multi-region replication","Lifecycle rules","Versioning"] },
35
+ { id: "ssd", name: "SSD Block", priceGb: 0.08, iops: "50k", throughput: "Up to 8 GB/s", features: ["NFS / iSCSI mount","Snapshots","Low latency","Resize online"] },
36
+ { id: "nvme", name: "NVMe Ultra", priceGb: 0.18, iops: "500k", throughput: "Up to 30 GB/s", features: ["Direct attach","Sub-100Β΅s latency","Ideal for training","Non-volatile"] },
37
+ ];
38
+
39
+ const TEMPLATES = [
40
+ { id: "llm-ft", name: "LLM Fine-tuning", gpu: "MI210 64 GB", icon: "sprint", tags: ["PyTorch","LoRA","HuggingFace"], desc: "LoRA / QLoRA fine-tuning with Accelerate & Transformers." },
41
+ { id: "rag", name: "RAG Pipeline", gpu: "W7800", icon: "database", tags: ["LangChain","pgvector","FastAPI"], desc: "Vector store + retrieval-augmented generation ready to deploy." },
42
+ { id: "sdxl", name: "Stable Diffusion XL", gpu: "W7900", icon: "star", tags: ["Diffusers","ComfyUI","LoRA"], desc: "SDXL inference + LoRA training environment pre-configured." },
43
+ { id: "whisper", name: "Whisper Transcription", gpu: "V520", icon: "message", tags: ["Whisper","FastAPI","Batch"], desc: "Large-v3 Whisper for batch audio transcription at scale." },
44
+ { id: "vision", name: "Vision Classifier", gpu: "V620", icon: "eye", tags: ["torchvision","ViT","W&B"], desc: "EfficientNet / ViT training with Weights & Biases logging." },
45
+ { id: "embed-srv", name: "Embedding Server", gpu: "V520", icon: "component", tags: ["vLLM","FastAPI","Redis"], desc: "Deploy your own embedding model with vLLM + FastAPI caching." },
46
+ ];
47
+
48
+ // ---- Studios Tab ----
49
+ const StudiosTab = () => {
50
+ const [studioType, setStudioType] = React.useState("ai-dev");
51
+ const [selectedGpu, setSelectedGpu] = React.useState("w7800");
52
+ const [qty, setQty] = React.useState(1);
53
+ const [interruptible, setInterruptible] = React.useState(true);
54
+ const [mode, setMode] = React.useState("gpu");
55
+ const [launching, setLaunching] = React.useState(false);
56
+
57
+ const gpu = STUDIO_GPU_TABLE.find(g => g.id === selectedGpu) || STUDIO_GPU_TABLE[2];
58
+ const effectivePrice = interruptible && gpu.spot ? gpu.spot : gpu.priceHr;
59
+ const totalHr = (effectivePrice * qty).toFixed(2);
60
+
61
+ const launch = () => {
62
+ if (!gpu.avail) { window.toast("GPU unavailable as interruptible"); return; }
63
+ setLaunching(true);
64
+ setTimeout(() => {
65
+ setLaunching(false);
66
+ window.toast(`Studio launched β€” ${qty}Γ— ${gpu.name} Β· $${totalHr}/hr`);
67
+ }, 1600);
68
+ };
69
+
70
+ return (
71
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 340px", gap: 20 }}>
72
+ {/* Left */}
73
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
74
+
75
+ {/* Studio type */}
76
+ <div className="card" style={{ padding: 16 }}>
77
+ <div style={{ fontSize: 11, fontWeight: 600, letterSpacing: ".1em", textTransform: "uppercase", color: "var(--fg-3)", marginBottom: 12 }}>Studio type</div>
78
+ <div style={{ display: "flex", gap: 8, overflowX: "auto", paddingBottom: 2 }}>
79
+ {STUDIO_TYPES.map(t => (
80
+ <button
81
+ key={t.id}
82
+ onClick={() => setStudioType(t.id)}
83
+ style={{
84
+ flexShrink: 0, padding: "12px 16px", borderRadius: 10,
85
+ border: `1.5px solid ${studioType === t.id ? "var(--accent)" : "var(--border)"}`,
86
+ background: studioType === t.id ? "var(--accent-soft)" : "var(--bg-1)",
87
+ cursor: "pointer", textAlign: "center", minWidth: 120,
88
+ transition: "border-color .15s, background .15s",
89
+ }}
90
+ >
91
+ <Icon name={t.icon} size={18} style={{ color: studioType === t.id ? "var(--accent)" : "var(--fg-2)", marginBottom: 6, display: "block", margin: "0 auto 8px" }} />
92
+ <div style={{ fontSize: 12, fontWeight: 600, color: studioType === t.id ? "var(--fg-0)" : "var(--fg-1)" }}>{t.label}</div>
93
+ <div style={{ fontSize: 10.5, color: "var(--fg-3)", marginTop: 3 }}>{t.desc}</div>
94
+ </button>
95
+ ))}
96
+ </div>
97
+ </div>
98
+
99
+ {/* Machine grid */}
100
+ <div className="card" style={{ padding: 0, overflow: "hidden" }}>
101
+ <div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 10 }}>
102
+ <div style={{ fontSize: 11, fontWeight: 600, letterSpacing: ".1em", textTransform: "uppercase", color: "var(--fg-3)", flex: 1 }}>Machine</div>
103
+ <div className="segmented">
104
+ <button className={mode === "cpu" ? "on" : ""} onClick={() => setMode("cpu")}>CPU</button>
105
+ <button className={mode === "gpu" ? "on" : ""} onClick={() => setMode("gpu")}>GPU</button>
106
+ </div>
107
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
108
+ <span style={{ fontSize: 11.5, color: "var(--fg-2)" }}>Interruptible</span>
109
+ <button
110
+ onClick={() => setInterruptible(v => !v)}
111
+ style={{
112
+ width: 32, height: 18, borderRadius: 9, border: "none", cursor: "pointer",
113
+ background: interruptible ? "var(--accent)" : "var(--bg-3)",
114
+ position: "relative", transition: "background .2s", flexShrink: 0,
115
+ }}
116
+ >
117
+ <span style={{
118
+ position: "absolute", top: 2, left: interruptible ? 16 : 2,
119
+ width: 14, height: 14, borderRadius: "50%", background: "#fff",
120
+ transition: "left .2s", display: "block",
121
+ }} />
122
+ </button>
123
+ </div>
124
+ </div>
125
+
126
+ {/* Table header */}
127
+ <div style={{ display: "grid", gridTemplateColumns: "80px 1fr 100px 90px 70px 130px 80px", padding: "6px 16px", borderBottom: "1px solid var(--border-subtle)", fontSize: 10.5, color: "var(--fg-3)", fontWeight: 600, letterSpacing: ".05em", textTransform: "uppercase" }}>
128
+ <span>Qty</span><span>Model</span><span>Speed (TFLOPs)</span><span>Memory (GB)</span><span>CPUs</span><span>Cost (/hour)</span><span>Wait (min)</span>
129
+ </div>
130
+
131
+ {STUDIO_GPU_TABLE.map(g => {
132
+ const sel = selectedGpu === g.id;
133
+ const effectiveP = interruptible && g.spot ? g.spot : g.priceHr;
134
+ return (
135
+ <div
136
+ key={g.id}
137
+ onClick={() => { if (!(!g.avail && interruptible)) setSelectedGpu(g.id); }}
138
+ style={{
139
+ display: "grid", gridTemplateColumns: "80px 1fr 100px 90px 70px 130px 80px",
140
+ padding: "9px 16px", borderBottom: "1px solid var(--border-subtle)",
141
+ background: sel ? "var(--accent-soft)" : "transparent",
142
+ cursor: (!g.avail && interruptible) ? "not-allowed" : "pointer",
143
+ opacity: (!g.avail && interruptible) ? 0.45 : 1,
144
+ transition: "background .12s",
145
+ alignItems: "center",
146
+ }}
147
+ >
148
+ {/* Qty buttons */}
149
+ <div style={{ display: "flex", gap: 3 }}>
150
+ {GPU_QTY.map(n => (
151
+ <button
152
+ key={n}
153
+ onClick={e => { e.stopPropagation(); setSelectedGpu(g.id); setQty(n); }}
154
+ style={{
155
+ width: 16, height: 16, fontSize: 9, borderRadius: 3, border: "none",
156
+ background: sel && qty === n ? "var(--accent)" : "var(--bg-3)",
157
+ color: sel && qty === n ? "var(--accent-fg)" : "var(--fg-2)",
158
+ cursor: "pointer", fontWeight: 600, display: "flex", alignItems: "center", justifyContent: "center",
159
+ }}
160
+ >{n}</button>
161
+ ))}
162
+ </div>
163
+ <span style={{ fontSize: 13, fontWeight: sel ? 600 : 400, color: sel ? "var(--fg-0)" : "var(--fg-1)" }}>{g.name}</span>
164
+ <span className="mono" style={{ fontSize: 11.5, color: "var(--fg-2)" }}>{g.speed.toLocaleString()}</span>
165
+ <span className="mono" style={{ fontSize: 11.5, color: "var(--fg-2)" }}>{g.mem}</span>
166
+ <span className="mono" style={{ fontSize: 11.5, color: "var(--fg-2)" }}>{g.cpus}</span>
167
+ <span style={{ display: "flex", alignItems: "center", gap: 4 }}>
168
+ {interruptible && g.spot && (
169
+ <span className="mono" style={{ fontSize: 10, color: "var(--fg-3)", textDecoration: "line-through" }}>${g.priceHr.toFixed(2)}</span>
170
+ )}
171
+ <span className="mono" style={{ fontSize: 12, fontWeight: 600, color: sel ? "var(--accent)" : "var(--fg-0)" }}>
172
+ ${(effectiveP * qty).toFixed(2)}
173
+ </span>
174
+ </span>
175
+ <span style={{ display: "flex", alignItems: "center", gap: 4 }}>
176
+ {!g.avail && interruptible
177
+ ? <span style={{ fontSize: 10.5, color: "oklch(0.80 0.14 75)" }}>⚠ Unavailable</span>
178
+ : <span className="mono" style={{ fontSize: 11.5, color: "var(--fg-2)" }}>{g.wait} min</span>
179
+ }
180
+ </span>
181
+ </div>
182
+ );
183
+ })}
184
+
185
+ <div style={{ padding: "8px 16px", fontSize: 10.5, color: "var(--fg-3)" }}>
186
+ ⚑ Interruptible machines are 40–60% cheaper but may experience data loss on preemption.
187
+ </div>
188
+ </div>
189
+ </div>
190
+
191
+ {/* Right β€” summary + launch */}
192
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
193
+ <div className="card" style={{ padding: 16 }}>
194
+ <div style={{ fontSize: 11, fontWeight: 600, letterSpacing: ".1em", textTransform: "uppercase", color: "var(--fg-3)", marginBottom: 14 }}>Teamspace</div>
195
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 10px", background: "var(--bg-0)", border: "1px solid var(--border)", borderRadius: 7 }}>
196
+ <span style={{ width: 8, height: 8, borderRadius: "50%", background: "var(--accent)", flexShrink: 0 }} />
197
+ <span style={{ fontSize: 12.5, flex: 1 }}>model-performance-assessment-project</span>
198
+ <Icon name="chevron-down" size={13} style={{ color: "var(--fg-3)" }} />
199
+ </div>
200
+ </div>
201
+
202
+ <div className="card" style={{ padding: 16 }}>
203
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 14 }}>Summary</strong>
204
+ <div className="divider" />
205
+ {[
206
+ { label: "Studio type", value: STUDIO_TYPES.find(t => t.id === studioType)?.label },
207
+ { label: "Machine", value: `${qty}Γ— ${gpu.name}` },
208
+ { label: "Memory", value: `${gpu.mem * qty} GB` },
209
+ { label: "CPUs", value: gpu.cpus * qty },
210
+ { label: "Mode", value: interruptible && gpu.spot ? "Interruptible" : "On-demand" },
211
+ ].map(r => (
212
+ <div key={r.label} className="flex items-center justify-between" style={{ padding: "5px 0", fontSize: 12.5 }}>
213
+ <span className="muted">{r.label}</span>
214
+ <span className="mono" style={{ fontWeight: 500 }}>{r.value}</span>
215
+ </div>
216
+ ))}
217
+ <div className="divider" />
218
+ <div className="flex items-center justify-between" style={{ padding: "6px 0" }}>
219
+ <span style={{ fontSize: 13 }}>Rate</span>
220
+ <span style={{ fontSize: 20, fontWeight: 700, color: "var(--accent)", fontFamily: "var(--font-mono)" }}>
221
+ ${totalHr}<span style={{ fontSize: 11, fontWeight: 400, color: "var(--fg-3)" }}>/hr</span>
222
+ </span>
223
+ </div>
224
+ {interruptible && gpu.spot && (
225
+ <div className="mono muted-2" style={{ fontSize: 10.5, marginTop: 2 }}>
226
+ Saved ${((gpu.priceHr - gpu.spot) * qty).toFixed(2)}/hr vs on-demand
227
+ </div>
228
+ )}
229
+ </div>
230
+
231
+ <button
232
+ className="btn primary"
233
+ style={{ width: "100%", padding: "12px", fontSize: 14, fontWeight: 600, opacity: launching ? 0.6 : 1 }}
234
+ onClick={launch}
235
+ disabled={launching}
236
+ >
237
+ {launching
238
+ ? <><Icon name="clock" size={14} /> Starting studio…</>
239
+ : <><Icon name="bolt" size={14} /> Launch studio</>
240
+ }
241
+ </button>
242
+
243
+ <div className="card" style={{ padding: 14 }}>
244
+ <div className="muted-2 mono" style={{ fontSize: 10, letterSpacing: ".1em", textTransform: "uppercase", marginBottom: 10 }}>Live availability</div>
245
+ {[
246
+ { label: "MI300X", slots: 2 },
247
+ { label: "MI250X", slots: 8 },
248
+ { label: "W7800", slots: 11 },
249
+ { label: "W7900", slots: 5 },
250
+ { label: "V520", slots: 24 },
251
+ ].map(a => (
252
+ <div key={a.label} className="flex items-center justify-between" style={{ fontSize: 11.5, padding: "3px 0" }}>
253
+ <span className="mono">{a.label}</span>
254
+ <span style={{ color: a.slots > 8 ? "var(--accent)" : a.slots > 2 ? "oklch(0.80 0.14 75)" : "oklch(0.72 0.17 20)" }}>
255
+ {a.slots} free
256
+ </span>
257
+ </div>
258
+ ))}
259
+ </div>
260
+ </div>
261
+ </div>
262
+ );
263
+ };
264
+
265
+ // ---- Model APIs Tab ----
266
+ const ModelAPIsTab = () => {
267
+ const [categoryFilter, setCategoryFilter] = React.useState("all");
268
+ const [activeModel, setActiveModel] = React.useState(null);
269
+ const [testInput, setTestInput] = React.useState("Explain transformer attention in 2 sentences.");
270
+ const [testRunning, setTestRunning] = React.useState(false);
271
+ const [testOutput, setTestOutput] = React.useState("");
272
+
273
+ const categories = ["all", "reasoning", "general", "fast", "multimodal", "embedding", "audio"];
274
+ const filtered = categoryFilter === "all" ? LLM_MODELS : LLM_MODELS.filter(m => m.category === categoryFilter);
275
+
276
+ const runTest = () => {
277
+ if (!testInput.trim()) return;
278
+ setTestRunning(true);
279
+ setTestOutput("");
280
+ const responses = [
281
+ "Transformer attention computes a weighted sum over all input tokens, where weights are derived from dot-product similarity between query and key vectors. This allows the model to dynamically focus on the most relevant parts of the sequence regardless of distance.",
282
+ "Attention mechanisms let each token in a sequence selectively attend to all other tokens by computing compatibility scores scaled by the square root of the key dimension. The result is a context-aware representation that enables long-range dependency modeling.",
283
+ ];
284
+ const resp = responses[Math.floor(Math.random() * responses.length)];
285
+ let i = 0;
286
+ const iv = setInterval(() => {
287
+ setTestOutput(resp.slice(0, i));
288
+ i += 3;
289
+ if (i > resp.length) { clearInterval(iv); setTestRunning(false); }
290
+ }, 18);
291
+ };
292
+
293
+ return (
294
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 340px", gap: 20 }}>
295
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
296
+ {/* Category filter */}
297
+ <div className="flex items-center gap-8" style={{ flexWrap: "wrap" }}>
298
+ {categories.map(c => (
299
+ <button
300
+ key={c}
301
+ onClick={() => setCategoryFilter(c)}
302
+ style={{
303
+ padding: "5px 12px", borderRadius: 20, fontSize: 12, fontWeight: 500,
304
+ border: `1.5px solid ${categoryFilter === c ? "var(--accent)" : "var(--border)"}`,
305
+ background: categoryFilter === c ? "var(--accent-soft)" : "var(--bg-1)",
306
+ color: categoryFilter === c ? "var(--accent)" : "var(--fg-1)",
307
+ cursor: "pointer", textTransform: "capitalize",
308
+ }}
309
+ >{c}</button>
310
+ ))}
311
+ </div>
312
+
313
+ {/* Model cards */}
314
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 12 }}>
315
+ {filtered.map(m => (
316
+ <button
317
+ key={m.id}
318
+ onClick={() => setActiveModel(activeModel?.id === m.id ? null : m)}
319
+ style={{
320
+ textAlign: "left", padding: 16, borderRadius: 12,
321
+ border: `1.5px solid ${activeModel?.id === m.id ? "var(--accent)" : "var(--border)"}`,
322
+ background: activeModel?.id === m.id ? "var(--accent-soft)" : "var(--bg-1)",
323
+ cursor: "pointer", transition: "border-color .15s, background .15s",
324
+ }}
325
+ >
326
+ <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 6 }}>
327
+ <div>
328
+ <div style={{ fontSize: 13.5, fontWeight: 600 }}>{m.name}</div>
329
+ <div style={{ fontSize: 11, color: "var(--fg-3)", marginTop: 1 }}>{m.provider}</div>
330
+ </div>
331
+ <span style={{
332
+ fontSize: 9, padding: "2px 6px", borderRadius: 4, fontFamily: "var(--font-mono)", fontWeight: 700,
333
+ background: m.badgeC + "22", color: m.badgeC,
334
+ }}>{m.badge}</span>
335
+ </div>
336
+
337
+ <div style={{ fontSize: 11.5, color: "var(--fg-2)", marginBottom: 10, lineHeight: 1.5 }}>{m.desc}</div>
338
+
339
+ <div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginBottom: 10 }}>
340
+ {m.features.map(f => (
341
+ <span key={f} style={{ fontSize: 10, padding: "2px 6px", borderRadius: 4, background: "var(--bg-3)", color: "var(--fg-2)" }}>{f}</span>
342
+ ))}
343
+ </div>
344
+
345
+ <div className="divider" style={{ margin: "8px 0" }} />
346
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: 11.5 }}>
347
+ <div>
348
+ <span className="mono" style={{ fontWeight: 600, color: "var(--fg-0)" }}>${m.inputPrice.toFixed(3)}</span>
349
+ <span className="muted-2"> in</span>
350
+ {m.outputPrice && <>
351
+ <span className="muted-2"> Β· </span>
352
+ <span className="mono" style={{ fontWeight: 600, color: "var(--fg-0)" }}>${m.outputPrice.toFixed(2)}</span>
353
+ <span className="muted-2"> out</span>
354
+ </>}
355
+ <span className="muted-2"> /1M tokens</span>
356
+ </div>
357
+ <div style={{ display: "flex", gap: 10 }}>
358
+ <span className="mono muted-2" style={{ fontSize: 10 }}>ctx {m.ctx}</span>
359
+ <span className="mono muted-2" style={{ fontSize: 10 }}>{m.latency}</span>
360
+ </div>
361
+ </div>
362
+ </button>
363
+ ))}
364
+ </div>
365
+ </div>
366
+
367
+ {/* Right β€” test panel */}
368
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
369
+ {activeModel ? (
370
+ <>
371
+ <div className="card" style={{ padding: 16 }}>
372
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
373
+ <span style={{ fontSize: 9, padding: "2px 6px", borderRadius: 4, fontFamily: "var(--font-mono)", fontWeight: 700, background: activeModel.badgeC + "22", color: activeModel.badgeC }}>{activeModel.badge}</span>
374
+ <strong style={{ fontSize: 13 }}>{activeModel.name}</strong>
375
+ </div>
376
+ <div style={{ fontSize: 11.5, color: "var(--fg-2)", lineHeight: 1.6, marginBottom: 12 }}>{activeModel.desc}</div>
377
+ <div className="divider" />
378
+ {[
379
+ { label: "Context", value: activeModel.ctx },
380
+ { label: "Input", value: `$${activeModel.inputPrice.toFixed(3)} /1M tokens` },
381
+ { label: "Output", value: activeModel.outputPrice ? `$${activeModel.outputPrice.toFixed(2)} /1M tokens` : "N/A" },
382
+ { label: "Latency", value: activeModel.latency },
383
+ ].map(r => (
384
+ <div key={r.label} className="flex items-center justify-between" style={{ padding: "4px 0", fontSize: 12 }}>
385
+ <span className="muted">{r.label}</span>
386
+ <span className="mono">{r.value}</span>
387
+ </div>
388
+ ))}
389
+ </div>
390
+
391
+ <div className="card" style={{ padding: 16 }}>
392
+ <strong style={{ fontSize: 12, display: "block", marginBottom: 8 }}>Quick test</strong>
393
+ <textarea
394
+ value={testInput}
395
+ onChange={e => setTestInput(e.target.value)}
396
+ rows={3}
397
+ style={{
398
+ width: "100%", resize: "vertical", padding: "8px 10px",
399
+ background: "var(--bg-0)", border: "1px solid var(--border)",
400
+ borderRadius: 7, fontSize: 12, outline: "none", boxSizing: "border-box",
401
+ fontFamily: "var(--font-mono)", color: "var(--fg-0)",
402
+ }}
403
+ />
404
+ <button
405
+ className="btn primary sm"
406
+ style={{ width: "100%", marginTop: 8, opacity: testRunning ? 0.6 : 1 }}
407
+ onClick={runTest}
408
+ disabled={testRunning}
409
+ >
410
+ {testRunning ? <><Icon name="clock" size={12} /> Generating…</> : <><Icon name="play" size={12} /> Run</>}
411
+ </button>
412
+ {(testOutput || testRunning) && (
413
+ <div style={{ marginTop: 10, padding: "10px 12px", background: "var(--bg-0)", border: "1px solid var(--border-subtle)", borderRadius: 7, fontSize: 11.5, lineHeight: 1.65, minHeight: 60, fontFamily: "var(--font-mono)", color: "var(--fg-1)", whiteSpace: "pre-wrap" }}>
414
+ {testOutput || <span className="muted-2">…</span>}
415
+ {testRunning && <span style={{ animation: "pulse 1s infinite", color: "var(--accent)" }}>▍</span>}
416
+ </div>
417
+ )}
418
+ </div>
419
+
420
+ <button className="btn primary" style={{ width: "100%" }} onClick={() => window.toast(`API key created for ${activeModel.name}`)}>
421
+ <Icon name="plus" size={13} /> Create API key
422
+ </button>
423
+ </>
424
+ ) : (
425
+ <div className="card" style={{ padding: 32, textAlign: "center" }}>
426
+ <Icon name="sparkle" size={28} style={{ color: "var(--fg-3)", marginBottom: 12 }} />
427
+ <div style={{ fontSize: 13.5, fontWeight: 500, marginBottom: 6 }}>Select a model</div>
428
+ <div style={{ fontSize: 12, color: "var(--fg-3)", lineHeight: 1.6 }}>Pick any model from the list to see pricing details and run a quick test.</div>
429
+ </div>
430
+ )}
431
+
432
+ <div className="card" style={{ padding: 14 }}>
433
+ <div className="muted-2 mono" style={{ fontSize: 10, letterSpacing: ".1em", textTransform: "uppercase", marginBottom: 10 }}>API usage this month</div>
434
+ {[
435
+ { model: "VaultMind-34B", tokens: "2.4M", cost: "$4.80" },
436
+ { model: "ArcLight-7B", tokens: "18.1M", cost: "$3.62" },
437
+ { model: "SonarText", tokens: "44.2M", cost: "$0.88" },
438
+ ].map(u => (
439
+ <div key={u.model} className="flex items-center justify-between" style={{ fontSize: 11.5, padding: "3px 0" }}>
440
+ <span className="truncate" style={{ flex: 1 }}>{u.model}</span>
441
+ <span className="mono muted-2" style={{ marginRight: 8 }}>{u.tokens}</span>
442
+ <span className="mono" style={{ color: "var(--fg-0)" }}>{u.cost}</span>
443
+ </div>
444
+ ))}
445
+ </div>
446
+ </div>
447
+ </div>
448
+ );
449
+ };
450
+
451
+ // ---- Storage Tab ----
452
+ const StorageTab = () => {
453
+ const [selectedPlan, setSelectedPlan] = React.useState("obj");
454
+ const [sizeGb, setSizeGb] = React.useState(500);
455
+ const plan = STORAGE_PLANS.find(p => p.id === selectedPlan);
456
+ const monthlyCost = (plan.priceGb * sizeGb).toFixed(2);
457
+
458
+ return (
459
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 300px", gap: 20 }}>
460
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
461
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12 }}>
462
+ {STORAGE_PLANS.map(p => (
463
+ <button
464
+ key={p.id}
465
+ onClick={() => setSelectedPlan(p.id)}
466
+ style={{
467
+ textAlign: "left", padding: 16, borderRadius: 12,
468
+ border: `1.5px solid ${selectedPlan === p.id ? "var(--accent)" : "var(--border)"}`,
469
+ background: selectedPlan === p.id ? "var(--accent-soft)" : "var(--bg-1)",
470
+ cursor: "pointer", transition: "border-color .15s, background .15s",
471
+ }}
472
+ >
473
+ <Icon name="database" size={18} style={{ color: selectedPlan === p.id ? "var(--accent)" : "var(--fg-3)", marginBottom: 10 }} />
474
+ <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{p.name}</div>
475
+ <div style={{ fontSize: 20, fontWeight: 700, color: "var(--accent)", fontFamily: "var(--font-mono)", marginBottom: 2 }}>
476
+ ${p.priceGb.toFixed(3)}<span style={{ fontSize: 11, fontWeight: 400, color: "var(--fg-3)" }}>/GB/mo</span>
477
+ </div>
478
+ <div style={{ fontSize: 11, color: "var(--fg-3)", marginBottom: 10 }}>{p.throughput}</div>
479
+ <div className="divider" />
480
+ {p.features.map(f => (
481
+ <div key={f} className="flex items-center gap-6" style={{ padding: "3px 0", fontSize: 11.5 }}>
482
+ <Icon name="check" size={11} style={{ color: "var(--accent)", flexShrink: 0 }} strokeWidth={2.5} />
483
+ <span className="muted">{f}</span>
484
+ </div>
485
+ ))}
486
+ </button>
487
+ ))}
488
+ </div>
489
+
490
+ <div className="card" style={{ padding: 16 }}>
491
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 14 }}>Configure volume</strong>
492
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
493
+ <div>
494
+ <label style={{ fontSize: 11.5, color: "var(--fg-2)", display: "block", marginBottom: 6 }}>Volume name</label>
495
+ <input
496
+ defaultValue="training-data-vol-1"
497
+ style={{ width: "100%", padding: "8px 10px", background: "var(--bg-0)", border: "1px solid var(--border)", borderRadius: 7, fontSize: 12.5, outline: "none", boxSizing: "border-box" }}
498
+ />
499
+ </div>
500
+ <div>
501
+ <label style={{ fontSize: 11.5, color: "var(--fg-2)", display: "block", marginBottom: 6 }}>Region</label>
502
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", background: "var(--bg-0)", border: "1px solid var(--border)", borderRadius: 7 }}>
503
+ <span style={{ fontSize: 12.5, flex: 1 }}>US East (N. Virginia)</span>
504
+ <Icon name="chevron-down" size={13} style={{ color: "var(--fg-3)" }} />
505
+ </div>
506
+ </div>
507
+ </div>
508
+
509
+ <div style={{ marginTop: 14 }}>
510
+ <label style={{ fontSize: 11.5, color: "var(--fg-2)", display: "block", marginBottom: 6 }}>
511
+ Size: <strong className="mono">{sizeGb >= 1024 ? (sizeGb / 1024).toFixed(1) + " TB" : sizeGb + " GB"}</strong>
512
+ </label>
513
+ <input
514
+ type="range" min="10" max="10240" step="10" value={sizeGb}
515
+ onChange={e => setSizeGb(Number(e.target.value))}
516
+ style={{ width: "100%", accentColor: "var(--accent)" }}
517
+ />
518
+ <div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: "var(--fg-3)", marginTop: 2 }}>
519
+ <span>10 GB</span><span>1 TB</span><span>10 TB</span>
520
+ </div>
521
+ </div>
522
+ </div>
523
+ </div>
524
+
525
+ {/* Right */}
526
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
527
+ <div className="card" style={{ padding: 16 }}>
528
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 14 }}>Estimate</strong>
529
+ <div className="divider" />
530
+ {[
531
+ { label: "Plan", value: plan.name },
532
+ { label: "Size", value: sizeGb >= 1024 ? (sizeGb / 1024).toFixed(1) + " TB" : sizeGb + " GB" },
533
+ { label: "IOPS", value: plan.iops },
534
+ { label: "Throughput", value: plan.throughput },
535
+ ].map(r => (
536
+ <div key={r.label} className="flex items-center justify-between" style={{ padding: "5px 0", fontSize: 12 }}>
537
+ <span className="muted">{r.label}</span>
538
+ <span className="mono">{r.value}</span>
539
+ </div>
540
+ ))}
541
+ <div className="divider" />
542
+ <div className="flex items-center justify-between" style={{ padding: "6px 0" }}>
543
+ <span style={{ fontSize: 13 }}>Monthly cost</span>
544
+ <span style={{ fontSize: 22, fontWeight: 700, color: "var(--accent)", fontFamily: "var(--font-mono)" }}>${monthlyCost}</span>
545
+ </div>
546
+ </div>
547
+ <button className="btn primary" style={{ width: "100%" }} onClick={() => window.toast("Volume provisioning started")}>
548
+ <Icon name="database" size={13} /> Create volume
549
+ </button>
550
+ <div className="card" style={{ padding: 14 }}>
551
+ <div className="muted-2 mono" style={{ fontSize: 10, letterSpacing: ".1em", textTransform: "uppercase", marginBottom: 8 }}>Existing volumes</div>
552
+ {[
553
+ { name: "datasets-main", size: "2 TB", type: "Object", used: 68 },
554
+ { name: "checkpoints-v3", size: "500 GB", type: "SSD", used: 42 },
555
+ ].map(v => (
556
+ <div key={v.name} style={{ padding: "7px 0", borderBottom: "1px solid var(--border-subtle)" }}>
557
+ <div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
558
+ <span style={{ fontSize: 12, fontWeight: 500 }}>{v.name}</span>
559
+ <span className="mono muted-2" style={{ fontSize: 10 }}>{v.size} Β· {v.type}</span>
560
+ </div>
561
+ <div style={{ height: 3, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden" }}>
562
+ <div style={{ width: `${v.used}%`, height: "100%", background: v.used > 80 ? "oklch(0.72 0.17 20)" : "var(--accent)" }} />
563
+ </div>
564
+ <span className="mono muted-2" style={{ fontSize: 10 }}>{v.used}% used</span>
565
+ </div>
566
+ ))}
567
+ </div>
568
+ </div>
569
+ </div>
570
+ );
571
+ };
572
+
573
+ // ---- Templates Tab ----
574
+ const TemplatesTab = () => {
575
+ const [deploying, setDeploying] = React.useState(null);
576
+
577
+ const deploy = (t) => {
578
+ setDeploying(t.id);
579
+ setTimeout(() => {
580
+ setDeploying(null);
581
+ window.toast(`Template "${t.name}" cloned β€” studio launching on ${t.gpu}`);
582
+ }, 1500);
583
+ };
584
+
585
+ return (
586
+ <div>
587
+ <div style={{ marginBottom: 16, fontSize: 13, color: "var(--fg-2)" }}>
588
+ One-click environments β€” pre-configured with the right GPU, framework, and starter code.
589
+ </div>
590
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 14 }}>
591
+ {TEMPLATES.map(t => (
592
+ <div
593
+ key={t.id}
594
+ className="card"
595
+ style={{ padding: 18, display: "flex", flexDirection: "column", gap: 10 }}
596
+ >
597
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
598
+ <div style={{
599
+ width: 36, height: 36, borderRadius: 8, background: "var(--accent-soft)",
600
+ display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
601
+ }}>
602
+ <Icon name={t.icon} size={16} style={{ color: "var(--accent)" }} />
603
+ </div>
604
+ <div>
605
+ <div style={{ fontSize: 13, fontWeight: 600 }}>{t.name}</div>
606
+ <div className="mono muted-2" style={{ fontSize: 10.5 }}>{t.gpu}</div>
607
+ </div>
608
+ </div>
609
+ <div style={{ fontSize: 12, color: "var(--fg-2)", lineHeight: 1.55, flex: 1 }}>{t.desc}</div>
610
+ <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
611
+ {t.tags.map(tag => (
612
+ <span key={tag} style={{ fontSize: 10, padding: "2px 7px", borderRadius: 4, background: "var(--bg-3)", color: "var(--fg-2)", fontFamily: "var(--font-mono)" }}>{tag}</span>
613
+ ))}
614
+ </div>
615
+ <button
616
+ className="btn sm"
617
+ style={{ width: "100%", marginTop: 4, opacity: deploying === t.id ? 0.6 : 1 }}
618
+ onClick={() => deploy(t)}
619
+ disabled={deploying === t.id}
620
+ >
621
+ {deploying === t.id
622
+ ? <><Icon name="clock" size={12} /> Cloning…</>
623
+ : <><Icon name="play" size={12} /> Use template</>
624
+ }
625
+ </button>
626
+ </div>
627
+ ))}
628
+ </div>
629
+ </div>
630
+ );
631
+ };
632
+
633
+ // ---- Main view ----
634
+ const AIMarketplaceView = () => {
635
+ const [tab, setTab] = React.useState("studios");
636
+
637
+ const TABS = [
638
+ { id: "studios", label: "Studios", icon: "bolt" },
639
+ { id: "models", label: "Model APIs", icon: "sparkle" },
640
+ { id: "storage", label: "Storage", icon: "database" },
641
+ { id: "templates", label: "Templates", icon: "component" },
642
+ ];
643
+
644
+ return (
645
+ <div className="flex col flex-1" style={{ minWidth: 0 }}>
646
+ <div className="page-header">
647
+ <div className="page-title">
648
+ <Icon name="bolt" size={15} style={{ color: "var(--accent)" }} />
649
+ <span>AI Infrastructure</span>
650
+ <span className="chip mono" style={{ color: "var(--accent)" }}>Meridian</span>
651
+ </div>
652
+ <div className="topbar-spacer" />
653
+ <div className="segmented">
654
+ {TABS.map(t => (
655
+ <button key={t.id} className={tab === t.id ? "on" : ""} onClick={() => setTab(t.id)}>
656
+ <Icon name={t.icon} size={12} style={{ marginRight: 4 }} />
657
+ {t.label}
658
+ </button>
659
+ ))}
660
+ </div>
661
+ <button className="btn ghost sm" onClick={() => window.toast("Docs opened")}>
662
+ <Icon name="docs" size={13} /> Docs
663
+ </button>
664
+ <button className="btn ghost sm" style={{ color: "var(--accent)" }}
665
+ onClick={() => window.openAI(
666
+ `I'm browsing the AI Infrastructure marketplace. Help me choose the right GPU and model for my use case.`,
667
+ "compute",
668
+ { gpus: STUDIO_GPU_TABLE, models: LLM_MODELS.map(m=>({id:m.id,name:m.name,category:m.category,inputPrice:m.inputPrice,outputPrice:m.outputPrice})) }
669
+ )}>
670
+ <Icon name="sparkle" size={13} /> AI Summary
671
+ </button>
672
+ </div>
673
+
674
+ <div className="scroll-y" style={{ flex: 1, padding: 20 }}>
675
+ <div style={{ maxWidth: 1200, margin: "0 auto" }}>
676
+ {tab === "studios" && <StudiosTab />}
677
+ {tab === "models" && <ModelAPIsTab />}
678
+ {tab === "storage" && <StorageTab />}
679
+ {tab === "templates" && <TemplatesTab />}
680
+ </div>
681
+ </div>
682
+ </div>
683
+ );
684
+ };
685
+
686
+ window.AIMarketplaceView = AIMarketplaceView;
views-chat.jsx ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const ReasoningSteps = ({ steps, status }) => {
2
+ const [isExpanded, setIsExpanded] = React.useState(false);
3
+ const bottomRef = React.useRef(null);
4
+ const stepsArray = steps || [];
5
+
6
+ React.useEffect(() => {
7
+ if (isExpanded && bottomRef.current) {
8
+ bottomRef.current.scrollIntoView({ behavior: 'smooth' });
9
+ }
10
+ }, [stepsArray.length, isExpanded]);
11
+
12
+ const getLatestText = () => {
13
+ if (stepsArray.length > 0) {
14
+ const lastStep = stepsArray[stepsArray.length - 1];
15
+ return typeof lastStep === 'object' ? (lastStep.content || lastStep.status || lastStep.thought) : lastStep;
16
+ }
17
+ return status || 'Processing...';
18
+ };
19
+
20
+ return (
21
+ <div className="flex col gap-8 mb-8" style={{ background: 'var(--bg-1)', padding: 12, borderRadius: 8, border: '1px solid var(--border)', cursor: 'pointer' }}
22
+ onClick={() => setIsExpanded(!isExpanded)}>
23
+ <div className="flex items-center justify-between">
24
+ <div className="flex items-center gap-8">
25
+ {status ? <Icon name="bolt" size={12} style={{ color: 'var(--accent)' }} /> : <Icon name="check" size={12} style={{ color: 'var(--status-done)' }} />}
26
+ <span className="mono muted-2" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
27
+ Reasoning Process {stepsArray.length > 0 ? `(${stepsArray.length})` : ''}
28
+ </span>
29
+ </div>
30
+ {stepsArray.length > 0 && (
31
+ <span className="mono muted-2" style={{ fontSize: 10 }}>{isExpanded ? 'Collapse' : 'Expand'}</span>
32
+ )}
33
+ </div>
34
+
35
+ {isExpanded && stepsArray.length > 0 ? (
36
+ <div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
37
+ {stepsArray.map((step, idx) => {
38
+ const stepText = typeof step === 'object' ? (step.content || step.status || step.thought || JSON.stringify(step)) : step;
39
+ return (
40
+ <div key={idx} className="flex gap-8" style={{ fontSize: 11.5, fontFamily: 'var(--font-mono)', color: 'var(--fg-2)', lineHeight: 1.4 }}>
41
+ <span style={{ color: 'var(--fg-3)', width: 16, textAlign: 'right', flexShrink: 0 }}>{idx + 1}</span>
42
+ <span style={{ flex: 1 }}>{stepText}</span>
43
+ </div>
44
+ );
45
+ })}
46
+ <div ref={bottomRef} />
47
+ </div>
48
+ ) : (
49
+ <div className="flex gap-8" style={{ marginTop: 4, fontSize: 11.5, fontFamily: 'var(--font-mono)', color: 'var(--fg-2)' }}>
50
+ <span style={{ color: 'var(--fg-3)', width: 16, textAlign: 'right' }}>{stepsArray.length > 0 ? '>' : ''}</span>
51
+ <span className="truncate" style={{ fontStyle: 'italic' }}>{getLatestText()}</span>
52
+ </div>
53
+ )}
54
+ </div>
55
+ );
56
+ };
57
+
58
+ const ChatView = () => {
59
+ const [messages, setMessages] = React.useState([]);
60
+ const [input, setInput] = React.useState('');
61
+ const [isTyping, setIsTyping] = React.useState(false);
62
+ const [activeSession, setActiveSession] = React.useState(null);
63
+
64
+ const scrollRef = React.useRef(null);
65
+ const token = localStorage.getItem('meridian-token');
66
+
67
+ // Check for initial query if coming from Command Palette
68
+ React.useEffect(() => {
69
+ if (window.chatInitialQuery) {
70
+ const q = window.chatInitialQuery;
71
+ window.chatInitialQuery = null;
72
+ // Small delay so the view is mounted
73
+ setTimeout(() => sendMessage(q), 50);
74
+ }
75
+ }, []);
76
+
77
+ React.useEffect(() => {
78
+ if (scrollRef.current) {
79
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
80
+ }
81
+ }, [messages, isTyping]);
82
+
83
+ const sendMessage = async (text) => {
84
+ if (!text || !text.trim()) return;
85
+ if (isTyping) return;
86
+
87
+ const userMsg = { id: Date.now(), text, sender: 'user', timestamp: new Date() };
88
+ setMessages(prev => [...prev, userMsg]);
89
+ setIsTyping(true);
90
+ const typingId = Date.now() + 1;
91
+
92
+ const thinkingMsg = { id: typingId, text: '', sender: 'ai', timestamp: new Date(), status: 'Thinking...', steps: [] };
93
+ setMessages(prev => [...prev, thinkingMsg]);
94
+
95
+ const liveSteps = [
96
+ 'Parsing query...', 'Searching issues and PRs...',
97
+ 'Cross-referencing sprint data...', 'Analyzing team and roadmap...', 'Composing response...',
98
+ ];
99
+ let stepIdx = 0;
100
+ const stepTimer = setInterval(() => {
101
+ if (stepIdx < liveSteps.length) {
102
+ const step = liveSteps[stepIdx++];
103
+ setMessages(prev => prev.map(msg =>
104
+ msg.id === typingId ? { ...msg, steps: [...(msg.steps || []), step] } : msg
105
+ ));
106
+ } else { clearInterval(stepTimer); }
107
+ }, 320);
108
+
109
+ try {
110
+ const key = window.getAmdUrl ? window.getAmdUrl() : '';
111
+ if (!key) throw new Error('AMD_URL_NOT_SET');
112
+
113
+ const history = messages.slice(-10).map(m => ({
114
+ role: m.sender === 'user' ? 'user' : 'assistant',
115
+ content: m.text
116
+ })).filter(m => m.content);
117
+
118
+ clearInterval(stepTimer);
119
+
120
+ // Stream from AMD
121
+ const res = await fetch(key, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer dummy-key` },
124
+ body: JSON.stringify({
125
+ model: 'llama-3.3-70b-versatile',
126
+ max_tokens: 1024,
127
+ stream: true,
128
+ messages: [
129
+ { role: 'system', content: 'You are VaultMind AI, the intelligent assistant for Meridian β€” a modern project management platform. You help engineering teams with issues, PRs, sprints, roadmap, docs, and compute jobs. Be concise, insightful, and proactive. Use markdown formatting.' },
130
+ ...history,
131
+ { role: 'user', content: text },
132
+ ],
133
+ }),
134
+ });
135
+
136
+ if (!res.ok) {
137
+ const err = await res.json().catch(() => ({}));
138
+ throw new Error(err.error?.message || `AMD HTTP ${res.status}`);
139
+ }
140
+
141
+ // Read stream
142
+ const reader = res.body.getReader();
143
+ const decoder = new TextDecoder();
144
+ let fullText = '';
145
+
146
+ setMessages(prev => prev.map(msg =>
147
+ msg.id === typingId ? { ...msg, status: null, steps: liveSteps, text: '' } : msg
148
+ ));
149
+
150
+ while (true) {
151
+ const { done, value } = await reader.read();
152
+ if (done) break;
153
+ const chunk = decoder.decode(value);
154
+ const lines = chunk.split('\n').filter(l => l.startsWith('data: ') && l !== 'data: [DONE]');
155
+ for (const line of lines) {
156
+ try {
157
+ const data = JSON.parse(line.slice(6));
158
+ const delta = data.choices?.[0]?.delta?.content || '';
159
+ if (delta) {
160
+ fullText += delta;
161
+ setMessages(prev => prev.map(msg =>
162
+ msg.id === typingId ? { ...msg, text: fullText } : msg
163
+ ));
164
+ }
165
+ } catch (_) {}
166
+ }
167
+ }
168
+
169
+ setMessages(prev => prev.map(msg =>
170
+ msg.id === typingId ? { ...msg, text: fullText, status: null, steps: liveSteps } : msg
171
+ ));
172
+
173
+ } catch (error) {
174
+ clearInterval(stepTimer);
175
+ console.error('Chat Error:', error);
176
+ const errText = error.message === 'AMD_URL_NOT_SET'
177
+ ? '⚠ No hay endpoint AMD configurado. AndΓ‘ a **Settings β†’ AI & Integrations** y pegΓ‘ la URL de tu instancia AMD.'
178
+ : `⚠ ${error.message}`;
179
+ setMessages(prev => prev.map(msg =>
180
+ msg.id === typingId ? { ...msg, text: errText, status: null } : msg
181
+ ));
182
+ } finally {
183
+ setIsTyping(false);
184
+ }
185
+ };
186
+
187
+ const handleSend = (e) => {
188
+ e.preventDefault();
189
+ if (!input.trim()) return;
190
+ const text = input;
191
+ setInput('');
192
+ sendMessage(text);
193
+ };
194
+
195
+ const renderMarkdown = (text) => {
196
+ if (!window.marked) return { __html: text };
197
+ return { __html: window.marked.parse(text) };
198
+ };
199
+
200
+ return (
201
+ <div className="flex col flex-1" style={{ minWidth: 0, height: '100%' }}>
202
+ <div className="page-header">
203
+ <div className="page-title">
204
+ <Icon name="sparkle" size={16} style={{ color: 'var(--accent)' }} />
205
+ <span>VaultMind AI</span>
206
+ <span className="chip mono" style={{ fontSize: 10, padding: '2px 7px', color: 'var(--amber)', borderColor: 'var(--amber)', opacity: 0.8 }}>demo</span>
207
+ </div>
208
+ <div className="topbar-spacer" />
209
+ <button className="btn sm ghost" onClick={() => { setMessages([]); setActiveSession(null); }}>
210
+ <Icon name="plus" size={12} /> New Chat
211
+ </button>
212
+ </div>
213
+
214
+ <div className="scroll-y flex col flex-1" ref={scrollRef} style={{ padding: "24px 28px" }}>
215
+ <div style={{ maxWidth: 800, margin: "0 auto", width: '100%', display: 'flex', flexDirection: 'column', gap: 24 }}>
216
+ {messages.length === 0 ? (
217
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginTop: 60 }}>
218
+ <Icon name="sparkle" size={32} style={{ opacity: 0.5, marginBottom: 16, color: 'var(--accent)' }} />
219
+ <div style={{ fontSize: 18, fontWeight: 500, color: 'var(--fg-0)', marginBottom: 6 }}>How can I help you today?</div>
220
+ <div style={{ fontSize: 13, color: 'var(--fg-3)', marginBottom: 32 }}>Ask me about your projects, issues, PRs, or team.</div>
221
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, width: '100%' }}>
222
+ {[
223
+ { icon: 'sprint', text: "What's the status of Iteration 42?" },
224
+ { icon: 'issues', text: "What's blocking the team right now?" },
225
+ { icon: 'pr', text: "Which PRs need attention?" },
226
+ { icon: 'team', text: "Who has capacity for new work?" },
227
+ { icon: 'roadmap', text: "Which milestones are at risk this quarter?" },
228
+ { icon: 'sparkle', text: "Give me today's standup brief" },
229
+ ].map((p, i) => (
230
+ <button key={i} onClick={() => { if (!isTyping) sendMessage(p.text); }}
231
+ style={{ textAlign: 'left', padding: '12px 14px', borderRadius: 10, border: '1px solid var(--border)', background: 'var(--bg-1)', cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: 10, fontSize: 13, color: 'var(--fg-1)', transition: 'border-color 0.15s' }}
232
+ onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent-dim)'}
233
+ onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
234
+ <Icon name={p.icon} size={14} style={{ color: 'var(--fg-3)', marginTop: 1, flexShrink: 0 }} />
235
+ <span>{p.text}</span>
236
+ </button>
237
+ ))}
238
+ </div>
239
+ </div>
240
+ ) : (
241
+ messages.map((msg) => (
242
+ <div key={msg.id} style={{ display: 'flex', gap: 16, flexDirection: msg.sender === 'user' ? 'row-reverse' : 'row' }}>
243
+ <div style={{ width: 32, height: 32, borderRadius: 16, background: msg.sender === 'user' ? 'var(--accent)' : 'var(--bg-2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
244
+ {msg.sender === 'user' ? <Icon name="team" size={16} style={{ color: '#000' }} /> : <Icon name="sparkle" size={16} style={{ color: 'var(--accent)' }} />}
245
+ </div>
246
+ <div style={{ maxWidth: '80%', padding: '12px 16px', borderRadius: 12, background: msg.sender === 'user' ? 'var(--accent-dim)' : 'var(--bg-1)', border: msg.sender === 'user' ? 'none' : '1px solid var(--border)', borderTopRightRadius: msg.sender === 'user' ? 2 : 12, borderTopLeftRadius: msg.sender === 'user' ? 12 : 2 }}>
247
+
248
+ {(msg.steps?.length > 0 || msg.status) && (
249
+ <ReasoningSteps steps={msg.steps || []} status={msg.status} />
250
+ )}
251
+
252
+ {!msg.status && msg.text && (
253
+ <div
254
+ className="markdown-body"
255
+ style={{ fontSize: 14, lineHeight: 1.5, color: msg.sender === 'user' ? 'var(--fg-0)' : 'var(--fg-1)' }}
256
+ dangerouslySetInnerHTML={renderMarkdown(msg.text)}
257
+ />
258
+ )}
259
+ </div>
260
+ </div>
261
+ ))
262
+ )}
263
+ </div>
264
+ </div>
265
+
266
+ <div style={{ padding: "16px 28px", borderTop: "1px solid var(--border)", background: "var(--bg-0)" }}>
267
+ <div style={{ maxWidth: 800, margin: "0 auto", position: 'relative' }}>
268
+ <form onSubmit={handleSend} style={{ display: 'flex', gap: 8 }}>
269
+ <input
270
+ type="text"
271
+ value={input}
272
+ onChange={(e) => setInput(e.target.value)}
273
+ placeholder="Ask VaultMind AI..."
274
+ style={{ flex: 1, padding: "12px 16px", borderRadius: 8, border: "1px solid var(--border)", background: "var(--bg-1)", color: "var(--fg-0)", outline: "none", fontSize: 14 }}
275
+ />
276
+ <button
277
+ type="submit"
278
+ disabled={!input.trim() || isTyping}
279
+ className="btn primary"
280
+ style={{ padding: "0 20px", height: 43, opacity: (!input.trim() || isTyping) ? 0.5 : 1 }}
281
+ >
282
+ <Icon name="arrow-up" size={16} />
283
+ </button>
284
+ </form>
285
+ <div style={{ textAlign: 'center', fontSize: 11, color: 'var(--fg-3)', marginTop: 8 }}>
286
+ VaultMind AI can make mistakes. Consider verifying important information.
287
+ </div>
288
+ </div>
289
+ </div>
290
+ </div>
291
+ );
292
+ };
293
+
294
+ window.ChatView = ChatView;
views-code.jsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const CodeEditorView = () => {
2
+ const iframeRef = React.useRef(null);
3
+
4
+ const sendKey = () => {
5
+ const url = window.getAmdUrl ? window.getAmdUrl() : '';
6
+ if (url && iframeRef.current?.contentWindow) {
7
+ iframeRef.current.contentWindow.postMessage({ type: 'AMD_URL', url }, '*');
8
+ }
9
+ };
10
+
11
+ return (
12
+ <iframe
13
+ ref={iframeRef}
14
+ src="vscode.html"
15
+ style={{ width: "100%", height: "100%", border: "none", display: "block" }}
16
+ title="VS Code β€” meridian-platform"
17
+ onLoad={sendKey}
18
+ />
19
+ );
20
+ };
21
+
22
+ window.CodeEditorView = CodeEditorView;
views-compute.jsx ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Meridian GPU Compute view
2
+
3
+ const GPUS = [
4
+ { id: "mi300x", name: "MI300X", vram: "192 GB HBM3", tflops: "10496 FP16", gen: "CDNA 3", tier: "flagship", priceHr: 4.89 },
5
+ { id: "mi300a", name: "MI300A", vram: "128 GB HBM3", tflops: "3800 FP16", gen: "CDNA 3", tier: "flagship", priceHr: 3.49 },
6
+ { id: "mi250x-128", name: "MI250X 128 GB", vram: "128 GB HBM2e", tflops: "383 FP16", gen: "CDNA 2", tier: "pro", priceHr: 2.21 },
7
+ { id: "mi210-64", name: "MI210 64 GB", vram: "64 GB HBM2e", tflops: "181 FP16", gen: "CDNA 2", tier: "pro", priceHr: 1.49 },
8
+ { id: "v620", name: "Radeon PRO V620", vram: "32 GB GDDR6", tflops: "105 FP16", gen: "RDNA 2", tier: "pro", priceHr: 1.89 },
9
+ { id: "w7900", name: "Radeon PRO W7900", vram: "48 GB GDDR6", tflops: "123 FP16", gen: "RDNA 3", tier: "standard", priceHr: 0.79 },
10
+ { id: "w7800", name: "Radeon PRO W7800", vram: "32 GB GDDR6", tflops: "89 FP16", gen: "RDNA 3", tier: "standard", priceHr: 0.59 },
11
+ { id: "v520", name: "Radeon PRO V520", vram: "8 GB HBM2", tflops: "18 FP16", gen: "RDNA", tier: "entry", priceHr: 0.29 },
12
+ ];
13
+
14
+ const REGIONS = [
15
+ { id: "us-east-1", label: "US East (N. Virginia)", ping: 12, cloud: "aws" },
16
+ { id: "us-west-2", label: "US West (Oregon)", ping: 38, cloud: "aws" },
17
+ { id: "eu-west-1", label: "EU West (Ireland)", ping: 91, cloud: "aws" },
18
+ { id: "ap-southeast-1", label: "Asia Pacific (Singapore)", ping: 174, cloud: "aws" },
19
+ { id: "core-dal", name: "Dallas TX", label: "Dallas TX", ping: 21, cloud: "coreweave" },
20
+ { id: "core-las", label: "Las Vegas NV", ping: 44, cloud: "coreweave" },
21
+ { id: "lambda-slc", label: "Salt Lake City UT", ping: 57, cloud: "lambda" },
22
+ { id: "lambda-atx", label: "Austin TX", ping: 29, cloud: "lambda" },
23
+ ];
24
+
25
+ const FRAMEWORKS = ["PyTorch 2.3", "JAX 0.4", "TensorFlow 2.16", "Custom image"];
26
+
27
+ const INSTANCE_SIZES = [
28
+ { id: "1x", label: "1Γ— GPU", multiplier: 1 },
29
+ { id: "4x", label: "4Γ— GPU", multiplier: 4 },
30
+ { id: "8x", label: "8Γ— GPU", multiplier: 8 },
31
+ { id: "16x", label: "16Γ— GPU", multiplier: 16 },
32
+ ];
33
+
34
+ const MOCK_JOBS = [
35
+ { id: "job-9af2", name: "llama3-finetune-v4", gpu: "MI250X 128 GB", gpuCount: 4, status: "running", started: "2h 14m", cost: 13.19, region: "US East", progress: 62 },
36
+ { id: "job-3bc1", name: "stable-diffusion-xl-eval", gpu: "Radeon PRO W7900", gpuCount: 1, status: "running", started: "41m", cost: 0.54, region: "Dallas TX", progress: 38 },
37
+ { id: "job-c77e", name: "embedding-batch-1M", gpu: "Radeon PRO W7800", gpuCount: 2, status: "done", started: "5h ago", cost: 5.90, region: "US West", progress: 100 },
38
+ { id: "job-12d0", name: "whisper-large-bench", gpu: "Radeon PRO V520", gpuCount: 1, status: "queued", started: "β€”", cost: 0, region: "EU West", progress: 0 },
39
+ ];
40
+
41
+ const TIER_COLORS = {
42
+ flagship: "var(--accent)",
43
+ pro: "oklch(0.72 0.18 300)",
44
+ standard: "oklch(0.78 0.13 220)",
45
+ entry: "var(--fg-3)",
46
+ };
47
+
48
+ const CLOUD_BADGE = { aws: "AWS", coreweave: "CoreWeave", lambda: "Lambda" };
49
+ const CLOUD_COLOR = { aws: "oklch(0.80 0.14 75)", coreweave: "oklch(0.78 0.13 220)", lambda: "oklch(0.72 0.18 300)" };
50
+
51
+ const COMPUTE_STATUS_META = {
52
+ queued: { label: "Queued", color: "var(--fg-3)" },
53
+ running: { label: "Running", color: "var(--accent)" },
54
+ done: { label: "Completed", color: "var(--status-done)" },
55
+ failed: { label: "Failed", color: "var(--rose)" },
56
+ canceled: { label: "Canceled", color: "var(--fg-3)" }
57
+ };
58
+
59
+ const ComputeView = () => {
60
+ const [tab, setTab] = React.useState("launch");
61
+ const [selectedGpu, setSelectedGpu] = React.useState("mi250x-128");
62
+ const [selectedRegion, setSelectedRegion] = React.useState("us-east-1");
63
+ const [selectedSize, setSelectedSize] = React.useState("1x");
64
+ const [selectedFw, setSelectedFw] = React.useState(FRAMEWORKS[0]);
65
+ const [gpuFilter, setGpuFilter] = React.useState("all");
66
+ const [jobName, setJobName] = React.useState("my-training-run");
67
+ const [hours, setHours] = React.useState(4);
68
+ const [launching, setLaunching] = React.useState(false);
69
+ const [jobs, setJobs] = React.useState(MOCK_JOBS);
70
+
71
+ const gpu = GPUS.find(g => g.id === selectedGpu) || GPUS[2];
72
+ const size = INSTANCE_SIZES.find(s => s.id === selectedSize);
73
+ const region = REGIONS.find(r => r.id === selectedRegion);
74
+ const totalHr = gpu.priceHr * size.multiplier;
75
+ const estCost = (totalHr * hours).toFixed(2);
76
+
77
+ const filteredGpus = gpuFilter === "all" ? GPUS : GPUS.filter(g => g.tier === gpuFilter);
78
+
79
+ const launch = () => {
80
+ if (!jobName.trim()) { window.toast("Give the job a name"); return; }
81
+ setLaunching(true);
82
+ setTimeout(() => {
83
+ const newJob = {
84
+ id: "job-" + Math.random().toString(36).slice(2, 6),
85
+ name: jobName,
86
+ gpu: gpu.name,
87
+ gpuCount: size.multiplier,
88
+ status: "running",
89
+ started: "just now",
90
+ cost: 0,
91
+ region: region?.label?.split("(")[0].trim() || "US East",
92
+ progress: 0,
93
+ };
94
+ setJobs(j => [newJob, ...j]);
95
+ setLaunching(false);
96
+ setTab("jobs");
97
+ window.toast(`Instance "${jobName}" launched on ${gpu.name} Γ—${size.multiplier}`);
98
+ }, 1800);
99
+ };
100
+
101
+ return (
102
+ <div className="flex col flex-1" style={{ minWidth: 0 }}>
103
+ {/* Page header */}
104
+ <div className="page-header">
105
+ <div className="page-title">
106
+ <Icon name="code" size={15} style={{ color: "var(--accent)" }} />
107
+ <span>Compute</span>
108
+ <span className="chip mono" style={{ color: "var(--accent)" }}>Meridian Compute</span>
109
+ </div>
110
+ <div className="topbar-spacer" />
111
+ <div className="segmented">
112
+ {[
113
+ { id: "launch", label: "Launch" },
114
+ { id: "jobs", label: `Jobs (${jobs.filter(j => j.status === "running").length} active)` },
115
+ { id: "quota", label: "Quota" },
116
+ ].map(t => (
117
+ <button key={t.id} className={tab === t.id ? "on" : ""} onClick={() => setTab(t.id)}>{t.label}</button>
118
+ ))}
119
+ </div>
120
+ <button className="btn ghost sm" onClick={() => window.toast("Docs opened")}><Icon name="docs" size={13} /> Docs</button>
121
+ <button className="btn ghost sm" style={{ color: "var(--accent)" }}
122
+ onClick={() => window.openAI(
123
+ `Analyze my compute usage. ${jobs.filter(j=>j.status==='running').length} jobs running.`,
124
+ "compute",
125
+ { jobs, quota: QUOTA_DATA }
126
+ )}>
127
+ <Icon name="sparkle" size={13} /> AI Summary
128
+ </button>
129
+ </div>
130
+
131
+ <div className="scroll-y" style={{ flex: 1, padding: 20 }}>
132
+ <div style={{ maxWidth: 1200, margin: "0 auto" }}>
133
+
134
+ {/* ===== LAUNCH TAB ===== */}
135
+ {tab === "launch" && (
136
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 320px", gap: 16 }}>
137
+ {/* Left column */}
138
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
139
+
140
+ {/* GPU Picker */}
141
+ <div className="card" style={{ padding: 16 }}>
142
+ <div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
143
+ <strong style={{ fontSize: 13 }}>Select GPU</strong>
144
+ <div className="segmented">
145
+ {["all", "flagship", "pro", "standard", "entry"].map(f => (
146
+ <button key={f} className={gpuFilter === f ? "on" : ""} onClick={() => setGpuFilter(f)} style={{ textTransform: "capitalize" }}>{f}</button>
147
+ ))}
148
+ </div>
149
+ </div>
150
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8 }}>
151
+ {filteredGpus.map(g => (
152
+ <button
153
+ key={g.id}
154
+ onClick={() => setSelectedGpu(g.id)}
155
+ style={{
156
+ padding: "12px 10px",
157
+ border: `1.5px solid ${selectedGpu === g.id ? "var(--accent)" : "var(--border)"}`,
158
+ borderRadius: 10,
159
+ background: selectedGpu === g.id ? "var(--accent-soft)" : "var(--bg-1)",
160
+ cursor: "pointer",
161
+ textAlign: "left",
162
+ transition: "border-color .15s, background .15s",
163
+ }}
164
+ >
165
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6 }}>
166
+ <span style={{ fontSize: 12.5, fontWeight: 600 }}>{g.name}</span>
167
+ <span style={{
168
+ fontSize: 10, padding: "1px 5px", borderRadius: 4,
169
+ background: TIER_COLORS[g.tier] + "22",
170
+ color: TIER_COLORS[g.tier],
171
+ fontFamily: "var(--font-mono)", fontWeight: 500,
172
+ }}>{g.tier}</span>
173
+ </div>
174
+ <div className="muted" style={{ fontSize: 10.5, marginBottom: 2 }}>{g.vram}</div>
175
+ <div className="muted-2 mono" style={{ fontSize: 10 }}>{g.tflops}</div>
176
+ <div style={{ marginTop: 8, fontSize: 12, fontWeight: 600, color: "var(--accent)" }}>
177
+ ${g.priceHr.toFixed(2)}<span className="muted-2 mono" style={{ fontSize: 10, fontWeight: 400 }}>/hr</span>
178
+ </div>
179
+ </button>
180
+ ))}
181
+ </div>
182
+ </div>
183
+
184
+ {/* GPU count + instance size */}
185
+ <div className="card" style={{ padding: 16 }}>
186
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 12 }}>Instance size</strong>
187
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8 }}>
188
+ {INSTANCE_SIZES.map(s => (
189
+ <button
190
+ key={s.id}
191
+ onClick={() => setSelectedSize(s.id)}
192
+ style={{
193
+ padding: "10px 12px",
194
+ border: `1.5px solid ${selectedSize === s.id ? "var(--accent)" : "var(--border)"}`,
195
+ borderRadius: 8,
196
+ background: selectedSize === s.id ? "var(--accent-soft)" : "var(--bg-1)",
197
+ cursor: "pointer",
198
+ textAlign: "center",
199
+ }}
200
+ >
201
+ <div style={{ fontSize: 13, fontWeight: 600 }}>{s.label}</div>
202
+ <div className="mono muted-2" style={{ fontSize: 10.5, marginTop: 3 }}>
203
+ ${(gpu.priceHr * s.multiplier).toFixed(2)}/hr
204
+ </div>
205
+ </button>
206
+ ))}
207
+ </div>
208
+ </div>
209
+
210
+ {/* Region */}
211
+ <div className="card" style={{ padding: 16 }}>
212
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 12 }}>Region &amp; cloud provider</strong>
213
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 }}>
214
+ {REGIONS.map(r => (
215
+ <button
216
+ key={r.id}
217
+ onClick={() => setSelectedRegion(r.id)}
218
+ style={{
219
+ padding: "10px 12px",
220
+ border: `1.5px solid ${selectedRegion === r.id ? "var(--accent)" : "var(--border)"}`,
221
+ borderRadius: 8,
222
+ background: selectedRegion === r.id ? "var(--accent-soft)" : "var(--bg-1)",
223
+ cursor: "pointer",
224
+ textAlign: "left",
225
+ display: "flex",
226
+ alignItems: "center",
227
+ gap: 8,
228
+ }}
229
+ >
230
+ <span style={{
231
+ fontSize: 9.5, padding: "1px 5px", borderRadius: 4, flexShrink: 0,
232
+ background: CLOUD_COLOR[r.cloud] + "22",
233
+ color: CLOUD_COLOR[r.cloud],
234
+ fontFamily: "var(--font-mono)", fontWeight: 600, textTransform: "uppercase",
235
+ }}>{CLOUD_BADGE[r.cloud]}</span>
236
+ <span style={{ flex: 1, fontSize: 12 }} className="truncate">{r.label}</span>
237
+ <span className="mono muted-2" style={{ fontSize: 10.5 }}>{r.ping}ms</span>
238
+ </button>
239
+ ))}
240
+ </div>
241
+ </div>
242
+
243
+ {/* Framework */}
244
+ <div className="card" style={{ padding: 16 }}>
245
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 12 }}>Runtime environment</strong>
246
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
247
+ {FRAMEWORKS.map(fw => (
248
+ <button
249
+ key={fw}
250
+ onClick={() => setSelectedFw(fw)}
251
+ style={{
252
+ padding: "7px 14px",
253
+ border: `1.5px solid ${selectedFw === fw ? "var(--accent)" : "var(--border)"}`,
254
+ borderRadius: 20,
255
+ background: selectedFw === fw ? "var(--accent-soft)" : "var(--bg-1)",
256
+ cursor: "pointer",
257
+ fontSize: 12.5,
258
+ color: selectedFw === fw ? "var(--accent)" : "var(--fg-1)",
259
+ }}
260
+ >{fw}</button>
261
+ ))}
262
+ </div>
263
+ </div>
264
+ </div>
265
+
266
+ {/* Right column β€” cost summary + launch */}
267
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
268
+ {/* Job name */}
269
+ <div className="card" style={{ padding: 16 }}>
270
+ <label style={{ fontSize: 12, color: "var(--fg-2)", display: "block", marginBottom: 6 }}>Job name</label>
271
+ <input
272
+ value={jobName}
273
+ onChange={e => setJobName(e.target.value)}
274
+ placeholder="my-training-run"
275
+ style={{
276
+ width: "100%", padding: "8px 10px", background: "var(--bg-0)",
277
+ border: "1px solid var(--border)", borderRadius: 7, outline: "none",
278
+ fontSize: 13, boxSizing: "border-box",
279
+ }}
280
+ />
281
+ </div>
282
+
283
+ {/* Cost estimate */}
284
+ <div className="card" style={{ padding: 16 }}>
285
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 14 }}>Estimated cost</strong>
286
+
287
+ <div className="divider" />
288
+
289
+ <div className="flex items-center justify-between" style={{ fontSize: 12.5, padding: "6px 0" }}>
290
+ <span className="muted">GPU</span>
291
+ <span className="mono">{gpu.name} Γ—{size.multiplier}</span>
292
+ </div>
293
+ <div className="flex items-center justify-between" style={{ fontSize: 12.5, padding: "6px 0" }}>
294
+ <span className="muted">Rate</span>
295
+ <span className="mono">${totalHr.toFixed(2)}/hr</span>
296
+ </div>
297
+ <div className="flex items-center justify-between" style={{ fontSize: 12.5, padding: "6px 0" }}>
298
+ <span className="muted">Region</span>
299
+ <span className="mono">{region?.label?.split(" (")[0]}</span>
300
+ </div>
301
+
302
+ <div className="divider" />
303
+
304
+ <label style={{ fontSize: 11.5, color: "var(--fg-2)", display: "block", marginBottom: 6 }}>
305
+ Max duration: <strong className="mono">{hours}h</strong>
306
+ </label>
307
+ <input
308
+ type="range" min="1" max="72" value={hours}
309
+ onChange={e => setHours(Number(e.target.value))}
310
+ style={{ width: "100%", accentColor: "var(--accent)", marginBottom: 12 }}
311
+ />
312
+
313
+ <div className="divider" />
314
+
315
+ <div className="flex items-center justify-between" style={{ padding: "8px 0" }}>
316
+ <span style={{ fontSize: 13 }}>Estimated total</span>
317
+ <span style={{ fontSize: 22, fontWeight: 700, color: "var(--accent)", fontFamily: "var(--font-mono)" }}>
318
+ ${estCost}
319
+ </span>
320
+ </div>
321
+ <div className="muted-2 mono" style={{ fontSize: 10.5 }}>Billed per second. Stops at ${estCost} unless extended.</div>
322
+ </div>
323
+
324
+ {/* Launch button */}
325
+ <button
326
+ className="btn primary"
327
+ style={{ width: "100%", padding: "12px", fontSize: 14, fontWeight: 600, opacity: launching ? 0.6 : 1 }}
328
+ onClick={launch}
329
+ disabled={launching}
330
+ >
331
+ {launching
332
+ ? <><Icon name="clock" size={14} /> Provisioning…</>
333
+ : <><Icon name="sprint" size={14} /> Launch instance</>
334
+ }
335
+ </button>
336
+
337
+ {/* Quick stats */}
338
+ <div className="card" style={{ padding: 14 }}>
339
+ <div className="muted-2 mono" style={{ fontSize: 10, letterSpacing: ".1em", textTransform: "uppercase", marginBottom: 10 }}>Cluster availability</div>
340
+ {[
341
+ { label: "MI300X", avail: 2 },
342
+ { label: "MI250X 128G", avail: 8 },
343
+ { label: "W7900", avail: 14 },
344
+ ].map(a => (
345
+ <div key={a.label} className="flex items-center justify-between" style={{ fontSize: 12, padding: "4px 0" }}>
346
+ <span className="mono">{a.label}</span>
347
+ <span style={{ color: a.avail > 5 ? "var(--accent)" : a.avail > 0 ? "oklch(0.80 0.14 75)" : "oklch(0.72 0.17 20)" }}>
348
+ {a.avail} nodes free
349
+ </span>
350
+ </div>
351
+ ))}
352
+ </div>
353
+ </div>
354
+ </div>
355
+ )}
356
+
357
+ {/* ===== JOBS TAB ===== */}
358
+ {tab === "jobs" && (
359
+ <div>
360
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12, marginBottom: 16 }}>
361
+ {[
362
+ { label: "Running", value: jobs.filter(j => j.status === "running").length, color: "var(--accent)" },
363
+ { label: "Total today", value: jobs.length, color: "var(--fg-1)" },
364
+ { label: "Spend today", value: "$" + jobs.reduce((s, j) => s + j.cost, 0).toFixed(2), color: "oklch(0.72 0.18 300)" },
365
+ ].map(s => (
366
+ <div key={s.label} className="card" style={{ padding: 14 }}>
367
+ <div className="muted" style={{ fontSize: 11.5, marginBottom: 4 }}>{s.label}</div>
368
+ <div style={{ fontSize: 26, fontWeight: 700, color: s.color, fontFamily: "var(--font-mono)" }}>{s.value}</div>
369
+ </div>
370
+ ))}
371
+ </div>
372
+
373
+ <div className="card" style={{ padding: 0, overflow: "hidden" }}>
374
+ <div className="flex items-center" style={{ padding: "10px 16px", borderBottom: "1px solid var(--border)", fontSize: 11, color: "var(--fg-3)", textTransform: "uppercase", letterSpacing: ".08em", fontWeight: 500 }}>
375
+ <span style={{ width: 130 }}>Job</span>
376
+ <span style={{ flex: 1 }}>Name</span>
377
+ <span style={{ width: 160 }}>GPU</span>
378
+ <span style={{ width: 100 }}>Region</span>
379
+ <span style={{ width: 80, textAlign: "right" }}>Runtime</span>
380
+ <span style={{ width: 80, textAlign: "right" }}>Cost</span>
381
+ <span style={{ width: 110 }}>Progress</span>
382
+ <span style={{ width: 70 }}></span>
383
+ </div>
384
+ {jobs.map(j => {
385
+ const sm = COMPUTE_STATUS_META[j.status] || COMPUTE_STATUS_META.queued;
386
+ return (
387
+ <div key={j.id} className="row" style={{ display: "flex", alignItems: "center" }}>
388
+ <span className="mono muted-2" style={{ fontSize: 11, width: 130, flexShrink: 0 }}>{j.id}</span>
389
+ <span style={{ flex: 1, fontSize: 13, fontWeight: 500 }} className="truncate">{j.name}</span>
390
+ <span style={{ width: 160, fontSize: 12 }} className="muted">{j.gpu} Γ—{j.gpuCount}</span>
391
+ <span style={{ width: 100, fontSize: 11.5 }} className="muted">{j.region}</span>
392
+ <span className="mono" style={{ width: 80, fontSize: 11.5, textAlign: "right", flexShrink: 0 }}>{j.started}</span>
393
+ <span className="mono" style={{ width: 80, fontSize: 11.5, textAlign: "right", flexShrink: 0, color: j.cost > 0 ? "var(--fg-0)" : "var(--fg-3)" }}>
394
+ {j.cost > 0 ? "$" + j.cost.toFixed(2) : "β€”"}
395
+ </span>
396
+ <span style={{ width: 110, flexShrink: 0, padding: "0 8px" }}>
397
+ <div style={{ height: 4, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden" }}>
398
+ <div style={{ width: `${j.progress}%`, height: "100%", background: sm.color, transition: "width .3s" }} />
399
+ </div>
400
+ <span className="mono muted-2" style={{ fontSize: 10 }}>{j.progress}%</span>
401
+ </span>
402
+ <span style={{ width: 70, flexShrink: 0 }}>
403
+ <span className="chip" style={{ color: sm.color, fontSize: 10.5 }}>
404
+ <span className="d" style={{ background: sm.color }} />{sm.label}
405
+ </span>
406
+ </span>
407
+ </div>
408
+ );
409
+ })}
410
+ </div>
411
+ </div>
412
+ )}
413
+
414
+ {/* ===== QUOTA TAB ===== */}
415
+ {tab === "quota" && (
416
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
417
+ <div className="card" style={{ padding: 16 }}>
418
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 14 }}>GPU quota</strong>
419
+ {[
420
+ { label: "MI300X", used: 0, total: 8 },
421
+ { label: "MI300A", used: 0, total: 8 },
422
+ { label: "MI250X 128 GB", used: 4, total: 16 },
423
+ { label: "MI210 64 GB", used: 0, total: 16 },
424
+ { label: "Radeon PRO V620", used: 0, total: 16 },
425
+ { label: "Radeon PRO W7900", used: 1, total: 32 },
426
+ { label: "Radeon PRO W7800", used: 2, total: 32 },
427
+ { label: "Radeon PRO V520", used: 0, total: 64 },
428
+ ].map(q => (
429
+ <div key={q.label} style={{ padding: "8px 0", borderBottom: "1px solid var(--border-subtle)" }}>
430
+ <div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
431
+ <span style={{ fontSize: 12.5 }}>{q.label}</span>
432
+ <span className="mono" style={{ fontSize: 11 }}>{q.used}/{q.total}</span>
433
+ </div>
434
+ <div style={{ height: 4, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden" }}>
435
+ <div style={{ width: `${(q.used / q.total) * 100}%`, height: "100%", background: q.used / q.total > 0.8 ? "oklch(0.72 0.17 20)" : "var(--accent)" }} />
436
+ </div>
437
+ </div>
438
+ ))}
439
+ </div>
440
+
441
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
442
+ <div className="card" style={{ padding: 16 }}>
443
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 14 }}>Spending limits</strong>
444
+ {[
445
+ { label: "Daily limit", used: 14.63, total: 200 },
446
+ { label: "Monthly limit", used: 89.21, total: 2000 },
447
+ ].map(s => (
448
+ <div key={s.label} style={{ marginBottom: 14 }}>
449
+ <div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
450
+ <span style={{ fontSize: 12.5 }}>{s.label}</span>
451
+ <span className="mono" style={{ fontSize: 11 }}>${s.used.toFixed(2)} / ${s.total}</span>
452
+ </div>
453
+ <div style={{ height: 5, background: "var(--bg-3)", borderRadius: 3, overflow: "hidden" }}>
454
+ <div style={{ width: `${(s.used / s.total) * 100}%`, height: "100%", background: "var(--accent)" }} />
455
+ </div>
456
+ </div>
457
+ ))}
458
+ <button className="btn ghost sm" style={{ marginTop: 4 }} onClick={() => window.toast("Limit editor opened")}><Icon name="settings" size={13} /> Edit limits</button>
459
+ </div>
460
+
461
+ <div className="card" style={{ padding: 16 }}>
462
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 14 }}>Plan</strong>
463
+ <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
464
+ <span style={{ fontSize: 20 }}>⚑</span>
465
+ <div>
466
+ <div style={{ fontSize: 14, fontWeight: 600 }}>Meridian Pro</div>
467
+ <div className="muted" style={{ fontSize: 11.5 }}>Renews Jun 1, 2026</div>
468
+ </div>
469
+ <div style={{ flex: 1 }} />
470
+ <span className="chip" style={{ color: "var(--accent)" }}>Active</span>
471
+ </div>
472
+ <div className="divider" />
473
+ {[
474
+ "Priority queue access",
475
+ "Up to 8Γ— MI300X per job",
476
+ "Spot instance discounts",
477
+ "24/7 cluster support",
478
+ ].map((f, i) => (
479
+ <div key={i} className="flex items-center gap-8" style={{ padding: "5px 0", fontSize: 12.5 }}>
480
+ <Icon name="check" size={13} style={{ color: "var(--accent)", flexShrink: 0 }} strokeWidth={2.5} />
481
+ <span>{f}</span>
482
+ </div>
483
+ ))}
484
+ <button className="btn sm primary" style={{ width: "100%", marginTop: 12 }} onClick={() => window.toast("Upgrade flow opened")}>Upgrade to Enterprise</button>
485
+ </div>
486
+ </div>
487
+ </div>
488
+ )}
489
+ </div>
490
+ </div>
491
+ </div>
492
+ );
493
+ };
494
+
495
+ window.ComputeView = ComputeView;
views-detail.jsx ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Issue detail + Docs + Roadmap + Sprints + PRs + Team + Settings
2
+
3
+ // ==== ISSUE DETAIL ====
4
+ const IssueDetail = ({ issueId, setView }) => {
5
+ const [issue, setIssue] = React.useState(ISSUES.find(i => i.id === issueId) || ISSUES[0]);
6
+ const proj = PROJECTS.find(p => p.id === issue.project);
7
+ const assignees = issue.assignees.map(id => PEOPLE.find(p => p.id === id));
8
+ const [activeTab, setActiveTab] = React.useState("comments");
9
+
10
+ React.useEffect(() => {
11
+ const load = () => window.apiFetch('GET', `/api/issues/${issueId}`).then(setIssue).catch(() => {});
12
+ load();
13
+ document.addEventListener('meridian:refresh', load);
14
+ return () => document.removeEventListener('meridian:refresh', load);
15
+ }, [issueId]);
16
+
17
+ const activity = [
18
+ { type: "create", user: "u1", time: "Apr 02", text: "opened this issue" },
19
+ { type: "status", user: "u1", time: "Apr 02", text: "moved from backlog to todo" },
20
+ { type: "assign", user: "u5", time: "Apr 07", text: "assigned Rohan Mehta and Kenji Ito" },
21
+ { type: "comment", user: "u2", time: "Apr 09", text: "Baseline profiling below β€” main hot path is composite hit-testing on every pointermove. Worth exploring tile-level caches before we touch the renderer.", code: true },
22
+ { type: "status", user: "u2", time: "Apr 09", text: "moved to in progress" },
23
+ { type: "branch", user: "u2", time: "Apr 09", text: "linked branch perf/canvas-pipeline" },
24
+ { type: "comment", user: "u3", time: "Apr 12", text: "Quick question on the tile size β€” are we going with 256px or adaptive? I worry about memory pressure on large documents.", code: false },
25
+ { type: "comment", user: "u2", time: "Apr 12", text: "Adaptive: base 256, halves if a tile's point count > 2k. Prototyped in the branch." },
26
+ { type: "pr", user: "u2", time: "Apr 14", text: "opened pull request #2341" },
27
+ { type: "comment", user: "u1", time: "12m", text: "@Kenji β€” can you weigh in on the tile-size heuristic? I want to make sure it plays well with the presence system.", highlight: true },
28
+ ];
29
+
30
+ const related = ISSUES.filter(i => i.project === issue.project && i.id !== issue.id).slice(0, 4);
31
+
32
+ return (
33
+ <div className="flex flex-1" style={{ minWidth: 0 }}>
34
+ <div className="flex col flex-1" style={{ minWidth: 0, borderRight: "1px solid var(--border)" }}>
35
+ {/* Sub-header */}
36
+ <div className="page-header">
37
+ <button className="icon-btn" onClick={() => setView("issues")}><Icon name="chevron-left" size={16} /></button>
38
+ <span className="mono muted" style={{ fontSize: 12 }}>{issue.id}</span>
39
+ <div className="status progress flex items-center gap-6" style={{ fontSize: 11.5 }}>
40
+ <span className="s-dot" />In progress
41
+ </div>
42
+ <div className="topbar-spacer" />
43
+ <button className="btn ghost sm" onClick={() => window.copyLink(issue.id)}><Icon name="link" size={13} /> Copy link</button>
44
+ <button className="btn ghost sm" onClick={() => window.openAttach()}><Icon name="attach" size={13} /></button>
45
+ <button className="btn sm" onClick={() => window.openPicker({ title: "Issue actions", options: [
46
+ { value: "duplicate", label: "Duplicate", icon: "plus" },
47
+ { value: "subscribe", label: "Subscribe", icon: "bell" },
48
+ { value: "archive", label: "Archive", icon: "x" },
49
+ { value: "delete", label: "Delete", icon: "x" },
50
+ ], onChoose: (o) => window.toast(o.label) })}><Icon name="more" size={13} /></button>
51
+ </div>
52
+
53
+ <div className="scroll-y issue-spotlight" style={{ flex: 1, padding: "24px 32px 48px", maxWidth: 820, width: "100%", margin: "0 auto" }}>
54
+ <h1 className="editorial hero-title" style={{ fontSize: 28, fontWeight: 400, letterSpacing: "-0.015em", margin: "0 0 8px", lineHeight: 1.2 }}>
55
+ {issue.title}
56
+ </h1>
57
+ <div className="flex items-center gap-8 muted" style={{ fontSize: 12, marginBottom: 24 }}>
58
+ <Avatar user={PEOPLE.find(p => p.id === "u1")} size="xs" />
59
+ <strong style={{ color: "var(--fg-1)" }}>Amara</strong>
60
+ <span>opened</span>
61
+ <span className="mono">{issue.id}</span>
62
+ <span>on {issue.created}</span>
63
+ <span>Β·</span>
64
+ <span>{issue.commentCount} comments</span>
65
+ </div>
66
+
67
+ {/* Description */}
68
+ <div style={{ marginBottom: 32, lineHeight: 1.6, fontSize: 13.5, color: "var(--fg-1)" }}>
69
+ <p>The current canvas-rendering path redraws the entire viewport on every interaction β€” cursor moves, selection changes, even hover states. For documents above ~2,000 nodes we're seeing 18–24ms paint budgets, which turns into sub-30fps during multi-select drags.</p>
70
+
71
+ <p>This work rebuilds the pipeline around three primitives:</p>
72
+
73
+ <ul>
74
+ <li><strong>Tile-based composition.</strong> Split the scene into adaptive 256Γ—256 tiles, each backed by an <code style={{ fontFamily: "var(--font-mono)", fontSize: 12, background: "var(--bg-2)", padding: "1px 5px", borderRadius: 4, border: "1px solid var(--border)" }}>OffscreenCanvas</code>. Only dirty tiles repaint.</li>
75
+ <li><strong>Hit-test indexing.</strong> R-tree keyed by bounding boxes, rebuilt incrementally on edit.</li>
76
+ <li><strong>Transform isolation.</strong> Pan/zoom become CSS transforms on the tile grid β€” no repaint needed.</li>
77
+ </ul>
78
+
79
+ <p><strong>Target:</strong> steady 60fps on documents up to 10k nodes, p95 paint budget under 8ms. Traces attached below.</p>
80
+ </div>
81
+
82
+ {/* Checklist */}
83
+ <div style={{ marginBottom: 32, border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden" }}>
84
+ <div className="flex items-center" style={{ padding: "10px 14px", background: "var(--bg-1)", borderBottom: "1px solid var(--border)", fontSize: 12, gap: 8 }}>
85
+ <strong>Acceptance criteria</strong>
86
+ <span className="mono muted-2">3 / 5</span>
87
+ <div style={{ flex: 1 }} />
88
+ <div style={{ width: 60, height: 3, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden" }}>
89
+ <div style={{ width: "60%", height: "100%", background: "var(--accent)" }} />
90
+ </div>
91
+ </div>
92
+ {[
93
+ { done: true, text: "Tile-based composition with OffscreenCanvas" },
94
+ { done: true, text: "Hit-test R-tree with incremental rebuild" },
95
+ { done: true, text: "Pan/zoom without full repaint" },
96
+ { done: false, text: "Benchmark harness for 1k/5k/10k scenes" },
97
+ { done: false, text: "Regression guard in CI (perf budget ≀ 8ms p95)" },
98
+ ].map((c, i) => (
99
+ <div key={i} className="flex items-center gap-10" style={{ padding: "8px 14px", borderTop: i > 0 ? "1px solid var(--border-subtle)" : "none", fontSize: 12.5 }}>
100
+ <span style={{
101
+ width: 14, height: 14, border: "1.5px solid var(--border-strong)", borderRadius: 3,
102
+ background: c.done ? "var(--accent)" : "transparent",
103
+ display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
104
+ color: "var(--accent-fg)"
105
+ }}>{c.done && <Icon name="check" size={10} strokeWidth={3} />}</span>
106
+ <span style={{ textDecoration: c.done ? "line-through" : "none", color: c.done ? "var(--fg-3)" : "var(--fg-0)" }}>{c.text}</span>
107
+ </div>
108
+ ))}
109
+ </div>
110
+
111
+ {/* Tabs */}
112
+ <div className="flex items-center" style={{ borderBottom: "1px solid var(--border)", marginBottom: 16, gap: 0 }}>
113
+ {[
114
+ { id: "comments", label: "Activity", count: 14 },
115
+ { id: "linked", label: "Linked work", count: 3 },
116
+ { id: "files", label: "Attachments", count: 4 },
117
+ ].map(t => (
118
+ <button key={t.id} className="flex items-center gap-6" onClick={() => setActiveTab(t.id)}
119
+ style={{ padding: "8px 14px", fontSize: 12.5, color: activeTab === t.id ? "var(--fg-0)" : "var(--fg-2)", fontWeight: 500, borderBottom: `2px solid ${activeTab === t.id ? "var(--accent)" : "transparent"}`, marginBottom: -1 }}>
120
+ {t.label} <span className="mono muted-2" style={{ fontSize: 10.5 }}>{t.count}</span>
121
+ </button>
122
+ ))}
123
+ </div>
124
+
125
+ {/* Activity */}
126
+ {activeTab === "comments" && (
127
+ <div>
128
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
129
+ {activity.map((ev, i) => {
130
+ const u = PEOPLE.find(p => p.id === ev.user);
131
+ if (ev.type === "comment") {
132
+ return (
133
+ <div key={i} className="flex gap-10" style={{ background: ev.highlight ? "var(--accent-soft)" : "var(--bg-1)", border: `1px solid ${ev.highlight ? "var(--accent-dim)" : "var(--border)"}`, borderRadius: 10, padding: 12 }}>
134
+ <Avatar user={u} size="sm" />
135
+ <div style={{ flex: 1 }}>
136
+ <div className="flex items-center gap-6" style={{ marginBottom: 4, fontSize: 12 }}>
137
+ <strong>{u.name}</strong>
138
+ <span className="muted-2">{ev.time} ago</span>
139
+ </div>
140
+ <div style={{ fontSize: 13, lineHeight: 1.5 }}>{ev.text}</div>
141
+ {ev.code && (
142
+ <pre className="mono" style={{ marginTop: 8, background: "var(--bg-0)", border: "1px solid var(--border)", padding: 10, borderRadius: 6, fontSize: 11, color: "var(--fg-1)", overflow: "auto" }}>
143
+ {`scripting 4.2ms
144
+ rendering 14.8ms
145
+ painting 3.1ms
146
+ compositing 0.8ms ← budget OK
147
+ --------------------
148
+ paint (main thread) 18.1ms ❌ p95 target 8ms`}
149
+ </pre>
150
+ )}
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+ const iconFor = { create: "plus", status: "arrow-right", assign: "at", branch: "branch", pr: "pr" };
156
+ return (
157
+ <div key={i} className="flex items-center gap-10 muted" style={{ fontSize: 11.5, paddingLeft: 4 }}>
158
+ <span className="avatar xs" style={{ background: "var(--bg-2)", color: "var(--fg-2)" }}><Icon name={iconFor[ev.type]} size={9} /></span>
159
+ {u && <strong style={{ color: "var(--fg-1)", fontWeight: 500 }}>{u.name.split(" ")[0]}</strong>}
160
+ <span>{ev.text}</span>
161
+ <span style={{ marginLeft: "auto" }} className="mono muted-2">{ev.time}</span>
162
+ </div>
163
+ );
164
+ })}
165
+ </div>
166
+
167
+ {/* Composer */}
168
+ <div style={{ marginTop: 16, border: "1px solid var(--border)", borderRadius: 10, padding: 4, background: "var(--bg-1)" }}>
169
+ <textarea id="commentbox" placeholder="Leave a comment… /commands and @mentions supported" rows={3} style={{
170
+ width: "100%", background: "transparent", border: "none", outline: "none", resize: "vertical",
171
+ padding: 10, fontSize: 13, fontFamily: "inherit", color: "var(--fg-0)"
172
+ }} />
173
+ <div className="flex items-center gap-6" style={{ padding: 6, borderTop: "1px solid var(--border-subtle)" }}>
174
+ <button className="icon-btn" title="Attach" onClick={() => window.openAttach()}><Icon name="attach" size={13} /></button>
175
+ <button className="icon-btn" title="Code" onClick={() => window.toast("Insert code block")}><Icon name="hash" size={13} /></button>
176
+ <button className="icon-btn" title="Mention" onClick={() => window.toast("Mention teammate")}><Icon name="at" size={13} /></button>
177
+ <button className="icon-btn" title="AI" onClick={() => window.openAI(`What's blocking ${proj?.name || 'this project'}?`, "general", { issue })}><Icon name="sparkle" size={13} /></button>
178
+ <div style={{ flex: 1 }} />
179
+ <span className="mono muted-2" style={{ fontSize: 10.5 }}>βŒ˜β†΅ to send</span>
180
+ <button className="btn sm primary" onClick={() => {
181
+ const el = document.getElementById("commentbox");
182
+ if (!el || !el.value.trim()) { window.toast("Type a comment first"); return; }
183
+ window.toast("Comment posted"); el.value = "";
184
+ }}>Comment</button>
185
+ </div>
186
+ </div>
187
+ </div>
188
+ )}
189
+ </div>
190
+ </div>
191
+
192
+ {/* Right sidebar */}
193
+ <aside style={{ width: 300, flexShrink: 0, padding: "20px 16px", overflow: "auto" }}>
194
+ <Section title="Status">
195
+ <div className="flex items-center gap-8" onClick={() => window.openPicker({ title: "Status", options: [
196
+ { value: "backlog", label: "Backlog" },{ value: "todo", label: "Todo" },
197
+ { value: "progress", label: "In progress" },{ value: "review", label: "In review" },{ value: "done", label: "Done" },
198
+ ], onChoose: (o) => window.apiFetch('PATCH', `/api/issues/${issue.id}`, { status: o.value }).then(setIssue).catch(() => window.toast("Update failed")) })} style={{ cursor: "pointer", padding: "6px 8px", borderRadius: 6, background: "var(--bg-1)", border: "1px solid var(--border)" }}>
199
+ <span className={`status ${issue.status === 'backlog' ? 'todo' : issue.status}`}><span className="s-dot" /></span>
200
+ <span style={{ fontSize: 12.5 }}>{STATUS_META[issue.status]?.label || issue.status}</span>
201
+ <Icon name="chevron-down" size={12} style={{ marginLeft: "auto", color: "var(--fg-3)" }} />
202
+ </div>
203
+ </Section>
204
+ <Section title="Priority">
205
+ <div className="flex items-center gap-8" onClick={() => window.openPicker({ title: "Priority", options: [
206
+ { value: "urgent", label: "Urgent" },{ value: "high", label: "High" },{ value: "med", label: "Medium" },{ value: "low", label: "Low" },{ value: "none", label: "No priority" },
207
+ ], onChoose: (o) => window.apiFetch('PATCH', `/api/issues/${issue.id}`, { priority: o.value }).then(setIssue).catch(() => window.toast("Update failed")) })} style={{ cursor: "pointer", padding: "6px 8px", borderRadius: 6, background: "var(--bg-1)", border: "1px solid var(--border)" }}>
208
+ <PriorityGlyph level={issue.priority} />
209
+ <span style={{ fontSize: 12.5 }}>{issue.priority === 'med' ? 'Medium' : issue.priority?.charAt(0).toUpperCase() + issue.priority?.slice(1)}</span>
210
+ <Icon name="chevron-down" size={12} style={{ marginLeft: "auto", color: "var(--fg-3)" }} />
211
+ </div>
212
+ </Section>
213
+ <Section title="Assignees">
214
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
215
+ {assignees.map(a => (
216
+ <div key={a.id} className="flex items-center gap-8" style={{ fontSize: 12.5 }}>
217
+ <Avatar user={a} size="xs" />
218
+ <span>{a.name}</span>
219
+ <span className="mono muted-2" style={{ marginLeft: "auto", fontSize: 10.5 }}>@{a.handle}</span>
220
+ </div>
221
+ ))}
222
+ <button className="btn ghost sm" style={{ justifyContent: "flex-start", marginTop: 2 }} onClick={() => window.openPicker({ title: "Assign teammate", options: PEOPLE.map(u => ({ value: u.id, label: u.name, hint: `@${u.handle}` })), onChoose: (o) => window.apiFetch('PATCH', `/api/issues/${issue.id}`, { assignees: [...issue.assignees, o.value] }).then(setIssue).catch(() => window.toast("Update failed")) })}><Icon name="plus" size={12} /> Add</button>
223
+ </div>
224
+ </Section>
225
+ <Section title="Project">
226
+ <div className="flex items-center gap-8" style={{ fontSize: 12.5 }}>
227
+ <span style={{ width: 10, height: 10, borderRadius: 2, background: proj.color }} />
228
+ <span>{proj.name}</span>
229
+ <span className="mono muted-2" style={{ marginLeft: "auto", fontSize: 10.5 }}>{proj.code}</span>
230
+ </div>
231
+ </Section>
232
+ <Section title="Sprint">
233
+ <div className="flex items-center gap-8" style={{ fontSize: 12.5 }}>
234
+ <Icon name="sprint" size={13} style={{ color: "var(--accent)" }} />
235
+ <span>Iteration 42</span>
236
+ <span className="mono muted-2" style={{ marginLeft: "auto", fontSize: 10.5 }}>Apr 14–28</span>
237
+ </div>
238
+ </Section>
239
+ <Section title="Labels">
240
+ <div className="flex items-center gap-4" style={{ flexWrap: "wrap" }}>
241
+ {issue.labels.map(lid => {
242
+ const lab = LABELS.find(l => l.id === lid);
243
+ return <span key={lid} className="tag" style={{ color: lab.color }}>#{lab.name}</span>;
244
+ })}
245
+ <button className="tag muted-2" onClick={() => window.openPicker({ title: "Add label", options: LABELS.map(l => ({ value: l.id, label: `#${l.name}`, swatch: l.color })), onChoose: (o) => window.apiFetch('PATCH', `/api/issues/${issue.id}`, { labels: [...issue.labels, o.value] }).then(setIssue).catch(() => window.toast("Update failed")) })}><Icon name="plus" size={10} /></button>
246
+ </div>
247
+ </Section>
248
+ <Section title="Estimate">
249
+ <div className="flex items-center gap-6" style={{ fontSize: 12.5 }}>
250
+ <span className="mono" style={{ fontSize: 14 }}>{issue.estimate}</span>
251
+ <span className="muted">points</span>
252
+ <div style={{ flex: 1 }} />
253
+ <span className="muted-2 mono" style={{ fontSize: 10.5 }}>~3d</span>
254
+ </div>
255
+ </Section>
256
+ <Section title="Dates">
257
+ <div style={{ fontSize: 12, lineHeight: 1.7 }}>
258
+ <div className="flex items-center justify-between"><span className="muted">Created</span><span className="mono">{issue.created}</span></div>
259
+ <div className="flex items-center justify-between"><span className="muted">Due</span><span className="mono" style={{ color: "var(--rose)" }}>{issue.due}</span></div>
260
+ <div className="flex items-center justify-between"><span className="muted">Updated</span><span className="mono">2h ago</span></div>
261
+ </div>
262
+ </Section>
263
+ {issue.branch && (
264
+ <Section title="Development">
265
+ <div className="flex items-center gap-6" style={{ fontSize: 11.5, padding: "6px 8px", borderRadius: 6, background: "var(--bg-1)", border: "1px solid var(--border)" }}>
266
+ <Icon name="branch" size={12} style={{ color: "var(--accent)" }} />
267
+ <span className="mono">{issue.branch}</span>
268
+ </div>
269
+ <div className="flex items-center gap-6" style={{ fontSize: 11.5, padding: "6px 8px", borderRadius: 6, background: "var(--bg-1)", border: "1px solid var(--border)", marginTop: 6 }}>
270
+ <Icon name="pr" size={12} style={{ color: "var(--accent)" }} />
271
+ <span className="mono">#2341</span>
272
+ <span className="truncate">perf(canvas): tile-based rendering</span>
273
+ </div>
274
+ </Section>
275
+ )}
276
+ <Section title="Related">
277
+ <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
278
+ {related.map(r => (
279
+ <button key={r.id} className="flex items-center gap-6" style={{ textAlign: "left", padding: "4px 6px", borderRadius: 4, fontSize: 11.5 }}>
280
+ <PriorityGlyph level={r.priority} />
281
+ <span className="mono muted-2" style={{ fontSize: 10.5 }}>{r.id}</span>
282
+ <span className="truncate flex-1">{r.title}</span>
283
+ </button>
284
+ ))}
285
+ </div>
286
+ </Section>
287
+ </aside>
288
+ </div>
289
+ );
290
+ };
291
+
292
+ const Section = ({ title, children }) => (
293
+ <div style={{ marginBottom: 18 }}>
294
+ <div className="sb-label" style={{ padding: "0 0 6px 0" }}>{title}</div>
295
+ {children}
296
+ </div>
297
+ );
298
+
299
+ // ==== DOCS ====
300
+ const DocsView = () => {
301
+ const [selected, setSelected] = React.useState("d3");
302
+ const [expanded, setExpanded] = React.useState(new Set(["d1","d2","d6","d10"]));
303
+ const [docs, setDocs] = React.useState(DOCS);
304
+
305
+ React.useEffect(() => {
306
+ const load = () => window.apiFetch('GET', '/api/docs').then(setDocs).catch(() => {});
307
+ load();
308
+ document.addEventListener('meridian:refresh', load);
309
+ return () => document.removeEventListener('meridian:refresh', load);
310
+ }, []);
311
+
312
+ const toggle = (id) => {
313
+ setExpanded(prev => {
314
+ const next = new Set(prev);
315
+ if (next.has(id)) next.delete(id); else next.add(id);
316
+ return next;
317
+ });
318
+ };
319
+
320
+ const renderTree = (items, depth = 0) => items.map(it => (
321
+ <div key={it.id}>
322
+ <button className="flex items-center gap-6" onClick={() => { if (it.children) toggle(it.id); setSelected(it.id); }}
323
+ style={{
324
+ width: "100%", textAlign: "left", padding: "4px 10px", fontSize: 12.5, paddingLeft: 10 + depth * 14,
325
+ color: selected === it.id ? "var(--fg-0)" : "var(--fg-1)",
326
+ background: selected === it.id ? "var(--bg-2)" : "transparent",
327
+ borderRadius: 5,
328
+ }}>
329
+ {it.children ? (
330
+ <Icon name={expanded.has(it.id) ? "chevron-down" : "chevron-right"} size={11} style={{ color: "var(--fg-3)" }} />
331
+ ) : <span style={{ width: 11 }} />}
332
+ {it.emoji ? <span style={{ fontSize: 11, color: "var(--fg-2)" }}>{it.emoji}</span> : <Icon name="docs" size={12} style={{ color: "var(--fg-2)" }} />}
333
+ <span className="truncate">{it.title}</span>
334
+ </button>
335
+ {it.children && expanded.has(it.id) && renderTree(it.children, depth + 1)}
336
+ </div>
337
+ ));
338
+
339
+ return (
340
+ <div className="flex flex-1" style={{ minWidth: 0 }}>
341
+ <aside style={{ width: 260, flexShrink: 0, borderRight: "1px solid var(--border)", background: "var(--bg-1)", overflow: "auto" }}>
342
+ <div style={{ padding: "12px 12px 6px" }}>
343
+ <div className="search" style={{ minWidth: 0 }}>
344
+ <Icon name="search" size={12} />
345
+ <input placeholder="Search docs…" />
346
+ </div>
347
+ </div>
348
+ <div style={{ padding: "6px 4px 24px" }}>
349
+ {renderTree(docs)}
350
+ </div>
351
+ </aside>
352
+ <div className="flex col flex-1" style={{ minWidth: 0 }}>
353
+ <div className="page-header">
354
+ <div className="page-title">
355
+ <span className="eyebrow">DOCS / ENGINEERING HANDBOOK / ADR</span>
356
+ </div>
357
+ <div className="topbar-spacer" />
358
+ <span className="chip" style={{ color: "var(--fg-2)" }}><Icon name="lock" size={11} /> Private</span>
359
+ <button className="btn ghost sm" onClick={() => window.toast("Watching this doc") }><Icon name="eye" size={13} /> 8</button>
360
+ <div className="vdivider" style={{ height: 20 }} />
361
+ <AvatarStack users={[PEOPLE[1], PEOPLE[2], PEOPLE[4]]} size="xs" />
362
+ <button className="btn ghost sm" onClick={() => window.openAI("Summarize this doc", "general", { doc: docs.find(d => d.id === selected) || {} })}><Icon name="sparkle" size={13} /> Ask AI</button>
363
+ <button className="btn sm primary" onClick={() => window.openShare("ADR 041 β€” Canvas rendering pipeline")}><Icon name="plus" size={13} /> Share</button>
364
+ </div>
365
+
366
+ <div className="scroll-y" style={{ flex: 1, padding: "40px 64px" }}>
367
+ <article style={{ maxWidth: 740, margin: "0 auto" }}>
368
+ <div className="mono muted-2" style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12 }}>ADR 041 Β· Engineering Β· Accepted</div>
369
+ <h1 className="editorial" style={{ fontSize: 42, lineHeight: 1.1, fontWeight: 400, letterSpacing: "-0.02em", margin: "0 0 12px" }}>
370
+ Canvas rendering pipeline, rebuilt on tiles
371
+ </h1>
372
+ <div className="flex items-center gap-10 muted" style={{ fontSize: 12.5, marginBottom: 36 }}>
373
+ <Avatar user={PEOPLE[1]} size="xs" />
374
+ <span><strong style={{ color: "var(--fg-1)" }}>Rohan Mehta</strong> Β· updated 2 days ago</span>
375
+ <span>Β·</span>
376
+ <span>4 min read</span>
377
+ <span>Β·</span>
378
+ <span className="flex items-center gap-4"><Icon name="link" size={11} /> AUR-412</span>
379
+ </div>
380
+
381
+ <div style={{ fontSize: 15, lineHeight: 1.7, color: "var(--fg-1)" }}>
382
+ <p style={{ fontSize: 17, color: "var(--fg-0)", fontStyle: "italic" }}>
383
+ The canvas hasn't aged well. Between <code style={{ fontFamily: "var(--font-mono)", fontSize: 13, background: "var(--bg-2)", padding: "2px 6px", borderRadius: 4 }}>v1.2</code> and now, the scene graph quietly quintupled in size while the renderer stayed monolithic. This is the plan to pay that down.
384
+ </p>
385
+
386
+ <h2 style={{ fontSize: 18, fontWeight: 600, marginTop: 36, marginBottom: 10, color: "var(--fg-0)" }}>Context</h2>
387
+ <p>At the shape of traffic we saw in March, p95 paint budgets crossed 18ms on documents above ~2k nodes. The current pipeline repaints the entire viewport on every pointer event β€” inexpensive in isolation, expensive when multiplied by modern input rates.</p>
388
+
389
+ <blockquote style={{ margin: "20px 0", paddingLeft: 16, borderLeft: "2px solid var(--accent)", fontStyle: "italic", color: "var(--fg-1)" }}>
390
+ We don't need a new renderer. We need a renderer that knows when <em>not</em> to run.
391
+ </blockquote>
392
+
393
+ <h2 style={{ fontSize: 18, fontWeight: 600, marginTop: 36, marginBottom: 10, color: "var(--fg-0)" }}>Decision</h2>
394
+ <p>Adopt a tile-composited pipeline. Each tile owns its own <code style={{ fontFamily: "var(--font-mono)", fontSize: 13, background: "var(--bg-2)", padding: "2px 6px", borderRadius: 4 }}>OffscreenCanvas</code>; the compositor composes tiles on the main thread using cheap CSS transforms for pan and zoom.</p>
395
+
396
+ {/* Code block */}
397
+ <div style={{ margin: "20px 0", borderRadius: 8, border: "1px solid var(--border)", overflow: "hidden" }}>
398
+ <div className="flex items-center gap-8" style={{ padding: "6px 12px", borderBottom: "1px solid var(--border)", background: "var(--bg-1)", fontSize: 11, color: "var(--fg-2)" }}>
399
+ <Icon name="hash" size={11} />
400
+ <span className="mono">renderer/tile.ts</span>
401
+ </div>
402
+ <pre className="mono" style={{ margin: 0, padding: 14, background: "var(--bg-0)", fontSize: 12, lineHeight: 1.65, color: "var(--fg-1)", overflow: "auto" }}>
403
+ {`interface Tile {
404
+ readonly key: TileKey
405
+ readonly bounds: Rect
406
+ readonly canvas: OffscreenCanvas
407
+ dirty: boolean
408
+ version: number
409
+ }
410
+
411
+ function composite(
412
+ viewport: Rect,
413
+ tiles: ReadonlyMap<TileKey, Tile>,
414
+ ctx: CanvasRenderingContext2D,
415
+ ): void {
416
+ for (const tile of visible(viewport, tiles)) {
417
+ if (tile.dirty) repaint(tile)
418
+ ctx.drawImage(tile.canvas, tile.bounds.x, tile.bounds.y)
419
+ }
420
+ }`}
421
+ </pre>
422
+ </div>
423
+
424
+ <h2 style={{ fontSize: 18, fontWeight: 600, marginTop: 36, marginBottom: 10, color: "var(--fg-0)" }}>Consequences</h2>
425
+ <ul>
426
+ <li>Peak memory goes up (tile atlases) but flat-lines instead of growing linearly with document size.</li>
427
+ <li>Multi-select drag is dominated by compositing, not repainting β€” costs drop by an order of magnitude.</li>
428
+ <li>Plugin renderers have to target tiles, not the root canvas; we ship a shim for the transition.</li>
429
+ </ul>
430
+
431
+ {/* Callout */}
432
+ <div style={{ margin: "24px 0", padding: "14px 16px", background: "var(--amber-soft)", border: "1px solid var(--amber)", borderRadius: 8, fontSize: 13, color: "var(--fg-0)" }}>
433
+ <div className="flex items-center gap-6" style={{ marginBottom: 4 }}>
434
+ <Icon name="flag" size={13} style={{ color: "var(--amber)" }} />
435
+ <strong style={{ fontSize: 12.5 }}>Migration risk</strong>
436
+ </div>
437
+ Third-party plugin renderers (14 in the registry as of Q1) will need a recompile against <code style={{ fontFamily: "var(--font-mono)", fontSize: 12, background: "var(--bg-1)", padding: "1px 5px", borderRadius: 3 }}>@meridian/canvas@2</code>. The shim buys us one minor version.
438
+ </div>
439
+ </div>
440
+ </article>
441
+ </div>
442
+ </div>
443
+ </div>
444
+ );
445
+ };
446
+
447
+ // ==== ROADMAP ====
448
+ const RoadmapView = () => {
449
+ const months = ["Apr","May","Jun","Jul","Aug","Sep"];
450
+ const cellsPerQ = 9; // cols in timeline
451
+ return (
452
+ <div className="flex col flex-1" style={{ minWidth: 0 }}>
453
+ <div className="page-header">
454
+ <div className="page-title">
455
+ <span>Roadmap</span>
456
+ <span className="mono muted-2" style={{ fontSize: 12 }}>Q2 β€” Q3 2026</span>
457
+ </div>
458
+ <div className="topbar-spacer" />
459
+ <div className="segmented">
460
+ <button className="on">Timeline</button>
461
+ <button onClick={() => window.openPicker({ title: "Switch view", options: [{ value: "q", label: "Quarters" }, { value: "m", label: "Milestones" }], onChoose: (o) => window.toast(`Switched to ${o.label}`) })}>Quarters</button>
462
+ <button onClick={() => window.openPicker({ title: "Milestones", options: [{ value: "m1", label: "Aurora launch Β· Jun 12" }, { value: "m2", label: "SSO audit Β· Jul 1" }, { value: "m3", label: "Tessera 2.0 Β· Aug 22" }], onChoose: (o) => window.toast(o.label) })}>Milestones</button>
463
+ </div>
464
+ <button className="btn ghost sm" onClick={() => window.openPicker({ title: "Filter projects", options: PROJECTS.map(p => ({ value: p.id, label: p.name, swatch: p.color })), onChoose: (o) => window.toast(`Filtered to ${o.label}`) })}><Icon name="filter" size={13} /> Projects: all</button>
465
+ <button className="btn ghost sm" onClick={() => window.openAI("What milestones are at risk this quarter?", "general", { roadmap: typeof ROADMAP !== 'undefined' ? ROADMAP : [] })}><Icon name="sparkle" size={13} /> AI Summary</button>
466
+ <button className="btn sm primary" onClick={() => window.openNewIssue({ kind: "initiative" })}><Icon name="plus" size={13} /> New initiative</button>
467
+ </div>
468
+
469
+ <div className="scroll-y" style={{ flex: 1, padding: "16px 20px 40px" }}>
470
+ {/* Header row */}
471
+ <div style={{ display: "grid", gridTemplateColumns: "220px 1fr 1fr", gap: 0, position: "sticky", top: 0, background: "var(--bg-0)", zIndex: 2, paddingBottom: 8, borderBottom: "1px solid var(--border)" }}>
472
+ <div />
473
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "4px 12px", borderRight: "1px solid var(--border)" }}>
474
+ <strong style={{ fontSize: 13 }}>Q2 2026</strong>
475
+ <span className="mono muted-2" style={{ fontSize: 10.5 }}>APR β€” JUN</span>
476
+ </div>
477
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "4px 12px" }}>
478
+ <strong style={{ fontSize: 13 }}>Q3 2026</strong>
479
+ <span className="mono muted-2" style={{ fontSize: 10.5 }}>JUL β€” SEP</span>
480
+ </div>
481
+ </div>
482
+
483
+ {/* Months */}
484
+ <div style={{ display: "grid", gridTemplateColumns: `220px repeat(${months.length}, 1fr)`, borderBottom: "1px solid var(--border-subtle)" }}>
485
+ <div />
486
+ {months.map((m, i) => (
487
+ <div key={i} className="mono muted-2" style={{ padding: "6px 10px", fontSize: 11, borderLeft: i === 0 ? "none" : "1px solid var(--border-subtle)" }}>{m}</div>
488
+ ))}
489
+ </div>
490
+
491
+ {/* Group by project */}
492
+ {PROJECTS.map(proj => {
493
+ const items = ROADMAP.filter(r => r.project === proj.id);
494
+ if (!items.length) return null;
495
+ return (
496
+ <div key={proj.id} style={{ borderBottom: "1px solid var(--border-subtle)" }}>
497
+ <div className="flex items-center gap-8" style={{ padding: "10px 0 6px 6px", position: "sticky", left: 0 }}>
498
+ <span style={{ width: 10, height: 10, borderRadius: 3, background: proj.color }} />
499
+ <strong style={{ fontSize: 12 }}>{proj.name}</strong>
500
+ <span className="mono muted-2" style={{ fontSize: 10.5 }}>{proj.code}</span>
501
+ <span className="chip">{items.length}</span>
502
+ </div>
503
+ {items.map(it => {
504
+ // Map startCol (0-17) across 2 quarters
505
+ const totalCells = 18;
506
+ const startPct = (it.startCol / totalCells) * 100;
507
+ const widthPct = (it.span / totalCells) * 100;
508
+ return (
509
+ <div key={it.id} style={{ display: "grid", gridTemplateColumns: `220px repeat(${months.length}, 1fr)`, alignItems: "center", padding: "6px 0", position: "relative" }}>
510
+ <div className="flex items-center gap-6 truncate" style={{ padding: "0 8px", fontSize: 12.5 }}>
511
+ <Icon name="bolt" size={12} style={{ color: proj.color }} />
512
+ <span className="truncate">{it.title}</span>
513
+ </div>
514
+ <div style={{ gridColumn: `2 / span ${months.length}`, position: "relative", height: 30 }}>
515
+ {months.map((_, i) => (
516
+ <div key={i} style={{ position: "absolute", left: `${(i / months.length) * 100}%`, top: 0, bottom: 0, borderLeft: "1px dashed var(--border-subtle)" }} />
517
+ ))}
518
+ <div className="roadmap-bar" style={{
519
+ position: "absolute", left: `${startPct}%`, width: `${widthPct}%`,
520
+ top: 4, bottom: 4, borderRadius: 6,
521
+ background: `color-mix(in oklch, ${proj.color} 22%, var(--bg-1))`,
522
+ border: `1px solid ${proj.color}`,
523
+ boxShadow: `0 0 20px -6px ${proj.color}`,
524
+ "--proj": proj.color,
525
+ overflow: "hidden",
526
+ display: "flex", alignItems: "center", padding: "0 10px",
527
+ fontSize: 11.5, color: "var(--fg-0)", gap: 8
528
+ }}>
529
+ <div style={{ position: "absolute", inset: 0, background: `linear-gradient(90deg, ${proj.color} 0%, ${proj.color} ${it.progress}%, transparent ${it.progress}%)`, opacity: 0.25 }} />
530
+ <span className="truncate" style={{ position: "relative" }}>{it.title}</span>
531
+ <span className="mono muted-2" style={{ position: "relative", marginLeft: "auto", fontSize: 10.5 }}>{it.progress}%</span>
532
+ </div>
533
+ </div>
534
+ </div>
535
+ );
536
+ })}
537
+ </div>
538
+ );
539
+ })}
540
+
541
+ {/* Today marker note */}
542
+ <div className="flex items-center gap-8 mono muted-2" style={{ fontSize: 10.5, marginTop: 12 }}>
543
+ <span style={{ width: 12, height: 2, background: "var(--accent)" }} />
544
+ <span>Today Β· Apr 20</span>
545
+ </div>
546
+ </div>
547
+ </div>
548
+ );
549
+ };
550
+
551
+ Object.assign(window, { IssueDetail, DocsView, RoadmapView });
views-main.jsx ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Home (dashboard) + Inbox + Issues (board + list) + Issue detail
2
+
3
+ // ==== HOME ====
4
+ const HomeView = ({ setView, setIssueId, cardStyle }) => {
5
+ if (cardStyle === "minimal") return <HomeMinimal setView={setView} setIssueId={setIssueId} />;
6
+ return <HomeDetailed setView={setView} setIssueId={setIssueId} />;
7
+ };
8
+
9
+ // Minimal home: quiet, scannable, editorial. Just 3 things.
10
+ const HomeMinimal = ({ setView, setIssueId }) => {
11
+ const myIssues = ISSUES.filter(i => i.assignees.includes("u1") || i.assignees.includes("u2")).slice(0, 4);
12
+ const activePRs = PRS.filter(p => p.status !== "merged").slice(0, 3);
13
+ const urgentCount = ISSUES.filter(i => i.priority === "urgent").length;
14
+
15
+ return (
16
+ <div className="scroll-y" style={{ padding: "48px 28px 32px", flex: 1, minWidth: 0 }}>
17
+ <div style={{ maxWidth: 760, margin: "0 auto" }}>
18
+ {/* Quiet greeting */}
19
+ <div style={{ marginBottom: 48 }}>
20
+ <div className="mono muted-2" style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 10 }}>Monday Β· 20 April Β· 07:48</div>
21
+ <h1 className="editorial" style={{ margin: 0, fontSize: 40, fontWeight: 400, letterSpacing: "-0.025em", lineHeight: 1.15 }}>
22
+ Good morning, Amara.
23
+ </h1>
24
+ <p className="muted" style={{ fontSize: 16, marginTop: 10, lineHeight: 1.5, fontWeight: 300 }}>
25
+ {urgentCount} urgent Β· {activePRs.length} reviews waiting Β· Iteration 42 on track.
26
+ </p>
27
+ <div className="flex gap-8" style={{ marginTop: 18 }}>
28
+ <button className="btn" onClick={() => window.openWeek()}><Icon name="calendar" size={13} /> Week</button>
29
+ <button className="btn" onClick={() => window.openDigest()}><Icon name="sparkle" size={13} /> Digest</button>
30
+ </div>
31
+ </div>
32
+
33
+ {/* Focus β€” plain list, no chrome */}
34
+ <div style={{ marginBottom: 40 }}>
35
+ <div className="flex items-center justify-between" style={{ marginBottom: 14 }}>
36
+ <div className="mono muted-2" style={{ fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase" }}>Your focus</div>
37
+ <button className="btn ghost sm" onClick={() => setView("issues")}>All issues <Icon name="arrow-right" size={12} /></button>
38
+ </div>
39
+ <div style={{ borderTop: "1px solid var(--border-subtle)" }}>
40
+ {myIssues.map(is => {
41
+ const proj = PROJECTS.find(p => p.id === is.project);
42
+ return (
43
+ <button key={is.id}
44
+ onClick={() => { setIssueId(is.id); setView("issue"); }}
45
+ style={{ display: "flex", alignItems: "center", gap: 14, width: "100%", padding: "14px 0", borderBottom: "1px solid var(--border-subtle)", textAlign: "left", background: "transparent", border: "none", borderBottom: "1px solid var(--border-subtle)", cursor: "pointer" }}>
46
+ <PriorityGlyph level={is.priority} />
47
+ <span className="mono muted-2" style={{ fontSize: 11, width: 64, flexShrink: 0 }}>{is.id}</span>
48
+ <span style={{ flex: 1, fontSize: 14 }} className="truncate">{is.title}</span>
49
+ <span className="mono muted-2" style={{ fontSize: 11 }}>{proj.code}</span>
50
+ </button>
51
+ );
52
+ })}
53
+ </div>
54
+ </div>
55
+
56
+ {/* Reviews β€” minimal */}
57
+ <div style={{ marginBottom: 40 }}>
58
+ <div className="flex items-center justify-between" style={{ marginBottom: 14 }}>
59
+ <div className="mono muted-2" style={{ fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase" }}>Review queue</div>
60
+ <button className="btn ghost sm" onClick={() => setView("prs")}>All PRs <Icon name="arrow-right" size={12} /></button>
61
+ </div>
62
+ <div style={{ borderTop: "1px solid var(--border-subtle)" }}>
63
+ {activePRs.map(pr => {
64
+ const author = PEOPLE.find(p => p.id === pr.author);
65
+ return (
66
+ <button key={pr.id} onClick={() => window.openPR(pr)}
67
+ style={{ display: "flex", alignItems: "center", gap: 14, width: "100%", padding: "14px 0", borderBottom: "1px solid var(--border-subtle)", textAlign: "left", background: "transparent", border: "none", borderBottom: "1px solid var(--border-subtle)", cursor: "pointer" }}>
68
+ <Avatar user={author} size="xs" />
69
+ <span className="mono muted-2" style={{ fontSize: 11, width: 64, flexShrink: 0 }}>{pr.id}</span>
70
+ <span style={{ flex: 1, fontSize: 14 }} className="truncate">{pr.title}</span>
71
+ <span className="mono muted-2" style={{ fontSize: 11 }}>{pr.updated}</span>
72
+ </button>
73
+ );
74
+ })}
75
+ </div>
76
+ </div>
77
+
78
+ {/* One-line iteration status */}
79
+ <div style={{ padding: "18px 0", borderTop: "1px solid var(--border-subtle)", borderBottom: "1px solid var(--border-subtle)", display: "flex", alignItems: "baseline", gap: 16 }}>
80
+ <span className="mono muted-2" style={{ fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase" }}>Iteration 42</span>
81
+ <span style={{ fontSize: 14 }}><strong className="mono">12</strong><span className="muted"> / 34 pts Β· day 6 of 14 Β· on track</span></span>
82
+ <div style={{ flex: 1 }} />
83
+ <div style={{ width: 120, height: 3, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden" }}>
84
+ <div style={{ width: "35%", height: "100%", background: "var(--accent)" }} />
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ );
90
+ };
91
+
92
+ // Detailed home: the current dense dashboard
93
+ const HomeDetailed = ({ setView, setIssueId }) => {
94
+ const myIssues = ISSUES.filter(i => i.assignees.includes("u1") || i.assignees.includes("u2")).slice(0, 5);
95
+ const activePRs = PRS.filter(p => p.status !== "merged");
96
+ const recentDocs = [
97
+ { title: "Aurora: collaborative canvas PRD", author: "u1", time: "2h ago" },
98
+ { title: "ADR 041 β€” Canvas rendering pipeline", author: "u2", time: "2d ago" },
99
+ { title: "Apr 18 β€” Platform weekly", author: "u5", time: "2d ago" },
100
+ ];
101
+
102
+ const weekDays = ["Apr 14","15","16","17","18","19","20"];
103
+ // Fake velocity sparkline
104
+ const velocity = [3, 7, 5, 9, 12, 8, 11];
105
+
106
+ return (
107
+ <div className="scroll-y" style={{ padding: "24px 28px", flex: 1, minWidth: 0 }}>
108
+ <div style={{ maxWidth: 1280, margin: "0 auto" }}>
109
+ {/* greeting */}
110
+ <div className="flex items-center justify-between" style={{ marginBottom: 18 }}>
111
+ <div>
112
+ <div className="mono muted-2" style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 4 }}>Monday Β· 20 April 2026</div>
113
+ <h1 className="editorial hero-title" style={{ margin: 0, fontSize: 32, fontWeight: 400, letterSpacing: "-0.02em" }}>
114
+ Good morning, Amara. Four things need your attention today.
115
+ </h1>
116
+ </div>
117
+ <div className="flex gap-8">
118
+ <button className="btn" onClick={() => window.openWeek()}><Icon name="calendar" size={13} /> This week</button>
119
+ <button className="btn primary" onClick={() => window.openDigest()}><Icon name="sparkle" size={13} /> Daily digest</button>
120
+ </div>
121
+ </div>
122
+
123
+ {/* focus row */}
124
+ <div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16, marginBottom: 16 }}>
125
+ {/* My focus */}
126
+ <div className="card" style={{ padding: 16 }}>
127
+ <div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
128
+ <div className="flex items-center gap-8">
129
+ <strong style={{ fontSize: 13 }}>My focus</strong>
130
+ <span className="chip">{myIssues.length} issues</span>
131
+ </div>
132
+ <button className="btn ghost sm" onClick={() => setView("issues")}>View all <Icon name="arrow-right" size={12} /></button>
133
+ </div>
134
+ <div className="ruled">
135
+ {myIssues.map(is => {
136
+ const proj = PROJECTS.find(p => p.id === is.project);
137
+ return (
138
+ <button key={is.id} className="flex items-center gap-12" style={{ width: "100%", padding: "10px 4px", textAlign: "left" }}
139
+ onClick={() => { setIssueId(is.id); setView("issue"); }}>
140
+ <PriorityGlyph level={is.priority} />
141
+ <span className="mono muted-2" style={{ fontSize: 11, width: 64, flexShrink: 0 }}>{is.id}</span>
142
+ <span className="status" >
143
+ <span className="s-dot" style={{ borderColor: `var(--status-${is.status === "backlog" ? "todo" : is.status})`, background: is.status === "done" ? `var(--status-${is.status})` : "transparent" }} />
144
+ </span>
145
+ <span className="flex-1 truncate" style={{ fontSize: 12.5 }}>{is.title}</span>
146
+ <span className="chip" style={{ color: proj.color, borderColor: "transparent", background: "transparent", padding: "2px 0" }}>
147
+ <span className="d" style={{ background: proj.color }} /> {proj.code}
148
+ </span>
149
+ <span className="muted-2 mono" style={{ fontSize: 11, width: 60, textAlign: "right" }}>{is.due || "β€”"}</span>
150
+ </button>
151
+ );
152
+ })}
153
+ </div>
154
+ </div>
155
+
156
+ {/* Iteration 42 progress */}
157
+ <div className="card" style={{ padding: 16 }}>
158
+ <div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
159
+ <strong style={{ fontSize: 13 }}>Iteration 42</strong>
160
+ <span className="chip"><span className="d" style={{ background: "var(--accent)" }} /> active</span>
161
+ </div>
162
+ <div className="muted mono" style={{ fontSize: 11, marginBottom: 12 }}>Apr 14 β€” Apr 28 Β· day 6 of 14</div>
163
+
164
+ <div style={{ display: "flex", alignItems: "baseline", gap: 6, marginBottom: 8 }}>
165
+ <span className="stat-num" style={{ fontSize: 42 }}>12</span>
166
+ <span className="muted" style={{ fontSize: 13 }}>/ 34 points done</span>
167
+ </div>
168
+
169
+ {/* segmented bar */}
170
+ <div style={{ display: "flex", height: 8, borderRadius: 4, overflow: "hidden", background: "var(--bg-3)", marginBottom: 10 }}>
171
+ <div style={{ flex: 12, background: "var(--accent)" }} />
172
+ <div style={{ flex: 8, background: "var(--violet)" }} />
173
+ <div style={{ flex: 5, background: "var(--amber)" }} />
174
+ <div style={{ flex: 9, background: "var(--bg-3)" }} />
175
+ </div>
176
+
177
+ <div className="flex gap-12" style={{ fontSize: 11.5, flexWrap: "wrap" }}>
178
+ <span className="flex items-center gap-4"><span style={{ width: 8, height: 8, borderRadius: 2, background: "var(--accent)" }} /> Done Β· 12</span>
179
+ <span className="flex items-center gap-4"><span style={{ width: 8, height: 8, borderRadius: 2, background: "var(--violet)" }} /> Review Β· 8</span>
180
+ <span className="flex items-center gap-4"><span style={{ width: 8, height: 8, borderRadius: 2, background: "var(--amber)" }} /> Doing Β· 5</span>
181
+ <span className="flex items-center gap-4"><span style={{ width: 8, height: 8, borderRadius: 2, background: "var(--bg-3)" }} /> Todo Β· 9</span>
182
+ </div>
183
+
184
+ <div className="divider" />
185
+
186
+ <div className="flex items-center justify-between" style={{ fontSize: 12 }}>
187
+ <span className="muted">On track for</span>
188
+ <span>Apr 26 <span className="muted-2">(2d early)</span></span>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ {/* second row */}
194
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
195
+ {/* PR queue */}
196
+ <div className="card" style={{ padding: 16 }}>
197
+ <div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
198
+ <strong style={{ fontSize: 13 }}>Your review queue</strong>
199
+ <button className="btn ghost sm" onClick={() => setView("prs")}>{activePRs.length}<Icon name="arrow-right" size={12} /></button>
200
+ </div>
201
+ <div className="ruled">
202
+ {activePRs.slice(0, 4).map(pr => {
203
+ const author = PEOPLE.find(p => p.id === pr.author);
204
+ return (
205
+ <div key={pr.id} style={{ padding: "9px 0" }}>
206
+ <div className="flex items-center gap-8" style={{ marginBottom: 4 }}>
207
+ <Icon name="pr" size={13} style={{ color: pr.status === "draft" ? "var(--fg-3)" : "var(--accent)" }} />
208
+ <span className="mono muted-2" style={{ fontSize: 11 }}>{pr.id}</span>
209
+ <span className="flex-1 truncate" style={{ fontSize: 12 }}>{pr.title}</span>
210
+ </div>
211
+ <div className="flex items-center gap-8 muted" style={{ fontSize: 11 }}>
212
+ <Avatar user={author} size="xs" />
213
+ <span className="mono">{author.handle}</span>
214
+ <span>Β·</span>
215
+ <span style={{ color: "var(--status-done)" }}>+{pr.additions}</span>
216
+ <span style={{ color: "var(--rose)" }}>βˆ’{pr.deletions}</span>
217
+ <span>Β·</span>
218
+ <span>{pr.checks.failed ? <span style={{ color: "var(--rose)" }}>βœ• {pr.checks.failed}</span> : <span style={{ color: "var(--status-done)" }}>βœ“ {pr.checks.passed}</span>}</span>
219
+ <span style={{ marginLeft: "auto" }}>{pr.updated}</span>
220
+ </div>
221
+ </div>
222
+ );
223
+ })}
224
+ </div>
225
+ </div>
226
+
227
+ {/* Activity stream */}
228
+ <div className="card" style={{ padding: 16 }}>
229
+ <div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
230
+ <strong style={{ fontSize: 13 }}>Activity</strong>
231
+ <span className="mono muted-2" style={{ fontSize: 11 }}>live</span>
232
+ </div>
233
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
234
+ {INBOX.slice(0, 5).map(n => {
235
+ const u = PEOPLE.find(p => p.id === n.from);
236
+ return (
237
+ <div key={n.id} className="flex gap-8" style={{ fontSize: 12 }}>
238
+ {u ? <Avatar user={u} size="xs" /> : <span className="avatar xs" style={{ background: "var(--bg-3)" }} />}
239
+ <div style={{ flex: 1, minWidth: 0, lineHeight: 1.4 }}>
240
+ <div>
241
+ {u && <strong>{u.name.split(" ")[0]}</strong>}{" "}
242
+ <span className="muted">{n.text}</span>{" "}
243
+ <span className="mono" style={{ color: "var(--accent)" }}>{n.target}</span>
244
+ </div>
245
+ <div className="muted-2 mono" style={{ fontSize: 10.5 }}>{n.time}</div>
246
+ </div>
247
+ </div>
248
+ );
249
+ })}
250
+ </div>
251
+ </div>
252
+
253
+ {/* Velocity */}
254
+ <div className="card" style={{ padding: 16 }}>
255
+ <div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
256
+ <strong style={{ fontSize: 13 }}>Team velocity</strong>
257
+ <span className="chip" style={{ color: "var(--status-done)" }}><Icon name="trend" size={11} /> +8%</span>
258
+ </div>
259
+ <div style={{ display: "flex", alignItems: "flex-end", gap: 6, height: 96, marginBottom: 10 }}>
260
+ {velocity.map((v, i) => (
261
+ <div key={i} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
262
+ <div className={`velo-bar ${i === velocity.length - 1 ? "" : "dim"}`} style={{ width: "100%", height: `${v * 7}px`, borderRadius: 3 }} />
263
+ </div>
264
+ ))}
265
+ </div>
266
+ <div className="flex justify-between muted-2 mono" style={{ fontSize: 10 }}>
267
+ {weekDays.map(d => <span key={d}>{d}</span>)}
268
+ </div>
269
+ <div className="divider" />
270
+ <div className="flex items-center justify-between" style={{ fontSize: 11.5 }}>
271
+ <span className="muted">Avg points / day</span>
272
+ <strong className="mono">7.8</strong>
273
+ </div>
274
+ <div className="flex items-center justify-between" style={{ fontSize: 11.5, marginTop: 4 }}>
275
+ <span className="muted">Cycle time p50</span>
276
+ <strong className="mono">2.4d</strong>
277
+ </div>
278
+ </div>
279
+ </div>
280
+
281
+ {/* Recent docs */}
282
+ <div className="card" style={{ padding: 16, marginBottom: 32 }}>
283
+ <div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
284
+ <strong style={{ fontSize: 13 }}>Recently edited docs</strong>
285
+ <button className="btn ghost sm" onClick={() => setView("docs")}>All docs <Icon name="arrow-right" size={12} /></button>
286
+ </div>
287
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12 }}>
288
+ {recentDocs.map((d, i) => {
289
+ const u = PEOPLE.find(p => p.id === d.author);
290
+ return (
291
+ <button key={i} className="flex col" style={{
292
+ padding: 14, border: "1px solid var(--border)", borderRadius: 10,
293
+ background: "var(--bg-0)", textAlign: "left", gap: 8
294
+ }} onClick={() => setView("docs")}>
295
+ <Icon name="docs" size={15} style={{ color: "var(--fg-2)" }} />
296
+ <div style={{ fontSize: 13, fontWeight: 500, lineHeight: 1.3 }}>{d.title}</div>
297
+ <div className="flex items-center gap-6 muted" style={{ fontSize: 11 }}>
298
+ <Avatar user={u} size="xs" />
299
+ <span>{u.name.split(" ")[0]}</span>
300
+ <span>Β·</span>
301
+ <span>{d.time}</span>
302
+ </div>
303
+ </button>
304
+ );
305
+ })}
306
+ </div>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ );
311
+ };
312
+
313
+ // ==== ISSUES ====
314
+ const IssuesView = ({ setView, setIssueId, cardStyle }) => {
315
+ const [mode, setMode] = React.useState("board");
316
+ const [grouping, setGrouping] = React.useState("status");
317
+ const [issues, setIssues] = React.useState(ISSUES);
318
+
319
+ React.useEffect(() => {
320
+ const load = () => window.apiFetch('GET', '/api/issues').then(setIssues).catch(() => {});
321
+ load();
322
+ document.addEventListener('meridian:refresh', load);
323
+ return () => document.removeEventListener('meridian:refresh', load);
324
+ }, []);
325
+
326
+ return (
327
+ <div className="flex col flex-1" style={{ minWidth: 0 }}>
328
+ <div className="page-header">
329
+ <div className="page-title">
330
+ <span className="eyebrow">PROJECT / AURORA</span>
331
+ <Icon name="chevron-right" size={12} style={{ color: "var(--fg-3)" }} />
332
+ <span>Issues</span>
333
+ <span className="chip mono">{issues.length}</span>
334
+ </div>
335
+ <div className="topbar-spacer" />
336
+ <button className="btn ghost sm" onClick={() => window.openFilter("issues")}><Icon name="filter" size={13} /> Filter</button>
337
+ <button className="btn ghost sm" onClick={() => window.openPicker({
338
+ title: "Group by",
339
+ options: [
340
+ { value: "status", label: "Status" },
341
+ { value: "priority", label: "Priority" },
342
+ { value: "assignee", label: "Assignee" },
343
+ { value: "project", label: "Project" },
344
+ { value: "label", label: "Label" },
345
+ ],
346
+ onChoose: (o) => setGrouping(o.value),
347
+ })}>Group: <span className="mono" style={{ color: "var(--fg-0)" }}>{grouping}</span> <Icon name="chevron-down" size={12} /></button>
348
+ <div className="vdivider" style={{ height: 20 }} />
349
+ <div className="segmented">
350
+ <button className={mode === "board" ? "on" : ""} onClick={() => setMode("board")}><Icon name="board" size={13} /> Board</button>
351
+ <button className={mode === "list" ? "on" : ""} onClick={() => setMode("list")}><Icon name="list" size={13} /> List</button>
352
+ <button className={mode === "timeline" ? "on" : ""} onClick={() => setMode("timeline")}><Icon name="timeline" size={13} /> Timeline</button>
353
+ </div>
354
+ <button className="btn ghost sm" onClick={() => window.openAI("What are the top blocking issues right now?", "general", { issues })}><Icon name="sparkle" size={13} /> AI Summary</button>
355
+ <button className="btn sm primary" onClick={() => window.openNewIssue()}><Icon name="plus" size={13} /> New issue</button>
356
+ </div>
357
+
358
+ {mode === "board" && <KanbanBoard setView={setView} setIssueId={setIssueId} cardStyle={cardStyle} issues={issues} />}
359
+ {mode === "list" && <IssuesList setView={setView} setIssueId={setIssueId} issues={issues} />}
360
+ {mode === "timeline" && <IssuesTimeline issues={issues} />}
361
+ </div>
362
+ );
363
+ };
364
+
365
+ const KanbanBoard = ({ setView, setIssueId, cardStyle, issues }) => {
366
+ const cols = [
367
+ { key: "backlog", label: "Backlog" },
368
+ { key: "todo", label: "Todo" },
369
+ { key: "progress", label: "In progress" },
370
+ { key: "review", label: "In review" },
371
+ { key: "done", label: "Done" },
372
+ ];
373
+ return (
374
+ <div className="scroll-y" style={{ flex: 1, padding: "12px 16px" }}>
375
+ <div style={{ display: "grid", gridTemplateColumns: `repeat(${cols.length}, minmax(260px, 1fr))`, gap: 12, minHeight: "100%" }}>
376
+ {cols.map(col => {
377
+ const items = issues.filter(i => i.status === col.key);
378
+ const klass = col.key === "backlog" ? "todo" : col.key;
379
+ return (
380
+ <div key={col.key} style={{ minWidth: 0, display: "flex", flexDirection: "column" }}>
381
+ <div className="flex items-center gap-8" style={{ padding: "6px 8px", marginBottom: 6 }}>
382
+ <span className={`status ${klass}`}><span className="s-dot" /></span>
383
+ <strong style={{ fontSize: 12 }}>{col.label}</strong>
384
+ <span className="mono muted-2" style={{ fontSize: 11 }}>{items.length}</span>
385
+ <div style={{ flex: 1 }} />
386
+ <button className="icon-btn" style={{ width: 20, height: 20 }} onClick={() => window.openNewIssue({ status: col.key })}><Icon name="plus" size={12} /></button>
387
+ </div>
388
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
389
+ {items.map(is => <KanbanCard key={is.id} issue={is} setView={setView} setIssueId={setIssueId} minimal={cardStyle === "minimal"} />)}
390
+ {items.length === 0 && (
391
+ <div style={{ padding: 24, textAlign: "center", color: "var(--fg-3)", border: "1px dashed var(--border)", borderRadius: 8, fontSize: 11.5 }}>
392
+ Drop here
393
+ </div>
394
+ )}
395
+ </div>
396
+ </div>
397
+ );
398
+ })}
399
+ </div>
400
+ </div>
401
+ );
402
+ };
403
+
404
+ const KanbanCard = ({ issue, setView, setIssueId, minimal }) => {
405
+ const proj = PROJECTS.find(p => p.id === issue.project);
406
+ const assignees = issue.assignees.map(id => PEOPLE.find(p => p.id === id));
407
+ if (minimal) {
408
+ return (
409
+ <button className="card" style={{ padding: "8px 10px", textAlign: "left", background: "var(--bg-1)" }}
410
+ onClick={() => { setIssueId(issue.id); setView("issue"); }}>
411
+ <div className="flex items-center gap-6" style={{ marginBottom: 4 }}>
412
+ <PriorityGlyph level={issue.priority} />
413
+ <span className="mono muted-2" style={{ fontSize: 10.5 }}>{issue.id}</span>
414
+ <div style={{ flex: 1 }} />
415
+ <AvatarStack users={assignees} size="xs" />
416
+ </div>
417
+ <div style={{ fontSize: 12.5, lineHeight: 1.3 }}>{issue.title}</div>
418
+ </button>
419
+ );
420
+ }
421
+ return (
422
+ <button className="card" style={{ padding: 10, textAlign: "left", background: "var(--bg-1)", display: "flex", flexDirection: "column", gap: 8 }}
423
+ onClick={() => { setIssueId(issue.id); setView("issue"); }}>
424
+ <div className="flex items-center gap-6">
425
+ <PriorityGlyph level={issue.priority} />
426
+ <span className="mono muted-2" style={{ fontSize: 10.5 }}>{issue.id}</span>
427
+ <div style={{ flex: 1 }} />
428
+ <span className="chip" style={{ color: proj.color, borderColor: "var(--border-subtle)", background: "transparent" }}>
429
+ <span className="d" style={{ background: proj.color }} /> {proj.code}
430
+ </span>
431
+ </div>
432
+ <div style={{ fontSize: 12.5, lineHeight: 1.35 }}>{issue.title}</div>
433
+ <div className="flex items-center gap-6" style={{ flexWrap: "wrap" }}>
434
+ {issue.labels.slice(0, 2).map(lid => {
435
+ const lab = LABELS.find(l => l.id === lid);
436
+ return <span key={lid} className="tag" style={{ color: lab.color }}>#{lab.name}</span>;
437
+ })}
438
+ </div>
439
+ <div className="flex items-center gap-8" style={{ fontSize: 10.5, color: "var(--fg-3)" }}>
440
+ <AvatarStack users={assignees} size="xs" />
441
+ <div style={{ flex: 1 }} />
442
+ {issue.branch && <span className="flex items-center gap-2"><Icon name="branch" size={10} /></span>}
443
+ {issue.commentCount > 0 && <span className="flex items-center gap-2 mono"><Icon name="message" size={10} />{issue.commentCount}</span>}
444
+ {issue.due && <span className="flex items-center gap-2 mono"><Icon name="calendar" size={10} />{issue.due}</span>}
445
+ </div>
446
+ </button>
447
+ );
448
+ };
449
+
450
+ const IssuesList = ({ setView, setIssueId, issues }) => {
451
+ const groups = {};
452
+ issues.forEach(i => { (groups[i.status] ||= []).push(i); });
453
+
454
+ return (
455
+ <div className="scroll-y" style={{ flex: 1 }}>
456
+ {STATUS.map(s => {
457
+ const items = groups[s] || [];
458
+ if (!items.length) return null;
459
+ const klass = s === "backlog" ? "todo" : s;
460
+ return (
461
+ <div key={s}>
462
+ <div className="flex items-center gap-8" style={{ padding: "10px 20px", background: "var(--bg-1)", borderTop: "1px solid var(--border)", borderBottom: "1px solid var(--border)" }}>
463
+ <span className={`status ${klass}`}><span className="s-dot" /></span>
464
+ <strong style={{ fontSize: 12 }}>{STATUS_META[s].label}</strong>
465
+ <span className="mono muted-2" style={{ fontSize: 11 }}>{items.length}</span>
466
+ <div style={{ flex: 1 }} />
467
+ <button className="icon-btn" style={{ width: 20, height: 20 }} onClick={() => window.openNewIssue({ status: s })}><Icon name="plus" size={12} /></button>
468
+ </div>
469
+ {items.map(is => {
470
+ const proj = PROJECTS.find(p => p.id === is.project);
471
+ const assignees = is.assignees.map(id => PEOPLE.find(p => p.id === id));
472
+ return (
473
+ <button key={is.id} className="row" style={{ width: "100%", textAlign: "left", paddingLeft: 20, paddingRight: 20 }}
474
+ onClick={() => { setIssueId(is.id); setView("issue"); }}>
475
+ <PriorityGlyph level={is.priority} />
476
+ <span className="mono muted-2" style={{ fontSize: 11, width: 70, flexShrink: 0 }}>{is.id}</span>
477
+ <span className="flex-1 truncate">{is.title}</span>
478
+ {is.labels.slice(0, 2).map(lid => {
479
+ const lab = LABELS.find(l => l.id === lid);
480
+ return <span key={lid} className="tag" style={{ color: lab.color, flexShrink: 0 }}>#{lab.name}</span>;
481
+ })}
482
+ <span className="chip" style={{ color: proj.color, flexShrink: 0 }}>
483
+ <span className="d" style={{ background: proj.color }} /> {proj.code}
484
+ </span>
485
+ {is.sprint && <span className="mono muted-2" style={{ fontSize: 11, flexShrink: 0 }}>{is.sprint}</span>}
486
+ <span className="muted-2 mono" style={{ fontSize: 11, width: 50, textAlign: "right", flexShrink: 0 }}>{is.due || "β€”"}</span>
487
+ <AvatarStack users={assignees} size="xs" />
488
+ </button>
489
+ );
490
+ })}
491
+ </div>
492
+ );
493
+ })}
494
+ </div>
495
+ );
496
+ };
497
+
498
+ const IssuesTimeline = ({ issues }) => {
499
+ // Weeks Apr 7 – Jun 1 (8 weeks)
500
+ const weeks = ["Apr 07","Apr 14","Apr 21","Apr 28","May 05","May 12","May 19","May 26"];
501
+ const items = issues.filter(i => i.due).slice(0, 10).map((is, i) => ({
502
+ issue: is,
503
+ startWeek: (i * 3) % 5,
504
+ span: 1 + (i % 3),
505
+ }));
506
+ return (
507
+ <div className="scroll-y" style={{ flex: 1, padding: 16 }}>
508
+ <div style={{ display: "grid", gridTemplateColumns: `240px repeat(${weeks.length}, 1fr)`, fontSize: 11, color: "var(--fg-3)", borderBottom: "1px solid var(--border)", paddingBottom: 6 }}>
509
+ <div />
510
+ {weeks.map(w => <div key={w} className="mono" style={{ padding: "0 6px" }}>{w}</div>)}
511
+ </div>
512
+ <div>
513
+ {items.map(({ issue, startWeek, span }) => {
514
+ const proj = PROJECTS.find(p => p.id === issue.project);
515
+ return (
516
+ <div key={issue.id} style={{ display: "grid", gridTemplateColumns: `240px repeat(${weeks.length}, 1fr)`, alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--border-subtle)" }}>
517
+ <div className="flex items-center gap-6 truncate" style={{ fontSize: 12 }}>
518
+ <PriorityGlyph level={issue.priority} />
519
+ <span className="mono muted-2" style={{ fontSize: 10.5 }}>{issue.id}</span>
520
+ <span className="truncate">{issue.title}</span>
521
+ </div>
522
+ {weeks.map((_, i) => (
523
+ <div key={i} style={{ height: 24, position: "relative", borderLeft: "1px dashed var(--border-subtle)" }}>
524
+ {i === startWeek && (
525
+ <div style={{
526
+ position: "absolute", top: 3, bottom: 3, left: 4, right: 4,
527
+ width: `calc(${span * 100}% - 8px)`,
528
+ background: `color-mix(in oklch, ${proj.color} 30%, var(--bg-1))`,
529
+ borderLeft: `3px solid ${proj.color}`,
530
+ borderRadius: 4,
531
+ padding: "2px 6px", fontSize: 10.5, fontFamily: "var(--font-mono)",
532
+ color: "var(--fg-0)", overflow: "hidden", whiteSpace: "nowrap"
533
+ }}>
534
+ {issue.id}
535
+ </div>
536
+ )}
537
+ </div>
538
+ ))}
539
+ </div>
540
+ );
541
+ })}
542
+ </div>
543
+ </div>
544
+ );
545
+ };
546
+
547
+ Object.assign(window, { HomeView, IssuesView });
views-more.jsx ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Sprints, Pull requests, Team, Settings views
2
+
3
+ // ==== SPRINTS ====
4
+ const SprintsView = ({ setView, setIssueId }) => {
5
+ const active = SPRINTS.find(s => s.active);
6
+ const sprintIssues = ISSUES.filter(i => i.sprint === "Iter 42");
7
+ const byStatus = { backlog: [], todo: [], progress: [], review: [], done: [] };
8
+ sprintIssues.forEach(i => byStatus[i.status]?.push(i));
9
+
10
+ // burndown data (ideal vs actual)
11
+ const days = 14;
12
+ const ideal = Array.from({ length: days + 1 }, (_, i) => 34 - (34 / days) * i);
13
+ const actual = [34, 34, 33, 31, 30, 28, 25, null, null, null, null, null, null, null, null];
14
+ // build path
15
+ const W = 100, H = 100;
16
+ const toPath = (pts, prop = "actual") => pts.map((v, i) => v == null ? null : `${(i / days) * W},${H - (v / 34) * H}`).filter(Boolean).join(" ");
17
+
18
+ return (
19
+ <div className="flex col flex-1" style={{ minWidth: 0 }}>
20
+ <div className="page-header">
21
+ <div className="page-title">
22
+ <Icon name="sprint" size={15} style={{ color: "var(--accent)" }} />
23
+ <span>Iteration 42</span>
24
+ <span className="chip" style={{ color: "var(--accent)" }}><span className="d" style={{ background: "var(--accent)" }} /> active</span>
25
+ <span className="mono muted-2" style={{ fontSize: 11 }}>Apr 14 – Apr 28 Β· day 6/14</span>
26
+ </div>
27
+ <div className="topbar-spacer" />
28
+ <button className="btn ghost sm" onClick={() => window.openSprintReport()}><Icon name="download" size={13} /> Report</button>
29
+ <button className="btn ghost sm" onClick={() => window.openRetro()}><Icon name="sparkle" size={13} /> Retrospective draft</button>
30
+ <button className="btn ghost sm" onClick={() => window.openAI("Summarize Iteration 42 progress", "sprint", { issues: sprintIssues })}><Icon name="sparkle" size={13} /> AI Summary</button>
31
+ <button className="btn sm" onClick={() => { if(confirm("Complete iteration 42? 22pts will move to backlog.")) window.toast("Iteration 42 closed"); }}>Complete iteration</button>
32
+ </div>
33
+
34
+ <div className="scroll-y" style={{ flex: 1, padding: 20 }}>
35
+ <div style={{ maxWidth: 1320, margin: "0 auto" }}>
36
+ {/* Top summary */}
37
+ <div style={{ display: "grid", gridTemplateColumns: "2fr 3fr", gap: 16, marginBottom: 20 }}>
38
+ {/* Burndown */}
39
+ <div className="card" style={{ padding: 16 }}>
40
+ <div className="flex items-center justify-between" style={{ marginBottom: 8 }}>
41
+ <strong style={{ fontSize: 13 }}>Burndown</strong>
42
+ <span className="chip mono" style={{ color: "var(--status-done)" }}>βˆ’22 pts</span>
43
+ </div>
44
+ <div style={{ position: "relative", height: 180, marginTop: 8 }}>
45
+ <svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: "100%", height: "100%", overflow: "visible" }}>
46
+ {/* gridlines */}
47
+ {[0, 25, 50, 75, 100].map(y => (
48
+ <line key={y} x1="0" y1={y} x2="100" y2={y} stroke="var(--border-subtle)" strokeWidth="0.2" vectorEffect="non-scaling-stroke" />
49
+ ))}
50
+ {/* ideal */}
51
+ <polyline points={toPath(ideal)} fill="none" stroke="var(--fg-3)" strokeWidth="0.4" strokeDasharray="1 1" vectorEffect="non-scaling-stroke" />
52
+ {/* actual */}
53
+ <polyline className="burndown-actual" points={toPath(actual)} fill="none" stroke="var(--accent)" strokeWidth="0.8" vectorEffect="non-scaling-stroke" />
54
+ {/* today marker */}
55
+ <line x1={(6/days)*100} y1="0" x2={(6/days)*100} y2="100" stroke="var(--accent-dim)" strokeWidth="0.3" vectorEffect="non-scaling-stroke" />
56
+ </svg>
57
+ </div>
58
+ <div className="flex justify-between muted-2 mono" style={{ fontSize: 10, marginTop: 6 }}>
59
+ <span>Apr 14</span>
60
+ <span>Apr 21</span>
61
+ <span>Apr 28</span>
62
+ </div>
63
+ <div className="divider" />
64
+ <div className="flex items-center justify-between" style={{ fontSize: 12 }}>
65
+ <div>
66
+ <div className="muted" style={{ fontSize: 11 }}>Remaining</div>
67
+ <div className="mono" style={{ fontSize: 15 }}>22<span className="muted-2" style={{ fontSize: 11 }}> pts</span></div>
68
+ </div>
69
+ <div>
70
+ <div className="muted" style={{ fontSize: 11 }}>Required / day</div>
71
+ <div className="mono" style={{ fontSize: 15 }}>2.75</div>
72
+ </div>
73
+ <div>
74
+ <div className="muted" style={{ fontSize: 11 }}>Confidence</div>
75
+ <div className="mono" style={{ fontSize: 15, color: "var(--status-done)" }}>82%</div>
76
+ </div>
77
+ </div>
78
+ </div>
79
+
80
+ {/* Status breakdown + goals */}
81
+ <div style={{ display: "grid", gridTemplateRows: "auto 1fr", gap: 16 }}>
82
+ <div className="card" style={{ padding: 16 }}>
83
+ <strong style={{ fontSize: 13, marginBottom: 10, display: "block" }}>By status</strong>
84
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 12 }}>
85
+ {STATUS.map(s => {
86
+ const items = byStatus[s] || [];
87
+ const klass = s === "backlog" ? "todo" : s;
88
+ return (
89
+ <div key={s}>
90
+ <div className={`status ${klass} flex items-center gap-6`} style={{ fontSize: 11, marginBottom: 4 }}><span className="s-dot" />{STATUS_META[s].label}</div>
91
+ <div style={{ display: "flex", alignItems: "baseline", gap: 4 }}>
92
+ <span className="stat-num" style={{ fontSize: 30 }}>{items.length}</span>
93
+ <span className="muted-2 mono" style={{ fontSize: 10.5 }}>issues</span>
94
+ </div>
95
+ </div>
96
+ );
97
+ })}
98
+ </div>
99
+ </div>
100
+
101
+ <div className="card" style={{ padding: 16 }}>
102
+ <div className="flex items-center justify-between" style={{ marginBottom: 10 }}>
103
+ <strong style={{ fontSize: 13 }}>Iteration goals</strong>
104
+ <span className="mono muted-2" style={{ fontSize: 11 }}>2 of 3 on track</span>
105
+ </div>
106
+ {[
107
+ { text: "Ship Aurora canvas performance rebuild to canary", progress: 62, status: "progress" },
108
+ { text: "Close Tessera token migration for iOS + Android", progress: 85, status: "progress" },
109
+ { text: "Freeze SSO infra ahead of security audit", progress: 30, status: "blocked" },
110
+ ].map((g, i) => (
111
+ <div key={i} className="flex items-center gap-10" style={{ padding: "8px 0", borderTop: i > 0 ? "1px solid var(--border-subtle)" : "none" }}>
112
+ <span style={{ width: 6, height: 6, borderRadius: "50%", background: g.status === "blocked" ? "var(--rose)" : "var(--accent)", flexShrink: 0 }} />
113
+ <span className="flex-1" style={{ fontSize: 12.5 }}>{g.text}</span>
114
+ <div style={{ width: 90, height: 3, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden" }}>
115
+ <div style={{ width: `${g.progress}%`, height: "100%", background: g.status === "blocked" ? "var(--rose)" : "var(--accent)" }} />
116
+ </div>
117
+ <span className="mono muted-2" style={{ fontSize: 11, width: 34, textAlign: "right" }}>{g.progress}%</span>
118
+ </div>
119
+ ))}
120
+ </div>
121
+ </div>
122
+ </div>
123
+
124
+ {/* Scope list */}
125
+ <div className="card" style={{ padding: 0, overflow: "hidden" }}>
126
+ <div className="flex items-center gap-8" style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)" }}>
127
+ <strong style={{ fontSize: 13 }}>Scope</strong>
128
+ <span className="mono muted-2" style={{ fontSize: 11 }}>{sprintIssues.length} issues Β· 34 pts</span>
129
+ <div style={{ flex: 1 }} />
130
+ <button className="btn ghost sm" onClick={() => window.openFilter("iteration")}><Icon name="filter" size={13} /> Filter</button>
131
+ <button className="btn ghost sm" onClick={() => window.openNewIssue({ sprint: "Iter 42" })}><Icon name="plus" size={13} /> Add from backlog</button>
132
+ </div>
133
+ <div>
134
+ {sprintIssues.map((is) => {
135
+ const proj = PROJECTS.find(p => p.id === is.project);
136
+ const assignees = is.assignees.map(id => PEOPLE.find(p => p.id === id));
137
+ const klass = is.status === "backlog" ? "todo" : is.status;
138
+ return (
139
+ <button key={is.id} className="row" style={{ width: "100%", textAlign: "left" }}
140
+ onClick={() => { setIssueId(is.id); setView("issue"); }}>
141
+ <PriorityGlyph level={is.priority} />
142
+ <span className="mono muted-2" style={{ fontSize: 11, width: 70, flexShrink: 0 }}>{is.id}</span>
143
+ <span className={`status ${klass}`}><span className="s-dot" /></span>
144
+ <span className="flex-1 truncate">{is.title}</span>
145
+ <span className="chip" style={{ color: proj.color }}>
146
+ <span className="d" style={{ background: proj.color }} /> {proj.code}
147
+ </span>
148
+ <span className="mono muted-2" style={{ fontSize: 11, width: 30, textAlign: "right" }}>{is.estimate}</span>
149
+ <AvatarStack users={assignees} size="xs" />
150
+ <span className="muted-2 mono" style={{ fontSize: 11, width: 50, textAlign: "right" }}>{is.due || "β€”"}</span>
151
+ </button>
152
+ );
153
+ })}
154
+ </div>
155
+ </div>
156
+
157
+ {/* Other iterations */}
158
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginTop: 16 }}>
159
+ {SPRINTS.map(s => (
160
+ <div key={s.id} className="card" style={{ padding: 12, opacity: s.active ? 1 : 0.85 }}>
161
+ <div className="flex items-center justify-between" style={{ marginBottom: 6 }}>
162
+ <strong style={{ fontSize: 12 }}>{s.name}</strong>
163
+ {s.active && <span className="chip" style={{ color: "var(--accent)" }}><span className="d" style={{ background: "var(--accent)" }} /> active</span>}
164
+ </div>
165
+ <div className="mono muted-2" style={{ fontSize: 10.5, marginBottom: 10 }}>{s.range}</div>
166
+ <div style={{ height: 4, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden", marginBottom: 8 }}>
167
+ <div style={{ width: `${(s.done / s.scope) * 100}%`, height: "100%", background: s.active ? "var(--accent)" : "var(--fg-3)" }} />
168
+ </div>
169
+ <div className="flex items-center justify-between" style={{ fontSize: 11 }}>
170
+ <span className="mono">{s.done}/{s.scope}</span>
171
+ <span className="muted-2 mono">v{s.velocity}</span>
172
+ </div>
173
+ </div>
174
+ ))}
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ );
180
+ };
181
+
182
+ // ==== PULL REQUESTS ====
183
+ const PRsView = () => {
184
+ const [filter, setFilter] = React.useState("all");
185
+ const [prs, setPrs] = React.useState(PRS);
186
+ const [aiOpen, setAiOpen] = React.useState(false);
187
+ const [aiText, setAiText] = React.useState("");
188
+ const [aiLoading, setAiLoading] = React.useState(false);
189
+
190
+ React.useEffect(() => {
191
+ const load = () => window.apiFetch('GET', '/api/prs').then(setPrs).catch(() => setPrs([...PRS]));
192
+ load();
193
+ document.addEventListener('meridian:refresh', load);
194
+ return () => document.removeEventListener('meridian:refresh', load);
195
+ }, []);
196
+
197
+ const list = prs.filter(pr => filter === "all" || pr.status === filter);
198
+
199
+ const runPRAnalysis = async () => {
200
+ const key = window.getAmdUrl ? window.getAmdUrl() : '';
201
+ if (!key) { window.openAI("Analyze all open pull requests", "pr", { prs: list }); return; }
202
+ setAiOpen(true); setAiText(""); setAiLoading(true);
203
+
204
+ const prSummary = list.map(p => ({
205
+ id: p.id, title: p.title, status: p.status, branch: p.branch,
206
+ additions: p.additions, deletions: p.deletions,
207
+ checks: p.checks, updated: p.updated,
208
+ }));
209
+
210
+ try {
211
+ const res = await fetch(key, {
212
+ method: 'POST',
213
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer dummy-key` },
214
+ body: JSON.stringify({
215
+ model: 'llama-3.3-70b-versatile', max_tokens: 800, stream: true,
216
+ messages: [
217
+ { role: 'system', content: 'You are the Meridian PR Analyst. Analyze pull requests and give: 1) Overall risk level (High/Medium/Low), 2) Top 3 action items for the team, 3) Any PRs with failing checks or missing reviewers. Be direct and concise. Use markdown.' },
218
+ { role: 'user', content: `Analyze these ${prSummary.length} PRs:\n${JSON.stringify(prSummary, null, 2)}` },
219
+ ],
220
+ }),
221
+ });
222
+ const reader = res.body.getReader(); const decoder = new TextDecoder(); let full = '';
223
+ while (true) {
224
+ const { done, value } = await reader.read(); if (done) break;
225
+ for (const line of decoder.decode(value).split('\n').filter(l => l.startsWith('data: ') && l !== 'data: [DONE]')) {
226
+ try { const delta = JSON.parse(line.slice(6)).choices?.[0]?.delta?.content || ''; full += delta; setAiText(full); } catch(_) {}
227
+ }
228
+ }
229
+ } catch(e) { setAiText(`⚠ API Error: ${e?.message || String(e)}`); }
230
+ setAiLoading(false);
231
+ };
232
+
233
+ const renderMd = t => t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
234
+ .replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
235
+ .replace(/`([^`]+)`/g,"<code style='background:var(--bg-3);padding:1px 4px;border-radius:3px;font-family:var(--font-mono);font-size:0.9em'>$1</code>")
236
+ .replace(/\n/g,'<br>');
237
+
238
+ return (
239
+ <div className="flex col flex-1" style={{ minWidth: 0 }}>
240
+ <div className="page-header">
241
+ <div className="page-title">
242
+ <Icon name="pr" size={15} />
243
+ <span>Pull requests</span>
244
+ <span className="chip mono">{prs.length}</span>
245
+ </div>
246
+ <div className="topbar-spacer" />
247
+ <div className="segmented">
248
+ {[
249
+ { id: "all", label: "All" },
250
+ { id: "open", label: "Open" },
251
+ { id: "review", label: "In review" },
252
+ { id: "draft", label: "Drafts" },
253
+ { id: "merged", label: "Merged" },
254
+ ].map(t => (
255
+ <button key={t.id} className={filter === t.id ? "on" : ""} onClick={() => setFilter(t.id)}>{t.label}</button>
256
+ ))}
257
+ </div>
258
+ <button className="btn ghost sm" onClick={() => window.openFilter("pull requests")}><Icon name="filter" size={13} /> Filter</button>
259
+ <button className="btn ghost sm" style={{ color: "var(--accent)" }} onClick={runPRAnalysis}><Icon name="sparkle" size={13} /> AI Analysis</button>
260
+ <button className="btn sm primary" onClick={() => window.openNewPR()}><Icon name="plus" size={13} /> New PR</button>
261
+ </div>
262
+
263
+ {/* AI Analysis panel */}
264
+ {aiOpen && (
265
+ <div style={{ margin: "12px 16px 0", padding: 16, background: "var(--bg-1)", border: "1px solid var(--border)", borderRadius: 10, position: "relative" }}>
266
+ <div className="flex items-center gap-8" style={{ marginBottom: 10 }}>
267
+ <Icon name="sparkle" size={14} style={{ color: "var(--accent)" }} />
268
+ <strong style={{ fontSize: 13 }}>PR Analyst</strong>
269
+ {aiLoading && <span className="chip mono" style={{ color: "var(--amber)" }}>analyzing…</span>}
270
+ <div style={{ flex: 1 }} />
271
+ <button className="icon-btn" onClick={() => setAiOpen(false)}><Icon name="x" size={13} /></button>
272
+ </div>
273
+ {aiText
274
+ ? <div style={{ fontSize: 13, lineHeight: 1.6, color: "var(--fg-1)" }} dangerouslySetInnerHTML={{ __html: renderMd(aiText) }} />
275
+ : <div style={{ display: "flex", gap: 5, alignItems: "center" }}>
276
+ {[0,1,2].map(i => <span key={i} style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)", animation: `pulse 1s ease-in-out ${i*0.2}s infinite` }} />)}
277
+ </div>
278
+ }
279
+ {!aiLoading && aiText && (
280
+ <button className="btn ghost sm" style={{ marginTop: 10 }} onClick={() => window.openAI("Dive deeper into these PRs", "pr", { prs: list })}>
281
+ <Icon name="sparkle" size={12} /> Open in AI panel
282
+ </button>
283
+ )}
284
+ </div>
285
+ )}
286
+
287
+ <div className="scroll-y" style={{ flex: 1 }}>
288
+ {list.map((pr, i) => {
289
+ const author = PEOPLE.find(p => p.id === pr.author);
290
+ const reviewers = pr.reviewers.map(id => PEOPLE.find(p => p.id === id));
291
+ const statusColor = { open: "var(--accent)", review: "var(--violet)", draft: "var(--fg-3)", merged: "var(--violet)" }[pr.status];
292
+ return (
293
+ <button key={pr.id} onClick={() => window.openPR(pr)} style={{ padding: "14px 24px", borderBottom: "1px solid var(--border-subtle)", display: "flex", alignItems: "flex-start", gap: 12, width: "100%", textAlign: "left", background: "transparent", border: "none", borderBottom: "1px solid var(--border-subtle)", cursor: "pointer" }}>
294
+ <Icon name="pr" size={18} style={{ color: statusColor, marginTop: 1 }} />
295
+ <div style={{ flex: 1, minWidth: 0 }}>
296
+ <div className="flex items-center gap-8" style={{ marginBottom: 4 }}>
297
+ <span style={{ fontSize: 13.5, fontWeight: 500 }}>{pr.title}</span>
298
+ <span className="chip" style={{ color: statusColor, textTransform: "capitalize" }}>{pr.status}</span>
299
+ {pr.issue && <span className="chip mono">{pr.issue}</span>}
300
+ </div>
301
+ <div className="flex items-center gap-10 muted" style={{ fontSize: 11.5, flexWrap: "wrap" }}>
302
+ <span className="mono">{pr.id}</span>
303
+ <span>opened by <strong className="mono" style={{ color: "var(--fg-1)" }}>@{author?.handle || "you"}</strong></span>
304
+ <span className="flex items-center gap-4"><Icon name="branch" size={11} /><span className="mono">{pr.branch}</span> β†’ <span className="mono">{pr.base || "main"}</span></span>
305
+ <span>Β· updated {pr.updated}</span>
306
+ </div>
307
+ </div>
308
+ {/* Checks */}
309
+ <div className="flex items-center gap-6" style={{ fontSize: 11, flexShrink: 0 }}>
310
+ {pr.checks?.passed > 0 && <span className="flex items-center gap-2 mono" style={{ color: "var(--status-done)" }}><Icon name="check" size={11} strokeWidth={2.5} />{pr.checks.passed}</span>}
311
+ {pr.checks?.failed > 0 && <span className="flex items-center gap-2 mono" style={{ color: "var(--rose)" }}><Icon name="x" size={11} strokeWidth={2.5} />{pr.checks.failed}</span>}
312
+ {pr.checks?.running > 0 && <span className="flex items-center gap-2 mono" style={{ color: "var(--amber)" }}><Icon name="clock" size={11} />{pr.checks.running}</span>}
313
+ </div>
314
+ <div className="flex items-center gap-2 mono" style={{ fontSize: 11, flexShrink: 0, minWidth: 90, justifyContent: "flex-end" }}>
315
+ <span style={{ color: "var(--status-done)" }}>+{pr.additions}</span>
316
+ <span style={{ color: "var(--rose)" }}>βˆ’{pr.deletions || 0}</span>
317
+ </div>
318
+ <div style={{ flexShrink: 0, width: 60, display: "flex", justifyContent: "flex-end" }}>
319
+ <AvatarStack users={reviewers} size="xs" />
320
+ </div>
321
+ </button>
322
+ );
323
+ })}
324
+ </div>
325
+ </div>
326
+ );
327
+ };
328
+ // ==== TEAM ====
329
+ const TeamView = () => {
330
+ const teams = [
331
+ { name: "Platform", members: ["u5","u7","u9"], color: "oklch(0.78 0.14 220)" },
332
+ { name: "Design systems", members: ["u1","u4"], color: "oklch(0.72 0.17 300)" },
333
+ { name: "Canvas", members: ["u2","u3","u6"], color: "oklch(0.80 0.16 145)" },
334
+ { name: "Security", members: ["u8","u9"], color: "oklch(0.72 0.17 20)" },
335
+ ];
336
+
337
+ return (
338
+ <div className="flex col flex-1" style={{ minWidth: 0 }}>
339
+ <div className="page-header">
340
+ <div className="page-title"><span>Team</span><span className="mono muted-2" style={{ fontSize: 12 }}>Β· Helix workspace</span></div>
341
+ <div className="topbar-spacer" />
342
+ <div className="search" style={{ minWidth: 220 }}>
343
+ <Icon name="search" size={12} />
344
+ <input placeholder="Find a teammate…" />
345
+ </div>
346
+ <button className="btn ghost sm" onClick={() => window.openAI("Who has the highest workload this sprint?", "team", { people: typeof PEOPLE !== 'undefined' ? PEOPLE : [] })}><Icon name="sparkle" size={13} /> AI Summary</button>
347
+ <button className="btn sm primary" onClick={() => window.openInvite()}><Icon name="plus" size={13} /> Invite</button>
348
+ </div>
349
+
350
+ <div className="scroll-y" style={{ flex: 1, padding: 24 }}>
351
+ <div style={{ maxWidth: 1200, margin: "0 auto" }}>
352
+ {/* Teams */}
353
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 24 }}>
354
+ {teams.map(t => (
355
+ <div key={t.name} className="card" style={{ padding: 14 }}>
356
+ <div className="flex items-center gap-8" style={{ marginBottom: 10 }}>
357
+ <span style={{ width: 8, height: 8, borderRadius: 2, background: t.color }} />
358
+ <strong style={{ fontSize: 13 }}>{t.name}</strong>
359
+ </div>
360
+ <div className="flex items-center gap-8">
361
+ <AvatarStack users={t.members.map(id => PEOPLE.find(p => p.id === id))} size="sm" max={4} />
362
+ <span className="muted-2 mono" style={{ fontSize: 11 }}>{t.members.length} people</span>
363
+ </div>
364
+ </div>
365
+ ))}
366
+ </div>
367
+
368
+ {/* Directory */}
369
+ <div className="card" style={{ overflow: "hidden" }}>
370
+ <div className="flex items-center" style={{ padding: "10px 16px", borderBottom: "1px solid var(--border)", fontSize: 11, color: "var(--fg-3)", textTransform: "uppercase", letterSpacing: "0.08em", fontWeight: 500 }}>
371
+ <span style={{ width: 240 }}>Name</span>
372
+ <span style={{ flex: 1 }}>Role</span>
373
+ <span style={{ width: 140 }}>Team</span>
374
+ <span style={{ width: 90, textAlign: "right" }}>Open</span>
375
+ <span style={{ width: 110, textAlign: "right" }}>Reviewing</span>
376
+ <span style={{ width: 80, textAlign: "right" }}>Load</span>
377
+ </div>
378
+ {PEOPLE.map((u, i) => {
379
+ const load = [62, 81, 45, 73, 58, 34, 90, 66, 51][i];
380
+ const team = teams.find(t => t.members.includes(u.id))?.name || "β€”";
381
+ const roles = ["Staff Engineer", "Sr Engineer", "Engineer", "Design Eng", "PM", "Design Lead", "SRE", "Security Eng", "Engineer"];
382
+ const open = [5, 8, 3, 6, 4, 2, 9, 7, 4][i];
383
+ const reviewing = [2, 4, 1, 3, 2, 1, 5, 3, 2][i];
384
+ return (
385
+ <button key={u.id} className="row" style={{ padding: "0 16px", width: "100%", textAlign: "left", background: "transparent", border: "none", borderBottom: "1px solid var(--border-subtle)", cursor: "pointer" }} onClick={() => window.openTeammate(u)}>
386
+ <span className="flex items-center gap-10" style={{ width: 240, flexShrink: 0 }}>
387
+ <Avatar user={u} size="sm" />
388
+ <span style={{ fontSize: 12.5 }}>{u.name}</span>
389
+ <span className="mono muted-2" style={{ fontSize: 10.5 }}>@{u.handle}</span>
390
+ </span>
391
+ <span style={{ flex: 1, fontSize: 12 }} className="muted">{roles[i]}</span>
392
+ <span style={{ width: 140, fontSize: 11.5 }}>{team}</span>
393
+ <span style={{ width: 90, textAlign: "right" }} className="mono">{open}</span>
394
+ <span style={{ width: 110, textAlign: "right" }} className="mono">{reviewing}</span>
395
+ <span style={{ width: 80, display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 6 }}>
396
+ <div style={{ width: 40, height: 3, background: "var(--bg-3)", borderRadius: 2, overflow: "hidden" }}>
397
+ <div style={{ width: `${load}%`, height: "100%", background: load > 80 ? "var(--rose)" : load > 60 ? "var(--amber)" : "var(--accent)" }} />
398
+ </div>
399
+ <span className="mono muted-2" style={{ fontSize: 10.5, width: 22 }}>{load}</span>
400
+ </span>
401
+ </button>
402
+ );
403
+ })}
404
+ </div>
405
+ </div>
406
+ </div>
407
+ </div>
408
+ );
409
+ };
410
+
411
+ // ==== INBOX (full page) ====
412
+ const InboxView = () => {
413
+ const [selected, setSelected] = React.useState(0);
414
+ const n = INBOX[selected];
415
+ const user = PEOPLE.find(p => p.id === n.from);
416
+
417
+ return (
418
+ <div className="flex flex-1" style={{ minWidth: 0 }}>
419
+ <aside style={{ width: 400, flexShrink: 0, borderRight: "1px solid var(--border)", display: "flex", flexDirection: "column" }}>
420
+ <div className="flex items-center gap-8" style={{ padding: "10px 14px", borderBottom: "1px solid var(--border)" }}>
421
+ <strong style={{ fontSize: 13 }}>Inbox</strong>
422
+ <span className="chip mono">{INBOX.filter(n => n.unread).length}</span>
423
+ <div style={{ flex: 1 }} />
424
+ <button className="btn ghost sm" onClick={() => window.openAI("Summarize my inbox notifications", "general", { inbox: INBOX })}><Icon name="sparkle" size={13} /> AI Summary</button>
425
+ <div className="segmented">
426
+ <button className="on">All</button>
427
+ <button onClick={() => window.toast("Mentions only")}>Mentions</button>
428
+ <button onClick={() => window.toast("Assigned only")}>Assigned</button>
429
+ </div>
430
+ </div>
431
+ <div className="scroll-y" style={{ flex: 1 }}>
432
+ {INBOX.map((n, i) => {
433
+ const u = PEOPLE.find(p => p.id === n.from);
434
+ return (
435
+ <button key={n.id} onClick={() => setSelected(i)} style={{
436
+ width: "100%", padding: "12px 14px", borderBottom: "1px solid var(--border-subtle)",
437
+ textAlign: "left", display: "flex", gap: 10, alignItems: "flex-start",
438
+ background: selected === i ? "var(--bg-2)" : "transparent",
439
+ borderLeft: selected === i ? "2px solid var(--accent)" : "2px solid transparent",
440
+ }}>
441
+ {n.unread && <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)", flexShrink: 0, marginTop: 6 }} />}
442
+ {!n.unread && <span style={{ width: 6, flexShrink: 0 }} />}
443
+ {u ? <Avatar user={u} size="sm" /> : <span className="avatar sm" style={{ background: "var(--bg-3)" }} />}
444
+ <div style={{ flex: 1, minWidth: 0 }}>
445
+ <div style={{ fontSize: 12.5, lineHeight: 1.35 }}>
446
+ {u && <strong>{u.name.split(" ")[0]} </strong>}
447
+ <span className="muted">{n.text} </span>
448
+ <span className="mono" style={{ color: "var(--accent)" }}>{n.target}</span>
449
+ </div>
450
+ <div className="truncate muted" style={{ fontSize: 11.5, marginTop: 2 }}>{n.snippet}</div>
451
+ </div>
452
+ <span className="mono muted-2" style={{ fontSize: 10.5, flexShrink: 0 }}>{n.time}</span>
453
+ </button>
454
+ );
455
+ })}
456
+ </div>
457
+ </aside>
458
+ <div className="flex col flex-1" style={{ minWidth: 0 }}>
459
+ <div className="page-header">
460
+ <div className="page-title">
461
+ <span className="mono muted-2" style={{ fontSize: 11 }}>{n.target}</span>
462
+ <span>{n.snippet}</span>
463
+ </div>
464
+ <div className="topbar-spacer" />
465
+ <button className="btn ghost sm" onClick={() => window.toast("Marked done")}><Icon name="check" size={13} /> Mark done</button>
466
+ <button className="btn ghost sm" onClick={() => window.toast("Snoozed 3h")}>Snooze</button>
467
+ <button className="btn sm" onClick={() => window.toast(`Opening ${n.target}`)}><Icon name="arrow-right" size={13} /> Open</button>
468
+ </div>
469
+ <div className="scroll-y" style={{ flex: 1, padding: "32px 48px" }}>
470
+ <div style={{ maxWidth: 680, margin: "0 auto" }}>
471
+ <div className="flex items-center gap-10" style={{ marginBottom: 20 }}>
472
+ {user ? <Avatar user={user} size="lg" /> : <span className="avatar lg" style={{ background: "var(--bg-3)" }} />}
473
+ <div>
474
+ <div style={{ fontSize: 14 }}>{user && <strong>{user.name}</strong>} <span className="muted">{n.text}</span> <span className="mono" style={{ color: "var(--accent)" }}>{n.target}</span></div>
475
+ <div className="muted-2 mono" style={{ fontSize: 11 }}>{n.time} ago</div>
476
+ </div>
477
+ </div>
478
+ <div style={{ padding: 18, border: "1px solid var(--border)", borderRadius: 10, background: "var(--bg-1)", fontSize: 14, lineHeight: 1.6 }}>
479
+ {n.snippet}
480
+ </div>
481
+ <div className="flex items-center gap-8" style={{ marginTop: 16 }}>
482
+ <input id="inboxreply" placeholder="Reply…" style={{ flex: 1, padding: "8px 12px", background: "var(--bg-1)", border: "1px solid var(--border)", borderRadius: 8, outline: "none" }} />
483
+ <button className="btn primary sm" onClick={() => {
484
+ const el = document.getElementById("inboxreply");
485
+ if (!el || !el.value.trim()) { window.toast("Type a reply"); return; }
486
+ window.toast("Reply sent"); el.value = "";
487
+ }}>Send</button>
488
+ </div>
489
+ </div>
490
+ </div>
491
+ </div>
492
+ </div>
493
+ );
494
+ };
495
+
496
+ Object.assign(window, { SprintsView, PRsView, TeamView, InboxView });
views-nexus.jsx ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Nexus β€” Cloud AI Platform (Meridian identity)
2
+
3
+ const NEXUS_MODELS = [
4
+ { id: "nexacore-72b", name: "NexaCore-72B", org: "VaultMind", orgColor: "#a78bfa", badges: ["LLM","FLAGSHIP"], grad: ["#3b1f6e","#1e0e3a"], input: 8.00, output: 24.00, ctx: "200k", new: false },
5
+ { id: "deeprift-v3", name: "DeepRift V3 Pro", org: "DeepRift AI", orgColor: "#38bdf8", badges: ["LLM","HOT"], grad: ["#0c2a3a","#0a1929"], input: 0.55, output: 2.19, ctx: "128k", new: false },
6
+ { id: "luminos-flash", name: "Luminos Flash 3.1", org: "LuminosAI", orgColor: "#4ade80", badges: ["LLM","FAST"], grad: ["#0f2a1a","#071a10"], input: 0.10, output: 0.40, ctx: "1M", new: false },
7
+ { id: "opus-47", name: "Opus 4.7", org: "Anthropic", orgColor: "#fb923c", badges: ["LLM"], grad: ["#2a1500","#180d00"], input: 15.00, output: 75.00, ctx: "200k", new: false },
8
+ { id: "gemma-4-31b", name: "Gemma 4 31B", org: "Google", orgColor: "#60a5fa", badges: ["LLM","OPEN"], grad: ["#0e1e3a","#06111f"], input: 0.14, output: 0.40, ctx: "128k", new: false },
9
+ { id: "kimi-k3", name: "Kimi K3", org: "Moonshot", orgColor: "#e879f9", badges: ["LLM","LONG CTX"], grad: ["#2a0a3a","#170620"], input: 2.00, output: 6.00, ctx: "1M", new: true },
10
+ { id: "gpt-55", name: "GPT-5.5", org: "OpenAI", orgColor: "#86efac", badges: ["LLM"], grad: ["#0a1f0a","#061206"], input: 2.50, output: 10.00, ctx: "256k", new: false },
11
+ { id: "glm-6", name: "GLM-6", org: "Zhipu AI", orgColor: "#7dd3fc", badges: ["LLM","OPEN"], grad: ["#0a1a2a","#050e16"], input: 0.05, output: 0.10, ctx: "128k", new: true },
12
+ { id: "arclight-7b", name: "ArcLight-7B", org: "VaultMind", orgColor: "#a78bfa", badges: ["LLM","FAST"], grad: ["#1a0f3a","#0e0820"], input: 0.10, output: 0.20, ctx: "32k", new: false },
13
+ { id: "nova-super-12b", name: "NovaSynth Super 12B", org: "NovaSynth", orgColor: "#4ade80", badges: ["LLM"], grad: ["#0c2a18","#06160c"], input: 0.15, output: 0.35, ctx: "128k", new: false },
14
+ { id: "gemini-4-flash", name: "Gemini 4 Flash Lite", org: "Google", orgColor: "#60a5fa", badges: ["LLM","FAST"], grad: ["#0e1e3a","#06111f"], input: 0.07, output: 0.15, ctx: "1M", new: false },
15
+ { id: "claude-sn-5", name: "Claude Sonnet 5", org: "Anthropic", orgColor: "#fb923c", badges: ["LLM","NEW"], grad: ["#2a1500","#180d00"], input: 3.00, output: 15.00, ctx: "200k", new: true },
16
+ ];
17
+
18
+ const NEXUS_GPUS = [
19
+ { id: "v520", name: "Radeon PRO V520", tflops: 14, mem: 16, cpus: 8, price: 0.28, spot: 0.14, wait: 2, qty: [1,4], spotOk: true },
20
+ { id: "w7500", name: "Radeon PRO W7500", tflops: 29, mem: 16, cpus: 8, price: 0.44, spot: 0.22, wait: 2, qty: [1,2,4,8], spotOk: true },
21
+ { id: "w7900", name: "Radeon PRO W7900", tflops: 61, mem: 48, cpus: 16, price: 1.49, spot: 0.75, wait: 2, qty: [1,2,4,8], spotOk: true },
22
+ { id: "mi210", name: "Instinct MI210", tflops: 181, mem: 64, cpus: 32, price: 2.20, spot: 1.10, wait: 2, qty: [1,2,4,8], spotOk: true },
23
+ { id: "mi250", name: "Instinct MI250", tflops: 362, mem: 128, cpus: 32, price: 3.50, spot: 1.75, wait: 2, qty: [1,2,4,8], spotOk: true },
24
+ { id: "mi300a", name: "Instinct MI300A", tflops: 980, mem: 128, cpus: 96, price: 5.20, spot: 2.60, wait: 2, qty: [1,2,4,8], spotOk: true },
25
+ { id: "mi300x", name: "Instinct MI300X", tflops: 1307, mem: 192, cpus: 64, price: 6.80, spot: null, wait: 3, qty: [1,8], spotOk: false },
26
+ { id: "mi350x", name: "Instinct MI350X", tflops: 2600, mem: 288, cpus: 128, price: 33.50, spot: null, wait: 4, qty: [8], spotOk: false },
27
+ ];
28
+
29
+ const NEXUS_STUDIO_TYPES = [
30
+ { id: "ai", label: "AI Dev", icon: "⬑", desc: "Jupyter + VS Code" },
31
+ { id: "python", label: "Python", icon: "β¬’", desc: "Pure compute" },
32
+ { id: "comfy", label: "ComfyUI", icon: "✦", desc: "Image workflows" },
33
+ { id: "training", label: "Training", icon: "⬟", desc: "Distributed train" },
34
+ { id: "infer", label: "Inference", icon: "β–·", desc: "vLLM / TGI serve" },
35
+ ];
36
+
37
+ const NEXUS_STUDIOS_MOCK = [
38
+ { id: "st-01", name: "llama-finetune-v4", type: "Training", gpu: "MI250 Γ— 4", status: "running", uptime: "2h 14m", cost: 24.43 },
39
+ { id: "st-02", name: "rag-api-prod", type: "AI Dev", gpu: "W7900 Γ— 1", status: "sleeping", uptime: "18m", cost: 0.50 },
40
+ { id: "st-03", name: "sdxl-comfyui-bench", type: "ComfyUI", gpu: "W7500 Γ— 1", status: "sleeping", uptime: "β€”", cost: 0.00 },
41
+ ];
42
+
43
+ const NEXUS_FILTER_TABS = ["All", "VaultMind", "OpenAI", "Anthropic", "Google", "xAI"];
44
+
45
+ const NEXUS_CODE = (m) => `pip install litai\nfrom litai import LLM\nllm = LLM(model="${m.org.toLowerCase()}/${m.id}",\n api_key="$NEXUS_API_KEY")\nprint(llm.chat("Hello!"))`;
46
+
47
+ // ── Org logo initials ──────────────────────────────────────────────────────
48
+ const OrgBadge = ({ org, color, size = 28 }) => (
49
+ <div style={{
50
+ width: size, height: size, borderRadius: size * 0.3,
51
+ background: `color-mix(in oklch, ${color} 25%, #111)`,
52
+ border: `1px solid color-mix(in oklch, ${color} 40%, transparent)`,
53
+ display: "flex", alignItems: "center", justifyContent: "center",
54
+ fontSize: size * 0.38, fontWeight: 700, color,
55
+ fontFamily: "var(--font-mono)", flexShrink: 0,
56
+ }}>
57
+ {org[0]}
58
+ </div>
59
+ );
60
+
61
+ // ── Model Card ─────────────────────────────────────────────────────────────
62
+ const NexusModelCard = ({ m, onSelect }) => {
63
+ const code = NEXUS_CODE(m);
64
+ return (
65
+ <button onClick={() => onSelect(m)}
66
+ style={{
67
+ background: `linear-gradient(145deg, ${m.grad[0]} 0%, ${m.grad[1]} 100%)`,
68
+ border: "1px solid rgba(255,255,255,0.07)",
69
+ borderRadius: 14, padding: 0, textAlign: "left", cursor: "pointer",
70
+ overflow: "hidden", display: "flex", flexDirection: "column",
71
+ transition: "border-color .15s, transform .12s",
72
+ }}
73
+ onMouseEnter={e => { e.currentTarget.style.borderColor = "rgba(255,255,255,0.18)"; e.currentTarget.style.transform = "translateY(-1px)"; }}
74
+ onMouseLeave={e => { e.currentTarget.style.borderColor = "rgba(255,255,255,0.07)"; e.currentTarget.style.transform = ""; }}
75
+ >
76
+ {/* Card header */}
77
+ <div style={{ padding: "14px 14px 10px", display: "flex", alignItems: "flex-start", gap: 10 }}>
78
+ <OrgBadge org={m.org} color={m.orgColor} size={34} />
79
+ <div style={{ flex: 1, minWidth: 0 }}>
80
+ <div style={{ display: "flex", alignItems: "center", gap: 5, flexWrap: "wrap", marginBottom: 3 }}>
81
+ {m.badges.map(b => (
82
+ <span key={b} style={{
83
+ fontSize: 9, fontWeight: 700, letterSpacing: ".06em",
84
+ padding: "1px 5px", borderRadius: 3,
85
+ background: "rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.6)",
86
+ fontFamily: "var(--font-mono)",
87
+ }}>{b}</span>
88
+ ))}
89
+ {m.new && <span style={{ fontSize: 9, fontWeight: 700, letterSpacing: ".06em", padding: "1px 5px", borderRadius: 3, background: "oklch(0.55 0.18 145 / 0.35)", color: "oklch(0.85 0.17 145)", fontFamily: "var(--font-mono)" }}>NEW</span>}
90
+ </div>
91
+ <div style={{ fontSize: 13, fontWeight: 600, color: "#e8e8e8", lineHeight: 1.2, marginBottom: 1 }}>{m.name}</div>
92
+ <div style={{ fontSize: 10.5, color: "rgba(255,255,255,0.4)" }}>{m.org}</div>
93
+ </div>
94
+ </div>
95
+
96
+ {/* Code snippet */}
97
+ <div style={{
98
+ margin: "0 10px 10px", borderRadius: 7,
99
+ background: "rgba(0,0,0,0.45)", padding: "8px 10px",
100
+ fontFamily: "var(--font-mono)", fontSize: 9.5, color: "rgba(255,255,255,0.55)",
101
+ lineHeight: 1.6, whiteSpace: "pre", overflow: "hidden",
102
+ }}>{code}</div>
103
+
104
+ {/* Footer */}
105
+ <div style={{ padding: "8px 14px 12px", display: "flex", alignItems: "center", gap: 6, borderTop: "1px solid rgba(255,255,255,0.05)" }}>
106
+ <span style={{ fontSize: 10.5, color: "rgba(255,255,255,0.35)" }}>in</span>
107
+ <span style={{ fontSize: 11, fontWeight: 600, color: "rgba(255,255,255,0.7)", fontFamily: "var(--font-mono)" }}>${m.input.toFixed(2)}</span>
108
+ <span style={{ fontSize: 10, color: "rgba(255,255,255,0.25)" }}>/M</span>
109
+ <span style={{ fontSize: 10.5, color: "rgba(255,255,255,0.35)", marginLeft: 4 }}>out</span>
110
+ <span style={{ fontSize: 11, fontWeight: 600, color: "rgba(255,255,255,0.7)", fontFamily: "var(--font-mono)" }}>${m.output !== null ? m.output.toFixed(2) : "β€”"}</span>
111
+ <span style={{ flex: 1 }} />
112
+ <span style={{ fontSize: 10, color: "rgba(255,255,255,0.35)", fontFamily: "var(--font-mono)" }}>{m.ctx} ctx</span>
113
+ </div>
114
+ </button>
115
+ );
116
+ };
117
+
118
+ // ── Model Detail Modal ─────────────────────────────────────────────────────
119
+ const ModelDetailModal = ({ m, onClose }) => {
120
+ const [tab, setTab] = React.useState("litai");
121
+ const tabs = ["LitAI", "OpenAI Python", "Python", "TypeScript", "cURL"];
122
+ const code = {
123
+ LitAI: `pip install litai\nfrom litai import LLM\nllm = LLM(model="${m.org.toLowerCase()}/${m.id}",\n api_key="sk-nexus-...")\nprint(llm.chat("Hello, world!"))`,
124
+ "OpenAI Python": `from openai import OpenAI\nclient = OpenAI(\n base_url="https://api.nexus.ai/v1",\n api_key="sk-nexus-..."\n)\nres = client.chat.completions.create(\n model="${m.id}",\n messages=[{"role":"user","content":"Hello"}]\n)\nprint(res.choices[0].message.content)`,
125
+ Python: `import requests\nres = requests.post("https://api.nexus.ai/v1/messages",\n headers={"x-api-key": "sk-nexus-..."},\n json={"model": "${m.id}", "messages": [{"role":"user","content":"Hi"}]}\n)\nprint(res.json()["content"][0]["text"])`,
126
+ TypeScript: `import Nexus from "@nexusai/sdk";\nconst client = new Nexus({ apiKey: "sk-nexus-..." });\nconst msg = await client.messages.create({\n model: "${m.id}",\n messages: [{ role: "user", content: "Hello!" }]\n});\nconsole.log(msg.content);`,
127
+ cURL: `curl -X POST https://api.nexus.ai/v1/messages \\\n -H "x-api-key: sk-nexus-..." \\\n -H "Content-Type: application/json" \\\n -d '{"model":"${m.id}","messages":[{"role":"user","content":"Hi"}]}'`,
128
+ };
129
+ return (
130
+ <div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.7)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center" }}>
131
+ <div onClick={e => e.stopPropagation()} style={{
132
+ width: 540, maxWidth: "95vw", maxHeight: "80vh", overflow: "auto",
133
+ background: "var(--bg-1)", border: "1px solid var(--border)",
134
+ borderRadius: 14, display: "flex", flexDirection: "column",
135
+ }}>
136
+ <div style={{ padding: "16px 18px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 12 }}>
137
+ <OrgBadge org={m.org} color={m.orgColor} size={36} />
138
+ <div style={{ flex: 1 }}>
139
+ <div style={{ fontSize: 15, fontWeight: 600 }}>{m.name}</div>
140
+ <div style={{ fontSize: 11, color: "var(--fg-3)" }}>{m.org} Β· {m.ctx} context</div>
141
+ </div>
142
+ <button onClick={onClose} style={{ background: "none", border: "none", color: "var(--fg-3)", cursor: "pointer", fontSize: 18, lineHeight: 1 }}>Γ—</button>
143
+ </div>
144
+
145
+ <div style={{ padding: "12px 18px 0", display: "flex", gap: 0, borderBottom: "1px solid var(--border)" }}>
146
+ {tabs.map(t => (
147
+ <button key={t} onClick={() => setTab(t)} style={{
148
+ padding: "6px 12px", fontSize: 11.5, fontWeight: 500, background: "none", border: "none",
149
+ cursor: "pointer", color: tab === t ? "var(--fg-0)" : "var(--fg-3)",
150
+ borderBottom: `2px solid ${tab === t ? "var(--accent)" : "transparent"}`,
151
+ transition: "color .15s", whiteSpace: "nowrap",
152
+ }}>{t}</button>
153
+ ))}
154
+ </div>
155
+
156
+ <div style={{ margin: 18, background: "var(--bg-0)", borderRadius: 8, padding: "12px 14px", fontFamily: "var(--font-mono)", fontSize: 11.5, lineHeight: 1.7, color: "var(--fg-2)", whiteSpace: "pre-wrap" }}>
157
+ {code[tab]}
158
+ </div>
159
+
160
+ <div style={{ padding: "12px 18px 18px", display: "flex", gap: 24 }}>
161
+ {[["Input", `$${m.input}/M tok`], ["Output", m.output ? `$${m.output}/M tok` : "β€”"], ["Context", m.ctx]].map(([k, v]) => (
162
+ <div key={k}>
163
+ <div style={{ fontSize: 10, color: "var(--fg-3)", marginBottom: 2, textTransform: "uppercase", letterSpacing: ".07em" }}>{k}</div>
164
+ <div style={{ fontSize: 13, fontWeight: 600, fontFamily: "var(--font-mono)" }}>{v}</div>
165
+ </div>
166
+ ))}
167
+ <div style={{ flex: 1 }} />
168
+ <button className="btn primary" onClick={() => { window.toast(`API key copiada para ${m.name}`); onClose(); }}>Get API Key</button>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ );
173
+ };
174
+
175
+ // ── Inference tab ──────────────────────────────────────────────────────────
176
+ const NexusInferenceTab = () => {
177
+ const [filter, setFilter] = React.useState("All");
178
+ const [selected, setSelected] = React.useState(null);
179
+ const [search, setSearch] = React.useState("");
180
+
181
+ const visible = NEXUS_MODELS.filter(m => {
182
+ const byFilter = filter === "All" || m.org.toLowerCase().includes(filter.toLowerCase()) || (filter === "VaultMind" && m.org === "VaultMind");
183
+ const bySearch = !search || m.name.toLowerCase().includes(search.toLowerCase()) || m.org.toLowerCase().includes(search.toLowerCase());
184
+ return byFilter && bySearch;
185
+ });
186
+
187
+ return (
188
+ <div style={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0 }}>
189
+ {/* Hero */}
190
+ <div style={{ padding: "36px 28px 24px", background: "linear-gradient(180deg, var(--bg-0) 0%, transparent 100%)" }}>
191
+ <h2 style={{ margin: "0 0 6px", fontSize: 26, fontWeight: 700, letterSpacing: "-.02em" }}>Access any model. One API.</h2>
192
+ <p style={{ margin: "0 0 18px", fontSize: 13.5, color: "var(--fg-3)", maxWidth: 480 }}>
193
+ All major open and closed models β€” single endpoint, per-token billing. 30M free tokens on signup.
194
+ </p>
195
+ <div style={{ display: "flex", gap: 8 }}>
196
+ <button className="btn primary" onClick={() => window.toast("API key generada: sk-nexus-demo-xxx")}>Get API Key</button>
197
+ <button className="btn" onClick={() => window.toast("Redirigiendo a docs...")}>Explore Docs</button>
198
+ </div>
199
+ </div>
200
+
201
+ {/* Filter + search bar */}
202
+ <div style={{ padding: "0 28px 16px", display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
203
+ <div style={{ display: "flex", gap: 4 }}>
204
+ {NEXUS_FILTER_TABS.map(f => (
205
+ <button key={f} onClick={() => setFilter(f)} style={{
206
+ padding: "5px 12px", borderRadius: 20, fontSize: 11.5, fontWeight: 500,
207
+ border: `1px solid ${filter === f ? "var(--accent)" : "var(--border)"}`,
208
+ background: filter === f ? "var(--accent-soft)" : "transparent",
209
+ color: filter === f ? "var(--accent)" : "var(--fg-2)",
210
+ cursor: "pointer", transition: "all .15s",
211
+ }}>{f}</button>
212
+ ))}
213
+ </div>
214
+ <div style={{ flex: 1 }} />
215
+ <input
216
+ value={search} onChange={e => setSearch(e.target.value)}
217
+ placeholder="Search models…"
218
+ style={{
219
+ background: "var(--bg-1)", border: "1px solid var(--border)", borderRadius: 8,
220
+ color: "var(--fg-0)", padding: "5px 12px", fontSize: 12, outline: "none", width: 180,
221
+ }}
222
+ />
223
+ <div style={{ display: "flex", gap: 2 }}>
224
+ <button className="icon-btn" title="Grid"><svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect x="0" y="0" width="6" height="6" rx="1"/><rect x="8" y="0" width="6" height="6" rx="1"/><rect x="0" y="8" width="6" height="6" rx="1"/><rect x="8" y="8" width="6" height="6" rx="1"/></svg></button>
225
+ <button className="icon-btn" title="List"><svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect x="0" y="1" width="14" height="2" rx="1"/><rect x="0" y="6" width="14" height="2" rx="1"/><rect x="0" y="11" width="14" height="2" rx="1"/></svg></button>
226
+ </div>
227
+ </div>
228
+
229
+ {/* Model grid */}
230
+ <div className="scroll-y" style={{ flex: 1, padding: "0 28px 28px" }}>
231
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))", gap: 12 }}>
232
+ {visible.map(m => <NexusModelCard key={m.id} m={m} onSelect={setSelected} />)}
233
+ </div>
234
+ {visible.length === 0 && (
235
+ <div style={{ padding: 60, textAlign: "center", color: "var(--fg-3)", fontSize: 13 }}>No models match "{search}"</div>
236
+ )}
237
+ </div>
238
+
239
+ {selected && <ModelDetailModal m={selected} onClose={() => setSelected(null)} />}
240
+ </div>
241
+ );
242
+ };
243
+
244
+ // ── New Studio Modal ───────────────────────────────────────────────────────
245
+ const NewStudioModal = ({ onClose }) => {
246
+ const [studioType, setStudioType] = React.useState("ai");
247
+ const [selectedGpu, setSelectedGpu] = React.useState("l40s");
248
+ const [qty, setQty] = React.useState(1);
249
+ const [interruptible, setInterruptible] = React.useState(true);
250
+ const [launching, setLaunching] = React.useState(false);
251
+
252
+ const gpu = NEXUS_GPUS.find(g => g.id === selectedGpu);
253
+ const effectivePrice = interruptible && gpu.spot ? gpu.spot : gpu.price;
254
+ const total = (effectivePrice * qty).toFixed(2);
255
+
256
+ const launch = () => {
257
+ if (!gpu.spotOk && interruptible) { window.toast("Este GPU no soporta modo interruptible"); return; }
258
+ setLaunching(true);
259
+ setTimeout(() => {
260
+ setLaunching(false);
261
+ onClose();
262
+ window.toast(`Studio lanzado β€” ${qty}Γ— ${gpu.name} Β· $${total}/hr`);
263
+ }, 1600);
264
+ };
265
+
266
+ return (
267
+ <div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.7)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center" }}>
268
+ <div onClick={e => e.stopPropagation()} style={{
269
+ width: 540, maxWidth: "95vw", maxHeight: "88vh", overflow: "auto",
270
+ background: "var(--bg-1)", border: "1px solid var(--border)",
271
+ borderRadius: 16, display: "flex", flexDirection: "column",
272
+ }}>
273
+ {/* Header */}
274
+ <div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
275
+ <div style={{ fontSize: 15, fontWeight: 600 }}>New Studio</div>
276
+ <button onClick={onClose} style={{ background: "none", border: "none", color: "var(--fg-3)", cursor: "pointer", fontSize: 20, lineHeight: 1 }}>Γ—</button>
277
+ </div>
278
+
279
+ {/* Studio type */}
280
+ <div style={{ padding: "16px 20px 0" }}>
281
+ <div style={{ fontSize: 11, fontWeight: 600, letterSpacing: ".1em", textTransform: "uppercase", color: "var(--fg-3)", marginBottom: 10 }}>Studio type</div>
282
+ <div style={{ display: "flex", gap: 6, overflowX: "auto", paddingBottom: 2 }}>
283
+ {NEXUS_STUDIO_TYPES.map(t => (
284
+ <button key={t.id} onClick={() => setStudioType(t.id)} style={{
285
+ flexShrink: 0, padding: "10px 14px", borderRadius: 10, minWidth: 96,
286
+ border: `1.5px solid ${studioType === t.id ? "var(--accent)" : "var(--border)"}`,
287
+ background: studioType === t.id ? "var(--accent-soft)" : "var(--bg-0)",
288
+ cursor: "pointer", textAlign: "center", transition: "border-color .15s",
289
+ }}>
290
+ <div style={{ fontSize: 18, marginBottom: 5 }}>{t.icon}</div>
291
+ <div style={{ fontSize: 11.5, fontWeight: 600, color: studioType === t.id ? "var(--accent)" : "var(--fg-1)" }}>{t.label}</div>
292
+ <div style={{ fontSize: 9.5, color: "var(--fg-3)", marginTop: 2 }}>{t.desc}</div>
293
+ </button>
294
+ ))}
295
+ </div>
296
+ </div>
297
+
298
+ {/* Teamspace */}
299
+ <div style={{ padding: "14px 20px 0" }}>
300
+ <div style={{ fontSize: 11, fontWeight: 600, letterSpacing: ".1em", textTransform: "uppercase", color: "var(--fg-3)", marginBottom: 8 }}>Teamspace</div>
301
+ <select style={{
302
+ width: "100%", background: "var(--bg-0)", border: "1px solid var(--border)",
303
+ borderRadius: 7, color: "var(--fg-1)", padding: "7px 10px", fontSize: 12.5, outline: "none",
304
+ }}>
305
+ <option>helix Β· enterprise</option>
306
+ <option>personal</option>
307
+ </select>
308
+ </div>
309
+
310
+ {/* GPU table */}
311
+ <div style={{ padding: "14px 20px 0" }}>
312
+ <div style={{ display: "flex", alignItems: "center", marginBottom: 8, gap: 8 }}>
313
+ <div style={{ fontSize: 11, fontWeight: 600, letterSpacing: ".1em", textTransform: "uppercase", color: "var(--fg-3)", flex: 1 }}>Machine</div>
314
+ <span style={{ fontSize: 11, color: "var(--fg-2)" }}>Interruptible</span>
315
+ <button onClick={() => setInterruptible(v => !v)} style={{
316
+ width: 32, height: 18, borderRadius: 9, border: "none", cursor: "pointer",
317
+ background: interruptible ? "var(--accent)" : "var(--bg-3)", position: "relative", transition: "background .2s",
318
+ }}>
319
+ <span style={{ position: "absolute", top: 2, left: interruptible ? 16 : 2, width: 14, height: 14, borderRadius: "50%", background: "#fff", transition: "left .2s", display: "block" }} />
320
+ </button>
321
+ </div>
322
+
323
+ {/* Column headers */}
324
+ <div style={{ display: "grid", gridTemplateColumns: "80px 1fr 80px 80px 60px 110px 80px", padding: "5px 10px", fontSize: 9.5, color: "var(--fg-3)", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase", borderBottom: "1px solid var(--border-subtle)" }}>
325
+ <div>Quantity</div><div>Model</div><div>TFLOPs</div><div>Mem (GB)</div><div>CPUs</div><div>Cost/hr</div><div>Wait</div>
326
+ </div>
327
+
328
+ <div style={{ maxHeight: 260, overflowY: "auto" }}>
329
+ {NEXUS_GPUS.map(g => {
330
+ const active = selectedGpu === g.id;
331
+ const effP = interruptible && g.spot ? g.spot : g.price;
332
+ const availQty = g.qty;
333
+ return (
334
+ <div key={g.id} onClick={() => { setSelectedGpu(g.id); setQty(availQty[0]); }} style={{
335
+ display: "grid", gridTemplateColumns: "80px 1fr 80px 80px 60px 110px 80px",
336
+ padding: "7px 10px", cursor: "pointer", alignItems: "center",
337
+ background: active ? "var(--accent-soft)" : "transparent",
338
+ borderLeft: `2px solid ${active ? "var(--accent)" : "transparent"}`,
339
+ borderBottom: "1px solid var(--border-subtle)", transition: "background .1s",
340
+ }}>
341
+ {/* Qty buttons */}
342
+ <div style={{ display: "flex", gap: 3 }}>
343
+ {availQty.map(q => (
344
+ <button key={q} onClick={e => { e.stopPropagation(); setSelectedGpu(g.id); setQty(q); }} style={{
345
+ width: 20, height: 20, borderRadius: 4, border: `1px solid ${active && qty === q ? "var(--accent)" : "var(--border)"}`,
346
+ background: active && qty === q ? "var(--accent)" : "var(--bg-2)",
347
+ color: active && qty === q ? "var(--accent-fg)" : "var(--fg-2)",
348
+ cursor: "pointer", fontSize: 10, fontWeight: 600, padding: 0,
349
+ }}>{q}</button>
350
+ ))}
351
+ </div>
352
+ <div style={{ fontSize: 12.5, fontWeight: active ? 600 : 400, color: active ? "var(--fg-0)" : "var(--fg-1)" }}>{g.name}</div>
353
+ <div style={{ fontSize: 11.5, fontFamily: "var(--font-mono)", color: "var(--fg-2)" }}>{g.tflops}</div>
354
+ <div style={{ fontSize: 11.5, fontFamily: "var(--font-mono)", color: "var(--fg-2)" }}>{g.mem}</div>
355
+ <div style={{ fontSize: 11.5, fontFamily: "var(--font-mono)", color: "var(--fg-2)" }}>{g.cpus}</div>
356
+ <div style={{ fontSize: 11.5 }}>
357
+ {interruptible && g.spot
358
+ ? <><span style={{ textDecoration: "line-through", color: "var(--fg-3)", marginRight: 4 }}>${g.price.toFixed(2)}</span><span style={{ color: "oklch(0.78 0.14 145)", fontWeight: 600 }}>${g.spot.toFixed(2)}</span></>
359
+ : <span style={{ fontWeight: 600 }}>${g.price.toFixed(2)}</span>
360
+ }
361
+ {!g.spotOk && interruptible && <span style={{ fontSize: 9.5, color: "var(--amber)", marginLeft: 4 }}>⚠</span>}
362
+ </div>
363
+ <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
364
+ <span style={{ fontSize: 11, color: "var(--fg-3)" }}>{g.wait} min</span>
365
+ <span style={{ fontSize: 10, color: "var(--fg-3)" }}>β–Ύ</span>
366
+ </div>
367
+ </div>
368
+ );
369
+ })}
370
+ </div>
371
+
372
+ {interruptible && <div style={{ fontSize: 10.5, color: "var(--amber)", padding: "8px 0 0", display: "flex", alignItems: "center", gap: 5 }}>⚠ Interruptible machines are 50–80% cheaper but may experience data loss</div>}
373
+ </div>
374
+
375
+ {/* Footer */}
376
+ <div style={{ padding: "16px 20px", borderTop: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 12, marginTop: 12 }}>
377
+ <div>
378
+ <div style={{ fontSize: 10, color: "var(--fg-3)", marginBottom: 2 }}>Estimated cost</div>
379
+ <div style={{ fontSize: 15, fontWeight: 700, fontFamily: "var(--font-mono)" }}>${total}<span style={{ fontSize: 11, fontWeight: 400, color: "var(--fg-3)" }}>/hr</span></div>
380
+ </div>
381
+ <div style={{ flex: 1 }} />
382
+ <button className="btn" onClick={onClose}>Cancel</button>
383
+ <button className="btn primary" onClick={launch} disabled={launching}>
384
+ {launching ? "Launching…" : "Confirm"}
385
+ </button>
386
+ </div>
387
+ </div>
388
+ </div>
389
+ );
390
+ };
391
+
392
+ // ── Studios tab ────────────────────────────────────────────────────────────
393
+ const NexusStudiosTab = () => {
394
+ const [showNew, setShowNew] = React.useState(false);
395
+
396
+ const statusColor = { running: "var(--status-progress)", sleeping: "var(--fg-3)", stopped: "var(--rose)" };
397
+
398
+ return (
399
+ <div style={{ padding: "28px", flex: 1, display: "flex", flexDirection: "column", gap: 20 }}>
400
+ <div style={{ display: "flex", alignItems: "center" }}>
401
+ <div>
402
+ <h3 style={{ margin: 0, fontSize: 17, fontWeight: 600 }}>Studios</h3>
403
+ <p style={{ margin: "3px 0 0", fontSize: 12, color: "var(--fg-3)" }}>Cloud development environments with GPU access</p>
404
+ </div>
405
+ <div style={{ flex: 1 }} />
406
+ <button className="btn primary" onClick={() => setShowNew(true)}>+ New Studio</button>
407
+ </div>
408
+
409
+ {/* Active studios */}
410
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
411
+ {NEXUS_STUDIOS_MOCK.map(s => (
412
+ <div key={s.id} className="card" style={{ padding: "14px 16px", display: "flex", alignItems: "center", gap: 14 }}>
413
+ <div style={{ width: 8, height: 8, borderRadius: "50%", background: statusColor[s.status], flexShrink: 0 }} />
414
+ <div style={{ flex: 1, minWidth: 0 }}>
415
+ <div style={{ fontSize: 13, fontWeight: 500 }}>{s.name}</div>
416
+ <div style={{ fontSize: 11, color: "var(--fg-3)", marginTop: 2 }}>{s.type} Β· {s.gpu}</div>
417
+ </div>
418
+ <div style={{ textAlign: "right", fontSize: 11, color: "var(--fg-3)" }}>
419
+ <div className="mono">{s.uptime}</div>
420
+ <div style={{ color: s.cost > 0 ? "var(--fg-1)" : "var(--fg-3)" }} className="mono">${s.cost.toFixed(2)}</div>
421
+ </div>
422
+ <div style={{ display: "flex", gap: 6 }}>
423
+ <button className="btn ghost sm" onClick={() => window.toast(`Abriendo ${s.name}…`)}>Open</button>
424
+ <button className="btn ghost sm" onClick={() => window.toast(`${s.name} detenido`)} style={{ color: "var(--rose)" }}>Stop</button>
425
+ </div>
426
+ </div>
427
+ ))}
428
+ </div>
429
+
430
+ {/* GPU pricing table */}
431
+ <div className="card" style={{ overflow: "hidden" }}>
432
+ <div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 8 }}>
433
+ <strong style={{ fontSize: 13 }}>Available GPUs</strong>
434
+ <span className="chip" style={{ color: "oklch(0.78 0.14 145)" }}>Interruptible prices shown</span>
435
+ </div>
436
+ <div style={{ overflowX: "auto" }}>
437
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
438
+ <thead>
439
+ <tr style={{ borderBottom: "1px solid var(--border-subtle)" }}>
440
+ {["GPU","TFLOPs","VRAM","CPUs","On-demand","Interruptible","Availability"].map(h => (
441
+ <th key={h} style={{ padding: "7px 14px", textAlign: "left", fontSize: 10, color: "var(--fg-3)", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase", whiteSpace: "nowrap" }}>{h}</th>
442
+ ))}
443
+ </tr>
444
+ </thead>
445
+ <tbody>
446
+ {NEXUS_GPUS.map(g => (
447
+ <tr key={g.id} style={{ borderBottom: "1px solid var(--border-subtle)" }}>
448
+ <td style={{ padding: "9px 14px", fontWeight: 600 }}>{g.name}</td>
449
+ <td style={{ padding: "9px 14px", fontFamily: "var(--font-mono)" }}>{g.tflops}</td>
450
+ <td style={{ padding: "9px 14px", fontFamily: "var(--font-mono)" }}>{g.mem} GB</td>
451
+ <td style={{ padding: "9px 14px", fontFamily: "var(--font-mono)" }}>{g.cpus}</td>
452
+ <td style={{ padding: "9px 14px", fontFamily: "var(--font-mono)", fontWeight: 600 }}>${g.price.toFixed(2)}/hr</td>
453
+ <td style={{ padding: "9px 14px" }}>
454
+ {g.spot ? <span style={{ color: "oklch(0.78 0.14 145)", fontFamily: "var(--font-mono)", fontWeight: 600 }}>${g.spot.toFixed(2)}/hr</span>
455
+ : <span style={{ color: "var(--fg-3)", fontSize: 11 }}>N/A</span>}
456
+ </td>
457
+ <td style={{ padding: "9px 14px" }}>
458
+ {g.spotOk
459
+ ? <span style={{ color: "oklch(0.78 0.14 145)", fontSize: 11 }}>● Available</span>
460
+ : <span style={{ color: "var(--amber)", fontSize: 11 }}>⚠ Limited</span>}
461
+ </td>
462
+ </tr>
463
+ ))}
464
+ </tbody>
465
+ </table>
466
+ </div>
467
+ </div>
468
+
469
+ {showNew && <NewStudioModal onClose={() => setShowNew(false)} />}
470
+ </div>
471
+ );
472
+ };
473
+
474
+ // ── Platform tab ───────────────────────────────────────────────────────────
475
+ const NexusPlatformTab = () => {
476
+ const [keyVisible, setKeyVisible] = React.useState(false);
477
+ const apiKey = "sk-nexus-4f8a2b...c93e";
478
+ const usage = [
479
+ { model: "NexaCore-72B", tokens: "1.24M", cost: 9.92 },
480
+ { model: "ArcLight-7B", tokens: "4.80M", cost: 0.48 },
481
+ { model: "Luminos Flash",tokens: "2.10M", cost: 0.21 },
482
+ ];
483
+ return (
484
+ <div style={{ padding: 28, display: "flex", flexDirection: "column", gap: 20 }}>
485
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
486
+ {/* API Keys */}
487
+ <div className="card" style={{ padding: 18 }}>
488
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 14 }}>API Keys</strong>
489
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", background: "var(--bg-0)", borderRadius: 7, border: "1px solid var(--border)" }}>
490
+ <span style={{ fontFamily: "var(--font-mono)", fontSize: 12, flex: 1, color: "var(--fg-2)" }}>
491
+ {keyVisible ? "sk-nexus-4f8a2b-demo-xxxc93e" : apiKey}
492
+ </span>
493
+ <button className="btn ghost sm" onClick={() => setKeyVisible(v => !v)}>{keyVisible ? "Hide" : "Show"}</button>
494
+ <button className="btn ghost sm" onClick={() => window.toast("Key copiada")}>Copy</button>
495
+ </div>
496
+ <button className="btn sm" style={{ marginTop: 10, width: "100%" }} onClick={() => window.toast("Nueva API key generada")}>+ Generate new key</button>
497
+ </div>
498
+
499
+ {/* Billing */}
500
+ <div className="card" style={{ padding: 18 }}>
501
+ <strong style={{ fontSize: 13, display: "block", marginBottom: 14 }}>Billing β€” May 2026</strong>
502
+ <div style={{ display: "flex", alignItems: "baseline", gap: 6, marginBottom: 10 }}>
503
+ <span style={{ fontSize: 28, fontWeight: 700, fontFamily: "var(--font-mono)" }}>$10.61</span>
504
+ <span style={{ fontSize: 12, color: "var(--fg-3)" }}>/ $50.00 limit</span>
505
+ </div>
506
+ <div style={{ height: 6, background: "var(--bg-3)", borderRadius: 3, overflow: "hidden", marginBottom: 10 }}>
507
+ <div style={{ width: "21%", height: "100%", background: "var(--accent)", borderRadius: 3 }} />
508
+ </div>
509
+ <div style={{ fontSize: 11, color: "var(--fg-3)" }}>30M free tokens remaining Β· Renews Jun 1</div>
510
+ </div>
511
+ </div>
512
+
513
+ {/* Usage table */}
514
+ <div className="card" style={{ overflow: "hidden" }}>
515
+ <div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)" }}>
516
+ <strong style={{ fontSize: 13 }}>Usage this month</strong>
517
+ </div>
518
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
519
+ <thead>
520
+ <tr style={{ borderBottom: "1px solid var(--border-subtle)" }}>
521
+ {["Model","Tokens","Cost"].map(h => (
522
+ <th key={h} style={{ padding: "7px 16px", textAlign: "left", fontSize: 10, color: "var(--fg-3)", fontWeight: 600, letterSpacing: ".06em", textTransform: "uppercase" }}>{h}</th>
523
+ ))}
524
+ </tr>
525
+ </thead>
526
+ <tbody>
527
+ {usage.map(u => (
528
+ <tr key={u.model} style={{ borderBottom: "1px solid var(--border-subtle)" }}>
529
+ <td style={{ padding: "9px 16px", fontWeight: 500 }}>{u.model}</td>
530
+ <td style={{ padding: "9px 16px", fontFamily: "var(--font-mono)" }}>{u.tokens}</td>
531
+ <td style={{ padding: "9px 16px", fontFamily: "var(--font-mono)", fontWeight: 600 }}>${u.cost.toFixed(2)}</td>
532
+ </tr>
533
+ ))}
534
+ </tbody>
535
+ </table>
536
+ </div>
537
+ </div>
538
+ );
539
+ };
540
+
541
+ // ── Main Nexus view ────────────────────────────────────────────────────────
542
+ const NexusView = () => {
543
+ const [tab, setTab] = React.useState("inference");
544
+
545
+ const tabs = [
546
+ { id: "inference", label: "Inference" },
547
+ { id: "studios", label: "Studios" },
548
+ { id: "platform", label: "Platform" },
549
+ ];
550
+
551
+ return (
552
+ <div style={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, overflow: "hidden" }}>
553
+ {/* Internal top nav */}
554
+ <div style={{
555
+ height: 44, display: "flex", alignItems: "center", gap: 2,
556
+ padding: "0 28px", borderBottom: "1px solid var(--border)",
557
+ background: "var(--bg-1)", flexShrink: 0,
558
+ }}>
559
+ <div style={{ fontSize: 13, fontWeight: 700, color: "var(--accent)", marginRight: 20, letterSpacing: "-.01em" }}>Nexus</div>
560
+ {tabs.map(t => (
561
+ <button key={t.id} onClick={() => setTab(t.id)} style={{
562
+ padding: "6px 14px", fontSize: 13, fontWeight: 500, background: "none", border: "none",
563
+ cursor: "pointer", borderRadius: 6,
564
+ color: tab === t.id ? "var(--fg-0)" : "var(--fg-3)",
565
+ background: tab === t.id ? "var(--bg-2)" : "transparent",
566
+ transition: "color .15s, background .15s",
567
+ }}>{t.label}</button>
568
+ ))}
569
+ <div style={{ flex: 1 }} />
570
+ <button className="btn ghost sm" onClick={() => window.openAI("What are the best models available in Nexus for fast inference?", "compute", { models: NEXUS_MODELS })}>
571
+ <Icon name="sparkle" size={13} /> AI Summary
572
+ </button>
573
+ </div>
574
+
575
+ {/* Tab content */}
576
+ <div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
577
+ {tab === "inference" && <NexusInferenceTab />}
578
+ {tab === "studios" && <div className="scroll-y" style={{ flex: 1 }}><NexusStudiosTab /></div>}
579
+ {tab === "platform" && <div className="scroll-y" style={{ flex: 1 }}><NexusPlatformTab /></div>}
580
+ </div>
581
+ </div>
582
+ );
583
+ };
584
+
585
+ window.NexusView = NexusView;
views-settings.jsx ADDED
@@ -0,0 +1,543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Settings view β€” workspace configuration + appearance (tweaks integrated)
2
+
3
+ const SettingsView = ({ settings, setSettings }) => {
4
+ const [section, setSection] = React.useState("appearance");
5
+
6
+ const sections = [
7
+ { group: "Workspace", items: [
8
+ { id: "general", label: "General", icon: "settings" },
9
+ { id: "members", label: "Members & roles", icon: "team" },
10
+ { id: "billing", label: "Billing", icon: "database" },
11
+ { id: "security", label: "Security & SSO", icon: "lock" },
12
+ ]},
13
+ { group: "Preferences", items: [
14
+ { id: "appearance", label: "Appearance", icon: "sparkle" },
15
+ { id: "notifications", label: "Notifications", icon: "bell" },
16
+ { id: "keyboard", label: "Keyboard shortcuts", icon: "hash" },
17
+ { id: "account", label: "My account", icon: "at" },
18
+ ]},
19
+ { group: "Integrations", items: [
20
+ { id: "integrations", label: "Connected apps", icon: "link" },
21
+ { id: "api", label: "API & webhooks", icon: "globe" },
22
+ { id: "ai", label: "AI & Integrations", icon: "sparkle" },
23
+ { id: "import", label: "Import / export", icon: "download" },
24
+ ]},
25
+ ];
26
+
27
+ return (
28
+ <div className="flex flex-1" style={{ minWidth: 0 }}>
29
+ <aside style={{ width: 240, flexShrink: 0, borderRight: "1px solid var(--border)", background: "var(--bg-1)", overflow: "auto", padding: "14px 8px" }}>
30
+ <div style={{ padding: "4px 10px 10px", fontSize: 12.5, fontWeight: 600 }}>Settings</div>
31
+ {sections.map(g => (
32
+ <div key={g.group} className="sb-section" style={{ padding: "4px 0" }}>
33
+ <div className="sb-label" style={{ padding: "6px 10px" }}>{g.group}</div>
34
+ {g.items.map(it => (
35
+ <button key={it.id} className={`sb-item ${section === it.id ? "active" : ""}`} onClick={() => setSection(it.id)}>
36
+ <span className="sb-ind" />
37
+ <Icon name={it.icon} className="sb-icon" />
38
+ <span>{it.label}</span>
39
+ </button>
40
+ ))}
41
+ </div>
42
+ ))}
43
+ </aside>
44
+ <div className="flex col flex-1" style={{ minWidth: 0 }}>
45
+ <div className="page-header">
46
+ <div className="page-title">
47
+ <span className="eyebrow">SETTINGS</span>
48
+ <Icon name="chevron-right" size={12} style={{ color: "var(--fg-3)" }} />
49
+ <span style={{ textTransform: "capitalize" }}>{sections.flatMap(g => g.items).find(i => i.id === section)?.label}</span>
50
+ </div>
51
+ </div>
52
+ <div className="scroll-y" style={{ flex: 1, padding: "32px 48px 64px" }}>
53
+ <div style={{ maxWidth: 760, margin: "0 auto" }}>
54
+ {section === "appearance" && <AppearanceSection settings={settings} setSettings={setSettings} />}
55
+ {section === "general" && <GeneralSection />}
56
+ {section === "notifications" && <NotificationsSection />}
57
+ {section === "keyboard" && <KeyboardSection />}
58
+ {section === "members" && <MembersPlaceholder />}
59
+ {section === "security" && <PlaceholderSection title="Security & SSO" desc="SAML, SCIM, audit logs, IP allowlist." />}
60
+ {section === "billing" && <PlaceholderSection title="Billing" desc="Plan, seats, invoices, tax information." />}
61
+ {section === "account" && <PlaceholderSection title="My account" desc="Profile, email, password, two-factor." />}
62
+ {section === "integrations" && <IntegrationsSection />}
63
+ {section === "api" && <PlaceholderSection title="API & webhooks" desc="Personal access tokens, OAuth apps, webhook endpoints." />}
64
+ {section === "ai" && <AISection />}
65
+ {section === "import" && <PlaceholderSection title="Import / export" desc="Migrate from Jira, Linear, GitHub Issues, Asana. Bulk export as CSV or JSON." />}
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ );
71
+ };
72
+
73
+ const SetRow = ({ title, desc, children }) => (
74
+ <div className="flex" style={{ padding: "18px 0", borderTop: "1px solid var(--border-subtle)", gap: 24, alignItems: "flex-start" }}>
75
+ <div style={{ flex: 1, minWidth: 0 }}>
76
+ <div style={{ fontSize: 13.5, fontWeight: 500, color: "var(--fg-0)" }}>{title}</div>
77
+ {desc && <div className="muted" style={{ fontSize: 12, marginTop: 2, lineHeight: 1.5 }}>{desc}</div>}
78
+ </div>
79
+ <div style={{ flexShrink: 0 }}>{children}</div>
80
+ </div>
81
+ );
82
+
83
+ const SetGroup = ({ title, desc, children }) => (
84
+ <section style={{ marginBottom: 40 }}>
85
+ <h2 className="editorial" style={{ fontSize: 22, fontWeight: 400, letterSpacing: "-0.02em", margin: "0 0 4px" }}>{title}</h2>
86
+ {desc && <p className="muted" style={{ fontSize: 13, margin: "0 0 12px", maxWidth: 620 }}>{desc}</p>}
87
+ <div>{children}</div>
88
+ </section>
89
+ );
90
+
91
+ const AppearanceSection = ({ settings, setSettings }) => {
92
+ const accents = [
93
+ { id: "lime", name: "Lime", value: "oklch(0.85 0.17 145)" },
94
+ { id: "cyan", name: "Cyan", value: "oklch(0.78 0.13 220)" },
95
+ { id: "violet", name: "Violet", value: "oklch(0.72 0.18 300)" },
96
+ { id: "amber", name: "Amber", value: "oklch(0.80 0.14 75)" },
97
+ { id: "rose", name: "Rose", value: "oklch(0.72 0.17 20)" },
98
+ ];
99
+
100
+ const themes = [
101
+ { id: "dark", label: "Dark", bg: "oklch(0.18 0.008 250)", fg: "oklch(0.97 0.005 250)" },
102
+ { id: "light", label: "Light", bg: "oklch(0.985 0.003 250)", fg: "oklch(0.18 0.015 250)" },
103
+ ];
104
+
105
+ return (
106
+ <div>
107
+ <SetGroup title="Appearance" desc="Customize how Meridian looks for you. Changes apply across all your devices signed in as @amara.">
108
+ <SetRow title="Theme" desc="Dark reduces eye strain in long sessions. Light is better in bright environments.">
109
+ <div style={{ display: "flex", gap: 8 }}>
110
+ {themes.map(t => (
111
+ <button key={t.id}
112
+ onClick={() => setSettings(s => ({ ...s, theme: t.id }))}
113
+ style={{
114
+ width: 88, padding: 6, borderRadius: 8,
115
+ border: `2px solid ${settings.theme === t.id ? "var(--accent)" : "var(--border)"}`,
116
+ background: "var(--bg-1)", textAlign: "left",
117
+ transition: "border-color 140ms"
118
+ }}>
119
+ <div style={{ height: 44, borderRadius: 4, background: t.bg, display: "flex", alignItems: "center", padding: "0 6px", gap: 4, marginBottom: 4, border: "1px solid var(--border-subtle)" }}>
120
+ <span style={{ width: 4, height: 4, borderRadius: "50%", background: "var(--accent)" }} />
121
+ <span style={{ flex: 1, height: 2, background: `color-mix(in oklch, ${t.fg} 50%, transparent)`, borderRadius: 1 }} />
122
+ </div>
123
+ <div style={{ fontSize: 11.5, color: "var(--fg-1)" }}>{t.label}</div>
124
+ </button>
125
+ ))}
126
+ </div>
127
+ </SetRow>
128
+
129
+ <SetRow title="Accent color" desc="Used for active states, primary actions, the aurora backdrop, and data highlights.">
130
+ <div style={{ display: "flex", gap: 10, flexWrap: "wrap", maxWidth: 280 }}>
131
+ {accents.map(a => (
132
+ <button key={a.id}
133
+ onClick={() => setSettings(s => ({ ...s, accent: a.id }))}
134
+ title={a.name}
135
+ style={{
136
+ width: 36, height: 36, borderRadius: "50%",
137
+ background: a.value,
138
+ border: `2px solid ${settings.accent === a.id ? "var(--fg-0)" : "transparent"}`,
139
+ boxShadow: `0 0 0 1px var(--border-strong), 0 0 14px -2px ${a.value}`,
140
+ cursor: "pointer", transition: "transform 140ms, border-color 140ms",
141
+ transform: settings.accent === a.id ? "scale(1.05)" : "scale(1)"
142
+ }} />
143
+ ))}
144
+ </div>
145
+ </SetRow>
146
+
147
+ <SetRow title="Density" desc="Controls row heights and spacing throughout lists and tables.">
148
+ <div className="segmented">
149
+ {["compact","default","relaxed"].map(d => (
150
+ <button key={d} className={settings.density === d ? "on" : ""} onClick={() => setSettings(s => ({ ...s, density: d }))} style={{ textTransform: "capitalize" }}>{d}</button>
151
+ ))}
152
+ </div>
153
+ </SetRow>
154
+
155
+ <SetRow title="Sidebar" desc="Collapsed shows icons only, maximizing canvas room.">
156
+ <div className="segmented">
157
+ {["expanded","collapsed"].map(d => (
158
+ <button key={d} className={settings.sidebar === d ? "on" : ""} onClick={() => setSettings(s => ({ ...s, sidebar: d }))} style={{ textTransform: "capitalize" }}>{d}</button>
159
+ ))}
160
+ </div>
161
+ </SetRow>
162
+
163
+ <SetRow title="Kanban card" desc="Detailed shows labels, branch, comments, dates. Minimal shows title and priority only.">
164
+ <div className="segmented">
165
+ {["detailed","minimal"].map(d => (
166
+ <button key={d} className={settings.cardStyle === d ? "on" : ""} onClick={() => setSettings(s => ({ ...s, cardStyle: d }))} style={{ textTransform: "capitalize" }}>{d}</button>
167
+ ))}
168
+ </div>
169
+ </SetRow>
170
+
171
+ <SetRow title="Reduce motion" desc="Disables the aurora backdrop animation, gradient shimmer, and page transitions.">
172
+ <Toggle on={settings.reduceMotion} onChange={v => setSettings(s => ({ ...s, reduceMotion: v }))} />
173
+ </SetRow>
174
+ </SetGroup>
175
+
176
+ <SetGroup title="Preview">
177
+ <div className="card" style={{ padding: 16, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
178
+ <div>
179
+ <div className="muted-2 mono" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 }}>Primary action</div>
180
+ <button className="btn primary" onClick={() => window.openNewIssue()}>
181
+ <Icon name="plus" size={13} /> Create issue
182
+ </button>
183
+ </div>
184
+ <div>
185
+ <div className="muted-2 mono" style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 8 }}>Status set</div>
186
+ <div className="flex items-center gap-12" style={{ flexWrap: "wrap" }}>
187
+ <span className="status todo"><span className="s-dot" /> Todo</span>
188
+ <span className="status progress"><span className="s-dot" /> Doing</span>
189
+ <span className="status review"><span className="s-dot" /> Review</span>
190
+ <span className="status done"><span className="s-dot" /> Done</span>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </SetGroup>
195
+ </div>
196
+ );
197
+ };
198
+
199
+ const Toggle = ({ on, onChange }) => (
200
+ <button onClick={() => onChange(!on)} style={{
201
+ width: 36, height: 20, borderRadius: 999,
202
+ background: on ? "var(--accent)" : "var(--bg-3)",
203
+ border: "1px solid var(--border)",
204
+ position: "relative", transition: "background 160ms",
205
+ cursor: "pointer"
206
+ }}>
207
+ <span style={{
208
+ position: "absolute", top: 1, left: on ? 17 : 1,
209
+ width: 16, height: 16, borderRadius: "50%",
210
+ background: on ? "var(--accent-fg)" : "var(--fg-1)",
211
+ transition: "left 160ms",
212
+ boxShadow: "0 1px 2px oklch(0 0 0 / 0.25)"
213
+ }} />
214
+ </button>
215
+ );
216
+
217
+ const GeneralSection = () => (
218
+ <div>
219
+ <SetGroup title="Workspace" desc="Shared across everyone in Helix Enterprise.">
220
+ <SetRow title="Workspace name">
221
+ <input defaultValue="Helix Enterprise" style={inputStyle} />
222
+ </SetRow>
223
+ <SetRow title="URL slug" desc="meridian.app/helix">
224
+ <input defaultValue="helix" style={inputStyle} />
225
+ </SetRow>
226
+ <SetRow title="Default project" desc="New issues are created in this project when none is specified.">
227
+ <select defaultValue="aurora" style={inputStyle}>
228
+ {PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
229
+ </select>
230
+ </SetRow>
231
+ <SetRow title="Start of week" desc="Used in roadmap, sprints, and calendars.">
232
+ <div className="segmented">
233
+ <button className="on">Monday</button>
234
+ <button>Sunday</button>
235
+ </div>
236
+ </SetRow>
237
+ <SetRow title="Time zone">
238
+ <select defaultValue="UTC+0" style={inputStyle}>
239
+ <option>UTC+0 β€” London</option>
240
+ <option>UTC+1 β€” Madrid</option>
241
+ <option>UTC-5 β€” New York</option>
242
+ <option>UTC-8 β€” San Francisco</option>
243
+ </select>
244
+ </SetRow>
245
+ </SetGroup>
246
+ <SetGroup title="Danger zone">
247
+ <SetRow title="Archive workspace" desc="Prevents writes. Data remains readable for 90 days.">
248
+ <button className="btn" onClick={() => window.toast("Workspace archived (demo)")}>Archive</button>
249
+ </SetRow>
250
+ <SetRow title="Delete workspace" desc="Irreversible. Removes all issues, docs, and integrations.">
251
+ <button className="btn" style={{ color: "var(--rose)", borderColor: "color-mix(in oklch, var(--rose) 40%, var(--border))" }} onClick={() => window.toast("Type β€˜DELETE’ to confirm (demo)")}>Delete…</button>
252
+ </SetRow>
253
+ </SetGroup>
254
+ </div>
255
+ );
256
+
257
+ const NotificationsSection = () => {
258
+ const [prefs, setPrefs] = React.useState({
259
+ mentions: { inbox: true, email: true, push: true },
260
+ assigned: { inbox: true, email: true, push: false },
261
+ reviewRequested: { inbox: true, email: false, push: true },
262
+ statusChange: { inbox: true, email: false, push: false },
263
+ newIssue: { inbox: false, email: false, push: false },
264
+ docUpdate: { inbox: true, email: false, push: false },
265
+ });
266
+
267
+ const types = [
268
+ { id: "mentions", label: "@mentions", desc: "Someone mentions you in a comment, doc, or review." },
269
+ { id: "assigned", label: "Assigned to you", desc: "An issue or review is assigned to you." },
270
+ { id: "reviewRequested", label: "Review requested", desc: "A pull request needs your review." },
271
+ { id: "statusChange", label: "Status changed", desc: "Your issues move across the board." },
272
+ { id: "newIssue", label: "New issues in projects you watch", desc: "Someone opens an issue in a subscribed project." },
273
+ { id: "docUpdate", label: "Doc updates", desc: "A document you've subscribed to is edited." },
274
+ ];
275
+
276
+ const channels = [
277
+ { id: "inbox", label: "Inbox" },
278
+ { id: "email", label: "Email" },
279
+ { id: "push", label: "Push" },
280
+ ];
281
+
282
+ return (
283
+ <SetGroup title="Notifications" desc="Route each event to the channels where you want to hear about it.">
284
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 70px 70px 70px", gap: 0, borderTop: "1px solid var(--border-subtle)" }}>
285
+ <div />
286
+ {channels.map(c => <div key={c.id} className="mono muted-2" style={{ fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.08em", padding: "12px 0", textAlign: "center" }}>{c.label}</div>)}
287
+ {types.map(t => (
288
+ <React.Fragment key={t.id}>
289
+ <div style={{ padding: "14px 12px 14px 0", borderTop: "1px solid var(--border-subtle)" }}>
290
+ <div style={{ fontSize: 13, fontWeight: 500 }}>{t.label}</div>
291
+ <div className="muted" style={{ fontSize: 11.5 }}>{t.desc}</div>
292
+ </div>
293
+ {channels.map(c => (
294
+ <div key={c.id} style={{ padding: "14px 0", borderTop: "1px solid var(--border-subtle)", display: "flex", justifyContent: "center", alignItems: "center" }}>
295
+ <Toggle on={prefs[t.id][c.id]} onChange={v => setPrefs(p => ({ ...p, [t.id]: { ...p[t.id], [c.id]: v } }))} />
296
+ </div>
297
+ ))}
298
+ </React.Fragment>
299
+ ))}
300
+ </div>
301
+ </SetGroup>
302
+ );
303
+ };
304
+
305
+ const KeyboardSection = () => {
306
+ const groups = [
307
+ { title: "Navigation", items: [
308
+ ["Open command palette", "⌘ K"],
309
+ ["Go to Home", "G H"],
310
+ ["Go to Inbox", "G I"],
311
+ ["Go to Issues", "G S"],
312
+ ["Go to Docs", "G D"],
313
+ ["Go to Roadmap", "G R"],
314
+ ["Go to Pull requests", "G P"],
315
+ ]},
316
+ { title: "Actions", items: [
317
+ ["Create issue", "C"],
318
+ ["Create document", "⌘ ⇧ D"],
319
+ ["Assign to me", "I"],
320
+ ["Change priority", "⇧ P"],
321
+ ["Change status", "⇧ S"],
322
+ ["Add label", "L"],
323
+ ["Add to sprint", "⇧ M"],
324
+ ]},
325
+ { title: "Editing", items: [
326
+ ["Submit comment", "⌘ ↡"],
327
+ ["Mention user", "@"],
328
+ ["Insert link", "⌘ K"],
329
+ ["Toggle bold", "⌘ B"],
330
+ ["Insert code block", "⌘ ⇧ C"],
331
+ ]},
332
+ ];
333
+ return (
334
+ <SetGroup title="Keyboard shortcuts" desc="Meridian is keyboard-first. Press ? anywhere for this list.">
335
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 32 }}>
336
+ {groups.map(g => (
337
+ <div key={g.title}>
338
+ <div className="mono muted-2" style={{ fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.08em", marginBottom: 8 }}>{g.title}</div>
339
+ <div>
340
+ {g.items.map(([label, kbd]) => (
341
+ <div key={label} className="flex items-center justify-between" style={{ padding: "8px 0", borderTop: "1px solid var(--border-subtle)", fontSize: 12.5 }}>
342
+ <span>{label}</span>
343
+ <span className="kbd-hint mono">{kbd}</span>
344
+ </div>
345
+ ))}
346
+ </div>
347
+ </div>
348
+ ))}
349
+ </div>
350
+ </SetGroup>
351
+ );
352
+ };
353
+
354
+ const MembersPlaceholder = () => (
355
+ <SetGroup title="Members & roles" desc="9 members in Helix Enterprise. Invite new teammates or adjust permissions.">
356
+ <div className="card" style={{ overflow: "hidden" }}>
357
+ {PEOPLE.slice(0, 6).map((u, i) => (
358
+ <div key={u.id} className="flex items-center" style={{ padding: "10px 14px", borderTop: i > 0 ? "1px solid var(--border-subtle)" : "none", gap: 10 }}>
359
+ <Avatar user={u} size="sm" />
360
+ <div style={{ flex: 1, minWidth: 0 }}>
361
+ <div style={{ fontSize: 12.5 }}>{u.name}</div>
362
+ <div className="muted mono" style={{ fontSize: 10.5 }}>@{u.handle} Β· {u.handle}@helix.com</div>
363
+ </div>
364
+ <select defaultValue={i === 0 ? "admin" : i < 3 ? "member" : "guest"} style={{ ...inputStyle, minWidth: 110 }}>
365
+ <option value="admin">Admin</option>
366
+ <option value="member">Member</option>
367
+ <option value="guest">Guest</option>
368
+ </select>
369
+ <button className="btn ghost sm" onClick={() => window.openPicker({ title: "Member", options: [
370
+ { value: "change-role", label: "Change role" },
371
+ { value: "transfer", label: "Transfer admin" },
372
+ { value: "remove", label: "Remove from workspace" },
373
+ ], onChoose: (o) => window.toast(o.label) })}><Icon name="more" size={13} /></button>
374
+ </div>
375
+ ))}
376
+ </div>
377
+ </SetGroup>
378
+ );
379
+
380
+ const IntegrationsSection = () => {
381
+ const apps = [
382
+ { name: "GitHub", desc: "Link PRs, sync commits, auto-close issues.", connected: true, kind: "git" },
383
+ { name: "Slack", desc: "Notifications in channels, slash-commands.", connected: true, kind: "message" },
384
+ { name: "Figma", desc: "Embed frames in issues and docs.", connected: true, kind: "component" },
385
+ { name: "Jira", desc: "Two-way sync for enterprise migration.", connected: true, kind: "issues" },
386
+ { name: "Sentry", desc: "Auto-create issues from new errors.", connected: true, kind: "flag" },
387
+ { name: "GitLab", desc: "Sync merge requests and pipelines.", connected: true, kind: "git" },
388
+ { name: "Datadog", desc: "Attach monitor alerts to incidents.", connected: true, kind: "chart" },
389
+ { name: "Zoom", desc: "Create meeting links in issues automatically.", connected: true, kind: "video" },
390
+ { name: "Linear", desc: "One-way migration of issues.", connected: false, kind: "issues" },
391
+ { name: "Notion", desc: "Import pages as Meridian docs.", connected: false, kind: "docs" },
392
+ ];
393
+ return (
394
+ <SetGroup title="Connected apps" desc="Meridian plays well with the rest of your stack.">
395
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
396
+ {apps.map(a => (
397
+ <div key={a.name} className="card" style={{ padding: 14, display: "flex", gap: 12, alignItems: "flex-start" }}>
398
+ <div style={{ width: 36, height: 36, borderRadius: 8, background: "var(--bg-2)", display: "inline-flex", alignItems: "center", justifyContent: "center", color: "var(--fg-1)", flexShrink: 0 }}>
399
+ <Icon name={a.kind} size={18} />
400
+ </div>
401
+ <div style={{ flex: 1, minWidth: 0 }}>
402
+ <div className="flex items-center justify-between" style={{ marginBottom: 2 }}>
403
+ <strong style={{ fontSize: 13 }}>{a.name}</strong>
404
+ {a.connected && <span className="chip" style={{ color: "var(--status-done)" }}><span className="d" style={{ background: "var(--status-done)" }} /> connected</span>}
405
+ </div>
406
+ <div className="muted" style={{ fontSize: 11.5, lineHeight: 1.4 }}>{a.desc}</div>
407
+ <div style={{ marginTop: 10 }}>
408
+ <button className={`btn sm ${a.connected ? "" : "primary"}`}>{a.connected ? "Configure" : "Connect"}</button>
409
+ </div>
410
+ </div>
411
+ </div>
412
+ ))}
413
+ </div>
414
+ </SetGroup>
415
+ );
416
+ };
417
+
418
+ const PlaceholderSection = ({ title, desc }) => (
419
+ <SetGroup title={title} desc={desc}>
420
+ <div className="card" style={{ padding: 32, textAlign: "center", color: "var(--fg-3)" }}>
421
+ <Icon name="settings" size={28} style={{ opacity: 0.5, marginBottom: 8 }} />
422
+ <div style={{ fontSize: 12.5 }}>Section mock β€” not wired up in this prototype.</div>
423
+ </div>
424
+ </SetGroup>
425
+ );
426
+
427
+ const AISection = () => {
428
+ const [url, setUrl] = React.useState(() => (window.getAmdUrl ? window.getAmdUrl() : '') || '');
429
+ const [saved, setSaved] = React.useState(false);
430
+ const [testing, setTesting] = React.useState(false);
431
+ const [testResult, setTestResult] = React.useState(null); // null | 'ok' | string(error)
432
+
433
+ const save = () => {
434
+ if (window.setAmdUrl) window.setAmdUrl(url.trim());
435
+ setSaved(true);
436
+ setTimeout(() => setSaved(false), 2000);
437
+ };
438
+
439
+ const test = async () => {
440
+ const u = url.trim();
441
+ if (!u) { window.toast('IngresΓ‘ la URL del endpoint primero'); return; }
442
+ // Auto-save before testing
443
+ if (window.setAmdUrl) window.setAmdUrl(u);
444
+ setTesting(true); setTestResult(null);
445
+ try {
446
+ const res = await fetch(u, {
447
+ method: 'POST',
448
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer dummy-key' },
449
+ body: JSON.stringify({
450
+ model: 'llama-3.3-70b-versatile', max_tokens: 5,
451
+ messages: [{ role: 'user', content: 'Reply: ok' }]
452
+ })
453
+ });
454
+ if (res.ok) { setTestResult('ok'); }
455
+ else { const e = await res.json().catch(() => ({})); setTestResult(e.error?.message || `HTTP ${res.status}`); }
456
+ } catch (e) { setTestResult(e.message); }
457
+ setTesting(false);
458
+ };
459
+
460
+ return (
461
+ <SetGroup title="AI & Integrations" desc="ConfigurΓ‘ el proveedor AI usado en todas las vistas de Meridian β€” Code Editor, PR Analyst, Sprint Coach, y mΓ‘s.">
462
+ <SetRow
463
+ title="AMD Endpoint URL"
464
+ desc={<>ObtenΓ© la URL al correr <strong>deploy_amd_endpoint.sh</strong> en AMD Developer Cloud. Guardada en el navegador, nunca se envΓ­a a ningΓΊn servidor externo.</>}
465
+ >
466
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 340 }}>
467
+ <div style={{ display: 'flex', gap: 8 }}>
468
+ <input
469
+ id="amd-endpoint-input"
470
+ type="text"
471
+ value={url}
472
+ onChange={e => { setUrl(e.target.value); setTestResult(null); }}
473
+ onKeyDown={e => e.key === 'Enter' && save()}
474
+ placeholder="http://<IP>:8000/v1/chat/completions"
475
+ style={{
476
+ ...inputStyle, flex: 1,
477
+ fontFamily: 'var(--font-mono)', fontSize: 11.5,
478
+ letterSpacing: url ? '0.04em' : 0,
479
+ }}
480
+ />
481
+ <button
482
+ className="btn sm"
483
+ onClick={save}
484
+ style={{ minWidth: 52, background: saved ? 'var(--status-done)' : undefined, color: saved ? '#fff' : undefined }}
485
+ >
486
+ {saved ? 'βœ“' : 'Save'}
487
+ </button>
488
+ </div>
489
+ <button
490
+ onClick={test}
491
+ disabled={testing}
492
+ style={{
493
+ background: 'none', border: 'none', padding: 0,
494
+ color: testing ? 'var(--fg-3)' : 'var(--accent)',
495
+ fontSize: 12, cursor: testing ? 'default' : 'pointer',
496
+ textAlign: 'left', width: 'fit-content',
497
+ textDecoration: 'underline', textDecorationStyle: 'dotted',
498
+ }}
499
+ >
500
+ {testing ? 'Probando conexiΓ³n…' : '⚑ Test connection'}
501
+ </button>
502
+ {testResult === 'ok' && (
503
+ <span style={{ fontSize: 11.5, color: 'var(--status-done)', display: 'flex', alignItems: 'center', gap: 4 }}>
504
+ βœ“ ConexiΓ³n exitosa β€” endpoint AMD activo
505
+ </span>
506
+ )}
507
+ {testResult && testResult !== 'ok' && (
508
+ <span style={{ fontSize: 11.5, color: 'var(--rose)' }}>βœ— {testResult}</span>
509
+ )}
510
+ </div>
511
+ </SetRow>
512
+
513
+ <SetRow title="AI Model" desc="Modelo usado por todos los asistentes con contexto.">
514
+ <div style={{ ...inputStyle, display: 'inline-flex', alignItems: 'center', gap: 8 }}>
515
+ <Icon name="sparkle" size={13} style={{ color: 'var(--accent)' }} />
516
+ llama-3.3-70b-versatile
517
+ </div>
518
+ </SetRow>
519
+
520
+ <SetRow title="Code Editor Agent" desc="El AI dentro del Code Editor usa el mismo endpoint y puede crear, editar y correr archivos HTML/CSS/JS.">
521
+ <span className="chip" style={{ color: 'var(--status-done)' }}><span className="d" style={{ background: 'var(--status-done)' }} /> enabled</span>
522
+ </SetRow>
523
+
524
+ <SetRow title="Contextual AI Panels" desc="Each view (PRs, Sprints, Issues, Team, Compute) has a role-specific AI that understands what's on screen.">
525
+ <span className="chip" style={{ color: 'var(--status-done)' }}><span className="d" style={{ background: 'var(--status-done)' }} /> enabled</span>
526
+ </SetRow>
527
+ </SetGroup>
528
+ );
529
+ };
530
+
531
+ const inputStyle = {
532
+ background: "var(--bg-1)",
533
+ border: "1px solid var(--border)",
534
+ borderRadius: 6,
535
+ padding: "6px 10px",
536
+ color: "var(--fg-0)",
537
+ fontSize: 12.5,
538
+ outline: "none",
539
+ minWidth: 180,
540
+ fontFamily: "inherit",
541
+ };
542
+
543
+ Object.assign(window, { SettingsView });
vscode.html ADDED
@@ -0,0 +1,1263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.0">
6
+ <title>Code Editor β€” Meridian</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
8
+ <style>
9
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
12
+ background: #1e1e1e; color: #cccccc;
13
+ height: 100vh; overflow: hidden;
14
+ display: flex; flex-direction: column; font-size: 13px;
15
+ }
16
+
17
+ /* Titlebar */
18
+ .titlebar { height: 30px; background: #3c3c3c; display: flex; align-items: center; padding: 0 12px; flex-shrink: 0; }
19
+ .titlebar-dots { display: flex; gap: 6px; margin-right: 12px; }
20
+ .titlebar-dot { width: 12px; height: 12px; border-radius: 50%; }
21
+ .dot-close { background: #ff5f57; } .dot-min { background: #febc2e; } .dot-max { background: #28c840; }
22
+ .titlebar-title { flex: 1; text-align: center; font-size: 12px; color: #cccccc; }
23
+
24
+ /* Workbench */
25
+ .workbench { display: flex; flex: 1; overflow: hidden; }
26
+
27
+ /* Activity bar */
28
+ .activity-bar {
29
+ width: 48px; background: #333333;
30
+ display: flex; flex-direction: column; align-items: center;
31
+ padding-top: 4px; flex-shrink: 0; border-right: 1px solid #252526;
32
+ }
33
+ .act-icon {
34
+ width: 36px; height: 36px;
35
+ display: flex; align-items: center; justify-content: center;
36
+ cursor: pointer; border-radius: 4px; margin: 1px 0;
37
+ color: #858585;
38
+ }
39
+ .act-icon:hover { color: #cccccc; }
40
+ .act-icon.active { color: #fff; border-left: 2px solid #007acc; }
41
+ .act-icon svg { width: 22px; height: 22px; }
42
+ .act-spacer { flex: 1; }
43
+
44
+ /* Sidebar */
45
+ .sidebar {
46
+ width: 250px; background: #252526; flex-shrink: 0;
47
+ display: flex; flex-direction: column; border-right: 1px solid #3c3c3c; overflow: hidden;
48
+ }
49
+ #sidebar-explorer { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
50
+ #sidebar-agent { display: none; flex-direction: column; flex: 1; overflow: hidden; }
51
+
52
+ .sidebar-header {
53
+ padding: 8px 12px; font-size: 11px; font-weight: 600;
54
+ color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.8px;
55
+ flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
56
+ border-bottom: 1px solid #3c3c3c;
57
+ }
58
+ .sidebar-btn {
59
+ background: none; border: none; color: #858585;
60
+ cursor: pointer; padding: 2px 6px; border-radius: 3px; font-size: 18px; line-height: 1;
61
+ }
62
+ .sidebar-btn:hover { color: #cccccc; background: rgba(255,255,255,0.1); }
63
+
64
+ /* File tree */
65
+ .file-tree { flex: 1; overflow-y: auto; }
66
+ .file-tree::-webkit-scrollbar { width: 6px; }
67
+ .file-tree::-webkit-scrollbar-thumb { background: #424242; border-radius: 3px; }
68
+ .file-item {
69
+ display: flex; align-items: center; gap: 6px;
70
+ padding: 4px 12px; cursor: pointer; color: #cccccc;
71
+ }
72
+ .file-item:hover { background: #2a2d2e; }
73
+ .file-item.active { background: #37373d; color: #fff; }
74
+ .file-icon { font-size: 10px; font-weight: 700; font-family: monospace; width: 22px; text-align: center; flex-shrink: 0; }
75
+ .icon-html { color: #f16529; } .icon-js { color: #f7df1e; }
76
+ .icon-css { color: #569cd6; } .icon-json { color: #fbc02d; }
77
+ .icon-txt { color: #858585; }
78
+ .file-name { flex: 1; font-size: 13px; }
79
+ .file-del {
80
+ opacity: 0; background: none; border: none; color: #858585;
81
+ cursor: pointer; padding: 0 2px; font-size: 14px; line-height: 1;
82
+ }
83
+ .file-item:hover .file-del { opacity: 0.6; }
84
+ .file-del:hover { opacity: 1 !important; color: #f48771; }
85
+ .empty-state { padding: 20px 12px; color: #555; font-size: 12px; text-align: center; line-height: 1.7; }
86
+
87
+ /* Agent sidebar */
88
+ .agent-chat { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
89
+ .agent-chat::-webkit-scrollbar { width: 4px; }
90
+ .agent-chat::-webkit-scrollbar-thumb { background: #424242; border-radius: 3px; }
91
+ .chat-msg { padding: 7px 9px; border-radius: 6px; font-size: 12px; line-height: 1.5; word-break: break-word; }
92
+ .chat-msg.user { background: #0e639c; color: #fff; align-self: flex-end; max-width: 90%; }
93
+ .chat-msg.assistant { background: #2d2d2d; color: #cccccc; max-width: 100%; }
94
+ .chat-msg.tool { background: #1a2633; color: #4fc1ff; font-family: monospace; font-size: 11px; border-left: 2px solid #007acc; }
95
+ .chat-msg.err { background: #2d1f1f; color: #f48771; }
96
+ .chat-msg.thinking { color: #858585; font-style: italic; }
97
+ .chat-msg pre { white-space: pre-wrap; margin-top: 4px; font-size: 10px; }
98
+ .api-row { padding: 6px 8px; border-bottom: 1px solid #3c3c3c; flex-shrink: 0; }
99
+ .api-label { font-size: 10px; color: #858585; margin-bottom: 3px; }
100
+ .api-input {
101
+ width: 100%; background: #3c3c3c; border: 1px solid #555;
102
+ border-radius: 3px; color: #cccccc; padding: 4px 8px; font-size: 11px; outline: none;
103
+ }
104
+ .api-input:focus { border-color: #007acc; }
105
+ .chat-input-row { padding: 6px 8px; border-top: 1px solid #3c3c3c; display: flex; gap: 4px; flex-shrink: 0; }
106
+ .chat-input {
107
+ flex: 1; background: #3c3c3c; border: 1px solid #555; border-radius: 4px;
108
+ color: #cccccc; padding: 5px 8px; font-size: 12px; resize: none; font-family: inherit; outline: none;
109
+ }
110
+ .chat-input:focus { border-color: #007acc; }
111
+ .chat-send {
112
+ background: #0e639c; border: none; color: #fff;
113
+ padding: 5px 10px; border-radius: 4px; cursor: pointer; font-size: 13px; flex-shrink: 0;
114
+ }
115
+ .chat-send:hover { background: #1177bb; }
116
+ .chat-send:disabled { opacity: 0.4; cursor: not-allowed; }
117
+
118
+ /* Editor area */
119
+ .editor-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
120
+
121
+ /* Tab bar */
122
+ .tab-bar {
123
+ height: 35px; background: #2d2d2d;
124
+ display: flex; align-items: flex-end;
125
+ border-bottom: 1px solid #252526; overflow-x: auto; flex-shrink: 0;
126
+ }
127
+ .tab-bar::-webkit-scrollbar { display: none; }
128
+ .tab {
129
+ height: 35px; padding: 0 10px;
130
+ display: flex; align-items: center; gap: 5px;
131
+ font-size: 12px; color: #969696; cursor: pointer;
132
+ background: #2d2d2d; border-right: 1px solid #252526;
133
+ white-space: nowrap; flex-shrink: 0;
134
+ }
135
+ .tab.active { background: #1e1e1e; color: #fff; border-top: 1px solid #007acc; }
136
+ .tab:hover:not(.active) { background: #2a2d2e; }
137
+ .tab-close {
138
+ width: 14px; height: 14px; display: flex; align-items: center; justify-content: center;
139
+ border-radius: 3px; opacity: 0; font-size: 14px; color: #ccc;
140
+ }
141
+ .tab:hover .tab-close, .tab.active .tab-close { opacity: 0.6; }
142
+ .tab-close:hover { background: rgba(255,255,255,0.15); opacity: 1 !important; }
143
+ .run-btn {
144
+ margin-left: auto; height: 35px; padding: 0 12px;
145
+ display: flex; align-items: center; gap: 5px;
146
+ background: none; border: none; color: #4ec9b0; cursor: pointer;
147
+ font-size: 12px; white-space: nowrap; flex-shrink: 0;
148
+ }
149
+ .run-btn:hover:not(:disabled) { color: #fff; background: rgba(78,201,176,0.12); }
150
+ .run-btn:disabled { color: #454545; cursor: not-allowed; }
151
+
152
+ /* Welcome */
153
+ .editor-welcome {
154
+ flex: 1; display: flex; flex-direction: column;
155
+ align-items: center; justify-content: center;
156
+ color: #555; text-align: center; padding: 20px;
157
+ }
158
+ .editor-welcome h2 { color: #777; font-size: 20px; font-weight: 400; margin-bottom: 10px; }
159
+ .editor-welcome p { font-size: 13px; line-height: 1.7; max-width: 300px; }
160
+ .editor-welcome .ext { margin-top: 14px; font-size: 11px; color: #3a3a3a; }
161
+
162
+ /* Code editor */
163
+ .editor-wrapper { flex: 1; display: flex; overflow: hidden; }
164
+ .line-numbers {
165
+ width: 50px; background: #1e1e1e; padding: 8px 0;
166
+ text-align: right; padding-right: 14px; color: #858585;
167
+ font-family: 'Cascadia Code', Consolas, 'Courier New', monospace;
168
+ font-size: 13px; line-height: 21px; user-select: none;
169
+ overflow: hidden; flex-shrink: 0;
170
+ }
171
+ .code-textarea {
172
+ flex: 1; background: #1e1e1e; color: #d4d4d4;
173
+ border: none; outline: none;
174
+ font-family: 'Cascadia Code', Consolas, 'Courier New', monospace;
175
+ font-size: 13px; line-height: 21px;
176
+ padding: 8px 12px; resize: none; tab-size: 2;
177
+ white-space: pre; overflow: auto;
178
+ }
179
+ .code-textarea::-webkit-scrollbar { width: 10px; height: 10px; }
180
+ .code-textarea::-webkit-scrollbar-track { background: #1e1e1e; }
181
+ .code-textarea::-webkit-scrollbar-thumb { background: #424242; border-radius: 2px; }
182
+
183
+ /* Bottom panel */
184
+ .bottom-panel {
185
+ height: 190px; background: #1e1e1e;
186
+ border-top: 1px solid #3c3c3c;
187
+ display: flex; flex-direction: column; flex-shrink: 0;
188
+ transition: height 0.15s ease;
189
+ }
190
+ .bottom-panel.expanded { height: calc(100% - 35px); }
191
+ .panel-header {
192
+ height: 28px; background: #252526;
193
+ display: flex; align-items: center; padding: 0 8px;
194
+ border-bottom: 1px solid #3c3c3c; flex-shrink: 0; gap: 0;
195
+ }
196
+ .ptab {
197
+ padding: 0 12px; height: 100%; display: flex; align-items: center;
198
+ font-size: 11px; color: #969696; cursor: pointer; border-bottom: 1px solid transparent;
199
+ }
200
+ .ptab.active { color: #cccccc; border-bottom-color: #007acc; }
201
+ .ptab:hover:not(.active) { color: #cccccc; }
202
+ .panel-actions { margin-left: auto; display: flex; align-items: center; gap: 4px; }
203
+ .pbtn {
204
+ background: none; border: none; color: #858585;
205
+ cursor: pointer; padding: 2px 6px; font-size: 11px; border-radius: 3px;
206
+ }
207
+ .pbtn:hover { color: #cccccc; background: rgba(255,255,255,0.08); }
208
+ .terminal-body {
209
+ flex: 1; padding: 6px 12px; overflow-y: auto;
210
+ font-family: 'Cascadia Code', Consolas, 'Courier New', monospace;
211
+ font-size: 12px; line-height: 1.65;
212
+ }
213
+ .terminal-body::-webkit-scrollbar { width: 6px; }
214
+ .terminal-body::-webkit-scrollbar-thumb { background: #424242; border-radius: 3px; }
215
+ .t-prompt { color: #3fc2fb; } .t-cmd { color: #cccccc; } .t-out { color: #858585; }
216
+ .t-ok { color: #4ec9b0; } .t-warn { color: #ffcc02; } .t-err { color: #f44747; }
217
+ .t-cursor { display: inline-block; width: 7px; height: 13px; background: #cccccc; vertical-align: text-bottom; animation: blink 1.2s step-end infinite; }
218
+ @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
219
+ .preview-body { flex: 1; background: #fff; display: none; }
220
+ .preview-iframe { width: 100%; height: 100%; border: none; display: block; }
221
+
222
+ /* Syntax tokens */
223
+ .kw { color: #569cd6; } .ty { color: #4ec9b0; }
224
+ .fn { color: #dcdcaa; } .va { color: #9cdcfe; }
225
+ .str { color: #ce9178; } .cmt { color: #6a9955; font-style: italic; }
226
+ .dec { color: #c586c0; } .cls { color: #4ec9b0; }
227
+ .num { color: #b5cea8; }
228
+
229
+ /* AI bar */
230
+ .ai-bar {
231
+ display: none; flex-shrink: 0;
232
+ padding: 5px 10px; background: #252526;
233
+ border-bottom: 1px solid #007acc;
234
+ align-items: center; gap: 8px;
235
+ }
236
+ .ai-bar.visible { display: flex; }
237
+ .ai-bar-icon { font-size: 14px; flex-shrink: 0; }
238
+ .ai-bar-input {
239
+ flex: 1; background: #3c3c3c; border: 1px solid #555;
240
+ border-radius: 4px; color: #cccccc; padding: 4px 10px;
241
+ font-size: 12px; outline: none; font-family: inherit;
242
+ }
243
+ .ai-bar-input:focus { border-color: #007acc; }
244
+ .ai-bar-btn {
245
+ background: #0e639c; border: none; color: #fff;
246
+ padding: 5px 14px; border-radius: 4px; cursor: pointer;
247
+ font-size: 12px; font-weight: 600; white-space: nowrap; flex-shrink: 0;
248
+ }
249
+ .ai-bar-btn:hover { background: #1177bb; }
250
+ .ai-bar-btn:disabled { opacity: 0.4; cursor: not-allowed; }
251
+
252
+ /* Ready bar */
253
+ .ready-bar {
254
+ display: none; flex-shrink: 0;
255
+ padding: 5px 10px; background: #252526;
256
+ border-bottom: 1px solid #28a745;
257
+ align-items: center; gap: 10px; font-size: 12px;
258
+ }
259
+ .ready-bar.visible { display: flex; }
260
+ .ready-bar-ok { color: #4ec9b0; flex-shrink: 0; font-size: 14px; }
261
+ .ready-bar-info { flex: 1; color: #858585; }
262
+ .ready-bar-info strong { color: #cccccc; }
263
+ .commit-btn {
264
+ background: #0e639c; border: none; color: #fff;
265
+ padding: 5px 14px; border-radius: 4px; cursor: pointer;
266
+ font-size: 12px; font-weight: 600; white-space: nowrap; flex-shrink: 0;
267
+ }
268
+ .commit-btn:hover { background: #1177bb; }
269
+
270
+ /* Code highlighted view */
271
+ .code-hl-view {
272
+ display: none; flex: 1; overflow: auto;
273
+ font-family: 'Cascadia Code', Consolas, 'Courier New', monospace;
274
+ font-size: 13px; line-height: 21px;
275
+ padding: 8px 12px; color: #d4d4d4; background: #1e1e1e;
276
+ }
277
+ .code-hl-view::-webkit-scrollbar { width: 10px; height: 10px; }
278
+ .code-hl-view::-webkit-scrollbar-track { background: #1e1e1e; }
279
+ .code-hl-view::-webkit-scrollbar-thumb { background: #424242; border-radius: 2px; }
280
+ .ai-gen-cursor { display: inline-block; width: 2px; height: 14px; background: #007acc; vertical-align: text-bottom; animation: blink 0.6s step-end infinite; margin-left: 1px; }
281
+
282
+ /* Commit overlay */
283
+ .commit-overlay {
284
+ display: none; position: fixed; inset: 0;
285
+ background: rgba(0,0,0,0.65); z-index: 1000;
286
+ align-items: center; justify-content: center;
287
+ }
288
+ .commit-overlay.open { display: flex; }
289
+ .commit-dialog {
290
+ background: #252526; border: 1px solid #454545;
291
+ border-radius: 8px; width: 420px; max-width: 90%;
292
+ display: flex; flex-direction: column;
293
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
294
+ }
295
+ .commit-header {
296
+ padding: 12px 16px; border-bottom: 1px solid #3c3c3c;
297
+ font-size: 13px; font-weight: 600; color: #cccccc;
298
+ display: flex; align-items: center; justify-content: space-between;
299
+ }
300
+ .commit-x { background: none; border: none; color: #858585; cursor: pointer; font-size: 18px; line-height: 1; }
301
+ .commit-x:hover { color: #cccccc; }
302
+ .commit-body { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
303
+ .commit-lbl { font-size: 11px; color: #858585; margin-bottom: 3px; }
304
+ .commit-msg-inp {
305
+ width: 100%; background: #3c3c3c; border: 1px solid #555;
306
+ border-radius: 4px; color: #cccccc; padding: 7px 10px;
307
+ font-size: 13px; outline: none; font-family: inherit; resize: none; box-sizing: border-box;
308
+ }
309
+ .commit-msg-inp:focus { border-color: #007acc; }
310
+ .commit-meta { font-size: 11px; color: #858585; display: flex; gap: 14px; align-items: center; }
311
+ .commit-add { color: #4ec9b0; }
312
+ .commit-footer {
313
+ padding: 11px 16px; border-top: 1px solid #3c3c3c;
314
+ display: flex; gap: 8px; justify-content: flex-end;
315
+ }
316
+ .cbtn { padding: 6px 16px; border-radius: 4px; font-size: 13px; cursor: pointer; border: none; }
317
+ .cbtn-sec { background: #3c3c3c; color: #cccccc; }
318
+ .cbtn-sec:hover { background: #505050; }
319
+ .cbtn-pri { background: #0e639c; color: #fff; font-weight: 600; }
320
+ .cbtn-pri:hover { background: #1177bb; }
321
+ .cbtn:disabled { opacity: 0.4; cursor: not-allowed; }
322
+
323
+ /* Status bar */
324
+ .status-bar {
325
+ height: 22px; background: #007acc;
326
+ display: flex; align-items: center; font-size: 11px; color: #fff; flex-shrink: 0;
327
+ }
328
+ .st-item { padding: 0 8px; cursor: pointer; display: flex; align-items: center; gap: 4px; height: 100%; white-space: nowrap; }
329
+ .st-item:hover { background: rgba(255,255,255,0.12); }
330
+ .st-right { margin-left: auto; display: flex; }
331
+ </style>
332
+ </head>
333
+ <body>
334
+
335
+ <div class="titlebar">
336
+ <div class="titlebar-dots">
337
+ <div class="titlebar-dot dot-close"></div>
338
+ <div class="titlebar-dot dot-min"></div>
339
+ <div class="titlebar-dot dot-max"></div>
340
+ </div>
341
+ <div class="titlebar-title" id="titlebar-title">Code Editor β€” Meridian</div>
342
+ </div>
343
+
344
+ <div class="workbench">
345
+
346
+ <!-- Activity bar -->
347
+ <div class="activity-bar">
348
+ <div class="act-icon active" id="act-explorer" title="Explorador" onclick="setSidebar('explorer')">
349
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
350
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h6"/>
351
+ </svg>
352
+ </div>
353
+ <div class="act-icon" id="act-agent" title="Agente AI" onclick="setSidebar('agent')">
354
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
355
+ <rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
356
+ <circle cx="12" cy="16" r="1.5" fill="currentColor"/>
357
+ </svg>
358
+ </div>
359
+ <div class="act-spacer"></div>
360
+ </div>
361
+
362
+ <!-- Sidebar -->
363
+ <div class="sidebar">
364
+
365
+ <!-- Explorer -->
366
+ <div id="sidebar-explorer">
367
+ <div class="sidebar-header">
368
+ Explorer
369
+ <button class="sidebar-btn" title="New file" onclick="newFile()">+</button>
370
+ </div>
371
+ <div class="file-tree" id="file-tree">
372
+ <div class="empty-state">No files.<br>Press <strong>+</strong> to create one.</div>
373
+ </div>
374
+ </div>
375
+
376
+ <!-- Agent -->
377
+ <div id="sidebar-agent">
378
+ <div class="sidebar-header">Code Agent</div>
379
+ <div class="api-row">
380
+ <div class="api-label">AMD Inference Endpoint URL</div>
381
+ <input type="text" class="api-input" id="api-key" placeholder="http://<IP>:8000/v1/chat/completions" />
382
+ </div>
383
+ <div class="agent-chat" id="agent-chat">
384
+ <div class="chat-msg assistant">Hi! I'm your code agent. I can create, edit and run HTML, CSS and JS files. What would you like to build?</div>
385
+ </div>
386
+ <div class="chat-input-row">
387
+ <textarea class="chat-input" id="chat-input" rows="2" placeholder="Ask me to build something..." onkeydown="chatKey(event)"></textarea>
388
+ <button class="chat-send" id="chat-send" onclick="sendChat()">β–Ά</button>
389
+ </div>
390
+ </div>
391
+
392
+ </div>
393
+
394
+ <!-- Editor area -->
395
+ <div class="editor-area">
396
+
397
+ <div class="tab-bar" id="tab-bar">
398
+ <button class="run-btn" id="run-btn" disabled onclick="runActive()">β–Ά Run</button>
399
+ </div>
400
+
401
+ <div class="ai-bar" id="ai-bar">
402
+ <span class="ai-bar-icon">✦</span>
403
+ <input class="ai-bar-input" id="ai-prompt" value="Generate a TypeScript notification service with retry logic and priority queues" />
404
+ <button class="ai-bar-btn" id="ai-gen-btn" onclick="generateCode()">Generate</button>
405
+ </div>
406
+ <div class="ready-bar" id="ready-bar">
407
+ <span class="ready-bar-ok">βœ“</span>
408
+ <span class="ready-bar-info" id="ready-info">code generated</span>
409
+ <button class="commit-btn" onclick="openCommit()">↑ Commit &amp; push</button>
410
+ </div>
411
+
412
+ <div class="editor-welcome" id="editor-welcome">
413
+ <h2>Meridian Code Editor</h2>
414
+ <p>Create a file with <strong>+</strong> in the explorer, or ask the <strong>AI Agent</strong> to do it.</p>
415
+ <div class="ext">HTML Β· JavaScript Β· CSS Β· JSON</div>
416
+ </div>
417
+
418
+ <div class="editor-wrapper" id="editor-wrapper" style="display:none">
419
+ <div class="line-numbers" id="line-numbers">1</div>
420
+ <textarea class="code-textarea" id="code-textarea" spellcheck="false"
421
+ oninput="onInput()" onscroll="syncScroll()" onkeydown="editorKey(event)"
422
+ onclick="updateCursor()" onkeyup="updateCursor()"></textarea>
423
+ <div class="code-hl-view" id="code-hl-view"></div>
424
+ </div>
425
+
426
+ <div class="bottom-panel" id="bottom-panel">
427
+ <div class="panel-header">
428
+ <div class="ptab active" id="ptab-terminal" onclick="setPanel('terminal')">TERMINAL</div>
429
+ <div class="ptab" id="ptab-preview" onclick="setPanel('preview')">PREVIEW</div>
430
+ <div class="panel-actions">
431
+ <button class="pbtn" id="expand-btn" onclick="toggleExpand()" title="Expand preview">β€’</button>
432
+ <button class="pbtn" onclick="clearTerm()">Clear</button>
433
+ </div>
434
+ </div>
435
+ <div class="terminal-body" id="terminal-body">
436
+ <div><span class="t-prompt">$</span> <span class="t-cursor"></span></div>
437
+ </div>
438
+ <div class="preview-body" id="preview-body">
439
+ <iframe class="preview-iframe" id="preview-iframe" sandbox="allow-scripts allow-same-origin"></iframe>
440
+ </div>
441
+ </div>
442
+
443
+ </div>
444
+ </div>
445
+
446
+ <div class="status-bar">
447
+ <div class="st-item" id="st-lang">Plain Text</div>
448
+ <div class="st-right">
449
+ <div class="st-item" id="st-cursor">Ln 1, Col 1</div>
450
+ <div class="st-item">UTF-8</div>
451
+ </div>
452
+ </div>
453
+
454
+ <script>
455
+ // ── Constants ──────────────────────────────────────────────────────────
456
+ const AMD_MODEL = 'llama-3.3-70b-versatile';
457
+ const LS_FILES = 'meridian-vscode-files';
458
+
459
+ // URL del endpoint AMD (recibida desde el app padre via postMessage o heredada de window)
460
+ let _amdUrlOverride = '';
461
+
462
+ // Receive API endpoint URL from parent Meridian app via postMessage
463
+ window.addEventListener('message', (e) => {
464
+ if (e.data?.type === 'AMD_URL' && e.data?.url) {
465
+ _amdUrlOverride = e.data.url;
466
+ const apiInput = document.getElementById('api-key');
467
+ if (apiInput && !apiInput.value.trim()) apiInput.value = _amdUrlOverride;
468
+ }
469
+ });
470
+
471
+ function getEffectiveAmdUrl() {
472
+ const fromInput = document.getElementById('api-key')?.value?.trim();
473
+ return fromInput || _amdUrlOverride || (window.getAmdUrl ? window.getAmdUrl() : '') || '';
474
+ }
475
+
476
+ // ── State ──────────────────────────────────────────────────────────────
477
+ const files = {}; // { name: content }
478
+ let openTabs = []; // ordered open file names
479
+ let active = null; // current file name
480
+ let panelMode = 'terminal';
481
+ let expanded = false;
482
+ let agentBusy = false;
483
+ let codeGenDone = false;
484
+ let codeStreaming = false;
485
+ const agentHistory = []; // chat messages array
486
+
487
+ // ── File icons / language ──────────────────────────────────────────────
488
+ function fileIcon(name) {
489
+ const ext = name.split('.').pop().toLowerCase();
490
+ const m = { html:'<span class="file-icon icon-html">HTML</span>', htm:'<span class="file-icon icon-html">HTML</span>',
491
+ js:'<span class="file-icon icon-js">JS</span>', mjs:'<span class="file-icon icon-js">JS</span>',
492
+ css:'<span class="file-icon icon-css">CSS</span>', json:'<span class="file-icon icon-json">JSON</span>' };
493
+ return m[ext] || '<span class="file-icon icon-txt">TXT</span>';
494
+ }
495
+ function fileLang(name) {
496
+ const ext = name.split('.').pop().toLowerCase();
497
+ const m = { html:'HTML', htm:'HTML', js:'JavaScript', mjs:'JavaScript', css:'CSS', json:'JSON', md:'Markdown', ts:'TypeScript' };
498
+ return m[ext] || 'Plain Text';
499
+ }
500
+ function defaultContent(name) {
501
+ const ext = name.split('.').pop().toLowerCase();
502
+ if (ext === 'html') return `<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <title>My page</title>\n <style>\n body { font-family: sans-serif; padding: 20px; }\n </style>\n</head>\n<body>\n <h1>Hello world</h1>\n <p>My first page.</p>\n</body>\n</html>`;
503
+ if (ext === 'js') return `// ${name}\nconsole.log('Hello from ${name}!');\n`;
504
+ if (ext === 'css') return `/* ${name} */\nbody {\n font-family: sans-serif;\n margin: 0;\n padding: 20px;\n}\n`;
505
+ if (ext === 'json') return `{\n \n}\n`;
506
+ return '';
507
+ }
508
+ function esc(s) {
509
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
510
+ }
511
+
512
+ // ── Persistence ────────────────────────────────────────────────────────
513
+ function persistFiles() {
514
+ try { localStorage.setItem(LS_FILES, JSON.stringify(files)); } catch(e) {}
515
+ }
516
+ function loadPersistedFiles() {
517
+ try {
518
+ const saved = JSON.parse(localStorage.getItem(LS_FILES) || '{}');
519
+ Object.assign(files, saved);
520
+ if (Object.keys(files).length) {
521
+ openTabs = Object.keys(files).slice(0, 5);
522
+ active = openTabs[0];
523
+ }
524
+ } catch(e) {}
525
+ }
526
+
527
+ // ── File operations ────────────────────────────────────────────────────
528
+ function createFile(name, content) {
529
+ saveCurrent();
530
+ if (content === undefined) content = defaultContent(name);
531
+ files[name] = content;
532
+ if (!openTabs.includes(name)) openTabs.push(name);
533
+ active = name;
534
+ renderAll();
535
+ persistFiles();
536
+ }
537
+
538
+ function openFile(name) {
539
+ if (files[name] === undefined) return;
540
+ saveCurrent();
541
+ if (!openTabs.includes(name)) openTabs.push(name);
542
+ active = name;
543
+ renderAll();
544
+ document.getElementById('code-textarea').focus();
545
+ }
546
+
547
+ function closeTab(name) {
548
+ const idx = openTabs.indexOf(name);
549
+ if (idx < 0) return;
550
+ openTabs.splice(idx, 1);
551
+ if (active === name) active = openTabs.length ? openTabs[Math.max(0, idx - 1)] : null;
552
+ renderAll();
553
+ }
554
+
555
+ function deleteFile(name) {
556
+ closeTab(name);
557
+ delete files[name];
558
+ renderAll();
559
+ persistFiles();
560
+ }
561
+
562
+ function saveCurrent() {
563
+ if (!active) return;
564
+ const ta = document.getElementById('code-textarea');
565
+ if (ta) { files[active] = ta.value; persistFiles(); }
566
+ }
567
+
568
+ // ── New file prompt ────────────────────────────────────────────────────
569
+ function newFile() {
570
+ const name = prompt('File name (e.g., index.html, app.js):');
571
+ if (!name || !name.trim()) return;
572
+ const trimmed = name.trim();
573
+ if (files[trimmed] !== undefined) { openFile(trimmed); return; }
574
+ createFile(trimmed);
575
+ }
576
+
577
+ // ── Render ─────────────────────────────────────────────────────────────
578
+ function renderAll() {
579
+ renderTree();
580
+ renderTabs();
581
+ renderEditor();
582
+ renderStatus();
583
+ }
584
+
585
+ function renderTree() {
586
+ const tree = document.getElementById('file-tree');
587
+ const names = Object.keys(files);
588
+ if (!names.length) {
589
+ tree.innerHTML = '<div class="empty-state">No files.<br>Press <strong>+</strong> to create one.</div>';
590
+ return;
591
+ }
592
+ tree.innerHTML = names.map(n => `
593
+ <div class="file-item ${n === active ? 'active' : ''}" data-file="${esc(n)}">
594
+ ${fileIcon(n)}
595
+ <span class="file-name">${esc(n)}</span>
596
+ <button class="file-del" data-del="${esc(n)}" title="Delete">Γ—</button>
597
+ </div>`).join('');
598
+ }
599
+
600
+ function isRunnable(name) {
601
+ if (!name) return false;
602
+ const ext = name.split('.').pop().toLowerCase();
603
+ return ext === 'html' || ext === 'htm';
604
+ }
605
+
606
+ function renderTabs() {
607
+ const bar = document.getElementById('tab-bar');
608
+ const canRun = isRunnable(active);
609
+ const prBtn = `<button class="run-btn" style="background:var(--violet);margin-right:8px" onclick="createPR()">βš‘ Create PR</button>`;
610
+ const runBtn = `<button class="run-btn" id="run-btn" ${canRun ? '' : 'disabled'} onclick="runActive()">β–Ά Run</button>`;
611
+ if (!openTabs.length) { bar.innerHTML = prBtn + runBtn; return; }
612
+ bar.innerHTML = openTabs.map(n => `
613
+ <div class="tab ${n === active ? 'active' : ''}" data-file="${esc(n)}">
614
+ ${fileIcon(n)} ${esc(n)}
615
+ <span class="tab-close" data-close="${esc(n)}">Γ—</span>
616
+ </div>`).join('') + prBtn + runBtn;
617
+ }
618
+
619
+ function renderEditor() {
620
+ const welcome = document.getElementById('editor-welcome');
621
+ const wrapper = document.getElementById('editor-wrapper');
622
+ const ta = document.getElementById('code-textarea');
623
+ document.getElementById('titlebar-title').textContent = active ? `${active} β€” Code Editor` : 'Code Editor β€” Meridian';
624
+
625
+ if (!active) {
626
+ welcome.style.display = 'flex';
627
+ wrapper.style.display = 'none';
628
+ } else {
629
+ welcome.style.display = 'none';
630
+ wrapper.style.display = 'flex';
631
+ ta.value = files[active] || '';
632
+ updateLineNums();
633
+ }
634
+ }
635
+
636
+ function renderStatus() {
637
+ document.getElementById('st-lang').textContent = active ? fileLang(active) : 'Plain Text';
638
+ }
639
+
640
+ // ── Editor helpers ─────────────────────────────────────────────────────
641
+ function updateLineNums() {
642
+ const ta = document.getElementById('code-textarea');
643
+ const ln = document.getElementById('line-numbers');
644
+ const cnt = ta.value.split('\n').length;
645
+ ln.innerHTML = Array.from({length: cnt}, (_, i) => i + 1).join('<br>');
646
+ }
647
+
648
+ function syncScroll() {
649
+ const ta = document.getElementById('code-textarea');
650
+ document.getElementById('line-numbers').scrollTop = ta.scrollTop;
651
+ }
652
+
653
+ function onInput() {
654
+ saveCurrent();
655
+ updateLineNums();
656
+ updateCursor();
657
+ }
658
+
659
+ function updateCursor() {
660
+ const ta = document.getElementById('code-textarea');
661
+ const val = ta.value.substring(0, ta.selectionStart);
662
+ const lines = val.split('\n');
663
+ document.getElementById('st-cursor').textContent = `Ln ${lines.length}, Col ${lines[lines.length - 1].length + 1}`;
664
+ }
665
+
666
+ function editorKey(e) {
667
+ if (e.key === 'Tab') {
668
+ e.preventDefault();
669
+ const ta = e.target, s = ta.selectionStart, en = ta.selectionEnd;
670
+ ta.value = ta.value.substring(0, s) + ' ' + ta.value.substring(en);
671
+ ta.selectionStart = ta.selectionEnd = s + 2;
672
+ saveCurrent(); updateLineNums();
673
+ }
674
+ }
675
+
676
+ // ── Sidebar toggle ─────────────────────────────────────────────────────
677
+ function setSidebar(mode) {
678
+ const isExplorer = mode === 'explorer';
679
+ document.getElementById('sidebar-explorer').style.display = isExplorer ? 'flex' : 'none';
680
+ document.getElementById('sidebar-agent').style.display = isExplorer ? 'none' : 'flex';
681
+ document.getElementById('act-explorer').classList.toggle('active', isExplorer);
682
+ document.getElementById('act-agent').classList.toggle('active', !isExplorer);
683
+ if (!isExplorer) {
684
+ setTimeout(() => document.getElementById('chat-input').focus(), 50);
685
+ }
686
+ }
687
+
688
+ // ── Panel toggle ────────────────────────────────────────────────────────
689
+ function setPanel(mode) {
690
+ panelMode = mode;
691
+ document.getElementById('ptab-terminal').classList.toggle('active', mode === 'terminal');
692
+ document.getElementById('ptab-preview').classList.toggle('active', mode === 'preview');
693
+ document.getElementById('terminal-body').style.display = mode === 'terminal' ? 'block' : 'none';
694
+ document.getElementById('preview-body').style.display = mode === 'preview' ? 'block' : 'none';
695
+ document.getElementById('expand-btn').style.display = mode === 'preview' ? 'inline-flex' : 'none';
696
+ }
697
+
698
+ function toggleExpand() {
699
+ expanded = !expanded;
700
+ const panel = document.getElementById('bottom-panel');
701
+ const wrap = document.getElementById('editor-wrapper');
702
+ const wel = document.getElementById('editor-welcome');
703
+ panel.classList.toggle('expanded', expanded);
704
+ if (expanded) {
705
+ wrap.style.display = 'none';
706
+ wel.style.display = 'none';
707
+ } else {
708
+ if (active) wrap.style.display = 'flex';
709
+ else wel.style.display = 'flex';
710
+ }
711
+ document.getElementById('expand-btn').textContent = expanded ? '‑' : '‒';
712
+ }
713
+
714
+ // ── Terminal ────────────────────────────────────────────────────────────
715
+ function termWrite(html) {
716
+ const body = document.getElementById('terminal-body');
717
+ const cur = body.querySelector('.t-cursor');
718
+ if (cur) cur.closest('div').remove();
719
+ const el = document.createElement('div');
720
+ el.innerHTML = html;
721
+ body.appendChild(el);
722
+ const curLine = document.createElement('div');
723
+ curLine.innerHTML = '<span class="t-prompt">$</span> <span class="t-cursor"></span>';
724
+ body.appendChild(curLine);
725
+ body.scrollTop = body.scrollHeight;
726
+ }
727
+ function termLine(type, text) {
728
+ const c = { ok:'t-ok', warn:'t-warn', err:'t-err', out:'t-out', cmd:'t-cmd' }[type] || 't-out';
729
+ termWrite(`<span class="${c}">${esc(text)}</span>`);
730
+ }
731
+ function termCmd(cmd) {
732
+ termWrite(`<span class="t-prompt">$</span> <span class="t-cmd">${esc(cmd)}</span>`);
733
+ }
734
+ function clearTerm() {
735
+ document.getElementById('terminal-body').innerHTML = '<div><span class="t-prompt">$</span> <span class="t-cursor"></span></div>';
736
+ }
737
+
738
+ // ── Run ────────────────────────────────────────────────────────────────
739
+ function runActive() {
740
+ if (!active) return;
741
+ saveCurrent();
742
+ runFile(active);
743
+ }
744
+
745
+ async function runFile(name) {
746
+ const content = files[name];
747
+ if (content === undefined) return 'Error: file not found';
748
+ const ext = name.split('.').pop().toLowerCase();
749
+ if (ext === 'html' || ext === 'htm') return runHTML(name, content);
750
+ if (ext === 'js') return runJS(name, content);
751
+ if (ext === 'css') {
752
+ termLine('warn', `CSS files cannot run standalone. Include ${name} in an HTML file.`);
753
+ return `Not directly runnable. Tip: create an HTML file that links to ${name}.`;
754
+ }
755
+ termLine('warn', `To run this file: open ${name} in a browser or with node ${name}`);
756
+ return `Not runnable: .${ext}`;
757
+ }
758
+
759
+ function runHTML(name, content) {
760
+ termCmd(`open ${name}`);
761
+ const lines = content.split('\n').length;
762
+ termLine('out', ` Loading ${name} (${lines} lines)…`);
763
+ termLine('ok', `βœ“ ${name} rendered β€” Preview active`);
764
+ document.getElementById('preview-iframe').srcdoc = content;
765
+ setPanel('preview');
766
+ return `HTML preview opened. Command to run locally: npx serve .`;
767
+ }
768
+
769
+ function runJS(name, content) {
770
+ termCmd(`node ${name}`);
771
+ return new Promise(resolve => {
772
+ const iframe = document.createElement('iframe');
773
+ iframe.style.display = 'none';
774
+ iframe.setAttribute('sandbox', 'allow-scripts');
775
+ document.body.appendChild(iframe);
776
+ const tid = setTimeout(() => { cleanup(); termLine('err', 'Timeout (5s)'); resolve('Timeout'); }, 5000);
777
+ function cleanup() { clearTimeout(tid); window.removeEventListener('message', handler); try { document.body.removeChild(iframe); } catch(e){} }
778
+ function handler(e) {
779
+ if (e.source !== iframe.contentWindow) return;
780
+ if (!e.data || e.data.type !== '_meridian_result') return;
781
+ cleanup();
782
+ const { logs, error } = e.data;
783
+ logs.forEach(([t, msg]) => termLine(t === 'error' ? 'err' : t === 'warn' ? 'warn' : 'out', msg));
784
+ if (error) { termLine('err', `⚠ ${error}`); resolve(`Error: ${error}`); }
785
+ else { termLine('ok', `βœ“ ${name} executed`); resolve(logs.map(([,m]) => m).join('\n') || '(no output)'); }
786
+ }
787
+ window.addEventListener('message', handler);
788
+ const src = content.replace(/<\/script>/gi, '<\\/script>');
789
+ iframe.srcdoc = `<!DOCTYPE html><html><body><script>
790
+ const _L=[];
791
+ const console={
792
+ log:(...a)=>_L.push(['log',a.map(x=>typeof x==='object'?JSON.stringify(x):String(x)).join(' ')]),
793
+ error:(...a)=>_L.push(['error',a.map(x=>String(x)).join(' ')]),
794
+ warn:(...a)=>_L.push(['warn',a.map(x=>String(x)).join(' ')]),
795
+ info:(...a)=>_L.push(['log',a.map(x=>String(x)).join(' ')]),
796
+ };
797
+ try{${src};parent.postMessage({type:'_meridian_result',logs:_L,error:null},'*');}
798
+ catch(e){parent.postMessage({type:'_meridian_result',logs:_L,error:e.message},'*');}
799
+ <\/script></body></html>`;
800
+ });
801
+ }
802
+
803
+ // ── AI Agent (AMD vLLM Endpoint / OpenAI-compatible) ───────────────────────────────
804
+ const SYSTEM = `You are a code agent embedded in the Meridian Code Editor. You create, edit and run web files (HTML, CSS, JavaScript) for the user.
805
+
806
+ Rules:
807
+ - Always produce complete, functional, beautiful code (no placeholders, no TODO comments).
808
+ - Use modern HTML5/CSS3/JS. For HTML pages, inline styles or a <style> tag is fine (no build step).
809
+ - After creating a file, always call run_file so the user sees the result immediately.
810
+ - Mention the terminal command to run the file (e.g. "npx serve ." or just open index.html in a browser).
811
+ - Be concise in chat β€” let the code speak.
812
+ - Respond in English.`;
813
+
814
+ // OpenAI-compatible function definitions for AMD Endpoint
815
+ const TOOLS = [
816
+ { type: 'function', function: { name: 'create_file', description: 'Create a new file with the given name and content. Use for HTML, CSS, JS.',
817
+ parameters: { type:'object', properties: { name:{type:'string',description:'Filename, e.g. index.html'}, content:{type:'string',description:'Full file content'} }, required:['name','content'] } } },
818
+ { type: 'function', function: { name: 'edit_file', description: 'Replace the entire content of an existing file.',
819
+ parameters: { type:'object', properties: { name:{type:'string'}, content:{type:'string'} }, required:['name','content'] } } },
820
+ { type: 'function', function: { name: 'read_file', description: 'Read the current content of a file.',
821
+ parameters: { type:'object', properties: { name:{type:'string'} }, required:['name'] } } },
822
+ { type: 'function', function: { name: 'list_files', description: 'List all files in the editor.',
823
+ parameters: { type:'object', properties: {} } } },
824
+ { type: 'function', function: { name: 'run_file', description: 'Execute a file β€” HTML opens live preview, JS runs in sandbox.',
825
+ parameters: { type:'object', properties: { name:{type:'string'} }, required:['name'] } } },
826
+ { type: 'function', function: { name: 'run_command', description: 'Run a terminal command (e.g., "node file.js", "npm install").',
827
+ parameters: { type:'object', properties: { command:{type:'string', description:'The terminal command to run'} }, required:['command'] } } },
828
+ ];
829
+
830
+ function chatKey(e) {
831
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
832
+ }
833
+
834
+ function addMsg(role, text) {
835
+ const box = document.getElementById('agent-chat');
836
+ const div = document.createElement('div');
837
+ div.className = `chat-msg ${role}`;
838
+ div.innerHTML = window.marked ? window.marked.parse(text) : esc(text).replace(/\n/g,'<br>');
839
+ box.appendChild(div);
840
+ box.scrollTop = box.scrollHeight;
841
+ return div;
842
+ }
843
+
844
+ function addToolMsg(name, input) {
845
+ const box = document.getElementById('agent-chat');
846
+ const div = document.createElement('div');
847
+ div.className = 'chat-msg tool';
848
+ const inp = JSON.stringify(input, null, 2);
849
+ div.innerHTML = `πŸ”§ <strong>${esc(name)}</strong><pre>${esc(inp.length > 300 ? inp.slice(0,300)+'…' : inp)}</pre>`;
850
+ box.appendChild(div);
851
+ box.scrollTop = box.scrollHeight;
852
+ }
853
+
854
+ async function sendChat() {
855
+ if (agentBusy) return;
856
+ // Prefer URL from input field; fall back to app-level AMD_ENDPOINT_URL
857
+ const amdUrl = getEffectiveAmdUrl();
858
+
859
+ const inputEl = document.getElementById('chat-input');
860
+ const msg = inputEl.value.trim();
861
+ if (!msg) return;
862
+ if (!amdUrl || amdUrl.includes('<IP-PUBLICA>')) { addMsg('err', '⚠ Endpoint AMD no configurado. ActualizÑ AMD_ENDPOINT_URL en ai-engine.jsx con la IP de tu instancia AMD.'); return; }
863
+
864
+ inputEl.value = '';
865
+ agentBusy = true;
866
+ document.getElementById('chat-send').disabled = true;
867
+
868
+ addMsg('user', msg);
869
+ agentHistory.push({ role: 'user', content: msg });
870
+
871
+ const thinking = addMsg('thinking', 'β‹― Thinking…');
872
+ try {
873
+ await agentLoop(amdUrl, thinking);
874
+ } catch(e) {
875
+ thinking.remove();
876
+ addMsg('assistant', `⚠ API Error: ${e?.message || String(e)}`);
877
+ } finally {
878
+ agentBusy = false;
879
+ document.getElementById('chat-send').disabled = false;
880
+ }
881
+ }
882
+
883
+ async function agentLoop(amdUrl, thinkingEl) {
884
+ let first = true;
885
+ const MAX_ITERS = 8;
886
+ for (let iter = 0; iter < MAX_ITERS; iter++) {
887
+ const res = await fetch(amdUrl, {
888
+ method: 'POST',
889
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer dummy-key` },
890
+ body: JSON.stringify({
891
+ model: AMD_MODEL,
892
+ max_tokens: 4096,
893
+ messages: [{ role: 'system', content: SYSTEM }, ...agentHistory],
894
+ tools: TOOLS,
895
+ tool_choice: 'auto',
896
+ })
897
+ });
898
+
899
+ if (!res.ok) {
900
+ const err = await res.json().catch(() => ({}));
901
+ throw new Error(err.error?.message || `AMD API HTTP ${res.status}`);
902
+ }
903
+
904
+ const data = await res.json();
905
+ if (first) { thinkingEl.remove(); first = false; }
906
+
907
+ const choice = data.choices?.[0];
908
+ const msg = choice?.message;
909
+ if (!msg) break;
910
+
911
+ agentHistory.push(msg);
912
+
913
+ // Text content
914
+ if (msg.content) addMsg('assistant', msg.content);
915
+
916
+ // Tool calls (OpenAI format)
917
+ const toolCalls = msg.tool_calls || [];
918
+ if (!toolCalls.length || choice.finish_reason === 'stop') break;
919
+
920
+ const results = [];
921
+ for (const tc of toolCalls) {
922
+ let input;
923
+ try { input = JSON.parse(tc.function.arguments); } catch { input = {}; }
924
+ addToolMsg(tc.function.name, input);
925
+ const out = await execTool(tc.function.name, input);
926
+ results.push({ role: 'tool', tool_call_id: tc.id, content: String(out) });
927
+ }
928
+ agentHistory.push(...results);
929
+ }
930
+ }
931
+
932
+ async function execTool(name, input) {
933
+ switch (name) {
934
+ case 'create_file':
935
+ createFile(input.name, input.content);
936
+ return `File "${input.name}" created.`;
937
+ case 'edit_file':
938
+ if (files[input.name] === undefined) return `Error: "${input.name}" does not exist.`;
939
+ files[input.name] = input.content;
940
+ if (active === input.name) { document.getElementById('code-textarea').value = input.content; updateLineNums(); }
941
+ renderTree(); renderTabs();
942
+ return `File "${input.name}" edited.`;
943
+ case 'read_file':
944
+ if (files[input.name] === undefined) return `Error: "${input.name}" does not exist.`;
945
+ return files[input.name];
946
+ case 'list_files': {
947
+ const ns = Object.keys(files);
948
+ return ns.length ? ns.join('\n') : 'No files.';
949
+ }
950
+ case 'run_file':
951
+ if (files[input.name] === undefined) return `Error: "${input.name}" does not exist.`;
952
+ openFile(input.name);
953
+ return await runFile(input.name);
954
+ case 'run_command':
955
+ termCmd(input.command);
956
+ if (input.command.startsWith('node ')) {
957
+ const f = input.command.split(' ')[1];
958
+ if (files[f]) { openFile(f); return await runFile(f); }
959
+ termLine('err', `Error: file ${f} not found`);
960
+ return `Error: file ${f} not found`;
961
+ }
962
+ termLine('out', `(Simulated) ${input.command}`);
963
+ return `Simulated command execution success.`;
964
+ default:
965
+ return `Unknown tool: ${name}`;
966
+ }
967
+ }
968
+
969
+ // ── Demo: AI Code Streaming ────────────────────────────────────────────
970
+ const notifierLines = [
971
+ `<span class="cmt">// notifier.ts β€” generated by Meridian AI</span>`,
972
+ `<span class="cmt">// Notification service with retry logic and priority queues</span>`,
973
+ ``,
974
+ `<span class="kw">export type</span> <span class="ty">Priority</span> = <span class="str">'low'</span> | <span class="str">'medium'</span> | <span class="str">'high'</span> | <span class="str">'critical'</span>;`,
975
+ ``,
976
+ `<span class="kw">export interface</span> <span class="cls">Notification</span> {`,
977
+ ` <span class="va">id</span>: <span class="ty">string</span>;`,
978
+ ` <span class="va">recipient</span>: <span class="ty">string</span>;`,
979
+ ` <span class="va">subject</span>: <span class="ty">string</span>;`,
980
+ ` <span class="va">body</span>: <span class="ty">string</span>;`,
981
+ ` <span class="va">priority</span>: <span class="ty">Priority</span>;`,
982
+ ` <span class="va">retries</span>: <span class="ty">number</span>;`,
983
+ ` <span class="va">maxRetries</span>: <span class="ty">number</span>;`,
984
+ ` <span class="va">createdAt</span>: <span class="ty">Date</span>;`,
985
+ `}`,
986
+ ``,
987
+ `<span class="kw">interface</span> <span class="cls">QueueItem</span> {`,
988
+ ` <span class="va">notification</span>: <span class="ty">Notification</span>;`,
989
+ ` <span class="va">score</span>: <span class="ty">number</span>;`,
990
+ `}`,
991
+ ``,
992
+ `<span class="kw">const</span> <span class="va">PRIORITY_SCORES</span>: <span class="ty">Record</span>&lt;<span class="ty">Priority</span>, <span class="ty">number</span>&gt; = {`,
993
+ ` <span class="str">low</span>: <span class="num">1</span>,`,
994
+ ` <span class="str">medium</span>: <span class="num">2</span>,`,
995
+ ` <span class="str">high</span>: <span class="num">4</span>,`,
996
+ ` <span class="str">critical</span>: <span class="num">8</span>,`,
997
+ `};`,
998
+ ``,
999
+ `<span class="kw">export class</span> <span class="cls">NotificationService</span> {`,
1000
+ ` <span class="kw">private</span> <span class="va">queue</span>: <span class="ty">QueueItem</span>[] = [];`,
1001
+ ` <span class="kw">private readonly</span> <span class="va">maxConcurrent</span> = <span class="num">3</span>;`,
1002
+ ` <span class="kw">private</span> <span class="va">running</span> = <span class="num">0</span>;`,
1003
+ ``,
1004
+ ` <span class="fn">enqueue</span>(<span class="va">notification</span>: <span class="ty">Notification</span>): <span class="ty">void</span> {`,
1005
+ ` <span class="kw">const</span> <span class="va">score</span> = <span class="va">PRIORITY_SCORES</span>[<span class="va">notification</span>.<span class="va">priority</span>];`,
1006
+ ` <span class="kw">this</span>.<span class="va">queue</span>.<span class="fn">push</span>({ <span class="va">notification</span>, <span class="va">score</span> });`,
1007
+ ` <span class="kw">this</span>.<span class="va">queue</span>.<span class="fn">sort</span>((<span class="va">a</span>, <span class="va">b</span>) =&gt; <span class="va">b</span>.<span class="va">score</span> - <span class="va">a</span>.<span class="va">score</span>);`,
1008
+ ` <span class="kw">this</span>.<span class="fn">flush</span>();`,
1009
+ ` }`,
1010
+ ``,
1011
+ ` <span class="kw">private async</span> <span class="fn">flush</span>(): <span class="ty">Promise</span>&lt;<span class="ty">void</span>&gt; {`,
1012
+ ` <span class="kw">while</span> (<span class="kw">this</span>.<span class="va">queue</span>.<span class="va">length</span> &gt; <span class="num">0</span> &amp;&amp; <span class="kw">this</span>.<span class="va">running</span> &lt; <span class="kw">this</span>.<span class="va">maxConcurrent</span>) {`,
1013
+ ` <span class="kw">const</span> <span class="va">item</span> = <span class="kw">this</span>.<span class="va">queue</span>.<span class="fn">shift</span>()!;`,
1014
+ ` <span class="kw">this</span>.<span class="va">running</span>++;`,
1015
+ ` <span class="kw">this</span>.<span class="fn">dispatch</span>(<span class="va">item</span>.<span class="va">notification</span>)`,
1016
+ ` .<span class="fn">finally</span>(() =&gt; { <span class="kw">this</span>.<span class="va">running</span>--; <span class="kw">this</span>.<span class="fn">flush</span>(); });`,
1017
+ ` }`,
1018
+ ` }`,
1019
+ ``,
1020
+ ` <span class="kw">private async</span> <span class="fn">dispatch</span>(<span class="va">n</span>: <span class="ty">Notification</span>): <span class="ty">Promise</span>&lt;<span class="ty">void</span>&gt; {`,
1021
+ ` <span class="kw">for</span> (<span class="kw">let</span> <span class="va">attempt</span> = <span class="num">0</span>; <span class="va">attempt</span> &lt;= <span class="va">n</span>.<span class="va">maxRetries</span>; <span class="va">attempt</span>++) {`,
1022
+ ` <span class="kw">try</span> {`,
1023
+ ` <span class="kw">await</span> <span class="kw">this</span>.<span class="fn">send</span>(<span class="va">n</span>);`,
1024
+ ` <span class="kw">return</span>;`,
1025
+ ` } <span class="kw">catch</span> (<span class="va">err</span>) {`,
1026
+ ` <span class="kw">if</span> (<span class="va">attempt</span> === <span class="va">n</span>.<span class="va">maxRetries</span>) <span class="kw">throw</span> <span class="va">err</span>;`,
1027
+ ` <span class="kw">await</span> <span class="kw">this</span>.<span class="fn">delay</span>(<span class="ty">Math</span>.<span class="fn">pow</span>(<span class="num">2</span>, <span class="va">attempt</span>) * <span class="num">200</span>);`,
1028
+ ` }`,
1029
+ ` }`,
1030
+ ` }`,
1031
+ ``,
1032
+ ` <span class="kw">private async</span> <span class="fn">send</span>(<span class="va">n</span>: <span class="ty">Notification</span>): <span class="ty">Promise</span>&lt;<span class="ty">void</span>&gt; {`,
1033
+ ` <span class="cmt">// Simulate async transport (replace with real implementation)</span>`,
1034
+ ` <span class="kw">await</span> <span class="kw">this</span>.<span class="fn">delay</span>(<span class="num">50</span> + <span class="ty">Math</span>.<span class="fn">random</span>() * <span class="num">150</span>);`,
1035
+ ` <span class="va">console</span>.<span class="fn">log</span>(<span class="str">'Sent ['</span> + <span class="va">n</span>.<span class="va">priority</span> + <span class="str">'] to '</span> + <span class="va">n</span>.<span class="va">recipient</span>);`,
1036
+ ` }`,
1037
+ ``,
1038
+ ` <span class="kw">private</span> <span class="fn">delay</span>(<span class="va">ms</span>: <span class="ty">number</span>): <span class="ty">Promise</span>&lt;<span class="ty">void</span>&gt; {`,
1039
+ ` <span class="kw">return new</span> <span class="cls">Promise</span>(<span class="va">resolve</span> =&gt; <span class="fn">setTimeout</span>(<span class="va">resolve</span>, <span class="va">ms</span>));`,
1040
+ ` }`,
1041
+ `}`,
1042
+ ];
1043
+
1044
+ function updateLineNumsForHL() {
1045
+ const ln = document.getElementById('line-numbers');
1046
+ ln.innerHTML = Array.from({length: notifierLines.length}, (_, i) => i + 1).join('<br>');
1047
+ }
1048
+
1049
+ function generateCode() {
1050
+ if (codeStreaming || codeGenDone) return;
1051
+ const btn = document.getElementById('ai-gen-btn');
1052
+ btn.disabled = true;
1053
+ btn.textContent = 'Generating…';
1054
+ codeStreaming = true;
1055
+
1056
+ const hlView = document.getElementById('code-hl-view');
1057
+ const ta = document.getElementById('code-textarea');
1058
+ const welcome = document.getElementById('editor-welcome');
1059
+ const wrapper = document.getElementById('editor-wrapper');
1060
+ const aiBar = document.getElementById('ai-bar');
1061
+ welcome.style.display = 'none';
1062
+ wrapper.style.display = 'flex';
1063
+ ta.style.display = 'none';
1064
+ hlView.style.display = 'block';
1065
+ hlView.innerHTML = '';
1066
+ updateLineNumsForHL();
1067
+
1068
+ let i = 0;
1069
+ function streamLine() {
1070
+ if (i >= notifierLines.length) {
1071
+ const cur = hlView.querySelector('.ai-gen-cursor');
1072
+ if (cur) cur.remove();
1073
+ codeStreaming = false;
1074
+ codeGenDone = true;
1075
+ files['notifier.ts'] = notifierLines.map(l => l.replace(/<[^>]*>/g, '')).join('\n');
1076
+ aiBar.classList.remove('visible');
1077
+ const readyBar = document.getElementById('ready-bar');
1078
+ document.getElementById('ready-info').innerHTML =
1079
+ `<strong>notifier.ts</strong> β€” ${notifierLines.length} lines generated`;
1080
+ readyBar.classList.add('visible');
1081
+ renderStatus();
1082
+ return;
1083
+ }
1084
+ const cur = hlView.querySelector('.ai-gen-cursor');
1085
+ if (cur) cur.remove();
1086
+ const div = document.createElement('div');
1087
+ div.innerHTML = notifierLines[i] + '<span class="ai-gen-cursor"></span>';
1088
+ hlView.appendChild(div);
1089
+ hlView.scrollTop = hlView.scrollHeight;
1090
+ i++;
1091
+ setTimeout(streamLine, 28 + Math.random() * 55);
1092
+ }
1093
+ streamLine();
1094
+ }
1095
+
1096
+ function openCommit() {
1097
+ document.getElementById('commit-additions').textContent = '+' + notifierLines.length;
1098
+ document.getElementById('commit-overlay').classList.add('open');
1099
+ }
1100
+ function closeCommit() {
1101
+ document.getElementById('commit-overlay').classList.remove('open');
1102
+ }
1103
+ function doCommit() {
1104
+ const msg = document.getElementById('commit-msg').value.trim() || 'feat: add NotificationService';
1105
+ const goBtn = document.getElementById('commit-go');
1106
+ goBtn.disabled = true;
1107
+ goBtn.textContent = 'Pushing…';
1108
+
1109
+ const steps = [
1110
+ { type: 'cmd', text: 'git add notifier.ts' },
1111
+ { type: 'cmd', text: `git commit -m "${msg}"` },
1112
+ { type: 'out', text: ` 1 file changed, ${notifierLines.length} insertions(+)` },
1113
+ { type: 'cmd', text: 'git push origin feat/ai-generated' },
1114
+ { type: 'out', text: 'Enumerating objects: 4, done.' },
1115
+ { type: 'out', text: 'Writing objects: 100% (3/3), 1.42 KiB, done.' },
1116
+ { type: 'ok', text: `βœ“ feat/ai-generated β†’ origin [new branch]` },
1117
+ ];
1118
+
1119
+ closeCommit();
1120
+ let idx = 0;
1121
+ function nextStep() {
1122
+ if (idx >= steps.length) {
1123
+ goBtn.disabled = false;
1124
+ goBtn.textContent = 'Commit & push';
1125
+ window.parent.postMessage({
1126
+ type: 'meridian:vscode-commit',
1127
+ fileName: 'notifier.ts',
1128
+ commitMessage: msg,
1129
+ branch: 'feat/ai-generated',
1130
+ additions: notifierLines.length,
1131
+ }, '*');
1132
+ return;
1133
+ }
1134
+ const { type, text } = steps[idx++];
1135
+ if (type === 'cmd') termCmd(text); else termLine(type, text);
1136
+ setTimeout(nextStep, 200 + Math.random() * 220);
1137
+ }
1138
+ nextStep();
1139
+ }
1140
+
1141
+ // ── Event delegation ───────────────────────────────────────────────────
1142
+ document.getElementById('file-tree').addEventListener('click', e => {
1143
+ const del = e.target.closest('[data-del]');
1144
+ const item = e.target.closest('[data-file]');
1145
+ if (del) { e.stopPropagation(); if (confirm(`Delete ${del.dataset.del}?`)) deleteFile(del.dataset.del); }
1146
+ else if (item) openFile(item.dataset.file);
1147
+ });
1148
+
1149
+ document.getElementById('tab-bar').addEventListener('click', e => {
1150
+ const close = e.target.closest('[data-close]');
1151
+ const tab = e.target.closest('[data-file]');
1152
+ if (close) { e.stopPropagation(); closeTab(close.dataset.close); }
1153
+ else if (tab) openFile(tab.dataset.file);
1154
+ });
1155
+
1156
+ // Ctrl+Enter or F5 to run
1157
+ document.addEventListener('keydown', e => {
1158
+ if ((e.ctrlKey && e.key === 'Enter') || e.key === 'F5') { e.preventDefault(); runActive(); }
1159
+ });
1160
+
1161
+ // ── Init ───────────────────────────────────────────────────────────────
1162
+ // Load persisted files from localStorage
1163
+ loadPersistedFiles();
1164
+
1165
+ // Pre-fill AMD endpoint URL desde la constante central (si el campo estΓ‘ vacΓ­o)
1166
+ (function() {
1167
+ const apiInput = document.getElementById('api-key');
1168
+ if (apiInput && !apiInput.value.trim() && window.AMD_ENDPOINT_URL) {
1169
+ apiInput.value = window.AMD_ENDPOINT_URL;
1170
+ }
1171
+ })();
1172
+
1173
+ setPanel('terminal');
1174
+ renderAll();
1175
+
1176
+ async function createPR() {
1177
+ const prBtn = document.querySelector('.run-btn[onclick="createPR()"]');
1178
+ if (prBtn) { prBtn.disabled = true; prBtn.innerText = '⏳ Creating PR...'; }
1179
+
1180
+ try {
1181
+ const amdUrl = getEffectiveAmdUrl();
1182
+
1183
+ if (!amdUrl || amdUrl.includes('<IP-PUBLICA>')) throw new Error("Endpoint AMD no configurado. ActualizΓ‘ AMD_ENDPOINT_URL en ai-engine.jsx.");
1184
+
1185
+ // Generate AI Summary based on code
1186
+ const fileContents = Object.entries(files).map(([name, content]) => `// File: ${name}\n${content}`).join('\n\n');
1187
+
1188
+ const res = await fetch(amdUrl, {
1189
+ method: 'POST',
1190
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer dummy-key` },
1191
+ body: JSON.stringify({
1192
+ model: 'llama-3.3-70b-versatile',
1193
+ max_tokens: 200,
1194
+ messages: [
1195
+ { role: 'system', content: 'You are a senior developer. Based on the file contents provided, generate a brief, one-sentence PR title and a very short description (max 2 sentences) summarizing what this code does. Format strictly as JSON: {"title": "the title", "description": "the description"}' },
1196
+ { role: 'user', content: fileContents || 'Empty project' }
1197
+ ],
1198
+ response_format: { type: "json_object" }
1199
+ })
1200
+ });
1201
+
1202
+ if (!res.ok) throw new Error("Failed to contact AMD API for PR summary.");
1203
+
1204
+ const data = await res.json();
1205
+ const summary = JSON.parse(data.choices[0].message.content);
1206
+
1207
+ // Count rough lines of code for additions
1208
+ let lines = 0;
1209
+ for (const key in files) lines += files[key].split('\\n').length;
1210
+
1211
+ // Use window.parent to dispatch to the main app state
1212
+ if (window.parent && window.parent.apiFetch) {
1213
+ await window.parent.apiFetch('POST', '/api/prs', {
1214
+ title: summary.title,
1215
+ branch: 'feature/vscode-ai-gen',
1216
+ base: 'main',
1217
+ status: 'open',
1218
+ additions: lines,
1219
+ deletions: 0
1220
+ });
1221
+ window.parent.toast("Pull Request created successfully!");
1222
+ if (window.parent.meridianRefresh) window.parent.meridianRefresh();
1223
+ } else {
1224
+ termLine('err', 'βœ— Could not connect to parent app window to create PR.');
1225
+ }
1226
+ } catch (err) {
1227
+ termLine('err', `⚠ API Error: ${err?.message || String(err)}`);
1228
+ } finally {
1229
+ if (prBtn) { prBtn.disabled = false; prBtn.innerText = 'βš‘ Create PR'; }
1230
+ }
1231
+ }
1232
+
1233
+ // Greet in terminal
1234
+ termLine('out', ' Meridian Code Editor ready. Ask the AI agent or press + to create a file.');
1235
+ termLine('ok', 'βœ“ Session storage active β€” files persist across refreshes.');
1236
+ </script>
1237
+
1238
+ <div class="commit-overlay" id="commit-overlay">
1239
+ <div class="commit-dialog">
1240
+ <div class="commit-header">
1241
+ ↑ Commit &amp; push
1242
+ <button class="commit-x" onclick="closeCommit()">Γ—</button>
1243
+ </div>
1244
+ <div class="commit-body">
1245
+ <div>
1246
+ <div class="commit-lbl">Commit message</div>
1247
+ <textarea class="commit-msg-inp" id="commit-msg" rows="2">feat: add NotificationService with retry logic and priority queues</textarea>
1248
+ </div>
1249
+ <div class="commit-meta">
1250
+ <span>1 file changed</span>
1251
+ <span class="commit-add" id="commit-additions">+59</span>
1252
+ <span>β†’ feat/ai-generated</span>
1253
+ </div>
1254
+ </div>
1255
+ <div class="commit-footer">
1256
+ <button class="cbtn cbtn-sec" onclick="closeCommit()">Cancel</button>
1257
+ <button class="cbtn cbtn-pri" id="commit-go" onclick="doCommit()">Commit &amp; push</button>
1258
+ </div>
1259
+ </div>
1260
+ </div>
1261
+
1262
+ </body>
1263
+ </html>