Add v2 rewrite: monorepo with desktop and web apps, shared packages, docs, and wireframes
This commit is contained in:
8
apps/web/src/README.md
Normal file
8
apps/web/src/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Web Source Layout
|
||||
|
||||
- `main.tsx`: renderer bootstrap.
|
||||
- `ui/`: React UI components.
|
||||
- `rpcClient.ts`: browser-side client for the preload RPC bridge.
|
||||
- `styles.css`: renderer styles.
|
||||
|
||||
Keep Electron-specific code out of this app. Shared message shapes belong in `packages/contracts`.
|
||||
9
apps/web/src/global.d.ts
vendored
Normal file
9
apps/web/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { RpcClient } from "../../../packages/contracts/src/rpc";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mosaic?: RpcClient;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
3
apps/web/src/lib/cn.ts
Normal file
3
apps/web/src/lib/cn.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function cx(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
97
apps/web/src/lib/constants.ts
Normal file
97
apps/web/src/lib/constants.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Alert, Catalyst, EarningsSchedule, ExportRecord, Filing, Holding, Risk, Screen } from "@mosaiciq/contracts/rpc";
|
||||
|
||||
export const screens: Screen[] = ["home", "workspace", "model", "memo", "agents"];
|
||||
|
||||
export const workspaceGroups = [
|
||||
["Research", "Company Snapshot", "Business Description", "Segment / Revenue Build", "Margin Build", "Historical Financials", "Three-Statement Model", "Key KPIs", "Management & Strategy"],
|
||||
["Analysis", "Competitive Landscape", "Peer Comparison", "Valuation Analysis", "Investment Thesis", "Risks & Mitigants", "Catalyst Tracker"],
|
||||
["Monitoring", "Earnings Monitor", "Filing Watch", "Thesis Alerts"],
|
||||
["Library", "Source Library", "Export Center"]
|
||||
];
|
||||
|
||||
export const agentCatalog: Array<[string, string, string, string]> = [
|
||||
["cr", "Company Research Agent", "Structured profiles from filings, transcripts, external data", "research"],
|
||||
["sf", "SEC Filings Agent", "Segment data, KPIs, risk factors, accounting policies", "research"],
|
||||
["fm", "Financial Modeling Agent", "Revenue builds, margin models, three-statement frameworks", "research"],
|
||||
["ec", "Earnings Call Agent", "Management tone, guidance, KPIs, Q&A themes", "competitive"],
|
||||
["ci", "Competitive Intel Agent", "Peer analysis, market positioning, competitive threats", "competitive"],
|
||||
["va", "Valuation Agent", "DCF, trading comps, scenario analysis, multiples", "research"],
|
||||
["rk", "Risk Agent", "Business, financial, competitive, regulatory risks", "competitive"],
|
||||
["mw", "Memo Writing Agent", "Investment memos, research reports, IC memos", "research"],
|
||||
["pa", "Presentation Agent", "IC presentation drafts, slide outlines, exhibits", "research"],
|
||||
["mn", "Monitoring Agent", "Filing alerts, thesis changes, earnings events", "cross-cutting"],
|
||||
["sv", "Source Verification Agent", "Citation checking, source reliability, cross-referencing", "cross-cutting"],
|
||||
["rt", "Red Team Agent", "Thesis challenges, assumption stress-testing, bear cases", "competitive"],
|
||||
["ex", "Export Agent", "PDF, Excel, PowerPoint export pipelines", "cross-cutting"],
|
||||
["qa", "Model QA Agent", "Formula auditing, balance sheet checks, sanity tests", "cross-cutting"]
|
||||
];
|
||||
|
||||
export const extraHoldings: Holding[] = [
|
||||
{ ticker: "BJ", name: "BJ's Wholesale Club", price: 84.2, changePct: 0.4, weight: 8 },
|
||||
{ ticker: "KR", name: "Kroger Co", price: 62.1, changePct: -0.6, weight: 5 },
|
||||
{ ticker: "DG", name: "Dollar General", price: 78.5, changePct: 1.5, weight: 4 }
|
||||
];
|
||||
|
||||
export const demoCatalysts: Catalyst[] = [
|
||||
{ id: "cat-1", date: "2026-06-05", event: "Q3 FY25 Earnings", impact: "high", thesisRelevance: "supports", source: "[4]" },
|
||||
{ id: "cat-2", date: "2026-07-15", event: "Executive member update", impact: "medium", thesisRelevance: "supports", source: "[2]" },
|
||||
{ id: "cat-3", date: "2026-08-01", event: "Annual membership fee review", impact: "high", thesisRelevance: "neutral", source: "[1]" },
|
||||
{ id: "cat-4", date: "2026-09-10", event: "New warehouse openings (Q4)", impact: "low", thesisRelevance: "supports", source: "[1]" }
|
||||
];
|
||||
|
||||
export const demoAlerts: Alert[] = [
|
||||
{ id: "alert-1", companyId: "cost", timestamp: "2026-05-12T08:30:00Z", type: "earnings_surprise", description: "Q2 FY25 earnings beat (+7.5% comp)", thesisImpact: "positive", status: "new", targetSection: "thesis" },
|
||||
{ id: "alert-2", companyId: "cost", timestamp: "2026-05-12T06:00:00Z", type: "filing", description: "New 8-K: Executive compensation update", thesisImpact: "neutral", status: "new" },
|
||||
{ id: "alert-3", companyId: "wmt", timestamp: "2026-05-11T14:00:00Z", type: "peer_event", description: "WMT announces price investment in grocery", thesisImpact: "negative", status: "reviewed" },
|
||||
{ id: "alert-4", companyId: "cost", timestamp: "2026-05-11T10:00:00Z", type: "price_move", description: "COST +2.1% on heavy volume", thesisImpact: "positive", status: "reviewed" }
|
||||
];
|
||||
|
||||
export const demoRisks: Risk[] = [
|
||||
{ id: "risk-1", companyId: "cost", risk: "Amazon enters warehouse club segment", category: "competitive", severity: "high", likelihood: "low", mitigation: "Costco's 93% renewal rate creates switching costs", status: "open" },
|
||||
{ id: "risk-2", companyId: "cost", risk: "Wage inflation compresses operating margin", category: "financial", severity: "medium", likelihood: "high", mitigation: "Automation and productivity offset 40-60% of wage pressure", status: "open" },
|
||||
{ id: "risk-3", companyId: "cost", risk: "Membership fee increase delayed beyond FY26", category: "financial", severity: "medium", likelihood: "medium", mitigation: "Fee income growth from executive tier conversion", status: "mitigated" },
|
||||
{ id: "risk-4", companyId: "cost", risk: "Regulatory pressure on merchandise sourcing", category: "regulatory", severity: "low", likelihood: "low", mitigation: "Diversified supply chain across 14 countries", status: "accepted" },
|
||||
{ id: "risk-5", companyId: "cost", risk: "E-commerce disruption of warehouse model", category: "competitive", severity: "medium", likelihood: "medium", mitigation: "Costco.com growth and Instacart partnership", status: "open" },
|
||||
{ id: "risk-6", companyId: "cost", risk: "Valuation compression on growth deceleration", category: "financial", severity: "high", likelihood: "medium", mitigation: "High-single-digit earnings growth supports premium", status: "open" }
|
||||
];
|
||||
|
||||
export const demoEarnings: EarningsSchedule[] = [
|
||||
{ id: "earn-1", companyId: "cost", quarter: "Q3 FY25", expectedDate: "2026-06-05", timing: "bmo" },
|
||||
{ id: "earn-2", companyId: "cost", quarter: "Q4 FY25", expectedDate: "2026-09-25", timing: "bmo" },
|
||||
{ id: "earn-3", companyId: "cost", quarter: "Q2 FY25", expectedDate: "2026-03-06", timing: "bmo", actualRevenue: "$62.5B", expectedRevenue: "$61.2B", actualEps: "$4.28", expectedEps: "$4.05" },
|
||||
{ id: "earn-4", companyId: "cost", quarter: "Q1 FY25", expectedDate: "2025-12-12", timing: "bmo", actualRevenue: "$60.2B", expectedRevenue: "$59.8B", actualEps: "$4.04", expectedEps: "$3.98" }
|
||||
];
|
||||
|
||||
export const demoFilings: Filing[] = [
|
||||
{ id: "filing-1", companyId: "cost", formType: "10-K", filedDate: "2024-10-10", title: "Annual Report FY2024", keyChanges: "Updated segment reporting, new warehouse commitments ($2.1B)", reviewed: true },
|
||||
{ id: "filing-2", companyId: "cost", formType: "10-Q", filedDate: "2026-03-10", title: "Quarterly Report Q2 FY25", keyChanges: "Membership fee income growth, margin expansion", reviewed: true },
|
||||
{ id: "filing-3", companyId: "cost", formType: "8-K", filedDate: "2026-05-12", title: "Executive Compensation Update", keyChanges: "New CEO compensation structure", reviewed: false }
|
||||
];
|
||||
|
||||
export const demoExports: ExportRecord[] = [
|
||||
{ id: "export-1", type: "excel", title: "COST Model — Revenue Build FY25-FY27", companyId: "cost", format: "Excel", fileSize: "2.4 MB", status: "complete", createdAt: "2026-05-10T14:30:00Z" },
|
||||
{ id: "export-2", type: "pdf", title: "COST Investment Memo — Draft", companyId: "cost", format: "PDF", fileSize: "1.8 MB", status: "complete", createdAt: "2026-05-09T16:00:00Z" },
|
||||
{ id: "export-3", type: "ppt", title: "COST IC Presentation", companyId: "cost", format: "PowerPoint", fileSize: "4.2 MB", status: "processing", createdAt: "2026-05-12T09:00:00Z" },
|
||||
{ id: "export-4", type: "pdf", title: "Peer Comparison Report", companyId: "cost", format: "PDF", fileSize: "980 KB", status: "complete", createdAt: "2026-05-08T11:00:00Z" }
|
||||
];
|
||||
|
||||
export const keyboardShortcuts: Array<[string, string, string]> = [
|
||||
["⌘K", "Open agent chat / command bar", "global"],
|
||||
["⌘F", "Focus search bar", "global"],
|
||||
["⌘S", "Create manual snapshot", "memo,model"],
|
||||
["⌘E", "Open export quick-action menu", "global"],
|
||||
["⌘\\", "Toggle left nav collapse", "workspace,memo"],
|
||||
["⌘1-5", "Switch to screen 1-5", "global"],
|
||||
["⌘.", "Toggle settings overlay", "global"],
|
||||
["Escape", "Close overlay / fullscreen / search", "global"],
|
||||
["↑ / ↓", "Navigate carousel, search, model cells", "context"],
|
||||
["Enter", "Select / confirm", "context"],
|
||||
["Tab", "Move between memo sections", "memo"],
|
||||
["⌘Z", "Undo last edit", "memo,model"],
|
||||
["⌘⇧Z", "Redo", "memo,model"]
|
||||
];
|
||||
|
||||
export const modelTabs = [
|
||||
"Revenue Build", "Income Statement", "Balance Sheet", "Cash Flow Statement",
|
||||
"Margin Build", "Tax Build", "LIFO / FIFO Conversion", "Scenario Analysis", "Charts"
|
||||
];
|
||||
15
apps/web/src/lib/format.ts
Normal file
15
apps/web/src/lib/format.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function formatPct(value: number) {
|
||||
return `${value > 0 ? "+" : ""}${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function capitalize(value: string) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
export function toneText(value: number) {
|
||||
return value >= 0 ? "text-[var(--green)]" : "text-[var(--red)]";
|
||||
}
|
||||
336
apps/web/src/lib/markdown.ts
Normal file
336
apps/web/src/lib/markdown.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import type { MemoAnnotation } from "@mosaiciq/contracts/rpc";
|
||||
import { cx } from "./cn";
|
||||
|
||||
export function markdownToEditableHtml(content: string, annotations: MemoAnnotation[] = []) {
|
||||
if (!content) return "";
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
const blocks: string[] = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
blocks.push("<p><br></p>");
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedItems: string[] = [];
|
||||
while (index < lines.length) {
|
||||
const match = lines[index].match(/^\s*[-*]\s+(.+)$/);
|
||||
if (!match) break;
|
||||
unorderedItems.push(match[1]);
|
||||
index += 1;
|
||||
}
|
||||
if (unorderedItems.length) {
|
||||
index -= 1;
|
||||
blocks.push(`<ul>${unorderedItems.map((item) => `<li>${renderEditableInlineMarkdown(item, annotations)}</li>`).join("")}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedItems: string[] = [];
|
||||
while (index < lines.length) {
|
||||
const match = lines[index].match(/^\s*\d+\.\s+(.+)$/);
|
||||
if (!match) break;
|
||||
orderedItems.push(match[1]);
|
||||
index += 1;
|
||||
}
|
||||
if (orderedItems.length) {
|
||||
index -= 1;
|
||||
blocks.push(`<ol>${orderedItems.map((item) => `<li>${renderEditableInlineMarkdown(item, annotations)}</li>`).join("")}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
if (heading) {
|
||||
const level = Math.min(heading[1].length + 3, 6);
|
||||
blocks.push(`<h${level}>${renderEditableInlineMarkdown(heading[2], annotations)}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = line.match(/^>\s+(.+)$/);
|
||||
if (quote) {
|
||||
blocks.push(`<blockquote>${renderEditableInlineMarkdown(quote[1], annotations)}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
blocks.push(`<p>${renderEditableInlineMarkdown(line, annotations)}</p>`);
|
||||
}
|
||||
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function renderEditableInlineMarkdown(value: string, annotations: MemoAnnotation[] = []) {
|
||||
const pattern = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*)/g;
|
||||
let cursor = 0;
|
||||
let html = "";
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(value)) !== null) {
|
||||
if (match.index > cursor) html += renderAnnotatedPlainText(value.slice(cursor, match.index), annotations);
|
||||
|
||||
const token = match[0];
|
||||
if (token.startsWith("**")) {
|
||||
html += `<strong>${escapeHtml(token.slice(2, -2))}</strong>`;
|
||||
} else if (token.startsWith("*")) {
|
||||
html += `<em>${escapeHtml(token.slice(1, -1))}</em>`;
|
||||
} else if (token.startsWith("`")) {
|
||||
html += `<code>${escapeHtml(token.slice(1, -1))}</code>`;
|
||||
} else {
|
||||
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
html += link ? `<a href="${escapeAttribute(link[2])}" rel="noreferrer">${escapeHtml(link[1])}</a>` : escapeHtml(token);
|
||||
}
|
||||
|
||||
cursor = match.index + token.length;
|
||||
}
|
||||
|
||||
if (cursor < value.length) html += renderAnnotatedPlainText(value.slice(cursor), annotations);
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderAnnotatedPlainText(value: string, annotations: MemoAnnotation[]) {
|
||||
const candidates = annotations.filter((annotation) => annotation.selectedText);
|
||||
let cursor = 0;
|
||||
let html = "";
|
||||
|
||||
while (cursor < value.length) {
|
||||
const match = findNextAnnotationMatch(value, candidates, cursor);
|
||||
if (!match) {
|
||||
html += escapeHtml(value.slice(cursor));
|
||||
break;
|
||||
}
|
||||
|
||||
if (match.index > cursor) html += escapeHtml(value.slice(cursor, match.index));
|
||||
|
||||
const matchingAnnotations = candidates.filter((annotation) => annotation.selectedText === match.text);
|
||||
html += `<mark class="${annotationClassName(matchingAnnotations)}" data-annotation-id="${escapeAttribute(matchingAnnotations[0].id)}">${escapeHtml(match.text)}</mark>`;
|
||||
cursor = match.index + match.text.length;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function findNextAnnotationMatch(value: string, annotations: MemoAnnotation[], offset: number) {
|
||||
let bestMatch: { index: number; text: string } | null = null;
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const index = value.indexOf(annotation.selectedText, offset);
|
||||
if (index === -1) continue;
|
||||
if (
|
||||
!bestMatch ||
|
||||
index < bestMatch.index ||
|
||||
(index === bestMatch.index && annotation.selectedText.length > bestMatch.text.length)
|
||||
) {
|
||||
bestMatch = { index, text: annotation.selectedText };
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function annotationClassName(annotations: MemoAnnotation[]) {
|
||||
const kinds = new Set(annotations.map((annotation) => annotation.kind));
|
||||
return cx(
|
||||
"px-0.5",
|
||||
kinds.has("highlight") && "bg-[oklch(92%_0.08_95)]",
|
||||
kinds.has("comment") && "bg-[oklch(92%_0.06_80)] underline decoration-[var(--accent)] decoration-2 underline-offset-2",
|
||||
kinds.has("strike") && "line-through decoration-[var(--red)] decoration-2"
|
||||
);
|
||||
}
|
||||
|
||||
export function getAnnotationSignature(annotations: MemoAnnotation[]) {
|
||||
return annotations
|
||||
.map((annotation) => `${annotation.id}:${annotation.kind}:${annotation.status}:${annotation.selectedText}`)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
export function getSelectionWithin(root: HTMLElement | null) {
|
||||
if (!root) return "";
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return "";
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!root.contains(range.commonAncestorContainer)) return "";
|
||||
return selection.toString().trim();
|
||||
}
|
||||
|
||||
export function editableHtmlToMarkdown(root: HTMLElement) {
|
||||
const blocks = Array.from(root.childNodes).flatMap((node) => serializeBlockNode(node));
|
||||
return joinMarkdownBlocks(blocks);
|
||||
}
|
||||
|
||||
function joinMarkdownBlocks(blocks: string[]) {
|
||||
if (blocks.every((block) => !block.trim())) return "";
|
||||
|
||||
return blocks.reduce((markdown, block, index) => {
|
||||
if (index === 0) return block;
|
||||
const previous = markdown.split("\n").at(-1) ?? "";
|
||||
const separator = !previous.trim() || !block.trim() || shouldKeepAdjacent(previous, block) ? "\n" : "\n";
|
||||
return `${markdown}${separator}${block}`;
|
||||
}, "");
|
||||
}
|
||||
|
||||
function shouldKeepAdjacent(previous: string, next: string) {
|
||||
return (
|
||||
(/^[-*]\s+\S/.test(previous) && /^[-*]\s+\S/.test(next)) ||
|
||||
(/^\d+\.\s+\S/.test(previous) && /^\d+\.\s+\S/.test(next)) ||
|
||||
(/^>\s+\S/.test(previous) && /^>\s+\S/.test(next))
|
||||
);
|
||||
}
|
||||
|
||||
function serializeBlockNode(node: ChildNode): string[] {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent?.trim();
|
||||
return text ? [text] : [];
|
||||
}
|
||||
|
||||
if (!(node instanceof HTMLElement)) return [];
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
|
||||
if (tagName === "ul") {
|
||||
return Array.from(node.children)
|
||||
.filter((child) => child.tagName.toLowerCase() === "li")
|
||||
.map((child) => `- ${serializeInlineNode(child).trim()}`)
|
||||
.filter((line) => line.length > 2);
|
||||
}
|
||||
|
||||
if (tagName === "ol") {
|
||||
return Array.from(node.children)
|
||||
.filter((child) => child.tagName.toLowerCase() === "li")
|
||||
.map((child, index) => `${index + 1}. ${serializeInlineNode(child).trim()}`)
|
||||
.filter((line) => /\d+\.\s+\S/.test(line));
|
||||
}
|
||||
|
||||
if (/^h[1-6]$/.test(tagName)) {
|
||||
const level = Math.min(Math.max(Number(tagName.slice(1)) - 3, 1), 3);
|
||||
const text = serializeInlineNode(node).trim();
|
||||
return text ? [`${"#".repeat(level)} ${text}`] : [];
|
||||
}
|
||||
|
||||
if (tagName === "blockquote") {
|
||||
const text = serializeInlineNode(node).trim();
|
||||
return text ? text.split(/\r?\n/).map((line) => `> ${line}`) : [];
|
||||
}
|
||||
|
||||
if (tagName === "div" || tagName === "p") {
|
||||
const text = serializeInlineNode(node).trim();
|
||||
return text ? [text] : [""];
|
||||
}
|
||||
|
||||
const text = serializeInlineNode(node).trim();
|
||||
return text ? [text] : [];
|
||||
}
|
||||
|
||||
function serializeInlineNode(node: ChildNode): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
|
||||
if (!(node instanceof HTMLElement)) return "";
|
||||
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
if (tagName === "br") return "\n";
|
||||
|
||||
const text = Array.from(node.childNodes).map((child) => serializeInlineNode(child)).join("");
|
||||
if (!text) return "";
|
||||
|
||||
if (tagName === "strong" || tagName === "b") return `**${text}**`;
|
||||
if (tagName === "em" || tagName === "i") return `*${text}*`;
|
||||
if (tagName === "code") return `\`${text.replace(/`/g, "")}\``;
|
||||
if (tagName === "a") {
|
||||
const href = node.getAttribute("href");
|
||||
return href ? `[${text}](${href})` : text;
|
||||
}
|
||||
if (tagName === "li") return text.replace(/\n+/g, " ").trim();
|
||||
if (tagName === "div" || tagName === "p") return text;
|
||||
return node.textContent ?? "";
|
||||
}
|
||||
|
||||
export function normalizeEditableMarkdown(root: HTMLElement): string {
|
||||
const caretOffset = getCaretTextOffset(root);
|
||||
const markdown = editableHtmlToMarkdown(root);
|
||||
const html = markdownToEditableHtml(markdown);
|
||||
|
||||
if (root.innerHTML !== html) {
|
||||
root.innerHTML = html;
|
||||
if (caretOffset !== null) restoreCaretTextOffset(root, caretOffset);
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
export function getCaretTextOffset(root: HTMLElement): number | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!root.contains(range.startContainer)) return null;
|
||||
|
||||
const prefixRange = document.createRange();
|
||||
prefixRange.selectNodeContents(root);
|
||||
prefixRange.setEnd(range.startContainer, range.startOffset);
|
||||
return prefixRange.toString().length;
|
||||
}
|
||||
|
||||
export function restoreCaretTextOffset(root: HTMLElement, offset: number): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let remaining = offset;
|
||||
let node = walker.nextNode();
|
||||
let lastTextNode: Text | null = null;
|
||||
|
||||
while (node) {
|
||||
const textNode = node as Text;
|
||||
lastTextNode = textNode;
|
||||
const length = textNode.textContent?.length ?? 0;
|
||||
|
||||
if (remaining <= length) {
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, remaining);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return;
|
||||
}
|
||||
|
||||
remaining -= length;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
if (lastTextNode) {
|
||||
range.setStart(lastTextNode, lastTextNode.textContent?.length ?? 0);
|
||||
} else {
|
||||
range.selectNodeContents(root);
|
||||
}
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
export function insertPlainText(text: string) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
selection.deleteFromDocument();
|
||||
const range = selection.getRangeAt(0);
|
||||
const textNode = document.createTextNode(text);
|
||||
range.insertNode(textNode);
|
||||
range.setStartAfter(textNode);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
export function insertParagraphBreak() {
|
||||
document.execCommand("insertParagraph");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string) {
|
||||
return escapeHtml(value).replace(/'/g, "'");
|
||||
}
|
||||
45
apps/web/src/lib/styles.ts
Normal file
45
apps/web/src/lib/styles.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const ui = {
|
||||
shell: "h-screen min-w-[1024px] grid grid-rows-[48px_minmax(0,1fr)_52px] bg-[var(--bg)] text-[var(--fg)] text-[13px]",
|
||||
topbar: "flex items-center gap-3 border-b border-[var(--border)] bg-[var(--surface)] px-5",
|
||||
wordmark: "border-0 bg-transparent p-0 font-[var(--font-display)] text-[15px] font-semibold leading-none tracking-normal cursor-pointer",
|
||||
ticker: "bg-[var(--fg)] px-2 py-1 font-[var(--font-mono)] text-[11px] font-bold uppercase tracking-[0.04em] text-[var(--surface)]",
|
||||
tag: "inline-flex items-center whitespace-nowrap border border-[var(--border)] px-2 py-1 font-[var(--font-mono)] text-[10px] font-medium uppercase tracking-[0.06em] text-[var(--muted)]",
|
||||
tagAccent: "!border-[var(--accent)] !text-[var(--accent)]",
|
||||
tagGreen: "!border-[var(--green)] !text-[var(--green)]",
|
||||
tagRed: "!border-[var(--red)] !text-[var(--red)]",
|
||||
tab: "border-0 border-r border-[var(--border)] bg-[var(--surface)] px-3.5 py-2 text-xs font-medium text-[var(--muted)] last:border-r-0 hover:text-[var(--fg)]",
|
||||
tabActive: "!bg-[var(--fg)] !text-[var(--surface)] hover:!text-[var(--surface)]",
|
||||
iconBtn: "grid h-7 w-7 place-items-center border border-[var(--border)] bg-[var(--surface)] text-[10px] text-[var(--muted)] hover:text-[var(--fg)]",
|
||||
screen: "h-full min-h-0 overflow-y-auto p-7 pr-9 pl-9",
|
||||
panel: "bg-[var(--surface)] p-4 min-h-[150px]",
|
||||
panelTitle: "mb-3 font-[var(--font-mono)] text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--muted)]",
|
||||
eyebrow: "mb-2 font-[var(--font-mono)] text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--accent)]",
|
||||
h1: "m-0 mb-2 font-[var(--font-display)] text-[30px] font-bold leading-[1.1] tracking-normal",
|
||||
h2: "mt-8 mb-3 border-t border-[var(--border)] pt-4 font-[var(--font-display)] text-lg font-semibold leading-tight",
|
||||
body: "max-w-[760px] text-[13.5px] leading-[1.7]",
|
||||
muted: "text-[var(--muted)]",
|
||||
metricGrid: "grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-px border border-[var(--border)] bg-[var(--border)]",
|
||||
metric: "bg-[var(--surface)] p-3.5",
|
||||
btn: "border border-[var(--border)] bg-[var(--surface)] px-3.5 py-2 text-xs font-medium text-[var(--fg)] hover:bg-[var(--bg)]",
|
||||
btnPrimary: "!border-[var(--fg)] !bg-[var(--fg)] !text-[var(--surface)] hover:opacity-90",
|
||||
btnSm: "!px-2 !py-1 font-[var(--font-mono)] text-[10px]",
|
||||
nav: "w-60 shrink-0 overflow-y-auto border-r border-[var(--border)] bg-[var(--surface)] p-4",
|
||||
rightPanel: "w-[300px] shrink-0 overflow-y-auto border-l border-[var(--border)] bg-[var(--surface)] p-5",
|
||||
center: "min-w-0 flex-1 overflow-y-auto p-8 pl-10 pr-10",
|
||||
spreadsheet: "w-full border-collapse bg-[var(--surface)] font-[var(--font-mono)] text-xs",
|
||||
command: "flex items-center justify-between gap-6 border-t border-[var(--border)] bg-[var(--surface)] px-5",
|
||||
overlay: "fixed inset-0 z-[500] grid place-items-center bg-black/20",
|
||||
overlayWide: "fixed inset-0 z-[600] grid place-items-center bg-black/20",
|
||||
toast: "fixed top-14 right-4 z-[1000] flex flex-col gap-2 max-w-[380px] pointer-events-none",
|
||||
toastItem: "flex items-start gap-2.5 bg-[var(--surface)] border border-[var(--border)] px-3.5 py-3 rounded-sm shadow-[0_4px_12px_oklch(20%_0.02_60_/_0.08)] pointer-events-auto toast-enter",
|
||||
skeleton: "skeleton",
|
||||
searchDropdown: "absolute top-full left-0 right-0 bg-[var(--surface)] border border-[var(--border)] border-t-0 shadow-[0_4px_12px_oklch(20%_0.02_60_/_0.08)] z-50 max-h-[400px] overflow-y-auto",
|
||||
searchRow: "flex items-center gap-3 px-3 py-2.5 text-xs hover:bg-[var(--bg)] cursor-pointer",
|
||||
confidenceHigh: "border border-[var(--green)] bg-[oklch(95%_0.04_145)] text-[var(--green)] px-1.5 py-0.5 font-[var(--font-mono)] text-[9px] uppercase tracking-wider",
|
||||
confidenceMed: "border border-[var(--accent)] bg-[oklch(95%_0.04_60)] text-[var(--accent)] px-1.5 py-0.5 font-[var(--font-mono)] text-[9px] uppercase tracking-wider",
|
||||
confidenceLow: "border border-[var(--red)] bg-[oklch(95%_0.04_25)] text-[var(--red)] px-1.5 py-0.5 font-[var(--font-mono)] text-[9px] uppercase tracking-wider",
|
||||
validationVerified: "text-[var(--green)]",
|
||||
validationFlagged: "text-[var(--accent)]",
|
||||
validationUnverified: "text-[var(--muted)]",
|
||||
validationFailed: "text-[var(--red)]"
|
||||
};
|
||||
10
apps/web/src/main.tsx
Normal file
10
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./ui/App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
12
apps/web/src/rpcClient.ts
Normal file
12
apps/web/src/rpcClient.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RpcClient, RpcMethod, RpcRequestMap } from "../../../packages/contracts/src/rpc";
|
||||
import { handleMockRpc } from "../../../packages/shared/src/mockRpc";
|
||||
|
||||
export const rpc: RpcClient = {
|
||||
call<T extends RpcMethod>(method: T, payload: RpcRequestMap[T]) {
|
||||
if (!window.mosaic) {
|
||||
return handleMockRpc(method, payload);
|
||||
}
|
||||
|
||||
return window.mosaic.call(method, payload);
|
||||
}
|
||||
};
|
||||
218
apps/web/src/styles.css
Normal file
218
apps/web/src/styles.css
Normal file
@@ -0,0 +1,218 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg: oklch(97% 0.012 80);
|
||||
--surface: oklch(99% 0.005 80);
|
||||
--fg: oklch(20% 0.02 60);
|
||||
--muted: oklch(48% 0.015 60);
|
||||
--border: oklch(89% 0.012 80);
|
||||
--accent: oklch(58% 0.16 35);
|
||||
--green: oklch(52% 0.12 145);
|
||||
--red: oklch(52% 0.14 25);
|
||||
--font-display: "Iowan Old Style", Charter, Georgia, serif;
|
||||
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, "IBM Plex Mono", Menlo, monospace;
|
||||
--density-body: 15px;
|
||||
--density-lh: 1.7;
|
||||
--density-card-pad: 16px;
|
||||
--density-gap: 1px;
|
||||
--density-cell-pad: 8px 12px;
|
||||
--density-metric-min: 140px;
|
||||
--density-nav-w: 240px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: oklch(15% 0.012 80);
|
||||
--surface: oklch(18% 0.008 80);
|
||||
--fg: oklch(92% 0.008 80);
|
||||
--muted: oklch(60% 0.01 80);
|
||||
--border: oklch(28% 0.01 80);
|
||||
--accent: oklch(65% 0.16 35);
|
||||
--green: oklch(60% 0.12 145);
|
||||
--red: oklch(60% 0.14 25);
|
||||
}
|
||||
|
||||
[data-density="compact"] {
|
||||
--density-body: 14px;
|
||||
--density-lh: 1.5;
|
||||
--density-card-pad: 12px;
|
||||
--density-gap: 1px;
|
||||
--density-cell-pad: 6px 10px;
|
||||
--density-metric-min: 120px;
|
||||
--density-nav-w: 220px;
|
||||
}
|
||||
|
||||
[data-density="dense"] {
|
||||
--density-body: 13px;
|
||||
--density-lh: 1.4;
|
||||
--density-card-pad: 8px;
|
||||
--density-gap: 0px;
|
||||
--density-cell-pad: 4px 8px;
|
||||
--density-metric-min: 100px;
|
||||
--density-nav-w: 200px;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: calc(var(--density-body) * 1px / var(--density-lh) var(--font-body));
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ─── Memo Editor ─── */
|
||||
.memo-editor-body:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted);
|
||||
}
|
||||
.memo-editor-body p { margin: 0.5rem 0; }
|
||||
.memo-editor-body h4, .memo-editor-body h5, .memo-editor-body h6 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.memo-editor-body ul, .memo-editor-body ol { margin: 0.75rem 0; padding-left: 1.25rem; }
|
||||
.memo-editor-body ul { list-style: disc; }
|
||||
.memo-editor-body ol { list-style: decimal; }
|
||||
.memo-editor-body li { margin: 0.25rem 0; }
|
||||
.memo-editor-body blockquote {
|
||||
margin: 0.75rem 0;
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.memo-editor-body code {
|
||||
background: var(--surface);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.memo-editor-body a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ─── Skeleton Shimmer ─── */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, oklch(92% 0.008 80) 25%, oklch(96% 0.008 80) 50%, oklch(92% 0.008 80) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 2px;
|
||||
}
|
||||
[data-theme="dark"] .skeleton {
|
||||
background: linear-gradient(90deg, oklch(28% 0.01 80) 25%, oklch(22% 0.01 80) 50%, oklch(28% 0.01 80) 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
/* ─── Screen Transitions ─── */
|
||||
.screen-transition {
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
/* ─── Right Panel Slide-in ─── */
|
||||
.right-panel-slide {
|
||||
transform: translateX(100%);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
.right-panel-slide.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* ─── Overlay ─── */
|
||||
.overlay-backdrop {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
.overlay-backdrop.open {
|
||||
opacity: 1;
|
||||
}
|
||||
.overlay-body {
|
||||
transform: scale(0.98);
|
||||
transition: transform 150ms ease-out;
|
||||
}
|
||||
.overlay-backdrop.open .overlay-body {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* ─── Agent Pulse ─── */
|
||||
@keyframes agent-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.agent-pulse {
|
||||
animation: agent-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ─── Section Flash ─── */
|
||||
@keyframes section-flash {
|
||||
0%, 100% { border-left-color: transparent; }
|
||||
50% { border-left-color: var(--accent); }
|
||||
}
|
||||
.section-flash {
|
||||
animation: section-flash 1s ease-in-out 2;
|
||||
}
|
||||
|
||||
/* ─── Toast ─── */
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.toast-enter {
|
||||
animation: toast-in 200ms ease-out;
|
||||
}
|
||||
|
||||
/* ─── Annotation ─── */
|
||||
.highlight-annotation {
|
||||
background: oklch(90% 0.06 80);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-theme="dark"] .highlight-annotation {
|
||||
background: oklch(28% 0.03 80);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 1023px) {
|
||||
body > *:not(.floor-message) { display: none !important; }
|
||||
body::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
body::after {
|
||||
content: 'MosaicIQ requires a desktop display of 1024px or wider.';
|
||||
display: flex;
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
align-items: center; justify-content: center;
|
||||
font: 400 16px/1.5 var(--font-body);
|
||||
color: var(--muted); text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) and (max-width: 1279px) {
|
||||
.responsive-nav { width: 36px !important; padding: 4px !important; }
|
||||
.responsive-nav-content { display: none !important; }
|
||||
.responsive-grid-2 { grid-template-columns: repeat(2, 1fr) !important; }
|
||||
}
|
||||
@media (min-width: 1280px) and (max-width: 1439px) {
|
||||
.responsive-panel { position: absolute; right: 0; top: 0; bottom: 0; z-index: 20; }
|
||||
}
|
||||
1002
apps/web/src/ui/App.tsx
Normal file
1002
apps/web/src/ui/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user