diff --git a/.evolve/tangle-cloud-design-audit-2026-06-07.md b/.evolve/tangle-cloud-design-audit-2026-06-07.md new file mode 100644 index 0000000000..47c98ab33c --- /dev/null +++ b/.evolve/tangle-cloud-design-audit-2026-06-07.md @@ -0,0 +1,126 @@ +# Product Design Audit + +Status: implemented and verified locally +Product: Tangle Cloud blueprint catalog / registration / deploy flow +Primary users: deployers browsing service blueprints; operators registering capacity +Reference surfaces: existing Tangle Cloud chrome, Arena readability critique, `apps/tangle-cloud/PRODUCT_BRIEF.md` +Product brief: `apps/tangle-cloud/PRODUCT_BRIEF.md` + +## Inventory +- Route/page: `/blueprints`, `/blueprints?view=grid`, `/blueprints?register=0`, `/blueprints/:id/deploy` +- Components: `BlueprintListing`, `ResultList`, `PageToolbar`, `StatusPill`, `BlueprintVisual`, `RegistrationDrawer`, `Header`, shared blueprint GraphQL hooks, shared wallet dropdown +- States: loading, cached/degraded, list, grid, mobile, disconnected register drawer, disconnected deploy gate +- Data dependencies: Envio indexer, direct chain fallback, selected Cloud network, wallet chain, React Query cache +- Known complaints: glass-on-glass catalog, duplicate page title, missing blueprint logo/image, button dots, no caching on revisit, localhost RPC failure after wallet connect, hidden wallet address, italic network text, hard-to-read colors/fonts, tiny status labels, AI Agent deploy/register blank screen + +## Page Evaluations +### Blueprints Catalog +Purpose: choose a blueprint, compare capacity/trust, deploy or register. +Primary user decision: deploy available blueprint or register operator capacity. +Current score: 6/10 before; 8/10 after. +Target score: 9/10. +Findings: +- Page header duplicated the topbar breadcrumb and consumed vertical space without adding a decision. +- List rows used the same background family as the page and had no blueprint identity art. +- Mobile list forced desktop columns into 390px, hiding titles under action buttons. +- Grid cards hid actions until hover and could intermittently render blank under `content-visibility`. +Complaint ledger: +- Fixed: duplicate title removed; actions moved into toolbar. +- Fixed: list/card surfaces use elevated card background, stronger borders, and generated/default visuals. +- Fixed: mobile list has dedicated compact cards with identity, description, status, and actions. +- Fixed: deploy/register action links have explicit action classes and no pseudo-dot affordances. +- Fixed: status pills are larger and catalog capacity/audit pills omit decorative dots. +- Fixed: grid actions are visible without hover. +- Fixed: removed `content-visibility` from the result grid. +Alternatives: +- Keep dense desktop list only: 6/10; fails mobile and visual identity. +- Default to card wall everywhere: 7/10; better identity, worse operator comparison. +- Hybrid: desktop list/grid toggle with mobile-specific cards: 9/10; keeps density and mobile readability. +Decision: hybrid. +Changes shipped: `BlueprintListing`, `ResultList`, `PageToolbar`, `StatusPill`, `BlueprintVisual`, `styles.css`. +Verification: screenshots `desktop-blueprints-list.png`, `desktop-blueprints-grid-v3.png`, `mobile-blueprints-list-v3.png`; no page errors; no horizontal overflow. +Remaining risk: topbar breadcrumb still says `Blueprints`; acceptable after removing page title, but a future app-shell pass could move route context fully into page-local controls. + +### Register Drawer +Purpose: register/preregister an active operator for selected blueprints. +Primary user decision: connect operator wallet or continue registration. +Current score: 7/10 before; 8/10 after. +Target score: 9/10. +Findings: +- `/blueprints?register=0` opened a nonblank drawer, but the drawer had two close controls. +Complaint ledger: +- Fixed: duplicate custom close button removed; Radix dialog close remains. +- Verified: mobile register screenshot shows connected-wallet gate, not a blank screen. +Alternatives: +- Keep drawer as-is: 6/10; looks broken. +- Replace with route page: 7/10; more work, less continuity from catalog. +- Keep drawer, remove duplicate close, preserve catalog context: 8/10. +Decision: keep drawer and remove duplicate control. +Verification: `mobile-blueprints-register-v3.png`; no page errors; no horizontal overflow. +Remaining risk: disconnected drawer still visually overlays a full-page screenshot while background content appears below in full-page captures; viewport behavior is correct. + +### Deploy Gate +Purpose: prepare and submit a service request transaction. +Primary user decision: connect wallet, then configure instance. +Current score: 6/10 before; 8/10 after. +Target score: 9/10. +Findings: +- Disconnected deploy gate was generic, so a direct route such as `/blueprints/10/deploy` did not confirm the selected blueprint. +Complaint ledger: +- Fixed: disconnected deploy gate title includes the loaded blueprint name. +- Verified: `/blueprints/10/deploy` contains `AI Agent Sandbox` and `Connect`. +Alternatives: +- Generic connect gate: 6/10; route context lost. +- Full blueprint preview before wallet: 9/10; best UX but broader deploy-page composition. +- Contextual connect gate with blueprint name: 8/10; low-risk fix now. +Decision: contextual connect gate. +Verification: `desktop-ai-agent-deploy-v2.png`; no page errors; no horizontal overflow. +Remaining risk: richer disconnected preview belongs in a deeper deploy-flow pass. + +## Cross-Cutting System Findings +- Navigation: removed catalog page header; topbar/sidebar remain the route shell. +- Theme: dark palette is less purple and less bright; card/list surfaces now separate from page background. +- Tables/charts: desktop list remains row-based; mobile list is not a squeezed table. +- Density: toolbar wraps on mobile instead of cramming into one 44px row. +- Copy/labels: no extra explanatory page title; statuses use larger text. +- Interactions: card actions always visible; register drawer no longer double-closes; deploy gate carries blueprint context. +- Responsiveness: 390px mobile has no horizontal overflow. +- Data truth: browse data follows selected Cloud network rather than connected wallet chain; connected wallet no longer forces read-only catalog to dead localhost RPC. +- Production/deploy: not deployed in this pass. + +## Product Innovation Audit +- High-value innovation shipped: generated/default blueprint visual identity from deterministic category/name diagrams. This gives every blueprint a scannable artifact without trusting missing metadata images. +- Reliability innovation shipped: read-only blueprint queries use Cloud network selection, keep previous data, and retain cache for 30 minutes. Wallet chain is reserved for transaction context instead of browse context. +- Interaction innovation shipped: hybrid responsive catalog: desktop comparison list plus mobile action cards. +- Rejected: app-store hero/gallery treatment. It would improve screenshots but weaken operator comparison and contradict the infrastructure-console brief. +- Rejected: hiding register/deploy behind hover. It looks cleaner but makes primary actions feel missing and hurts touch devices. + +## Verification +- Commands: + - `npx nx run tangle-cloud:typecheck` + - `npx nx build tangle-cloud` + - `npx nx test tangle-cloud` — 26 files, 167 tests passed + - `npx nx lint tangle-cloud` — passed with existing warnings + - `npx nx run ui-components:typecheck` + - `npx nx run tangle-shared-ui:test` — 5 suites, 74 tests passed + - `npx nx lint tangle-shared-ui` — passed with existing warnings + - `git diff --check` +- Browser checks: + - `/blueprints` + - `/blueprints?view=grid` + - `/blueprints?register=0` + - `/blueprints/0/deploy` + - `/blueprints/10/deploy` +- Screenshots: + - `.evolve/tangle-cloud-design-audit-2026-06-07/desktop-blueprints-list.png` + - `.evolve/tangle-cloud-design-audit-2026-06-07/desktop-blueprints-grid-v3.png` + - `.evolve/tangle-cloud-design-audit-2026-06-07/mobile-blueprints-list-v3.png` + - `.evolve/tangle-cloud-design-audit-2026-06-07/mobile-blueprints-register-v3.png` + - `.evolve/tangle-cloud-design-audit-2026-06-07/desktop-ai-agent-deploy-v2.png` +- Deployment: not requested. +- Live proof: local dev server at `http://127.0.0.1:4210` with polling due system watcher limit. + +## Next Pass +- Add a richer disconnected deploy preview that shows blueprint identity, operators, and trust before wallet connect. +- Consider moving route context out of the fixed topbar in a broader app-shell pass. +- Add a configured testnet Envio endpoint to avoid expected fallback warnings during local browsing. 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/CreditsProvider.tsx b/apps/tangle-cloud/src/app/CreditsProvider.tsx index cfb229fb7a..40cbb7a681 100644 --- a/apps/tangle-cloud/src/app/CreditsProvider.tsx +++ b/apps/tangle-cloud/src/app/CreditsProvider.tsx @@ -48,12 +48,15 @@ export const CreditsProvider: FC = ({ children }) => { : undefined; useEffect(() => { - setCreditAccounts([]); - setIsLoading(true); const gen = ++genRef.current; + queueMicrotask(() => { + if (genRef.current === gen) { + setCreditAccounts([]); + setIsLoading(Boolean(address)); + } + }); if (!address) { - setIsLoading(false); return; } diff --git a/apps/tangle-cloud/src/app/ShieldedProvider.tsx b/apps/tangle-cloud/src/app/ShieldedProvider.tsx index a999484271..26dbded6b0 100644 --- a/apps/tangle-cloud/src/app/ShieldedProvider.tsx +++ b/apps/tangle-cloud/src/app/ShieldedProvider.tsx @@ -61,14 +61,19 @@ export const ShieldedProvider: FC = ({ children }) => { const [isReady, setIsReady] = useState(false); const storageRef = useRef(null); const notesRef = useRef(notes); - notesRef.current = notes; // Sequential write queue to prevent concurrent persist() from losing notes const writeQueueRef = useRef>(Promise.resolve()); + useEffect(() => { + notesRef.current = notes; + }, [notes]); + useEffect(() => { // Reset synchronously to prevent stale data flash - setNotes([]); - setIsReady(false); + queueMicrotask(() => { + setNotes([]); + setIsReady(false); + }); // Flush the write queue so no pending writes leak to the new address writeQueueRef.current = Promise.resolve(); 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/blueprintApps/components/BlueprintAppFrame.tsx b/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrame.tsx index c85a9ec702..d9216dcb03 100644 --- a/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrame.tsx +++ b/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrame.tsx @@ -72,11 +72,31 @@ const useParentTheme = (): 'light' | 'dark' => { // - allow-forms: required for normal form interactions inside the iframe. // - allow-popups[-to-escape-sandbox]: gated on the manifest's allowPopups // flag because they widen attack surface (oauth flows commonly need them). +// - allow-same-origin: gated on allowSameOrigin AND only when the app is +// CROSS-ORIGIN to us. Cross-origin + allow-same-origin restores the iframe's +// OWN origin (so embedded apps running their own wallet get the localStorage/ +// IndexedDB WalletConnect needs) while cross-origin policy still blocks it +// from reaching our DOM/storage. We refuse it for same-origin apps, since +// same-origin + allow-scripts would let the frame remove its own sandbox. +const isCrossOrigin = (origin: string): boolean => { + if (typeof window === 'undefined') { + return false; + } + try { + return new URL(origin).origin !== window.location.origin; + } catch { + return false; + } +}; + const buildSandbox = (config: BlueprintIframeConfig): string => { const tokens = ['allow-scripts', 'allow-forms']; if (config.allowPopups) { tokens.push('allow-popups', 'allow-popups-to-escape-sandbox'); } + if (config.allowSameOrigin && isCrossOrigin(config.origin)) { + tokens.push('allow-same-origin'); + } return tokens.join(' '); }; diff --git a/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrameOverlay.tsx b/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrameOverlay.tsx new file mode 100644 index 0000000000..eaec81187a --- /dev/null +++ b/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppFrameOverlay.tsx @@ -0,0 +1,167 @@ +import { type FC, type ReactNode } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { Button } from '@tangle-network/sandbox-ui/primitives'; +import { focus, typeRole } from '../../styles/chrome'; +import StatusPill from '../../components/chrome/StatusPill'; + +/** + * Designed load states for the embedded publisher app. The iframe is a dead + * rectangle until the origin responds; without these overlays a slow or + * unreachable origin shows a silent blank surface (audit EMBED-2). Each phase + * gets a real label, a tone pill, and — for the failure phases — a retry. + * + * Phases: + * loading — handshake hasn't completed; show a shape-matched skeleton so the + * user sees the surface is alive, not frozen. + * timeout — `onLoad` never fired inside the budget; the origin is slow or the + * network is degraded. Retry reloads the frame. + * error — the iframe element raised `error`, or the origin refused to frame + * (X-Frame-Options / CSP). Retry + open-in-new-tab escape hatch. + * blocked — the parent couldn't build a valid iframe URL/origin (malformed + * manifest). Not retryable here; the publisher must fix the manifest. + */ +export type FramePhase = 'loading' | 'ready' | 'timeout' | 'error' | 'blocked'; + +type Props = { + phase: FramePhase; + appDisplayName: string; + /** Origin the app loads from — shown so the user can audit where it came from. */ + origin: string; + /** Reload the iframe (bumps the key in the host). */ + onRetry?: () => void; + /** Standalone URL for the open-in-new-tab escape hatch on failure phases. */ + externalUrl?: string; + /** Rounded corners match the frame's 12px radius; focus-mode passes 0. */ + rounded?: boolean; +}; + +const PHASE_COPY: Record< + Exclude, + { tone: 'pending' | 'warning' | 'danger' | 'neutral'; label: string } +> = { + loading: { tone: 'pending', label: 'Connecting' }, + timeout: { tone: 'warning', label: 'Slow to respond' }, + error: { tone: 'danger', label: 'Unavailable' }, + blocked: { tone: 'danger', label: 'Blocked' }, +}; + +const PHASE_TITLE: Record, string> = { + loading: 'Loading the app', + timeout: 'The app is taking too long', + error: 'The app could not be loaded', + blocked: 'This app cannot be embedded', +}; + +const phaseBody = ( + phase: Exclude, + appDisplayName: string, +): ReactNode => { + switch (phase) { + case 'loading': + return `Waiting for ${appDisplayName} to respond.`; + case 'timeout': + return `${appDisplayName} did not finish loading. The origin may be slow or temporarily down — retry, or open it in a new tab.`; + case 'error': + return `${appDisplayName} refused to load in this frame. The origin may be offline or may not allow embedding.`; + case 'blocked': + return `The blueprint manifest does not point at a valid app origin, so it can't be embedded safely. The publisher must fix the manifest.`; + } +}; + +/** + * Skeleton that mirrors a typical embedded app's chrome (top bar + content + * blocks) so the loading state reads as the app's shape, not a spinner over a + * void. Pure decoration — aria-hidden — the live region lives in the overlay. + */ +const FrameSkeleton: FC = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +/** + * Overlay rendered above the iframe for every non-ready phase. On `loading` it + * is a translucent veil over the skeleton (so a fast app flashes minimally); on + * failure phases it is an opaque panel with the recovery actions. + */ +const BlueprintAppFrameOverlay: FC = ({ + phase, + appDisplayName, + origin, + onRetry, + externalUrl, + rounded = true, +}) => { + if (phase === 'ready') return null; + const meta = PHASE_COPY[phase]; + const isFailure = phase !== 'loading'; + + return ( +
+ {phase === 'loading' && } +
+ {meta.label} +
+

+ {PHASE_TITLE[phase]} +

+

+ {phaseBody(phase, appDisplayName)} +

+

+ {origin} +

+
+ {isFailure && ( +
+ {onRetry && phase !== 'blocked' && ( + + )} + {externalUrl && ( + + Open in new tab ↗ + + )} +
+ )} +
+
+ ); +}; + +export default BlueprintAppFrameOverlay; diff --git a/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppLandingPage.tsx b/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppLandingPage.tsx index 8a441a78ce..82dd587a19 100644 --- a/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppLandingPage.tsx +++ b/apps/tangle-cloud/src/blueprintApps/components/BlueprintAppLandingPage.tsx @@ -83,8 +83,14 @@ const BlueprintAppLandingPage: FC = ({ entry }) => { // directly. Each renderer falls back to a sensible default when its field // is absent so blueprints with no opinion still get a polished landing. const blueprintUi = canonicalBlueprint?.blueprintUi ?? null; - const overviewCards = blueprintUi?.overviewCards ?? []; - const actions = blueprintUi?.actions ?? []; + const overviewCards = useMemo( + () => blueprintUi?.overviewCards ?? [], + [blueprintUi?.overviewCards], + ); + const actions = useMemo( + () => blueprintUi?.actions ?? [], + [blueprintUi?.actions], + ); const theme = blueprintUi?.theme; // Right-hand "checkout path" panel — defaults to a three-step affordance @@ -92,19 +98,16 @@ const BlueprintAppLandingPage: FC = ({ entry }) => { // each action becomes a step (label → optional description). Cap at 5 to // keep the column scannable; overflow lives on the deploy page. const checkoutSteps: { title: string; description?: string }[] = - useMemo(() => { - if (actions.length > 0) { - return actions.slice(0, 5).map((a: BlueprintUiAction) => ({ + actions.length > 0 + ? actions.slice(0, 5).map((a: BlueprintUiAction) => ({ title: a.label, description: a.description, - })); - } - return [ - { title: `Configure the ${serviceNoun}` }, - { title: 'Choose registered operators' }, - { title: `Track ${resourceNoun} output` }, - ]; - }, [actions, serviceNoun, resourceNoun]); + })) + : [ + { title: `Configure the ${serviceNoun}` }, + { title: 'Choose registered operators' }, + { title: `Track ${resourceNoun} output` }, + ]; // Iframe-mode blueprints get the iframe-first layout — the publisher's // hosted app fills the viewport, our chrome is a 52px strip with the @@ -120,6 +123,7 @@ const BlueprintAppLandingPage: FC = ({ entry }) => { description={view.manifest.description} serviceNoun={serviceNoun} provisionPath={provisionPath} + websiteUrl={canonicalBlueprint?.website} protocolDetailHref={ activeMode?.blueprintId !== undefined ? `/blueprints/${activeMode.blueprintId.toString()}?raw=1` diff --git a/apps/tangle-cloud/src/blueprintApps/components/IframeBlueprintLayout.tsx b/apps/tangle-cloud/src/blueprintApps/components/IframeBlueprintLayout.tsx index 033574573a..a04b3ceb2d 100644 --- a/apps/tangle-cloud/src/blueprintApps/components/IframeBlueprintLayout.tsx +++ b/apps/tangle-cloud/src/blueprintApps/components/IframeBlueprintLayout.tsx @@ -26,6 +26,8 @@ type Props = { protocolDetailHref?: string; /** Optional publisher console (external app's standalone URL). */ externalAppUrl?: string; + /** Optional link to the blueprint's own dedicated site (metadata `website`). */ + websiteUrl?: string | null; /** Mode picker — only rendered when there are 2+ modes. */ modes: BlueprintMode[]; activeMode: BlueprintMode | null; @@ -70,6 +72,7 @@ const IframeBlueprintLayout: FC = ({ provisionPath, protocolDetailHref, externalAppUrl, + websiteUrl, modes, activeMode, onSelectMode, @@ -168,6 +171,21 @@ const IframeBlueprintLayout: FC = ({ > + {websiteUrl && ( + + Visit site + + + )} - ); -} - type StatusVariant = 'success' | 'warning' | 'error' | 'info'; const getStatusVariant = ( @@ -255,14 +125,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 +167,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 +196,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 +228,9 @@ function CloudNetworkSelector({ networks }: { networks: Network[] }) { ) : ( )} - {item.name} + + {item.name} + {isSelected && ( +> = TANGLE_SOCIAL_URLS_RECORD; + +const BOTTOM_LINK_OVERRIDES: Partial< + Record<(typeof bottomLinks)[number]['name'], string> +> = { + 'Terms of Service': TANGLE_TERMS_OF_SERVICE_URL, + 'Privacy Policy': TANGLE_PRIVACY_POLICY_URL, +}; + const Layout: FC> = ({ children, isSidebarInitiallyExpanded, }) => { - const [theme, setTheme] = useState<'dark' | 'light'>(() => { - const storedTheme = window.localStorage.getItem('tangle-cloud-theme'); - if (storedTheme === 'dark' || storedTheme === 'light') { - return storedTheme; - } - - return 'dark'; - }); + const [isDarkMode] = useDarkMode('dark'); + const theme = isDarkMode ? 'dark' : 'light'; const [isSidebarExpanded, setIsSidebarExpanded] = useState(() => { if (isSidebarInitiallyExpanded !== undefined) { return isSidebarInitiallyExpanded; @@ -46,7 +59,6 @@ const Layout: FC> = ({ 'data-sandbox-theme', theme === 'dark' ? 'tangle' : 'vault', ); - window.localStorage.setItem('tangle-cloud-theme', theme); }, [theme]); // Publish the current sidebar width as a CSS variable on the root element so @@ -76,23 +88,15 @@ const Layout: FC> = ({
-
setIsSidebarExpanded((value) => !value)} />
@@ -102,9 +106,28 @@ const Layout: FC> = ({ /> -
- {children} -
+
+
+
+
+ +
+ +
+
+ +
+ {children} +
+
+ +
+
diff --git a/apps/tangle-cloud/src/components/Sidebar.tsx b/apps/tangle-cloud/src/components/Sidebar.tsx index a967e20ccb..1616a9059c 100644 --- a/apps/tangle-cloud/src/components/Sidebar.tsx +++ b/apps/tangle-cloud/src/components/Sidebar.tsx @@ -1,72 +1,72 @@ -import { DocumentationIcon } from '@tangle-network/icons/DocumentationIcon'; -import GlobalLine from '@tangle-network/icons/GlobalLine'; -import { GridFillIcon } from '@tangle-network/icons/GridFillIcon'; import { CoinsLineIcon, + DocumentationIcon, GiftLineIcon, + GlobalLine, + GridFillIcon, HomeFillIcon, ShieldKeyholeLineIcon, } from '@tangle-network/icons'; -import { TangleKnot } from '@tangle-network/sandbox-ui/primitives'; -import { type ComponentType, type FC, useState } from 'react'; -import { Link, useLocation } from 'react-router'; -import { twMerge } from 'tailwind-merge'; +import { + MobileSidebar as MobileSidebarCmp, + type MobileSidebarProps, + SideBar as SideBarCmp, + type SideBarFooterType, + type SideBarItemProps, + TangleLogo, +} from '@tangle-network/ui-components'; +import { SidebarTangleClosedIcon } from '@tangle-network/ui-components/components'; +import { + TANGLE_DOCS_URL, + TANGLE_MKT_URL, +} from '@tangle-network/ui-components/constants'; +import { type FC, useMemo } from 'react'; +import { useLocation } from 'react-router'; import { PagePath } from '../types'; -const TANGLE_DOCS_URL = 'https://docs.tangle.tools'; - -type IconComponent = ComponentType<{ className?: string }>; - -type SidebarItem = { - name: string; - href: string; - isInternal: boolean; - Icon: IconComponent; - subItems?: Array<{ - name: string; - href: string; - isInternal: boolean; - }>; -}; - type Props = { isExpandedByDefault?: boolean; - onExpandedChange?: (isExpanded: boolean) => void; + onExpandedChange?: () => void; }; -const SIDEBAR_ITEMS: SidebarItem[] = [ +const SIDEBAR_ITEMS: SideBarItemProps[] = [ { - name: 'Home', + name: 'Dashboard', href: PagePath.INSTANCES, isInternal: true, Icon: HomeFillIcon, + subItems: [], }, { name: 'Blueprints', href: PagePath.BLUEPRINTS, isInternal: true, Icon: GridFillIcon, + subItems: [], }, { name: 'Operators', href: PagePath.OPERATORS, isInternal: true, Icon: GlobalLine, + subItems: [], }, { name: 'Rewards', href: PagePath.REWARDS, isInternal: true, Icon: GiftLineIcon, + subItems: [], }, { name: 'Earnings', href: PagePath.EARNINGS, isInternal: true, Icon: CoinsLineIcon, + subItems: [], }, { - name: 'Private Payments', + name: 'Payments', href: PagePath.PAYMENTS_POOL, isInternal: true, Icon: ShieldKeyholeLineIcon, @@ -85,258 +85,50 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ }, ]; -const DOCS_ITEM: SidebarItem = { +const SIDEBAR_FOOTER: SideBarFooterType = { name: 'Docs', href: TANGLE_DOCS_URL, isInternal: false, Icon: DocumentationIcon, }; -const Sidebar: FC = ({ isExpandedByDefault, onExpandedChange }) => { - const pathname = useLocation().pathname; - const [isMobileOpen, setIsMobileOpen] = useState(false); - const [isDesktopExpanded, setIsDesktopExpanded] = useState( - isExpandedByDefault ?? true, +const useCloudSidebarProps = (): MobileSidebarProps => + useMemo( + () => ({ + ClosedLogo: SidebarTangleClosedIcon, + Logo: TangleLogo, + footer: SIDEBAR_FOOTER, + items: SIDEBAR_ITEMS, + logoLink: TANGLE_MKT_URL, + }), + [], ); - const toggleDesktopExpanded = () => { - setIsDesktopExpanded((current) => { - const next = !current; - window.localStorage.setItem( - 'tangle-cloud-sidebar-expanded', - String(next), - ); - onExpandedChange?.(next); - return next; - }); - }; +const Sidebar: FC = ({ isExpandedByDefault, onExpandedChange }) => { + const location = useLocation(); + const sidebarProps = useCloudSidebarProps(); return ( - <> - - -
- -
- - {isMobileOpen && ( -
- -
- )} - + ); }; -const SidebarBrand = ({ isExpanded }: { isExpanded: boolean }) => ( - - - {isExpanded && ( - - Tangle Cloud - - )} - -); - -const SidebarNav = ({ - items, - pathname, - isExpanded, - onNavigate, -}: { - items: SidebarItem[]; - pathname: string; - isExpanded: boolean; - onNavigate?: () => void; -}) => ( - -); - -// Pinned to the sidebar's right edge so it's affordance-clear (matches VS -// Code / Linear / Vercel patterns) and never competes with the nav stack -// for the "what's a nav item" mental model. Floats over the border so it -// stays visible whether the sidebar is expanded (w-64) or collapsed (w-16). -const SidebarCollapseButton = ({ - isExpanded, - onClick, -}: { - isExpanded: boolean; - onClick: () => void; -}) => ( - -); - -const SidebarLink = ({ - item, - pathname, - isExpanded, - onNavigate, -}: { - item: SidebarItem; - pathname: string; - isExpanded: boolean; - onNavigate?: () => void; -}) => { - const isSubItemActive = - item.subItems?.some( - (subItem) => - pathname === subItem.href || pathname.startsWith(`${subItem.href}/`), - ) ?? false; - const isActive = - item.isInternal && - (pathname === item.href || - pathname.startsWith(`${item.href}/`) || - isSubItemActive); - const Icon = item.Icon; - const className = [ - 'group flex items-center gap-3 rounded-md text-sm font-semibold transition-colors', - isExpanded ? 'min-h-11 justify-start px-3' : 'h-11 w-12 justify-center', - isActive - ? 'bg-primary text-primary-foreground shadow-[var(--shadow-accent)]' - : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground', - ].join(' '); - const content = ( - <> - - {isExpanded && {item.name}} - - ); +export const MobileSidebar: FC = () => { + const location = useLocation(); + const sidebarProps = useCloudSidebarProps(); return ( -
- {item.isInternal ? ( - - {content} - - ) : ( - - {content} - - )} - - {isExpanded && item.subItems && isActive && ( -
- {item.subItems.map((subItem) => { - const isSubActive = pathname === subItem.href; - return ( - - {subItem.name} - - ); - })} -
- )} -
+ ); }; diff --git a/apps/tangle-cloud/src/components/binaryUpgrade/PublishVersionDialog.tsx b/apps/tangle-cloud/src/components/binaryUpgrade/PublishVersionDialog.tsx index d5107aa62e..473f34ef2f 100644 --- a/apps/tangle-cloud/src/components/binaryUpgrade/PublishVersionDialog.tsx +++ b/apps/tangle-cloud/src/components/binaryUpgrade/PublishVersionDialog.tsx @@ -153,7 +153,9 @@ const WizardPublishDialog: FC<{ // notify step. useEffect(() => { if (publishSuccess && furthestStep < 5) { - setFurthestStep(5); + queueMicrotask(() => { + setFurthestStep(5); + }); } }, [publishSuccess, furthestStep]); diff --git a/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx b/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx index e16a12349d..fd53433a3c 100644 --- a/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx +++ b/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx @@ -265,6 +265,25 @@ const BlueprintHostCard = ({
)} + {/* The blueprint's own site — distinct from the publisher console. + * Works for ANY blueprint that sets `website` in its metadata, with or + * without an iframe/console tier. */} + {blueprint.websiteUrl && ( +
+

+ Visit the blueprint's own site. +

+ + Visit site + + +
+ )}
diff --git a/apps/tangle-cloud/src/components/blueprints/BlueprintVisual.tsx b/apps/tangle-cloud/src/components/blueprints/BlueprintVisual.tsx index 23ded35a31..817a6439c0 100644 --- a/apps/tangle-cloud/src/components/blueprints/BlueprintVisual.tsx +++ b/apps/tangle-cloud/src/components/blueprints/BlueprintVisual.tsx @@ -1,5 +1,6 @@ import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; -import { useEffect, useState, type CSSProperties } from 'react'; +import { useState, type CSSProperties } from 'react'; +import { twMerge } from 'tailwind-merge'; import { formatBlueprintName, getGithubPreviewUrl, @@ -22,24 +23,19 @@ export const BlueprintVisual = ({ const imageUrl = getUsableMetadataImageUrl(blueprint.imgUrl) ?? getGithubPreviewUrl(blueprint.githubUrl); - const [hasImageError, setHasImageError] = useState(false); + const [imageErrorUrl, setImageErrorUrl] = useState(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,17 +45,13 @@ export const BlueprintVisual = ({ alt="" className="absolute inset-0 h-full w-full object-cover opacity-80" loading="lazy" - onError={() => setHasImageError(true)} + onError={() => setImageErrorUrl(imageUrl)} />
) : ( )} - -
- {displayName.slice(0, 1).toUpperCase()} -
); }; 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..522c42143a 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,44 +156,54 @@ 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} + + ); + }, ); +Button.displayName = 'Button'; + export type InputProps = Omit< ComponentProps, 'onChange' @@ -321,13 +338,51 @@ export const ModalFooterActions: FC<{ ); +// Map every `color` label callers pass to a *distinct* Badge variant. The old +// map only knew green→success / red→destructive, so blue/purple/yellow all +// collapsed to one identical `outline` pill — Fixed vs Dynamic membership and +// PayOnce vs Subscription vs EventDriven pricing rendered the same (audit +// F1-svc). The Badge variant set is the source of truth: +// default | secondary | destructive | outline | success | warning | info. +// +// New status badges should prefer the canonical `` + +// `statusToneFor(domain, status)` (src/components/chrome). This `Chip` is the +// legacy color-string surface kept truthful for existing call sites. +const CHIP_COLOR_TO_VARIANT: Record< + string, + | 'default' + | 'secondary' + | 'destructive' + | 'outline' + | 'success' + | 'warning' + | 'info' +> = { + green: 'success', + emerald: 'success', + red: 'destructive', + rose: 'destructive', + yellow: 'warning', + amber: 'warning', + orange: 'warning', + blue: 'info', + cyan: 'info', + purple: 'secondary', + violet: 'secondary', + grey: 'outline', + gray: 'outline', + 'dark-grey': 'outline', + 'dark-gray': 'outline', + neutral: 'outline', +}; + export const Chip: FC<{ color?: string; className?: string; children: ReactNode; }> = ({ color, className, children }) => { const variant = - color === 'green' ? 'success' : color === 'red' ? 'destructive' : 'outline'; + (color && CHIP_COLOR_TO_VARIANT[color.toLowerCase()]) ?? 'outline'; return ( diff --git a/apps/tangle-cloud/src/hooks/payments/useKeypair.ts b/apps/tangle-cloud/src/hooks/payments/useKeypair.ts index 78f0750aad..f39876587a 100644 --- a/apps/tangle-cloud/src/hooks/payments/useKeypair.ts +++ b/apps/tangle-cloud/src/hooks/payments/useKeypair.ts @@ -28,15 +28,19 @@ const useKeypair = () => { // Track current address to detect stale async completions const addressRef = useRef(address); - addressRef.current = address; + useEffect(() => { + addressRef.current = address; + }, [address]); useEffect(() => { - setKeypair(null); - setError(null); - setIsLoading(false); // Cancel any stuck loading state from previous address - setHasStoredKeypair( - !!address && localStorage.getItem(`${STORAGE_KEY}:${address}`) !== null, - ); + queueMicrotask(() => { + setKeypair(null); + setError(null); + setIsLoading(false); // Cancel any stuck loading state from previous address + setHasStoredKeypair( + !!address && localStorage.getItem(`${STORAGE_KEY}:${address}`) !== null, + ); + }); }, [address]); const deriveKeypair = useCallback(async () => { diff --git a/apps/tangle-cloud/src/main.tsx b/apps/tangle-cloud/src/main.tsx index 4ea4d075fb..23278b2186 100644 --- a/apps/tangle-cloud/src/main.tsx +++ b/apps/tangle-cloud/src/main.tsx @@ -1,19 +1,42 @@ -import '@fontsource/geist-sans/400.css'; -import '@fontsource/geist-sans/500.css'; -import '@fontsource/geist-sans/600.css'; -import '@fontsource/geist-sans/700.css'; -import '@fontsource/geist-sans/800.css'; -import '@fontsource/geist-mono/400.css'; -import '@fontsource/geist-mono/500.css'; +import '@tangle-network/ui-components/tailwind.css'; +import '@tangle-network/ui-components/css/typography-fonts.css'; import { StrictMode } from 'react'; import { BrowserRouter } from 'react-router-dom'; import * as ReactDOM from 'react-dom/client'; import App from './app/app'; import './styles.css'; -const storedTheme = window.localStorage.getItem('tangle-cloud-theme'); -const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches; -const initialTheme = storedTheme ?? (prefersLight ? 'light' : 'dark'); +type ThemeMode = 'dark' | 'light'; + +const normalizeTheme = (value: string | null): ThemeMode | null => { + if (value === 'dark' || value === 'light') { + return value; + } + + if (value === null) { + return null; + } + + try { + const parsedValue: unknown = JSON.parse(value); + return parsedValue === 'dark' || parsedValue === 'light' + ? parsedValue + : null; + } catch { + return null; + } +}; + +const storedTheme = normalizeTheme(window.localStorage.getItem('theme')); +const legacyTheme = normalizeTheme( + window.localStorage.getItem('tangle-cloud-theme'), +); +const initialTheme = storedTheme ?? legacyTheme ?? 'dark'; + +if (storedTheme === null && legacyTheme !== null) { + window.localStorage.setItem('theme', JSON.stringify(legacyTheme)); + window.localStorage.removeItem('tangle-cloud-theme'); +} document.documentElement.classList.toggle('dark', initialTheme === 'dark'); document.documentElement.style.colorScheme = initialTheme; diff --git a/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx b/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx index 3413149860..afbf5ffdef 100644 --- a/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx @@ -1,102 +1,56 @@ import type { RowSelectionState } from '@tanstack/table-core'; -import { - Button, - Card, - CardContent, - Skeleton, -} from '../../components/sandbox/SandboxUi'; -import { formatBlueprintName } from '../../components/blueprints/blueprintVisualUtils'; -import { EmptyState, FilterTray, PageToolbar } from '../../components/chrome'; -import { - enumCodec, - intCodec, - stringCodec, - useUrlState, -} from '../../components/chrome/useUrlState'; -import { focus, typeRole } from '../../styles/chrome'; +import BlueprintGallery from '@tangle-network/tangle-shared-ui/components/blueprints/BlueprintGallery'; +import type { BlueprintItemProps } from '@tangle-network/tangle-shared-ui/components/blueprints/BlueprintGallery/types'; import type { UseAllBlueprintsReturn } from '@tangle-network/tangle-shared-ui/data/graphql'; import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; +import { Button, Typography } from '@tangle-network/ui-components'; import { - Dispatch, - FC, - SetStateAction, - useCallback, - useDeferredValue, - useEffect, + type Dispatch, + type FC, + type ReactNode, + type SetStateAction, useMemo, } from 'react'; -import * as Popover from '@radix-ui/react-popover'; -import { useQueries } from '@tanstack/react-query'; -import { useChainId, usePublicClient } from 'wagmi'; -import type { Address } from 'viem'; -import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; -import BinaryUpgradeABI from '@tangle-network/tangle-shared-ui/abi/tangleBinaryUpgrade'; -import { - fetchAttestations, - fetchAuditorOnChain, -} from '@tangle-network/tangle-shared-ui/data/blueprints/useBinaryVersions'; -import { - AttestationKind, - type Auditor, -} from '@tangle-network/tangle-shared-ui/blueprintApps/trustScore'; -import { auditorFallbackRegistry } from '../../auditors'; -import { Link } from 'react-router'; +import { useNavigate } from 'react-router'; import { twMerge } from 'tailwind-merge'; -import { PagePath } from '../../types'; import { dedupeBlueprintsByIdentity, type DedupedBlueprintRow, } from '../../blueprintApps/dedupe'; - -const PAGE_SIZE = 12; - -type AudienceFilter = 'all' | 'customers' | 'operators'; -type ManifestFilter = 'all' | 'verified' | 'fallback'; -type AuditFilter = 'all' | 'audited'; +import { BlueprintVisual } from '../../components/blueprints/BlueprintVisual'; +import { formatBlueprintName } from '../../components/blueprints/blueprintVisualUtils'; +import { PagePath } from '../../types'; 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`; -/** - * 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" - * vs "Inference" vs "INFERENCE" all render as "Inference"). - */ const normalizeTag = (raw: string): string => { const t = raw.trim(); if (t.length === 0) return ''; - // Preserve all-caps acronyms (TEE, LLM, RAG, ZK, AI, MEV). if (/^[A-Z]{2,5}$/.test(t)) return t; + return t .split(/[\s-]+/) .filter(Boolean) - .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); }; -/** - * Resolve a blueprint's category in three tiers, in order: - * - * 1. `blueprintUi.tags[0]` — publisher-declared, on-chain, authoritative. - * Multi-tag blueprints surface the first as the primary chip. - * 2. `blueprint.category` — chain-derived, present on older metadata. - * 3. Keyword inference from name + description — last resort. - * - * The keyword fallback is a fingerprint, not a taxonomy. It's narrow on - * purpose: misclassifying a blueprint into the wrong bucket is worse than - * dropping it into "Other" and asking the publisher to declare tags. - */ const getBlueprintCategory = (blueprint: Blueprint): string => { - const declaredTags = blueprint.blueprintUi?.tags ?? []; - for (const raw of declaredTags) { - const normalized = normalizeTag(raw); - if (normalized.length > 0) return normalized; + const declaredTag = blueprint.blueprintUi?.tags + ?.map(normalizeTag) + .find(Boolean); + + if (declaredTag) { + return declaredTag; } const explicitCategory = blueprint.category?.trim(); @@ -112,15 +66,19 @@ const getBlueprintCategory = (blueprint: Blueprint): string => { ) { return 'Data'; } + if (/\b(agent|sandbox|automation|bot)\b/.test(searchable)) { return 'Agents'; } + if (/\b(trading|market|strategy|portfolio)\b/.test(searchable)) { return 'Trading'; } + if (/\b(training|train|fine[- ]?tune|checkpoint)\b/.test(searchable)) { return 'Training'; } + if ( /\b(ai|llm|inference|model|image|video|voice|avatar|modal|gpu)\b/.test( searchable, @@ -132,141 +90,52 @@ const getBlueprintCategory = (blueprint: Blueprint): string => { return 'Other'; }; -/** - * Full tag set for a blueprint — all publisher-declared tags + the primary - * category (so search/filter still matches on the inferred bucket when the - * publisher didn't declare it explicitly). - */ -const getBlueprintTags = (blueprint: Blueprint): readonly string[] => { - const declared = (blueprint.blueprintUi?.tags ?? []) - .map(normalizeTag) - .filter((t) => t.length > 0); - if (declared.length > 0) return declared; - return [getBlueprintCategory(blueprint)]; -}; - -const hasVerifiedManifest = (blueprint: Blueprint) => - blueprint.metadataVerification?.status === 'verified'; - -const matchesSearch = (blueprint: Blueprint, query: string) => { - if (query === '') { - return true; - } - - const haystack = [ - blueprint.name, - blueprint.description, - blueprint.author, - blueprint.category, - // Match against every declared tag, not just the primary category — - // a search for "tee" should hit a blueprint tagged ["Inference", "TEE"] - // even though its primary chip says Inference. - ...getBlueprintTags(blueprint), - blueprint.id.toString(), - ] - .filter(Boolean) - .join(' ') - .toLowerCase(); +const getModeCount = (row: DedupedBlueprintRow) => + row.modes?.length ?? (row.aliases.length > 0 ? row.aliases.length + 1 : 1); - return haystack.includes(query); -}; - -/** - * Multi-select category dropdown for the toolbar. Replaces the old - * full-width segmented row — categories collapse into one pill with a - * checkbox list, so search + categories + filters fit a single toolbar row. - */ -const CategoryFilterMenu: FC<{ - categories: { category: string; count: number }[]; - selected: string[]; - onToggle: (category: string) => void; - onClear: () => void; -}> = ({ categories, selected, onToggle, onClear }) => { - const count = selected.length; - return ( - - - - - - -
- Categories - {count > 0 && ( - - )} -
- {categories.length === 0 ? ( -

- No categories -

- ) : ( - categories.map(({ category, count: c }) => { - const checked = selected.includes(category); - return ( - - ); - }) - )} -
-
-
- ); +const toGalleryItem = ( + row: DedupedBlueprintRow, + onView: (blueprint: Blueprint) => void, + onDeploy: (blueprint: Blueprint) => void, + onRegister?: (blueprint: Blueprint) => void, +): BlueprintItemProps => { + const { blueprint } = row; + const category = getBlueprintCategory(blueprint); + const modeCount = getModeCount(row); + + return { + id: blueprint.id, + name: formatBlueprintName(blueprint.name), + author: + modeCount > 1 + ? `${blueprint.author} · ${modeCount} ${pluralize('mode', modeCount)}` + : blueprint.author, + imgUrl: blueprint.imgUrl, + description: blueprint.description, + instancesCount: blueprint.instancesCount, + operatorsCount: blueprint.operatorsCount, + stakersCount: blueprint.stakersCount, + tvl: blueprint.tvl, + isBoosted: blueprint.isBoosted, + category, + onClick: () => onView(blueprint), + renderImage: () => ( + + ), + action: ( + + ), + }; }; const BlueprintListing: FC = ({ @@ -276,356 +145,85 @@ const BlueprintListing: FC = ({ isLoading, error, onRegisterBlueprint, + toolbarAction, + onRetry, }) => { - // 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('')); - // 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( - 'category', - stringCodec(''), - ); - const selectedCategories = useMemo( - () => (categoryParam ? categoryParam.split(',').filter(Boolean) : []), - [categoryParam], - ); - const toggleCategory = useCallback( - (category: string) => { - const next = selectedCategories.includes(category) - ? selectedCategories.filter((c) => c !== category) - : [...selectedCategories, category]; - setCategoryParam(next.join(',')); - }, - [selectedCategories, setCategoryParam], - ); - const [audienceFilter, setAudienceFilter] = useUrlState( - 'avail', - enumCodec(['all', 'customers', 'operators'] as const, 'all'), - ); - const [manifestFilter, setManifestFilter] = useUrlState( - 'source', - enumCodec(['all', 'verified', 'fallback'] as const, 'all'), - ); - const [auditFilter, setAuditFilter] = useUrlState( - 'trust', - enumCodec(['all', 'audited'] as const, 'all'), - ); - const deferredSearchQuery = useDeferredValue( - searchQuery.trim().toLowerCase(), - ); + const navigate = useNavigate(); - const blueprintItems = useMemo(() => { - return Array.from(blueprints.values()).sort((a, b) => { + const rows = useMemo(() => { + const sorted = 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; }); + + return dedupeBlueprintsByIdentity(sorted); }, [blueprints]); - // Audited-status map keyed by blueprintId string. We pre-fetch in parallel - // so the "Audited only" toggle can filter before pagination — otherwise - // we'd render the wrong page count when toggling. - const auditedStatus = useAuditedStatusMap(blueprintItems.map((b) => b.id)); - - const categories = useMemo(() => { - const counts = new Map(); - - for (const blueprint of blueprintItems) { - const category = getBlueprintCategory(blueprint); - counts.set(category, (counts.get(category) ?? 0) + 1); - } - - return Array.from(counts.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([category, count]) => ({ category, count })); - }, [blueprintItems]); - - const filteredBlueprints = useMemo(() => { - return blueprintItems.filter((blueprint) => { - if (!matchesSearch(blueprint, deferredSearchQuery)) { - return false; - } - - if ( - selectedCategories.length > 0 && - !selectedCategories.includes(getBlueprintCategory(blueprint)) - ) { - return false; - } - - if ( - audienceFilter === 'customers' && - (blueprint.operatorsCount ?? 0) <= 0 - ) { - return false; - } - - if ( - audienceFilter === 'operators' && - (blueprint.operatorsCount ?? 0) >= 3 - ) { - return false; - } - - if (manifestFilter === 'verified' && !hasVerifiedManifest(blueprint)) { - return false; - } - - if (manifestFilter === 'fallback' && hasVerifiedManifest(blueprint)) { - return false; - } - - if ( - auditFilter === 'audited' && - !auditedStatus.get(blueprint.id.toString()) - ) { - return false; - } - - return true; - }); - }, [ - audienceFilter, - auditFilter, - auditedStatus, - blueprintItems, - deferredSearchQuery, - manifestFilter, - categoryParam, - ]); - - useEffect(() => { - setPage(0); - }, [ - audienceFilter, - auditFilter, - deferredSearchQuery, - manifestFilter, - categoryParam, - ]); - - // Collapse the catalog by metadata identity (publisher.namespace, - // requestedSlug) AFTER filtering. Running dedupe before filter would - // mean a filter like "audited only" couldn't surface a sibling mode - // when the canonical one isn't audited. After filter, the dedupe runs - // on the survivor set — same set of cards the operator was about to - // see, just collapsed into one per identity. - const dedupedRows = useMemo( - () => dedupeBlueprintsByIdentity(filteredBlueprints), - [filteredBlueprints], + const galleryItems = useMemo( + () => + rows.map((row) => + toGalleryItem( + row, + (blueprint) => navigate(`${PagePath.BLUEPRINTS}/${blueprint.id}`), + (blueprint) => + navigate(`${PagePath.BLUEPRINTS}/${blueprint.id}/deploy`), + onRegisterBlueprint, + ), + ), + [navigate, onRegisterBlueprint, rows], ); - const totalPages = Math.max(1, Math.ceil(dedupedRows.length / PAGE_SIZE)); - const safePage = Math.min(page, totalPages - 1); - const visibleRows = dedupedRows.slice( - safePage * PAGE_SIZE, - safePage * PAGE_SIZE + PAGE_SIZE, - ); - const hasActiveFilters = - searchQuery.trim() !== '' || - selectedCategories.length > 0 || - audienceFilter !== 'all' || - manifestFilter !== 'all' || - auditFilter !== 'all'; - - const showSelection = - typeof rowSelection !== 'undefined' && - typeof onRowSelectionChange === 'function'; - - if (isLoading) { - return ( -
- {Array.from({ length: 6 }).map((_, idx) => ( - - ))} -
- ); - } - if (error) { - return ( - - -

- {error.message} -

-
-
- ); - } - - if (blueprintItems.length === 0) { - return ( - - ); - } - - // 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 resetAllFilters = () => { - setSearchQuery(''); - setCategoryParam(''); - setAudienceFilter('all'); - setManifestFilter('all'); - setAuditFilter('all'); - }; + const hasCachedData = rows.length > 0; return (
- - setCategoryParam('')} - /> - - - - - - - +
+
+ + Blueprints + + + + {rows.length.toLocaleString()} deployable service{' '} + {pluralize('blueprint', rows.length)} + +
+ + {toolbarAction !== undefined && ( +
+ {toolbarAction}
- } - /> + )} +
- {dedupedRows.length === 0 ? ( - Clear all filters - ) - } - /> - ) : ( -
- {visibleRows.map((row) => ( - { - onRowSelectionChange?.((previous) => ({ - ...previous, - [row.blueprint.id.toString()]: isSelected, - })); - }} - onRegister={onRegisterBlueprint} - /> - ))} + {error && hasCachedData && ( +
+ Showing cached blueprints. Latest refresh failed: {error.message} + {onRetry !== undefined && ( + + )}
)} -
- - Showing {dedupedRows.length === 0 ? 0 : safePage * PAGE_SIZE + 1}- - {Math.min((safePage + 1) * PAGE_SIZE, dedupedRows.length)} of{' '} - {dedupedRows.length} {pluralize('blueprint', dedupedRows.length)} - - -
- - - - {safePage + 1}/{totalPages} - - - -
-
+
); }; @@ -634,354 +232,49 @@ BlueprintListing.displayName = 'BlueprintListing'; export default BlueprintListing; -/** - * Per-blueprint audited-status fetcher. For each id, reads the active - * version + that version's attestation list, then determines whether at - * least one non-revoked, non-expired, non-SELF attestation came from a - * known active auditor. - * - * Returns a Map so the parent filter can stay synchronous. - * - * Each query is keyed identically to `useBlueprintTrust` in - * `BlueprintTrustChip.tsx`, so the card chip + the listing filter share - * the same react-query cache entry — no duplicate chain reads. - */ -const useAuditedStatusMap = (blueprintIds: bigint[]): Map => { - const chainId = useChainId(); - const publicClient = usePublicClient({ chainId }); - const fallback = useMemo(() => auditorFallbackRegistry(), []); - - const queries = useQueries({ - queries: blueprintIds.map((blueprintId) => ({ - queryKey: ['tangle', 'blueprint-trust', chainId, blueprintId.toString()], - queryFn: async (): Promise<{ - score: number; - hasCriticalFinding: boolean; - hasAuditedAttestation: boolean; - attestationCount: number; - }> => { - if (!publicClient) { - return { - score: 0, - hasCriticalFinding: false, - hasAuditedAttestation: false, - attestationCount: 0, - }; - } - let tangle: Address; - try { - tangle = getContractsByChainId(chainId).tangle as Address; - } catch { - return { - score: 0, - hasCriticalFinding: false, - hasAuditedAttestation: false, - attestationCount: 0, - }; - } - const [count, activeId] = await Promise.all([ - publicClient.readContract({ - address: tangle, - abi: BinaryUpgradeABI, - functionName: 'getBinaryVersionCount', - args: [blueprintId], - }) as Promise, - publicClient.readContract({ - address: tangle, - abi: BinaryUpgradeABI, - functionName: 'getActiveBinaryVersionId', - args: [blueprintId], - }) as Promise, - ]); - if (count === 0n) { - return { - score: 0, - hasCriticalFinding: false, - hasAuditedAttestation: false, - attestationCount: 0, - }; - } - const attestations = await fetchAttestations( - publicClient, - chainId, - blueprintId, - BigInt(activeId), - ); - if (attestations.length === 0) { - return { - score: 0, - hasCriticalFinding: false, - hasAuditedAttestation: false, - attestationCount: 0, - }; - } - const uniqueAttesters = Array.from( - new Set(attestations.map((a) => a.attester.toLowerCase())), - ) as Address[]; - const auditors = await Promise.all( - uniqueAttesters.map(async (address): Promise => { - const onChain = await fetchAuditorOnChain( - publicClient, - chainId, - address, - ); - if (onChain !== null && onChain.active) return onChain; - const entry = fallback[address]; - if (entry) { - return { - name: entry.name, - metadataUri: entry.metadataUri, - weight: entry.weight, - tier: entry.tier, - active: entry.active, - admittedAt: 0n, - }; - } - return onChain; - }), - ); - const auditorMap = new Map(); - uniqueAttesters.forEach((address, idx) => { - auditorMap.set(address, auditors[idx] ?? null); - }); - 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) { - return false; - } - if (row.kind === AttestationKind.SELF) return false; - const auditor = auditorMap.get(row.attester.toLowerCase()); - return auditor !== null && auditor !== undefined && auditor.active; - }); - return { - score: 0, - hasCriticalFinding: false, - hasAuditedAttestation, - attestationCount: attestations.length, - }; - }, - enabled: publicClient !== undefined, - staleTime: 60_000, - })), - }); - - const map = new Map(); - blueprintIds.forEach((id, idx) => { - map.set(id.toString(), queries[idx]?.data?.hasAuditedAttestation ?? false); - }); - 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 BlueprintCard = ({ - row, - isSelectable, - isSelected, - isAudited, - onSelectionChange, - onRegister, -}: { - row: DedupedBlueprintRow; - isSelectable: boolean; - isSelected: boolean; - isAudited: boolean; - onSelectionChange: (isSelected: boolean) => void; +const BlueprintActions: FC<{ + blueprint: Blueprint; + onView: (blueprint: Blueprint) => void; + onDeploy: (blueprint: Blueprint) => void; onRegister?: (blueprint: Blueprint) => void; -}) => { - const { blueprint } = row; - const description = - blueprint.description?.trim() || - 'Service blueprint — deploy when operators are available.'; - 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); - 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. */} - +}> = ({ blueprint, onView, onDeploy, onRegister }) => { + const hasOperators = (blueprint.operatorsCount ?? 0) > 0; - {/* Watermark ID — mono, low-contrast, top-right. The card's identity - * marker; not a UI element, not actionable. */} - + + + {hasOperators && ( + )} - {/* Header — name + 1-line description. */} -
-

- {displayName} -

-

- {description} -

-
- - {/* Status row — one chip, mono operator count, audit/trust if present. - * Everything is bottom-anchored so cards align by their last line. */} -
- onRegister(blueprint)} > - - {hasOperators ? ( - <> - {operatorCount}{' '} - {pluralize('operator', operatorCount)} - - ) : ( - 'Needs operators' - )} - - {isAudited && ( - - Audited - - )} - {(blueprint.instancesCount ?? 0) > 0 && ( - - {blueprint.instancesCount} instances - - )} -
- - {/* Hover-revealed actions. Hidden by default — single click on the - * card opens the detail page; the actions are for power-users who - * want to jump straight to deploy/register without the detail step. - * `z-20` + `stopPropagation` keeps them clickable inside the overlay - * link. - * - * On non-hover devices (touch) the actions are always visible — the - * `group-hover:` selector only matches on pointer hover, so this is - * automatic. */} -
- {hasOperators ? ( - <> - e.stopPropagation()} - className="flex-1 rounded-md bg-primary px-2 py-1.5 text-center text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90" - > - Deploy - - - - ) : ( - - )} -
-
+ Add capacity + + )} + ); }; - -const RegisterCapacityButton = ({ - blueprint, - onRegister, - isPrimary = false, -}: { - blueprint: Blueprint; - onRegister?: (blueprint: Blueprint) => void; - isPrimary?: boolean; -}) => ( - -); diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/RegistrationDrawer.tsx b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/RegistrationDrawer.tsx index 85f7824470..86b635b53b 100644 --- a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/RegistrationDrawer.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/RegistrationDrawer.tsx @@ -548,7 +548,7 @@ const RegistrationDrawer: FC = ({ return ( - +
Register as Operator @@ -556,10 +556,6 @@ const RegistrationDrawer: FC = ({ Register an active operator for one or more selected blueprints. -
{renderGatedContent()} diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx index 047cb4adf2..cb8ac2736e 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx @@ -127,7 +127,9 @@ export const SelectOperatorsStep: FC = ({ nextKeys.every((key) => rowSelection[key]); if (!isSame) { - setRowSelection(nextSelection); + queueMicrotask(() => { + setRowSelection(nextSelection); + }); } }, [rowSelection, selectedOperators]); diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/PaymentStep.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/PaymentStep.tsx index 3f1ca2abe4..33c2b14106 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/PaymentStep.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/PaymentStep.tsx @@ -71,15 +71,19 @@ export const PaymentStep: FC = ({ } if (!selectedCommitment && creditAccounts[0]?.commitment) { - setValue('creditCommitment', creditAccounts[0].commitment, { - shouldDirty: false, + queueMicrotask(() => { + setValue('creditCommitment', creditAccounts[0].commitment, { + shouldDirty: false, + }); }); } }, [creditAccounts, paymentMethod, selectedCommitment, setValue]); useEffect(() => { if (!fundingAssetId && selectableAssets[0]?.id) { - setFundingAssetId(selectableAssets[0].id); + queueMicrotask(() => { + setFundingAssetId(selectableAssets[0].id); + }); } }, [fundingAssetId, selectableAssets]); diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx index e27f908bfd..2edecc9ce0 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx @@ -440,8 +440,8 @@ const DeployPage: FC = () => { return ( diff --git a/apps/tangle-cloud/src/pages/blueprints/manage/page.tsx b/apps/tangle-cloud/src/pages/blueprints/manage/page.tsx index 3b2fd84e0b..5a914bc7e8 100644 --- a/apps/tangle-cloud/src/pages/blueprints/manage/page.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/manage/page.tsx @@ -839,11 +839,13 @@ const UpdateMetadataModal: FC = ({ return; } - setMetadataUri(blueprint.metadataUri ?? ''); - setPreviewError(null); - setPreviewData(null); - setIsLoadingPreview(false); - reset(); + queueMicrotask(() => { + setMetadataUri(blueprint.metadataUri ?? ''); + setPreviewError(null); + setPreviewData(null); + setIsLoadingPreview(false); + reset(); + }); return () => { cancelInFlightPreviewRequest(); }; diff --git a/apps/tangle-cloud/src/pages/blueprints/page.tsx b/apps/tangle-cloud/src/pages/blueprints/page.tsx index bf7793137e..fd37625d0c 100644 --- a/apps/tangle-cloud/src/pages/blueprints/page.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/page.tsx @@ -1,40 +1,31 @@ import { RowSelectionState } from '@tanstack/table-core'; -import { Button } from '@tangle-network/sandbox-ui/primitives'; import { useAllBlueprints, useBlueprintsByOwner, } from '@tangle-network/tangle-shared-ui/data/graphql'; import useOperatorInfo from '@tangle-network/tangle-shared-ui/hooks/useOperatorInfo'; import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; +import { Button } from '@tangle-network/ui-components'; import { AnimatePresence, motion } from 'framer-motion'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { Link, useSearchParams } from 'react-router'; +import { useNavigate, useSearchParams } from 'react-router'; import { twMerge } from 'tailwind-merge'; -import useRoleStore, { Role } from '../../stores/roleStore'; import { PagePath } from '../../types'; import pollWithBackoff from '../../utils/pollWithBackoff'; import BlueprintListing from './BlueprintListing'; import RegistrationDrawer from './RegistrationDrawer'; -import { PageHeader } from '../../components/chrome'; const BLUEPRINT_DOCS_LINK = 'https://docs.tangle.tools/developers/blueprints/introduction'; -const ROLE_TITLE = { - [Role.OPERATOR]: 'Blueprints', - [Role.DEPLOYER]: 'Blueprints', -} satisfies Record; - -const HAS_BLUEPRINTS_TITLE = 'Blueprints'; - const pluralize = (label: string, count: number) => count === 1 ? label : `${label}s`; const Page: FC = () => { - const role = useRoleStore((store) => store.role); const [rowSelection, setRowSelection] = useState({}); const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); const { blueprints, isLoading, error, refetch } = useAllBlueprints(); const { data: ownedBlueprints } = useBlueprintsByOwner(); @@ -69,10 +60,12 @@ const Page: FC = () => { return; } - handleRegisterBlueprint(blueprint); - const nextParams = new URLSearchParams(searchParams); - nextParams.delete('register'); - setSearchParams(nextParams, { replace: true }); + queueMicrotask(() => { + handleRegisterBlueprint(blueprint); + const nextParams = new URLSearchParams(searchParams); + nextParams.delete('register'); + setSearchParams(nextParams, { replace: true }); + }); }, [ blueprints, handleRegisterBlueprint, @@ -154,43 +147,31 @@ const Page: FC = () => { }); }, []); - const title = hasOwnedBlueprints ? HAS_BLUEPRINTS_TITLE : ROLE_TITLE[role]; - // No subtitle on a catalog page — the visible content IS the subtitle. - // Adding "Find blueprints, create service instances, ..." steals 24px of - // vertical space for copy the operator already knows by being on this - // page. Catalog tier = title + toolbar + grid, nothing else above content. - // Catalog-wide stats (total, ready, needs-capacity, your-registrations) - // belong on the home dashboard, not above a search bar. + const toolbarAction = hasOwnedBlueprints ? ( + + ) : ( + + ); return (
- - {hasOwnedBlueprints && ( - - )} - - - } - /> - { rowSelection={isOperator ? rowSelection : undefined} onRowSelectionChange={isOperator ? setRowSelection : undefined} onRegisterBlueprint={handleRegisterBlueprint} + toolbarAction={toolbarAction} + onRetry={() => void refetch()} /> @@ -220,7 +203,7 @@ const Page: FC = () => {

- )} diff --git a/apps/tangle-cloud/src/pages/earnings/page.tsx b/apps/tangle-cloud/src/pages/earnings/page.tsx index fa41e1576d..bfab1576fa 100644 --- a/apps/tangle-cloud/src/pages/earnings/page.tsx +++ b/apps/tangle-cloud/src/pages/earnings/page.tsx @@ -36,7 +36,9 @@ const EarningsPage: FC = () => { ); useEffect(() => { - setEventsPageIndex((current) => Math.min(current, totalEventPages - 1)); + queueMicrotask(() => { + setEventsPageIndex((current) => Math.min(current, totalEventPages - 1)); + }); }, [totalEventPages]); const visibleEvents = useMemo(() => { diff --git a/apps/tangle-cloud/src/pages/instances/AccountStatsCard.tsx b/apps/tangle-cloud/src/pages/instances/AccountStatsCard.tsx index eefcb2a194..b4daa5c5bc 100644 --- a/apps/tangle-cloud/src/pages/instances/AccountStatsCard.tsx +++ b/apps/tangle-cloud/src/pages/instances/AccountStatsCard.tsx @@ -1,11 +1,10 @@ import { useMemo, type FC } from 'react'; import { - Avatar, - AvatarFallback, Badge, Card, CardContent, } from '@tangle-network/sandbox-ui/primitives'; +import { Avatar } from '@tangle-network/ui-components'; import { ExternalLinkLine } from '@tangle-network/icons'; import { useAccount, useChainId } from 'wagmi'; import ConnectWalletButton from '@tangle-network/tangle-shared-ui/components/ConnectWalletButton'; @@ -118,26 +117,18 @@ export const AccountStatsCard: FC = (props) => { className={twMerge('w-full', rootProps?.className)} > -
- - - TC - - - -
-

- Account -

-
- Connect a wallet to load your account -
-

- Connect to load deployed services, operator registrations, and - account-scoped lifecycle events. Public catalog and operator - registry data load below without a wallet. -

+
+

+ Account +

+
+ Connect a wallet to load your account
+

+ Connect to load deployed services, operator registrations, and + account-scoped lifecycle events. Public catalog and operator + registry data load below without a wallet. +

@@ -152,13 +143,12 @@ export const AccountStatsCard: FC = (props) => {
- - - {accountAddress - ? accountAddress.slice(2, 4).toUpperCase() - : 'TC'} - - +
{identityName} diff --git a/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ServiceRequestDetailModal.tsx b/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ServiceRequestDetailModal.tsx index 499e146f67..0c3344217d 100644 --- a/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ServiceRequestDetailModal.tsx +++ b/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ServiceRequestDetailModal.tsx @@ -244,7 +244,9 @@ const ServiceRequestDetailModal: FC = ({ const requestId = selectedRequest?.requestId; useEffect(() => { if (requestId !== undefined) { - setView('details'); + queueMicrotask(() => { + setView('details'); + }); } }, [requestId]); diff --git a/apps/tangle-cloud/src/pages/operators/page.tsx b/apps/tangle-cloud/src/pages/operators/page.tsx index 80bf6b668c..2d7ff44c28 100644 --- a/apps/tangle-cloud/src/pages/operators/page.tsx +++ b/apps/tangle-cloud/src/pages/operators/page.tsx @@ -3,7 +3,6 @@ import { useOperators, } from '@tangle-network/tangle-shared-ui/data/graphql/useOperators'; import { - Badge, Button, Card, CardContent, @@ -20,17 +19,23 @@ import { import { Search } from '@tangle-network/icons'; import { type ChangeEvent, - type CSSProperties, type FC, useCallback, useMemo, useState, } from 'react'; -import { formatUnits, type Address } from 'viem'; +import { type Address } from 'viem'; import { useNavigate } from 'react-router'; import { PagePath } from '../../types'; import { useAccount } from 'wagmi'; -import { MetricStrip, PageHeader } from '../../components/chrome'; +import { + formatMoney, + MetricStrip, + Money, + PageHeader, + StatusPill, + statusToneFor, +} from '../../components/chrome'; import type { Metric } from '../../components/chrome'; import createStakeDelegateUrl from './createStakeDelegateUrl'; @@ -88,7 +93,11 @@ const Page: FC = () => { }, { label: 'Total stake', - value: formatStake(totalStake), + value: formatMoney(totalStake, { + decimals: 18, + symbol: 'TNT', + displayDecimals: 2, + }).display, sublabel: 'TNT delegated', loading: isLoading, }, @@ -365,8 +374,9 @@ const OperatorTableRow = ({
@@ -380,32 +390,24 @@ const OperatorTableRow = ({
- {formatStake(operator.stakingStake)} - TNT + {formatCount(operator.stakingDelegationCount)} - + {delegationModeLabel} - + - + {formatStatus(status)} - +
@@ -433,12 +435,9 @@ const OperatorTableRow = ({ const OperatorIdenticon = ({ address }: { address: string }) => (
{address.slice(2, 4).toUpperCase()}
@@ -449,35 +448,42 @@ const shortenAddress = (address: string) => { return `${address.slice(0, 6)}...${address.slice(-4)}`; }; -const formatStake = (value: bigint | null) => - value === null - ? '-' - : Number(formatUnits(value, 18)).toLocaleString(undefined, { - maximumFractionDigits: 2, - }); - const formatCount = (value: bigint | null) => value === null ? '-' : value.toLocaleString(); const hashAddress = (address: string) => Array.from(address).reduce((hash, char) => { - return (hash * 31 + char.charCodeAt(0)) % 360; + return (hash * 31 + char.charCodeAt(0)) % 997; }, 0); -const getOperatorIdenticonStyle = (address: string) => { - const hue = hashAddress(address); - return { - backgroundColor: '#111827', - backgroundImage: `linear-gradient(135deg, hsl(${hue} 82% 40%), hsl(${(hue + 42) % 360} 82% 32%))`, - }; -}; +const OPERATOR_ACCENT_CLASSES = [ + 'bg-[color:var(--border-accent-hover)]', + 'bg-[color:var(--md3-tertiary,#10b981)]', + 'bg-[color:var(--md3-warning,#f59e0b)]', + 'bg-muted-foreground/70', +] as const; -const getOperatorAccent = (address: string): CSSProperties => { - const hue = hashAddress(address); - return { - background: `linear-gradient(180deg, hsl(${hue} 82% 58%), hsl(${(hue + 42) % 360} 82% 50%))`, - }; -}; +const OPERATOR_IDENTICON_CLASSES = [ + 'border-[color:var(--border-accent-hover)]/60', + 'border-[color:var(--md3-tertiary,#10b981)]/50', + 'border-[color:var(--md3-warning,#f59e0b)]/50', + 'border-border', +] as const; + +const getOperatorBucket = (address: string) => + hashAddress(address) % OPERATOR_ACCENT_CLASSES.length; + +const getOperatorAccentClass = (address: string) => + OPERATOR_ACCENT_CLASSES[getOperatorBucket(address)]; + +const getOperatorIdenticonClass = (address: string) => + OPERATOR_IDENTICON_CLASSES[getOperatorBucket(address)]; + +const getDelegationModeTone = (mode: number | null) => + statusToneFor( + 'availability', + mode === 2 ? 'Available' : mode === 1 ? 'Limited' : 'Unavailable', + ); const getDelegationModeLabel = (mode: number | null) => { if (mode === 2) return 'Open'; diff --git a/apps/tangle-cloud/src/pages/rewards/page.tsx b/apps/tangle-cloud/src/pages/rewards/page.tsx index 481d7333d2..b1f94d7806 100644 --- a/apps/tangle-cloud/src/pages/rewards/page.tsx +++ b/apps/tangle-cloud/src/pages/rewards/page.tsx @@ -179,24 +179,26 @@ const RewardsPage: FC = () => { [pendingRows, selectedTokenSet], ); useEffect(() => { - if (pendingRows.length === 0) { - setSelectedTokenSet(new Set()); - return; - } - - setSelectedTokenSet((current) => { - const activeTokens = new Set( - pendingRows.map((row) => row.token.toLowerCase()), - ); - const next = new Set( - [...current].filter((token) => activeTokens.has(token)), - ); - - if (next.size === current.size) { - return current; + queueMicrotask(() => { + if (pendingRows.length === 0) { + setSelectedTokenSet(new Set()); + return; } - return next; + setSelectedTokenSet((current) => { + const activeTokens = new Set( + pendingRows.map((row) => row.token.toLowerCase()), + ); + const next = new Set( + [...current].filter((token) => activeTokens.has(token)), + ); + + if (next.size === current.size) { + return current; + } + + return next; + }); }); }, [pendingRows]); diff --git a/apps/tangle-cloud/src/pages/services/[id]/JobHistoryTable.tsx b/apps/tangle-cloud/src/pages/services/[id]/JobHistoryTable.tsx index 6512e4c9b1..1ddb7d970d 100644 --- a/apps/tangle-cloud/src/pages/services/[id]/JobHistoryTable.tsx +++ b/apps/tangle-cloud/src/pages/services/[id]/JobHistoryTable.tsx @@ -17,10 +17,10 @@ import { EmptyState, SkeletonTable, } from '@tangle-network/sandbox-ui/primitives'; +import { StatusPill, statusToneFor } from '../../../components/chrome'; import type { JobCall } from '@tangle-network/tangle-shared-ui/data/graphql'; import { isOptimisticJob } from '@tangle-network/tangle-shared-ui/data/graphql/useJobs'; import type { BlueprintJobDefinition } from '@tangle-network/tangle-shared-ui/data/services'; -import { twMerge } from 'tailwind-merge'; import { JobResultsModal } from './JobResultsModal'; interface Props { @@ -61,11 +61,7 @@ const makeColumns = (jobDefinitions?: BlueprintJobDefinition[]) => [ cell: (info) => { const job = info.row.original; if (isOptimisticJob(job)) { - return ( - - Confirming... - - ); + return Confirming; } return ( @@ -114,24 +110,12 @@ const makeColumns = (jobDefinitions?: BlueprintJobDefinition[]) => [ cell: (info) => { const job = info.row.original; if (isOptimisticJob(job)) { - return ( - - Confirming - - ); + return Confirming; } const completed = info.getValue(); + const label = completed ? 'Completed' : 'Pending'; return ( - - {completed ? 'Completed' : 'Pending'} - + {label} ); }, }), diff --git a/apps/tangle-cloud/src/pages/services/[id]/JobSubmissionForm.tsx b/apps/tangle-cloud/src/pages/services/[id]/JobSubmissionForm.tsx index d60340a079..441e3370bc 100644 --- a/apps/tangle-cloud/src/pages/services/[id]/JobSubmissionForm.tsx +++ b/apps/tangle-cloud/src/pages/services/[id]/JobSubmissionForm.tsx @@ -336,7 +336,9 @@ export const JobSubmissionForm: FC = ({ serviceId, blueprint }) => { // Initialize form values when schema changes useEffect(() => { if (hasSchema) { - setFormValues(selectedSchema.map(getDefaultValue)); + queueMicrotask(() => { + setFormValues(selectedSchema.map(getDefaultValue)); + }); } }, [hasSchema, selectedSchema]); diff --git a/apps/tangle-cloud/src/pages/services/[id]/MetadataJsonInput.tsx b/apps/tangle-cloud/src/pages/services/[id]/MetadataJsonInput.tsx index 03e65b4e92..74949a210e 100644 --- a/apps/tangle-cloud/src/pages/services/[id]/MetadataJsonInput.tsx +++ b/apps/tangle-cloud/src/pages/services/[id]/MetadataJsonInput.tsx @@ -59,7 +59,9 @@ export const MetadataJsonInput: FC = ({ // submit). The string compare avoids clobbering in-progress edits while the // user is typing. useEffect(() => { - setDraftJson((current) => (current === value ? current : (value ?? ''))); + queueMicrotask(() => { + setDraftJson((current) => (current === value ? current : (value ?? ''))); + }); }, [value]); const parsed = useMemo(() => parseMetadata(draftJson), [draftJson]); diff --git a/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx b/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx index 400342525b..7a2b044c64 100644 --- a/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx +++ b/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx @@ -109,6 +109,9 @@ const OperatorExitPanel: FC = ({ }) => { const [selectedOperatorForForceExit, setSelectedOperatorForForceExit] = useState
(null); + const [nowSeconds, setNowSeconds] = useState(() => + Math.floor(Date.now() / 1000), + ); const [countdown, setCountdown] = useState(''); const { data: exitConfig, isLoading: isLoadingConfig } = @@ -129,13 +132,21 @@ const OperatorExitPanel: FC = ({ useServiceOperators(serviceId); const { data: latestBlock } = useBlock({ watch: true }); + useEffect(() => { + const intervalId = setInterval(() => { + setNowSeconds(Math.floor(Date.now() / 1000)); + }, 1000); + + return () => clearInterval(intervalId); + }, []); + // Compute offset between chain time and wall clock to handle // environments where block.timestamp drifts from real time // (e.g., local dev with evm_increaseTime). const chainTimeOffset = useMemo(() => { if (!latestBlock) return 0; - return Number(latestBlock.timestamp) - Math.floor(Date.now() / 1000); - }, [latestBlock]); + return Number(latestBlock.timestamp) - nowSeconds; + }, [latestBlock, nowSeconds]); const isLoading = isLoadingConfig || @@ -185,13 +196,14 @@ const OperatorExitPanel: FC = ({ const canExecuteNow = exitRequest && exitStatus === ExitStatus.Scheduled - ? BigInt(Math.floor(Date.now() / 1000) + chainTimeOffset) >= - exitRequest.executeAfter + ? BigInt(nowSeconds + chainTimeOffset) >= exitRequest.executeAfter : false; useEffect(() => { if (exitStatus !== ExitStatus.Scheduled || !exitRequest || canExecuteNow) { - setCountdown(''); + queueMicrotask(() => { + setCountdown(''); + }); return; } @@ -209,7 +221,7 @@ const OperatorExitPanel: FC = ({ setCountdown(formatDuration(BigInt(remaining))); }; - updateCountdown(); + queueMicrotask(updateCountdown); const interval = setInterval(updateCountdown, 1000); return () => clearInterval(interval); diff --git a/apps/tangle-cloud/src/pages/services/[id]/ServiceOnChainDetails.tsx b/apps/tangle-cloud/src/pages/services/[id]/ServiceOnChainDetails.tsx index 6af445eb94..c1ad5a3a3a 100644 --- a/apps/tangle-cloud/src/pages/services/[id]/ServiceOnChainDetails.tsx +++ b/apps/tangle-cloud/src/pages/services/[id]/ServiceOnChainDetails.tsx @@ -3,14 +3,19 @@ * pricing model, and membership configuration. */ -import { type ComponentProps, type FC, type ReactNode } from 'react'; +import { type ComponentProps, type FC, type ReactNode, useState } from 'react'; import { - Badge, Button as SandboxButton, Card, Skeleton, } from '@tangle-network/sandbox-ui/primitives'; import { Text } from '../../../components/sandbox/SandboxUi'; +import { + Money, + StatusPill, + formatMoney, + statusToneFor, +} from '../../../components/chrome'; import { useServiceDetails, useServiceEscrow, @@ -25,7 +30,6 @@ import { MembershipModel } from '@tangle-network/tangle-shared-ui/data/services/ import { formatTtl, formatCreatedAt } from '../../../types/serviceRequest'; import { useChainId } from 'wagmi'; import { chainsConfig } from '@tangle-network/dapp-config/chains'; -import { formatUnits } from 'viem'; interface Props { serviceId: bigint; @@ -63,16 +67,6 @@ const Button: FC = ({ /> ); -const Chip: FC<{ color?: string; children: ReactNode }> = ({ - color, - children, -}) => { - const variant = - color === 'green' ? 'success' : color === 'red' ? 'destructive' : 'outline'; - - return {children}; -}; - const ServiceOnChainDetails: FC = ({ serviceId, blueprintId, @@ -97,6 +91,7 @@ const ServiceOnChainDetails: FC = ({ txHash: billingTxHash, reset: resetBillingState, } = useBillSubscriptionTx(); + const [now] = useState(() => BigInt(Math.floor(Date.now() / 1000))); const isLoading = isLoadingDetails || isLoadingEscrow || isLoadingConfig || isLoadingToken; @@ -127,6 +122,10 @@ const ServiceOnChainDetails: FC = ({ const tokenSymbol = tokenMetadata?.symbol ?? (isNativeToken ? 'ETH' : 'TOKEN'); const tokenDecimals = tokenMetadata?.decimals ?? 18; + const moneyOptions = { + decimals: tokenDecimals, + symbol: tokenSymbol, + }; const explorerBaseUrl = activeChain?.blockExplorers?.default?.url; const isSubscriptionService = @@ -140,7 +139,6 @@ const ServiceOnChainDetails: FC = ({ const hasEscrowForNextBill = hasSubscriptionConfig && escrowBalance >= subscriptionRate; const isServiceActive = serviceDetails?.status === ServiceStatus.Active; - const now = BigInt(Math.floor(Date.now() / 1000)); const lastPaymentAt = serviceDetails?.lastPaymentAt ?? BigInt(0); const nextBillingAt = hasSubscriptionConfig && serviceDetails @@ -166,12 +164,28 @@ const ServiceOnChainDetails: FC = ({ await executeBillSubscription({ serviceId }); }; - const formatAmount = (amount: bigint | undefined): string => { + const renderMoney = ( + amount: bigint | null | undefined, + suffix?: ReactNode, + ): ReactNode => { + if (amount === null || amount === undefined) { + return EMPTY_VALUE_PLACEHOLDER; + } + + return ( + + + {suffix !== undefined && ( + {suffix} + )} + + ); + }; + + const formatAmountForDetail = (amount: bigint | undefined): string => { if (amount === undefined) return EMPTY_VALUE_PLACEHOLDER; - const formatted = Number(formatUnits(amount, tokenDecimals)); - return Number.isFinite(formatted) - ? formatted.toLocaleString(undefined, { maximumFractionDigits: 4 }) - : formatUnits(amount, tokenDecimals); + const view = formatMoney(amount, moneyOptions); + return `${view.full} ${view.symbol}`.trim(); }; const getMembershipLabel = ( @@ -181,30 +195,17 @@ const ServiceOnChainDetails: FC = ({ return membership === MembershipModel.Fixed ? 'Fixed' : 'Dynamic'; }; - const getMembershipChipColor = (membership: MembershipModel | undefined) => { - if (membership === undefined) return 'dark-grey'; - return membership === MembershipModel.Fixed ? 'blue' : 'purple'; - }; - - const getPricingChipColor = (pricing: ServicePricingModel | undefined) => { - if (pricing === undefined) return 'dark-grey'; - switch (pricing) { - case ServicePricingModel.PayOnce: - return 'green'; - case ServicePricingModel.Subscription: - return 'yellow'; - case ServicePricingModel.EventDriven: - return 'blue'; - default: - return 'dark-grey'; - } + const getPricingLabel = ( + pricing: ServicePricingModel | undefined, + ): string => { + if (pricing === undefined) return EMPTY_VALUE_PLACEHOLDER; + return getServicePricingModelLabel(pricing); }; - const formatSubscriptionRate = (): string => { + const formatSubscriptionRate = (): ReactNode => { if (!blueprintConfig || blueprintConfig.subscriptionRate === BigInt(0)) { return EMPTY_VALUE_PLACEHOLDER; } - const rate = formatAmount(blueprintConfig.subscriptionRate); const intervalSeconds = Number(blueprintConfig.subscriptionInterval); const intervalLabel = intervalSeconds >= 86400 @@ -212,14 +213,14 @@ const ServiceOnChainDetails: FC = ({ : intervalSeconds >= 3600 ? `${Math.floor(intervalSeconds / 3600)} hour(s)` : `${Math.floor(intervalSeconds / 60)} minute(s)`; - return `${rate} ${tokenSymbol} / ${intervalLabel}`; + return renderMoney(blueprintConfig.subscriptionRate, `/ ${intervalLabel}`); }; - const formatEventRate = (): string => { + const formatEventRate = (): ReactNode => { if (!blueprintConfig || blueprintConfig.eventRate === BigInt(0)) { return EMPTY_VALUE_PLACEHOLDER; } - return `${formatAmount(blueprintConfig.eventRate)} ${tokenSymbol} / job`; + return renderMoney(blueprintConfig.eventRate, '/ job'); }; return ( @@ -260,19 +261,27 @@ const ServiceOnChainDetails: FC = ({ + {getMembershipLabel(serviceDetails?.membership)} - + } /> - {serviceDetails?.pricing !== undefined - ? getServicePricingModelLabel(serviceDetails.pricing) - : EMPTY_VALUE_PLACEHOLDER} - + + {getPricingLabel(serviceDetails?.pricing)} + } /> = ({
- {formatAmount(escrow?.balance)} {tokenSymbol} - - } + value={renderMoney(escrow?.balance)} highlight /> = ({ + {canBillSubscription ? 'Billable now' : 'Not billable yet'} - + } />
@@ -388,14 +387,19 @@ const ServiceOnChainDetails: FC = ({ ok={hasSubscriptionConfig} detail={ hasSubscriptionConfig - ? `${formatAmount(subscriptionRate)} ${tokenSymbol} every ${formatTtl(subscriptionInterval)}` + ? renderMoney( + subscriptionRate, + `every ${formatTtl(subscriptionInterval)}`, + ) : 'Missing subscription rate or interval' } /> = ({ {isBillingSuccess && billingTxHash && (
- - Subscription billed successfully. - + Subscription billed {explorerBaseUrl ? ( View transaction @@ -457,17 +459,17 @@ interface DetailItemProps { const BillingCondition: FC<{ label: string; ok: boolean; - detail: string | null; + detail: ReactNode | null; }> = ({ label, ok, detail }) => (
{label} - + {ok ? 'Yes' : 'No'} - + {detail && ( - + {detail} )} @@ -478,7 +480,7 @@ const DetailItem: FC = ({ label, value, highlight }) => (
diff --git a/apps/tangle-cloud/src/pages/services/[id]/page.tsx b/apps/tangle-cloud/src/pages/services/[id]/page.tsx index 1a8696f50e..06100a5bb6 100644 --- a/apps/tangle-cloud/src/pages/services/[id]/page.tsx +++ b/apps/tangle-cloud/src/pages/services/[id]/page.tsx @@ -42,7 +42,6 @@ import { } from '@tangle-network/tangle-shared-ui/data/services'; import { addressesEqual } from '@tangle-network/tangle-shared-ui/utils/safeParseAddress'; import useEvmOperatorInfo from '../../../hooks/useEvmOperatorInfo'; -import { twMerge } from 'tailwind-merge'; import { Address, getAddress, isAddress, zeroAddress } from 'viem'; import { JobSubmissionForm } from './JobSubmissionForm'; import { JobHistoryTable } from './JobHistoryTable'; @@ -54,6 +53,7 @@ import { PagePath } from '../../../types'; import BlueprintHostCard from '../../../components/blueprintApps/BlueprintHostCard'; import ServiceUpgradePanel from '../../../components/binaryUpgrade/ServiceUpgradePanel'; import ServiceUpgradeBadge from '../../../components/binaryUpgrade/ServiceUpgradeBadge'; +import { StatusPill, statusToneFor } from '../../../components/chrome'; const EMPTY_VALUE_PLACEHOLDER = '-'; const CARD_SURFACE = 'sandbox' as const; @@ -252,26 +252,30 @@ const ServiceDetailPage: FC = () => { operatorAddress ?? undefined, { enabled: !!operatorAddress && isOperator }, ); + const ownerAddress = onChainDetails?.owner; + const servicePermittedCallers = service?.permittedCallers; // Determine if user is the owner const isOwner = useMemo(() => { - if (!address || !onChainDetails?.owner) return false; - return addressesEqual(onChainDetails.owner, address); - }, [address, onChainDetails?.owner]); + if (!address || !ownerAddress) return false; + return addressesEqual(ownerAddress, address); + }, [address, ownerAddress]); useEffect(() => { - setCallerInput(''); - setCallerInputError(null); - setRemovingCaller(null); + const timeoutId = window.setTimeout(() => { + setCallerInput(''); + setCallerInputError(null); + setRemovingCaller(null); + }, 0); + + return () => window.clearTimeout(timeoutId); }, [serviceId]); const permittedCallers = useMemo(() => { - const owner = onChainDetails?.owner - ? getAddress(onChainDetails.owner) - : null; + const owner = ownerAddress ? getAddress(ownerAddress) : null; const callers = new Map(); - for (const caller of service?.permittedCallers ?? []) { + for (const caller of servicePermittedCallers ?? []) { if (!isAddress(caller, { strict: false })) { continue; } @@ -292,7 +296,7 @@ const ServiceDetailPage: FC = () => { (caller) => !addressesEqual(caller, owner), ); return [owner, ...withoutOwner]; - }, [onChainDetails?.owner, service?.permittedCallers]); + }, [ownerAddress, servicePermittedCallers]); // User can submit jobs if they are the owner or a permitted caller const canSubmitJobs = useMemo(() => { @@ -314,10 +318,10 @@ const ServiceDetailPage: FC = () => { () => validatePermittedCallerInput({ value: callerInput, - owner: onChainDetails?.owner, + owner: ownerAddress, permittedCallers, }), - [callerInput, onChainDetails?.owner, permittedCallers], + [callerInput, ownerAddress, permittedCallers], ); const canAddPermittedCaller = @@ -517,16 +521,9 @@ const ServiceDetailPage: FC = () => { + {service.status} - + } /> {
Connected account access:{' '} - {canSubmitJobs ? 'Allowed' : 'Not allowed'} - +
diff --git a/apps/tangle-cloud/src/styles.css b/apps/tangle-cloud/src/styles.css index 134e3ec93f..61f9d596e6 100644 --- a/apps/tangle-cloud/src/styles.css +++ b/apps/tangle-cloud/src/styles.css @@ -1,17 +1,17 @@ /* Tangle Cloud styles. Tokens (--md3-*, --hsl-*, --bg-*, --text-*, --border-*, * --btn-*, --shadow-*, [data-sandbox-theme='vault'] light overrides) come from * @tangle-network/brand via the sandbox-ui vendor CSS preloaded in index.html. - * This file adds Geist typeface, tangle/vault theme deltas, and cloud-only - * component classes. `!important` is reserved for fights against legacy - * ui-components @layer base rules ('*:not(code) Satoshi', 'h1..h6 text-mono-*') - * that arrive in the bundle BEFORE cloud styles. */ + * This file adds tangle/vault theme deltas and cloud-only component classes. + * `!important` is reserved for fights against legacy ui-components @layer base + * rules ('*:not(code) Satoshi', 'h1..h6 text-mono-*') that arrive in the bundle + * BEFORE cloud styles. */ @import '@tangle-network/brand/styles/tokens.css'; :root, [data-sandbox-ui] { - --font-sans: 'Geist Sans', ui-sans-serif, system-ui, sans-serif; - --font-display: 'Geist Sans', ui-sans-serif, system-ui, sans-serif; - --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, monospace; + --font-sans: 'Satoshi', ui-sans-serif, system-ui, sans-serif; + --font-display: 'Satoshi', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'Cousine', ui-monospace, SFMono-Regular, monospace; } html, @@ -29,14 +29,7 @@ body { font-family: var(--font-sans); } -/* Override ui-components' .bg-tangle wallpaper with the brand radial gradient. */ -.tangle-cloud-shell.bg-tangle, -[data-sandbox-ui].bg-tangle { - background: var(--bg-root); - background-attachment: initial; -} .tangle-cloud-shell { - background: var(--bg-root); color: var(--text-primary); } @@ -46,13 +39,16 @@ body { .tangle-cloud-shell[data-sandbox-theme='tangle'] { color-scheme: dark; /* prettier-ignore */ - --bg-root: radial-gradient(ellipse 70% 48% at 18% 0%, rgba(99, 102, 241, 0.14), transparent), radial-gradient(ellipse 48% 36% at 82% 6%, rgba(20, 184, 166, 0.09), transparent), linear-gradient(180deg, #0a0a14 0%, #07070d 100%); - --bg-card: #1b1a2c; - --bg-elevated: #262448; - --bg-hover: rgba(129, 140, 248, 0.16); + --bg-root: radial-gradient(ellipse 60% 36% at 16% 0%, rgba(79, 70, 229, 0.1), transparent), radial-gradient(ellipse 44% 30% at 84% 4%, rgba(20, 184, 166, 0.08), transparent), linear-gradient(180deg, #090b10 0%, #07080c 100%); + --bg-card: #151922; + --bg-elevated: #202737; + --bg-hover: rgba(116, 126, 194, 0.18); + --text-primary: #dbe3f0; + --text-muted: #9aa7ba; + --text-secondary: #c3ccdc; --border-subtle: rgba(116, 126, 194, 0.12); - --border-default: rgba(128, 138, 213, 0.24); - --border-hover: rgba(151, 161, 245, 0.48); + --border-default: rgba(135, 146, 172, 0.26); + --border-hover: rgba(151, 161, 245, 0.46); --border-accent: rgba(129, 140, 248, 0.36); --border-accent-hover: rgba(129, 140, 248, 0.55); --accent-surface-soft: rgba(99, 102, 241, 0.12); @@ -90,13 +86,30 @@ body { * fires when a requested weight is missing from a fallback font) doesn't * sneak in slanted glyphs on body text. Italic is a deliberate emphasis * marker in this app and shows up only via explicit `.italic` class. */ -.tangle-cloud-shell :where(h1, h2, h3, h4, h5, h6, p, label, li, td, th, button, a, span) { +.tangle-cloud-shell + :where(h1, h2, h3, h4, h5, h6, p, label, li, td, th, button, a, span) { color: inherit; } .tangle-cloud-shell :where( - h1, h2, h3, h4, h5, h6, p, span, a, button, label, li, td, th, - input, select, textarea, div + h1, + h2, + h3, + h4, + h5, + h6, + p, + span, + a, + button, + label, + li, + td, + th, + input, + select, + textarea, + div ):not(.italic) { /* `!important` + form controls: the previous rule (no !important, no * inputs/selects) let slanted fallback glyphs through on the search bar @@ -212,9 +225,15 @@ body { * those alone so my centering math doesn't drag the drawer back to center. */ @media (min-width: 1024px) { - [role='dialog'][data-state='open']:not([class*='left-auto']):not([class*='right-auto']):not([class*='translate-x-0']), - [role='dialog'][data-state='closed']:not([class*='left-auto']):not([class*='right-auto']):not([class*='translate-x-0']) { - --tw-translate-x: calc(-50% + var(--cloud-sidebar-width, 16rem) / 2) !important; + [role='dialog'][data-state='open']:not([class*='left-auto']):not( + [class*='right-auto'] + ):not([class*='translate-x-0']), + [role='dialog'][data-state='closed']:not([class*='left-auto']):not( + [class*='right-auto'] + ):not([class*='translate-x-0']) { + --tw-translate-x: calc( + -50% + var(--cloud-sidebar-width, 16rem) / 2 + ) !important; } } @@ -256,11 +275,6 @@ body { box-shadow: var(--shadow-card); } -.results-grid { - content-visibility: auto; - contain-intrinsic-size: 800px; -} - /* Page-to-page transition triggered by PageMotion when react-router's * pathname changes. 100ms total: 4px upward translate + opacity fade-in. * Honors `prefers-reduced-motion` — reduced-motion users get the fade diff --git a/apps/tangle-cloud/src/styles/chrome.ts b/apps/tangle-cloud/src/styles/chrome.ts index b6e1fbaed1..1292fce588 100644 --- a/apps/tangle-cloud/src/styles/chrome.ts +++ b/apps/tangle-cloud/src/styles/chrome.ts @@ -23,10 +23,9 @@ export const typeRole = { /** Page H1. One per page. */ display: - 'font-display font-extrabold text-3xl sm:text-4xl tracking-[-0.035em] leading-[1.05]', + 'font-display font-extrabold text-3xl sm:text-4xl tracking-normal leading-[1.08]', /** Section heads, card titles. */ - section: - 'font-display font-semibold text-lg leading-tight tracking-[-0.012em]', + section: 'font-display font-semibold text-lg leading-tight tracking-normal', /** Default body copy. */ body: 'text-sm leading-relaxed', /** Control labels, eyebrows. Always uppercase, always tracked. */ @@ -57,29 +56,176 @@ export const chromeHeight = { headerCompact: 'min-h-[60px] py-3', headerDefault: 'min-h-[80px] py-4', headerHero: 'min-h-[120px] py-6', - toolbar: 'h-11', // 44px + toolbar: 'min-h-11', // 44px minimum; wraps on narrow screens. metricStrip: 'min-h-[56px] py-3', tray: 'w-[420px]', } as const; // ── Status palette ─────────────────────────────────────────────────────────── -// Three status tones. Outlined pills only — `border-{color} bg-{color}/8` — -// never filled. One color per status. Used at boundaries (state of a service, -// audit state, capacity state). Never decorative. +// Six status tones, one per *meaning*. Outlined pills only — +// `border-{color} bg-{color}/8` — never filled. One color per status. Used at +// boundaries (state of a service/instance/job/operator/payment, audit state, +// capacity state). Never decorative. +// +// The tone enum is closed on purpose: surfaces map a domain status to one of +// these six and the pill renders identically everywhere. A status can never +// silently collapse to a default (the bug F1-svc: a Chip color map that only +// knew green/red, so blue/purple/yellow all rendered the same neutral pill). +// +// Tone meanings: +// neutral — inert / unknown / placeholder (default; no signal) +// info — informational, in the accent family (e.g. Fixed membership, +// EventDriven pricing, an in-band state worth surfacing) +// success — healthy / active / live / approved / billable / audited +// warning — needs attention / pending action / degraded / stale / not-yet +// danger — failed / rejected / slashed / terminated / disputed +// pending — in-flight / provisioning / indexing / awaiting confirmation +// (rendered with a soft pulsing dot by StatusPill) -export type StatusTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'; +export type StatusTone = + | 'neutral' + | 'info' + | 'success' + | 'warning' + | 'danger' + | 'pending'; export const statusPill: Record = { neutral: 'border border-border bg-transparent text-muted-foreground', + info: 'border border-[color:var(--border-accent)] bg-[var(--accent-surface-soft)] text-foreground', success: 'border border-[color:var(--md3-tertiary,#10b981)]/40 bg-[color:var(--md3-tertiary,#10b981)]/8 text-[color:var(--md3-tertiary,#10b981)]', warning: 'border border-[color:var(--md3-warning,#f59e0b)]/40 bg-[color:var(--md3-warning,#f59e0b)]/8 text-[color:var(--md3-warning,#f59e0b)]', danger: 'border border-[color:var(--md3-error,#ef4444)]/40 bg-[color:var(--md3-error,#ef4444)]/8 text-[color:var(--md3-error,#ef4444)]', - info: 'border border-[color:var(--border-accent)] bg-[var(--accent-surface-soft)] text-foreground', + pending: + 'border border-[color:var(--border-accent)] bg-[var(--accent-surface-soft)] text-[color:var(--border-accent-hover)]', +} as const; + +// The leading dot color for each tone — kept in sync with `statusPill` so the +// StatusPill auto-dot and any tone-aware indicator read the same hue. +export const statusDot: Record = { + neutral: 'bg-muted-foreground/40', + info: 'bg-[color:var(--border-accent-hover)]', + success: 'bg-[color:var(--md3-tertiary,#10b981)]', + warning: 'bg-[color:var(--md3-warning,#f59e0b)]', + danger: 'bg-[color:var(--md3-error,#ef4444)]', + pending: 'bg-[color:var(--border-accent-hover)]', } as const; +// ── Canonical domain → tone mapping ────────────────────────────────────────── +// `statusToneFor(domain, status)` is the single place the app decides which +// tone a given domain status earns. Surfaces NEVER hand-roll +// `color === 'green' ? 'success' : …`; they call this. Adding a new status +// value is a one-line edit here, not a sweep across N files. +// +// Each domain maps its *string* status label (the value surfaces already have +// in hand — enum label, indexer field, derived flag) to a tone. Unknown values +// fall through to `neutral`, which is the honest default: no fake signal. + +export type StatusDomain = + | 'service' // service lifecycle: Pending | Active | Terminated + | 'instance' // instance / request approval: Pending | Approved | Rejected | Active | Terminated + | 'job' // job execution: Submitted | Running | Completed | Failed + | 'operator' // operator state: Active | Inactive | Leaving | Slashed + | 'payment' // payment / pricing model: PayOnce | Subscription | EventDriven + | 'membership' // membership model: Fixed | Dynamic + | 'audit' // audit / attestation: Audited | Unaudited | Pending + | 'availability' // capacity: Available | Limited | Unavailable + | 'billing'; // subscription billability: Billable | NotBillable | Due | Paid + +const TONE_BY_DOMAIN: Record> = { + service: { + pending: 'pending', + active: 'success', + terminated: 'danger', + }, + instance: { + pending: 'pending', + approved: 'success', + active: 'success', + rejected: 'danger', + terminated: 'danger', + indexing: 'pending', + submitted: 'pending', + }, + job: { + submitted: 'pending', + pending: 'pending', + running: 'pending', + completed: 'success', + succeeded: 'success', + failed: 'danger', + errored: 'danger', + }, + operator: { + active: 'success', + online: 'success', + inactive: 'neutral', + offline: 'warning', + leaving: 'warning', + exiting: 'warning', + slashed: 'danger', + disabled: 'danger', + }, + payment: { + // Distinct tones so PayOnce / Subscription / EventDriven are never + // visually identical (the F1-svc collapse). These are *informational* + // category badges, not health signals — kept in the accent/neutral family. + payonce: 'info', + subscription: 'success', + eventdriven: 'warning', + }, + membership: { + // Fixed vs Dynamic must be distinguishable. + fixed: 'info', + dynamic: 'success', + }, + audit: { + audited: 'success', + verified: 'success', + unaudited: 'neutral', + pending: 'pending', + failed: 'danger', + }, + availability: { + available: 'success', + limited: 'warning', + degraded: 'warning', + unavailable: 'danger', + }, + billing: { + billable: 'success', + due: 'warning', + notbillable: 'neutral', + notyet: 'warning', + paid: 'success', + }, +} as const; + +/** + * Map a domain status to its canonical {@link StatusTone}. Pass the status as a + * string (enum label, indexer field, or a derived label) plus the domain so the + * same word can mean different things in different contexts (an `active` + * service and an `active` operator both read success, but `pending` is a + * `pending` tone for a service yet there is no such status for membership). + * + * Returns `neutral` for any unknown status — the honest default. Matching is + * case/space/dash-insensitive so `'PayOnce'`, `'pay_once'`, and `'pay once'` + * all resolve. + */ +export function statusToneFor( + domain: StatusDomain, + status: string | number | null | undefined, +): StatusTone { + if (status === null || status === undefined) return 'neutral'; + const key = String(status) + .toLowerCase() + .replace(/[\s_-]+/g, ''); + return TONE_BY_DOMAIN[domain][key] ?? 'neutral'; +} + // ── Surface roles ──────────────────────────────────────────────────────────── // Eight roles. Bound to brand tokens. Anything not in this list is a bug. @@ -99,3 +245,150 @@ export const surface = { export const focus = { ring: 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--border-accent-hover)] focus-visible:ring-offset-2 focus-visible:ring-offset-background', } as const; + +// ── Money contract ─────────────────────────────────────────────────────────── +// On a money/permissions console the value on screen MUST be the exact on-chain +// bigint, never a lossy float. The bug this kills (F1-acct, F5-svc): bigints +// pushed through `Number(formatUnits(...)).toLocaleString({maxFractionDigits:4})` +// — which silently truncates precision and drops the unit, so an auditor can +// neither trust nor copy the number. +// +// `formatMoney` is a *view* over the exact bigint: it returns a compact display +// string for the cell, the full-precision decimal string for the copy +// affordance, and the unit. The `` chrome component renders this view +// with a copy-full-precision button. Surfaces format money ONE way: through +// this contract. + +export type MoneyView = { + /** Compact, human display — e.g. `1,234.56` or `1.2M`. Lossy by design; the + * cell stays scannable. Never the source of truth. */ + display: string; + /** Full-precision decimal string — every significant digit the bigint holds. + * This is what the copy affordance writes to the clipboard. */ + full: string; + /** The raw on-chain integer as a string (wei-equivalent), for power users / + * debugging. */ + raw: string; + /** Token symbol / unit. Provenance always travels with the number. */ + symbol: string; + /** Decimals used to scale `raw` → `full`. */ + decimals: number; + /** True when the source value was missing — render an em-dash, not `0`. */ + isEmpty: boolean; +}; + +export type FormatMoneyOptions = { + /** Token decimals. Default 18. */ + decimals?: number; + /** Token symbol / unit. Default `''` (caller should always pass one). */ + symbol?: string; + /** Significant fraction digits in the *compact display* only — `full` is + * always complete. Default 4. */ + displayDecimals?: number; + /** Collapse large magnitudes to K/M/B in the compact display. Default true. */ + compact?: boolean; +}; + +const EM_DASH = '—'; + +function trimTrailingZeros(decimalStr: string): string { + if (!decimalStr.includes('.')) return decimalStr; + return decimalStr.replace(/\.?0+$/, ''); +} + +/** + * Build a {@link MoneyView} from an exact on-chain bigint. Lossless: `full` is + * derived by integer-only division of the raw value, never by `Number()`. + * + * formatMoney(1234567890000000000n, { decimals: 18, symbol: 'TNT' }) + * // → { display: '1.2346', full: '1.23456789', raw: '1234567890000000000', + * // symbol: 'TNT', decimals: 18, isEmpty: false } + */ +export function formatMoney( + value: bigint | null | undefined, + options: FormatMoneyOptions = {}, +): MoneyView { + const { + decimals = 18, + symbol = '', + displayDecimals = 4, + compact = true, + } = options; + + if (value === null || value === undefined) { + return { + display: EM_DASH, + full: EM_DASH, + raw: '', + symbol, + decimals, + isEmpty: true, + }; + } + + const negative = value < 0n; + const abs = negative ? -value : value; + const base = 10n ** BigInt(decimals); + const whole = abs / base; + const fraction = abs % base; + + // Lossless full-precision decimal string from integer arithmetic. + const fractionStr = + decimals > 0 + ? fraction.toString().padStart(decimals, '0').replace(/0+$/, '') + : ''; + const full = + (negative ? '-' : '') + + (fractionStr.length > 0 + ? `${whole.toString()}.${fractionStr}` + : whole.toString()); + + // Compact display. We round the fractional part to `displayDecimals` for the + // cell only; the source of truth stays in `full`. + const wholeNum = whole; // bigint, may exceed Number range + let display: string; + if (compact && wholeNum >= 1_000_000n) { + const units: Array<[bigint, string]> = [ + [1_000_000_000_000n, 'T'], + [1_000_000_000n, 'B'], + [1_000_000n, 'M'], + ]; + const [unitBase, suffix] = units.find(([u]) => wholeNum >= u) ?? [ + 1_000_000n, + 'M', + ]; + // One decimal of the compacted magnitude, integer-only. + const scaled = (wholeNum * 10n) / unitBase; + const head = scaled / 10n; + const tenth = scaled % 10n; + display = + (negative ? '-' : '') + + (tenth > 0n + ? `${head.toString()}.${tenth.toString()}` + : head.toString()) + + suffix; + } else { + // wholeNum is < 1e6 here, safe to render with group separators. + const wholeDisplay = Number(wholeNum).toLocaleString('en-US'); + const roundedFraction = + displayDecimals > 0 && fractionStr.length > 0 + ? `.${trimTrailingZeros( + fraction + .toString() + .padStart(decimals, '0') + .slice(0, displayDecimals), + ).replace(/^\./, '')}`.replace(/\.$/, '') + : ''; + const fracClean = roundedFraction === '.' ? '' : roundedFraction; + display = (negative ? '-' : '') + wholeDisplay + fracClean; + } + + return { + display: display === '' ? '0' : display, + full, + raw: value.toString(), + symbol, + decimals, + isEmpty: false, + }; +} diff --git a/apps/tangle-dapp/src/components/account/TransferModal.tsx b/apps/tangle-dapp/src/components/account/TransferModal.tsx index 99d0d88c1e..d818f23ab2 100644 --- a/apps/tangle-dapp/src/components/account/TransferModal.tsx +++ b/apps/tangle-dapp/src/components/account/TransferModal.tsx @@ -112,14 +112,18 @@ const TransferModal: FC = ({ isOpen, onClose }) => { if (!selectedToken) { // Set default token when first available - setSelectedToken(tokenOptions[0]); + queueMicrotask(() => { + setSelectedToken(tokenOptions[0]); + }); } else { // Update selectedToken when balance changes in tokenOptions const updatedToken = tokenOptions.find( (t) => t.address === selectedToken.address, ); if (updatedToken && updatedToken.balance !== selectedToken.balance) { - setSelectedToken(updatedToken); + queueMicrotask(() => { + setSelectedToken(updatedToken); + }); } } }, [tokenOptions]); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/apps/tangle-dapp/src/components/staking/ClaimableRewardsCard.tsx b/apps/tangle-dapp/src/components/staking/ClaimableRewardsCard.tsx index edebd5f562..2cf03a67aa 100644 --- a/apps/tangle-dapp/src/components/staking/ClaimableRewardsCard.tsx +++ b/apps/tangle-dapp/src/components/staking/ClaimableRewardsCard.tsx @@ -261,6 +261,7 @@ const ClaimableRewardsCard: FC = () => {