diff --git a/apps/console/index.html b/apps/console/index.html index 50ac94cb..6d20cd88 100644 --- a/apps/console/index.html +++ b/apps/console/index.html @@ -4,6 +4,12 @@ Emulate + + +
diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 8e26a09a..4fd566fc 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -42,7 +42,7 @@ export default function App() {
- Emulate + emulate
diff --git a/apps/console/src/api.ts b/apps/console/src/api.ts index 3733ff5c..808e8266 100644 --- a/apps/console/src/api.ts +++ b/apps/console/src/api.ts @@ -91,7 +91,17 @@ async function getJson(url: string): Promise { // Control-plane fetchers ------------------------------------------------------- -export const fetchServices = (): Promise<{ services: CatalogEntry[] }> => getJson(`${ORIGIN}/_emulate/services`); +// The catalog is a static registry the worker inlines into the served HTML +// (window.__EMULATE_SERVICES__), so the console has it on first paint with no +// fetch. Read it synchronously; only fall back to the endpoint in dev. +export const injectedServices = (): CatalogEntry[] | null => + (typeof window !== "undefined" && window.__EMULATE_SERVICES__?.services) || null; + +export const fetchServices = (): Promise<{ services: CatalogEntry[] }> => { + const inlined = injectedServices(); + if (inlined) return Promise.resolve({ services: inlined }); + return getJson(`${ORIGIN}/_emulate/services`); +}; export const fetchManifest = (service: string, instance: string): Promise => getJson(`${controlBase(service, instance)}/manifest`); diff --git a/apps/console/src/styles.css b/apps/console/src/styles.css index 25e5eb29..ec51fd87 100644 --- a/apps/console/src/styles.css +++ b/apps/console/src/styles.css @@ -1,99 +1,120 @@ +/* Registry-grade minimal, per executor/design.md (the Useful Software Co brand). + Grayscale only; the single hue is destructive red. Geist for UI/prose, Geist + Mono for all metadata. Hierarchy from tone and hairlines, not shadow or color. + Dark palette (design.md Dark values): the provider brand marks served by the + icon service are white monochrome, so the catalog is dark for them to read. */ :root { - --bg: #0b0e14; - --panel: #11161f; - --panel2: #0d1219; - --line: #222a35; - --fg: #d6dae0; - --muted: #8b949e; - --accent: #7ee787; - --blue: #58a6ff; - --amber: #d29922; - --red: #f85149; + --bg: #0a0a0a; /* page */ + --panel: #0f0f0f; /* cards, separated from the page by a hairline */ + --panel2: #141414; /* quiet secondary fill */ + --line: #1f1f1f; /* default hairline */ + --line-strong: #333333; /* field / heavy stroke */ + --fg: #ededed; /* primary text */ + --muted: #9a9a9a; /* secondary text, metadata */ + --muted2: #777777; /* tertiary text, eyebrows */ + --accent-surface: #1a1a1a; /* hover / selected tint */ + --primary: #ffffff; /* solid fill for the one key action */ + --primary-fg: #0a0a0a; + --red: #e0726a; /* destructive, the only hue */ } + * { box-sizing: border-box; } -body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.55 -apple-system, system-ui, sans-serif; } -a { color: var(--blue); text-decoration: none; } -code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 15px/1.55 "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif; + letter-spacing: -0.011em; + -webkit-font-smoothing: antialiased; +} +a { color: var(--fg); text-decoration: none; } +a:hover { color: var(--fg); } +code, .mono { font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; } +::selection { background: var(--fg); color: var(--bg); } + +/* buttons: secondary (outline) by default, .go is the one primary fill */ button { - background: #1b2230; border: 1px solid var(--line); color: var(--fg); - border-radius: 8px; padding: 7px 12px; cursor: pointer; font-size: 13px; + background: transparent; border: 1px solid var(--line-strong); color: var(--fg); + border-radius: 6px; padding: 7px 12px; cursor: pointer; font-size: 13px; font-weight: 500; + font-family: inherit; transition: background-color .15s, border-color .15s; } -button:hover { border-color: #3a4658; } -button.go { background: #10371f; border-color: #1f5132; color: var(--accent); } +button:hover { background: var(--accent-surface); } +button.go { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); } +button.go:hover { background: var(--primary); opacity: 0.9; } button.sm { padding: 4px 9px; font-size: 12px; } .button { - background: #1b2230; border: 1px solid var(--line); color: var(--fg); - border-radius: 8px; padding: 7px 12px; font-size: 13px; display: inline-flex; - align-items: center; justify-content: center; gap: 6px; + background: transparent; border: 1px solid var(--line-strong); color: var(--fg); + border-radius: 6px; padding: 7px 12px; font-size: 13px; font-weight: 500; display: inline-flex; + align-items: center; justify-content: center; gap: 6px; transition: background-color .15s, border-color .15s; } -.button:hover { border-color: #3a4658; color: var(--fg); } +.button:hover { background: var(--accent-surface); color: var(--fg); } .button.sm { padding: 4px 9px; font-size: 12px; } input, select { - background: #0b0e14; border: 1px solid var(--line); color: var(--fg); - border-radius: 8px; padding: 7px 10px; font-size: 13px; + background: var(--bg); border: 1px solid var(--line-strong); color: var(--fg); + border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit; } -input.mono { font-family: ui-monospace, monospace; } +input.mono { font-family: "Geist Mono", ui-monospace, monospace; } /* layout */ .shell { max-width: 1100px; margin: 0 auto; padding: 0 22px; } -.topbar { border-bottom: 1px solid var(--line); position: sticky; top: 0; background: rgba(11,14,20,.85); backdrop-filter: blur(8px); z-index: 5; } +.topbar { border-bottom: 1px solid var(--line); position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(8px); z-index: 5; } .topbar .row { display: flex; align-items: center; gap: 14px; padding: 12px 0; } -.brand { font-weight: 700; display: flex; align-items: center; gap: 8px; } -.brand .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); } +.brand { font-family: "Geist Mono", ui-monospace, monospace; font-weight: 500; font-size: 15px; letter-spacing: -0.01em; display: flex; align-items: center; gap: 8px; color: var(--fg); } .nav { display: flex; gap: 6px; margin-left: auto; flex-wrap: wrap; } -.nav a { padding: 5px 11px; border-radius: 99px; border: 1px solid transparent; color: var(--muted); font-size: 13px; display: flex; gap: 6px; align-items: center; } -.nav a:hover { color: var(--fg); } -.nav a.on { color: var(--fg); border-color: var(--line); background: var(--panel); } +.nav a { padding: 5px 11px; border-radius: 6px; border: 1px solid transparent; color: var(--muted); font-size: 13px; display: flex; gap: 6px; align-items: center; transition: color .15s, background-color .15s; } +.nav a:hover { color: var(--fg); background: var(--accent-surface); } +.nav a.on { color: var(--fg); border-color: var(--line); background: var(--panel2); } .nav img, .ico { width: 16px; height: 16px; object-fit: contain; } .content { padding: 26px 0 80px; } -h1 { font-size: 26px; margin: 0 0 4px; } -h2 { font-size: 17px; margin: 26px 0 12px; } -.lead { color: var(--muted); margin: 0 0 22px; max-width: 720px; } +h1 { font-size: 28px; font-weight: 600; letter-spacing: -0.03em; margin: 0 0 6px; } +h2 { font-size: 17px; font-weight: 600; letter-spacing: -0.02em; margin: 26px 0 12px; } +.lead { color: var(--muted); margin: 0 0 22px; max-width: 720px; line-height: 1.6; } +.eyebrow { font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted2); margin: 0 0 12px; } .hero { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; margin-bottom: 12px; } .hero-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; } /* cards */ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } -.service-grid { display: grid; grid-template-columns: 1fr; gap: 14px; } -.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; display: block; color: inherit; } -.card.link:hover { border-color: #3a4658; } +.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } +.card { background: var(--panel); border: 1px solid var(--line); border-radius: 11px; padding: 16px; display: block; color: inherit; } +.card.link:hover { border-color: var(--line-strong); } .card .hd { display: flex; align-items: center; gap: 10px; } .card .hd img, .card .hd .emoji { width: 26px; height: 26px; object-fit: contain; font-size: 22px; line-height: 26px; text-align: center; } -.card h3 { margin: 0; font-size: 16px; } -.card .b { color: var(--muted); font-size: 13px; margin: 10px 0 0; } -.pill { font-size: 11px; border-radius: 99px; padding: 2px 8px; border: 1px solid var(--line); color: var(--muted); margin-left: auto; } +.card h3 { margin: 0; font-size: 16px; font-weight: 600; } +.card .b { color: var(--muted); font-size: 13px; margin: 10px 0 0; line-height: 1.55; } +.pill { font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; border-radius: 9999px; padding: 2px 8px; border: 1px solid var(--line); color: var(--muted); margin-left: auto; } .service-card { display: grid; grid-template-columns: minmax(190px, .55fr) minmax(280px, 1fr); gap: 14px; } .service-head { grid-column: 1; } -.service-title { color: var(--fg); display: inline-flex; align-items: center; gap: 10px; font-weight: 700; } -.service-title:hover { color: var(--blue); } +.service-title { color: var(--fg); display: inline-flex; align-items: center; gap: 10px; font-weight: 600; } +.service-title:hover { color: var(--fg); } .service-card .b { grid-column: 1; align-self: start; margin-top: -4px; } .endpoint-list { grid-column: 2; grid-row: 1 / span 3; display: grid; gap: 6px; } .endpoint { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px; align-items: baseline; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; + font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } .endpoint span { color: var(--muted); } -.endpoint a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.endpoint a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); } .service-actions { grid-column: 1; display: flex; gap: 8px; flex-wrap: wrap; align-self: end; } /* panels / rows */ -.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; margin-bottom: 16px; } +.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 11px; padding: 16px; margin-bottom: 16px; } .panel .ph { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } .panel .ph h2 { margin: 0; } .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .spread { justify-content: space-between; } .muted { color: var(--muted); } -.kv { font-family: ui-monospace, monospace; font-size: 12.5px; } +.kv { font-family: "Geist Mono", ui-monospace, monospace; font-size: 12.5px; } .kv b { color: var(--muted); font-weight: 500; } -.tag { font-size: 11px; padding: 2px 7px; border-radius: 6px; border: 1px solid var(--line); color: var(--muted); } -.tag.ok { color: var(--accent); border-color: #1f5132; } +.tag { font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; padding: 2px 7px; border-radius: 5px; border: 1px solid var(--line); color: var(--muted); } +.tag.ok { color: var(--fg); border-color: var(--line-strong); } -/* app rows (spotify creds) */ +/* app rows (oauth creds) */ .app { border: 1px solid var(--line); border-radius: 10px; padding: 11px 13px; margin-top: 10px; background: var(--panel2); } -.app .cred { font-family: ui-monospace, monospace; font-size: 12.5px; color: var(--muted); margin-top: 4px; word-break: break-all; } -.app .cred b { color: var(--fg); } -.app.active { border-color: #1f5132; } +.app .cred { font-family: "Geist Mono", ui-monospace, monospace; font-size: 12.5px; color: var(--muted); margin-top: 4px; word-break: break-all; } +.app .cred b { color: var(--fg); font-weight: 500; } +.app.active { border-color: var(--line-strong); } /* result cards */ .results { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px,1fr)); gap: 10px; } @@ -103,28 +124,28 @@ h2 { font-size: 17px; margin: 26px 0 12px; } .res button { margin-top: 8px; } /* json + log */ -pre.json { background: #06080d; border: 1px solid var(--line); border-radius: 10px; padding: 12px; overflow: auto; max-height: 360px; font-size: 12.5px; margin: 0; white-space: pre-wrap; word-break: break-word; } -.req { color: var(--blue); } .okc { color: var(--accent); } .errc { color: var(--red); } +pre.json { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 12px; overflow: auto; max-height: 360px; font-family: "Geist Mono", ui-monospace, monospace; font-size: 12.5px; margin: 0; white-space: pre-wrap; word-break: break-word; } +.req { color: var(--fg); } .okc { color: var(--fg); } .errc { color: var(--red); } .copy { font-size: 11px; } .empty { color: var(--muted); font-style: italic; padding: 8px 0; } -/* monogram, simple catalog card */ +/* monogram fallback (used only when a real brand icon is missing) */ .mono-badge { display: inline-flex; align-items: center; justify-content: center; - border-radius: 8px; background: #1b2230; border: 1px solid var(--line); - color: var(--accent); font-weight: 700; flex-shrink: 0; + border-radius: 8px; background: var(--panel2); border: 1px solid var(--line); + color: var(--muted); font-family: "Geist Mono", ui-monospace, monospace; font-weight: 500; flex-shrink: 0; } .service-card-simple { transition: border-color .15s; } -.service-card-simple:hover { border-color: #3a4658; } -.service-card-simple .service-title { color: var(--fg); font-weight: 700; } +.service-card-simple:hover { border-color: var(--line-strong); } +.service-card-simple .service-title { color: var(--fg); font-weight: 600; } /* tabs */ .tabs { display: flex; gap: 4px; flex-wrap: wrap; margin: 6px 0 18px; border-bottom: 1px solid var(--line); } .tab { background: transparent; border: 1px solid transparent; border-bottom: none; - border-radius: 8px 8px 0 0; color: var(--muted); padding: 8px 14px; font-size: 13px; + border-radius: 6px 6px 0 0; color: var(--muted); padding: 8px 14px; font-size: 13px; font-weight: 500; } -.tab:hover { color: var(--fg); border-color: transparent; } +.tab:hover { color: var(--fg); background: transparent; border-color: transparent; } .tab.on { color: var(--fg); border-color: var(--line); background: var(--panel); margin-bottom: -1px; } /* data tables */ @@ -132,15 +153,15 @@ pre.json { background: #06080d; border: 1px solid var(--line); border-radius: 10 .data-table th, .data-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); font-size: 13px; vertical-align: top; } -.data-table th { color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; } +.data-table th { color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; font-family: "Geist Mono", ui-monospace, monospace; } .data-table tbody tr:last-child td { border-bottom: none; } -.tag.err { color: var(--red); border-color: #5b1f1f; } +.tag.err { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); } .ledger-table .ledger-row { cursor: pointer; } .ledger-table .ledger-row:hover td { background: var(--panel2); } textarea { - background: #0b0e14; border: 1px solid var(--line); color: var(--fg); - border-radius: 8px; padding: 10px; font-size: 12.5px; font-family: ui-monospace, monospace; + background: var(--bg); border: 1px solid var(--line-strong); color: var(--fg); + border-radius: 6px; padding: 10px; font-size: 12.5px; font-family: "Geist Mono", ui-monospace, monospace; } select { cursor: pointer; } @media (max-width: 820px) { diff --git a/apps/console/src/types.ts b/apps/console/src/types.ts index 0c4e8b31..3b9bb43b 100644 --- a/apps/console/src/types.ts +++ b/apps/console/src/types.ts @@ -108,6 +108,13 @@ export interface CatalogEntry { manifest: string; } +declare global { + interface Window { + // Catalog inlined into the served HTML by the worker (static registry). + __EMULATE_SERVICES__?: { services: CatalogEntry[] }; + } +} + export interface LedgerEntry { id: string; correlationId: string; diff --git a/apps/console/src/views/Home.tsx b/apps/console/src/views/Home.tsx index 6f5e9e11..1d5dd3dc 100644 --- a/apps/console/src/views/Home.tsx +++ b/apps/console/src/views/Home.tsx @@ -1,22 +1,26 @@ import { useEffect, useState } from "react"; -import { fetchServices, serviceHost } from "../api"; +import { fetchServices, injectedServices, serviceHost } from "../api"; import type { CatalogEntry } from "../types"; import { ServiceIcon } from "../App"; export default function Home() { - const [services, setServices] = useState(null); + // The worker inlines the static catalog, so it's present on first paint (no + // loading state). Fall back to fetching only when it wasn't injected (dev). + const [services, setServices] = useState(() => injectedServices()); const [error, setError] = useState(null); useEffect(() => { + if (services) return; fetchServices() .then((r) => setServices(r.services)) .catch((e) => setError(String(e))); - }, []); + }, [services]); return ( <>
+

Service catalog

Emulate

Stateful integration emulators for real developer APIs. Pick a service, create an isolated instance, copy a @@ -36,7 +40,10 @@ export default function Home() {

{error &&
Could not load the service catalog ({error}).
} - {!services && !error &&
Loading services...
} + + {services && services.length > 0 && ( +

All emulators ยท {services.length}

+ )}
{(services ?? []).map((s) => ( diff --git a/packages/@emulators/cloudflare/src/console-html.ts b/packages/@emulators/cloudflare/src/console-html.ts index 39bbe9cf..be3a6464 100644 --- a/packages/@emulators/cloudflare/src/console-html.ts +++ b/packages/@emulators/cloudflare/src/console-html.ts @@ -1,4 +1,3 @@ // AUTO-GENERATED from apps/console (pnpm --filter @emulators/console build). // Do not edit by hand; regenerate with `node scripts/gen-console.mjs`. -export const consoleHtml = - '\n\n \n \n \n Emulate\n \n \n \n \n
\n \n\n'; +export const consoleHtml = "\n\n \n \n \n Emulate\n \n \n \n \n \n \n \n
\n \n\n"; diff --git a/packages/@emulators/cloudflare/src/worker.ts b/packages/@emulators/cloudflare/src/worker.ts index 839ea6fc..8e177e7c 100644 --- a/packages/@emulators/cloudflare/src/worker.ts +++ b/packages/@emulators/cloudflare/src/worker.ts @@ -14,6 +14,19 @@ function serviceCatalogEntries() { })); } +// The catalog is a static registry, so inline it into the served SPA HTML: the +// console renders the grid on first paint, with no client fetch and no loading +// state. JSON `<` is escaped so a description can never break out of the script. +async function serveCatalogConsole(ctx: { origin: string; protocol: string; hostSuffix: string }): Promise { + const json = (await servicesCatalog(serviceCatalogEntries(), ctx).text()).replace(/
', + `
`, + ), + ); +} + interface EmulatorNamespace { idFromName(name: string): unknown; get(id: unknown): { fetch(request: Request): Promise }; @@ -183,7 +196,8 @@ export default { // Apex root or bare `/` โ†’ the catalog. Browser โ†’ SPA; agent โ†’ a // server-rendered list of emulators (readable without running JS). if (!service || !instance) { - if (isBrowserNavigation(request)) return html(consoleHtml); + if (isBrowserNavigation(request)) + return serveCatalogConsole({ origin: apexOrigin, protocol: url.protocol, hostSuffix: suffix }); if (wantsJson(request)) { return servicesCatalog(serviceCatalogEntries(), { origin: apexOrigin,