From 046bc3ebdf3e38a455b300d540e139c169e71b58 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 29 Apr 2026 15:45:33 +0800 Subject: [PATCH 01/27] feat: apply stitch-inspired frontend ui refresh --- apps/web/app/opportunities/page.tsx | 37 +- apps/web/app/styles.css | 542 +++++++++++++++--- .../components/dashboard/Dashboard.module.css | 43 +- apps/web/components/layout/AppShell.tsx | 16 +- apps/web/components/logs/LogsPage.tsx | 72 +-- .../opportunities/OpportunityBoard.tsx | 64 +-- .../opportunities/OpportunityCard.tsx | 66 +-- .../opportunities/OpportunityDetail.tsx | 125 +--- apps/web/components/reports/ReportsPage.tsx | 72 +-- apps/web/components/settings/SettingsPage.tsx | 121 ++-- apps/web/components/signals/SignalCard.tsx | 2 +- .../components/signals/SignalInbox.module.css | 97 +++- apps/web/lib/constants.ts | 2 +- 13 files changed, 703 insertions(+), 556 deletions(-) diff --git a/apps/web/app/opportunities/page.tsx b/apps/web/app/opportunities/page.tsx index 4728e3f..95c8960 100644 --- a/apps/web/app/opportunities/page.tsx +++ b/apps/web/app/opportunities/page.tsx @@ -1,4 +1,3 @@ -import type { CSSProperties } from "react"; import { OpportunityBoard } from "../../components/opportunities/OpportunityBoard"; import { EmptyState } from "../../components/ui/EmptyState"; import { ErrorState } from "../../components/ui/ErrorState"; @@ -10,33 +9,6 @@ type OpportunitiesPageProps = { searchParams?: Promise; }; -const pageStyle: CSSProperties = { - display: "grid", - gap: 16 -}; - -const headerStyle: CSSProperties = { - display: "flex", - alignItems: "flex-start", - justifyContent: "space-between", - gap: 16, - flexWrap: "wrap" -}; - -const titleStyle: CSSProperties = { - margin: 0, - color: "var(--text)", - fontSize: 24, - fontWeight: 800, - lineHeight: 1.2 -}; - -const subtitleStyle: CSSProperties = { - margin: "6px 0 0", - color: "var(--muted)", - lineHeight: 1.5 -}; - export default async function OpportunitiesPage({ searchParams }: OpportunitiesPageProps) { const resolvedSearchParams: SearchParams = searchParams ? await searchParams : {}; const projectId = getSingleSearchParam(resolvedSearchParams.projectId); @@ -54,11 +26,12 @@ export default async function OpportunitiesPage({ searchParams }: OpportunitiesP const response = await api.opportunities.list(projectId, { page_size: 100 }); return ( -
-
+
+
-

Opportunity Board

-

+

Opportunities

+

Opportunity Board

+

Grouped by status for project {projectId}. Showing {response.items.length} of{" "} {response.total} opportunities.

diff --git a/apps/web/app/styles.css b/apps/web/app/styles.css index a640ff5..2100254 100644 --- a/apps/web/app/styles.css +++ b/apps/web/app/styles.css @@ -1,20 +1,26 @@ :root { color-scheme: light; - --background: #f6f8fb; + --background: #f8fafc; --surface: #ffffff; - --surface-subtle: #f9fafc; - --surface-strong: #eef3f8; - --text: #172033; - --muted: #647084; - --muted-strong: #465267; - --border: #d9e0ea; - --border-strong: #bdc8d8; - --accent: #245f73; - --accent-strong: #174a5e; - --positive: #16794f; - --warning: #a15c10; - --danger: #b42318; - --shadow: 0 1px 2px rgba(15, 23, 42, 0.08); + --surface-subtle: #f5f3f5; + --surface-strong: #eae7e9; + --surface-raised: #fbf9fa; + --text: #0f172a; + --muted: #94a3b8; + --muted-strong: #475569; + --border: #e2e8f0; + --border-strong: #c5c6cd; + --accent: #1d2b3e; + --accent-strong: #334155; + --accent-soft: #d5e3fd; + --high-value: #ea580c; + --high-value-bg: #fff7ed; + --positive: #16a34a; + --warning: #d97706; + --danger: #dc2626; + --disabled: #cbd5e1; + --shadow: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow-raised: 0 10px 28px -24px rgba(15, 23, 42, 0.55); } * { @@ -50,55 +56,57 @@ select { .appShell { min-height: 100vh; display: grid; - grid-template-columns: 248px minmax(0, 1fr); - grid-template-rows: 56px minmax(0, 1fr); + grid-template-columns: 256px minmax(0, 1fr); + grid-template-rows: 48px minmax(0, 1fr); background: var(--background); } .topBar { - grid-column: 1 / -1; + grid-column: 2; + grid-row: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; - height: 56px; - padding: 0 20px; + height: 48px; + padding: 0 24px; border-bottom: 1px solid var(--border); - background: var(--surface); - box-shadow: var(--shadow); + background: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(12px); } .brandLockup { min-width: 0; - display: flex; - align-items: baseline; - gap: 10px; + display: grid; + gap: 1px; } .brandName { margin: 0; color: var(--text); - font-size: 16px; + font-size: 14px; font-weight: 750; letter-spacing: 0; } .brandPhase { - color: var(--accent); - font-size: 12px; + color: var(--muted-strong); + font-size: 11px; font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; } .sidebar { grid-column: 1; - grid-row: 2; + grid-row: 1 / -1; display: flex; min-height: 0; flex-direction: column; - gap: 16px; - padding: 16px; + gap: 18px; + padding: 0 16px 16px; border-right: 1px solid var(--border); - background: var(--surface-subtle); + background: var(--surface); } .workspaceArea { @@ -111,30 +119,82 @@ select { .contentFrame { width: 100%; - min-height: calc(100vh - 56px); - padding: 20px; + min-height: calc(100vh - 48px); + padding: 24px; +} + +.brandPanel { + display: flex; + align-items: center; + gap: 12px; + min-height: 64px; + border-bottom: 1px solid var(--border); +} + +.brandMark { + display: inline-flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border-radius: 4px; + background: var(--accent); + color: #ffffff; + font-size: 13px; + font-weight: 800; + letter-spacing: 0; +} + +.brandTitle { + margin: 0; + color: var(--text); + font-size: 18px; + font-weight: 800; + line-height: 1.1; +} + +.brandSubtitle { + margin: 2px 0 0; + color: var(--muted-strong); + font-size: 12px; + line-height: 1.2; +} + +.sidebarAction { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 4px; + background: var(--accent); + color: #ffffff; + font-weight: 760; + cursor: default; } .sectionLabel { margin: 0 0 8px; - color: var(--muted); + color: var(--muted-strong); font-size: 11px; font-weight: 750; - letter-spacing: 0.04em; + letter-spacing: 0.05em; text-transform: uppercase; } .projectSelector { display: grid; gap: 8px; + border-bottom: 1px solid var(--border); + padding-bottom: 16px; } .selectControl { width: 100%; min-height: 36px; - border: 1px solid var(--border-strong); - border-radius: 6px; - background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + background: #ffffff; color: var(--text); padding: 7px 10px; } @@ -153,29 +213,31 @@ select { .navList { display: grid; - gap: 4px; + gap: 3px; } .navItem { display: flex; - min-height: 34px; + min-height: 36px; align-items: center; justify-content: space-between; gap: 10px; - border-radius: 6px; - padding: 7px 10px; + border-radius: 4px; + border-right: 2px solid transparent; + padding: 8px 10px; color: var(--muted-strong); - font-weight: 650; + font-weight: 680; } .navItem:hover { - background: var(--surface-strong); + background: #f8fafc; color: var(--text); } .navItemActive { - background: #e6f0f3; - color: var(--accent-strong); + border-right-color: var(--accent-strong); + background: #f1f5f9; + color: var(--text); } .navHint { @@ -188,7 +250,7 @@ select { display: flex; align-items: center; gap: 8px; - color: var(--muted); + color: var(--muted-strong); font-size: 12px; } @@ -206,10 +268,15 @@ select { justify-content: center; gap: 8px; border: 1px solid transparent; - border-radius: 6px; + border-radius: 4px; padding: 6px 12px; cursor: pointer; - font-weight: 700; + font-weight: 760; + transition: + background-color 140ms ease, + border-color 140ms ease, + color 140ms ease, + opacity 140ms ease; } .button:disabled { @@ -222,17 +289,31 @@ select { color: #ffffff; } +.buttonPrimary:hover:not(:disabled) { + background: var(--accent-strong); +} + .buttonSecondary { - border-color: var(--border-strong); + border-color: var(--border); background: var(--surface); color: var(--text); } +.buttonSecondary:hover:not(:disabled) { + background: #f8fafc; + border-color: var(--border-strong); +} + .buttonGhost { background: transparent; color: var(--muted-strong); } +.buttonGhost:hover:not(:disabled) { + background: #f8fafc; + color: var(--text); +} + .buttonSmall { min-height: 28px; padding: 4px 9px; @@ -258,24 +339,24 @@ select { } .badgeNeutral { - background: var(--surface-strong); + background: #f1f5f9; } .badgeSuccess { - border-color: #b6ddc8; - background: #e7f5ed; + border-color: #bbf7d0; + background: #f0fdf4; color: var(--positive); } .badgeWarning { - border-color: #f3d4a7; - background: #fff3df; + border-color: #fed7aa; + background: var(--high-value-bg); color: var(--warning); } .badgeDanger { - border-color: #f2b8b5; - background: #ffebe9; + border-color: #fecaca; + background: #fef2f2; color: var(--danger); } @@ -284,14 +365,14 @@ select { gap: 4px; min-width: 0; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 6px; background: var(--surface); padding: 12px; } .metricLabel { margin: 0; - color: var(--muted); + color: var(--muted-strong); font-size: 12px; font-weight: 700; } @@ -299,14 +380,14 @@ select { .metricValue { margin: 0; color: var(--text); - font-size: 22px; + font-size: 24px; font-weight: 780; line-height: 1.15; } .metricDetail { margin: 0; - color: var(--muted); + color: var(--muted-strong); font-size: 12px; line-height: 1.35; } @@ -316,7 +397,7 @@ select { gap: 8px; width: 100%; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 6px; background: var(--surface); padding: 16px; } @@ -335,7 +416,7 @@ select { .stateText { margin: 0; - color: var(--muted); + color: var(--muted-strong); line-height: 1.5; } @@ -348,7 +429,7 @@ select { display: flex; align-items: center; gap: 10px; - color: var(--muted); + color: var(--muted-strong); } .spinner { @@ -360,6 +441,337 @@ select { animation: spin 0.8s linear infinite; } +.pageHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.pageEyebrow { + margin: 0 0 4px; + color: var(--muted-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.pageTitle { + margin: 0; + color: var(--text); + font-size: 20px; + font-weight: 780; + line-height: 1.35; +} + +.pageSubtitle { + margin: 4px 0 0; + max-width: 780px; + color: var(--muted-strong); + line-height: 1.5; +} + +.surfacePanel { + display: grid; + gap: 12px; + min-width: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + padding: 16px; +} + +.dataTableWrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); +} + +.dataTable { + width: 100%; + min-width: 760px; + border-collapse: collapse; +} + +.dataTable th, +.dataTable td { + border-bottom: 1px solid var(--border); + padding: 10px 12px; + text-align: left; + vertical-align: top; +} + +.dataTable th { + color: var(--muted-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +.dataTable td { + color: var(--text); +} + +.dataTable tbody tr:hover { + background: #f8fafc; +} + +.opportunityBoard { + display: grid; + grid-template-columns: repeat(6, minmax(220px, 1fr)); + gap: 12px; + align-items: start; + overflow-x: auto; + padding-bottom: 4px; +} + +.opportunityColumn { + display: grid; + gap: 10px; + min-width: 220px; + border: 1px solid var(--border); + border-radius: 6px; + background: #f8fafc; + padding: 10px; +} + +.opportunityColumnHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.opportunityColumnTitle { + margin: 0; + color: var(--text); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.opportunityCount { + min-width: 24px; + border-radius: 999px; + background: #e2e8f0; + color: var(--muted-strong); + padding: 2px 8px; + text-align: center; + font-size: 12px; + font-weight: 800; +} + +.opportunityCardList { + display: grid; + gap: 10px; +} + +.opportunityCard { + display: grid; + gap: 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + padding: 12px; + box-shadow: var(--shadow); +} + +.opportunityCardHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.opportunityCardTitle { + margin: 0; + color: var(--text); + font-size: 14px; + font-weight: 780; + line-height: 1.35; +} + +.opportunityMetaGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.compactMeta { + display: grid; + gap: 2px; + min-width: 0; +} + +.compactMetaLabel { + margin: 0; + color: var(--muted-strong); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.compactMetaValue { + margin: 0; + color: var(--text); + overflow-wrap: anywhere; +} + +.detailPage { + display: grid; + gap: 18px; + max-width: 1120px; + margin: 0 auto; +} + +.detailHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.detailTitleBlock { + display: grid; + gap: 8px; + min-width: 0; +} + +.detailTitle { + margin: 0; + color: var(--text); + font-size: 22px; + font-weight: 800; + line-height: 1.22; +} + +.detailDescription { + margin: 0; + max-width: 820px; + color: var(--muted-strong); + line-height: 1.55; +} + +.detailActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.metricGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.evidenceGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.reportsPreview { + min-height: 360px; + width: 100%; + resize: vertical; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + background: #f8fafc; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + line-height: 1.55; + padding: 12px; +} + +.csvPreview { + max-height: 260px; + overflow: auto; + margin: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: #f8fafc; + color: var(--text); + font-size: 12px; + line-height: 1.5; + padding: 12px; + white-space: pre; +} + +.integrationList { + display: grid; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + background: var(--surface); +} + +.integrationRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--border); + padding: 16px; +} + +.integrationRow:last-child { + border-bottom: 0; +} + +.integrationRow:hover { + background: #f8fafc; +} + +.integrationIdentity { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} + +.integrationIcon { + display: inline-flex; + width: 40px; + height: 40px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 6px; + background: #f8fafc; + color: var(--accent); + font-weight: 800; +} + +.integrationName { + margin: 0; + color: var(--text); + font-weight: 780; +} + +.integrationMeta { + margin: 4px 0 0; + color: var(--muted-strong); + font-size: 12px; +} + +.integrationBadges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + @keyframes spin { to { transform: rotate(360deg); @@ -373,6 +785,8 @@ select { } .topBar { + grid-column: 1; + grid-row: 1; height: auto; min-height: 56px; align-items: flex-start; diff --git a/apps/web/components/dashboard/Dashboard.module.css b/apps/web/components/dashboard/Dashboard.module.css index f25d353..c3a870a 100644 --- a/apps/web/components/dashboard/Dashboard.module.css +++ b/apps/web/components/dashboard/Dashboard.module.css @@ -1,6 +1,8 @@ .page { display: grid; - gap: 16px; + gap: 24px; + max-width: 1280px; + margin: 0 auto; } .header { @@ -18,17 +20,17 @@ .eyebrow { margin: 0; - color: var(--muted); + color: var(--muted-strong); font-size: 11px; - font-weight: 750; - letter-spacing: 0.04em; + font-weight: 800; + letter-spacing: 0.05em; text-transform: uppercase; } .title { margin: 0; color: var(--text); - font-size: 24px; + font-size: 20px; font-weight: 780; letter-spacing: 0; line-height: 1.2; @@ -36,14 +38,14 @@ .subtitle { margin: 0; - color: var(--muted); + color: var(--muted-strong); line-height: 1.45; } .grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); - gap: 16px; + gap: 18px; } .panel { @@ -51,9 +53,10 @@ gap: 12px; min-width: 0; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 6px; background: var(--surface); - padding: 14px; + padding: 18px; + box-shadow: var(--shadow); } .span3 { @@ -87,13 +90,13 @@ margin: 0; color: var(--text); font-size: 14px; - font-weight: 760; + font-weight: 780; line-height: 1.3; } .panelMeta { margin: 2px 0 0; - color: var(--muted); + color: var(--muted-strong); font-size: 12px; line-height: 1.4; } @@ -101,7 +104,7 @@ .largeValue { margin: 0; color: var(--text); - font-size: 34px; + font-size: 38px; font-weight: 800; line-height: 1; } @@ -114,16 +117,16 @@ .qualityBar { width: 100%; - height: 8px; + height: 9px; overflow: hidden; border-radius: 999px; - background: var(--surface-strong); + background: #f1f5f9; } .qualityFill { height: 100%; border-radius: inherit; - background: var(--positive); + background: var(--high-value); } .list { @@ -142,7 +145,7 @@ border: 1px solid var(--border); border-radius: 6px; padding: 10px; - background: var(--surface-subtle); + background: #f8fafc; } .itemTitle { @@ -163,7 +166,7 @@ } .score { - color: var(--accent-strong); + color: var(--high-value); font-weight: 780; text-align: right; white-space: nowrap; @@ -181,7 +184,7 @@ border: 1px solid var(--border); border-radius: 6px; padding: 10px; - background: var(--surface-subtle); + background: #f8fafc; } .platformName { @@ -229,7 +232,7 @@ .logMeta { margin: 0; - color: var(--muted); + color: var(--muted-strong); font-size: 12px; line-height: 1.4; } @@ -241,7 +244,7 @@ .emptyText { margin: 0; - color: var(--muted); + color: var(--muted-strong); line-height: 1.5; } diff --git a/apps/web/components/layout/AppShell.tsx b/apps/web/components/layout/AppShell.tsx index 0a2cbb2..6142e37 100644 --- a/apps/web/components/layout/AppShell.tsx +++ b/apps/web/components/layout/AppShell.tsx @@ -53,8 +53,8 @@ export function AppShell({ children }: { children: ReactNode }) {
-

SignalForge

- Phase 6 Frontend MVP +

Signal Insight Radar

+ Stitch-inspired UI refresh
diff --git a/apps/web/components/signals/SignalInbox.module.css b/apps/web/components/signals/SignalInbox.module.css index 7f450db..033b59f 100644 --- a/apps/web/components/signals/SignalInbox.module.css +++ b/apps/web/components/signals/SignalInbox.module.css @@ -1,31 +1,50 @@ .inbox { display: grid; - grid-template-columns: 260px minmax(360px, 1fr) 340px; - gap: 16px; - align-items: start; + grid-template-columns: 280px minmax(420px, 1fr) 360px; + min-height: calc(100vh - 96px); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + background: var(--surface); + box-shadow: var(--shadow-raised); } .filters, .signalList, .detailPreview { min-width: 0; - border: 1px solid var(--border); - border-radius: 8px; background: var(--surface); - padding: 14px; } .filters, .detailPreview { position: sticky; - top: 20px; + top: 0; display: grid; + align-content: start; gap: 14px; + height: calc(100vh - 96px); + overflow-y: auto; + padding: 16px; +} + +.filters { + border-right: 1px solid var(--border); + box-shadow: 2px 0 8px -6px rgba(15, 23, 42, 0.3); +} + +.detailPreview { + border-left: 1px solid var(--border); } .signalList { display: grid; + align-content: start; gap: 12px; + min-height: 0; + overflow-y: auto; + padding: 16px; + background: #f8fafc; } .listHeader { @@ -37,10 +56,10 @@ .eyebrow { margin: 0 0 4px; - color: var(--muted); + color: var(--muted-strong); font-size: 11px; - font-weight: 750; - letter-spacing: 0.04em; + font-weight: 800; + letter-spacing: 0.05em; text-transform: uppercase; } @@ -53,7 +72,7 @@ } .title { - font-size: 22px; + font-size: 20px; font-weight: 780; } @@ -72,18 +91,26 @@ .fieldLabel { color: var(--muted-strong); - font-size: 12px; - font-weight: 720; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; } .input { width: 100%; - min-height: 34px; - border: 1px solid var(--border-strong); - border-radius: 6px; + min-height: 36px; + border: 1px solid var(--border); + border-radius: 4px; background: var(--surface); color: var(--text); - padding: 7px 9px; + padding: 8px 10px; +} + +.input:focus { + border-color: var(--accent); + outline: 0; + box-shadow: 0 0 0 1px var(--accent); } .input:disabled { @@ -123,7 +150,8 @@ } .cards { - display: grid; + display: flex; + flex-direction: column; gap: 12px; } @@ -131,19 +159,20 @@ display: grid; gap: 10px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 6px; background: var(--surface); padding: 12px; + box-shadow: var(--shadow); } .selectedSignal { border-color: var(--accent); - box-shadow: 0 0 0 1px rgba(36, 95, 115, 0.18); + box-shadow: 0 0 0 1px rgba(29, 43, 62, 0.18); } .highValueSignal { - border-color: #9fd2b9; - background: #f4fbf7; + border-color: var(--high-value); + background: var(--high-value-bg); } .cardBodyButton { @@ -179,7 +208,7 @@ .cardTitle { margin: 0; color: var(--text); - font-size: 15px; + font-size: 14px; font-weight: 760; line-height: 1.35; } @@ -195,7 +224,7 @@ .excerpt { margin: 0; color: var(--muted-strong); - line-height: 1.5; + line-height: 1.45; } .keyword { @@ -204,7 +233,7 @@ align-items: center; border: 1px solid var(--border); border-radius: 999px; - background: var(--surface-subtle); + background: #f8fafc; color: var(--muted-strong); padding: 2px 8px; font-size: 12px; @@ -222,16 +251,21 @@ min-height: 28px; align-items: center; justify-content: center; - border: 1px solid var(--border-strong); - border-radius: 6px; + border: 1px solid var(--border); + border-radius: 4px; padding: 4px 9px; font-size: 12px; font-weight: 700; } .sourceLink { - color: var(--accent-strong); - background: var(--surface); + color: #ffffff; + background: var(--accent); + border-color: var(--accent); +} + +.sourceLink:hover { + background: var(--accent-strong); } .disabledLink { @@ -259,7 +293,7 @@ } .detailGrid dt { - color: var(--muted); + color: var(--muted-strong); font-size: 12px; font-weight: 700; } @@ -311,11 +345,13 @@ @media (max-width: 1180px) { .inbox { grid-template-columns: 240px minmax(0, 1fr); + overflow: visible; } .detailPreview { position: static; grid-column: 1 / -1; + height: auto; } } @@ -327,6 +363,7 @@ .filters, .detailPreview { position: static; + height: auto; } .cardTitleRow { diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index 7a05440..bdf76ad 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -16,7 +16,7 @@ export const ROUTES = { } as const; export const NAV_ITEMS = [ - { href: ROUTES.signals, label: "Signals", hint: "Inbox" }, + { href: ROUTES.signals, label: "Signal Inbox", hint: "Core" }, { href: ROUTES.dashboard, label: "Dashboard", hint: "Summary" }, { href: ROUTES.opportunities, label: "Opportunities", hint: "Pipeline" }, { href: ROUTES.logs, label: "Logs", hint: "Collection" }, From 4d006f8210f12db97ab4d778e0ec72388f32e757 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 29 Apr 2026 16:05:13 +0800 Subject: [PATCH 02/27] chore: localize frontend ui to Chinese --- README.md | 3 +- apps/web/app/layout.tsx | 4 +- apps/web/app/opportunities/[id]/page.tsx | 2 +- apps/web/app/opportunities/page.tsx | 18 +-- apps/web/components/dashboard/Dashboard.tsx | 103 ++++++++---------- apps/web/components/layout/AppShell.tsx | 18 +-- apps/web/components/layout/Navigation.tsx | 4 +- .../web/components/layout/ProjectSelector.tsx | 14 +-- apps/web/components/logs/LogsPage.tsx | 48 ++++---- .../opportunities/OpportunityBoard.tsx | 8 +- .../opportunities/OpportunityCard.tsx | 12 +- .../opportunities/OpportunityDetail.tsx | 32 +++--- .../opportunities/opportunityView.ts | 24 ++-- apps/web/components/reports/ReportsPage.tsx | 30 ++--- apps/web/components/settings/SettingsPage.tsx | 22 ++-- apps/web/components/signals/SignalCard.tsx | 50 +++++---- .../signals/SignalDetailPreview.tsx | 55 ++++++---- apps/web/components/signals/SignalFilters.tsx | 42 +++---- apps/web/components/signals/SignalInbox.tsx | 32 +++--- apps/web/components/ui/ErrorState.tsx | 4 +- apps/web/components/ui/LoadingState.tsx | 2 +- apps/web/lib/constants.ts | 12 +- apps/web/lib/format.ts | 102 +++++++++++++++-- docs/acceptance/final-acceptance-report.md | 3 +- docs/testing/test-report.md | 5 +- scripts/validate_frontend_mvp.py | 12 +- 26 files changed, 381 insertions(+), 280 deletions(-) diff --git a/README.md b/README.md index c94b453..8d7de00 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,8 @@ If `http://localhost:3000` is reachable, `scripts/validate_frontend_mvp.py` also Expected Phase 6 behavior: - Required frontend pages exist. -- The UI includes Open Source evidence text and a high value marker. +- The UI includes Open Source / 打开来源 evidence text and a High Value / 高价值 marker. +- The frontend UI is localized to Chinese while preserving backend enum values, URL paths, and API request parameters. - Settings does not render `encrypted_payload`. - Reports expose markdown and csv export controls. - The frontend API client accepts only SignalForge backend-relative paths. diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 1ce6c12..d208e5f 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,7 +5,7 @@ import "./styles.css"; export const metadata: Metadata = { title: "SignalForge", - description: "SignalForge research workstation" + description: "SignalForge 研究工作台" }; export default function RootLayout({ @@ -14,7 +14,7 @@ export default function RootLayout({ children: ReactNode; }>) { return ( - + {children} diff --git a/apps/web/app/opportunities/[id]/page.tsx b/apps/web/app/opportunities/[id]/page.tsx index 8e0accd..296e833 100644 --- a/apps/web/app/opportunities/[id]/page.tsx +++ b/apps/web/app/opportunities/[id]/page.tsx @@ -26,7 +26,7 @@ export default async function OpportunityDetailPage({ return ; } catch (error) { - return ; + return ; } } diff --git a/apps/web/app/opportunities/page.tsx b/apps/web/app/opportunities/page.tsx index 95c8960..1b37af0 100644 --- a/apps/web/app/opportunities/page.tsx +++ b/apps/web/app/opportunities/page.tsx @@ -16,8 +16,8 @@ export default async function OpportunitiesPage({ searchParams }: OpportunitiesP if (!projectId) { return ( ); } @@ -29,18 +29,18 @@ export default async function OpportunitiesPage({ searchParams }: OpportunitiesP
-

Opportunities

-

Opportunity Board

+

机会

+

机会看板

- Grouped by status for project {projectId}. Showing {response.items.length} of{" "} - {response.total} opportunities. + 按状态分组展示项目 {projectId} 的机会。当前显示 {response.items.length} /{" "} + {response.total} 个机会。

{response.items.length === 0 ? ( ) : ( @@ -48,7 +48,7 @@ export default async function OpportunitiesPage({ searchParams }: OpportunitiesP
); } catch (error) { - return ; + return ; } } diff --git a/apps/web/components/dashboard/Dashboard.tsx b/apps/web/components/dashboard/Dashboard.tsx index 347ee37..4ddc9d9 100644 --- a/apps/web/components/dashboard/Dashboard.tsx +++ b/apps/web/components/dashboard/Dashboard.tsx @@ -5,6 +5,7 @@ import { formatNumber, formatPercent, formatScore, + formatStatusLabel, platformLabel } from "../../lib/format"; import type { @@ -42,8 +43,8 @@ export async function Dashboard({ projectId }: DashboardProps) {
); @@ -69,19 +70,18 @@ function PageHeader({ errors = [] }: { errors?: string[] }) { return (
-

Dashboard

-

Signal Quality Summary

+

仪表盘

+

信号质量摘要

- Signal Quality Gate summary with high value ratio, top opportunities, platform readiness, - and recent collection / processing status. + 汇总高价值占比、重点机会、平台状态以及最近采集 / 处理状态。

{errors.length > 0 ? ( - Partial data + 部分数据 ) : ( - Live API + API 在线 )}
); @@ -92,9 +92,9 @@ function TodaySignalsPanel({ count }: { count: number }) {
Signals} + value={信号} />

{formatNumber(count)}

@@ -110,30 +110,30 @@ function HighValuePanel({ summary }: { summary: ProcessingSummary | null }) {
0 ? "success" : "neutral"}>{formatPercent(highValueRatio)}} />

{formatNumber(highValueCount)}

-
+
@@ -146,12 +146,12 @@ function TopOpportunitiesPanel({ opportunities }: { opportunities: Opportunity[]
{formatNumber(opportunities.length)} shown} + meta="按机会评分排序" + title="重点机会" + value={显示 {formatNumber(opportunities.length)} 个} /> {opportunities.length === 0 ? ( -

No opportunities returned for this project.

+

当前项目暂无机会。

) : (
    {opportunities.map((opportunity) => ( @@ -159,9 +159,9 @@ function TopOpportunitiesPanel({ opportunities }: { opportunities: Opportunity[]

    {opportunity.title}

    - {formatNumber(opportunity.evidence_count)} evidence + {formatNumber(opportunity.evidence_count)} 条证据 - {formatStatus(opportunity.status)} + {formatStatusLabel(opportunity.status)}
    @@ -181,9 +181,9 @@ function PlatformStatusPanel({ platforms }: { platforms: PlatformStatus[] }) {
    4 platforms} + value={4 个平台} />
    {PLATFORM_ORDER.map((platformName) => { @@ -194,9 +194,9 @@ function PlatformStatusPanel({ platforms }: { platforms: PlatformStatus[] }) {

    {platformLabel(platformName)}

    - {formatStatus(status)} + {formatStatusLabel(status)} - {platform?.enabled_for_mvp ? "MVP enabled" : "MVP off"} + {platform?.enabled_for_mvp ? "MVP 已启用" : "MVP 未启用"} {platform ? {platform.phase} : null}
    @@ -219,28 +219,28 @@ function RecentStatusPanel({
    {summary ? "Summary ready" : "No summary"}} + meta="采集日志和处理摘要" + title="最近运行状态" + value={{summary ? "摘要就绪" : "暂无摘要"}} />
    {logs.length === 0 ? ( -

    No recent collection logs returned for this project.

    +

    当前项目暂无最近采集日志。

    ) : (
      {logs.map((log) => (
    • {platformLabel(log.platform)}

      - {formatStatus(log.status)} + {formatStatusLabel(log.status)} {formatDateTime(log.created_at)}

      - {formatNumber(log.items_collected)} collected / {formatNumber(log.items_inserted)} inserted /{" "} - {formatNumber(log.items_skipped)} skipped + 采集 {formatNumber(log.items_collected)} / 入库 {formatNumber(log.items_inserted)} /{" "} + 跳过 {formatNumber(log.items_skipped)}

      - {log.error_message ?

      Error: {log.error_message}

      : null} + {log.error_message ?

      错误:{log.error_message}

      : null}
    • ))}
    @@ -248,18 +248,18 @@ function RecentStatusPanel({
    @@ -305,11 +305,11 @@ async function loadDashboardData(projectId: string): Promise { ]); const errors = [ - resultError(todaySignals, "today signals"), - resultError(processingSummary, "processing summary"), - resultError(opportunities, "opportunities"), - resultError(platforms, "platform settings"), - resultError(collectionLogs, "collection logs") + resultError(todaySignals, "今日信号"), + resultError(processingSummary, "处理摘要"), + resultError(opportunities, "机会"), + resultError(platforms, "平台设置"), + resultError(collectionLogs, "采集日志") ].filter((error): error is string => Boolean(error)); return { @@ -342,7 +342,7 @@ function resultError(result: PromiseSettledResult, label: string): string return `${label}: ${result.reason.code}`; } - return `${label}: unavailable`; + return `${label}: 不可用`; } function opportunityStatusTone(status: string): DashboardBadgeTone { @@ -380,10 +380,3 @@ function collectionStatusTone(status: string): DashboardBadgeTone { return "warning"; } - -function formatStatus(status: string): string { - return status - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} diff --git a/apps/web/components/layout/AppShell.tsx b/apps/web/components/layout/AppShell.tsx index 6142e37..3f21245 100644 --- a/apps/web/components/layout/AppShell.tsx +++ b/apps/web/components/layout/AppShell.tsx @@ -39,7 +39,7 @@ export function AppShell({ children }: { children: ReactNode }) { if (active) { setProjectState({ ...INITIAL_PROJECT_STATE, - errorMessage: "Unable to load projects from SignalForge backend." + errorMessage: "无法从 SignalForge 后端加载项目。" }); } }); @@ -53,26 +53,26 @@ export function AppShell({ children }: { children: ReactNode }) {
    -

    Signal Insight Radar

    - Stitch-inspired UI refresh +

    信号洞察雷达

    + 前端 MVP
    -
    +
    -
    - {error ? : null} + {error ? : null} -
    - - - - +
    + + + +

    - Evidence + 证据

    - - + + - +

    - Source evidence unavailable from current opportunity payload. + 当前机会数据未返回可直接打开的来源证据。

    diff --git a/apps/web/components/opportunities/opportunityView.ts b/apps/web/components/opportunities/opportunityView.ts index eb3637e..8e4b301 100644 --- a/apps/web/components/opportunities/opportunityView.ts +++ b/apps/web/components/opportunities/opportunityView.ts @@ -10,12 +10,12 @@ export const OPPORTUNITY_STATUSES: OpportunityStatus[] = [ ]; export const OPPORTUNITY_STATUS_LABELS: Record = { - new: "New", - watching: "Watching", - validating: "Validating", - build_candidate: "Build candidate", - content_candidate: "Content candidate", - archived: "Archived" + new: "新建", + watching: "观察中", + validating: "验证中", + build_candidate: "产品候选", + content_candidate: "内容候选", + archived: "已归档" }; export function isOpportunityStatus(value: string): value is OpportunityStatus { @@ -24,7 +24,7 @@ export function isOpportunityStatus(value: string): value is OpportunityStatus { export function opportunityStatusLabel(value: string | null | undefined): string { if (!value) { - return "Unknown"; + return "未知"; } return isOpportunityStatus(value) @@ -39,14 +39,14 @@ export function formatPlatformDistribution( distribution: Record | null | undefined ): string { if (!distribution || Object.keys(distribution).length === 0) { - return "Unavailable"; + return "不可用"; } const items = Object.entries(distribution) .filter(([key]) => !isSensitiveKey(key)) .map(([platform, value]) => `${labelFromSnakeCase(platform)}: ${formatDistributionValue(value)}`); - return items.length > 0 ? items.join(", ") : "Unavailable"; + return items.length > 0 ? items.join(", ") : "不可用"; } function formatDistributionValue(value: unknown): string { @@ -55,14 +55,14 @@ function formatDistributionValue(value: unknown): string { } if (Array.isArray(value)) { - return `${value.length} values`; + return `${value.length} 个值`; } if (value && typeof value === "object") { - return "available"; + return "可用"; } - return "unknown"; + return "未知"; } function isSensitiveKey(key: string): boolean { diff --git a/apps/web/components/reports/ReportsPage.tsx b/apps/web/components/reports/ReportsPage.tsx index 39b3923..a6ea426 100644 --- a/apps/web/components/reports/ReportsPage.tsx +++ b/apps/web/components/reports/ReportsPage.tsx @@ -28,8 +28,8 @@ export function ReportsPage() { if (!projectId) { return ( ); } @@ -80,10 +80,10 @@ export function ReportsPage() {
    -

    Reports

    -

    Report exports

    +

    报告导出

    +

    报告导出

    - Export markdown and csv reports with source_url preserved from the backend response. + 导出 Markdown 和 CSV 报告,并保留后端返回的 source_url。

    @@ -96,10 +96,10 @@ export function ReportsPage() { type="button" variant="primary" > - {loadingFormat === "markdown" ? "Exporting markdown" : "Export markdown"} + {loadingFormat === "markdown" ? "正在导出 Markdown" : "Markdown 导出"}
    {state.status === "error" ? ( - + ) : null} {state.status === "markdown" ? (
    -

    Markdown preview

    +

    Markdown 预览

    - Generated {formatDateTime(state.report.generated_at)} + 生成时间 {formatDateTime(state.report.generated_at)}