From 52155cc2893cfec6cd9aebdee7d5ab3b66351118 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:18:12 +0530 Subject: [PATCH 01/14] ui: dual-theme design system with the new shell chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyle the Web UI to the provided design and add a light theme alongside the existing dark one, without changing the app's flow or information architecture. Theming - index.css now defines the full token ramp as CSS variables with a dark default (:root) and a light override (:root[data-theme]). The W.* tokens in tokens.ts resolve through those variables, so every screen that already used W.* themes for both palettes with no per-screen color edit — one architecture change instead of touching 19 screens by hand. - theme.ts owns the active theme: written to data-theme on , persisted to localStorage, applied before first paint (no flash). useTheme() drives the topbar toggle. - The 55 `${W.x}NN` hex-alpha concatenations that would become invalid once W.x is a variable (var(--accent)1A is not a color) are replaced with a tint() helper backed by color-mix. The handful of hardcoded hexes that actually break in light mode — dark text meant to sit on the cobalt fill, dark switch-track backgrounds — move to W.onAccent / W.borderHi so they flip; the dataviz mid-tones (teal/amber/green series colors) stay fixed since they read on either background. Shell chrome - Topbar: page title, an instance-switcher pill with a status dot and wide-caps label, a Commands button that opens the ⌘K palette, a Connected health pill, the light/dark toggle, and a Docs link. - Sidebar: a LocalNet group label, icon + label nav (one stroke glyph per route), and a pinned footer showing loopback-only and the live schema version. - CommandPalette gains an openPalette() event hook so the topbar button can open it while the component keeps ownership of its state. Verified: tsc clean, 218/218 frontend tests, Go UI suite, make lint 0 issues, and both themes reviewed live against a running LocalNet (Overview, Explorer, DAR, Agent Skills) — the toggle flips the entire app. --- frontend/src/components/icons.tsx | 90 ++++++ frontend/src/index.css | 173 +++++++--- frontend/src/main.tsx | 5 + frontend/src/screens/BackupRestore.tsx | 10 +- frontend/src/screens/ContainerHealth.tsx | 6 +- frontend/src/screens/ContainerLogsModal.tsx | 4 +- frontend/src/screens/CreateLocalNetModal.tsx | 30 +- frontend/src/screens/CreatingPanel.tsx | 10 +- frontend/src/screens/DARDiff.tsx | 8 +- frontend/src/screens/DARPackageTree.tsx | 4 +- frontend/src/screens/DARScreen.tsx | 20 +- frontend/src/screens/Dashboard.tsx | 8 +- frontend/src/screens/DeveloperSetup.tsx | 2 +- frontend/src/screens/DoctorScreen.tsx | 4 +- frontend/src/screens/ExplorerScreen.tsx | 18 +- frontend/src/screens/InstanceDetail.tsx | 6 +- frontend/src/screens/MetricsScreen.tsx | 4 +- frontend/src/screens/TokensScreen.tsx | 2 +- frontend/src/screens/WalletScreen.tsx | 12 +- frontend/src/shell/CommandPalette.tsx | 21 +- frontend/src/shell/ErrorBoundary.tsx | 4 +- frontend/src/shell/Shell.tsx | 312 ++++++++++++++----- frontend/src/theme.ts | 67 ++++ frontend/src/tokens.ts | 105 ++++--- internal/ui/dist/index.html | 4 +- 25 files changed, 675 insertions(+), 254 deletions(-) create mode 100644 frontend/src/theme.ts diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx index 874a7022..faa73fd0 100644 --- a/frontend/src/components/icons.tsx +++ b/frontend/src/components/icons.tsx @@ -148,6 +148,96 @@ export const IcDroplet = (p: IconProps) => ( ); +// ---- Navigation glyphs (sidebar) ---- + +export const IcOverview = (p: IconProps) => ( + + + + + + +); + +export const IcDoctor = (p: IconProps) => ( + + + +); + +export const IcWallet = (p: IconProps) => ( + + + + + +); + +export const IcExplorer = (p: IconProps) => ( + + + + +); + +export const IcPackage = (p: IconProps) => ( + + + + +); + +export const IcMetrics = (p: IconProps) => ( + + + + +); + +export const IcTokens = (p: IconProps) => ( + + + + + +); + +export const IcAgent = (p: IconProps) => ( + + + + + +); + +// ---- Topbar glyphs ---- + +export const IcSun = (p: IconProps) => ( + + + + +); + +export const IcMoon = (p: IconProps) => ( + + + +); + +export const IcCommand = (p: IconProps) => ( + + + +); + +export const IcBook = (p: IconProps) => ( + + + + +); + /** Status dot — the only full-radius element in the system. */ export function Dot({ color, diff --git a/frontend/src/index.css b/frontend/src/index.css index 72cca064..da753071 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,7 +1,9 @@ -/* Base styles. Components own their styling via tokens.ts; this file - holds only what CSS must own: font-face, body defaults, focus - rings, hover states, and keyframes. tokens.ts stays the single - source of truth for the palette. */ +/* Base styles + the design-system token sheet. Every semantic color + lives here as a CSS variable with a dark default (:root) and a light + override (:root[data-theme="light"]), so tokens.ts W.* references + resolve per-theme with no per-screen change. Components own layout + via tokens.ts; this file owns fonts, the token sheet, focus rings, + hover states, and keyframes. */ /* Canton Infrastructure Design System typefaces, self-hosted so the UI renders identically offline (licenses in src/fonts/). Archivo @@ -41,15 +43,68 @@ font-display: swap; } +/* ---- Raw ramps (theme-independent) ---- */ :root { - color-scheme: dark; - --bg: #0b0f1a; - --text: #e9ecf4; + --gray-25: #fcfcfd; --gray-50: #f7f8fa; --gray-100: #eff1f5; + --gray-200: #e2e5ec; --gray-300: #cdd2dd; --gray-400: #9ba3b5; + --gray-500: #6c7488; --gray-600: #4d5567; --gray-700: #384050; + --gray-800: #232a39; --gray-900: #141a28; --gray-950: #0b0f1a; + --blue-50: #eef1fd; --blue-100: #dce3fb; --blue-200: #bcc9f6; + --blue-300: #93a7f0; --blue-400: #6480e6; --blue-500: #3d5bdc; + --blue-600: #2946ce; --blue-700: #2138a8; --blue-800: #1d2f85; + --blue-900: #1a2861; --blue-950: #101836; + --teal-300: #7bd2c6; --teal-500: #189e8c; + --green-500: #2e9e5b; --amber-500: #d89117; --red-500: #d24a38; + /* Motion — quick, damped, no bounce. */ --ease-out: cubic-bezier(0.2, 0.6, 0.2, 1); --duration-fast: 120ms; } +/* ---- Dark theme (default) ---- */ +:root { + color-scheme: dark; + --bg-page: #0b0f1a; --bg-sunken: #080c15; --bg-surface: #10151f; + --bg-raised: #161c29; --bg-inset: #0d1220; + --text-primary: #e9ecf4; --text-secondary: #a9b2c6; + --text-muted: #7c8598; --text-faint: #5a6375; + --border-subtle: #171e2c; --border-default: #232b3d; --border-strong: #313b52; + --hover-tint: #171e2c; --active-tint: #1e2637; + --accent: #6480e6; --accent-hover: #7b93ec; --accent-active: #93a7f0; + --accent-subtle: #141c36; --accent-muted: #1c2747; --accent-text: #93a7f0; + --on-accent: #0b0f1a; + --accent-solid: #2e4cd4; --accent-solid-hover: #3b57db; --on-accent-solid: #ffffff; + --link: #8fa3ee; --link-hover: #b3c1f4; + --ok-text: #7cc89a; --ok-bg: #0e1f16; --ok-border: #1e3a2a; + --warn-text: #ddb25e; --warn-bg: #211a0b; --warn-border: #3e3115; + --danger-text: #e08d7d; --danger-bg: #24120e; --danger-border: #45201a; + --danger: #d2604b; --danger-hover: #e08d7d; + --info-text: #a9baf2; --info-bg: #121a33; --info-border: #223059; + --dot-grid: radial-gradient(circle at 1px 1px, #232b3d 1px, transparent 1px); +} + +/* ---- Light theme ---- */ +:root[data-theme="light"] { + color-scheme: light; + --bg-page: #ffffff; --bg-sunken: #f7f8fa; --bg-surface: #ffffff; + --bg-raised: #ffffff; --bg-inset: #eff1f5; + --text-primary: #141a28; --text-secondary: #4d5567; + --text-muted: #6c7488; --text-faint: #9ba3b5; + --border-subtle: #eff1f5; --border-default: #e2e5ec; --border-strong: #cdd2dd; + --hover-tint: #f7f8fa; --active-tint: #eff1f5; + --accent: #2946ce; --accent-hover: #2138a8; --accent-active: #1d2f85; + --accent-subtle: #eef1fd; --accent-muted: #dce3fb; --accent-text: #2138a8; + --on-accent: #ffffff; + --accent-solid: #2946ce; --accent-solid-hover: #2138a8; --on-accent-solid: #ffffff; + --link: #2946ce; --link-hover: #1d2f85; + --ok-text: #18653b; --ok-bg: #edf7f0; --ok-border: #bce0c9; + --warn-text: #85560e; --warn-bg: #fcf5e8; --warn-border: #ebd9a9; + --danger-text: #962e20; --danger-bg: #fbefed; --danger-border: #efc5bd; + --danger: #ba3a29; --danger-hover: #962e20; + --info-text: #1d2f85; --info-bg: #eef1fd; --info-border: #bcc9f6; + --dot-grid: radial-gradient(circle at 1px 1px, #e2e5ec 1px, transparent 1px); +} + * { box-sizing: border-box; } @@ -60,13 +115,14 @@ body, margin: 0; padding: 0; height: 100%; - background: var(--bg); - color: var(--text); + background: var(--bg-page); + color: var(--text-primary); font-family: "Archivo", -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } button { @@ -79,6 +135,31 @@ a { text-decoration: none; } +::selection { + background: color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--text-primary); +} + +/* Themed scrollbars — a raised thumb on a transparent track, so the + chrome doesn't read as OS-default grey against either theme. */ +::-webkit-scrollbar { + width: 11px; + height: 11px; +} +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 999px; + border: 3px solid transparent; + background-clip: content-box; +} +::-webkit-scrollbar-thumb:hover { + background: var(--text-faint); + background-clip: content-box; +} +::-webkit-scrollbar-track { + background: transparent; +} + /* a11y: keyboard focus rings. * * :focus-visible — only when the focus came from keyboard (Tab, @@ -93,7 +174,7 @@ a { } :focus-visible { - outline: 2px solid #3d5bdc; + outline: 2px solid var(--blue-500); outline-offset: 2px; border-radius: 2px; } @@ -103,31 +184,46 @@ a { * Active = accent-subtle fill + accent text at 2px radius — the * design system's "current item" signature. */ .side-nav-link { - display: block; + display: flex; + align-items: center; + gap: 10px; padding: 7px 10px; margin: 1px 0; - border-radius: 2px; + border-radius: 4px; font-size: 13px; - color: #a9b2c6; + color: var(--text-secondary); transition: background var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out); } .side-nav-link:hover { - background: #171e2c; - color: #e9ecf4; + background: var(--hover-tint); + color: var(--text-primary); } .side-nav-link.active, .side-nav-link.active:hover { - background: #141c36; - color: #93a7f0; + background: var(--accent-subtle); + color: var(--accent-text); font-weight: 500; } +/* The nav icon dims with the label and lights up on the active row. */ +.side-nav-link svg { + color: var(--text-faint); + flex: none; +} +.side-nav-link:hover svg { + color: var(--text-muted); +} +.side-nav-link.active svg { + color: var(--accent); +} + /* Button system (components/Button.tsx). Hover/active tints live - * here because inline style objects can't express :hover. Values - * are the dark-console tokens from tokens.ts. */ + * here because inline style objects can't express :hover. + * primary = the solid cobalt CTA (white text, both themes); + * secondary = bordered surface; ghost = quiet; danger = filled red. */ .bd-btn { appearance: none; display: inline-flex; @@ -167,50 +263,47 @@ a { } .bd-btn--primary { - background: #6480e6; - color: #0b0f1a; + background: var(--accent-solid); + color: var(--on-accent-solid); } .bd-btn--primary:hover:not(:disabled) { - background: #7b93ec; + background: var(--accent-solid-hover); } .bd-btn--primary:active:not(:disabled) { - background: #93a7f0; + background: var(--accent-active); } .bd-btn--secondary { - background: #10151f; - border-color: #232b3d; - color: #e9ecf4; + background: var(--bg-surface); + border-color: var(--border-default); + color: var(--text-primary); } .bd-btn--secondary:hover:not(:disabled) { - background: #171e2c; - border-color: #313b52; + background: var(--hover-tint); + border-color: var(--border-strong); } .bd-btn--secondary:active:not(:disabled) { - background: #1e2637; + background: var(--active-tint); } .bd-btn--ghost { background: transparent; - color: #a9b2c6; + color: var(--text-secondary); } .bd-btn--ghost:hover:not(:disabled) { - background: #171e2c; - color: #e9ecf4; + background: var(--hover-tint); + color: var(--text-primary); } .bd-btn--ghost:active:not(:disabled) { - background: #1e2637; + background: var(--active-tint); } .bd-btn--danger { - background: #d2604b; + background: var(--danger); color: #fff; } .bd-btn--danger:hover:not(:disabled) { - background: #e08d7d; -} -.bd-btn--danger:active:not(:disabled) { - background: #ba3a29; + background: var(--danger-hover); } .bd-btn__icon { @@ -226,8 +319,8 @@ a { position: absolute; top: -100px; left: 8px; - background: #6480e6; - color: #0b0f1a; + background: var(--accent-solid); + color: var(--on-accent-solid); padding: 8px 14px; font-weight: 600; border-radius: 2px; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 217dea6d..49f29fb8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,11 @@ import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import "./index.css"; import { App } from "./App"; +import { initTheme } from "./theme"; + +// Apply the persisted theme before the first paint so there is no +// flash of the wrong palette. +initTheme(); ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/screens/BackupRestore.tsx b/frontend/src/screens/BackupRestore.tsx index 4b1332f0..0da811a2 100644 --- a/frontend/src/screens/BackupRestore.tsx +++ b/frontend/src/screens/BackupRestore.tsx @@ -5,7 +5,7 @@ import { restoreSnapshot, type RestoreResponse, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint } from "../tokens"; import { Button } from "../components/Button"; import { IcCheck, IcDownload } from "../components/icons"; @@ -155,7 +155,7 @@ export function BackupRestore({ instanceName }: Props) { role="alert" style={{ marginTop: 10, - background: `${W.err}10`, + background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, borderRadius: 2, padding: "8px 12px", @@ -197,7 +197,7 @@ export function BackupRestore({ instanceName }: Props) { aria-label="Drop snapshot file here or click to choose" style={{ border: `1.5px dashed ${dragOver ? W.brand : W.border}`, - background: dragOver ? `${W.brand}10` : "transparent", + background: dragOver ? `${tint(W.brand, 6)}` : "transparent", borderRadius: 4, padding: "14px 16px", cursor: "pointer", @@ -291,7 +291,7 @@ export function BackupRestore({ instanceName }: Props) { role="status" style={{ marginTop: 10, - background: `${W.brand}10`, + background: `${tint(W.brand, 6)}`, border: `1px solid ${W.brand}`, borderRadius: 2, padding: "8px 12px", @@ -319,7 +319,7 @@ export function BackupRestore({ instanceName }: Props) { role="alert" style={{ marginTop: 10, - background: `${W.err}10`, + background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, borderRadius: 2, padding: "8px 12px", diff --git a/frontend/src/screens/ContainerHealth.tsx b/frontend/src/screens/ContainerHealth.tsx index dcb53085..a76c1ec5 100644 --- a/frontend/src/screens/ContainerHealth.tsx +++ b/frontend/src/screens/ContainerHealth.tsx @@ -5,7 +5,7 @@ import { fetchContainers, restartContainer, } from "../api"; -import { W, wMono, tableCaps } from "../tokens"; +import { W, wMono, tableCaps, tint } from "../tokens"; import { Button } from "../components/Button"; import { Dot, IcRefresh } from "../components/icons"; import { ContainerLogsModal } from "./ContainerLogsModal"; @@ -131,7 +131,7 @@ export function ContainerHealth({ name }: { name: string }) { role="alert" style={{ color: W.err, - background: `${W.err}10`, + background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, borderRadius: 2, padding: "6px 10px", @@ -147,7 +147,7 @@ export function ContainerHealth({ name }: { name: string }) { role="alert" style={{ color: W.err, - background: `${W.err}10`, + background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, borderRadius: 2, padding: "6px 10px", diff --git a/frontend/src/screens/ContainerLogsModal.tsx b/frontend/src/screens/ContainerLogsModal.tsx index f0fcfea5..10985e57 100644 --- a/frontend/src/screens/ContainerLogsModal.tsx +++ b/frontend/src/screens/ContainerLogsModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { ApiError, fetchContainerLogs } from "../api"; -import { W, wMono, wSans } from "../tokens"; +import { W, wMono, wSans, tint } from "../tokens"; import { Button } from "../components/Button"; import { IcX } from "../components/icons"; @@ -134,7 +134,7 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props role="alert" style={{ color: W.err, - background: `${W.err}10`, + background: `${tint(W.err, 6)}`, borderBottom: `1px solid ${W.err}`, padding: "8px 16px", fontSize: 12, diff --git a/frontend/src/screens/CreateLocalNetModal.tsx b/frontend/src/screens/CreateLocalNetModal.tsx index 0687ef45..e116a5f9 100644 --- a/frontend/src/screens/CreateLocalNetModal.tsx +++ b/frontend/src/screens/CreateLocalNetModal.tsx @@ -13,7 +13,7 @@ import { type PreflightReport, type SpliceVersionEntry, } from "../api"; -import { W, wMono, wSans } from "../tokens"; +import { W, wMono, wSans, tint } from "../tokens"; import { Button } from "../components/Button"; import { Dot, IcAlert, IcCheck, IcStop, IcX } from "../components/icons"; import { remediationForCode } from "./remediation"; @@ -558,7 +558,7 @@ function FormBody({ fontSize: 10.5, padding: "1px 6px", borderRadius: 2, - background: `${W.brand}1A`, + background: `${tint(W.brand, 10)}`, color: W.brand, fontFamily: wMono, }} @@ -603,7 +603,7 @@ function FormBody({ fontSize: 10.5, padding: "1px 6px", borderRadius: 2, - background: `${W.brand}1A`, + background: `${tint(W.brand, 10)}`, color: W.brand, fontFamily: wMono, }} @@ -623,7 +623,7 @@ function FormBody({ style={{ marginTop: 8, padding: "6px 8px", - background: `${W.warn}15`, + background: `${tint(W.warn, 8)}`, border: `1px solid ${W.warn}`, borderRadius: 2, fontSize: 11.5, @@ -666,7 +666,7 @@ function FormBody({ fontSize: 10.5, padding: "1px 6px", borderRadius: 2, - background: `${W.brand}1A`, + background: `${tint(W.brand, 10)}`, color: W.brand, fontFamily: wMono, }} @@ -823,8 +823,8 @@ function ProgressBody({
@@ -935,9 +935,9 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
onChange(opt)} style={{ background: opt === value ? W.brand : W.surface2, - color: opt === value ? "#0B0F1A" : W.text2, + color: opt === value ? W.onAccent : W.text2, border: `1px solid ${opt === value ? W.brand : W.border}`, borderRadius: 2, padding: "4px 10px", diff --git a/frontend/src/screens/DoctorScreen.tsx b/frontend/src/screens/DoctorScreen.tsx index 69470114..9c0090a6 100644 --- a/frontend/src/screens/DoctorScreen.tsx +++ b/frontend/src/screens/DoctorScreen.tsx @@ -7,7 +7,7 @@ import { fetchDoctor, fetchSpliceVersions, } from "../api"; -import { W, wMono, wideCaps } from "../tokens"; +import { W, wMono, wideCaps, tint } from "../tokens"; import { Button } from "../components/Button"; import { Dot, IcAlert, IcCheck, IcRefresh, IcX } from "../components/icons"; @@ -91,7 +91,7 @@ export function DoctorScreen() { style={{ marginTop: 16, padding: "12px 14px", - background: `${W.err}1A`, + background: `${tint(W.err, 10)}`, border: `1px solid ${W.err}`, borderRadius: 4, color: W.err, diff --git a/frontend/src/screens/ExplorerScreen.tsx b/frontend/src/screens/ExplorerScreen.tsx index 294d7b26..0269d7d3 100644 --- a/frontend/src/screens/ExplorerScreen.tsx +++ b/frontend/src/screens/ExplorerScreen.tsx @@ -16,7 +16,7 @@ import { import { useInstanceSelection } from "../shell/useInstanceSelection"; import { Button } from "../components/Button"; import { Dot, IcRefresh } from "../components/icons"; -import { TX_KIND_COLOR, W, wMono, tableCaps, wideCaps } from "../tokens"; +import { TX_KIND_COLOR, W, wMono, tableCaps, wideCaps, tint } from "../tokens"; import { ContractDetailDrawer } from "./ContractDetailDrawer"; import { TxReplayDrawer } from "./TxReplayDrawer"; @@ -627,7 +627,7 @@ function ProjectionBar({ ? "#DDB25E" : streamStatus === "truncated" ? "#7BD2C6" - : "#7C8598"; + : W.dim; const pillLabel = streamStatus === "live" ? "live" @@ -736,7 +736,7 @@ function ProjectionBar({ borderRadius: 2, border: "none", background: v === view ? W.brand : "transparent", - color: v === view ? "#0B0F1A" : W.dim, + color: v === view ? W.onAccent : W.dim, fontWeight: v === view ? 600 : 500, cursor: "pointer", textTransform: "capitalize", @@ -837,7 +837,7 @@ function AcsRow({ gap: 14, padding: "9px 14px", alignItems: "center", - background: active ? `${W.brand}10` : "transparent", + background: active ? `${tint(W.brand, 6)}` : "transparent", borderLeft: active ? `2px solid ${W.brand}` : "2px solid transparent", paddingLeft: active ? 12 : 14, borderBottom: `1px solid ${W.border}`, @@ -1272,7 +1272,7 @@ function TxRowComponent({ gap: 14, padding: "9px 14px", alignItems: "center", - background: open ? `${W.brand}10` : "transparent", + background: open ? `${tint(W.brand, 6)}` : "transparent", borderBottom: `1px solid ${W.border}`, cursor: "pointer", }} @@ -1353,7 +1353,7 @@ function TxRowComponent({ {open && tx.events && tx.events.length > 0 && (
{buckets.map((b, i) => { @@ -1566,7 +1566,7 @@ function TimelineView({ name, role }: { name: string; role: Role }) { background: b.count === 0 ? W.border - : `linear-gradient(180deg, ${W.brand}66 0%, ${W.brand} 100%)`, + : `linear-gradient(180deg, ${tint(W.brand, 40)} 0%, ${W.brand} 100%)`, borderRadius: 2, }} /> @@ -1944,7 +1944,7 @@ function EmptyPanel({ return (
{v === "instruments" ? "Instruments" : "Holdings matrix"} diff --git a/frontend/src/screens/WalletScreen.tsx b/frontend/src/screens/WalletScreen.tsx index e180cc3b..5a2446fe 100644 --- a/frontend/src/screens/WalletScreen.tsx +++ b/frontend/src/screens/WalletScreen.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { ApiError, fetchInstance, type Instance, type Role } from "../api"; import { useInstanceSelection } from "../shell/useInstanceSelection"; -import { ROLE_COLOR, W, wMono } from "../tokens"; +import { ROLE_COLOR, W, wMono, tint } from "../tokens"; import { Button } from "../components/Button"; import { Dot, IcAlert, IcRefresh } from "../components/icons"; @@ -155,8 +155,8 @@ export function WalletScreen() { users don't have to dig through env files. */}
{children} diff --git a/frontend/src/shell/CommandPalette.tsx b/frontend/src/shell/CommandPalette.tsx index ade28077..0b60b3b0 100644 --- a/frontend/src/shell/CommandPalette.tsx +++ b/frontend/src/shell/CommandPalette.tsx @@ -7,7 +7,7 @@ import { type KeyboardEvent, } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { W, wMono, wSans } from "../tokens"; +import { W, wMono, wSans, tint } from "../tokens"; import { useInstanceSelection } from "./useInstanceSelection"; import { NAV, isInstanceScoped, linkTo } from "./routes"; @@ -41,6 +41,14 @@ const NAV_ACTIONS: Array & { path: string }> = NAV.map( }), ); +// openPalette lets non-keyboard callers (the topbar "Commands" button) +// open the palette. It dispatches an event the mounted CommandPalette +// listens for, so the open state stays owned by the component. +const OPEN_EVENT = "cdk-open-palette"; +export function openPalette(): void { + window.dispatchEvent(new CustomEvent(OPEN_EVENT)); +} + export function CommandPalette() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); @@ -67,8 +75,15 @@ export function CommandPalette() { setOpen(false); } } + function onOpen() { + setOpen(true); + } window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + window.addEventListener(OPEN_EVENT, onOpen); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener(OPEN_EVENT, onOpen); + }; }, [open]); // Auto-focus the input when the palette opens; reset query + @@ -260,7 +275,7 @@ function renderGroups( gap: 12, width: "100%", padding: "8px 12px", - background: isCursor ? `${W.brand}1A` : "transparent", + background: isCursor ? `${tint(W.brand, 10)}` : "transparent", border: "none", borderRadius: 2, color: W.text, diff --git a/frontend/src/shell/ErrorBoundary.tsx b/frontend/src/shell/ErrorBoundary.tsx index edb82164..ebfbf25e 100644 --- a/frontend/src/shell/ErrorBoundary.tsx +++ b/frontend/src/shell/ErrorBoundary.tsx @@ -1,5 +1,5 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint } from "../tokens"; import { Button } from "../components/Button"; // ErrorBoundary — catches render-time exceptions from descendants and @@ -79,7 +79,7 @@ function Fallback({ error, onRetry }: FallbackProps) {
JSX.Element> = { + "/": IcOverview, + "/doctor": IcDoctor, + "/wallet": IcWallet, + "/explorer": IcExplorer, + "/dar": IcPackage, + "/metrics": IcMetrics, + "/tokens": IcTokens, + "/agent": IcAgent, +}; + +// Published docs site (astro.config site + base). +const DOCS_URL = "https://bitdynamics-ab.github.io/canton-devkit/"; + interface ShellProps { children: React.ReactNode; } @@ -22,10 +53,11 @@ export function Shell({ children }: ShellProps) {
@@ -81,9 +113,19 @@ function SkipLink() { ); } +// currentRouteLabel resolves the active pathname to its NAV label for +// the topbar page title. Instance-scoped routes carry a query string, +// so match on pathname only. +function currentRouteLabel(pathname: string): string { + const hit = NAV.find((n) => n.to === pathname); + return hit ? hit.label : ""; +} + function TopBar() { const conn = useConnectionHealth(); const sel = useInstanceSelection(); + const { pathname } = useLocation(); + const title = currentRouteLabel(pathname); return (
- - canton-devkit · local development - + {title && ( + + {title} + + )}
- - loopback only · ssh -L for remote - + + + + Docs +
); } +function ThemeToggle() { + const theme = useTheme(); + const next = theme === "dark" ? "light" : "dark"; + return ( + + ); +} + function InstanceSwitcher({ sel }: { sel: InstanceSelection }) { const [open, setOpen] = useState(false); // Empty / loading / error states all degrade to a muted label @@ -123,6 +220,7 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) { if (sel.error || sel.instances.length === 0) { return no instances; } + const selected = sel.instances.find((i) => i.name === sel.selected); return (
{open && (
    {sel.instances.map((i) => ( @@ -181,12 +296,12 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) { alignItems: "center", gap: 8, width: "100%", - padding: "6px 10px", + padding: "7px 10px", background: - i.name === sel.selected ? W.surface2 : "transparent", + i.name === sel.selected ? W.brandSoft : "transparent", border: "none", borderRadius: 2, - color: W.text, + color: i.name === sel.selected ? W.brandText : W.text, fontFamily: wMono, fontSize: 12, textAlign: "left", @@ -207,48 +322,46 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) { ); } -function StatusDot({ status }: { status: string }) { - const color = - status === "running" - ? W.ok - : status === "failed" +function statusColor(status: string): string { + return status === "running" + ? W.ok + : status === "failed" ? W.err : status === "stopped" - ? W.dim - : W.warn; - return ( - - ); + ? W.dim + : W.warn; +} + +function StatusDot({ status }: { status: string }) { + return ; } function PaletteHint() { - // Static visual hint that the ⌘K palette exists. Not a button — - // pressing the actual key opens the modal; this is purely for - // discovery. UA-sniff for the glyph because Mac users expect ⌘ - // and Windows/Linux users expect Ctrl. + // Opens the ⌘K palette; the keycap is a discovery hint. UA-sniff + // for the glyph because Mac users expect ⌘ and others expect Ctrl. const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); const mod = isMac ? "⌘" : "Ctrl"; return ( - + Commands {mod} K - commands - + ); } @@ -272,9 +384,10 @@ function pillStyle(color: string): React.CSSProperties { display: "inline-flex", alignItems: "center", gap: 6, - padding: "3px 10px", + height: 28, + padding: "0 10px", borderRadius: 2, - border: `1px solid ${color}`, + border: `1px solid ${W.border}`, color, fontFamily: wMono, fontSize: 11, @@ -287,7 +400,7 @@ function HealthPill({ conn }: { conn: ConnectionState }) { case "ok": return { color: W.ok, - label: `v${conn.serverVersion}`, + label: "Connected", tooltip: `Connected · schema v${conn.serverVersion}`, }; case "mismatch": @@ -313,29 +426,18 @@ function HealthPill({ conn }: { conn: ConnectionState }) { style={{ display: "inline-flex", alignItems: "center", - gap: 6, - padding: "3px 9px", + gap: 7, + height: 28, + padding: "0 11px", borderRadius: 2, - border: `1px solid ${color}`, - background: `${color}1A`, + border: `1px solid ${tint(color, 40)}`, + background: tint(color, 10), color, - fontFamily: wMono, - fontSize: 11, + fontSize: 12, cursor: "help", }} > - + {label} ); @@ -347,28 +449,65 @@ function Sidebar() { // ./routes so the ⌘K palette shares the same table. const [params] = useSearchParams(); const instance = params.get("instance"); + const conn = useConnectionHealth(); return ( ); } @@ -383,7 +522,7 @@ function LogoLockup() { lineHeight: 1, }} > - + @@ -402,6 +541,7 @@ function LogoLockup() { > BITDYNAMICS + .cc ); } diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts new file mode 100644 index 00000000..c912ecfa --- /dev/null +++ b/frontend/src/theme.ts @@ -0,0 +1,67 @@ +// Theme state for the Web UI. The design system ships both a dark +// (default) and light palette; the active one is written to +// `data-theme` on , which flips every CSS variable in index.css +// and therefore every W.* token app-wide. The choice persists in +// localStorage so a reload keeps it. + +import { useSyncExternalStore } from "react"; + +export type Theme = "dark" | "light"; + +const STORAGE_KEY = "cdk-theme"; +const listeners = new Set<() => void>(); + +function read(): Theme { + try { + const v = window.localStorage.getItem(STORAGE_KEY); + if (v === "light" || v === "dark") return v; + } catch { + // localStorage may be unavailable (private mode / sandbox); fall + // through to the default. + } + return "dark"; +} + +// applyTheme sets the attribute that drives the CSS variables. Called +// once at startup (before render, see main.tsx) so there is no +// light-on-dark flash, and again on every change. +export function applyTheme(t: Theme): void { + document.documentElement.dataset.theme = t; +} + +export function getTheme(): Theme { + return (document.documentElement.dataset.theme as Theme) || read(); +} + +export function setTheme(t: Theme): void { + applyTheme(t); + try { + window.localStorage.setItem(STORAGE_KEY, t); + } catch { + // Non-fatal: the theme still applies for this session. + } + listeners.forEach((fn) => fn()); +} + +export function toggleTheme(): void { + setTheme(getTheme() === "dark" ? "light" : "dark"); +} + +// initTheme applies the persisted (or default) theme. Call before the +// first render. +export function initTheme(): void { + applyTheme(read()); +} + +// useTheme subscribes a component to theme changes so a toggle +// re-renders with the current value. +export function useTheme(): Theme { + return useSyncExternalStore( + (cb) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + getTheme, + () => "dark", + ); +} diff --git a/frontend/src/tokens.ts b/frontend/src/tokens.ts index 06ceb309..f5d13c0b 100644 --- a/frontend/src/tokens.ts +++ b/frontend/src/tokens.ts @@ -1,58 +1,69 @@ -// Web UI design tokens — the Canton Infrastructure Design System's -// dark console theme (consoles default dark). Single source of truth -// so every screen pulls from the same palette and a design-system -// change is a one-line update here. +// Web UI design tokens — the Canton Infrastructure Design System. // -// The system uses cool ink neutrals with ONE interactive accent -// (cobalt); teal is a data accent only — series, parties, throughput — -// never buttons or links. Status hues are desaturated. Structure comes -// from 1px hairlines, not shadows. +// Every semantic color resolves through a CSS variable defined in +// index.css under :root (dark) and :root[data-theme="light"], so the +// same W.* reference renders correctly in both themes with no +// per-screen change. Structure comes from 1px hairlines, not shadows; +// one interactive accent (cobalt); teal/amber are DATA accents only +// (series, parties, throughput) and stay fixed mid-tones legible on +// either background. export const W = { - bg: "#0B0F1A", // bg-page - surface: "#10151F", // bg-surface — cards, sidebars, inputs - surface2: "#161C29", // bg-raised — menus, hovered rows in menus - border: "#232B3D", // border-default - borderHi: "#313B52", // border-strong — hover borders - text: "#E9ECF4", // text-primary - text2: "#A9B2C6", // text-secondary - dim: "#7C8598", // text-muted - faint: "#5A6375", // text-faint - brand: "#6480E6", // accent (cobalt) — buttons, tabs, active nav - brandSoft: "#141C36", // accent-subtle — active-nav fill, selection - brandText: "#93A7F0", // accent-text — accent-colored text - ok: "#7CC89A", // ok-text - warn: "#DDB25E", // warn-text - err: "#E08D7D", // danger-text - info: "#8FA3EE", // info-text / links - mag: "#93A7F0", // series accent (cobalt-light) + bg: "var(--bg-page)", + surface: "var(--bg-surface)", // cards, sidebars, inputs + surface2: "var(--bg-raised)", // menus, raised rows + border: "var(--border-default)", + borderHi: "var(--border-strong)", // hover borders + text: "var(--text-primary)", + text2: "var(--text-secondary)", + dim: "var(--text-muted)", + faint: "var(--text-faint)", + brand: "var(--accent)", // cobalt — buttons, tabs, active nav + brandSoft: "var(--accent-subtle)", // active-nav fill, selection + brandText: "var(--accent-text)", + ok: "var(--ok-text)", + warn: "var(--warn-text)", + err: "var(--danger-text)", + info: "var(--info-text)", // status/info + links + mag: "#93A7F0", // series accent (cobalt-light — data) rose: "#7BD2C6", // series accent (teal — data only) - amber: "#C8971F", // series accent (deep amber) - card: "#10151F", // bg-surface - rowHover: "#171E2C", // hover-tint - selRow: "#1E2637", // active-tint + amber: "#C8971F", // series accent (deep amber — data) + card: "var(--bg-surface)", + rowHover: "var(--hover-tint)", + selRow: "var(--active-tint)", - // CDS-specific roles beyond the original palette. - sunken: "#080C15", // bg-sunken — nav rail, card footers - inset: "#0D1220", // bg-inset — wells, disabled fields - onAccent: "#0B0F1A", // text on accent-filled controls - accentHover: "#7B93EC", - accentActive: "#93A7F0", + // CDS roles. + sunken: "var(--bg-sunken)", // nav rail, card footers + inset: "var(--bg-inset)", // wells, disabled fields + onAccent: "var(--on-accent)", // text on accent-filled controls + onAccentSolid: "var(--on-accent-solid)", // text on the solid CTA fill + accentSolid: "var(--accent-solid)", // the primary-button fill (both themes) + accentSolidHover: "var(--accent-solid-hover)", + accentHover: "var(--accent-hover)", + accentActive: "var(--accent-active)", teal: "#7BD2C6", // data accent — throughput, parties tealDeep: "#189E8C", // dense data accent — log sources - okBg: "#0E1F16", - okBorder: "#1E3A2A", - okIcon: "#4FAE76", - warnBg: "#211A0B", - warnBorder: "#3E3115", - warnIcon: "#C8971F", - errBg: "#24120E", - errBorder: "#45201A", - errIcon: "#D2604B", - infoBg: "#121A33", - infoBorder: "#223059", - focus: "#3D5BDC", // 2px focus outline — identical in both themes + okBg: "var(--ok-bg)", + okBorder: "var(--ok-border)", + okIcon: "var(--ok-text)", + warnBg: "var(--warn-bg)", + warnBorder: "var(--warn-border)", + warnIcon: "var(--warn-text)", + errBg: "var(--danger-bg)", + errBorder: "var(--danger-border)", + errIcon: "var(--danger-text)", + infoBg: "var(--info-bg)", + infoBorder: "var(--info-border)", + focus: "var(--blue-500)", // 2px focus outline — identical in both themes } as const; +// Translucent tint of a themed color. Replaces the old `${W.x}NN` +// hex-alpha concatenation, which is invalid once W.x is a CSS var +// (`var(--accent)1A` is not a color). color-mix over transparent is +// the faithful equivalent of a hex alpha over the surface behind it. +export function tint(color: string, pct: number): string { + return `color-mix(in srgb, ${color} ${pct}%, transparent)`; +} + export const wMono = "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace"; export const wSans = diff --git a/internal/ui/dist/index.html b/internal/ui/dist/index.html index 9fe1ef78..023be8f1 100644 --- a/internal/ui/dist/index.html +++ b/internal/ui/dist/index.html @@ -6,8 +6,8 @@ canton-devkit - - + +
    From 50bc72e5ceb592e4d5d3b7ba26f3194adccf6ce6 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:46:11 +0530 Subject: [PATCH 02/14] feat(token): make the one-click demo work on standard (V1) instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `token demo` (and the Web UI "Launch demo token" button) required a token-standard-v2 instance: it always created a new on-ledger V2 instrument, which needs the splice-test-token-v2 DAR that a standard release doesn't publish — so on a normal LocalNet the demo failed with "the test-token DAR isn't published for this instance's Splice version". RunDemo now adapts to the instance instead of assuming V2: - token-standard-v2 instance → unchanged: allocate an issuer, create a V2 instrument, mint the supply, seed a holder. - standard instance (V1) → there is no create/mint (Amulet is the only instrument), so allocate a holder and fund it with Amulet moved from the role's network-funded party (app-user) via the faucet — a transferable balance in one click, no alpha DAR required. The path is chosen by the instance's catalogue channel (SupportedVersions[version].IsAlpha()), not the alpha_protocol_enabled flag — that flag is true on both standard and V2 0.6.x instances and so can't discriminate. Unknown/uncurated versions default to the V1 path, which works on any running instance. Both surfaces call RunDemo, so the CLI and Web UI are fixed by one change; the CLI help text is updated to describe both paths. Adds a V1-path regression test and threads a routing seam through the existing V2 tests so they still exercise the V2 flow. --- internal/cli/localnet/token/demo.go | 24 ++++-- internal/localnet/token/demo.go | 108 +++++++++++++++++++++++++-- internal/localnet/token/demo_test.go | 60 +++++++++++++++ 3 files changed, 179 insertions(+), 13 deletions(-) diff --git a/internal/cli/localnet/token/demo.go b/internal/cli/localnet/token/demo.go index c24cefd9..f9179405 100644 --- a/internal/cli/localnet/token/demo.go +++ b/internal/cli/localnet/token/demo.go @@ -9,9 +9,11 @@ import ( ) // buildDemo returns `token demo` — a one-step "launch a transferable -// demo token" (issuer party + on-ledger V2 instrument + minted supply + -// optional funded holder). The Web UI's "Launch demo token" button drives -// the same token.RunDemo via POST /api/tokens/demo. +// demo token" that adapts to the instance: on a token-standard-v2 +// instance it creates a new on-ledger V2 instrument (issuer + minted +// supply + funded holder); on a standard instance it funds a holder +// with the existing V1 Amulet. The Web UI's "Launch demo token" button +// drives the same token.RunDemo via POST /api/tokens/demo. func buildDemo() *cobra.Command { var ( instance string @@ -27,12 +29,18 @@ func buildDemo() *cobra.Command { cmd := &cobra.Command{ Use: "demo", Short: "Provision a live, transferable demo token in one step", - Long: `Launch a demo token end-to-end in a single command: allocate an issuer -party, create a V2 instrument on-ledger, mint the initial supply, and -(unless --seed-holder=false) fund a holder party so the token is -transferable immediately. + Long: `Launch a transferable demo token end-to-end in a single command. The +flow adapts to the instance: -Requires a running V2 LocalNet — the participant endpoint is auto- + token-standard-v2 instance: allocate an issuer party, create a V2 + instrument on-ledger, mint the initial supply, and (unless + --seed-holder=false) fund a holder party. + + standard instance (V1): there is no create/mint — Amulet is the only + instrument — so a holder party is funded with Amulet moved from the + role's network-funded party, giving a transferable balance. + +Requires a running LocalNet — the participant endpoint is auto- discovered from the instance's captured port (pass --endpoint to override). The Web UI's "Launch demo token" button runs the same orchestration via POST /api/tokens/demo.`, diff --git a/internal/localnet/token/demo.go b/internal/localnet/token/demo.go index 351aff5a..33aa9c97 100644 --- a/internal/localnet/token/demo.go +++ b/internal/localnet/token/demo.go @@ -7,6 +7,7 @@ import ( "io" "github.com/bitdynamics-ab/canton-devkit/internal/registry" + "github.com/bitdynamics-ab/canton-devkit/internal/splice" ) // DemoOptions configures the one-click demo-token provisioning. @@ -49,6 +50,10 @@ var ( demoCreate = RunCreate demoMint = RunMint demoFaucet = RunFaucet + // demoV2Capable routes the demo: true → create a new V2 instrument; + // false → the V1 Amulet demo. A seam so the choreography of each path + // can be unit-tested without a real registry/catalogue. + demoV2Capable = v2InstrumentCreateCapable ) // RunDemo provisions a live, transferable demo token in one call: @@ -61,10 +66,16 @@ var ( // transfer works immediately. // // It composes the same Run* functions the individual CLI/UI verbs use, -// so its behaviour can't drift from them. A live V2 endpoint is required -// (there's no on-ledger instrument to mint/transfer otherwise) — without -// one it returns ErrNeedsV2LocalNet, which both surfaces map to a "start -// a V2 instance first" remediation. +// so its behaviour can't drift from them. A live ledger endpoint is +// required (empty → ErrNeedsV2LocalNet). +// +// The path is chosen by the instance's capability: +// - a token-standard-v2 instance CAN create a new on-ledger instrument, +// so the demo creates + mints + seeds a "DEMO" token (the V2 flow); +// - a standard release instance can only read/transfer the existing V1 +// Amulet, so the demo funds a fresh holder with Amulet moved from the +// network-funded role party — a transferable token in one click, +// without needing the alpha token-standard-v2 DAR. func RunDemo(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResult, error) { if opts.Instance == "" { return nil, fmt.Errorf("demo: instance is required") @@ -72,8 +83,16 @@ func RunDemo(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResult, if opts.Endpoint == "" { return nil, ErrNeedsV2LocalNet } - opts = applyDemoDefaults(opts) + if demoV2Capable(opts.Instance) { + return runDemoV2(ctx, out, applyDemoDefaults(opts)) + } + return runDemoV1(ctx, out, applyDemoV1Defaults(opts)) +} +// runDemoV2 creates a new on-ledger V2 instrument, mints its supply, and +// optionally seeds a holder — the one-click demo on a token-standard-v2 +// instance. opts is already defaulted. +func runDemoV2(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResult, error) { step := func(format string, a ...any) { if out != nil { _, _ = fmt.Fprintf(out, format+"\n", a...) @@ -183,6 +202,85 @@ func ensureDemoParty(ctx context.Context, opts DemoOptions, alias string) (*regi return nil, err } +// runDemoV1 provisions a live, transferable demo on a standard (V1) +// instance. CIP-0056 V1 has no create/mint — Amulet is the only +// instrument and the network-funded role party (app-user) holds it — so +// the demo allocates a fresh holder and moves some Amulet to it via the +// faucet (a funded transfer), giving a transferable balance in one click. +// opts is already defaulted (applyDemoV1Defaults). +func runDemoV1(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResult, error) { + step := func(format string, a ...any) { + if out != nil { + _, _ = fmt.Fprintf(out, format+"\n", a...) + } + } + // The network's Amulet lives on the role's seeded party (app-user by + // default); it is the demo's funding source. + source := roleOrDefault(opts.Role) + + step("Allocating holder party %q…", opts.HolderAlias) + holder, err := ensureDemoParty(ctx, opts, opts.HolderAlias) + if err != nil { + return nil, fmt.Errorf("demo: allocate holder: %w", err) + } + + step("Funding %s with %s Amulet from %s…", opts.HolderAlias, opts.SeedAmount, source) + if ferr := demoFaucet(ctx, out, FaucetOptions{ + Instance: opts.Instance, + Instrument: amuletSymbol, + To: holder.PartyID, + Amount: opts.SeedAmount, + Source: source, + Endpoint: opts.Endpoint, + Role: opts.Role, + Insecure: opts.Insecure, + }); ferr != nil { + return nil, fmt.Errorf("demo: fund holder with Amulet: %w", ferr) + } + + step("Amulet demo is live and transferable — %s now holds %s Amulet.", opts.HolderAlias, opts.SeedAmount) + return &DemoResult{ + // Amulet is the pre-existing V1 instrument; there is no created + // token or minted supply on this path. + Token: registry.TokenRef{Name: amuletSymbol, Symbol: amuletSymbol, InstrumentID: amuletSymbol}, + Issuer: registry.PartyRef{Alias: source, Role: roleOrDefault(opts.Role)}, + Holder: holder, + Seeded: true, + }, nil +} + +// amuletSymbol is the network's V1 instrument used by the V1 demo. +const amuletSymbol = "Amulet" + +// v2InstrumentCreateCapable reports whether the instance can create a NEW +// on-ledger V2 instrument — i.e. its Splice version ships the +// splice-test-token-v2 example DAR. Today that is the alpha +// (token-standard-v2) channel; standard releases (0.6.x) can only +// read/transfer the existing V1 Amulet. Unknown/uncurated versions +// default to false so the demo takes the V1 path, which works on any +// running instance. +func v2InstrumentCreateCapable(instance string) bool { + st, err := registry.Read(instance) + if err != nil { + return false + } + v, ok := splice.SupportedVersions[st.SpliceVersion] + return ok && v.IsAlpha() +} + +// applyDemoV1Defaults fills the V1-demo tunables. The seed is smaller than +// the V2 default (1000) because it comes out of the funded role party's +// finite genesis Amulet rather than a freshly-minted supply. +func applyDemoV1Defaults(o DemoOptions) DemoOptions { + if o.SeedAmount == "" { + o.SeedAmount = "100" + } + if o.HolderAlias == "" { + o.HolderAlias = "demo-holder" + } + return o +} + func applyDemoDefaults(o DemoOptions) DemoOptions { if o.Symbol == "" { o.Symbol = "DEMO" diff --git a/internal/localnet/token/demo_test.go b/internal/localnet/token/demo_test.go index 8d461858..291dae9e 100644 --- a/internal/localnet/token/demo_test.go +++ b/internal/localnet/token/demo_test.go @@ -27,6 +27,15 @@ func stubDemoSeams( t.Cleanup(func() { demoPartyNew, demoCreate, demoMint, demoFaucet = op, oc, om, of }) } +// stubDemoV2Capable pins the V1/V2 routing decision so a test exercises the +// chosen path without a real catalogue/registry lookup. +func stubDemoV2Capable(t *testing.T, v bool) { + t.Helper() + prev := demoV2Capable + demoV2Capable = func(string) bool { return v } + t.Cleanup(func() { demoV2Capable = prev }) +} + func TestRunDemo_RequiresEndpoint(t *testing.T) { if _, err := RunDemo(context.Background(), nil, DemoOptions{Instance: "demo"}); !errors.Is(err, ErrNeedsV2LocalNet) { t.Fatalf("want ErrNeedsV2LocalNet, got %v", err) @@ -40,6 +49,7 @@ func TestRunDemo_RequiresInstance(t *testing.T) { } func TestRunDemo_ComposesPartyCreateMintFaucet(t *testing.T) { + stubDemoV2Capable(t, true) var order []string var createOpts CreateOptions var mintOpts MintOptions @@ -95,6 +105,7 @@ func TestRunDemo_ComposesPartyCreateMintFaucet(t *testing.T) { } func TestRunDemo_NoSeedHolderSkipsFaucet(t *testing.T) { + stubDemoV2Capable(t, true) var order []string stubDemoSeams(t, func(_ context.Context, o PartyOptions) (*registry.PartyRef, error) { @@ -125,6 +136,7 @@ func TestRunDemo_NoSeedHolderSkipsFaucet(t *testing.T) { } func TestRunDemo_StopsOnCreateError(t *testing.T) { + stubDemoV2Capable(t, true) minted := false stubDemoSeams(t, func(_ context.Context, o PartyOptions) (*registry.PartyRef, error) { @@ -147,6 +159,7 @@ func TestRunDemo_StopsOnCreateError(t *testing.T) { // must give an actionable "already exists" message, while still wrapping // ErrSymbolInUse so both surfaces map it to 409. func TestRunDemo_DuplicateSymbolIsActionable(t *testing.T) { + stubDemoV2Capable(t, true) stubDemoSeams(t, func(_ context.Context, o PartyOptions) (*registry.PartyRef, error) { return ®istry.PartyRef{Alias: o.Alias, PartyID: "pid"}, nil @@ -166,6 +179,7 @@ func TestRunDemo_DuplicateSymbolIsActionable(t *testing.T) { } func TestRunDemo_ReusesExistingIssuerAlias(t *testing.T) { + stubDemoV2Capable(t, true) t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) s := registry.NewState("demo", "0.6.4") s.Parties = map[string]registry.PartyRef{"demo-issuer": {Alias: "demo-issuer", PartyID: "existing::pid"}} @@ -193,3 +207,49 @@ func TestRunDemo_ReusesExistingIssuerAlias(t *testing.T) { t.Errorf("should reuse the existing demo-issuer party, got %q", createIssuer) } } + +// On a standard (V1) instance the demo can't create/mint a new instrument; +// it allocates a holder and faucets Amulet to it from the funded role +// party. No create, no mint. +func TestRunDemo_V1FundsHolderWithAmulet(t *testing.T) { + stubDemoV2Capable(t, false) + var order []string + var faucetOpts FaucetOptions + stubDemoSeams(t, + func(_ context.Context, o PartyOptions) (*registry.PartyRef, error) { + order = append(order, "party:"+o.Alias) + return ®istry.PartyRef{Alias: o.Alias, PartyID: o.Alias + "::pid", Role: o.Role}, nil + }, + func(_ io.Writer, o CreateOptions) (*CreateResult, error) { + order = append(order, "create") + return &CreateResult{TokenRef: registry.TokenRef{Symbol: o.Symbol}}, nil + }, + func(_ context.Context, _ io.Writer, _ MintOptions) error { order = append(order, "mint"); return nil }, + func(_ context.Context, _ io.Writer, o FaucetOptions) error { + order = append(order, "faucet:"+o.To) + faucetOpts = o + return nil + }, + ) + + res, err := RunDemo(context.Background(), nil, DemoOptions{ + Instance: "demo", Endpoint: "localhost:5001", Role: "app-user", SeedHolder: true, + }) + if err != nil { + t.Fatalf("RunDemo: %v", err) + } + + // Only allocate-holder + faucet — never create or mint on V1. + if want := []string{"party:demo-holder", "faucet:demo-holder::pid"}; !slices.Equal(order, want) { + t.Fatalf("V1 call order = %v, want %v (no create/mint)", order, want) + } + // Faucet moves Amulet from the role's funded party (app-user), with the + // smaller V1 seed default. + if faucetOpts.Instrument != "Amulet" || faucetOpts.Source != "app-user" || + faucetOpts.To != "demo-holder::pid" || faucetOpts.Amount != "100" { + t.Errorf("V1 faucet opts wrong: %+v", faucetOpts) + } + if res.Token.Symbol != "Amulet" || res.Holder == nil || res.Holder.PartyID != "demo-holder::pid" || !res.Seeded { + t.Errorf("V1 result wrong: %+v", res) + } +} From f479ede7a3458b1e18e1584d4b6803f9bc850557 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:46:42 +0530 Subject: [PATCH 03/14] ui: Carbon Slate palette + console design polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related passes to make the Web UI read as crafted and considered, both theme-aware (dark + light). Palette (Carbon Slate) - Replace the cobalt-on-navy palette, which shared one blue hue family between accent and neutrals and read flat. Carbon Slate uses a near-neutral graphite ramp with a single indigo-violet accent that sits well off the neutral hue, so action separates from chrome. - Every text/background pair verified WCAG AA in both themes (body 15:1, secondary ~8:1, status ~5:1, faint ~3.5:1, white-on-indigo button 5.7:1); the light-mode greens/reds/amber were darkened to clear 4.5, and the focus ring moved to the indigo family. Design polish (theme-agnostic) - One status renderer (StatusBadge): Title-Case label + redundant dot, so status is never color-only. - One ledger-id renderer (MonoId): middle-truncation with click-to-copy and tabular figures, replacing tail-only truncation that hid the discriminating suffix. - In-app ConfirmDialog replaces native confirm() for destructive actions, with the real dpm command shown inline. - Layout-matched Skeleton loaders replace bare "Loading…" text so tables don't pop in and shift. - Replace the coloured left-border selection idiom across the ACS, filter chips, and DAR/token/skill rows with a flat active fill and constant padding, so rows no longer shift on click. - One depth technique per surface (hairline or shadow, not both); radii normalized to 2/4/8; decorative gradients and per-element hover-lifts removed from data views; transition:all scoped; microcopy tightened; error/empty states given cause + action + retry and left-aligned. Verified: tsc clean, 218/218 frontend tests, both themes reviewed live against a running LocalNet across Overview, Explorer, DAR, and Tokens. --- frontend/src/App.tsx | 4 + frontend/src/components/ConfirmDialog.tsx | 145 ++++++ frontend/src/components/MetricCard.tsx | 9 +- frontend/src/components/MonoId.tsx | 79 ++++ frontend/src/components/Skeleton.tsx | 92 ++++ frontend/src/components/StatusBadge.tsx | 112 +++++ frontend/src/index.css | 97 ++-- frontend/src/screens/AgentSkillsScreen.tsx | 10 +- frontend/src/screens/BackupRestore.tsx | 27 +- frontend/src/screens/ContainerHealth.tsx | 26 +- frontend/src/screens/ContainerLogsModal.tsx | 12 +- frontend/src/screens/ContractDetailDrawer.tsx | 96 ++-- frontend/src/screens/CreateLocalNetModal.tsx | 42 +- frontend/src/screens/CreatingPanel.tsx | 52 +-- frontend/src/screens/DARDiff.tsx | 22 +- frontend/src/screens/DARPackageTree.tsx | 75 ++- frontend/src/screens/DARScreen.tsx | 135 +++--- frontend/src/screens/Dashboard.test.tsx | 7 +- frontend/src/screens/Dashboard.tsx | 166 +++---- frontend/src/screens/DeveloperSetup.tsx | 9 +- frontend/src/screens/DoctorScreen.tsx | 95 +++- frontend/src/screens/ExplorerScreen.tsx | 428 ++++++++++++------ frontend/src/screens/InstanceDetail.test.tsx | 25 +- frontend/src/screens/InstanceDetail.tsx | 118 +++-- frontend/src/screens/MetricsScreen.tsx | 46 +- frontend/src/screens/Placeholder.tsx | 19 +- frontend/src/screens/TokensScreen.tsx | 124 +++-- frontend/src/screens/TxReplayDrawer.tsx | 51 +-- frontend/src/screens/WalletScreen.tsx | 16 +- frontend/src/shell/CommandPalette.tsx | 30 +- frontend/src/shell/ErrorBoundary.tsx | 56 ++- frontend/src/shell/Shell.tsx | 40 +- internal/ui/dist/index.html | 4 +- 33 files changed, 1584 insertions(+), 685 deletions(-) create mode 100644 frontend/src/components/ConfirmDialog.tsx create mode 100644 frontend/src/components/MonoId.tsx create mode 100644 frontend/src/components/Skeleton.tsx create mode 100644 frontend/src/components/StatusBadge.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 04a9bcd6..850172f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { SCHEMA_VERSION, fetchVersion } from "./api"; import { Shell } from "./shell/Shell"; import { InstanceSelectionProvider } from "./shell/useInstanceSelection"; import { ErrorBoundary } from "./shell/ErrorBoundary"; +import { ConfirmHost } from "./components/ConfirmDialog"; import { Dashboard } from "./screens/Dashboard"; import { DoctorScreen } from "./screens/DoctorScreen"; import { Placeholder } from "./screens/Placeholder"; @@ -49,6 +50,9 @@ export function App() { + {/* One confirm-dialog host for the whole app; confirmDialog() + from anywhere resolves against it. */} + ); } diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000..02986118 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,145 @@ +// In-app confirm dialog — replaces the browser-native confirm(), which +// can't match the console's typography, can't show container/port +// detail inline, and reads as unfinished. Promise-based so call sites +// stay a one-liner: +// +// if (!(await confirmDialog({ title, body, confirmLabel, danger }))) return; +// +// A single ConfirmHost is mounted once (see App); confirmDialog() +// dispatches an event it listens for, keeping open state in the host. + +import { useEffect, useState } from "react"; +import { W, wMono, wSans, R, EASE, FAST } from "../tokens"; +import { Button } from "./Button"; + +export interface ConfirmOptions { + title: string; + /** Plain-language consequence. Rendered as-is (string). */ + body: string; + /** Optional monospace detail line (the exact command / effect). */ + detail?: string; + confirmLabel?: string; + danger?: boolean; +} + +interface Pending extends ConfirmOptions { + resolve: (ok: boolean) => void; +} + +const EVENT = "cdk-confirm"; + +export function confirmDialog(opts: ConfirmOptions): Promise { + return new Promise((resolve) => { + window.dispatchEvent( + new CustomEvent(EVENT, { detail: { ...opts, resolve } }), + ); + }); +} + +export function ConfirmHost() { + const [p, setP] = useState(null); + + useEffect(() => { + function onReq(e: Event) { + setP((e as CustomEvent).detail); + } + window.addEventListener(EVENT, onReq); + return () => window.removeEventListener(EVENT, onReq); + }, []); + + useEffect(() => { + if (!p) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") settle(false); + else if (e.key === "Enter") settle(true); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [p]); + + if (!p) return null; + function settle(ok: boolean) { + p?.resolve(ok); + setP(null); + } + + return ( +
    settle(false)} + style={{ + position: "fixed", + inset: 0, + zIndex: 200, + background: "color-mix(in srgb, #000 44%, transparent)", + display: "flex", + alignItems: "flex-start", + justifyContent: "center", + paddingTop: "18vh", + fontFamily: wSans, + animation: `cdk-fade ${FAST} ${EASE}`, + }} + > +
    e.stopPropagation()} + style={{ + width: "min(440px, 92vw)", + // One depth technique: hairline border, no competing shadow. + background: W.surface, + border: `1px solid ${W.borderHi}`, + borderRadius: R.dialog, + overflow: "hidden", + }} + > +
    +

    + {p.title} +

    +

    + {p.body} +

    + {p.detail && ( +
    + {p.detail} +
    + )} +
    +
    + + +
    +
    +
    + ); +} diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx index 500f2ce3..cb29510d 100644 --- a/frontend/src/components/MetricCard.tsx +++ b/frontend/src/components/MetricCard.tsx @@ -1,7 +1,7 @@ import type { Point } from "./charts/types"; import { Sparkline } from "./charts/Sparkline"; import { IcArrowUp } from "./icons"; -import { W, wMono, wideCaps } from "../tokens"; +import { W, wMono, wideCaps, R } from "../tokens"; // MetricCard — the 4-up strip at the top of the Metrics screen. // One headline number + a delta vs the prior window + an inline @@ -56,7 +56,7 @@ export function MetricCard({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 14, display: "flex", flexDirection: "column", @@ -87,6 +87,7 @@ export function MetricCard({ style={{ fontFamily: wMono, fontSize: 11, + fontVariantNumeric: "tabular-nums", color: deltaColor, fontWeight: 600, display: "inline-flex", @@ -131,6 +132,7 @@ export function MetricCard({ fontWeight: 600, lineHeight: 1.1, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", }} > — @@ -143,6 +145,7 @@ export function MetricCard({ fontSize: 26, fontWeight: 600, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", lineHeight: 1, }} > @@ -181,7 +184,7 @@ function Skeleton({ width, height, background: W.border, - borderRadius: 2, + borderRadius: R.control, opacity: 0.4, }} /> diff --git a/frontend/src/components/MonoId.tsx b/frontend/src/components/MonoId.tsx new file mode 100644 index 00000000..9176d50c --- /dev/null +++ b/frontend/src/components/MonoId.tsx @@ -0,0 +1,79 @@ +// MonoId — the one way to render a ledger identifier (contract id, +// party id, package id, hash, offset) in this console. +// +// Ledger ids are long and their *suffix* is the discriminating part, +// so tail-only truncation ("00ce960f…") hides exactly what tells two +// ids apart. MonoId middle-truncates (head…tail), keeps the full value +// in the title for hover, and copies it on click — the discipline an +// auditor comparing ids relies on. + +import { useState, type CSSProperties } from "react"; +import { W, wMono } from "../tokens"; + +function truncateMid(s: string, head: number, tail: number): string { + if (s.length <= head + tail + 1) return s; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} + +interface MonoIdProps { + value: string; + /** Leading chars kept. Default 8. */ + head?: number; + /** Trailing chars kept — the discriminating suffix. Default 6. */ + tail?: number; + /** Render in full (no truncation) — for short ids like symbols. */ + full?: boolean; + size?: number; + color?: string; + style?: CSSProperties; +} + +export function MonoId({ + value, + head = 8, + tail = 6, + full = false, + size = 12, + color = W.text2, + style, +}: MonoIdProps) { + const [copied, setCopied] = useState(false); + const shown = full ? value : truncateMid(value, head, tail); + const copy = () => { + // clipboard may be unavailable (http on non-localhost) or denied; + // swallow both the throw and the promise rejection so a failed + // copy is a silent no-op, not an unhandled rejection. + try { + const p = navigator.clipboard?.writeText(value); + if (p) p.catch(() => {}); + setCopied(true); + window.setTimeout(() => setCopied(false), 1100); + } catch { + // no clipboard API at all + } + }; + return ( + + ); +} diff --git a/frontend/src/components/Skeleton.tsx b/frontend/src/components/Skeleton.tsx new file mode 100644 index 00000000..6c729098 --- /dev/null +++ b/frontend/src/components/Skeleton.tsx @@ -0,0 +1,92 @@ +// Skeleton — layout-matched loading placeholders. A dense console +// knows its table shapes ahead of time, so a bare centered "Loading…" +// that pops into a full table causes a jarring layout shift. Skeletons +// mirror the real row height and column rhythm so content arrives in +// place, and a short show-delay avoids a flicker on fast local fetches. + +import { useEffect, useState, type CSSProperties } from "react"; +import { W, R } from "../tokens"; + +// useDelayedFlag returns true only after `ms`, so a fetch that resolves +// in <150ms never flashes a skeleton. +export function useLoadingDelay(active: boolean, ms = 160): boolean { + const [shown, setShown] = useState(false); + useEffect(() => { + if (!active) { + setShown(false); + return; + } + const t = window.setTimeout(() => setShown(true), ms); + return () => window.clearTimeout(t); + }, [active, ms]); + return shown; +} + +export function SkeletonBar({ + width = "100%", + height = 12, + style, +}: { + width?: number | string; + height?: number; + style?: CSSProperties; +}) { + return ( + + ); +} + +// SkeletonTable mirrors a column-based table: pass the same relative +// column widths the real table uses so the skeleton lines up with it. +export function SkeletonTable({ + columns, + rows = 4, + rowHeight = 38, + label = "Loading", +}: { + columns: (number | string)[]; + rows?: number; + rowHeight?: number; + label?: string; +}) { + return ( +
    + {Array.from({ length: rows }).map((_, r) => ( +
    + {columns.map((w, c) => ( +
    + +
    + ))} +
    + ))} +
    + ); +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 00000000..9cc733fb --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,112 @@ +// StatusBadge — the one renderer for instance / container / connection +// status across the console. Before this, the same status datum showed +// up four different ways (a lowercase dot+enum in the table, plain mono +// text in the detail grid, a Title-Case pill in the topbar, a bare +// color-only dot in the ACS). One renderer fixes the inconsistency and +// guarantees color is never the ONLY cue — the label carries the +// meaning for the colorblind / auditor audience. + +import type { CSSProperties } from "react"; +import { W, tint, R } from "../tokens"; +import { Dot } from "./icons"; + +type Tone = "ok" | "warn" | "danger" | "muted"; + +// Canonical status vocabulary. Terse Title-Case labels; unknown values +// fall through to a muted, capitalized rendering rather than breaking. +const MAP: Record = { + running: { label: "Running", tone: "ok" }, + healthy: { label: "Healthy", tone: "ok" }, + ready: { label: "Ready", tone: "ok" }, + stopped: { label: "Stopped", tone: "muted" }, + exited: { label: "Exited", tone: "muted" }, + creating: { label: "Creating", tone: "warn" }, + starting: { label: "Starting", tone: "warn" }, + stopping: { label: "Stopping", tone: "warn" }, + restarting: { label: "Restarting", tone: "warn" }, + partial: { label: "Partial", tone: "warn" }, + paused: { label: "Paused", tone: "warn" }, + stalled: { label: "Stalled", tone: "warn" }, + failed: { label: "Failed", tone: "danger" }, + error: { label: "Error", tone: "danger" }, + dead: { label: "Dead", tone: "danger" }, + // Explorer stream states — the ACS/tx snapshot-vs-live stream. + live: { label: "Live", tone: "ok" }, + reconnecting: { label: "Reconnecting", tone: "warn" }, + truncated: { label: "Truncated", tone: "warn" }, + idle: { label: "Idle", tone: "muted" }, +}; + +function toneColor(tone: Tone): string { + return tone === "ok" + ? W.ok + : tone === "warn" + ? W.warn + : tone === "danger" + ? W.err + : W.dim; +} + +function resolve(status: string): { label: string; color: string } { + const hit = MAP[status.toLowerCase()]; + if (hit) return { label: hit.label, color: toneColor(hit.tone) }; + const label = status.charAt(0).toUpperCase() + status.slice(1); + return { label, color: W.dim }; +} + +interface StatusBadgeProps { + status: string; + /** "text" = dot + colored label (tables, detail rows); + * "pill" = bordered tinted chip (topbar, cards). */ + variant?: "text" | "pill"; + /** Pulse the dot (in-flight states). */ + pulse?: boolean; + style?: CSSProperties; +} + +export function StatusBadge({ + status, + variant = "text", + pulse = false, + style, +}: StatusBadgeProps) { + const { label, color } = resolve(status); + if (variant === "pill") { + return ( + + + {label} + + ); + } + return ( + + + {label} + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index da753071..943b1f21 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -50,7 +50,7 @@ --gray-500: #6c7488; --gray-600: #4d5567; --gray-700: #384050; --gray-800: #232a39; --gray-900: #141a28; --gray-950: #0b0f1a; --blue-50: #eef1fd; --blue-100: #dce3fb; --blue-200: #bcc9f6; - --blue-300: #93a7f0; --blue-400: #6480e6; --blue-500: #3d5bdc; + --blue-300: #93a7f0; --blue-400: #6480e6; --blue-500: #5661db; --blue-600: #2946ce; --blue-700: #2138a8; --blue-800: #1d2f85; --blue-900: #1a2861; --blue-950: #101836; --teal-300: #7bd2c6; --teal-500: #189e8c; @@ -61,47 +61,50 @@ --duration-fast: 120ms; } -/* ---- Dark theme (default) ---- */ +/* ---- Dark theme (default) — Carbon Slate: true-neutral graphite + + one indigo-violet accent. Accent hue sits well off the near-zero-hue + neutrals, so action never collides with chrome. All pairs verified + WCAG AA. ---- */ :root { color-scheme: dark; - --bg-page: #0b0f1a; --bg-sunken: #080c15; --bg-surface: #10151f; - --bg-raised: #161c29; --bg-inset: #0d1220; - --text-primary: #e9ecf4; --text-secondary: #a9b2c6; - --text-muted: #7c8598; --text-faint: #5a6375; - --border-subtle: #171e2c; --border-default: #232b3d; --border-strong: #313b52; - --hover-tint: #171e2c; --active-tint: #1e2637; - --accent: #6480e6; --accent-hover: #7b93ec; --accent-active: #93a7f0; - --accent-subtle: #141c36; --accent-muted: #1c2747; --accent-text: #93a7f0; - --on-accent: #0b0f1a; - --accent-solid: #2e4cd4; --accent-solid-hover: #3b57db; --on-accent-solid: #ffffff; - --link: #8fa3ee; --link-hover: #b3c1f4; - --ok-text: #7cc89a; --ok-bg: #0e1f16; --ok-border: #1e3a2a; - --warn-text: #ddb25e; --warn-bg: #211a0b; --warn-border: #3e3115; - --danger-text: #e08d7d; --danger-bg: #24120e; --danger-border: #45201a; - --danger: #d2604b; --danger-hover: #e08d7d; - --info-text: #a9baf2; --info-bg: #121a33; --info-border: #223059; - --dot-grid: radial-gradient(circle at 1px 1px, #232b3d 1px, transparent 1px); -} - -/* ---- Light theme ---- */ + --bg-page: #0f1012; --bg-sunken: #0b0c0e; --bg-surface: #16171a; + --bg-raised: #1e1f23; --bg-inset: #101113; + --text-primary: #e4e5e8; --text-secondary: #aeb1b8; + --text-muted: #75787f; --text-faint: #6e7178; + --border-subtle: #1e1f23; --border-default: #2a2c31; --border-strong: #3a3d44; + --hover-tint: #1a1b1f; --active-tint: #212227; + --accent: #8b93f2; --accent-hover: #9aa1f5; --accent-active: #a6acf6; + --accent-subtle: #1b1d2e; --accent-muted: #23263c; --accent-text: #a6acf6; + --on-accent: #12121a; + --accent-solid: #4e57d6; --accent-solid-hover: #5a62de; --on-accent-solid: #ffffff; + --link: #a6acf6; --link-hover: #c0c4f9; + --ok-text: #5bc98c; --ok-bg: #0f2118; --ok-border: #1e3a2a; + --warn-text: #e8b24c; --warn-bg: #221a0b; --warn-border: #3e3115; + --danger-text: #f07b72; --danger-bg: #241210; --danger-border: #45201a; + --danger: #e5604f; --danger-hover: #f07b72; + --info-text: #a9baf2; --info-bg: #16182b; --info-border: #2a2e48; + --dot-grid: radial-gradient(circle at 1px 1px, #2a2c31 1px, transparent 1px); +} + +/* ---- Light theme — Carbon Slate ---- */ :root[data-theme="light"] { color-scheme: light; - --bg-page: #ffffff; --bg-sunken: #f7f8fa; --bg-surface: #ffffff; - --bg-raised: #ffffff; --bg-inset: #eff1f5; - --text-primary: #141a28; --text-secondary: #4d5567; - --text-muted: #6c7488; --text-faint: #9ba3b5; - --border-subtle: #eff1f5; --border-default: #e2e5ec; --border-strong: #cdd2dd; - --hover-tint: #f7f8fa; --active-tint: #eff1f5; - --accent: #2946ce; --accent-hover: #2138a8; --accent-active: #1d2f85; - --accent-subtle: #eef1fd; --accent-muted: #dce3fb; --accent-text: #2138a8; + --bg-page: #fbfbfc; --bg-sunken: #f7f7f8; --bg-surface: #ffffff; + --bg-raised: #f4f5f6; --bg-inset: #eeeff1; + --text-primary: #1b1c1f; --text-secondary: #54575e; + --text-muted: #82868e; --text-faint: #8a8d95; + --border-subtle: #eeeff1; --border-default: #e1e3e6; --border-strong: #c6c9ce; + --hover-tint: #f4f5f6; --active-tint: #eeeff1; + --accent: #4a52c9; --accent-hover: #3d45be; --accent-active: #333ba8; + --accent-subtle: #eef0fd; --accent-muted: #dde0fa; --accent-text: #3d45be; --on-accent: #ffffff; - --accent-solid: #2946ce; --accent-solid-hover: #2138a8; --on-accent-solid: #ffffff; - --link: #2946ce; --link-hover: #1d2f85; - --ok-text: #18653b; --ok-bg: #edf7f0; --ok-border: #bce0c9; - --warn-text: #85560e; --warn-bg: #fcf5e8; --warn-border: #ebd9a9; - --danger-text: #962e20; --danger-bg: #fbefed; --danger-border: #efc5bd; - --danger: #ba3a29; --danger-hover: #962e20; - --info-text: #1d2f85; --info-bg: #eef1fd; --info-border: #bcc9f6; + --accent-solid: #4a52c9; --accent-solid-hover: #3d45be; --on-accent-solid: #ffffff; + --link: #4a52c9; --link-hover: #333ba8; + --ok-text: #157c45; --ok-bg: #edf7f0; --ok-border: #bce0c9; + --warn-text: #8a6410; --warn-bg: #fcf5e8; --warn-border: #ebd9a9; + --danger-text: #b93a2e; --danger-bg: #fbefed; --danger-border: #efc5bd; + --danger: #b93a2e; --danger-hover: #962e20; + --info-text: #3d45be; --info-bg: #eef0fd; --info-border: #bcc9f6; --dot-grid: radial-gradient(circle at 1px 1px, #e2e5ec 1px, transparent 1px); } @@ -343,6 +346,19 @@ a { 50% { opacity: 0.35; } } +/* Modal/overlay entrance (ConfirmDialog). */ +@keyframes cdk-fade { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} + +/* Skeleton shimmer (components/Skeleton). A slow sweep, gated below by + prefers-reduced-motion. */ +@keyframes cdk-shimmer { + 0% { background-position: -180% 0; } + 100% { background-position: 180% 0; } +} + /* Respect prefers-reduced-motion — the pulse is ambient and not load-bearing for the state communication (color carries the signal too). */ @@ -350,4 +366,11 @@ a { @keyframes pulse { 0%, 100% { opacity: 1; } } + @keyframes cdk-shimmer { + 0%, 100% { background-position: 0 0; } + } + @keyframes cdk-fade { + from { opacity: 1; } + to { opacity: 1; } + } } diff --git a/frontend/src/screens/AgentSkillsScreen.tsx b/frontend/src/screens/AgentSkillsScreen.tsx index 586b952f..75f62509 100644 --- a/frontend/src/screens/AgentSkillsScreen.tsx +++ b/frontend/src/screens/AgentSkillsScreen.tsx @@ -5,7 +5,7 @@ import { installSkills, type Skill, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, FAST } from "../tokens"; import { Button } from "../components/Button"; import { IcAlert, IcCheck, IcX } from "../components/icons"; @@ -210,11 +210,11 @@ export function AgentSkillsScreen() { width: "100%", textAlign: "left", padding: "10px 14px", - background: isActive ? W.surface2 : "transparent", + background: isActive ? tint(W.brand, 12) : "transparent", border: "none", - borderLeft: `2px solid ${isActive ? W.brand : "transparent"}`, cursor: "pointer", color: isActive ? W.text : W.text2, + transition: `background-color ${FAST}`, }} >
    {s.name}
    @@ -278,8 +278,8 @@ function Header() {
Safe `dpm localnet` workflows for AI agents. Same docs as the CLI - `localnet skills` command — install into your agent and let it - drive DevKit. + `localnet skills` command. Install into your agent and let it drive + DevKit.
); diff --git a/frontend/src/screens/BackupRestore.tsx b/frontend/src/screens/BackupRestore.tsx index 0da811a2..9a8a57a0 100644 --- a/frontend/src/screens/BackupRestore.tsx +++ b/frontend/src/screens/BackupRestore.tsx @@ -5,7 +5,7 @@ import { restoreSnapshot, type RestoreResponse, } from "../api"; -import { W, wMono, tint } from "../tokens"; +import { W, wMono, tint, R, FAST } from "../tokens"; import { Button } from "../components/Button"; import { IcCheck, IcDownload } from "../components/icons"; @@ -106,11 +106,12 @@ export function BackupRestore({ instanceName }: Props) { return (
@@ -157,7 +158,7 @@ export function BackupRestore({ instanceName }: Props) { marginTop: 10, background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, - borderRadius: 2, + borderRadius: R.control, padding: "8px 12px", fontSize: 12, color: W.err, @@ -196,15 +197,15 @@ export function BackupRestore({ instanceName }: Props) { tabIndex={0} aria-label="Drop snapshot file here or click to choose" style={{ - border: `1.5px dashed ${dragOver ? W.brand : W.border}`, + border: `1px dashed ${dragOver ? W.brand : W.border}`, background: dragOver ? `${tint(W.brand, 6)}` : "transparent", - borderRadius: 4, + borderRadius: R.control, padding: "14px 16px", cursor: "pointer", color: W.dim, fontSize: 12.5, textAlign: "center", - transition: "all 0.12s", + transition: `background-color ${FAST}, border-color ${FAST}`, }} > {restore.kind === "uploading" ? ( @@ -255,7 +256,7 @@ export function BackupRestore({ instanceName }: Props) { background: "transparent", color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "2px 6px", fontSize: 12, fontFamily: wMono, @@ -293,7 +294,7 @@ export function BackupRestore({ instanceName }: Props) { marginTop: 10, background: `${tint(W.brand, 6)}`, border: `1px solid ${W.brand}`, - borderRadius: 2, + borderRadius: R.control, padding: "8px 12px", fontSize: 12, color: W.text2, @@ -321,7 +322,7 @@ export function BackupRestore({ instanceName }: Props) { marginTop: 10, background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, - borderRadius: 2, + borderRadius: R.control, padding: "8px 12px", fontSize: 12, color: W.err, @@ -352,7 +353,7 @@ function UploadProgress({ style={{ height: 6, background: W.border, - borderRadius: 2, + borderRadius: R.control, overflow: "hidden", }} > diff --git a/frontend/src/screens/ContainerHealth.tsx b/frontend/src/screens/ContainerHealth.tsx index a76c1ec5..02eaf562 100644 --- a/frontend/src/screens/ContainerHealth.tsx +++ b/frontend/src/screens/ContainerHealth.tsx @@ -5,9 +5,10 @@ import { fetchContainers, restartContainer, } from "../api"; -import { W, wMono, tableCaps, tint } from "../tokens"; +import { W, wMono, tableCaps, tint, R } from "../tokens"; import { Button } from "../components/Button"; import { Dot, IcRefresh } from "../components/icons"; +import { confirmDialog } from "../components/ConfirmDialog"; import { ContainerLogsModal } from "./ContainerLogsModal"; // ContainerHealth — live per-container status panel. Polls @@ -35,7 +36,15 @@ export function ContainerHealth({ name }: { name: string }) { const [restartErr, setRestartErr] = useState(null); async function onRestart(container: string) { - if (!confirm(`Restart ${container}? Container will be stopped + started; in-flight requests may drop.`)) { + if ( + !(await confirmDialog({ + title: "Restart container?", + body: `Stops then starts ${container}. In-flight requests to it may drop.`, + detail: `docker restart ${container}`, + confirmLabel: "Restart", + danger: true, + })) + ) { return; } setRestarting((s) => new Set([...s, container])); @@ -98,7 +107,7 @@ export function ContainerHealth({ name }: { name: string }) { marginTop: 16, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 14, }} > @@ -133,7 +142,7 @@ export function ContainerHealth({ name }: { name: string }) { color: W.err, background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, - borderRadius: 2, + borderRadius: R.control, padding: "6px 10px", fontSize: 12, }} @@ -149,7 +158,7 @@ export function ContainerHealth({ name }: { name: string }) { color: W.err, background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, - borderRadius: 2, + borderRadius: R.control, padding: "6px 10px", fontSize: 12, marginBottom: 8, @@ -307,12 +316,13 @@ function SummaryPills({ counts }: { counts: ContainersResponse }) { key={label} style={{ padding: "2px 8px", - borderRadius: 2, - border: `1px solid ${color}`, - background: `${color}1A`, + borderRadius: R.control, + border: `1px solid ${tint(color, 34)}`, + background: tint(color, 13), color, fontSize: 10.5, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", }} > {n} {label} diff --git a/frontend/src/screens/ContainerLogsModal.tsx b/frontend/src/screens/ContainerLogsModal.tsx index 10985e57..b2debd83 100644 --- a/frontend/src/screens/ContainerLogsModal.tsx +++ b/frontend/src/screens/ContainerLogsModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { ApiError, fetchContainerLogs } from "../api"; -import { W, wMono, wSans, tint } from "../tokens"; +import { W, wMono, wSans, tint, R } from "../tokens"; import { Button } from "../components/Button"; import { IcX } from "../components/icons"; @@ -230,9 +230,11 @@ const modalStyle: React.CSSProperties = { width: "min(900px, 95vw)", height: "min(700px, 88vh)", background: W.surface, - border: `1px solid ${W.border}`, - borderRadius: 4, - boxShadow: "0 24px 64px rgba(0,0,0,0.6)", + // One depth technique: a slightly stronger hairline, no competing + // box-shadow — matches the ConfirmDialog treatment so every overlay + // in the console separates from the page the same way. + border: `1px solid ${W.borderHi}`, + borderRadius: R.card, display: "flex", flexDirection: "column", overflow: "hidden", @@ -250,7 +252,7 @@ const selectStyle: React.CSSProperties = { background: W.bg, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "2px 6px", fontSize: 11, fontFamily: wMono, diff --git a/frontend/src/screens/ContractDetailDrawer.tsx b/frontend/src/screens/ContractDetailDrawer.tsx index 56c20e85..fecafe85 100644 --- a/frontend/src/screens/ContractDetailDrawer.tsx +++ b/frontend/src/screens/ContractDetailDrawer.tsx @@ -6,8 +6,9 @@ import { type ContractRow, type Role, } from "../api"; -import { W, wMono, wideCaps } from "../tokens"; +import { W, wMono, wideCaps, tint, R } from "../tokens"; import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; import { IcX } from "../components/icons"; // ContractDetailDrawer is a true right-side overlay: position-fixed @@ -49,7 +50,6 @@ export function ContractDetailDrawer({ | { kind: "ok"; detail: ContractDetail } | { kind: "err"; message: string } >({ kind: "loading" }); - const [copied, setCopied] = useState(false); useEffect(() => { let cancelled = false; @@ -116,16 +116,6 @@ export function ContractDetailDrawer({ archived: false, }; - const copyCid = async () => { - try { - await navigator.clipboard.writeText(detail.contract_id); - setCopied(true); - setTimeout(() => setCopied(false), 1100); - } catch { - // clipboard may be unavailable (http on non-localhost); silent - } - }; - return (
@@ -291,23 +277,32 @@ export function ContractDetailDrawer({
)} {detail.archived_offset !== undefined && ( -
+
offset {detail.archived_offset.toLocaleString()}
)} {detail.archived_update_id && ( - tx · {detail.archived_update_id.slice(0, 16)}… + tx · {truncMid(detail.archived_update_id)} )} @@ -338,6 +333,13 @@ function shortTemplateLabel(tpl: string | undefined): string { return parts.length >= 3 ? `${parts[1]}:${parts[2]}` : tpl; } +// Middle-truncate an id for a link label — the suffix is the +// discriminating part, so keep both ends (matches MonoId's discipline). +function truncMid(s: string, head = 8, tail = 6): string { + if (s.length <= head + tail + 1) return s; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} + function Section({ label, children, @@ -372,11 +374,11 @@ function Pill({ return ( - - {party} - +
); } @@ -495,7 +488,8 @@ function primStyle(kind: "text" | "num" | "dim"): React.CSSProperties { return { fontFamily: wMono, fontSize: 11, - color: kind === "dim" ? W.dim : kind === "num" ? "#DDB25E" : W.text2, - wordBreak: "break-all", + color: kind === "dim" ? W.dim : kind === "num" ? W.warn : W.text2, + fontVariantNumeric: kind === "num" ? "tabular-nums" : undefined, + wordBreak: "break-word", }; } diff --git a/frontend/src/screens/CreateLocalNetModal.tsx b/frontend/src/screens/CreateLocalNetModal.tsx index e116a5f9..30c30c17 100644 --- a/frontend/src/screens/CreateLocalNetModal.tsx +++ b/frontend/src/screens/CreateLocalNetModal.tsx @@ -13,7 +13,7 @@ import { type PreflightReport, type SpliceVersionEntry, } from "../api"; -import { W, wMono, wSans, tint } from "../tokens"; +import { W, wMono, wSans, tint, R } from "../tokens"; import { Button } from "../components/Button"; import { Dot, IcAlert, IcCheck, IcStop, IcX } from "../components/icons"; import { remediationForCode } from "./remediation"; @@ -677,7 +677,7 @@ function FormBody({ Injects the alpha-protocol Canton config for CIP-0112 token flows. Requires a V2-capable Splice version (e.g.{" "} token-standard-v2). The - instance will settle at status partial — the V2 + instance settles at status partial. The V2 splice healthcheck never reports healthy, but token flows work. Equivalent to{" "} @@ -698,7 +698,7 @@ function FormBody({ padding: "6px 0", }} > - Advanced — uncurated versions + Advanced · uncurated versions
@@ -745,12 +745,13 @@ function FormBody({ style={{ width: 88, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", fontSize: 12, padding: "4px 6px", background: W.surface, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, }} />
@@ -996,11 +997,10 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) { style={{ marginTop: 8, padding: "8px 10px", - background: W.surface2, - borderRadius: 2, + background: tint(W.warn, 8), + borderRadius: R.control, color: W.text2, fontSize: 11.5, - borderLeft: `3px solid ${W.warn}`, }} > {remediation.title} @@ -1027,7 +1027,7 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) { }} > - cancelled{banner.reason ? ` — ${banner.reason}` : ""} + Cancelled{banner.reason ? `. ${banner.reason}` : ""}
); @@ -1060,7 +1060,7 @@ function StepRow({ label, state }: { label: string; state: StepState }) { display: "flex", gap: 10, padding: "6px 4px", - borderBottom: `1px dashed ${W.border}`, + borderBottom: `1px solid ${W.border}`, fontSize: 12.5, }} > @@ -1144,7 +1144,7 @@ export function VersionPicker({ // endless "Loading…". let placeholder = "No curated versions available"; if (loading) placeholder = "Loading curated versions…"; - else if (error) placeholder = `Couldn't load versions — ${error}`; + else if (error) placeholder = `Couldn't load versions: ${error}`; return ( @@ -1250,10 +1250,11 @@ const selectStyle: React.CSSProperties = { background: W.bg, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "7px 10px", fontSize: 13, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", outline: "none", cursor: "pointer", appearance: "auto", @@ -1323,7 +1324,7 @@ function PreflightPanel({ state }: { state: PreflightState }) { const heading = blocked ? "Host doesn't meet this version's requirements" : warns.length > 0 - ? "Host meets minimums — but raise resources for headroom" + ? "Host meets minimums. Raise resources for headroom." : "Host is ready for this version"; if (!blocked && warns.length === 0) { // Compact success pill — don't clutter the form. @@ -1350,9 +1351,9 @@ function PreflightPanel({ state }: { state: PreflightState }) { role={blocked ? "alert" : undefined} style={{ padding: "10px 12px", - background: `${accent}10`, + background: tint(accent, 6), border: `1px solid ${accent}`, - borderRadius: 4, + borderRadius: R.control, fontSize: 12, }} > @@ -1501,9 +1502,11 @@ const overlayStyle: React.CSSProperties = { const modalStyle: React.CSSProperties = { width: "min(680px, 92vw)", background: W.surface, + // Overlay depth matched to the confirm dialog + palette: hairline + // border + one subtle shadow, not a hard border AND a heavy shadow. border: `1px solid ${W.border}`, - borderRadius: 8, - boxShadow: "0 24px 64px rgba(0,0,0,0.6)", + borderRadius: R.dialog, + boxShadow: "0 10px 32px rgba(0,0,0,0.24)", overflow: "hidden", }; @@ -1512,9 +1515,10 @@ const inputStyle: React.CSSProperties = { background: W.bg, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "7px 10px", fontSize: 13, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", outline: "none", }; diff --git a/frontend/src/screens/CreatingPanel.tsx b/frontend/src/screens/CreatingPanel.tsx index 271a84a9..83cc179a 100644 --- a/frontend/src/screens/CreatingPanel.tsx +++ b/frontend/src/screens/CreatingPanel.tsx @@ -6,9 +6,10 @@ import { scrubInstance, type StepName, } from "../api"; -import { W, wMono, tint } from "../tokens"; +import { W, wMono, tint, R } from "../tokens"; import { Button } from "../components/Button"; import { Dot, IcAlert, IcCheck, IcRefresh, IcX } from "../components/icons"; +import { StatusBadge } from "../components/StatusBadge"; import { type ProgressState, type StepState, @@ -92,7 +93,7 @@ export function CreatingPanel({ name, onRefresh }: Props) { marginTop: 24, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 16, }} > @@ -128,7 +129,7 @@ export function CreatingPanel({ name, onRefresh }: Props) { background: `${tint(W.warn, 10)}`, border: `1px solid ${tint(W.warn, 27)}`, color: W.warn, - borderRadius: 2, + borderRadius: R.control, padding: "6px 10px", fontSize: 11.5, marginBottom: 4, @@ -170,7 +171,7 @@ export function CreatingPanel({ name, onRefresh }: Props) { margin: "8px 0 0", background: W.bg, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "10px 12px", fontFamily: wMono, fontSize: 10.5, @@ -240,7 +241,7 @@ function StepRow({ label, state }: { label: string; state: StepState }) { display: "flex", gap: 10, padding: "6px 4px", - borderBottom: `1px dashed ${W.border}`, + borderBottom: `1px solid ${W.border}`, fontSize: 12.5, }} > @@ -277,7 +278,7 @@ function StepRow({ label, state }: { label: string; state: StepState }) { marginTop: 4, height: 4, background: W.surface2, - borderRadius: 2, + borderRadius: R.control, overflow: "hidden", }} > @@ -303,42 +304,24 @@ function BannerPill({ banner: ProgressState["banner"]; zombie: boolean; }) { + // Route the bring-up banner through the shared StatusBadge so the + // creating panel reads the same as every other status in the console. if (zombie) { - return looks stalled; + return ; } switch (banner.kind) { case "done": - return ready; + return ; case "failed": - return failed; + return ; case "cancelled": - return cancelled; + return ; default: - return streaming; + // Live SSE stream: pulse the dot to signal in-progress. + return ; } } -function Pill({ color, children }: { color: string; children: React.ReactNode }) { - return ( - - {children} - - ); -} - function ZombieHint({ name, onScrub, @@ -350,10 +333,11 @@ function ZombieHint({ }) { return (
    -
  • The bring-up finished after the page loaded — refresh to pick up the new state.
  • +
  • The bring-up finished after the page loaded. Refresh to pick up the new state.
  • The server was restarted mid-bring-up, orphaning the entry. Click Remove entry to scrub it from the diff --git a/frontend/src/screens/DARDiff.tsx b/frontend/src/screens/DARDiff.tsx index 05ee5a26..d31bf973 100644 --- a/frontend/src/screens/DARDiff.tsx +++ b/frontend/src/screens/DARDiff.tsx @@ -10,7 +10,8 @@ import { type DARDiffResponse, type Role, } from "../api"; -import { W, wMono, tint } from "../tokens"; +import { W, wMono, tableCaps, R, tint } from "../tokens"; +import { MonoId } from "../components/MonoId"; import { IcArrowRight, IcChevronDown, @@ -201,7 +202,7 @@ export function DARDiff({ instance, a, b, role }: Props) { const paneStyle: React.CSSProperties = { background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 12, fontSize: 12, maxHeight: "60vh", @@ -229,14 +230,14 @@ function Side({ ); } return ( - - {label}: + + {label}: {side.name}@{side.version} - - {side.main.slice(0, 8)}… - + ); } @@ -246,7 +247,7 @@ type Tone = "add" | "rm" | "chg" | "info"; function toneColour(t: Tone): { bg: string; fg: string } { switch (t) { case "add": - return { bg: "#7CC89A22", fg: "#7CC89A" }; + return { bg: tint(W.ok, 13), fg: W.ok }; case "rm": return { bg: `${tint(W.err, 13)}`, fg: W.err }; case "chg": @@ -281,10 +282,9 @@ function Section({ border: "none", color: c.fg, fontSize: 11.5, - fontWeight: 600, cursor: "pointer", padding: "2px 0", - letterSpacing: 0.6, + ...tableCaps, display: "inline-flex", alignItems: "center", gap: 6, @@ -337,7 +337,7 @@ function ChipGroup({ key={l} style={{ padding: "0 5px", - borderRadius: 2, + borderRadius: R.control, background: c.bg, color: c.fg, fontSize: 10.5, diff --git a/frontend/src/screens/DARPackageTree.tsx b/frontend/src/screens/DARPackageTree.tsx index 18942b05..a6c966bf 100644 --- a/frontend/src/screens/DARPackageTree.tsx +++ b/frontend/src/screens/DARPackageTree.tsx @@ -11,9 +11,18 @@ import { type DARPackageInspect, type Role, } from "../api"; -import { W, wMono, tint } from "../tokens"; +import { W, wMono, R, tint } from "../tokens"; +import { MonoId } from "../components/MonoId"; import { IcChevronDown, IcChevronRight } from "../components/icons"; +// Middle-truncate for ids rendered INSIDE a toggle button, where a +// full MonoId (itself a button) would nest interactive elements. Keeps +// the discriminating suffix visible instead of a tail-only slice. +function midId(s: string, head = 10, tail = 6): string { + if (s.length <= head + tail + 1) return s; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} + interface Props { instance: string; mainID: string; @@ -85,12 +94,21 @@ export function DARPackageTree({ instance, mainID, role }: Props) { return (
    -
    - {state.data.packages.length} package - {state.data.packages.length === 1 ? "" : "s"} · sha256{" "} - - {state.data.sha256.slice(0, 12)}… - +
    + + {state.data.packages.length} package + {state.data.packages.length === 1 ? "" : "s"} · sha256 + +
    {state.data.packages.map((pkg) => ( )} - - {pkg.name || pkg.package_id.slice(0, 12)} + + {pkg.name || midId(pkg.package_id)} {pkg.version && ( - + {pkg.version} )} - - {pkg.lf_version} · {pkg.package_id.slice(0, 10)}… + + {pkg.lf_version} · {midId(pkg.package_id)} {expanded && @@ -228,7 +265,7 @@ function ModuleNode({
    {(mod.templates ?? []).map((t) => (
    - template{" "} + template{" "} {t.name} {t.choices && t.choices.length > 0 && ( @@ -241,7 +278,7 @@ function ModuleNode({ ))} {(mod.interfaces ?? []).map((i) => (
    - interface{" "} + interface{" "} {i.name} {i.choices && i.choices.length > 0 && ( @@ -261,7 +298,7 @@ function ModuleNode({ ))} {(mod.data_types ?? []).map((dt) => (
    - data{" "} + data{" "} {dt}
    ))} @@ -301,7 +338,9 @@ function Chip({ kind: "choice" | "method"; }) { const tone = - kind === "choice" ? { bg: `${tint(W.brand, 10)}`, fg: W.brand } : { bg: "#8FA3EE22", fg: "#8FA3EE" }; + kind === "choice" + ? { bg: tint(W.brand, 10), fg: W.brand } + : { bg: tint(W.mag, 13), fg: W.mag }; return ( - {state.kind === "loading" && Loading DAR list…} + {state.kind === "loading" && } {state.kind === "err" && } {state.kind === "port-missing" && ( {upload.kind === "uploading" ? ( @@ -359,7 +359,7 @@ export function DARScreen() { Drop DAR here
    - or click to browse · multi-file ok + or click to browse · multiple .dar accepted
    )} @@ -468,7 +468,7 @@ export function DARScreen() { >
    Packages on {role} participant
    @@ -598,14 +598,18 @@ function WatchModeCard({ instance }: { instance: string }) {
    + {active ? "Watching" : "Idle"} {last && ( @@ -676,11 +680,10 @@ function PkgRow({ gap: 14, padding: "10px 14px", alignItems: "center", - background: active ? `${tint(W.brand, 6)}` : "transparent", - borderLeft: active ? `2px solid ${W.brand}` : "2px solid transparent", - paddingLeft: active ? 12 : 14, + background: active ? tint(W.brand, 12) : "transparent", borderBottom: `1px solid ${W.border}`, cursor: "pointer", + transition: "background-color 120ms", }} > {row.name} - - {row.version} - - {row.main.slice(0, 12)}…{row.main.slice(-6)} + {row.version} +
    ); @@ -751,7 +749,7 @@ function VettingCell({ vet }: { vet: VetState | undefined }) { {vet.rows.map((r) => { const abbr = r.role === "app-user" ? "U" : r.role === "app-provider" ? "P" : "S"; - const color = r.error ? W.warn : r.vetted ? "#7CC89A" : W.dim; + const color = r.error ? W.warn : r.vetted ? W.ok : W.dim; const title = r.error ? `${r.role}: ${r.error}` : `${r.role}: ${r.vetted ? "vetted" : "not vetted"}`; @@ -794,14 +792,16 @@ function InspectDrawer({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, - padding: 32, - textAlign: "center", + borderRadius: R.card, + padding: 14, + textAlign: "left", color: W.dim, fontSize: 13, + lineHeight: 1.5, }} > - Select a package to inspect. + Select a package to inspect its tree, per-participant vetting, and + structural diff.
    ); } @@ -816,7 +816,7 @@ function InspectDrawer({ >
    - + {row.name} @@ -830,7 +830,18 @@ function InspectDrawer({ )}
    - +
    + pkg-id + +
    {row.description && ( @@ -1014,14 +1025,14 @@ function VettingPanel({ border: "none", padding: 0, cursor: pending === r.role ? "wait" : "pointer", - color: r.vetted ? "#7CC89A" : W.dim, + color: r.vetted ? W.ok : W.dim, }} > onChange(r)} style={{ - background: active ? W.surface : "transparent", - color: active ? W.text : W.dim, + background: active ? tint(W.brand, 16) : "transparent", + color: active ? W.brand : W.dim, border: "none", - borderRadius: 2, + borderRadius: R.control, padding: "5px 12px", fontSize: 12, fontFamily: wMono, fontWeight: active ? 600 : 500, cursor: active ? "default" : "pointer", - boxShadow: active ? `0 0 0 1px ${W.brand}` : "none", + transition: "background-color 120ms", }} > {r} @@ -1383,8 +1394,8 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
    {children} @@ -1418,7 +1429,8 @@ function KV({ color: color ?? W.text2, fontSize: mono ? 11 : 12, fontFamily: mono ? wMono : undefined, - wordBreak: "break-all", + fontVariantNumeric: mono ? "tabular-nums" : undefined, + wordBreak: "break-word", }} > {value} @@ -1451,19 +1463,40 @@ function Row({ ); } -function Status({ children }: { children: React.ReactNode }) { +// DARListLoading is the middle package-list skeleton: same four-column +// rhythm as the real list, so rows arrive in place instead of popping +// in after a bare "Loading…". Gated so a fast local fetch never flashes. +function DARListLoading() { + const show = useLoadingDelay(true); return (
    - {children} +
    + Loading package list +
    + {show ? ( + + ) : ( +
    + )}
    ); } diff --git a/frontend/src/screens/Dashboard.test.tsx b/frontend/src/screens/Dashboard.test.tsx index 5066d2d0..9d9870bd 100644 --- a/frontend/src/screens/Dashboard.test.tsx +++ b/frontend/src/screens/Dashboard.test.tsx @@ -186,10 +186,11 @@ describe("Dashboard", () => { expect(within(table).getByText("demo")).toBeInTheDocument(); expect(within(table).getByText("hubble")).toBeInTheDocument(); }); - // STATE badges within the table. + // State badges within the table — StatusBadge renders Title-Case + // labels so the dot is never the only cue. const table = screen.getByRole("table"); - expect(within(table).getByText("running")).toBeInTheDocument(); - expect(within(table).getByText("stopped")).toBeInTheDocument(); + expect(within(table).getByText("Running")).toBeInTheDocument(); + expect(within(table).getByText("Stopped")).toBeInTheDocument(); }); it("renders the EmptyState when no instances are registered", async () => { diff --git a/frontend/src/screens/Dashboard.tsx b/frontend/src/screens/Dashboard.tsx index 0d0d4368..74f259ab 100644 --- a/frontend/src/screens/Dashboard.tsx +++ b/frontend/src/screens/Dashboard.tsx @@ -6,9 +6,12 @@ import { type TransactionEvent, type TransactionRow, } from "../api"; -import { W, wMono, tableCaps, tint } from "../tokens"; +import { W, wMono, tableCaps, tint, R } from "../tokens"; import { Button } from "../components/Button"; -import { Dot, IcPlus, IcRefresh } from "../components/icons"; +import { IcPlus, IcRefresh } from "../components/icons"; +import { StatusBadge } from "../components/StatusBadge"; +import { MonoId } from "../components/MonoId"; +import { SkeletonTable, useLoadingDelay } from "../components/Skeleton"; import { useInstanceSelection } from "../shell/useInstanceSelection"; import { ContainerHealth } from "./ContainerHealth"; import { CreateLocalNetModal } from "./CreateLocalNetModal"; @@ -26,6 +29,8 @@ import { InstanceDetail } from "./InstanceDetail"; export function Dashboard() { const sel = useInstanceSelection(); const [createOpen, setCreateOpen] = useState(false); + // Gate the skeleton so a fast local fetch never flashes it. + const showSkeleton = useLoadingDelay(sel.loading); return (
    @@ -67,7 +72,7 @@ export function Dashboard() { )} /> - {sel.loading &&

    Loading…

    } + {sel.loading && showSkeleton && } {sel.error && } @@ -80,13 +85,13 @@ export function Dashboard() { background: `${tint(W.dim, 10)}`, border: `1px solid ${W.dim}`, color: W.dim, - borderRadius: 4, + borderRadius: R.control, padding: "6px 12px", marginBottom: 12, fontSize: 12, }} > - Couldn’t refresh — showing last known state. + Couldn’t refresh. Showing last known state.
    )} {sel.warning && ( @@ -95,7 +100,7 @@ export function Dashboard() { background: `${tint(W.warn, 10)}`, border: `1px solid ${W.warn}`, color: W.warn, - borderRadius: 4, + borderRadius: R.control, padding: "8px 12px", marginBottom: 16, fontSize: 13, @@ -156,7 +161,7 @@ function InstanceTable({ instances, selected, onSelect }: InstanceTableProps) { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, overflow: "hidden", }} > @@ -169,47 +174,65 @@ function InstanceTable({ instances, selected, onSelect }: InstanceTableProps) { > - NAME - STATE - SPLICE - PORTS + Name + State + Splice + Ports - {instances.map((i) => ( - onSelect(i.name)} - style={{ - borderTop: `1px solid ${W.border}`, - background: i.name === selected ? W.surface2 : undefined, - cursor: "pointer", - }} - > - - - {i.name} - - - - - - {i.splice_version} - - {i.ports} - - - ))} + {instances.map((i) => { + const isSel = i.name === selected; + return ( + onSelect(i.name)} + style={{ + borderTop: `1px solid ${W.border}`, + // Flat active fill — no accent side-bar, no padding + // swap, so the row never shifts on selection. + background: isSel ? W.selRow : undefined, + cursor: "pointer", + }} + > + + + {i.name} + + + + + + + {i.splice_version} + + {i.ports} + + ); + })}
    ); } +// Table loading placeholder — mirrors the four-column instance table so +// rows arrive in place instead of jumping in after a spinner. +function InstanceTableLoading() { + return ( +
    + +
    + ); +} + const th: React.CSSProperties = { ...tableCaps, padding: "8px 12px", @@ -221,31 +244,13 @@ const td: React.CSSProperties = { verticalAlign: "middle", }; -function StatusBadge({ status }: { status: string }) { - const color = (() => { - switch (status) { - case "running": - return W.ok; - case "creating": - case "stopping": - case "partial": - return W.warn; - case "failed": - return W.err; - case "stopped": - default: - return W.dim; - } - })(); - return ( - - - {status} - - ); -} +// Numeric / mono columns (ports, versions): right-aligned, tabular so +// digits line up column-to-column. +const numCell: React.CSSProperties = { + textAlign: "right", + fontFamily: wMono, + fontVariantNumeric: "tabular-nums", +}; function EmptyState({ onCreate }: { onCreate: () => void }) { return ( @@ -253,10 +258,9 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, - padding: 32, + borderRadius: R.card, + padding: 16, color: W.dim, - textAlign: "center", }} >

    @@ -273,7 +277,9 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {

    Or run{" "} - dpm localnet up --name demo{" "} + + dpm localnet up --name demo + {" "} in your terminal.

    @@ -283,10 +289,11 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { function ErrorPanel({ error }: { error: string }) { return (
    -

    Recent activity

    +

    Recent activity

    ledger events · as seen by the app-provider participant @@ -391,15 +398,15 @@ function RecentActivity({ name }: { name: string }) { )} {state.kind === "needs-jwt" && (
    - Ledger activity needs a party-rights JWT — Splice LocalNet signs user-id tokens by + Ledger activity needs a party-rights JWT. Splice LocalNet signs user-id tokens by default. Open the Explorer to project through a specific party.
    )} {state.kind === "err" && (
    {/no jwt recorded/i.test(state.error) - ? "Ledger activity needs recorded role JWTs — restart the instance to capture them (older instances predate JWT capture)." - : `Ledger activity unavailable — ${state.error}.`}{" "} + ? "Ledger activity needs recorded role JWTs. Restart the instance to capture them (older instances predate JWT capture)." + : `Ledger activity unavailable. ${state.error}.`}{" "} Open the Explorer for the full ledger view.
    )} @@ -421,13 +428,14 @@ function RecentActivity({ name }: { name: string }) { {events.map((e) => ( - {e.time} + {e.time} {e.event} - - {e.cid.slice(0, 10)}… + + ))} diff --git a/frontend/src/screens/DeveloperSetup.tsx b/frontend/src/screens/DeveloperSetup.tsx index c495cbff..d356d748 100644 --- a/frontend/src/screens/DeveloperSetup.tsx +++ b/frontend/src/screens/DeveloperSetup.tsx @@ -9,6 +9,7 @@ import { } from "../api"; import { W, wMono } from "../tokens"; import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; // DeveloperSetup — the "Developer setup" card. Two sub-panels: // @@ -103,9 +104,11 @@ function JwtPanel({ name }: { name: string }) { /> - - {jwt?.party ?? "—"} - + {jwt?.party ? ( + + ) : ( + + )}
    diff --git a/frontend/src/screens/DoctorScreen.tsx b/frontend/src/screens/DoctorScreen.tsx index 9c0090a6..ebf8a079 100644 --- a/frontend/src/screens/DoctorScreen.tsx +++ b/frontend/src/screens/DoctorScreen.tsx @@ -7,8 +7,9 @@ import { fetchDoctor, fetchSpliceVersions, } from "../api"; -import { W, wMono, wideCaps, tint } from "../tokens"; +import { W, wMono, wideCaps, tint, R } from "../tokens"; import { Button } from "../components/Button"; +import { SkeletonTable, useLoadingDelay } from "../components/Skeleton"; import { Dot, IcAlert, IcCheck, IcRefresh, IcX } from "../components/icons"; // DoctorScreen — the Web UI surface for `dpm localnet doctor`. @@ -86,31 +87,75 @@ export function DoctorScreen() { {report && } - {err && ( + {err && run(version)} />} + + {loading && !report && !err && } + + {report?.sections.map((sec) => ( +
    + ))} +
    + ); +} + +// DoctorError — the endpoint failed. Give a plain-language cause, a +// Retry, and tuck the raw server message behind a disclosure so the +// screen leads with what to do, not a stack-shaped string. +function DoctorError({ + message, + onRetry, +}: { + message: string; + onRetry: () => void; +}) { + return ( +
    + Couldn't run host checks.{" "} + The doctor endpoint didn't respond. Confirm the devkit server is up, + then retry. +
    + +
    +
    + + Server message +
    - {err} + {message}
    - )} - - {loading && !report && ( -
    - Running host checks… -
    - )} +
    +
    + ); +} - {report?.sections.map((sec) => ( -
    - ))} +// DoctorLoading mirrors the section-of-rows shape the report renders so +// content lands in place instead of popping in under a "Running…" line. +function DoctorLoading() { + const shown = useLoadingDelay(true); + if (!shown) return null; + return ( +
    +
    ); } @@ -169,10 +214,11 @@ function Header({ background: W.surface, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "5px 8px", fontSize: 12, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", }} > @@ -218,9 +264,9 @@ function SummaryBanner({ report }: { report: PreflightReport }) { style={{ marginTop: 16, padding: "12px 14px", - background: `${accent}14`, + background: tint(accent, 8), border: `1px solid ${accent}`, - borderRadius: 4, + borderRadius: R.control, color: accent, fontSize: 13, fontWeight: 600, @@ -263,7 +309,7 @@ function Section({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, overflow: "hidden", }} > @@ -318,6 +364,7 @@ function CheckRow({ check, last }: { check: PreflightCheck; last: boolean }) { color: W.dim, fontSize: 11.5, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", marginTop: 2, }} > diff --git a/frontend/src/screens/ExplorerScreen.tsx b/frontend/src/screens/ExplorerScreen.tsx index 0269d7d3..d2329a18 100644 --- a/frontend/src/screens/ExplorerScreen.tsx +++ b/frontend/src/screens/ExplorerScreen.tsx @@ -16,7 +16,10 @@ import { import { useInstanceSelection } from "../shell/useInstanceSelection"; import { Button } from "../components/Button"; import { Dot, IcRefresh } from "../components/icons"; -import { TX_KIND_COLOR, W, wMono, tableCaps, wideCaps, tint } from "../tokens"; +import { MonoId } from "../components/MonoId"; +import { StatusBadge } from "../components/StatusBadge"; +import { SkeletonTable, useLoadingDelay } from "../components/Skeleton"; +import { TX_KIND_COLOR, W, wMono, tableCaps, wideCaps, tint, R, FAST } from "../tokens"; import { ContractDetailDrawer } from "./ContractDetailDrawer"; import { TxReplayDrawer } from "./TxReplayDrawer"; @@ -41,6 +44,12 @@ const PALETTE = [ type View = "contracts" | "transactions" | "timeline"; +// Honour the OS reduced-motion setting for the timeline glyph fades. +const prefersReducedMotion = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + export function ExplorerScreen() { const sel = useInstanceSelection(); const name = sel.selected; @@ -339,8 +348,13 @@ export function ExplorerScreen() { streamStatus={streamStatus} /> - {state.kind === "loading" && Snapshotting ACS…} - {state.kind === "err" && } + {state.kind === "loading" && } + {state.kind === "err" && ( + void refreshSnapshot(name, role, false)} + /> + )} {state.kind === "port-missing" && ( Stream - - {streamStatus} - +
    {state.data.ledger_end ?? "—"} @@ -532,20 +551,71 @@ export function ExplorerScreen() { }} > Template - Cid - Owner / signatory - Payload + Contract Id + Owner / Signatory + Payload Age Sig · Obs
    - {filtered.length === 0 && ( -
    - No contracts match the current filters. -
    - )} + {filtered.length === 0 && + (() => { + const hasAcsFilters = + activeTemplates.size > 0 || + activeParties.size > 0 || + search.trim() !== ""; + return ( +
    + {hasAcsFilters ? ( + <> + + No contracts match these filters.{" "} + {state.data.contracts.length.toLocaleString()} in the + snapshot. + + + + ) : ( + <> + + The active contract set is empty. Create a contract to + populate it. + + + dpm localnet tx submit + + + )} +
    + ); + })()}
    {filtered.map((c) => ( - + Showing {filtered.length} of {state.data.contracts.length} ·{" "} {streamStatus === "live" ? "live" : "snapshot"} @ offset{" "} {state.data.ledger_end ?? "—"} @@ -620,22 +690,6 @@ function ProjectionBar({ ledgerEnd: number | null; streamStatus: "idle" | "live" | "reconnecting" | "truncated"; }) { - const pillColor = - streamStatus === "live" - ? "#7CC89A" - : streamStatus === "reconnecting" - ? "#DDB25E" - : streamStatus === "truncated" - ? "#7BD2C6" - : W.dim; - const pillLabel = - streamStatus === "live" - ? "live" - : streamStatus === "reconnecting" - ? "reconnecting" - : streamStatus === "truncated" - ? "truncated" - : "idle"; return (
    {v} ))}
    - {pillLabel} +
    ); } @@ -767,27 +822,28 @@ function FilterChip({ return ( + + ) : ( + <> + No updates in the current ledger window. + + dpm localnet tx ls + + + )}
    )} @@ -1287,22 +1382,23 @@ function TxRowComponent({ > {tx.kind} - - {tx.offset.toLocaleString()} - - {tx.command_id ?? tx.update_id?.slice(0, 16) ?? "—"} + {tx.offset.toLocaleString()} + {tx.command_id ? ( + + ) : tx.update_id ? ( + + ) : ( + + )} {tx.event_count ?? "—"} @@ -1410,17 +1507,14 @@ function EventTreeNode({ ? ev.template.split(":").slice(1).join(":") : "—"} - - {ev.contract_id.slice(0, 16)}… - +
    ); } @@ -1440,6 +1534,9 @@ function TimelineView({ name, role }: { name: string; role: Role }) { // selected. Click again or Esc clears. const [selectedIdx, setSelectedIdx] = useState(null); const [hoverIdx, setHoverIdx] = useState(null); + // Bumped by the error-state Retry to re-run the fetch effect. + const [nonce, setNonce] = useState(0); + const reload = useCallback(() => setNonce((n) => n + 1), []); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -1488,10 +1585,14 @@ function TimelineView({ name, role }: { name: string; role: Role }) { return () => { cancelled = true; }; - }, [name, role]); + }, [name, role, nonce]); - if (state.kind === "loading") return Loading timeline…; - if (state.kind === "err") return ; + if (state.kind === "loading") + return ( + + ); + if (state.kind === "err") + return ; if (state.kind === "port-missing") return (
    - {/* Activity strip */} + {/* Activity strip — flat bars; height is the data encoding. */}
    {buckets.map((b, i) => { @@ -1563,11 +1664,8 @@ function TimelineView({ name, role }: { name: string; role: Role }) { style={{ flex: 1, height: h, - background: - b.count === 0 - ? W.border - : `linear-gradient(180deg, ${tint(W.brand, 40)} 0%, ${W.brand} 100%)`, - borderRadius: 2, + background: b.count === 0 ? W.border : W.brand, + borderRadius: R.control, }} /> ); @@ -1638,18 +1736,14 @@ function TimelineView({ name, role }: { name: string; role: Role }) { background: color, opacity: selectedIdx === i || hoverIdx === i ? 1 : 0.65, - borderRadius: 1.5, + borderRadius: R.control, cursor: "pointer", - transition: "opacity 80ms, transform 80ms", + transition: prefersReducedMotion + ? undefined + : `opacity ${FAST}`, outline: selectedIdx === i ? `2px solid ${W.brand}` : "none", outlineOffset: selectedIdx === i ? 1 : 0, - transform: - selectedIdx === i - ? "translateY(-2px)" - : hoverIdx === i - ? "translateY(-1px)" - : "none", }} /> ); @@ -1672,7 +1766,7 @@ function TimelineView({ name, role }: { name: string; role: Role }) { {selectedIdx !== null - ? "Selected — click again or press Esc to clear." + ? "Pinned. Click again or press Esc to clear." : "Hover for preview · click to pin."}
    @@ -1689,10 +1783,9 @@ function TimelineView({ name, role }: { name: string; role: Role }) { bottom: 0, width: "min(480px, 92vw)", // Raised surface — matches ContractDetailDrawer/TxReplayDrawer. + // One depth technique: hairline border, no shadow ring. background: W.surface2, borderLeft: `1px solid ${W.borderHi}`, - boxShadow: - "0 0 0 1px rgba(0,0,0,0.2), -16px 0 40px -12px rgba(0,0,0,0.5)", // A hover preview must not steal hit-testing from the strip // underneath it (hover-in would unhover the glyph and // unmount the panel in a loop); only a pinned selection is @@ -1717,7 +1810,14 @@ function TimelineView({ name, role }: { name: string; role: Role }) { > {focused.kind} - + offset {focused.offset.toLocaleString()}
    @@ -1777,7 +1877,8 @@ function Mono({ children }: { children: React.ReactNode }) { fontFamily: wMono, color: W.text2, fontSize: 11, - wordBreak: "break-all", + fontVariantNumeric: "tabular-nums", + wordBreak: "break-word", }} > {children} @@ -1836,7 +1937,7 @@ function Card({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 10, }} > @@ -1883,11 +1984,11 @@ function Pill({ color, children }: { color: string; children: React.ReactNode }) return ( - {children} +
); } -function ErrorPanel({ msg }: { msg: string }) { +// TableLoading — a bordered skeleton container for the Transactions / +// Timeline tables. Same delay gate as the ACS loader. +function TableLoading({ + columns, + rows, + rowHeight, +}: { + columns: (number | string)[]; + rows: number; + rowHeight: number; +}) { + const show = useLoadingDelay(true); + if (!show) return null; return (
+ +
+ ); +} + +// ErrorPanel — left-aligned, plain-language cause up front, a Retry +// affordance, and the raw message tucked behind a details disclosure +// so it doesn't shout at the operator on every transient blip. +function ErrorPanel({ msg, onRetry }: { msg: string; onRetry?: () => void }) { + return ( +
- {msg} +
+ Could not load ledger data. +
+
+ The participant did not answer. Check the instance is running, then + retry. +
+ {onRetry && ( + + )} +
+ Details + + {msg} + +
); } @@ -1944,17 +2106,21 @@ function EmptyPanel({ return (

{title}

-

{body}

-

{remediation}

+

+ {body} +

+

+ {remediation} +

); } diff --git a/frontend/src/screens/InstanceDetail.test.tsx b/frontend/src/screens/InstanceDetail.test.tsx index db98bcc6..e8de2fb2 100644 --- a/frontend/src/screens/InstanceDetail.test.tsx +++ b/frontend/src/screens/InstanceDetail.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { InstanceDetail } from "./InstanceDetail"; +import { ConfirmHost } from "../components/ConfirmDialog"; // InstanceDetail tests — surfaces every field the /api/instances/:name // endpoint returns beyond the summary. Three states: @@ -266,11 +267,13 @@ describe("InstanceDetail", () => { ); }); vi.stubGlobal("fetch", fetchMock); - vi.stubGlobal("confirm", vi.fn().mockReturnValue(true)); const onChanged = vi.fn(); render( - , + <> + + + , ); // Wait for the Recreate button to appear (the action-button @@ -278,6 +281,11 @@ describe("InstanceDetail", () => { const restartBtn = await screen.findByRole("button", { name: /recreate/i }); fireEvent.click(restartBtn); + // Recreate is destructive-ish, so it routes through the in-app + // confirm dialog. Approve it by clicking the dialog's confirm. + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: /recreate/i })); + await waitFor(() => { const calls = fetchMock.mock.calls.map((c) => c[0]); expect( @@ -415,16 +423,23 @@ describe("InstanceDetail", () => { ); }); vi.stubGlobal("fetch", fetchMock); - vi.stubGlobal("confirm", vi.fn().mockReturnValue(true)); const onChanged = vi.fn(); render( - , + <> + + + , ); const downBtn = await screen.findByRole("button", { name: /^Down$/ }); fireEvent.click(downBtn); + // Down removes containers, so it routes through the in-app confirm + // dialog. Approve it via the dialog's confirm button. + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: /^Down$/ })); + await waitFor(() => { const calls = fetchMock.mock.calls.map((c) => c[0]); expect( diff --git a/frontend/src/screens/InstanceDetail.tsx b/frontend/src/screens/InstanceDetail.tsx index ad0d8ebf..177189fa 100644 --- a/frontend/src/screens/InstanceDetail.tsx +++ b/frontend/src/screens/InstanceDetail.tsx @@ -12,7 +12,7 @@ import { stopInstance, unpauseInstance, } from "../api"; -import { W, wMono, tint } from "../tokens"; +import { W, wMono, tint, R } from "../tokens"; import { Button } from "../components/Button"; import { IcEject, @@ -22,6 +22,9 @@ import { IcStop, IcX, } from "../components/icons"; +import { StatusBadge } from "../components/StatusBadge"; +import { SkeletonBar, useLoadingDelay } from "../components/Skeleton"; +import { confirmDialog } from "../components/ConfirmDialog"; import { BackupRestore } from "./BackupRestore"; // UI endpoints the backend probed and found not serving HTTP. @@ -62,6 +65,8 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { | { kind: "running" } | { kind: "err"; message: string } >({ kind: "idle" }); + // Gate the loading skeleton so a fast local fetch never flashes it. + const showSkeleton = useLoadingDelay(state.kind === "loading"); async function onStop() { // Gentle stop: `docker compose stop` keeps containers around for a @@ -81,7 +86,15 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { } async function onDown() { - if (!confirm(`Tear down instance ${name}? Containers will be removed via docker compose down. Data volumes are preserved.`)) { + if ( + !(await confirmDialog({ + title: "Tear down instance?", + body: `Removes ${name}'s containers and networks. Data volumes are preserved, so Start recreates it.`, + detail: `dpm localnet down ${name}`, + confirmLabel: "Down", + danger: true, + })) + ) { return; } setStopping({ kind: "running" }); @@ -130,10 +143,12 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { async function onRecreate() { if ( - !confirm( - `Recreate ${name}? Containers will be brought down and back up via docker compose. ` + - `The recorded Splice version and profiles are preserved; data volumes are NOT touched.`, - ) + !(await confirmDialog({ + title: "Recreate instance?", + body: `Brings ${name} down then back up. The recorded Splice version and profiles are preserved. Data volumes are not touched.`, + detail: `dpm localnet down ${name} && dpm localnet up ${name}`, + confirmLabel: "Recreate", + })) ) { return; } @@ -173,10 +188,13 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { async function onRemove() { if ( - !confirm( - `Remove ${name} from the registry?\n\nThis deletes the instance entry + state.json. ` + - `Docker volumes (if any) are NOT touched — for that, use \`dpm localnet remove --name ${name}\` from a terminal.`, - ) + !(await confirmDialog({ + title: "Remove from registry?", + body: `Deletes the ${name} entry and its state.json. Docker volumes (if any) are not touched. To drop those, run dpm localnet remove from a terminal.`, + detail: `dpm localnet remove --name ${name}`, + confirmLabel: "Remove", + danger: true, + })) ) { return; } @@ -224,7 +242,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { marginTop: 24, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 16, }} > @@ -238,12 +256,13 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { style={{ color: W.warn, fontSize: 11, - border: `1px solid ${W.warn}`, - borderRadius: 2, + border: `1px solid ${tint(W.warn, 34)}`, + background: tint(W.warn, 13), + borderRadius: R.control, padding: "2px 8px", }} > - live probe failed + Live probe failed )} @@ -272,7 +291,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { background: `${tint(W.err, 6)}`, color: W.err, border: `1px solid ${W.err}`, - borderRadius: 2, + borderRadius: R.control, padding: "6px 10px", fontSize: 12, marginBottom: 10, @@ -289,7 +308,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { background: `${tint(W.warn, 6)}`, color: W.warn, border: `1px solid ${W.warn}`, - borderRadius: 6, + borderRadius: R.control, padding: "6px 10px", fontSize: 12, marginBottom: 10, @@ -298,7 +317,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { {unreachableUIs(state.instance) .map((e) => e.label) .join(", ")}{" "} - not serving HTTP — usually a stale port overlay from an instance + not serving HTTP. Usually a stale port overlay from an instance created by an older DevKit. Use Recreate (or re-run{" "} dpm localnet up --name {name} @@ -307,11 +326,9 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) {
)} - {state.kind === "loading" && ( -
Loading…
- )} + {state.kind === "loading" && showSkeleton && } {state.kind === "err" && ( -
{state.error}
+
{state.error}
)} {state.kind === "ok" && } {/* Rendered even on loading/error so the user can still take a @@ -322,17 +339,19 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { } function DetailGrid({ instance }: { instance: Instance }) { - // Identity first, then runtime, then on-disk locations. - const rows: Array<[string, React.ReactNode]> = [ - ["splice", instance.splice_version], - ["status", instance.status], - ["created", instance.created_at], - ["uptime", instance.uptime ?? "—"], - ["compose project", instance.compose_project], - ["docker network", instance.docker_network], - ["container prefix", instance.container_prefix], - ["project dir", instance.project_dir], - ["data dir", instance.data_dir], + // Identity first, then runtime, then on-disk locations. `mono` marks + // the machine-string rows (ids, paths, network names) so plain-prose + // values like status/uptime aren't forced into the monospace column. + const rows: Array<[string, React.ReactNode, boolean]> = [ + ["splice", instance.splice_version, true], + ["status", , false], + ["created", instance.created_at, true], + ["uptime", instance.uptime ?? "—", false], + ["compose project", instance.compose_project, true], + ["docker network", instance.docker_network, true], + ["container prefix", instance.container_prefix, true], + ["project dir", instance.project_dir, true], + ["data dir", instance.data_dir, true], ]; return ( @@ -345,10 +364,17 @@ function DetailGrid({ instance }: { instance: Instance }) { fontSize: 12.5, }} > - {rows.map(([k, v]) => ( -
+ {rows.map(([k, v, mono]) => ( +
{k}
-
+
{v}
@@ -357,6 +383,28 @@ function DetailGrid({ instance }: { instance: Instance }) { ); } +// DetailGridLoading — same 160px / 1fr rhythm as the real grid so the +// values slot in without a jump. +function DetailGridLoading() { + return ( +
+ {Array.from({ length: 6 }).map((_, r) => ( +
+ + +
+ ))} +
+ ); +} + // ActionButton dispatches the right verb(s) per instance status. // Registry status alone isn't enough — docker truth may diverge: // diff --git a/frontend/src/screens/MetricsScreen.tsx b/frontend/src/screens/MetricsScreen.tsx index f64e2a24..71d40462 100644 --- a/frontend/src/screens/MetricsScreen.tsx +++ b/frontend/src/screens/MetricsScreen.tsx @@ -8,7 +8,7 @@ import { type PrometheusRangeResponse, } from "../api"; import { useInstanceSelection } from "../shell/useInstanceSelection"; -import { W, wMono, tint } from "../tokens"; +import { W, wMono, tint, R } from "../tokens"; import { Button } from "../components/Button"; import { IcX } from "../components/icons"; import { MetricCard } from "../components/MetricCard"; @@ -239,9 +239,13 @@ export function MetricsScreen() { if (!name) { return ( -
-

- No instance selected. Create or pick one from the dashboard first. +

+

+ No instance selected. +

+

+ Pick an instance from the topbar switcher, or create one from + Overview.

); @@ -336,7 +340,7 @@ export function MetricsScreen() { marginBottom: 16, }} > - + {latencyPhase.kind === "err" ? ( ) : ( @@ -389,7 +393,7 @@ export function MetricsScreen() { ) : null} - + {cpuSeries.kind === "err" ? ( ) : ( @@ -464,9 +468,10 @@ function LatencyStrip(props: { padding: "10px 14px", background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, fontFamily: wMono, fontSize: 13, + fontVariantNumeric: "tabular-nums", color: W.text, }; const label: CSSProperties = { @@ -507,7 +512,7 @@ function DashboardsBlock(props: { url?: string }) { padding: "10px 14px", background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, fontFamily: wMono, fontSize: 13, color: W.text, @@ -582,10 +587,29 @@ function ChartCard({ ); } +// ErrLine — a chart card's query failed. The 5 s poll re-issues the +// query on the next tick, so this states the cause and that a retry is +// already in flight, with the raw server message tucked behind a +// disclosure rather than shouting a stack-shaped string. function ErrLine({ msg }: { msg: string }) { return ( -
- {msg} +
+
Query failed. Retrying every 5 s.
+
+ + Server message + +
+ {msg} +
+
); } @@ -619,7 +643,7 @@ function ObservabilityOffPanel({ style={{ background: `${tint(W.warn, 6)}`, border: `1px solid ${W.warn}`, - borderRadius: 4, + borderRadius: R.card, padding: 20, }} > diff --git a/frontend/src/screens/Placeholder.tsx b/frontend/src/screens/Placeholder.tsx index f13a6d25..34b59967 100644 --- a/frontend/src/screens/Placeholder.tsx +++ b/frontend/src/screens/Placeholder.tsx @@ -1,4 +1,4 @@ -import { W } from "../tokens"; +import { W, R } from "../tokens"; // Placeholder — the route stub for screens whose backend hasn't // landed yet. Swap the route in App.tsx to the real screen component @@ -8,18 +8,19 @@ export function Placeholder({ name }: { name: string }) {
-

{name}

-

- Not implemented yet in this build. +

+ {name} +

+

+ Not implemented yet in this build. Pick another screen from the + sidebar or press ⌘K.

); diff --git a/frontend/src/screens/TokensScreen.tsx b/frontend/src/screens/TokensScreen.tsx index c223d6e7..34d56587 100644 --- a/frontend/src/screens/TokensScreen.tsx +++ b/frontend/src/screens/TokensScreen.tsx @@ -31,9 +31,11 @@ import { type TokenRef, } from "../api"; import { useInstanceSelection } from "../shell/useInstanceSelection"; -import { W, wMono, tableCaps, wideCaps } from "../tokens"; +import { W, wMono, tableCaps, wideCaps, tint, R, FAST } from "../tokens"; import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; import { + Dot, IcArrowRight, IcArrowUp, IcBolt, @@ -67,14 +69,14 @@ function partyLabel(aliases: AliasMap, p: string): string { // AllocationV2/DvP). export function mintDisabledReason(t: InstrumentRef): string | null { if (t.generation !== "v2") - return `${t.symbol} (${t.standard}) has no standard mint — use the asset's wallet UI`; + return `${t.symbol} (${t.standard}) has no standard mint. Use the asset's wallet UI.`; if (!t.on_ledger) - return `${t.symbol} is recorded only — create it on-ledger first`; + return `${t.symbol} is recorded only. Create it on-ledger first.`; return null; } const BURN_DISABLED_REASON = "Burn is only available on a native CIP-0112 v2 token created on this " + - "instance — Amulet has no burn surface."; + "instance. Amulet has no burn surface."; // TOKEN_DAR_UNAVAILABLE_HINT is the friendly remediation for the on-ledger // create 412 (TEST_TOKEN_DAR_UNAVAILABLE): the test-token DAR isn't @@ -347,8 +349,8 @@ export function TokensScreen() { setTopNotice({ tone: "ok", text: res.seeded - ? `Launched ${res.token.symbol} — supply minted to ${res.issuer.alias}, ${res.holder?.alias ?? "a holder"} funded. Try a transfer.` - : `Launched ${res.token.symbol} — supply minted to ${res.issuer.alias}.`, + ? `Launched ${res.token.symbol}. Supply minted to ${res.issuer.alias}, ${res.holder?.alias ?? "a holder"} funded. Try a transfer.` + : `Launched ${res.token.symbol}. Supply minted to ${res.issuer.alias}.`, }); } catch (e) { setTopNotice(renderActionError(e, "demo launch failed")); @@ -432,7 +434,7 @@ export function TokensScreen() { No tokens on {instance} yet
- Go from empty to a live, transferable token in one click — no party ids to paste. + Go from empty to a live, transferable token in one click. No party ids to paste.
-
- admin {partyLabel(aliases, active.admin)} · id {active.instrument_id} +
+ admin {partyLabel(aliases, active.admin)} + · id +
{/* Overview / Activity tab switcher */} @@ -542,12 +547,12 @@ export function TokensScreen() { {summary && summary.holders.length > 0 && }

- Holdings · a balance is the sum of its Holding contracts — click a row to expand + Holdings · a balance sums its Holding contracts. Click a row to expand.

{holdingsSource === "registry" && (
No live ledger reachable for {instance}. These are - registry pseudo-balances — local bookkeeping that shows the issuer + registry pseudo-balances. Local bookkeeping that shows the issuer holding the full supply and everyone else zero, not on-ledger holdings. Start the instance to see real balances.
@@ -557,7 +562,7 @@ export function TokensScreen() { PARTY - AMOUNT + AMOUNT @@ -566,7 +571,7 @@ export function TokensScreen() { toggleExpand(h.party)} - style={{ cursor: "pointer", background: expanded === h.party ? W.surface2 : "transparent" }} + style={{ cursor: "pointer", background: expanded === h.party ? tint(W.brand, 12) : "transparent", transition: `background-color ${FAST}` }} > @@ -574,19 +579,26 @@ export function TokensScreen() { {partyLabel(aliases, h.party)} - {h.amount} + {h.amount} {expanded === h.party && contracts.map((c) => ( - - └ {c.contract_id.slice(0, 16)}… - {c.locked && locked} + + + + + {c.locked && ( + + Locked + + )} + - {c.amount} + {c.amount} ))} {expanded === h.party && contracts.length === 0 && ( - loading contracts… + Loading contracts… )} ))} @@ -656,7 +668,7 @@ export function TokensScreen() { if (offered) { // Offer transfer: hand the id straight to a prefilled Accept // modal so the receiver can settle it without copy-pasting. - setTopNotice({ tone: "ok", text: `Transfer offered — accept instruction ${offered.instructionId.slice(0, 12)}… to settle it` }); + setTopNotice({ tone: "ok", text: `Transfer offered. Accept instruction ${offered.instructionId.slice(0, 12)}… to settle it.` }); setModal({ kind: "accept", id: offered.instructionId, party: offered.receiver }); } else { setModal(null); @@ -684,7 +696,7 @@ export function TokensScreen() { fields={[ { label: "To party", key: "to", party: true }, { label: "Amount", key: "amount" }, - { label: "Source (optional — defaults to funded party)", key: "source", optional: true, party: true }, + { label: "Source (optional, defaults to funded party)", key: "source", optional: true, party: true }, ]} instance={instance} parties={parties} @@ -786,7 +798,7 @@ function TransferModal({ setReason(e.target.value)} style={input} /> {plan && ( @@ -797,19 +809,21 @@ function TransferModal({ {plan.sufficient ? (
{plan.inputs.map((i) => ( -
- {i.contract_id.slice(0, 14)}… consume - −{i.amount} +
+ + consume + + −{i.amount}
))}
→ {shortParty(to || from)} receive - +{amount} + +{amount}
{Number(plan.change) > 0 && (
→ {shortParty(from)} change - +{plan.change} + +{plan.change}
)}
@@ -920,9 +934,9 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali HOLDER - BALANCE + BALANCE SHARE - UTXOS + UTXOS @@ -931,7 +945,7 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali return ( {partyLabel(aliases, h.party)} - {h.balance} + {h.balance}
@@ -944,12 +958,12 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali }} />
- + {h.pct_of_supply}%
- {h.contract_count} + {h.contract_count} ); })} @@ -973,6 +987,11 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null burn: W.err, transfer: W.warn, }; + const kindLabel: Record = { + mint: "Mint", + burn: "Burn", + transfer: "Transfer", + }; const fmtParties = (ps?: { party: string; amount: string }[]) => !ps || ps.length === 0 ? "·" @@ -983,7 +1002,7 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null TIME KIND - AMOUNT + AMOUNT FROM TO @@ -991,23 +1010,28 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null {events.map((e) => ( - + {e.record_time ? e.record_time.replace("T", " ").slice(0, 19) : `@${e.offset}`} - {e.kind} + + {kindLabel[e.kind]} - {e.amount} + {e.amount} {fmtParties(e.senders)} {fmtParties(e.receivers)} @@ -1022,7 +1046,7 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null // parties the role's JWT can read appear. function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; err: string | null; aliases: AliasMap }) { if (err) return
{err}
; - if (!matrix) return
Loading matrix…
; + if (!matrix) return
Scanning ACS…
; const syms = matrix.instruments.map((i) => i.symbol ?? i.instrument_id); const symByInst: Record = {}; @@ -1038,8 +1062,8 @@ function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; er return (
- {parties.length} {parties.length === 1 ? "party" : "parties"} × {syms.length} {syms.length === 1 ? "instrument" : "instruments"} — - every readable party's balance of every instrument, in one ACS scan. + {parties.length} {parties.length === 1 ? "party" : "parties"} × {syms.length} {syms.length === 1 ? "instrument" : "instruments"}. + Every readable party's balance of every instrument, in one ACS scan.
@@ -1053,7 +1077,7 @@ function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; er {syms.map((s) => ( - ))} @@ -1062,7 +1086,7 @@ function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; er {syms.map((s) => ( - + ))} {parties.length === 0 && ( @@ -1509,11 +1533,15 @@ const input: React.CSSProperties = { // side-padding keeps >=12px of air between adjacent columns. const th: React.CSSProperties = { ...tableCaps, padding: "6px 10px", borderBottom: `1px solid ${W.border}`, fontSize: 11 }; const td: React.CSSProperties = { padding: "6px 10px", borderBottom: `1px solid ${W.border}`, color: W.text }; +// Numeric columns (amounts, balances, counts) right-align with tabular +// figures so digits line up column-wise. +const thNum: React.CSSProperties = { ...th, textAlign: "right" }; +const tdNum: React.CSSProperties = { ...td, textAlign: "right", fontFamily: wMono, fontVariantNumeric: "tabular-nums" }; function notice(tone: "ok" | "warn" | "err"): React.CSSProperties { const c = tone === "ok" ? W.ok : tone === "warn" ? W.warn : W.err; return { - background: `${c}10`, color: c, border: `1px solid ${c}`, - borderRadius: 4, padding: "8px 12px", fontSize: 12.5, + background: tint(c, 10), color: c, border: `1px solid ${tint(c, 40)}`, + borderRadius: R.control, padding: "8px 12px", fontSize: 12.5, }; } diff --git a/frontend/src/screens/TxReplayDrawer.tsx b/frontend/src/screens/TxReplayDrawer.tsx index e47939c0..5eed589f 100644 --- a/frontend/src/screens/TxReplayDrawer.tsx +++ b/frontend/src/screens/TxReplayDrawer.tsx @@ -6,8 +6,9 @@ import { type TxReplayEvent, type TxReplayResponse, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, R } from "../tokens"; import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; import { IcX } from "../components/icons"; // TxReplayDrawer — the Web UI counterpart of `dpm localnet tx replay @@ -98,11 +99,10 @@ export function TxReplayDrawer({ bottom: 0, width: "min(480px, 92vw)", // Raised surface — a fixed overlay sits above the page, and - // surface-on-page was reading dark-on-dark. + // surface-on-page was reading dark-on-dark. One depth technique + // for a dense-console drawer: hairline border, no shadow. background: W.surface2, borderLeft: `1px solid ${W.borderHi}`, - boxShadow: - "0 0 0 1px rgba(0,0,0,0.2), -16px 0 40px -12px rgba(0,0,0,0.5)", // Below the CommandPalette (zIndex 100) but above page content. zIndex: 40, overscrollBehavior: "contain", @@ -122,17 +122,7 @@ export function TxReplayDrawer({
Replay · per-party projection
- - {updateId} - + +
+ + Error details + +
+          {error.message || "(no message)"}
+        
+
); } diff --git a/frontend/src/shell/Shell.tsx b/frontend/src/shell/Shell.tsx index 3cb8705d..5803379a 100644 --- a/frontend/src/shell/Shell.tsx +++ b/frontend/src/shell/Shell.tsx @@ -1,6 +1,7 @@ import { NavLink, useLocation, useSearchParams } from "react-router-dom"; import { useState } from "react"; -import { W, wMono, wSans, wideCaps, tint } from "../tokens"; +import { W, wMono, wSans, wideCaps, tint, R } from "../tokens"; +import { StatusBadge } from "../components/StatusBadge"; import { Dot, IcOverview, @@ -215,10 +216,10 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) { // rather than a dropdown. The Dashboard owns the "go run dpm // localnet up" empty-state messaging; the topbar just shrugs. if (sel.loading) { - return loading instances…; + return Loading instances…; } if (sel.error || sel.instances.length === 0) { - return no instances; + return No instances; } const selected = sel.instances.find((i) => i.name === sel.selected); return ( @@ -255,7 +256,14 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) { > instance - + {sel.selected ?? "—"} @@ -271,11 +279,13 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) { padding: 4, listStyle: "none", background: W.surface, + // Floating overlay: one depth technique. Hairline border + // plus a subtle shadow, matched to the command palette. border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, minWidth: 240, zIndex: 10, - boxShadow: "0 8px 28px rgba(0,0,0,0.28)", + boxShadow: "0 6px 20px rgba(0,0,0,0.16)", }} > {sel.instances.map((i) => ( @@ -294,13 +304,15 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) { style={{ display: "flex", alignItems: "center", - gap: 8, + gap: 10, width: "100%", padding: "7px 10px", + // Flat active fill, constant padding — no accent + // side-bar, no content shift on selection. background: i.name === sel.selected ? W.brandSoft : "transparent", border: "none", - borderRadius: 2, + borderRadius: R.control, color: i.name === sel.selected ? W.brandText : W.text, fontFamily: wMono, fontSize: 12, @@ -308,9 +320,15 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) { cursor: "pointer", }} > - {i.name} - + + {i.splice_version} @@ -412,7 +430,7 @@ function HealthPill({ conn }: { conn: ConnectionState }) { case "offline": return { color: W.err, - label: "offline", + label: "Offline", tooltip: conn.serverVersion != null ? `Lost connection · last seen schema v${conn.serverVersion}` diff --git a/internal/ui/dist/index.html b/internal/ui/dist/index.html index 023be8f1..efab4b69 100644 --- a/internal/ui/dist/index.html +++ b/internal/ui/dist/index.html @@ -6,8 +6,8 @@ canton-devkit - - + +
From b36156ea41a1b28a12a55c5fa75b8c68aafa1561 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:34:46 +0530 Subject: [PATCH 04/14] fix(ui): correct the Metrics screen queries for Splice 0.6.4 Two panels queried metrics Splice 0.6.4 does not provide, so they never populated: - ACS lookup buffer used daml_participant_api_index_db_active_contract_lookup_batch_buffer_length, which is no longer emitted. Point it at the live daml_participant_api_index_active_contracts_buffer_size gauge. - The latency panels used histogram_quantile on the sequencing-duration histogram, which 0.6.4 exports with only the +Inf bucket, so quantiles are NaN regardless of load. Show the computable average (sum/count) instead, labelled as an average, and hide the p50/p95/p99 strip when the backend can't compute those (it returns on versions whose histograms carry finite buckets). --- frontend/src/screens/MetricsScreen.tsx | 84 +++++++++++++++----------- internal/ui/dist/index.html | 2 +- 2 files changed, 50 insertions(+), 36 deletions(-) diff --git a/frontend/src/screens/MetricsScreen.tsx b/frontend/src/screens/MetricsScreen.tsx index 71d40462..9d4a27cf 100644 --- a/frontend/src/screens/MetricsScreen.tsx +++ b/frontend/src/screens/MetricsScreen.tsx @@ -48,21 +48,27 @@ const Q = { // Substitute: indexer-update counter, same as HeadlineLedgerTPS. throughputSeries: "sum(rate(daml_participant_api_indexer_updates[1m])) or vector(0)", - p99: 'histogram_quantile(0.99, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))', + // Splice 0.6.4 exports the sequencing-duration histogram with only the + // +Inf bucket (no finite `le` boundaries), so histogram_quantile() + // returns NaN regardless of load — percentiles are not computable here. + // The average IS (sum/count), so the latency surfaces show that instead, + // labelled honestly as an average. In milliseconds. + avgLatency: + "1000 * sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_sum[5m])) / sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_count[5m]))", // Live Splice does not expose total ACS cardinality as a stock - // Prometheus metric. This is the audited ACS-related signal that - // exists in 0.6.4; keep UI copy honest and call it a lookup buffer. + // Prometheus metric. The former proxy + // (daml_participant_api_index_db_active_contract_lookup_batch_buffer_length) + // is no longer emitted by Splice 0.6.4 — verified absent from a live + // instance's Prometheus. The active-contracts in-memory buffer gauge + // is the audited ACS-related signal that exists in 0.6.4; keep UI copy + // honest and call it a lookup buffer. acsLookupBuffer: - "sum(daml_participant_api_index_db_active_contract_lookup_batch_buffer_length)", + "sum(daml_participant_api_index_active_contracts_buffer_size)", // No daml_* command-rejection counter on Splice 0.6.4 — use the // user-error completion-status counter as a proxy for "things // the participant refused to commit". Returns 0 if not exposed. errorsRate: 'sum(rate(daml_grpc_server_handled_total{grpc_code!="OK"}[1m])) or vector(0)', - latencyMedian: - 'histogram_quantile(0.50, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))', - latencyP99: - 'histogram_quantile(0.99, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))', // Splice 0.6.x does not expose template-grain submission counters. // Use the live gRPC method counter as a command-throughput fallback // instead of querying a non-existent `daml_commands_*` family. @@ -98,7 +104,7 @@ export function scopeQ(query: string, scope: string): string { } const TPS_COLOR = "#8FA3EE"; -const P99_COLOR = "#DDB25E"; +const LATENCY_COLOR = "#DDB25E"; const ACS_COLOR = "#6480E6"; const ERR_COLOR = "#7BD2C6"; @@ -112,7 +118,7 @@ export function MetricsScreen() { const [throughputSeries, setThroughputSeries] = useState>({ kind: "loading", }); - const [p99Series, setP99Series] = useState>({ + const [latencySeries, setLatencySeries] = useState>({ kind: "loading", }); const [acsSeries, setAcsSeries] = useState>({ @@ -177,15 +183,12 @@ export function MetricsScreen() { } await Promise.all([ loadSeries(name, scopeQ(Q.throughputSeries, scope), "tx/s", setThroughputSeries, signal), - loadSeries(name, scopeQ(Q.p99, scope), "p99", setP99Series, signal), + loadSeries(name, scopeQ(Q.avgLatency, scope), "avg latency", setLatencySeries, signal), loadSeries(name, scopeQ(Q.acsLookupBuffer, scope), "ACS lookup buffer", setAcsSeries, signal), loadSeries(name, scopeQ(Q.errorsRate, scope), "errors", setErrorsSeries, signal), loadMultiSeries( name, - [ - { query: scopeQ(Q.latencyMedian, scope), label: "median", color: CHART_PALETTE[1] }, - { query: scopeQ(Q.latencyP99, scope), label: "p99", color: CHART_PALETTE[3] }, - ], + [{ query: scopeQ(Q.avgLatency, scope), label: "avg", color: CHART_PALETTE[1] }], setLatencyPhase, signal, ), @@ -233,7 +236,8 @@ export function MetricsScreen() { // order is stable across the (!name) and (observabilityOff) // early-exit paths — rules of hooks. const tpsDelta = useMemo(() => deltaFromSeries(throughputSeries.data), [throughputSeries.data]); - const p99Delta = useMemo(() => deltaFromSeries(p99Series.data, 1000), [p99Series.data]); + // avgLatency is already in ms — no unit scaling for the delta. + const latencyDelta = useMemo(() => deltaFromSeries(latencySeries.data), [latencySeries.data]); const acsDelta = useMemo(() => deltaFromSeries(acsSeries.data), [acsSeries.data]); const errDelta = useMemo(() => deltaFromSeries(errorsSeries.data), [errorsSeries.data]); @@ -268,10 +272,10 @@ export function MetricsScreen() { } const m = summary.data?.metrics; - const p99Value = - summary.kind === "ok" && summary.data - ? (summary.data.latency?.p99_ms ?? Number.NaN) - : undefined; + // The backend latency.p99_ms is histogram_quantile-derived and NaN on + // Splice 0.6.4 (no finite buckets); use the computable average from the + // frontend series instead — its latest point, already in ms. + const latencyValue = latencySeries.data?.points.at(-1)?.v; return (
@@ -297,13 +301,13 @@ export function MetricsScreen() { deltaPolarity="up-is-good" /> ({ t: p.t, v: p.v * 1000 }))} - sparklineColor={P99_COLOR} - error={p99Series.kind === "err" ? p99Series.error : undefined} - delta={p99Delta} + value={latencyValue} + sparkline={latencySeries.data?.points} + sparklineColor={LATENCY_COLOR} + error={latencySeries.kind === "err" ? latencySeries.error : undefined} + delta={latencyDelta} deltaPolarity="down-is-good" format={(v) => (Math.abs(v) >= 100 ? v.toFixed(0) : v.toFixed(1))} /> @@ -340,7 +344,7 @@ export function MetricsScreen() { marginBottom: 16, }} > - + {latencyPhase.kind === "err" ? ( ) : ( @@ -348,7 +352,7 @@ export function MetricsScreen() { series={latencyPhase.data ?? []} width={420} height={170} - format={(v) => (v >= 1 ? v.toFixed(2) + "s" : (v * 1000).toFixed(0) + "ms")} + format={(v) => (v >= 1000 ? (v / 1000).toFixed(2) + "s" : v.toFixed(0) + "ms")} /> )} @@ -426,13 +430,23 @@ export function MetricsScreen() { - {/* Latency headline triplet — mirrors `dpm localnet metrics` - text output so CLI and UI agree on the curated quantiles. */} - + {/* Latency headline triplet — mirrors `dpm localnet metrics` text + output so CLI and UI agree on the curated quantiles. Splice 0.6.4 + exports the histogram with only the +Inf bucket, so these + percentiles are NaN there; hide the strip rather than show three + dashes. It reappears on any version whose histogram carries finite + buckets. */} + {[ + summary.data?.latency?.p50_ms, + summary.data?.latency?.p95_ms, + summary.data?.latency?.p99_ms, + ].some((v) => typeof v === "number" && Number.isFinite(v)) && ( + + )} {/* Top error sources — full width */} diff --git a/internal/ui/dist/index.html b/internal/ui/dist/index.html index efab4b69..4f3697e9 100644 --- a/internal/ui/dist/index.html +++ b/internal/ui/dist/index.html @@ -6,7 +6,7 @@ canton-devkit - + From 5bdb8a2218fe026f9ffbce6d2730a876dfe46f5a Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:49:50 +0530 Subject: [PATCH 05/14] ui: trim comment noise and correct stale palette naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only pass over the console. The component and shell files had grown header blocks that narrated redesign history and editorialised about the audience ("the discipline an auditor relies on", "reads as unfinished", "one depth technique") rather than documenting the code. Trim those to the load-bearing "why" and drop the asides; the metric, race-condition, and accessibility comments are left intact. Also fix palette naming left over from before the Carbon Slate swap: the accent is no longer cobalt, so the design-token, CSS, and Button docs that still called it that were inaccurate. The chart ramp stays labelled cobalt — those hexes really are cobalt-blue. One small non-comment change: MonoId's clipboard copy collapses the redundant Promise temporary into a single optional-chained call. --- frontend/src/components/Button.tsx | 2 +- frontend/src/components/ConfirmDialog.tsx | 10 ++-- frontend/src/components/MonoId.tsx | 21 +++----- frontend/src/components/Skeleton.tsx | 15 +++--- frontend/src/components/StatusBadge.tsx | 14 ++---- frontend/src/index.css | 7 ++- frontend/src/screens/ContractDetailDrawer.tsx | 5 +- frontend/src/screens/Dashboard.tsx | 4 +- frontend/src/screens/ExplorerScreen.tsx | 3 +- frontend/src/shell/CommandPalette.tsx | 49 ++++++------------- frontend/src/shell/Shell.tsx | 7 +-- frontend/src/theme.ts | 19 +++---- frontend/src/tokens.ts | 36 ++++++-------- internal/ui/dist/index.html | 2 +- 14 files changed, 69 insertions(+), 125 deletions(-) diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 9a8ed232..dc40ea69 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -1,7 +1,7 @@ // The one button system for the Web UI (visuals in index.css under // .bd-btn). Four variants with a strict usage contract: // -// primary — THE one dominant action of a view or dialog (cobalt +// primary — THE one dominant action of a view or dialog (accent // fill, ink text). At most one visible per context. // secondary — the default: bordered, quiet (Refresh, Pause, Mint…). // ghost — low-emphasis inline actions (Edit, close ×, chips). diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx index 02986118..79a198c4 100644 --- a/frontend/src/components/ConfirmDialog.tsx +++ b/frontend/src/components/ConfirmDialog.tsx @@ -1,12 +1,9 @@ -// In-app confirm dialog — replaces the browser-native confirm(), which -// can't match the console's typography, can't show container/port -// detail inline, and reads as unfinished. Promise-based so call sites -// stay a one-liner: +// In-app confirm dialog. Promise-based so call sites stay a one-liner: // // if (!(await confirmDialog({ title, body, confirmLabel, danger }))) return; // -// A single ConfirmHost is mounted once (see App); confirmDialog() -// dispatches an event it listens for, keeping open state in the host. +// A single ConfirmHost (mounted in App) listens for the event +// confirmDialog() dispatches and owns the open state. import { useEffect, useState } from "react"; import { W, wMono, wSans, R, EASE, FAST } from "../tokens"; @@ -87,7 +84,6 @@ export function ConfirmHost() { onClick={(e) => e.stopPropagation()} style={{ width: "min(440px, 92vw)", - // One depth technique: hairline border, no competing shadow. background: W.surface, border: `1px solid ${W.borderHi}`, borderRadius: R.dialog, diff --git a/frontend/src/components/MonoId.tsx b/frontend/src/components/MonoId.tsx index 9176d50c..e0365ff7 100644 --- a/frontend/src/components/MonoId.tsx +++ b/frontend/src/components/MonoId.tsx @@ -1,11 +1,6 @@ -// MonoId — the one way to render a ledger identifier (contract id, -// party id, package id, hash, offset) in this console. -// -// Ledger ids are long and their *suffix* is the discriminating part, -// so tail-only truncation ("00ce960f…") hides exactly what tells two -// ids apart. MonoId middle-truncates (head…tail), keeps the full value -// in the title for hover, and copies it on click — the discipline an -// auditor comparing ids relies on. +// MonoId — renders a ledger identifier (contract / party / package id, +// hash, offset). Middle-truncates (head…tail) so the discriminating suffix +// stays visible, shows the full value on hover, and copies it on click. import { useState, type CSSProperties } from "react"; import { W, wMono } from "../tokens"; @@ -40,16 +35,14 @@ export function MonoId({ const [copied, setCopied] = useState(false); const shown = full ? value : truncateMid(value, head, tail); const copy = () => { - // clipboard may be unavailable (http on non-localhost) or denied; - // swallow both the throw and the promise rejection so a failed - // copy is a silent no-op, not an unhandled rejection. + // clipboard can be unavailable (non-localhost http) or denied; ignore + // both the throw and the rejection so a failed copy is a no-op. try { - const p = navigator.clipboard?.writeText(value); - if (p) p.catch(() => {}); + navigator.clipboard?.writeText(value).catch(() => {}); setCopied(true); window.setTimeout(() => setCopied(false), 1100); } catch { - // no clipboard API at all + /* no clipboard API */ } }; return ( diff --git a/frontend/src/components/Skeleton.tsx b/frontend/src/components/Skeleton.tsx index 6c729098..8b2b9e33 100644 --- a/frontend/src/components/Skeleton.tsx +++ b/frontend/src/components/Skeleton.tsx @@ -1,14 +1,11 @@ -// Skeleton — layout-matched loading placeholders. A dense console -// knows its table shapes ahead of time, so a bare centered "Loading…" -// that pops into a full table causes a jarring layout shift. Skeletons -// mirror the real row height and column rhythm so content arrives in -// place, and a short show-delay avoids a flicker on fast local fetches. +// Skeleton — loading placeholders shaped like the real table so content +// arrives in place without a layout shift. A short delay avoids a +// flicker on fast local fetches. import { useEffect, useState, type CSSProperties } from "react"; import { W, R } from "../tokens"; -// useDelayedFlag returns true only after `ms`, so a fetch that resolves -// in <150ms never flashes a skeleton. +// Returns true only after `ms`, so a fast fetch never flashes a skeleton. export function useLoadingDelay(active: boolean, ms = 160): boolean { const [shown, setShown] = useState(false); useEffect(() => { @@ -49,8 +46,8 @@ export function SkeletonBar({ ); } -// SkeletonTable mirrors a column-based table: pass the same relative -// column widths the real table uses so the skeleton lines up with it. +// Pass the same relative column widths the real table uses so the +// skeleton lines up with it. export function SkeletonTable({ columns, rows = 4, diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index 9cc733fb..b95220a7 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,10 +1,5 @@ -// StatusBadge — the one renderer for instance / container / connection -// status across the console. Before this, the same status datum showed -// up four different ways (a lowercase dot+enum in the table, plain mono -// text in the detail grid, a Title-Case pill in the topbar, a bare -// color-only dot in the ACS). One renderer fixes the inconsistency and -// guarantees color is never the ONLY cue — the label carries the -// meaning for the colorblind / auditor audience. +// StatusBadge — single renderer for instance / container / stream status. +// Always pairs a colored dot with a text label so color is never the only cue. import type { CSSProperties } from "react"; import { W, tint, R } from "../tokens"; @@ -12,8 +7,7 @@ import { Dot } from "./icons"; type Tone = "ok" | "warn" | "danger" | "muted"; -// Canonical status vocabulary. Terse Title-Case labels; unknown values -// fall through to a muted, capitalized rendering rather than breaking. +// Known statuses map to a label + tone; unknown values render muted. const MAP: Record = { running: { label: "Running", tone: "ok" }, healthy: { label: "Healthy", tone: "ok" }, @@ -30,7 +24,7 @@ const MAP: Record = { failed: { label: "Failed", tone: "danger" }, error: { label: "Error", tone: "danger" }, dead: { label: "Dead", tone: "danger" }, - // Explorer stream states — the ACS/tx snapshot-vs-live stream. + // Explorer stream states. live: { label: "Live", tone: "ok" }, reconnecting: { label: "Reconnecting", tone: "warn" }, truncated: { label: "Truncated", tone: "warn" }, diff --git a/frontend/src/index.css b/frontend/src/index.css index 943b1f21..f0805025 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -169,9 +169,8 @@ a { * arrow keys) or programmatic .focus(). Mouse clicks don't paint * the ring, matching what sighted users expect from native UI. * - * 2px cobalt outline (blue-500 — identical in light and dark per - * the design system), offset 2px so it doesn't merge into the - * element's own border. */ + * 2px accent outline (blue-500 — identical in light and dark), offset + * 2px so it doesn't merge into the element's own border. */ :focus { outline: none; } @@ -225,7 +224,7 @@ a { /* Button system (components/Button.tsx). Hover/active tints live * here because inline style objects can't express :hover. - * primary = the solid cobalt CTA (white text, both themes); + * primary = the solid accent CTA (white text, both themes); * secondary = bordered surface; ghost = quiet; danger = filled red. */ .bd-btn { appearance: none; diff --git a/frontend/src/screens/ContractDetailDrawer.tsx b/frontend/src/screens/ContractDetailDrawer.tsx index fecafe85..8429021d 100644 --- a/frontend/src/screens/ContractDetailDrawer.tsx +++ b/frontend/src/screens/ContractDetailDrawer.tsx @@ -125,9 +125,8 @@ export function ContractDetailDrawer({ right: 0, bottom: 0, width: "min(480px, 92vw)", - // Raised surface — a fixed overlay sits above the page, and - // surface-on-page was reading dark-on-dark. One depth technique - // for a dense-console drawer: hairline border, no shadow. + // Raised surface — a fixed overlay sits above the page, so + // surface-on-page would read too flat against it. background: W.surface2, borderLeft: `1px solid ${W.borderHi}`, // Below the CommandPalette (zIndex 100) but above page content. diff --git a/frontend/src/screens/Dashboard.tsx b/frontend/src/screens/Dashboard.tsx index 74f259ab..db17fe03 100644 --- a/frontend/src/screens/Dashboard.tsx +++ b/frontend/src/screens/Dashboard.tsx @@ -46,8 +46,8 @@ export function Dashboard() { LocalNet instances
{partyLabel(aliases, p)} + {amt[p]?.[s] ?? "·"}
Σ total{totals[s] ?? ""}{totals[s] ?? ""}
so we're asserting the row, not the - // detail card's echo. + // Scope to
so we assert the row, not the detail card's echo of "demo". await waitFor(() => { const table = screen.getByRole("table"); expect(within(table).getByText("demo")).toBeInTheDocument(); expect(within(table).getByText("hubble")).toBeInTheDocument(); }); - // State badges within the table — StatusBadge renders Title-Case - // labels so the dot is never the only cue. const table = screen.getByRole("table"); expect(within(table).getByText("Running")).toBeInTheDocument(); expect(within(table).getByText("Stopped")).toBeInTheDocument(); @@ -199,8 +177,6 @@ describe("Dashboard", () => { await waitFor(() => { expect(screen.getByText(/no localnet instances/i)).toBeInTheDocument(); }); - // The remediation hint must include the dpm command — this - // is the user's first interaction with an empty UI. expect(screen.getByText(/dpm localnet up/i)).toBeInTheDocument(); }); @@ -213,9 +189,6 @@ describe("Dashboard", () => { }); it("renders the warning strip when ListResponse.warning is set", async () => { - // Same warning the CLI's `dpm localnet list` surfaces (e.g. - // registry parse drift). Should show as an amber strip above - // the table. mockListResponse( [{ name: "demo", status: "running" }], "registry has 1 unreadable entry; ignoring", @@ -235,20 +208,12 @@ describe("Dashboard", () => { ]); renderDashboard(); - // The auto-pick rule picks demo (first running). Click on - // hubble's row to override. + // Auto-pick selects demo (first running); click hubble to override. const hubbleCell = await screen.findByText("hubble"); await userEvent.click(hubbleCell); - // After selection, the InstanceDetail card pops with the - // detail-fetched data. We fetch a static "demo" detail in - // the mock, but the card header echoes the URL-selected - // name (hubble), so look for that as the source-of-truth. + // InstanceDetail only renders once selection is non-null. await waitFor(() => { - // The hubble cell should now show in the brand colour - // class — but we can't easily check colour. Instead pin - // that the InstanceDetail section appeared, which only - // happens once selection is non-null. expect(screen.getByText(/instance detail/i)).toBeInTheDocument(); }); }); @@ -260,9 +225,7 @@ describe("Dashboard", () => { ]); renderDashboard(); - // InstanceDetail appears because the auto-pick selected demo. - // Without the auto-pick rule there'd be no selected - // instance and the detail card wouldn't render. + // InstanceDetail renders only because auto-pick selected demo. await waitFor(() => { expect(screen.getByText(/instance detail/i)).toBeInTheDocument(); }); @@ -271,8 +234,6 @@ describe("Dashboard", () => { it("shows the recent-activity panel with ledger events for a running instance", async () => { mockListResponse([{ name: "demo", status: "running" }]); renderDashboard(); - // The panel mounts for the auto-selected running instance and - // flattens transactions → one row per ledger event. await waitFor(() => expect(screen.getByText(/recent activity/i)).toBeInTheDocument(), ); @@ -293,8 +254,7 @@ describe("Dashboard", () => { }); it("recent-activity shows the restart-to-capture hint for the no-JWT-recorded 500", async () => { - // The real e2e-metrics-demo case: instances predating JWT capture - // return a generic 500, distinguished by message, not a code. + // Instances predating JWT capture return a generic 500 distinguished by message, not code. mockListResponse([{ name: "demo", status: "running" }], undefined, { status: 500, body: { code: "INTERNAL", error: "no JWT recorded for role app-provider" }, diff --git a/frontend/src/screens/Dashboard.tsx b/frontend/src/screens/Dashboard.tsx index db17fe03..ae21898e 100644 --- a/frontend/src/screens/Dashboard.tsx +++ b/frontend/src/screens/Dashboard.tsx @@ -19,17 +19,11 @@ import { CreatingPanel } from "./CreatingPanel"; import { DeveloperSetup } from "./DeveloperSetup"; import { InstanceDetail } from "./InstanceDetail"; -// Dashboard — the Overview screen. Renders the registered-instance -// table from GET /api/instances. -// -// Selection state lives in the URL (?instance=) via -// useInstanceSelection so the topbar switcher and Dashboard agree on a -// single source of truth — and so shared links preserve the user's -// pick. +// Selection state lives in the URL (?instance=) so the topbar +// switcher and Dashboard share one source of truth and links survive. export function Dashboard() { const sel = useInstanceSelection(); const [createOpen, setCreateOpen] = useState(false); - // Gate the skeleton so a fast local fetch never flashes it. const showSkeleton = useLoadingDelay(sel.loading); return ( @@ -46,8 +40,7 @@ export function Dashboard() { LocalNet instances