From 56795c6b639fa76ea478dd8bb015c9c7a7487ba1 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Sat, 20 Jun 2026 21:49:12 +0700 Subject: [PATCH] feat(web): landing Memory Playground + ?format=markdown API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an instant, zero-auth "memory context API" playground to the landing page (recall · ask · knowledge · remember) with live calls to the public Worker, Markdown/JSON response tabs, and copy-as-curl / TS / Python snippets pinned to the public origin so copied requests run anywhere. Add ?format=markdown (alias ?format=md) to /api/memwal/recall, /chat, and /facts/:ns so agents can consume recall hits, grounded answers, and namespace knowledge as Markdown directly instead of parsing the JSON envelope. Query-param keyed only (no Accept-header Vary), so cached facts responses can't be poisoned across formats. Default JSON behavior is unchanged when the param is absent. --- apps/api/src/worker.ts | 105 ++++++++++++++- apps/web/src/main.tsx | 288 ++++++++++++++++++++++++++++++++++++++++ apps/web/src/styles.css | 94 +++++++++++++ 3 files changed, 483 insertions(+), 4 deletions(-) diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 37ea9da..9e7bc25 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -661,7 +661,10 @@ async function routeWorkerRequest(request: Request, env: WorkerEnv, ctx: WorkerE const ns = decodeURIComponent(url.pathname.slice("/api/memwal/facts/".length)); // Tier 1: bundled demo facts (public, edge-cacheable). const seeded = SEED_FACTS[ns]; - if (seeded) return jsonCached({ namespace: ns, source: "seed", facts: seeded, proof: SEED_PROOFS[ns] ?? null }, 300); + if (seeded) { + if (wantsMarkdown(request)) return markdown(factsToMarkdown(ns, seeded), 200, 300); + return jsonCached({ namespace: ns, source: "seed", facts: seeded, proof: SEED_PROOFS[ns] ?? null }, 300); + } // Tier 2: real built namespace — public for public namespaces, read-token for private. const auth = await store.authorizeNamespace(ns, readAccessToken(request)); if (!auth.ok) return json({ error: auth.message }, auth.status); @@ -675,6 +678,9 @@ async function routeWorkerRequest(request: Request, env: WorkerEnv, ctx: WorkerE if (manifest && typeof manifest === "object") facts = (manifest as Record).facts; } if (!facts) return json({ error: `No facts artifact for namespace "${ns}".` }, 404); + if (wantsMarkdown(request)) { + return auth.summary.visibility === "public" ? markdown(factsToMarkdown(ns, facts), 200, 300) : markdown(factsToMarkdown(ns, facts)); + } const payload = { namespace: ns, source: "r2", facts }; return auth.summary.visibility === "public" ? jsonCached(payload, 300) : json(payload); } @@ -3704,6 +3710,70 @@ function factsFallbackRecall(namespace: string, query: string): { results: Array return { results }; } +// ── Markdown serializers (for ?format=markdown) ────────────────────────────── +// These mirror the landing playground's client-side rendering so the surface is +// consistent whether an agent renders the JSON itself or asks the API for md. +function recallToMarkdown(body: { namespace?: string; query?: string; result?: unknown }): string { + const head = `### Recall — ${body.namespace ?? ""}`; + const result = body.result as { results?: Array<{ text?: unknown }>; context?: unknown; answer?: unknown } | undefined; + if (result && Array.isArray(result.results) && result.results.length) { + const lines = result.results.map((r) => `- ${typeof r?.text === "string" ? r.text : JSON.stringify(r)}`).join("\n"); + return `${head}\n\n${lines}\n`; + } + const ctx = typeof result?.context === "string" ? result.context : typeof result?.answer === "string" ? result.answer : ""; + if (ctx) return `${head}\n\n${ctx}\n`; + return `${head}\n\n\`\`\`json\n${JSON.stringify(body.result ?? {}, null, 2)}\n\`\`\`\n`; +} + +function chatToMarkdown(body: { data?: { answer?: unknown; key_points?: unknown }; confidence?: unknown; sources?: unknown }): string { + const answer = typeof body.data?.answer === "string" ? body.data.answer : ""; + const pct = typeof body.confidence === "number" ? Math.round(body.confidence * 100) : null; + const head = pct === null ? "### Answer" : `### Answer _(confidence ${pct}%)_`; + let out = `${head}\n\n${answer || "_No answer returned._"}\n`; + const keyPoints = Array.isArray(body.data?.key_points) ? body.data!.key_points.filter((k): k is string => typeof k === "string" && !!k.trim()) : []; + if (keyPoints.length) out += `\n**Key points**\n${keyPoints.map((k) => `- ${k}`).join("\n")}\n`; + const sources = Array.isArray(body.sources) ? body.sources : []; + if (sources.length) { + const lines = sources + .map((s) => { + const path = typeof (s as { routePath?: unknown })?.routePath === "string" ? (s as { routePath: string }).routePath : ""; + const quote = typeof (s as { quote?: unknown })?.quote === "string" ? (s as { quote: string }).quote : ""; + return `- ${path}${quote ? ` — ${quote}` : ""}`; + }) + .filter((l) => l.trim() !== "-") + .join("\n"); + if (lines) out += `\n**Sources**\n${lines}\n`; + } + return out; +} + +function factsToMarkdown(namespace: string, facts: unknown): string { + const f = (facts && typeof facts === "object" ? facts : {}) as { + identity?: { name?: string; oneLiner?: string; category?: string }; + topics?: Array<{ label?: string }>; + entities?: Array<{ type?: string; name?: string; description?: string; salience?: number }>; + claims?: Array<{ text?: string }>; + stats?: Array<{ label?: string; valueRaw?: string }>; + questions?: Array<{ question?: string; answer?: string }>; + }; + const title = f.identity?.name?.trim() || namespace; + const out: string[] = [`# ${title}`]; + if (f.identity?.oneLiner) out.push("", f.identity.oneLiner); + if (f.identity?.category) out.push("", `**Category:** ${f.identity.category}`); + const topics = (f.topics ?? []).map((t) => t?.label).filter((l): l is string => !!l); + if (topics.length) out.push("", "## Topics", topics.map((t) => `- ${t}`).join("\n")); + const entities = [...(f.entities ?? [])].sort((a, b) => (b?.salience ?? 0) - (a?.salience ?? 0)).filter((e) => !!e?.name); + if (entities.length) out.push("", "## Entities", entities.map((e) => `- **${e.name}**${e.type ? ` (${e.type})` : ""}${e.description ? ` — ${e.description}` : ""}`).join("\n")); + const claims = (f.claims ?? []).map((c) => c?.text).filter((t): t is string => !!t); + if (claims.length) out.push("", "## Claims", claims.map((c) => `- ${c}`).join("\n")); + const stats = (f.stats ?? []).filter((s) => !!s?.label && !!s?.valueRaw); + if (stats.length) out.push("", "## Stats", stats.map((s) => `- ${s.label}: ${s.valueRaw}`).join("\n")); + const qa = (f.questions ?? []).filter((q) => !!q?.question && !!q?.answer); + if (qa.length) out.push("", "## Q&A", qa.map((q) => `**Q: ${q.question}**\n\n${q.answer}`).join("\n\n")); + if (out.length === 1) return `# ${title}\n\n\`\`\`json\n${JSON.stringify(facts ?? {}, null, 2)}\n\`\`\`\n`; + return out.join("\n") + "\n"; +} + // POST /api/memwal/recall { namespace, query } — recall via the server-side delegate. async function memwalRecall(request: Request, env: WorkerEnv): Promise { let body: { namespace?: unknown; query?: unknown }; @@ -3717,17 +3787,22 @@ async function memwalRecall(request: Request, env: WorkerEnv): Promise if (!namespace || !query) return json({ error: "namespace and query are required." }, 400); const { url, privateKey, accountId } = resolveMemwalCreds(request, env); // Try real Walrus Memory recall when we have a valid delegate. + const asMarkdown = wantsMarkdown(request); if (isMemwalSeed(privateKey) && accountId) { try { const result = await new MemWalMcpClient({ url, privateKey, accountId }).recallSiteContext(namespace, query); - return json({ namespace, query, source: "walrus-memory", result }); + const payload = { namespace, query, source: "walrus-memory" as const, result }; + return asMarkdown ? markdown(recallToMarkdown(payload)) : json(payload); } catch { /* fall through to the facts fallback so the Memory tab still answers */ } } // Fallback: answer from the namespace's verified facts (no delegate required). const fallback = factsFallbackRecall(namespace, query); - if (fallback) return json({ namespace, query, source: "facts", result: fallback }); + if (fallback) { + const payload = { namespace, query, source: "facts" as const, result: fallback }; + return asMarkdown ? markdown(recallToMarkdown(payload)) : json(payload); + } return json({ error: "Walrus Memory recall needs a valid 64-char hex delegate. Re-import your delegate in Settings, or set MEMWAL_PRIVATE_KEY + MEMWAL_ACCOUNT_ID on the Worker." }, 503); } @@ -3903,7 +3978,8 @@ async function memwalChat(request: Request, env: WorkerEnv): Promise { ? recallHits.map((h, index) => ({ url: "", routePath: `walrus-memory#${index + 1}`, quote: h.text.slice(0, 280), blobId: typeof h.distance === "number" ? `distance ${h.distance.toFixed(3)}` : undefined })) : (factsFallbackRecall(namespace, query)?.results ?? []).slice(0, 4).map((r, index) => ({ url: "", routePath: `verified-fact#${index + 1}`, quote: r.text.slice(0, 280) })); - return json({ namespace, target: subject, source, data, confidence, usedProvider, sources }); + const payload = { namespace, target: subject, source, data, confidence, usedProvider, sources }; + return wantsMarkdown(request) ? markdown(chatToMarkdown(payload)) : json(payload); } function maybeWithMemWalRecall(store: CloudflareNamespaceStore, env: WorkerEnv, request: Request): HostedNamespaceStore { @@ -5578,6 +5654,27 @@ function jsonCached(value: unknown, maxAgeSeconds = 300): Response { ); } +// Coding agents often prefer to paste recall/chat/facts straight into a prompt +// as Markdown rather than parse a JSON envelope. Opt in with ?format=markdown +// (alias ?format=md). Keyed off the query param only — never the Accept header — +// so cached responses don't need a Vary and can't be poisoned across formats. +function wantsMarkdown(request: Request): boolean { + const fmt = new URL(request.url).searchParams.get("format")?.toLowerCase(); + return fmt === "markdown" || fmt === "md"; +} + +function markdown(text: string, status = 200, maxAgeSeconds?: number): Response { + return cors( + new Response(text, { + status, + headers: { + "content-type": "text/markdown; charset=utf-8", + "cache-control": maxAgeSeconds ? `public, max-age=${maxAgeSeconds}, must-revalidate` : "no-store" + } + }) + ); +} + function mcpJsonError(message: string, status: number): Response { return cors( new Response( diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5ab8f07..d95bd8a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2404,6 +2404,292 @@ function DemoPreviewAppPanel({ preview, onBackHome }: { preview: DemoPreviewStat ); } +// Public Worker origin for copy-paste snippets. Hardcoded (NOT API_BASE) so a +// curl copied during local preview still runs for an external developer — +// API_BASE is localhost:8791 in dev, which nobody else can reach. +const PUBLIC_API_ORIGIN = "https://contextmem-backend.petlofi.workers.dev"; + +type PlaygroundVerb = "recall" | "ask" | "knowledge" | "remember"; + +const PLAYGROUND_VERBS: Array<{ id: PlaygroundVerb; label: string; hint: string; icon: React.ComponentType<{ size?: number }> }> = [ + { id: "recall", label: "recall", hint: "Read stored memory for a query", icon: Search }, + { id: "ask", label: "ask", hint: "Grounded RAG answer over the namespace", icon: MessageSquare }, + { id: "knowledge", label: "knowledge", hint: "The verified knowledge graph", icon: Boxes }, + { id: "remember", label: "remember", hint: "Write a memory (needs an account)", icon: Brain } +]; + +// Build a copyable snippet for a verb in cURL / TypeScript / Python, always +// against the PUBLIC origin. `remember` has no hosted route yet, so its snippet +// is clearly marked as private-beta rather than shipped as a runnable call. +function playgroundSnippet(lang: "curl" | "ts" | "python", verb: PlaygroundVerb, namespace: string, query: string): string { + const ns = namespace || "demo:anthropic"; + const q = (query || "What is this and who is it for?").replace(/"/g, '\\"'); + const o = PUBLIC_API_ORIGIN; + if (verb === "knowledge") { + const path = `/api/memwal/facts/${ns}`; + if (lang === "curl") return `curl ${o}${path}`; + if (lang === "ts") return `const res = await fetch("${o}${path}");\nconst { facts, proof } = await res.json();`; + return `import requests\nfacts = requests.get("${o}${path}").json()`; + } + if (verb === "remember") { + if (lang === "curl") return `# remember (write) is in private beta — needs an API key\ncurl -X POST ${o}/api/memory \\\n -H "authorization: Bearer " \\\n -H "content-type: application/json" \\\n -d '{"namespace":"${ns}","text":"...","private":true}'`; + if (lang === "ts") return `// remember (write) is in private beta — needs an API key\nawait fetch("${o}/api/memory", {\n method: "POST",\n headers: { authorization: "Bearer ", "content-type": "application/json" },\n body: JSON.stringify({ namespace: "${ns}", text: "...", private: true })\n});`; + return `# remember (write) is in private beta — needs an API key\nimport requests\nrequests.post("${o}/api/memory",\n headers={"authorization": "Bearer "},\n json={"namespace": "${ns}", "text": "...", "private": True})`; + } + const path = verb === "ask" ? "/api/memwal/chat" : "/api/memwal/recall"; + const payload = verb === "ask" + ? `{"namespace":"${ns}","messages":[{"role":"user","content":"${q}"}]}` + : `{"namespace":"${ns}","query":"${q}"}`; + if (lang === "curl") return `curl -X POST ${o}${path} \\\n -H "content-type: application/json" \\\n -d '${payload}'`; + if (lang === "ts") return `const res = await fetch("${o}${path}", {\n method: "POST",\n headers: { "content-type": "application/json" },\n body: JSON.stringify(${payload})\n});\nconst data = await res.json();`; + return `import requests\ndata = requests.post("${o}${path}",\n json=${payload}).json()`; +} + +// Render a recall/ask/knowledge response as LLM-ready markdown (the format an +// agent would actually paste into a prompt), degrading to fenced JSON. +function playgroundMarkdown(verb: PlaygroundVerb, body: any): string { + if (!body) return ""; + const fence = (v: unknown) => "```json\n" + JSON.stringify(v, null, 2) + "\n```"; + try { + if (verb === "ask") { + const answer = body?.data?.answer ?? body?.answer ?? ""; + const points: string[] = body?.data?.key_points ?? body?.key_points ?? []; + const conf = typeof body?.confidence === "number" ? ` _(confidence ${Math.round(body.confidence * 100)}%)_` : ""; + let md = `### Answer${conf}\n\n${answer || "_(no answer)_"}\n`; + if (Array.isArray(points) && points.length) md += `\n**Key points**\n${points.map((p) => `- ${p}`).join("\n")}\n`; + return md; + } + if (verb === "knowledge") { + const f = body?.facts ?? {}; + const id = f?.identity ?? {}; + let md = `### ${id?.name ?? "Knowledge graph"}\n`; + if (id?.oneLiner) md += `\n${id.oneLiner}\n`; + const topics = (f?.topics ?? []).map((t: any) => t?.label).filter(Boolean); + if (topics.length) md += `\n**Topics:** ${topics.slice(0, 12).join(", ")}\n`; + const ents = Array.isArray(f?.entities) ? f.entities : []; + if (ents.length) md += `\n**Entities (${ents.length}):** ${ents.slice(0, 8).map((e: any) => e?.name).filter(Boolean).join(", ")}\n`; + return md || fence(f); + } + const result = body?.result; + if (result && Array.isArray(result.results) && result.results.length) { + const lines = result.results.map((r: any) => `- ${typeof r?.text === "string" ? r.text : JSON.stringify(r)}`).join("\n"); + return `### Recall — ${body?.namespace ?? ""}\n\n${lines}\n`; + } + const text = typeof result === "string" ? result : (result?.context ?? result?.answer ?? ""); + if (text) return `### Recall — ${body?.namespace ?? ""}\n\n${text}\n`; + return fence(result ?? body); + } catch { + return fence(body); + } +} + +// Firecrawl/context.dev-style "try it" surface for the landing page: paste a +// question, pick a verb, run it live against a curated public namespace (all +// zero-auth), then copy the exact call. Read verbs (recall/ask/knowledge) hit +// the existing /api/memwal/* routes; `remember` is gated to an account CTA +// because the hosted write route does not exist yet. +function MemoryPlayground({ onRequestAccess }: { onRequestAccess?: () => void }) { + const [namespaces, setNamespaces] = useState>([]); + const [namespace, setNamespace] = useState(""); + const [verb, setVerb] = useState("recall"); + const [query, setQuery] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [body, setBody] = useState(null); + const [source, setSource] = useState(null); + const [respTab, setRespTab] = useState<"markdown" | "json">("markdown"); + const [snippetTab, setSnippetTab] = useState<"curl" | "ts" | "python">("curl"); + const [copied, setCopied] = useState(false); + + useEffect(() => { + void (async () => { + try { + const r = await fetch(`${API_BASE}/api/memwal/namespaces`, { headers: authHeaders("") }); + if (!r.ok) return; + const b = (await r.json()) as { namespaces?: Array<{ namespace: string; label: string }> }; + const list = b.namespaces ?? []; + setNamespaces(list); + setNamespace((c) => c || list[0]?.namespace || ""); + } catch { + /* no curated chips — the playground still renders, snippets still copyable */ + } + })(); + }, []); + + async function run() { + if (verb === "remember") { + onRequestAccess?.(); + return; + } + const ns = namespace.trim(); + if (!ns) { + setError("Pick a namespace first."); + return; + } + const q = query.trim() || "What is this and who is it for?"; + setBusy(true); + setError(null); + setBody(null); + setSource(null); + try { + let res: Response; + if (verb === "knowledge") { + res = await fetch(`${API_BASE}/api/memwal/facts/${encodeURIComponent(ns)}`, { headers: authHeaders("") }); + } else if (verb === "ask") { + res = await fetch(`${API_BASE}/api/memwal/chat`, { + method: "POST", + headers: authHeaders("", { "content-type": "application/json" }), + body: JSON.stringify({ namespace: ns, messages: [{ role: "user", content: q }] }) + }); + } else { + res = await fetch(`${API_BASE}/api/memwal/recall`, { + method: "POST", + headers: authHeaders("", { "content-type": "application/json" }), + body: JSON.stringify({ namespace: ns, query: q }) + }); + } + const text = await res.text(); + let parsed: any = {}; + try { + parsed = text ? JSON.parse(text) : {}; + } catch { + parsed = { raw: text }; + } + if (!res.ok) throw new Error(parsed?.error || `Request failed (${res.status}).`); + setBody(parsed); + setSource(typeof parsed?.source === "string" ? parsed.source : verb === "knowledge" ? "facts" : null); + } catch (err) { + const raw = err instanceof Error ? err.message : String(err); + setError(/Failed to fetch/i.test(raw) ? "Can't reach the API right now — try again in a moment." : raw); + } finally { + setBusy(false); + } + } + + const snippet = playgroundSnippet(snippetTab, verb, namespace, query); + async function copySnippet() { + try { + await navigator.clipboard.writeText(snippet); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + } catch { + /* clipboard blocked */ + } + } + + const sourceBadge = source === "walrus-memory" + ? { label: "Walrus Memory · live recall", tone: "live" } + : source === "facts" + ? { label: "Curated facts · keyword-ranked", tone: "facts" } + : null; + + return ( +
+
+ Memory API +

Paste a question. Get agent-ready context.

+

Zero-auth, runs live against curated public namespaces. When you like the result, copy the exact call.

+
+ +
+
+
+ {PLAYGROUND_VERBS.map((v) => { + const Icon = v.icon; + return ( + + ); + })} +
+ + {namespaces.length ? ( +
+ {namespaces.map((ns) => ( + + ))} +
+ ) : null} + + {verb === "remember" ? ( +
+ +
+ Writing memory is in private beta. + recall · ask · knowledge are live now. Writing your own memory to Walrus — optionally Seal-encrypted — needs an account. +
+ +
+ ) : ( +
+ + setQuery(e.target.value)} + placeholder={verb === "knowledge" ? "knowledge needs no query — pick a namespace, then Run" : "Ask anything about the selected namespace"} + disabled={verb === "knowledge"} + onKeyDown={(e) => { + if (e.key === "Enter") void run(); + }} + /> + +
+ )} + + {error ? ( +
+ {error} +
+ ) : null} + +
+
+ {(["curl", "ts", "python"] as const).map((t) => ( + + ))} + +
+
+              {snippet}
+            
+
+
+ +
+
+
+ {(["markdown", "json"] as const).map((t) => ( + + ))} +
+ {sourceBadge ? {sourceBadge.label} : null} +
+
+            {busy ? "Running…" : !body ? "// Run a verb to see the response here." : respTab === "json" ? JSON.stringify(body, null, 2) : playgroundMarkdown(verb, body) || "// (empty)"}
+          
+
+
+ +

+ Public namespaces answer from verified facts (keyword-ranked). Live semantic Walrus recall and writing your own memory need an account. +

+
+ ); +} + function LandingPage({ target, setTarget, @@ -2605,6 +2891,8 @@ function LandingPage({ + +

{revealWords.map((word, index) => { diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index a527788..4d005c7 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -10239,3 +10239,97 @@ textarea:focus { /* ── Runs catalog row: proof badge ─────────────────────────────────────────── */ .runRowEnd { display: flex; justify-content: flex-end; } .runProofBadge { font-size: 10.5px; font-weight: 800; letter-spacing: 0.03em; padding: 2px 8px; border-radius: 999px; background: var(--cm-teal); color: #fff; } + +/* ── Memory API playground (landing, Firecrawl/context.dev-style) ──────────── */ +.memPlay { max-width: 1120px; margin: 8px auto 0; padding: 40px 24px 12px; } +.memPlayHead { text-align: center; max-width: 660px; margin: 0 auto 26px; } +.memPlayKicker { + display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; + border: 1px solid var(--cm-border); border-radius: 999px; background: var(--cm-frame); + color: #5c7a16; font-size: 12px; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; +} +.memPlayHead h2 { margin: 14px 0 8px; font-size: clamp(22px, 3.4vw, 32px); line-height: 1.1; color: var(--cm-ink); } +.memPlayHead p { margin: 0; color: var(--cm-muted); font-size: 15px; } +.memPlayBody { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: stretch; } +@media (max-width: 880px) { .memPlayBody { grid-template-columns: 1fr; } } +.memPlayControls { + display: flex; flex-direction: column; gap: 14px; padding: 18px; + background: var(--cm-frame); border: 1px solid var(--cm-border); border-radius: 16px; box-shadow: var(--cm-soft-shadow); +} +.memPlayVerbs { display: flex; flex-wrap: wrap; gap: 8px; } +.memPlayVerb { + display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; + border: 1px solid var(--cm-border); border-radius: 999px; background: #fff; color: var(--cm-muted); + font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.16s ease; +} +.memPlayVerb:hover { border-color: var(--cm-border-strong); color: var(--cm-ink); } +.memPlayVerb.selected { background: var(--cm-accent); border-color: var(--cm-accent); color: #1b2207; } +.memPlayNs { display: flex; flex-wrap: wrap; gap: 7px; } +.memPlayInput { + display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; + border: 1px solid var(--cm-border); border-radius: 12px; background: #fff; +} +.memPlayInput svg { color: var(--cm-muted); flex: none; } +.memPlayInput input { flex: 1; border: 0; outline: none; background: transparent; font-size: 14px; color: var(--cm-ink); min-width: 0; } +.memPlayInput input:disabled { color: var(--cm-muted); } +.memPlayInput button { + display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; border: 0; border-radius: 9px; + background: var(--cm-ink); color: #fff; font-weight: 600; font-size: 13px; cursor: pointer; flex: none; +} +.memPlayInput button:hover:not(:disabled) { background: #000; } +.memPlayInput button:disabled { opacity: 0.6; cursor: default; } +.memPlayGate { + display: flex; align-items: center; gap: 12px; padding: 14px; + border: 1px dashed var(--cm-border-strong); border-radius: 12px; background: rgba(168, 217, 70, 0.08); +} +.memPlayGate > svg:first-child { color: #5c7a16; flex: none; } +.memPlayGate strong { display: block; font-size: 14px; color: var(--cm-ink); } +.memPlayGate span { display: block; font-size: 13px; color: var(--cm-muted); margin-top: 2px; } +.memPlayGateCta { + margin-left: auto; display: inline-flex; align-items: center; gap: 5px; padding: 8px 14px; border: 0; border-radius: 9px; + background: var(--cm-accent); color: #1b2207; font-weight: 600; font-size: 13px; cursor: pointer; white-space: nowrap; flex: none; +} +.memPlayError { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--cm-coral); } +.memPlaySnippet { border: 1px solid var(--cm-border); border-radius: 12px; overflow: hidden; margin-top: 2px; } +.memPlaySnippetTabs { + display: flex; align-items: center; gap: 2px; padding: 6px 8px; + background: #11141b; border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.memPlaySnippetTabs button { + padding: 5px 10px; border: 0; border-radius: 7px; background: transparent; + color: rgba(255, 255, 255, 0.55); font-size: 12px; font-weight: 600; cursor: pointer; +} +.memPlaySnippetTabs button.selected { background: rgba(255, 255, 255, 0.1); color: #fff; } +.memPlayCopy { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; color: rgba(255, 255, 255, 0.7) !important; } +.memPlayCopy:hover { color: #fff !important; } +.memPlayCode { + margin: 0; padding: 14px 16px; background: var(--cm-graph-bg); color: #e4ead2; + font-size: 12.5px; line-height: 1.6; overflow-x: auto; white-space: pre; +} +.memPlayCode code, .memPlayRespBody code { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; } +.memPlayResponse { + display: flex; flex-direction: column; border: 1px solid var(--cm-border); border-radius: 16px; + overflow: hidden; background: var(--cm-graph-bg); min-height: 320px; +} +.memPlayRespHead { + display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; + background: #11141b; border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.memPlayRespTabs { display: flex; gap: 2px; } +.memPlayRespTabs button { + padding: 5px 11px; border: 0; border-radius: 7px; background: transparent; + color: rgba(255, 255, 255, 0.55); font-size: 12px; font-weight: 600; cursor: pointer; +} +.memPlayRespTabs button.selected { background: rgba(255, 255, 255, 0.1); color: #fff; } +.memPlaySource { font-size: 11px; font-weight: 600; padding: 4px 9px; border-radius: 999px; white-space: nowrap; } +.memPlaySource.live { background: rgba(168, 217, 70, 0.16); color: #a8d946; } +.memPlaySource.facts { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); } +.memPlayRespBody { + flex: 1; margin: 0; padding: 16px; color: #e4ead2; font-size: 12.5px; line-height: 1.65; + overflow: auto; white-space: pre-wrap; word-break: break-word; +} +.memPlayFoot { + display: flex; align-items: center; justify-content: center; gap: 7px; margin: 18px auto 0; + max-width: 720px; text-align: center; font-size: 12.5px; color: var(--cm-muted); +} +.memPlayFoot svg { color: #5c7a16; flex: none; }