From ef0cc53d57c51a07caf366b4c4307c3d00e2fdff Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 8 Jun 2026 13:10:54 +0300 Subject: [PATCH 1/2] feat(tangle-cloud): restore Tangle dapp design system (#3259) --- .../tangle-cloud-design-audit-2026-06-08.md | 57 ++ apps/tangle-cloud/PRODUCT_BRIEF.md | 3 +- apps/tangle-cloud/src/app/providers.tsx | 36 +- apps/tangle-cloud/src/components/Header.tsx | 38 +- .../components/blueprints/BlueprintVisual.tsx | 18 +- .../src/components/chrome/CommandPalette.tsx | 10 +- .../src/components/chrome/CopyableId.tsx | 103 +++ .../src/components/chrome/DataTable.tsx | 177 ++++ .../src/components/chrome/FilterTray.tsx | 4 +- .../src/components/chrome/Money.tsx | 127 +++ .../src/components/chrome/PageMotion.tsx | 4 +- .../src/components/chrome/PageToolbar.tsx | 14 +- .../src/components/chrome/ResultList.tsx | 8 +- .../src/components/chrome/StatusPill.tsx | 41 +- .../src/components/chrome/TopNavSlot.tsx | 89 +- .../src/components/chrome/ViewToggle.tsx | 4 +- .../src/components/chrome/index.ts | 24 + .../src/components/sandbox/SandboxUi.tsx | 89 +- .../src/pages/blueprints/BlueprintListing.tsx | 799 ++++++++++++++---- .../RegistrationDrawer/RegistrationDrawer.tsx | 6 +- .../src/pages/blueprints/[id]/deploy/page.tsx | 4 +- .../src/pages/blueprints/page.tsx | 85 +- apps/tangle-cloud/src/styles.css | 20 +- apps/tangle-cloud/src/styles/chrome.ts | 311 ++++++- .../ConnectWalletButton/WalletDropdown.tsx | 2 +- .../src/context/IndexerStatusContext.tsx | 9 +- .../src/data/graphql/useBlueprints.ts | 15 +- libs/tangle-shared-ui/src/utils/setupTest.ts | 6 + 28 files changed, 1740 insertions(+), 363 deletions(-) create mode 100644 .evolve/tangle-cloud-design-audit-2026-06-08.md create mode 100644 apps/tangle-cloud/src/components/chrome/CopyableId.tsx create mode 100644 apps/tangle-cloud/src/components/chrome/DataTable.tsx create mode 100644 apps/tangle-cloud/src/components/chrome/Money.tsx diff --git a/.evolve/tangle-cloud-design-audit-2026-06-08.md b/.evolve/tangle-cloud-design-audit-2026-06-08.md new file mode 100644 index 0000000000..6deb79ef58 --- /dev/null +++ b/.evolve/tangle-cloud-design-audit-2026-06-08.md @@ -0,0 +1,57 @@ +# Tangle Cloud Design Audit - 2026-06-08 + +Status: implemented and browser verified on `feat/tangle-cloud-tangle-dapp-system`. + +## Verdict + +The Blueprints page moved from glass-on-glass catalog cards to a list-first infrastructure console that matches the original Tangle dapp's operational hierarchy while keeping Cloud-specific density. The remaining surface is not a marketing hero; it is a service catalog for deployers, operators, and publishers. + +## Problems Addressed + +- Duplicate Blueprints title in top nav and page header. +- Separate nav/title layer doing no unique work. +- Blueprints catalog readability at roughly 6/10: same-background cards, weak hierarchy, small labels, hidden wallet address, italic controls, and decorative button dots. +- Missing default blueprint visual identity for chain-only blueprints. +- Local preview defaulting to Anvil and firing localhost RPC/indexer calls while the UI showed Base Sepolia. +- Blueprint data refetching felt uncached after leaving and returning to the page. +- Add-capacity/register flow could look blank or broken. + +## Product Direction + +- Use the original Tangle dapp design system selectively: wallet/network affordances, compact chrome, crisp borders, action hierarchy, and restrained Tangle color accents. +- Keep Cloud as an infrastructure console: list/table first, cards only for repeated catalog/mobile/modals, no hero marketing treatment. +- Make Blueprints read like inventory: capacity, trust, source, usage, and actions are first-order columns. + +## Changes Shipped + +- Removed the `/blueprints` top-nav breadcrumb title and let the page own the large `Blueprints` heading. +- Kept wallet connection visible in the top-right shell and made the connected-address affordance visible. +- Forced network/action controls to normal font style and removed button pseudo-dot artifacts. +- Added default blueprint visuals for chain-only catalog rows/cards. +- Added list-first catalog layout with metrics, availability filters, category/filter controls, grid/list toggle, pagination, and mobile cards. +- Added React Query stale/cache behavior for blueprint queries. +- Normalized Cloud's local-preview network to Base Sepolia unless `VITE_FORCE_LOCAL_CHAIN=true`. +- Moved network normalization before indexer health checks and aligned audited-status reads with the selected Cloud network. +- Made the local sandbox `Button` wrapper forward refs for Radix `asChild` compatibility. +- Tightened the add-capacity drawer into a true right-side panel. +- Stubbed `window.scrollTo` in shared Vitest setup to remove noisy route-test output. + +## Verification + +- `NX_DAEMON=false NX_SKIP_NX_CACHE=true yarn nx typecheck tangle-cloud` +- `NX_DAEMON=false NX_SKIP_NX_CACHE=true yarn nx test tangle-cloud` +- `NX_DAEMON=false NX_SKIP_NX_CACHE=true yarn nx build tangle-cloud` +- Playwright screenshots and console checks: + - `/tmp/tangle-cloud-dapp-system-audit/blueprints-desktop-dark.png` + - `/tmp/tangle-cloud-dapp-system-audit/blueprints-desktop-light.png` + - `/tmp/tangle-cloud-dapp-system-audit/blueprints-mobile-dark.png` + - `/tmp/tangle-cloud-dapp-system-audit/instances-desktop-dark.png` + - `/tmp/tangle-cloud-dapp-system-audit/blueprints-add-capacity-flow.png` + +Final browser counters: + +- `local8545`: 0 +- `local8080`: 0 +- Radix ref warnings: 0 +- italic controls: 0 +- measured overflow: 0 diff --git a/apps/tangle-cloud/PRODUCT_BRIEF.md b/apps/tangle-cloud/PRODUCT_BRIEF.md index 34d5958046..1716dca998 100644 --- a/apps/tangle-cloud/PRODUCT_BRIEF.md +++ b/apps/tangle-cloud/PRODUCT_BRIEF.md @@ -28,7 +28,7 @@ Primary actions: Connect wallet, switch network, create service, register operat Trust, risk, and compliance: Money, permissions, and protocol identity must be exact, copyable, and auditable. Destructive or irreversible actions need clear state, provenance, and confirmation. Embedded apps must expose origin, permissions, denied actions, and unavailable bridge methods. Empty/error/loading states must be truthful and actionable. -Design posture: Dense infrastructure console with restrained blueprint identity accents. Use compact tables, split workspaces, tabs, ledgers, event timelines, thin borders, and low-chroma status colors. Cards belong mainly to catalog discovery, repeated items, and modals. +Design posture: Dense infrastructure console with restrained blueprint identity accents. Use compact tables, split workspaces, tabs, ledgers, event timelines, thin borders, and low-chroma status colors. Cards belong mainly to catalog discovery, repeated items, and modals. Cloud should inherit the original Tangle dapp's wallet, network, chrome, and action hierarchy where it improves operator trust, but keep Cloud pages data-first rather than marketing-first. Non-goals: Do not reskin this as the AI trading app. Do not make a marketing landing page, decorative hero wall, app-store gallery, or purple/glow-heavy SaaS dashboard. Do not duplicate sidebar, topbar, breadcrumbs, page headers, tabs, and local nav unless each layer has a distinct job. @@ -39,6 +39,7 @@ Evidence: - `src/pages/services/[id]/page.tsx` mixes service console, blueprint presentation, ACL, jobs, billing, and upgrade surfaces in one vertical stack. - `src/pages/blueprints/[id]/deploy/page.tsx` submits a request but sends users back to blueprint detail instead of request/service status. - Parallel audit reports on 2026-06-05 converged on app/workspace-first IA and infrastructure-console visual direction. +- 2026-06-08 design-system pass restored Tangle dapp-style wallet/network affordances, removed the duplicate Blueprints nav title, converted the catalog to a list-first operator-capacity surface, added blueprint default visuals, and browser-verified desktop/mobile/light/dark render states. Open questions: diff --git a/apps/tangle-cloud/src/app/providers.tsx b/apps/tangle-cloud/src/app/providers.tsx index 7412c8d2b2..5ae0a0c9b8 100644 --- a/apps/tangle-cloud/src/app/providers.tsx +++ b/apps/tangle-cloud/src/app/providers.tsx @@ -3,11 +3,13 @@ import { ToastProvider } from '@tangle-network/sandbox-ui/primitives'; import useLocalChainGuard from '@tangle-network/tangle-shared-ui/hooks/useLocalChainGuard'; import useNetworkSync from '@tangle-network/tangle-shared-ui/hooks/useNetworkSync'; import { IndexerStatusProvider } from '@tangle-network/tangle-shared-ui/context/IndexerStatusContext'; +import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; import { isLocalPreviewHost } from '@tangle-network/tangle-shared-ui/utils/localPreview'; import { FC, type PropsWithChildren, useEffect, useState } from 'react'; import { WagmiProvider } from 'wagmi'; import { ANVIL_LOCAL_NETWORK, + BASE_SEPOLIA_NETWORK, TANGLE_CLOUD_NETWORKS, } from '../constants/networks'; import { cloudWagmiConfig } from './cloudWagmiConfig'; @@ -15,11 +17,29 @@ import PaymentProviders from './PaymentProviders'; // Component to sync network store with wagmi chain const NetworkSync: FC = ({ children }) => { + const forceLocalChain = import.meta.env.VITE_FORCE_LOCAL_CHAIN === 'true'; + const selectedNetwork = useNetworkStore((store) => store.network2); + const setNetwork = useNetworkStore((store) => store.setNetwork); + + const needsCloudNetworkReset = + !forceLocalChain && + selectedNetwork?.evmChainId === ANVIL_LOCAL_NETWORK.evmChainId; + + useEffect(() => { + if (needsCloudNetworkReset) { + setNetwork(BASE_SEPOLIA_NETWORK); + } + }, [needsCloudNetworkReset, setNetwork]); + useNetworkSync(TANGLE_CLOUD_NETWORKS); useLocalChainGuard({ - enabled: isLocalPreviewHost(), + enabled: forceLocalChain && isLocalPreviewHost(), targetChainId: ANVIL_LOCAL_NETWORK.evmChainId ?? 31337, }); + if (needsCloudNetworkReset) { + return null; + } + return children; }; @@ -59,14 +79,14 @@ const Providers: FC = ({ children }) => { reconnectOnMount={reconnectOnMount} > - - - - + + + + {children} - - - + + + ); diff --git a/apps/tangle-cloud/src/components/Header.tsx b/apps/tangle-cloud/src/components/Header.tsx index 4a5d0c36df..c2ddcedf77 100644 --- a/apps/tangle-cloud/src/components/Header.tsx +++ b/apps/tangle-cloud/src/components/Header.tsx @@ -47,10 +47,6 @@ export default function Header({ const pathname = useLocation().pathname; const trail = useMemo(() => getHeaderTrail(pathname), [pathname]); const topNavContent = useTopNavSlotContent(); - const hasContextualConnect = - pathname.startsWith('/rewards') || - pathname.startsWith('/earnings') || - pathname.startsWith('/instances'); return (
- {topNavContent ?? } + {topNavContent ?? + (trail.length > 0 ? : null)}
@@ -76,9 +73,10 @@ export default function Header({ - {!hasContextualConnect && ( - - )} + {/* Connection state lives in the chrome on every route — the header is + * the single owner of Connect Wallet. Pages never duplicate this; a + * disconnected page body renders a designed empty state instead. */} +
); @@ -107,6 +105,7 @@ const getHeaderTrail = (pathname: string): TrailItem[] => { return [blueprints, { label: 'Service' }]; if (pathname.startsWith('/blueprints/') && pathname !== '/blueprints') return [blueprints, { label: 'Details' }]; + if (pathname === '/blueprints') return []; if (pathname.startsWith('/blueprints')) return [blueprints]; if (pathname === '/operators/manage') return [operators, { label: 'Manage' }]; @@ -255,14 +254,15 @@ function CloudNetworkSelector({ networks }: { networks: Network[] }) { NetworkId | null | 'custom' >(null); const [customRpcEndpoint, setCustomRpcEndpoint] = useState(''); + const evmChainId = network?.evmChainId; const isWrongEvmNetwork = useMemo(() => { - if (!isConnected || !network?.evmChainId) { + if (!isConnected || !evmChainId) { return false; } - return network.evmChainId !== chainId; - }, [chainId, isConnected, network?.evmChainId]); + return evmChainId !== chainId; + }, [chainId, evmChainId, isConnected]); const isLoading = isWalletConnecting || isSwitchingChain; const networkName = isLoading ? 'Connecting' : (network?.name ?? 'Network'); @@ -296,12 +296,12 @@ function CloudNetworkSelector({ networks }: { networks: Network[] }) { }, [customRpcEndpoint, setNetwork]); const switchToCorrectEvmChain = useCallback(() => { - if (!network?.evmChainId || !switchChain) { + if (!evmChainId || !switchChain) { return; } - switchChain({ chainId: network.evmChainId }); - }, [network?.evmChainId, switchChain]); + switchChain({ chainId: evmChainId }); + }, [evmChainId, switchChain]); return (
@@ -325,14 +325,16 @@ function CloudNetworkSelector({ networks }: { networks: Network[] }) { type="button" variant="outline" disabled={isLoading} - className="h-11 gap-2 border-border bg-muted/30 px-3 font-bold text-foreground hover:bg-muted" + className="h-11 gap-2 border-border bg-muted/30 px-3 font-sans font-medium not-italic text-foreground hover:bg-muted" > {isLoading ? ( ) : ( )} - {networkName} + + {networkName} + @@ -355,7 +357,9 @@ function CloudNetworkSelector({ networks }: { networks: Network[] }) { ) : ( )} - {item.name} + + {item.name} + {isSelected && ( (null); const displayName = formatBlueprintName(blueprint.name); const style = getBlueprintVisualStyle(`${displayName}:${category}`); + const hasImageError = imageErrorUrl === imageUrl; const resolvedImageUrl = imageUrl && !hasImageError ? imageUrl : null; - useEffect(() => { - setHasImageError(false); - }, [imageUrl]); - return (
{resolvedImageUrl ? ( @@ -49,7 +45,7 @@ export const BlueprintVisual = ({ alt="" className="absolute inset-0 h-full w-full object-cover opacity-80" loading="lazy" - onError={() => setHasImageError(true)} + onError={() => setImageErrorUrl(imageUrl)} />
diff --git a/apps/tangle-cloud/src/components/chrome/CommandPalette.tsx b/apps/tangle-cloud/src/components/chrome/CommandPalette.tsx index 45197bb4c8..a4ded5f19d 100644 --- a/apps/tangle-cloud/src/components/chrome/CommandPalette.tsx +++ b/apps/tangle-cloud/src/components/chrome/CommandPalette.tsx @@ -153,8 +153,10 @@ const CommandPalette: FC = ({ open, onOpenChange, extra = [] }) => { // Reset on open / collapse on close so the next invocation starts fresh. useEffect(() => { if (open) { - setQuery(''); - setActiveIndex(0); + queueMicrotask(() => { + setQuery(''); + setActiveIndex(0); + }); // Focus on next tick so the Dialog's autofocus doesn't steal it. const id = window.setTimeout(() => inputRef.current?.focus(), 10); return () => window.clearTimeout(id); @@ -163,7 +165,9 @@ const CommandPalette: FC = ({ open, onOpenChange, extra = [] }) => { }, [open]); useEffect(() => { - setActiveIndex((i) => Math.min(i, Math.max(0, filtered.length - 1))); + queueMicrotask(() => { + setActiveIndex((i) => Math.min(i, Math.max(0, filtered.length - 1))); + }); }, [filtered.length]); const onKeyDown = (e: React.KeyboardEvent) => { diff --git a/apps/tangle-cloud/src/components/chrome/CopyableId.tsx b/apps/tangle-cloud/src/components/chrome/CopyableId.tsx new file mode 100644 index 0000000000..5dbccf841b --- /dev/null +++ b/apps/tangle-cloud/src/components/chrome/CopyableId.tsx @@ -0,0 +1,103 @@ +import { useCallback, useState, type FC } from 'react'; +import { twMerge } from 'tailwind-merge'; + +type Props = { + /** The full value copied to the clipboard (id, hash, address). */ + value: string | null | undefined; + /** + * Display text. Defaults to a middle-truncated form of `value` when it looks + * like a hash/address (long + hex), otherwise the raw value. + */ + display?: string; + /** Middle-truncate hex-looking values to `head…tail`. Default true. */ + truncate?: boolean; + className?: string; +}; + +function middleTruncate(value: string, head = 6, tail = 4): string { + if (value.length <= head + tail + 1) return value; + return `${value.slice(0, head)}…${value.slice(-tail)}`; +} + +/** + * Copyable identifier cell — mono, theme-aware, click-to-copy. The canonical + * way to render an id / tx hash / address inside a table or detail row so the + * operator can audit and copy the exact value (principle 2: copyable IDs). Long + * hex values are middle-truncated for scan density; the full value is always in + * `title` and on the clipboard. + */ +const CopyableId: FC = ({ + value, + display, + truncate = true, + className, +}) => { + const [copied, setCopied] = useState(false); + + const onCopy = useCallback(() => { + if (!value) return; + void navigator.clipboard.writeText(value).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }); + }, [value]); + + if (!value) { + return ; + } + + const looksHex = /^0x[0-9a-fA-F]{8,}$/.test(value); + const shown = + display ?? (truncate && looksHex ? middleTruncate(value) : value); + + return ( + + ); +}; + +export default CopyableId; diff --git a/apps/tangle-cloud/src/components/chrome/DataTable.tsx b/apps/tangle-cloud/src/components/chrome/DataTable.tsx new file mode 100644 index 0000000000..de871c7d92 --- /dev/null +++ b/apps/tangle-cloud/src/components/chrome/DataTable.tsx @@ -0,0 +1,177 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@tangle-network/sandbox-ui/primitives'; +import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { focus, typeRole } from '../../styles/chrome'; + +export type DataColumn = { + /** Column header. */ + header: ReactNode; + /** Cell renderer for a row. */ + render: (item: T, index: number) => ReactNode; + /** + * Alignment. Use `right` for amounts/numerics so columns line up on the + * decimal — the senior move for a ledger. + */ + align?: 'left' | 'right' | 'center'; + /** Render this column's cells as mono tabular numerics (IDs, hashes, amounts). */ + mono?: boolean; + /** Width / sizing class for the ``/`` (e.g. `w-24`, `w-[1%]`). */ + className?: string; + /** Header-cell class override. */ + headerClassName?: string; + /** Fold this column on narrow viewports. */ + hideBelow?: 'sm' | 'md' | 'lg' | 'xl'; +}; + +type Props = { + items: T[]; + columns: DataColumn[]; + rowKey: (item: T, index: number) => string; + /** Make rows clickable (entire row is the target). */ + onRowClick?: (item: T, index: number) => void; + /** Caption for screen readers. */ + caption?: string; + /** Empty slot — pass an for the zero-row case. */ + empty?: ReactNode; + className?: string; +}; + +const HIDE_CLASS: Record< + NonNullable['hideBelow']>, + string +> = { + sm: 'hidden sm:table-cell', + md: 'hidden md:table-cell', + lg: 'hidden lg:table-cell', + xl: 'hidden xl:table-cell', +}; + +const ALIGN_CLASS: Record['align']>, string> = { + left: 'text-left', + right: 'text-right', + center: 'text-center', +}; + +/** + * Dense console table — the design-system instrument for in-page ledgers, + * job history, payout events, and any N-row homogeneous data inside a detail + * surface. Wraps the design-system `Table` family with the console defaults the + * brief mandates: mono tabular numerics, right-aligned amounts, copyable IDs + * (via the `mono` + `` helpers), thin borders, row-as-target. + * + * Use this — NOT a hand-rolled `` (the F4-svc bug) — whenever the data + * is tabular and lives inside a page. For top-level catalog/directory pages + * that need search + view-toggle, use `ResultList` instead. + * + * j.id} + * columns={[ + * { header: 'Job', render: (j) => , mono: true }, + * { header: 'Amount', align: 'right', mono: true, + * render: (j) => }, + * { header: 'Status', render: (j) => + * {j.status} }, + * ]} + * /> + */ +function DataTable({ + items, + columns, + rowKey, + onRowClick, + caption, + empty, + className, +}: Props) { + if (items.length === 0 && empty !== undefined) { + return empty; + } + + return ( +
+
+ {caption && } + + + {columns.map((col, i) => ( + + {col.header} + + ))} + + + + {items.map((item, index) => { + const interactive = onRowClick !== undefined; + return ( + onRowClick(item, index) : undefined + } + onKeyDown={ + interactive + ? (e: ReactKeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onRowClick(item, index); + } + } + : undefined + } + className={twMerge( + 'border-b border-border/60 last:border-b-0', + interactive && + twMerge( + 'cursor-pointer transition-colors hover:bg-[color:var(--bg-hover)]/60', + focus.ring, + ), + )} + > + {columns.map((col, i) => ( + + {col.render(item, index)} + + ))} + + ); + })} + +
{caption}
+
+ ); +} + +export default DataTable; diff --git a/apps/tangle-cloud/src/components/chrome/FilterTray.tsx b/apps/tangle-cloud/src/components/chrome/FilterTray.tsx index 46e07396a4..89f55979c8 100644 --- a/apps/tangle-cloud/src/components/chrome/FilterTray.tsx +++ b/apps/tangle-cloud/src/components/chrome/FilterTray.tsx @@ -44,7 +44,7 @@ const FilterTray: FC = ({ diff --git a/apps/tangle-cloud/src/components/chrome/Money.tsx b/apps/tangle-cloud/src/components/chrome/Money.tsx new file mode 100644 index 0000000000..d082de4dc6 --- /dev/null +++ b/apps/tangle-cloud/src/components/chrome/Money.tsx @@ -0,0 +1,127 @@ +import { useCallback, useState, type FC } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { + formatMoney, + type FormatMoneyOptions, + type MoneyView, +} from '../../styles/chrome'; + +type Props = { + /** Exact on-chain value. The component derives a lossless view from it. */ + value: bigint | null | undefined; + /** Formatting contract — decimals, symbol, compact display. */ + options?: FormatMoneyOptions; + /** Pre-built view (when the caller already computed one). Overrides `value`. */ + view?: MoneyView; + /** + * Show the copy-full-precision affordance. Default true. Disable in extremely + * dense rows where copy lives elsewhere; the full value is still in `title`. + */ + copyable?: boolean; + /** Render the unit/symbol after the number. Default true. */ + showSymbol?: boolean; + /** Right-align (the default for amounts in tables/ledgers). */ + align?: 'left' | 'right'; + className?: string; +}; + +/** + * Canonical money cell. Renders the compact display from a {@link MoneyView}, + * carries the unit, exposes the full-precision value on hover (`title`) and one + * click away (copy). This is the ONLY way money should render on the console — + * it makes the on-chain bigint exact, copyable, and auditable, which the brief + * mandates and the old `parseFloat` formatter violated. + * + * + * + * Empty values render an em-dash (never a fake `0`). + */ +const Money: FC = ({ + value, + options, + view, + copyable = true, + showSymbol = true, + align = 'right', + className, +}) => { + const money = view ?? formatMoney(value, options); + const [copied, setCopied] = useState(false); + + const onCopy = useCallback(() => { + if (money.isEmpty) return; + if (typeof navigator === 'undefined' || !navigator.clipboard) return; + + void navigator.clipboard.writeText(money.full).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }); + }, [money.full, money.isEmpty]); + + const fullLabel = money.isEmpty + ? 'No value' + : `${money.full}${money.symbol ? ` ${money.symbol}` : ''}`; + + return ( + + {money.display} + {showSymbol && money.symbol && !money.isEmpty && ( + + {money.symbol} + + )} + {copyable && !money.isEmpty && ( + + )} + + ); +}; + +const CopyGlyph: FC = () => ( + + + + +); + +const CheckGlyph: FC = () => ( + + + +); + +export default Money; diff --git a/apps/tangle-cloud/src/components/chrome/PageMotion.tsx b/apps/tangle-cloud/src/components/chrome/PageMotion.tsx index cc31613046..b1d667c401 100644 --- a/apps/tangle-cloud/src/components/chrome/PageMotion.tsx +++ b/apps/tangle-cloud/src/components/chrome/PageMotion.tsx @@ -34,7 +34,9 @@ const PageMotion: FC = ({ children, className }) => { const [entering, setEntering] = useState(false); useEffect(() => { - setEntering(true); + queueMicrotask(() => { + setEntering(true); + }); // Two rAFs: one to commit the entering state, one to clear it after the // browser has had a chance to render the initial keyframe state. The // CSS animation handles the actual transition; the attribute is just a diff --git a/apps/tangle-cloud/src/components/chrome/PageToolbar.tsx b/apps/tangle-cloud/src/components/chrome/PageToolbar.tsx index ec42aa8c25..3b044d47c1 100644 --- a/apps/tangle-cloud/src/components/chrome/PageToolbar.tsx +++ b/apps/tangle-cloud/src/components/chrome/PageToolbar.tsx @@ -45,12 +45,12 @@ const PageToolbar: FC = ({
-
+
= ({ placeholder={search.placeholder} autoFocus={search.autoFocus} className={twMerge( - 'h-9 w-full rounded-md border border-transparent bg-transparent pl-9 pr-3 text-sm leading-tight text-foreground placeholder:text-muted-foreground/70', - 'hover:border-border', + 'h-9 w-full rounded-md border border-transparent bg-transparent pl-9 pr-3 font-sans text-sm leading-tight text-foreground not-italic placeholder:text-muted-foreground/70', + 'hover:border-border focus:border-[color:var(--border-accent-hover)]', focus.ring, )} /> @@ -95,7 +95,9 @@ const PageToolbar: FC = ({ )} {trailing !== undefined && ( -
{trailing}
+
+ {trailing} +
)}
); diff --git a/apps/tangle-cloud/src/components/chrome/ResultList.tsx b/apps/tangle-cloud/src/components/chrome/ResultList.tsx index e18b7778de..99d43775cb 100644 --- a/apps/tangle-cloud/src/components/chrome/ResultList.tsx +++ b/apps/tangle-cloud/src/components/chrome/ResultList.tsx @@ -53,12 +53,12 @@ function ResultList({ return (
{/* Header */} -
+
{columns.map((col, i) => (
({ : undefined } className={twMerge( - 'flex items-center border-b border-border/60 px-4 py-3 text-sm last:border-b-0', + 'flex items-center border-b border-border/60 bg-[color:var(--bg-card)] px-4 py-3.5 text-sm last:border-b-0', interactive && - 'cursor-pointer transition-colors hover:bg-[color:var(--bg-hover)]/60', + 'cursor-pointer transition-colors hover:bg-[color:var(--bg-hover)]', interactive && focus.ring, )} > diff --git a/apps/tangle-cloud/src/components/chrome/StatusPill.tsx b/apps/tangle-cloud/src/components/chrome/StatusPill.tsx index b48f26f9a3..d5d10b2994 100644 --- a/apps/tangle-cloud/src/components/chrome/StatusPill.tsx +++ b/apps/tangle-cloud/src/components/chrome/StatusPill.tsx @@ -1,38 +1,63 @@ import type { FC, ReactNode } from 'react'; import { twMerge } from 'tailwind-merge'; -import { statusPill, type StatusTone } from '../../styles/chrome'; +import { statusDot, statusPill, type StatusTone } from '../../styles/chrome'; type Props = { tone?: StatusTone; children: ReactNode; className?: string; - /** Optional leading icon (sized 12px). */ + /** Optional leading icon (sized 12px). Overrides the auto tone dot. */ icon?: ReactNode; + /** + * Render a small leading tone dot when no `icon` is supplied. Default true. + * The dot reads the same hue as the pill border so the status is legible at a + * glance even before the label. `pending` tone pulses softly. + */ + dot?: boolean; }; /** - * Outlined status pill — single tone per status, never filled. Use for - * service state, audit state, capacity state, network state. **Not** for - * decoration; pills should always reflect a model field the operator can act - * on. + * Outlined status pill — single tone per status, never filled. The one + * canonical status badge for the whole console: service / instance / job / + * operator / payment state, audit state, capacity state. + * + * Pair with `statusToneFor(domain, status)` so a domain status maps to a tone + * in exactly one place: + * + * Active + * + * **Not** for decoration; a pill should always reflect a model field the + * operator can act on. */ const StatusPill: FC = ({ tone = 'neutral', children, className, icon, + dot = true, }) => ( - {icon !== undefined && ( + {icon !== undefined ? ( {icon} + ) : ( + dot && ( + + ) )} {children} diff --git a/apps/tangle-cloud/src/components/chrome/TopNavSlot.tsx b/apps/tangle-cloud/src/components/chrome/TopNavSlot.tsx index 36abb8d811..a9c77fbfb2 100644 --- a/apps/tangle-cloud/src/components/chrome/TopNavSlot.tsx +++ b/apps/tangle-cloud/src/components/chrome/TopNavSlot.tsx @@ -2,9 +2,14 @@ import { createContext, useContext, useEffect, - useState, + useMemo, type ReactNode, } from 'react'; +import { useState } from 'react'; +import { Link } from 'react-router'; +import { twMerge } from 'tailwind-merge'; +import StatusPill from './StatusPill'; +import type { StatusTone } from '../../styles/chrome'; /** * Lets a page inject content (pills, contextual actions) into the global top @@ -47,3 +52,85 @@ export function useTopNavSlot(node: ReactNode): void { return () => ctx.setContent(null); }, [ctx, node]); } + +/** + * Ergonomic publish API for detail pages: declare the entity identity (the + * section breadcrumb + the entity name), an optional status, and an optional + * primary action, and this builds the canonical top-nav row and publishes it. + * + * This is the single place that owns the breadcrumb + identity + status + + * action layout, so every detail page reads identically in the chrome without + * re-implementing truncation, the `ml-auto` action group, or the breadcrumb + * separator. A detail page adopts the contextual top nav with one call: + * + * useTopNavEntity({ + * section: 'Instances', + * sectionHref: PagePath.INSTANCES, + * name: service.name, + * status: { label: service.status, tone: statusToneFor('service', service.status) }, + * actions: , + * }); + * + * Principle map #8: global controls live in the chrome; the contextual top bar + * carries entity identity + the single primary action. + */ +export type TopNavEntity = { + /** Section root label, e.g. "Instances" / "Blueprints". */ + section: string; + /** Section root href; the label links back to the section catalog. */ + sectionHref: string; + /** The entity name (truncates). The leaf of the breadcrumb. */ + name: ReactNode; + /** + * Optional status pill rendered next to the name — the one canonical badge, + * never a hand-rolled chip. Use `statusToneFor(domain, status)` for the tone. + */ + status?: { label: ReactNode; tone: StatusTone }; + /** Optional right-aligned primary action(s). Keep to one button on most pages. */ + actions?: ReactNode; +}; + +// eslint-disable-next-line react-refresh/only-export-components +export function useTopNavEntity(entity: TopNavEntity | null): void { + const { section, sectionHref, name, status, actions } = entity ?? {}; + const statusLabel = status?.label; + const statusTone = status?.tone; + + const node = useMemo(() => { + if (!entity) return null; + return ( + <> + + {statusTone !== undefined && ( + + {statusLabel} + + )} + {actions !== undefined && ( +
+ {actions} +
+ )} + + ); + // `entity` is included so a null->object transition re-publishes; the + // primitive fields drive the memo so a parent that rebuilds the object each + // render (without changing values) doesn't thrash the publish effect. + }, [entity, section, sectionHref, name, statusLabel, statusTone, actions]); + + useTopNavSlot(node); +} diff --git a/apps/tangle-cloud/src/components/chrome/ViewToggle.tsx b/apps/tangle-cloud/src/components/chrome/ViewToggle.tsx index 432bc267e9..1b2b813152 100644 --- a/apps/tangle-cloud/src/components/chrome/ViewToggle.tsx +++ b/apps/tangle-cloud/src/components/chrome/ViewToggle.tsx @@ -47,7 +47,7 @@ const ViewToggle: FC = ({ value, onChange, className }) => { role="radiogroup" aria-label="View" className={twMerge( - 'inline-flex h-9 items-center rounded-md border border-border bg-transparent p-0.5', + 'inline-flex h-9 items-center rounded-md border border-border bg-muted/30 p-0.5', className, )} > @@ -79,7 +79,7 @@ const ViewButton: FC<{ onClick={onClick} title={kind === 'grid' ? 'Grid view' : 'List view'} className={twMerge( - 'inline-flex h-7 w-8 items-center justify-center rounded transition-colors', + 'inline-flex h-7 w-8 items-center justify-center rounded font-sans not-italic transition-colors', active ? 'bg-[color:var(--bg-hover)] text-foreground' : 'text-muted-foreground hover:text-foreground', diff --git a/apps/tangle-cloud/src/components/chrome/index.ts b/apps/tangle-cloud/src/components/chrome/index.ts index 85f710f08e..c7a95ad95b 100644 --- a/apps/tangle-cloud/src/components/chrome/index.ts +++ b/apps/tangle-cloud/src/components/chrome/index.ts @@ -20,6 +20,11 @@ * */ +export { default as CopyableId } from './CopyableId'; + +export { default as DataTable } from './DataTable'; +export type { DataColumn } from './DataTable'; + export { default as EmptyState } from './EmptyState'; export type { EmptyKind } from './EmptyState'; @@ -28,6 +33,8 @@ export { default as FilterTray } from './FilterTray'; export { default as MetricStrip } from './MetricStrip'; export type { Metric, MetricTone } from './MetricStrip'; +export { default as Money } from './Money'; + export { default as PageHeader } from './PageHeader'; export { default as PageToolbar } from './PageToolbar'; @@ -41,3 +48,20 @@ export { default as StatusPill } from './StatusPill'; export { default as ViewToggle } from './ViewToggle'; export type { ResultView } from './ViewToggle'; + +// Design-system contracts (tokens + helpers) — re-exported so surfaces have a +// single import path for the whole chrome system. The status tone mapping and +// the money formatter are the two canonical contracts every surface shares. +export { + formatMoney, + statusDot, + statusPill, + statusToneFor, + typeRole, +} from '../../styles/chrome'; +export type { + FormatMoneyOptions, + MoneyView, + StatusDomain, + StatusTone, +} from '../../styles/chrome'; diff --git a/apps/tangle-cloud/src/components/sandbox/SandboxUi.tsx b/apps/tangle-cloud/src/components/sandbox/SandboxUi.tsx index 62c0e8e150..842de38d1c 100644 --- a/apps/tangle-cloud/src/components/sandbox/SandboxUi.tsx +++ b/apps/tangle-cloud/src/components/sandbox/SandboxUi.tsx @@ -33,7 +33,14 @@ import { TabsTrigger, Textarea, } from '@tangle-network/sandbox-ui/primitives'; -import type { ChangeEvent, ComponentProps, FC, ReactNode } from 'react'; +import { + forwardRef, + type ChangeEvent, + type ComponentProps, + type ElementRef, + type FC, + type ReactNode, +} from 'react'; // Re-export the canonical tangle-cloud Text from the dedicated module so we // preserve the existing import surface (`import { Text } from '...sandbox/SandboxUi'`) @@ -149,42 +156,50 @@ export type ButtonProps = Omit< disabledTooltip?: string; }; -export const Button: FC = ({ - variant, - size, - isDisabled, - isLoading, - isJustIcon, - isFullWidth, - leftIcon, - rightIcon, - loadingText: _loadingText, - disabledTooltip: _disabledTooltip, - disabled, - className = '', - children, - ...props -}) => ( - - {leftIcon} - {children} - {rightIcon} - +export const Button = forwardRef, ButtonProps>( + function Button( + { + variant, + size, + isDisabled, + isLoading, + isJustIcon, + isFullWidth, + leftIcon, + rightIcon, + loadingText: _loadingText, + disabledTooltip: _disabledTooltip, + disabled, + className = '', + children, + ...props + }, + ref, + ) { + return ( + + {leftIcon} + {children} + {rightIcon} + + ); + }, ); export type InputProps = Omit< diff --git a/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx b/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx index 3413149860..80c6b44797 100644 --- a/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx @@ -6,7 +6,16 @@ import { Skeleton, } from '../../components/sandbox/SandboxUi'; import { formatBlueprintName } from '../../components/blueprints/blueprintVisualUtils'; -import { EmptyState, FilterTray, PageToolbar } from '../../components/chrome'; +import { + EmptyState, + FilterTray, + PageToolbar, + ResultList, + StatusPill, + ViewToggle, + statusToneFor, + type ResultView, +} from '../../components/chrome'; import { enumCodec, intCodec, @@ -19,6 +28,7 @@ import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint import { Dispatch, FC, + ReactNode, SetStateAction, useCallback, useDeferredValue, @@ -35,34 +45,65 @@ import { fetchAttestations, fetchAuditorOnChain, } from '@tangle-network/tangle-shared-ui/data/blueprints/useBinaryVersions'; +import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; import { AttestationKind, type Auditor, } from '@tangle-network/tangle-shared-ui/blueprintApps/trustScore'; import { auditorFallbackRegistry } from '../../auditors'; -import { Link } from 'react-router'; +import { Link, useNavigate } from 'react-router'; import { twMerge } from 'tailwind-merge'; import { PagePath } from '../../types'; import { dedupeBlueprintsByIdentity, type DedupedBlueprintRow, } from '../../blueprintApps/dedupe'; +import { BlueprintVisual } from '../../components/blueprints/BlueprintVisual'; const PAGE_SIZE = 12; -type AudienceFilter = 'all' | 'customers' | 'operators'; +type AvailabilityFilter = 'all' | 'deployable' | 'capacity'; type ManifestFilter = 'all' | 'verified' | 'fallback'; type AuditFilter = 'all' | 'audited'; +type FilterOption = { + value: T; + label: string; + description?: string; +}; +type CatalogStat = { + label: string; + value: number; + detail: string; +}; type Props = { rowSelection?: RowSelectionState; onRowSelectionChange?: Dispatch>; onRegisterBlueprint?: (blueprint: Blueprint) => void; + toolbarAction?: ReactNode; + onRetry?: () => void; } & Omit; const pluralize = (label: string, count: number) => count === 1 ? label : `${label}s`; +const availabilityOptions: FilterOption[] = [ + { value: 'all', label: 'All' }, + { value: 'deployable', label: 'Deployable' }, + { value: 'capacity', label: 'Needs capacity' }, +]; + +const sourceOptions: FilterOption[] = [ + { value: 'all', label: 'All sources' }, + { value: 'verified', label: 'Pinned source' }, + { value: 'fallback', label: 'Chain-only' }, +]; + +const trustOptions: FilterOption[] = [ + { value: 'all', label: 'All trust' }, + { value: 'audited', label: 'Audited' }, +]; + /** * Title-case a single tag so the catalog renders consistent chips even if * the publisher's on-chain string is lowercase or mixed-case ("inference" @@ -171,6 +212,13 @@ const matchesSearch = (blueprint: Blueprint, query: string) => { return haystack.includes(query); }; +const getBlueprintDescription = (blueprint: Blueprint) => + blueprint.description?.trim() || + 'Service blueprint ready for deployment once capacity is available.'; + +const getModeCount = (row: DedupedBlueprintRow) => + row.modes?.length ?? (row.aliases.length > 0 ? row.aliases.length + 1 : 1); + /** * Multi-select category dropdown for the toolbar. Replaces the old * full-width segmented row — categories collapse into one pill with a @@ -189,7 +237,7 @@ const CategoryFilterMenu: FC<{ @@ -240,7 +288,7 @@ const CategoryFilterMenu: FC<{ type="button" onClick={() => onToggle(category)} className={twMerge( - 'flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm text-foreground transition-colors hover:bg-[color:var(--bg-hover)]', + 'flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left font-sans text-sm text-foreground not-italic transition-colors hover:bg-[color:var(--bg-hover)]', focus.ring, )} > @@ -269,6 +317,106 @@ const CategoryFilterMenu: FC<{ ); }; +const BlueprintCatalogHeader: FC<{ + matchCount: number; + totalCount: number; + stats: CatalogStat[]; +}> = ({ matchCount, totalCount, stats }) => ( +
+
+
+

+ Blueprints +

+

+ Live service catalog with capacity, source, and trust signals for the + selected network. +

+
+ + {matchCount.toLocaleString()} + {matchCount !== totalCount && ( + + {' / '} + {totalCount.toLocaleString()} + + )} + + {pluralize('blueprint', matchCount)} +
+
+ +
+ {stats.map((stat) => ( +
+
+ {stat.value.toLocaleString()} +
+
{stat.label}
+
+ {stat.detail} +
+
+ ))} +
+
+
+); + +function FilterSegment({ + label, + options, + value, + onChange, + stacked = false, +}: { + label: string; + options: FilterOption[]; + value: T; + onChange: (value: T) => void; + stacked?: boolean; +}) { + return ( +
+ {stacked && {label}} +
+ {options.map((option) => { + const active = option.value === value; + return ( + + ); + })} +
+
+ ); +} + const BlueprintListing: FC = ({ rowSelection, onRowSelectionChange, @@ -276,13 +424,20 @@ const BlueprintListing: FC = ({ isLoading, error, onRegisterBlueprint, + toolbarAction, + onRetry, }) => { + const navigate = useNavigate(); // Filter state lives in the URL — refresh persists the view, deep-links are // shareable, the back button works. Defaults are omitted from the URL so a // bare /blueprints stays clean. `replace: true` (in `useUrlState`) means // every keystroke doesn't pollute the history stack. const [page, setPage] = useUrlState('page', intCodec(0)); const [searchQuery, setSearchQuery] = useUrlState('q', stringCodec('')); + const [view, setView] = useUrlState( + 'view', + enumCodec(['grid', 'list'] as const, 'list'), + ); // Multi-select categories, comma-joined in the URL (empty = all). Single // dropdown in the toolbar replaces the old full-width category row. const [categoryParam, setCategoryParam] = useUrlState( @@ -302,10 +457,11 @@ const BlueprintListing: FC = ({ }, [selectedCategories, setCategoryParam], ); - const [audienceFilter, setAudienceFilter] = useUrlState( - 'avail', - enumCodec(['all', 'customers', 'operators'] as const, 'all'), - ); + const [availabilityFilter, setAvailabilityFilter] = + useUrlState( + 'avail', + enumCodec(['all', 'deployable', 'capacity'] as const, 'all'), + ); const [manifestFilter, setManifestFilter] = useUrlState( 'source', enumCodec(['all', 'verified', 'fallback'] as const, 'all'), @@ -322,7 +478,8 @@ const BlueprintListing: FC = ({ return Array.from(blueprints.values()).sort((a, b) => { if (a.isBoosted && !b.isBoosted) return -1; if (!a.isBoosted && b.isBoosted) return 1; - return Number(b.id - a.id); + if (a.id === b.id) return 0; + return a.id < b.id ? 1 : -1; }); }, [blueprints]); @@ -358,14 +515,14 @@ const BlueprintListing: FC = ({ } if ( - audienceFilter === 'customers' && + availabilityFilter === 'deployable' && (blueprint.operatorsCount ?? 0) <= 0 ) { return false; } if ( - audienceFilter === 'operators' && + availabilityFilter === 'capacity' && (blueprint.operatorsCount ?? 0) >= 3 ) { return false; @@ -389,19 +546,22 @@ const BlueprintListing: FC = ({ return true; }); }, [ - audienceFilter, + availabilityFilter, auditFilter, auditedStatus, blueprintItems, deferredSearchQuery, manifestFilter, - categoryParam, + selectedCategories, ]); + // `useUrlState` returns a new setter each render; depending on it creates a + // page-reset loop. Filter values are the actual synchronization boundary. useEffect(() => { setPage(0); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - audienceFilter, + availabilityFilter, auditFilter, deferredSearchQuery, manifestFilter, @@ -418,6 +578,47 @@ const BlueprintListing: FC = ({ () => dedupeBlueprintsByIdentity(filteredBlueprints), [filteredBlueprints], ); + const allDedupedRows = useMemo( + () => dedupeBlueprintsByIdentity(blueprintItems), + [blueprintItems], + ); + const catalogStats = useMemo(() => { + let deployable = 0; + let capacityNeeded = 0; + let pinnedSource = 0; + let audited = 0; + + for (const { blueprint } of allDedupedRows) { + const operatorCount = blueprint.operatorsCount ?? 0; + if (operatorCount > 0) deployable += 1; + if (operatorCount < 3) capacityNeeded += 1; + if (hasVerifiedManifest(blueprint)) pinnedSource += 1; + if (auditedStatus.get(blueprint.id.toString()) === true) audited += 1; + } + + return [ + { + label: 'Deployable', + value: deployable, + detail: 'ready now', + }, + { + label: 'Need capacity', + value: capacityNeeded, + detail: 'below target', + }, + { + label: 'Pinned', + value: pinnedSource, + detail: 'verified source', + }, + { + label: 'Audited', + value: audited, + detail: 'trust signal', + }, + ]; + }, [allDedupedRows, auditedStatus]); const totalPages = Math.max(1, Math.ceil(dedupedRows.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); const visibleRows = dedupedRows.slice( @@ -427,31 +628,144 @@ const BlueprintListing: FC = ({ const hasActiveFilters = searchQuery.trim() !== '' || selectedCategories.length > 0 || - audienceFilter !== 'all' || + availabilityFilter !== 'all' || manifestFilter !== 'all' || auditFilter !== 'all'; const showSelection = typeof rowSelection !== 'undefined' && typeof onRowSelectionChange === 'function'; + const effectiveView = showSelection ? 'grid' : view; - if (isLoading) { + const listColumns = useMemo( + () => [ + { + header: 'Blueprint', + className: 'min-w-0 flex-[2.4]', + render: (row: DedupedBlueprintRow) => ( + + ), + }, + { + header: 'Capacity', + className: 'w-40', + hideBelow: 'md' as const, + render: (row: DedupedBlueprintRow) => ( + + ), + }, + { + header: 'Trust', + className: 'w-28', + hideBelow: 'md' as const, + render: (row: DedupedBlueprintRow) => { + const audited = + auditedStatus.get(row.blueprint.id.toString()) ?? false; + return ( + + {audited ? 'Audited' : 'Unaudited'} + + ); + }, + }, + { + header: 'Source', + className: 'w-32', + hideBelow: 'lg' as const, + render: (row: DedupedBlueprintRow) => { + const verified = hasVerifiedManifest(row.blueprint); + return ( + + {verified ? 'Pinned' : 'Chain-only'} + + ); + }, + }, + { + header: 'Usage', + className: 'w-24 justify-end', + hideBelow: 'lg' as const, + render: (row: DedupedBlueprintRow) => ( + + {(row.blueprint.instancesCount ?? 0).toLocaleString()} + + ), + }, + { + header: 'Actions', + className: 'w-52 justify-end sm:w-56', + render: (row: DedupedBlueprintRow) => { + const { blueprint } = row; + const blueprintHref = `${PagePath.BLUEPRINTS}/${blueprint.id.toString()}`; + const hasOperators = (blueprint.operatorsCount ?? 0) > 0; + + return ( +
+ {hasOperators && ( + event.stopPropagation()} + className={catalogPrimaryActionClass} + > + Deploy + + )} + +
+ ); + }, + }, + ], + [auditedStatus, onRegisterBlueprint], + ); + + if (isLoading && blueprintItems.length === 0) { return ( -
- {Array.from({ length: 6 }).map((_, idx) => ( - - ))} +
+ +
+ {Array.from({ length: 6 }).map((_, idx) => ( + + ))} +
); } - if (error) { + if (error && blueprintItems.length === 0) { return ( - -

- {error.message} -

+ +
+

+ Unable to refresh blueprints +

+

+ {error.message} +

+
+ {onRetry !== undefined && ( + + )}
); @@ -462,41 +776,53 @@ const BlueprintListing: FC = ({ ); } - // Count active non-default filters — drives the FilterTray's active badge - // so the operator sees at a glance how many constraints they have on. - const activeFilterCount = - (audienceFilter !== 'all' ? 1 : 0) + - (manifestFilter !== 'all' ? 1 : 0) + - (auditFilter !== 'all' ? 1 : 0); + const trayFilterCount = + (manifestFilter !== 'all' ? 1 : 0) + (auditFilter !== 'all' ? 1 : 0); const resetAllFilters = () => { setSearchQuery(''); setCategoryParam(''); - setAudienceFilter('all'); + setAvailabilityFilter('all'); setManifestFilter('all'); setAuditFilter('all'); }; return (
+ + + } count={{ matches: dedupedRows.length, - total: blueprintItems.length, + total: allDedupedRows.length, noun: 'matches', }} trailing={ -
+
+ {toolbarAction} + {!showSelection && } = ({ onClear={() => setCategoryParam('')} /> - - - - - + + +
} /> + {error && ( +
+ Showing cached blueprint data. Latest refresh failed: {error.message} +
+ )} + {dedupedRows.length === 0 ? ( = ({ ) } /> + ) : effectiveView === 'list' ? ( + <> +
+ {visibleRows.map((row) => ( + + ))} +
+ row.blueprint.id.toString()} + onRowClick={(row) => + navigate(`${PagePath.BLUEPRINTS}/${row.blueprint.id.toString()}`) + } + /> + ) : (
{visibleRows.map((row) => ( @@ -647,7 +971,9 @@ export default BlueprintListing; * the same react-query cache entry — no duplicate chain reads. */ const useAuditedStatusMap = (blueprintIds: bigint[]): Map => { - const chainId = useChainId(); + const wagmiChainId = useChainId(); + const networkChainId = useNetworkStore((store) => store.network2?.evmChainId); + const chainId = networkChainId ?? wagmiChainId; const publicClient = usePublicClient({ chainId }); const fallback = useMemo(() => auditorFallbackRegistry(), []); @@ -747,7 +1073,7 @@ const useAuditedStatusMap = (blueprintIds: bigint[]): Map => { const nowSeconds = Math.floor(Date.now() / 1000); const hasAuditedAttestation = attestations.some((row) => { if (row.revoked) return false; - if (row.expiresAt !== 0n && Number(row.expiresAt) <= nowSeconds) { + if (row.expiresAt !== 0n && row.expiresAt <= BigInt(nowSeconds)) { return false; } if (row.kind === AttestationKind.SELF) return false; @@ -773,27 +1099,175 @@ const useAuditedStatusMap = (blueprintIds: bigint[]): Map => { return map; }; -/** - * Catalog card — the operator/customer's primary scan surface. - * - * Hard rule: ONE fact per visual line, three lines of information total. - * - * Line 1: title (large, primary read) - * Line 2: one-line description (truncated) - * Line 3: status — "Ready · N operators" OR "Needs operators" - * - * Everything else is hover-revealed (action buttons) or absent (publisher - * address, category pill, deployment-modes pill, source-pinning pill, - * three-cell metric grid, github link). The detail page is one click away - * and surfaces those facts where they belong. - * - * Distinctive identity (so the wall doesn't look like a shadcn template - * gallery): the blueprint id renders as a large mono "watermark" in the - * top-right corner — like a tactical card chip number. Featured / - * audited blueprints get a 2px accent stripe on the left border, not a - * full perimeter glow. Visual identity comes from typography and - * proportion, not from gradients. - */ +const BlueprintIdentitySummary = ({ row }: { row: DedupedBlueprintRow }) => { + const { blueprint } = row; + const category = getBlueprintCategory(blueprint); + const modeCount = getModeCount(row); + + return ( +
+ +
+
+ + {formatBlueprintName(blueprint.name)} + + + #{blueprint.id.toString()} + +
+

+ {getBlueprintDescription(blueprint)} +

+
+ {category} + {modeCount > 1 && ( + + {modeCount} {pluralize('mode', modeCount)} + + )} +
+
+
+ ); +}; + +const BlueprintMetaChip = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const BlueprintCapacityCell = ({ + operatorCount, +}: { + operatorCount: number; +}) => { + const hasOperators = operatorCount > 0; + + return ( +
+ + + {hasOperators + ? `${operatorCount.toLocaleString()} registered` + : '0 registered'} + +
+ ); +}; + +const BlueprintCapacitySignal = ({ + operatorCount, +}: { + operatorCount: number; +}) => { + const hasOperators = operatorCount > 0; + + return ( + + {hasOperators ? 'Ready' : 'Needs capacity'} + + ); +}; + +const catalogActionClass = + 'inline-flex min-h-8 flex-1 items-center justify-center rounded-md border border-border bg-muted/30 px-3 py-1.5 text-center font-sans text-xs font-semibold text-foreground not-italic transition-colors before:hidden after:hidden marker:hidden hover:bg-[color:var(--bg-hover)] whitespace-nowrap'; + +const catalogPrimaryActionClass = + 'inline-flex min-h-8 flex-1 items-center justify-center rounded-md bg-primary px-3 py-1.5 text-center font-sans text-xs font-semibold text-primary-foreground not-italic transition-colors before:hidden after:hidden marker:hidden hover:bg-primary/90 whitespace-nowrap'; + +const MobileBlueprintRow = ({ + row, + isAudited, + onRegister, +}: { + row: DedupedBlueprintRow; + isAudited: boolean; + onRegister?: (blueprint: Blueprint) => void; +}) => { + const { blueprint } = row; + const blueprintHref = `${PagePath.BLUEPRINTS}/${blueprint.id.toString()}`; + const operatorCount = blueprint.operatorsCount ?? 0; + const hasOperators = operatorCount > 0; + const description = getBlueprintDescription(blueprint); + + return ( +
+ + +
+
+

+ {formatBlueprintName(blueprint.name)} +

+ + #{blueprint.id.toString()} + +
+

+ {description} +

+
+ + +
+ + + {isAudited ? 'Audited' : 'Unaudited'} + + + {hasVerifiedManifest(blueprint) ? 'Pinned' : 'Chain-only'} + +
+ +
+ {hasOperators && ( + + Deploy + + )} + +
+
+ ); +}; + const BlueprintCard = ({ row, isSelectable, @@ -810,37 +1284,37 @@ const BlueprintCard = ({ onRegister?: (blueprint: Blueprint) => void; }) => { const { blueprint } = row; - const description = - blueprint.description?.trim() || - 'Service blueprint — deploy when operators are available.'; + const description = getBlueprintDescription(blueprint); const blueprintHref = `${PagePath.BLUEPRINTS}/${blueprint.id.toString()}`; const operatorCount = blueprint.operatorsCount ?? 0; const hasOperators = operatorCount > 0; const isFeatured = blueprint.isBoosted === true || isAudited; const displayName = formatBlueprintName(blueprint.name); + const modeCount = getModeCount(row); + const sourcePinned = hasVerifiedManifest(blueprint); return (
- {/* Whole-card link — keeps the deploy/register buttons interactive - * because they have z-20 and stopPropagation. Aria label on the - * link is the source of truth; the visible title is a styled span. */} - {/* Watermark ID — mono, low-contrast, top-right. The card's identity - * marker; not a UI element, not actionable. */} + + - {/* Selection checkbox (drawer mode) — top-left so it never collides - * with the watermark or with the hover-revealed actions. */} {isSelectable && (