diff --git a/CLAUDE.md b/CLAUDE.md index c62d511..c7d79af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,35 +40,54 @@ These are load-bearing. Do not weaken, work around, or "optimize" them. See --- -## Current status & scope guardrail +## Current status — Built / In progress / Not yet built -**MVP v0.1 is a burner/cleaner only.** This repo is currently **scaffold + docs**: -**Solana transaction logic and business logic are NOT yet implemented.** Do not -add application/business logic unless explicitly asked. +This repo is **no longer scaffold + docs** — the burner core is implemented and +live at [feedthepyre.com](https://feedthepyre.com). Keep new work aligned with the +design doc; the trust rules above are non-negotiable. -**Explicitly OUT of scope for v0.1** (per §5): +**Built (working):** -- Automatic Pump.fun launch -- User-contributed Essence vault -- Custom PYRE Solana program (Anchor) -- NFT handling (incl. compressed NFTs) -- Automatic valuable-token sacrifice -- Custodial signing -- Background wallet automation -- On-chain swap routing (TRANSMUTABLE) and Token-2022 confidential-transfer / - fee-harvest flows +- **Wallet scan + conservative classifier** (`@pyre/core`): EMPTY_CLOSE_ONLY / + INCINERATE_ONLY / TRANSMUTABLE / PROTECTED_SKIP / UNSUPPORTED. "Unknown means + skip"; never "safe". +- **Token-2022** supported conservatively with account+mint **extension gating** + (§7.1): confidential transfer / withheld transfer fees / frozen / unknown + extensions skipped; transfer-hook & permanent-delegate mints cleanable but + flagged; unverifiable mints → UNSUPPORTED. In `@pyre/core` (`extensions.ts`, + `classify.ts`) + `@pyre/solana`. +- **Close-empty + burn→close transactions** (`@pyre/solana`): UNSIGNED, server + re-validates on-chain, value-gated, rent → user, with a transparent **5% + protocol fee** (§3.1) to the treasury. Decoded + matched in the web app before + signing. Live-verified on mainnet. +- **Sell / transmute DETECTION** via Jupiter (read-only quotes + Shield + dust + gate) in `@pyre/core` (`sell.ts`) and the API (`jupiter.ts`). Swap **execution + is not built**. +- **Essence ledger** (`@pyre/db`, Postgres: `rounds`, `cleanup_receipts`, + `essence_contributions`): `/api/receipt` persists the receipt and records the + on-chain fee as Essence; public "🔥 fed the PYRE" panel + `/api/essence`. +- **Prometheus engine** (`@pyre/prometheus`): meta-mixer + name/ticker/lore/ + tagline + image-prompt + safety. Provider-abstracted with real providers + (Gemini/Anthropic/OpenAI text; Pollinations/fal/DeepInfra/Replicate image; + OpenAI moderation) plus a deterministic stub fallback; runs on a free stack + today. +- **Pump.fun creator workflow** (manual / approval-gated): `spawn_records`, + admin-gated generate/launch endpoints, public `/spawn` page. -**Token-2022 IS in v0.1 scope** (Rev 2 — most new/pump.fun tokens are Token-2022), -supported conservatively with account+mint **extension gating**: confidential -transfer / withheld transfer fees / frozen / unknown extensions are skipped; -transfer-hook & permanent-delegate mints are cleanable but flagged. See -[`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) §7.1. Implemented in -`@pyre/core` (`extensions.ts` + `classify.ts`) and `@pyre/solana` (account+mint -extension reads); unverifiable mints → UNSUPPORTED. +**In progress:** -v0.1 ships: wallet connect → scan token accounts (classic SPL + Token-2022) → -classify → close eligible empty ATAs (optionally burn obvious junk) → return rent -to user → show receipt. +- Admin generation UI (review/approve Spawn packages). +- Generation-from-receipt wiring (`/api/prometheus/generate` already pulls + receipt context; full receipt → Spawn flow being finished). + +**Not yet built:** + +- Swap **execution** (TRANSMUTABLE detection only today). +- On-chain claim program v1.0 (`programs/pyre-core` is still a placeholder). +- Background worker jobs (`apps/worker` is a skeleton — no queues wired up). + +Token-2022 close/burn is in scope; confidential-transfer / fee-harvest flows and +swapping hook/permanent-delegate tokens remain out of scope. --- @@ -81,8 +100,8 @@ pyre/ # cleanup preview, receipt page, Prometheus preview, admin review api/ # Fastify HTTP API: scan, classify, build tx, receipt, # generation, admin endpoints - worker/ # BullMQ worker: async metadata lookup, AI generation, - # safety checks, tx-confirmation watcher, receipt enrichment + worker/ # BullMQ worker (SKELETON — jobs not yet wired): planned async + # metadata lookup, AI generation, safety, tx-confirm, enrichment packages/ core/ # shared types & logic: classification enums, risk rules, # DTOs, receipt schema, Prometheus I/O schema @@ -127,12 +146,14 @@ Node 22 is required. pnpm install # install workspace deps pnpm -r build # build all packages/apps pnpm -r typecheck # type-check all -pnpm -r test # run all tests +pnpm -r test # run all test suites (real tests in core/solana/prometheus) pnpm dev # run apps in parallel (dev) ``` -> **Nothing is installed yet.** These commands are not runnable until the -> skeleton's `package.json` files and dependencies exist. +Production runs under **pm2** (`ecosystem.config.cjs`: `pyre-web`, `pyre-api`, +`pyre-worker`). The API and worker are launched with +`node --env-file-if-exists=/.env --import tsx`, so the API loads secrets +from the gitignored `.env` at the repo root. --- @@ -145,6 +166,17 @@ pnpm dev # run apps in parallel (dev) --- +## Secrets + +Secrets live in a **gitignored `.env` at the repo root** (chmod 600), loaded via +`node --env-file-if-exists`. **Never commit keys** — there is no private-key env +var by design (§trust rules), and `.env.example` documents the shape only. Admin +endpoints (`/api/prometheus/generate`, Spawn launch) require an `x-admin-token` +header equal to `ADMIN_API_TOKEN`; when that token is unset, admin endpoints are +**closed** (403), not open. + +--- + ## Docs - [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) — canonical design (source of truth) @@ -156,19 +188,11 @@ pnpm dev # run apps in parallel (dev) --- -## Bootstrap prompts (§20) +## Working in this repo -Use these in order. **Plan first — no code:** - -> Read CLAUDE.md and docs/PYRE_MVP_DESIGN.md. Do not write code yet. Produce an -> implementation plan for PYRE MVP v0.1 focused only on wallet scanning, token -> account classification, close-empty-ATA transaction building, transaction -> preview, and receipt generation. Identify the exact packages, APIs, database -> tables, and test cases needed. - -Then, after plan review — **skeleton only:** - -> Create the monorepo skeleton with pnpm workspaces. Add apps/web, apps/api, -> apps/worker, packages/core, packages/solana, packages/prometheus, packages/db, -> and docs. Add TypeScript configs, package.json files, README files, and -> .env.example files. Do not implement Solana transaction logic yet. +The skeleton and burner core already exist (see "Current status" above). When +extending, stay inside the design doc's scope and the trust rules. Before +touching transaction-building or classification code, read the relevant section +of [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) (§6, §7, §8, §16) — that +document wins on any conflict. Run `pnpm -r typecheck` and `pnpm -r test` after +changes. diff --git a/README.md b/README.md index 2413d74..41894b1 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,15 @@ **Links:** [feedthepyre.com](https://feedthepyre.com) · repo: `git.lumiai.dev/RogueWave/pyre` · dev status: [feedthepyre.com](https://feedthepyre.com) (status dashboard) PYRE is a **Solana wallet-cleanup and ritual meme-rebirth protocol**. You connect -a wallet; PYRE scans your SPL token accounts, classifies them conservatively, and -helps you safely close empty associated token accounts (ATAs) and burn obvious -junk — **returning recovered rent to you** and producing a clear, shareable -receipt. A later layer (Prometheus) uses AI to generate a meme-token "Spawn" from -burned remnants for **manual, human-reviewed** launch. +a wallet; PYRE scans your SPL token accounts (classic **and** Token-2022), +classifies them conservatively, and helps you safely close empty associated token +accounts (ATAs) and burn obvious junk — **returning recovered rent to you** (minus +a transparent 5% protocol fee) and producing a clear, shareable receipt. The +Prometheus engine uses AI to generate a meme-token "Spawn" from burned remnants +for **manual, human-reviewed** launch on Pump.fun. + +The burner core is **live at [feedthepyre.com](https://feedthepyre.com)** and the +5%-fee close/burn flow has been verified end-to-end on mainnet. The first emotional win is simple: *"PYRE cleaned my wallet and returned SOL I forgot was trapped in token accounts."* @@ -26,8 +30,10 @@ promises. **Trust guarantees:** PYRE never holds your private keys, never signs custodially, and always shows a decoded transaction preview that matches what you -sign. Recovered rent goes back to your wallet by default. Anything the system -cannot safely reason about is skipped. +sign. Recovered rent goes back to your wallet, minus a single **transparent 5% +protocol fee** shown before you sign (it funds the Spawn — see the roadmap). +Swap proceeds always go to you. Anything the system cannot safely reason about is +skipped. ## The burner flow at a glance @@ -35,25 +41,34 @@ cannot safely reason about is skipped. Connect wallet → scan token accounts → classify accounts (closeable / burnable / transmutable / protected / unsupported) - → preview the transaction (accounts, rent, destination, fees, warnings) + → preview the transaction (accounts, rent, destination, 5% fee, warnings) → you sign locally in your wallet - → recovered rent returns to you + → recovered rent (minus the fee) returns to you; the fee feeds the PYRE → see your PYRE receipt ``` -## Roadmap +## Roadmap & status -- **v0.1 — Burner / Cleaner** *(current focus)*: wallet connect, scan, classify, - close empty ATAs, optional junk burn, rent return, receipt. -- **v0.2 — Prometheus Meta Mixer**: AI generation of a Spawn identity from - burned/cleaned token context (candidate package only — no auto-launch). -- **v0.3 — Manual Pump.fun Workflow**: human reviews the Spawn package and - manually creates the token; PYRE records mint, URL, metadata, and tx. -- **v0.4 — Essence Ledger**: record net SOL value of safe scrap swaps as Essence - per wallet/round (database-only, experimental, no claim promises). -- **v1.0 — PYRE Core Program**: custom Solana (Anchor) program for trust-critical - accounting — rounds, Essence vault, contribution receipts, Spawn distribution, - claims, and refunds. +- **v0.1 — Burner / Cleaner** — **working.** Wallet connect, scan, conservative + classification (classic SPL + Token-2022 with extension gating), close empty + ATAs, burn-then-close junk, rent return minus the transparent 5% fee, receipt. + Live on mainnet. +- **v0.2 — Prometheus Meta Mixer** — **working.** AI generation of a Spawn + identity (name/ticker/lore/tagline/image-prompt + safety) from burned/cleaned + token context; provider-abstracted with a deterministic stub fallback. Produces + a candidate package only — no auto-launch. *In progress:* admin review UI and + finishing the receipt → generation wiring. +- **v0.3 — Manual Pump.fun Workflow** — **working (manual, approval-gated).** + Admin-gated generate/launch endpoints record mint, URL, metadata, and tx; public + `/spawn` page. Human creates the token. +- **v0.4 — Essence Ledger** — **working (database-only).** The on-chain 5% fee is + recorded as Essence per round in Postgres; a public "🔥 fed the PYRE" panel and + `/api/essence` show round progress. Experimental, no claim promises. +- **Sell / Transmute** — **detection only.** Jupiter quotes + Shield + dust gate + classify TRANSMUTABLE tokens; swap **execution is not built yet**. +- **v1.0 — PYRE Core Program** — **not built.** Future Solana (Anchor) program for + trust-critical accounting: rounds, Essence vault, contribution receipts, Spawn + distribution, claims, refunds. ## Repo structure @@ -61,7 +76,7 @@ Connect wallet apps/ web/ Next.js user app (wallet connect, scanner, preview, receipt) api/ Fastify HTTP API (scan, classify, build tx, receipt, generation) - worker/ BullMQ background worker (metadata, AI, safety, confirmations) + worker/ BullMQ background worker (skeleton — jobs not yet wired) packages/ core/ shared types, classification enums, risk rules, schemas solana/ token-account parsing, close/burn tx builders, decoder, simulation @@ -75,17 +90,20 @@ docs/ design, architecture, security, classification ## Quick start -> **This is a scaffold.** The commands below are not yet runnable — the workspace -> package definitions and source are still being built out. - **Prerequisites:** Node 22, pnpm, PostgreSQL, Redis. ```bash cp .env.example .env # then fill in values (no private keys — by design) pnpm install -pnpm dev # (once apps are implemented) +pnpm -r build # build all packages/apps +pnpm -r test # run the test suites +pnpm dev # run web + api in dev ``` +Secrets are read from the gitignored `.env` at the repo root; never commit keys. +Admin endpoints require an `ADMIN_API_TOKEN` (see `.env.example`). In production +the apps run under pm2 (`ecosystem.config.cjs`). + ## Docs See [`docs/`](docs/) — start with diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx new file mode 100644 index 0000000..66ea7f8 --- /dev/null +++ b/apps/web/src/app/admin/page.tsx @@ -0,0 +1,630 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { PrometheusGenerateResponse, SpawnRecord } from "@pyre/core"; +import { Footer } from "../../components/Footer"; + +// Same-origin by default so production hits "/api/…" behind the same host. +// Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev). +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; + +// The admin token is operator-supplied at runtime and kept ONLY in sessionStorage +// (cleared when the tab closes). It is NEVER read from an env var, NEVER baked +// into the build, and NEVER committed. It travels only as the `x-admin-token` +// header on admin calls. +const TOKEN_KEY = "pyre.adminToken"; + +/** Pull a string field out of the loosely-typed generation metadata blob. */ +function metaString( + metadata: Record | undefined, + key: string, +): string | undefined { + const v = metadata?.[key]; + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +// Humanize a risk flag (e.g. "PROFANITY_DETECTED" -> "profanity detected"). +function humanizeRiskFlag(flag: string): string { + return flag.toLowerCase().replace(/_/g, " ").trim(); +} + +// --------------------------------------------------------------------------- +// Token gate +// --------------------------------------------------------------------------- + +function TokenGate({ + token, + onSave, + onClear, + authError, +}: { + token: string | null; + onSave: (t: string) => void; + onClear: () => void; + authError: boolean; +}) { + const [draft, setDraft] = useState(""); + + const save = () => { + const trimmed = draft.trim(); + if (trimmed) { + onSave(trimmed); + setDraft(""); + } + }; + + return ( +
+

+ Operator token +

+ {token ? ( +
+ admin token set + + Held in this tab only (sessionStorage). Sent as{" "} + x-admin-token. Never stored on the server or in the build. + + +
+ ) : ( +
+

+ Paste your admin token to enable operator actions. It stays in this + browser tab only and is sent as the x-admin-token header. +

+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") save(); + }} + aria-label="Admin token" + /> + +
+
+ )} + {authError && ( +

+ invalid or missing admin token — paste a valid token to continue. +

+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Generate panel +// --------------------------------------------------------------------------- + +function GeneratePanel({ + token, + onAuthError, + onGenerated, + generation, +}: { + token: string | null; + onAuthError: () => void; + onGenerated: (g: PrometheusGenerateResponse) => void; + generation: PrometheusGenerateResponse | null; +}) { + const [chaos, setChaos] = useState(0.5); + const [operatorSeed, setOperatorSeed] = useState(""); + const [receiptId, setReceiptId] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const generate = useCallback(async () => { + if (!token) return; + setLoading(true); + setError(null); + try { + const body: { + receiptId?: string; + chaos?: number; + operatorSeed?: string; + } = { chaos }; + if (operatorSeed.trim()) body.operatorSeed = operatorSeed.trim(); + if (receiptId.trim()) body.receiptId = receiptId.trim(); + + const res = await fetch(`${API_BASE}/api/prometheus/generate`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-admin-token": token, + }, + body: JSON.stringify(body), + }); + if (res.status === 403) { + onAuthError(); + return; + } + if (!res.ok) throw new Error(`Generation failed (${res.status})`); + const data = (await res.json()) as PrometheusGenerateResponse; + onGenerated(data); + } catch (e) { + setError(e instanceof Error ? e.message : "Generation failed."); + } finally { + setLoading(false); + } + }, [token, chaos, operatorSeed, receiptId, onAuthError, onGenerated]); + + const tagline = useMemo( + () => metaString(generation?.metadata, "tagline"), + [generation], + ); + const description = useMemo( + () => metaString(generation?.metadata, "description"), + [generation], + ); + const imageUrl = useMemo( + () => metaString(generation?.metadata, "imageUrl"), + [generation], + ); + + return ( +
+

+ Generate a Spawn +

+

+ Drive Prometheus to produce a Spawn package for manual review. Generation + does nothing on-chain — it only proposes a name, ticker, lore, and image. +

+ +
+ + + + + + + +
+ + {!token && ( +

+ Set an admin token above to generate. +

+ )} + + {loading && ( +
+
+ )} + + {error && ( +

+ Something went wrong: {error} +

+ )} + + {generation && ( +
+
+ {imageUrl ? ( + // Pollinations / generated image URLs render directly. + // eslint-disable-next-line @next/next/no-img-element + {`${generation.spawnName} + ) : ( + + )} +
+

+ {generation.spawnName}{" "} + ${generation.ticker} +

+ {tagline &&

{tagline}

} +
+
+ + {(description || generation.lore) && ( +

{description ?? generation.lore}

+ )} + {description && generation.lore && description !== generation.lore && ( +

+ {generation.lore} +

+ )} + +
+
+
Generation id
+
+ {generation.generationId} +
+
+
+
Image prompt
+
{generation.imagePrompt}
+
+
+ + {generation.riskFlags.length > 0 ? ( +
+ + ⚠ Review these risk flags before launching: + +
+ {generation.riskFlags.map((f) => ( + + {humanizeRiskFlag(f)} + + ))} +
+
+ ) : ( +

No risk flags raised.

+ )} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Launch panel +// --------------------------------------------------------------------------- + +function truncate(addr: string): string { + if (addr.length <= 10) return addr; + return `${addr.slice(0, 4)}…${addr.slice(-4)}`; +} + +function LaunchPanel({ + token, + generation, + onAuthError, +}: { + token: string | null; + generation: PrometheusGenerateResponse; + onAuthError: () => void; +}) { + const [mint, setMint] = useState(""); + const [pumpfunUrl, setPumpfunUrl] = useState(""); + const [launchTx, setLaunchTx] = useState(""); + const [metadataUri, setMetadataUri] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [record, setRecord] = useState(null); + + // A fresh generation invalidates any previous launch result/inputs. + useEffect(() => { + setRecord(null); + setError(null); + }, [generation.generationId]); + + const record_launch = useCallback(async () => { + if (!token) return; + setLoading(true); + setError(null); + try { + const body: { + generationId: string; + mint: string; + metadataUri?: string; + pumpfunUrl?: string; + launchTx?: string; + } = { generationId: generation.generationId, mint: mint.trim() }; + if (metadataUri.trim()) body.metadataUri = metadataUri.trim(); + if (pumpfunUrl.trim()) body.pumpfunUrl = pumpfunUrl.trim(); + if (launchTx.trim()) body.launchTx = launchTx.trim(); + + const res = await fetch(`${API_BASE}/api/spawn/launch`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-admin-token": token, + }, + body: JSON.stringify(body), + }); + if (res.status === 403) { + onAuthError(); + return; + } + if (res.status === 404) { + throw new Error("generation not found (404) — re-generate and retry."); + } + if (!res.ok) throw new Error(`Record launch failed (${res.status})`); + const data = (await res.json()) as SpawnRecord; + setRecord(data); + } catch (e) { + setError(e instanceof Error ? e.message : "Record launch failed."); + } finally { + setLoading(false); + } + }, [token, generation.generationId, mint, metadataUri, pumpfunUrl, launchTx, onAuthError]); + + return ( +
+

+ Record a Pump.fun launch +

+

+ After you create the token on Pump.fun by hand, record its identifiers + here. PYRE never signs or creates the launch — this only files the manual + result against the generation. +

+ +
+ + + + + + + + + + + +
+ + {error && ( +

+ Something went wrong: {error} +

+ )} + + {record && ( +
+

Launch recorded 🔥

+
+
+
Record id
+
+ {record.id} +
+
+
+
Spawn
+
+ {record.spawnName} ${record.ticker} +
+
+
+
Status
+
{record.status}
+
+ {record.mint && ( +
+
Mint
+
+ {truncate(record.mint)} +
+
+ )} +
+ {record.pumpfunUrl && ( + + View on Pump.fun ↗ + + )} + + See it on the public Spawn record → + +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +/** + * Operator-only admin console. Public route, but every useful action is gated + * server-side by the `x-admin-token` header — the page does nothing without a + * valid token. The token is pasted at runtime and kept ONLY in sessionStorage. + */ +export default function AdminPage() { + const [token, setToken] = useState(null); + const [authError, setAuthError] = useState(false); + const [generation, setGeneration] = useState( + null, + ); + + // Hydrate the token from sessionStorage on mount (tab-scoped, never persisted + // beyond the session, never read from env or build output). + useEffect(() => { + try { + const stored = sessionStorage.getItem(TOKEN_KEY); + if (stored) setToken(stored); + } catch { + // sessionStorage may be unavailable (e.g. privacy mode) — ignore. + } + }, []); + + const saveToken = useCallback((t: string) => { + try { + sessionStorage.setItem(TOKEN_KEY, t); + } catch { + // ignore storage failures; still keep it in memory for this session. + } + setToken(t); + setAuthError(false); + }, []); + + const clearToken = useCallback(() => { + try { + sessionStorage.removeItem(TOKEN_KEY); + } catch { + // ignore + } + setToken(null); + }, []); + + // A 403 means the stored token is invalid/missing: drop it and re-prompt. + const handleAuthError = useCallback(() => { + try { + sessionStorage.removeItem(TOKEN_KEY); + } catch { + // ignore + } + setToken(null); + setAuthError(true); + }, []); + + return ( +
+
+
+

Operator console

+

+ Prometheus admin +

+

+ Operator-only. Drive Prometheus generation and record manual Pump.fun + launches. The flow is intentionally manual: Prometheus proposes a + Spawn, you review it, create the token on Pump.fun in your own wallet, + then file the result here. PYRE never holds keys and never signs a + launch. +

+
+ + + + { + setGeneration(g); + setAuthError(false); + }} + generation={generation} + /> + + {generation && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index f0f00e7..4ec12b5 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1057,3 +1057,342 @@ body { .spawn-card__link:hover { text-decoration: underline; } + +/* ---- Operator admin console (/admin) ---- */ +.admin { + display: flex; + flex-direction: column; + gap: 1.5rem; +} +.admin__head { + text-align: center; +} +.admin__intro { + max-width: 42rem; + margin: 0.5rem auto 0; + color: var(--color-smoke); + font-size: 0.95rem; + line-height: 1.7; +} + +.admin-card { + border: 1px solid rgba(255, 138, 61, 0.22); + background: linear-gradient(180deg, rgba(255, 87, 34, 0.06), rgba(26, 20, 18, 0.6)); + border-radius: 1rem; + padding: 1.5rem 1.5rem 1.6rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} +.admin-card__heading { + font-size: 1.15rem; + font-weight: 700; + margin: 0; +} +.admin-card__hint { + color: var(--color-smoke); + font-size: 0.88rem; + line-height: 1.6; + margin: 0; +} +.admin-card__hint--muted { + font-style: italic; + opacity: 0.85; +} +.admin-card code, +.admin-result code, +.admin-record code { + font-family: ui-monospace, monospace; + font-size: 0.82em; + color: var(--color-ember-bright); + word-break: break-all; +} + +/* Inputs / fields */ +.admin-fieldset { + border: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} +.admin-fieldset:disabled { + opacity: 0.6; +} +.admin-field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.admin-field__label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-weight: 600; + font-size: 0.88rem; + color: #f5ede6; +} +.admin-field__value { + font-variant-numeric: tabular-nums; + color: var(--color-ember-bright); +} +.admin-field-row { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; +} +.admin-input { + flex: 1; + min-width: 0; + appearance: none; + background: var(--color-coal); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.5rem; + color: #f5ede6; + font-size: 0.95rem; + padding: 0.65rem 0.85rem; + width: 100%; +} +.admin-input:focus { + outline: none; + border-color: var(--color-ember); + box-shadow: 0 0 0 2px rgba(255, 87, 34, 0.2); +} +.admin-input::placeholder { + color: rgba(184, 169, 156, 0.6); +} +.admin-input--readonly { + color: var(--color-smoke); + font-family: ui-monospace, monospace; + font-size: 0.85rem; +} +.admin-slider { + width: 100%; + accent-color: var(--color-ember); + cursor: pointer; +} + +/* Buttons */ +.admin-btn { + appearance: none; + border: 1px solid var(--color-ember); + background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember)); + color: #1a0d06; + font-weight: 700; + font-size: 0.95rem; + padding: 0 1.25rem; + height: 46px; + border-radius: 0.5rem; + cursor: pointer; + align-self: flex-start; + transition: filter 0.15s ease, opacity 0.15s ease; +} +.admin-btn:hover:not(:disabled) { + filter: brightness(1.1); +} +.admin-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.admin-btn--ghost { + background: transparent; + border-color: rgba(255, 255, 255, 0.18); + color: var(--color-smoke); + height: 40px; +} +.admin-btn--ghost:hover:not(:disabled) { + filter: none; + border-color: rgba(255, 138, 61, 0.5); + color: #f5ede6; +} + +/* Token gate */ +.admin-token-form, +.admin-token-set { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.admin-token-set { + align-items: flex-start; +} +.admin-token-set__badge { + display: inline-block; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.2rem 0.6rem; + border-radius: 999px; + color: #7be3a3; + background: rgba(60, 200, 120, 0.12); + border: 1px solid rgba(60, 200, 120, 0.35); +} +.admin-token-set__note { + color: var(--color-smoke); + font-size: 0.85rem; + line-height: 1.6; +} +.admin-error { + margin: 0; + text-align: left; +} + +.admin-loading { + display: flex; + align-items: center; + gap: 0.6rem; + color: var(--color-smoke); +} + +/* Generation / launch result */ +.admin-result, +.admin-record { + margin-top: 0.5rem; + border: 1px solid rgba(255, 138, 61, 0.4); + background: linear-gradient(180deg, rgba(255, 87, 34, 0.1), rgba(26, 20, 18, 0.7)); + border-radius: 0.85rem; + padding: 1.25rem 1.4rem 1.4rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} +.admin-result__head { + display: flex; + gap: 1rem; + align-items: flex-start; +} +.admin-result__img { + width: 6rem; + height: 6rem; + flex-shrink: 0; + object-fit: cover; + border-radius: 0.6rem; + border: 1px solid rgba(255, 138, 61, 0.3); +} +.admin-result__img--placeholder { + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + background: radial-gradient(80% 80% at 50% 30%, rgba(255, 87, 34, 0.2), var(--color-ash)); +} +.admin-result__name { + margin: 0; + font-size: 1.3rem; + font-weight: 800; + color: #f5ede6; +} +.admin-result__ticker { + color: var(--color-ember-bright); + font-weight: 700; + font-size: 0.95rem; +} +.admin-result__tagline { + margin: 0.35rem 0 0; + color: var(--color-ember-bright); + font-weight: 600; + font-size: 0.95rem; +} +.admin-result__lore { + margin: 0; + color: #f5ede6; + font-size: 0.92rem; + line-height: 1.6; +} +.admin-result__lore--muted { + color: var(--color-smoke); + font-size: 0.85rem; + font-style: italic; +} +.admin-result__meta { + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.admin-result__row { + display: flex; + flex-direction: column; + gap: 0.15rem; + font-size: 0.85rem; +} +.admin-result__row dt { + color: var(--color-smoke); + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.72rem; + font-weight: 600; +} +.admin-result__row dd { + margin: 0; + color: #f5ede6; + line-height: 1.5; +} + +/* Risk flag chips */ +.admin-riskflags { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.admin-riskflags--warn { + border: 1px solid rgba(255, 60, 40, 0.45); + background: rgba(255, 60, 40, 0.08); + border-radius: 0.6rem; + padding: 0.85rem 1rem; +} +.admin-riskflags__label { + color: #ff9a7a; + font-weight: 700; + font-size: 0.88rem; +} +.admin-riskflags__chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +.admin-riskflags__none { + margin: 0; + color: var(--color-smoke); + font-size: 0.85rem; + font-style: italic; +} +.admin-chip { + display: inline-block; + font-size: 0.74rem; + font-weight: 600; + padding: 0.14rem 0.55rem; + border-radius: 999px; + white-space: nowrap; +} +.admin-chip--warn { + color: #ff9a7a; + background: rgba(255, 60, 40, 0.12); + border: 1px solid rgba(255, 60, 40, 0.4); +} + +/* Recorded launch */ +.admin-record { + border-color: rgba(60, 200, 120, 0.4); + background: linear-gradient(180deg, rgba(60, 200, 120, 0.1), rgba(26, 20, 18, 0.7)); +} +.admin-record__headline { + margin: 0; + font-size: 1.2rem; + font-weight: 800; + color: #7be3a3; +} +.admin-link { + align-self: flex-start; + color: var(--color-ember-bright); + font-weight: 600; + font-size: 0.9rem; + text-decoration: none; +} +.admin-link:hover { + text-decoration: underline; +} diff --git a/apps/web/src/components/Footer.tsx b/apps/web/src/components/Footer.tsx index 3125981..9c7c196 100644 --- a/apps/web/src/components/Footer.tsx +++ b/apps/web/src/components/Footer.tsx @@ -22,6 +22,7 @@ export function Footer() { Scanner The Spawn + Operator