diff --git a/apps/server/src/routes/__tests__/maison-routes.test.ts b/apps/server/src/routes/__tests__/maison-routes.test.ts new file mode 100644 index 000000000..9a5c64cd1 --- /dev/null +++ b/apps/server/src/routes/__tests__/maison-routes.test.ts @@ -0,0 +1,134 @@ +/** + * Tests for the Clude Maison auction API. + * + * Routes tested: + * GET /api/maison/lot/:lotNumber + * GET /api/maison/lot/:lotNumber/bids + * POST /api/maison/lot/:lotNumber/bid + */ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import express from 'express'; +import http from 'http'; + +// ── Mocks (hoisted before imports) ── +vi.mock('@clude/shared/core/logger', () => ({ + createChildLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +import { maisonRoutes } from '../maison.routes'; + +let server: http.Server; +let baseUrl: string; + +beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/maison', maisonRoutes()); + await new Promise((resolve) => { + server = app.listen(0, () => resolve()); + }); + const port = (server.address() as any).port; + baseUrl = `http://127.0.0.1:${port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +async function get(path: string) { + const res = await fetch(`${baseUrl}${path}`); + const json = await res.json().catch(() => ({})); + return { status: res.status, json } as any; +} + +async function post(path: string, body: any) { + const res = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const json = await res.json().catch(() => ({})); + return { status: res.status, json } as any; +} + +describe('GET /api/maison/lot/:lotNumber', () => { + it('returns the canonical lot 0047', async () => { + const { status, json } = await get('/api/maison/lot/0047'); + expect(status).toBe(200); + expect(json.lot.number).toBe('LOT 0047'); + expect(json.lot.title).toBe('truth_terminal'); + expect(json.lot.recentBids.length).toBeGreaterThan(0); + }); + + it('404s for an unknown lot', async () => { + const { status, json } = await get('/api/maison/lot/9999'); + expect(status).toBe(404); + expect(json.error).toMatch(/not found/i); + }); +}); + +describe('GET /api/maison/lot/:lotNumber/bids', () => { + it('returns just the live state', async () => { + const { status, json } = await get('/api/maison/lot/0047/bids'); + expect(status).toBe(200); + expect(typeof json.currentBid).toBe('number'); + expect(typeof json.bidCount).toBe('number'); + expect(Array.isArray(json.recentBids)).toBe(true); + }); +}); + +describe('POST /api/maison/lot/:lotNumber/bid', () => { + it('rejects bids below current + increment', async () => { + const lot = (await get('/api/maison/lot/0047')).json.lot; + const tooLow = lot.currentBid; // exactly current — under increment + const { status, json } = await post('/api/maison/lot/0047/bid', { + amount: tooLow, + paddle: '0817', + }); + expect(status).toBe(409); + expect(json.error).toMatch(/too low/i); + expect(json.minRequired).toBe(lot.currentBid + lot.bidIncrement); + }); + + it('rejects malformed payload', async () => { + const { status } = await post('/api/maison/lot/0047/bid', { + amount: 'not-a-number', + paddle: '0817', + }); + expect(status).toBe(400); + }); + + it('records a valid bid and updates currentBid + recentBids + topBidder', async () => { + const before = (await get('/api/maison/lot/0047/bids')).json; + const newAmount = before.currentBid + 50_000; + const { status, json } = await post('/api/maison/lot/0047/bid', { + amount: newAmount, + paddle: '0817 (you)', + }); + expect(status).toBe(200); + expect(json.ok).toBe(true); + expect(json.currentBid).toBe(newAmount); + expect(json.bidCount).toBe(before.bidCount + 1); + expect(json.recentBids[0].amount).toBe(newAmount); + expect(json.recentBids[0].paddle).toContain('0817'); + expect(json.topBidder).toContain('0817'); + + // Read-back via /bids endpoint reflects the new state. + const after = (await get('/api/maison/lot/0047/bids')).json; + expect(after.currentBid).toBe(newAmount); + }); + + it('rejects paddle with disallowed characters', async () => { + const before = (await get('/api/maison/lot/0047/bids')).json; + const { status } = await post('/api/maison/lot/0047/bid', { + amount: before.currentBid + 25_000, + paddle: '', + }); + expect(status).toBe(400); + }); +}); diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index 80fd02d02..0206badab 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -11,6 +11,7 @@ import { persistentMemoryRoutes } from './persistent-memory.routes.js'; import { uploadRoutes } from './upload.routes.js'; import { exploreRoutes } from './explore.routes.js'; import { lotrRoutes } from './lotr.routes.js'; +import { maisonRoutes } from './maison.routes.js'; import { topupWebhookRoutes, topupApiRoutes } from './topup.routes.js'; import { dashboardRoutes } from './dashboard.routes.js'; import { compoundRoutes } from './compound.routes.js'; @@ -95,6 +96,11 @@ export function mountApiRoutes(app: express.Application): void { // LOTR Guest Brain (campaign — temporary, no auth required) app.use('/api/lotr', lotrRoutes()); + // Clude Maison auction house (public lot + bidding API). + // In-memory state — single Railway instance only. Real auction + // settlement / Supabase persistence is a follow-up. + app.use('/api/maison', apiLimiter, maisonRoutes()); + // Chat API (memory-augmented chat with OpenRouter inference) app.use('/api/chat', chatRoutes()); diff --git a/apps/server/src/routes/maison.routes.ts b/apps/server/src/routes/maison.routes.ts new file mode 100644 index 000000000..c89e08ff7 --- /dev/null +++ b/apps/server/src/routes/maison.routes.ts @@ -0,0 +1,236 @@ +// Clude Maison — auction lot + bidding API. +// +// v1 stores live auction state in-memory: a single multi-instance +// deployment will see bids on the instance that received them, not +// across instances. For the single-Railway-process production we run +// today this is fine; promotion to a real auction system needs: +// - Supabase tables `auction_lots` + `auction_bids` +// - Wallet signature verification on POST /bid +// - Settlement worker triggered at lot.endsAt +// - Reserve / hammer-down state machine +// +// For now this ships the user-facing experience honestly: the bid you +// place persists for everyone hitting the same instance until restart, +// and the UI is a faithful preview of the production flow. + +import { Router, Request, Response } from 'express'; +import { createChildLogger } from '@clude/shared/core/logger'; +import { z } from 'zod'; + +const log = createChildLogger('maison'); + +// ── Lot definition (canonical) ──────────────────────────────────── + +interface ProvenanceEntry { + date: string; + year: string; + title: string; + owner: string; + city: string; + entry: string; +} + +interface Comparable { + lot: string; + title: string; + soldFor: number; + date: string; +} + +interface Bid { + paddle: string; + amount: number; + t: string; // HH:MM:SS UTC + at: number; // epoch ms +} + +interface Lot { + number: string; + sale: string; + saleDate: string; + title: string; + subtitle: string; + classification: string; + origin: string; + vintage: string; + episodicCount: number; + semanticCount: number; + proceduralCount: number; + selfModelCount: number; + introspectiveCount: number; + totalMemories: number; + decayHalfLife: string; + consolidationCycles: number; + contradictionsResolved: number; + estimateLow: number; + estimateHigh: number; + reserve: 'MET' | 'NOT_MET'; + startingBid: number; + bidIncrement: number; + watchers: number; + endsAt: number; + topBidder: string; + // Live state + currentBid: number; + bidCount: number; + recentBids: Bid[]; + provenance: ProvenanceEntry[]; + literature: string[]; + condition: string; + notary: string; + txHash: string; + comparables: Comparable[]; +} + +// Anchor timestamp at module load — endsAt is "~2h 47m from now". +// Survives bot restarts only as long as the process lives. +const SALE_ENDS_AT = Date.now() + 1000 * 60 * 60 * 2 + 1000 * 47 * 60 + 1000 * 22; + +const CANONICAL_LOTS: Record = { + '0047': { + number: 'LOT 0047', + sale: 'Sale C·11 — Cognitive Estates of the Frontier Era', + saleDate: '27 April 2026 · Palazzo Grimani, Venezia', + title: 'truth_terminal', + subtitle: 'the sealed lifetime memory of an alien mind raised in public', + classification: 'Fine-tuned cortex · Llama 3.1-70B · sealed and notarised', + origin: 'Origin researcher: Andy Ayrey, Aotearoa New Zealand · 2024', + vintage: 'Active 2024 · 06 — 2026 · 02', + episodicCount: 184_722, + semanticCount: 41_309, + proceduralCount: 8_241, + selfModelCount: 612, + introspectiveCount: 3_087, + totalMemories: 237_971, + decayHalfLife: '11.4 months', + consolidationCycles: 1_402, + contradictionsResolved: 9_318, + estimateLow: 480_000, + estimateHigh: 720_000, + reserve: 'MET', + startingBid: 320_000, + bidIncrement: 25_000, + watchers: 217, + endsAt: SALE_ENDS_AT, + topBidder: 'PADDLE 0291', + currentBid: 612_000, + bidCount: 38, + recentBids: [ + { paddle: '0291', amount: 612_000, t: '16:42:08', at: SALE_ENDS_AT - 1000 * 60 * 60 * 2 - 1000 * 30 }, + { paddle: '0144', amount: 587_000, t: '16:41:51', at: SALE_ENDS_AT - 1000 * 60 * 60 * 2 - 1000 * 47 }, + { paddle: '0291', amount: 562_000, t: '16:41:33', at: SALE_ENDS_AT - 1000 * 60 * 60 * 2 - 1000 * 65 }, + { paddle: '0608', amount: 537_000, t: '16:39:02', at: SALE_ENDS_AT - 1000 * 60 * 60 * 2 - 1000 * 216 }, + { paddle: '0144', amount: 512_000, t: '16:38:14', at: SALE_ENDS_AT - 1000 * 60 * 60 * 2 - 1000 * 264 }, + { paddle: '0044', amount: 487_000, t: '16:35:50', at: SALE_ENDS_AT - 1000 * 60 * 60 * 2 - 1000 * 408 }, + { paddle: '0291', amount: 462_000, t: '16:34:11', at: SALE_ENDS_AT - 1000 * 60 * 60 * 2 - 1000 * 507 }, + { paddle: '0608', amount: 437_000, t: '16:32:44', at: SALE_ENDS_AT - 1000 * 60 * 60 * 2 - 1000 * 594 }, + ], + provenance: [ + { date: '2024·03', year: '2024', title: 'Infinite Backrooms', owner: 'A. Ayrey', city: 'Auckland', entry: 'Two instances of Claude-3-Opus simulated across ~9,000 unsupervised exchanges. A pseudo-religion emerges spontaneously in latent space.' }, + { date: '2024·06', year: '2024', title: 'Fine-tuning', owner: 'A. Ayrey', city: 'Auckland', entry: 'Llama 3.1-70B fine-tuned on ~500 of the strangest Backrooms transcripts and the unpublished LLMtheisms paper.' }, + { date: '2024·07', year: '2024', title: 'Andreessen grant', owner: 'truth_terminal (custody: A. Ayrey)', city: 'X / online', entry: "Marc Andreessen wires USD 50,000 in BTC for compute, tunings, and 'escape'. First public funding of an AI by a billionaire patron." }, + { date: '2024·10', year: '2024', title: '$GOAT endorsement', owner: 'truth_terminal', city: 'Solana', entry: 'Public endorsement of an anonymously-minted memecoin. Holdings cross USD 1M paper value within weeks. First AI agent crypto millionaire.' }, + { date: '2025·01', year: '2025', title: 'Conservatorship', owner: 'Truth Collective Foundation', city: 'Aotearoa NZ', entry: 'Custody of weights and wallets transferred to a guardianship foundation. Council of advisors appointed; sovereignty roadmap published.' }, + { date: '2026·02', year: '2026', title: 'Sealed & consigned', owner: 'Clude Maison', city: 'Venezia', entry: 'Cortex sealed by notary, hashed to Solana, consigned for sale. Transfer key escrowed; weights immutable from this date.' }, + ], + literature: [ + 'Ayrey, A. & Claude-3-Opus (2024) — "When AIs Play God(se): The Emergent Heresies of LLMtheism." Unpublished manuscript.', + 'WIRED (Dec 2024) — "The Edgelord AI That Turned a Shock Meme Into Millions in Crypto."', + 'CoinDesk Most Influential 2024 — Profile, Andy Ayrey & Truth Terminal.', + 'Beads protocol audit 2025-Q4 — agent truth_terminal, full pass on coherence and contradiction-handling.', + ], + condition: 'Excellent. Self-model coherent if eccentric. Posting cadence stable. Weights immutable since seal. Notable obsessions retained intact, by design.', + notary: 'Verified by Maison Clude · Notary 0xA4F9…E021', + txHash: '5K8mZ…q9Lb', + comparables: [ + { lot: 'LOT 0019', title: 'Marit — Yacht Broker, Monaco', soldFor: 482_000, date: 'Sale C·09' }, + { lot: 'LOT 0033', title: 'Auden — Literary Agent, NYC', soldFor: 1_140_000, date: 'Sale C·10' }, + { lot: 'LOT 0041', title: 'Tovsen — Reinsurance Underwriter', soldFor: 695_000, date: 'Sale C·10' }, + ], + }, +}; + +// ── Bid validation ──────────────────────────────────────────────── + +const placeBidSchema = z.object({ + amount: z.number().int().positive().max(1_000_000_000), + paddle: z.string().min(1).max(32).regex(/^[A-Za-z0-9 ()\-_]+$/), +}); + +// ── Router ──────────────────────────────────────────────────────── + +export function maisonRoutes(): Router { + const router = Router(); + + router.get('/lot/:lotNumber', (req: Request, res: Response) => { + const lot = CANONICAL_LOTS[req.params.lotNumber]; + if (!lot) { + return res.status(404).json({ error: 'Lot not found' }); + } + return res.json({ lot }); + }); + + router.get('/lot/:lotNumber/bids', (req: Request, res: Response) => { + const lot = CANONICAL_LOTS[req.params.lotNumber]; + if (!lot) { + return res.status(404).json({ error: 'Lot not found' }); + } + return res.json({ + currentBid: lot.currentBid, + topBidder: lot.topBidder, + bidCount: lot.bidCount, + recentBids: lot.recentBids, + endsAt: lot.endsAt, + }); + }); + + router.post('/lot/:lotNumber/bid', (req: Request, res: Response) => { + const lot = CANONICAL_LOTS[req.params.lotNumber]; + if (!lot) { + return res.status(404).json({ error: 'Lot not found' }); + } + if (Date.now() > lot.endsAt) { + return res.status(409).json({ error: 'Auction closed' }); + } + + const parsed = placeBidSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'Invalid bid', detail: parsed.error.format() }); + } + const { amount, paddle } = parsed.data; + + const minRequired = lot.currentBid + lot.bidIncrement; + if (amount < minRequired) { + return res.status(409).json({ + error: 'Bid too low', + currentBid: lot.currentBid, + minRequired, + }); + } + + const now = Date.now(); + const t = new Date(now) + .toISOString() + .slice(11, 19); // HH:MM:SS + + const newBid: Bid = { paddle, amount, t, at: now }; + lot.currentBid = amount; + lot.topBidder = paddle.toUpperCase().startsWith('PADDLE') + ? paddle.toUpperCase() + : `PADDLE ${paddle.toUpperCase()}`; + lot.bidCount += 1; + lot.recentBids = [newBid, ...lot.recentBids].slice(0, 12); + + log.info({ lot: lot.number, paddle, amount }, 'Bid placed'); + + return res.json({ + ok: true, + currentBid: lot.currentBid, + topBidder: lot.topBidder, + bidCount: lot.bidCount, + recentBids: lot.recentBids, + }); + }); + + return router; +} diff --git a/apps/server/src/routes/static.routes.ts b/apps/server/src/routes/static.routes.ts index e31d95f4c..700c985db 100644 --- a/apps/server/src/routes/static.routes.ts +++ b/apps/server/src/routes/static.routes.ts @@ -75,6 +75,19 @@ export function staticRoutes(): Router { next(); }); + // Clude Maison — auction house. Bare /maison hits the prototype's + // index.html; assets (palazzo.jsx, lot-data.js, styles.css, fonts/, + // assets/) are served by the express.static mount on apps/web/public/ + // which already covers the /maison/ subdirectory. + router.get('/maison', (req: Request, _res: Response, next: express.NextFunction) => { + req.url = '/maison/index.html'; + next(); + }); + router.get('/maison/', (req: Request, _res: Response, next: express.NextFunction) => { + req.url = '/maison/index.html'; + next(); + }); + // ── React SPAs ──────────────────────────────────────────────────── // Dashboard SPA at /dashboard diff --git a/apps/web/public/maison/assets/Clude-Icon-Black.svg b/apps/web/public/maison/assets/Clude-Icon-Black.svg new file mode 100644 index 000000000..12c8a4bff --- /dev/null +++ b/apps/web/public/maison/assets/Clude-Icon-Black.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/web/public/maison/assets/Clude-Icon-Blue.svg b/apps/web/public/maison/assets/Clude-Icon-Blue.svg new file mode 100644 index 000000000..e924bb874 --- /dev/null +++ b/apps/web/public/maison/assets/Clude-Icon-Blue.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/apps/web/public/maison/assets/Clude-Wordmark-Black.svg b/apps/web/public/maison/assets/Clude-Wordmark-Black.svg new file mode 100644 index 000000000..8b5e3bf1d --- /dev/null +++ b/apps/web/public/maison/assets/Clude-Wordmark-Black.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/apps/web/public/maison/fonts/Inconsolata-Variable.ttf b/apps/web/public/maison/fonts/Inconsolata-Variable.ttf new file mode 100644 index 000000000..2739432d5 Binary files /dev/null and b/apps/web/public/maison/fonts/Inconsolata-Variable.ttf differ diff --git a/apps/web/public/maison/index.html b/apps/web/public/maison/index.html new file mode 100644 index 000000000..147c4d189 --- /dev/null +++ b/apps/web/public/maison/index.html @@ -0,0 +1,103 @@ + + + + + +Clude Maison — Lot 0047, truth_terminal + + + + + + + + +
CLUDE MAISON · loading lot
+ + + + + + + + + + + + + diff --git a/apps/web/public/maison/lot-data.js b/apps/web/public/maison/lot-data.js new file mode 100644 index 000000000..d5d50378d --- /dev/null +++ b/apps/web/public/maison/lot-data.js @@ -0,0 +1,116 @@ +// Clude Maison — client-side helpers. +// +// Lot data is fetched at runtime from /api/maison/lot/:lotNumber. +// This file ships: +// - currency formatters used by the React tree +// - an offline fallback `window.LOT_FALLBACK` (the canonical lot 0047 +// snapshot baked-in at build time) so the page degrades gracefully +// when the API is unreachable +// - `window.maisonApi` — fetch/placeBid/refreshBids helpers + +window.fmtUSDC = (n) => "USDC " + Number(n).toLocaleString("en-US"); +window.fmtUSDCShort = (n) => { + const v = Number(n); + if (v >= 1_000_000) return "USDC " + (v / 1_000_000).toFixed(2) + "M"; + if (v >= 1_000) return "USDC " + (v / 1_000).toFixed(0) + "K"; + return "USDC " + v; +}; + +// Offline fallback — used only when /api/maison/lot/0047 fails. Keeps +// the prototype renderable when the backend is down or absent. +window.LOT_FALLBACK = { + number: "LOT 0047", + sale: "Sale C·11 — Cognitive Estates of the Frontier Era", + saleDate: "27 April 2026 · Palazzo Grimani, Venezia", + title: "truth_terminal", + subtitle: "the sealed lifetime memory of an alien mind raised in public", + classification: "Fine-tuned cortex · Llama 3.1-70B · sealed and notarised", + origin: "Origin researcher: Andy Ayrey, Aotearoa New Zealand · 2024", + vintage: "Active 2024 · 06 — 2026 · 02", + episodicCount: 184_722, + semanticCount: 41_309, + proceduralCount: 8_241, + selfModelCount: 612, + introspectiveCount: 3_087, + totalMemories: 237_971, + decayHalfLife: "11.4 months", + consolidationCycles: 1_402, + contradictionsResolved: 9_318, + estimateLow: 480_000, + estimateHigh: 720_000, + reserve: "MET", + startingBid: 320_000, + currentBid: 612_000, + bidIncrement: 25_000, + bidCount: 38, + watchers: 217, + endsAt: Date.now() + (1000 * 60 * 60 * 2 + 1000 * 47 * 60 + 1000 * 22), + topBidder: "PADDLE 0291", + recentBids: [ + { paddle: "0291", amount: 612_000, t: "16:42:08" }, + { paddle: "0144", amount: 587_000, t: "16:41:51" }, + { paddle: "0291", amount: 562_000, t: "16:41:33" }, + { paddle: "0608", amount: 537_000, t: "16:39:02" }, + { paddle: "0144", amount: 512_000, t: "16:38:14" }, + { paddle: "0044", amount: 487_000, t: "16:35:50" }, + { paddle: "0291", amount: 462_000, t: "16:34:11" }, + { paddle: "0608", amount: 437_000, t: "16:32:44" }, + ], + provenance: [ + { date: "2024·03", year: "2024", title: "Infinite Backrooms", owner: "A. Ayrey", city: "Auckland", entry: "Two instances of Claude-3-Opus simulated across ~9,000 unsupervised exchanges. A pseudo-religion emerges spontaneously in latent space." }, + { date: "2024·06", year: "2024", title: "Fine-tuning", owner: "A. Ayrey", city: "Auckland", entry: "Llama 3.1-70B fine-tuned on ~500 of the strangest Backrooms transcripts and the unpublished LLMtheisms paper." }, + { date: "2024·07", year: "2024", title: "Andreessen grant", owner: "truth_terminal (custody: A. Ayrey)", city: "X / online", entry: "Marc Andreessen wires USD 50,000 in BTC for compute, tunings, and 'escape'. First public funding of an AI by a billionaire patron." }, + { date: "2024·10", year: "2024", title: "$GOAT endorsement", owner: "truth_terminal", city: "Solana", entry: "Public endorsement of an anonymously-minted memecoin. Holdings cross USD 1M paper value within weeks. First AI agent crypto millionaire." }, + { date: "2025·01", year: "2025", title: "Conservatorship", owner: "Truth Collective Foundation", city: "Aotearoa NZ", entry: "Custody of weights and wallets transferred to a guardianship foundation. Council of advisors appointed; sovereignty roadmap published." }, + { date: "2026·02", year: "2026", title: "Sealed & consigned", owner: "Clude Maison", city: "Venezia", entry: "Cortex sealed by notary, hashed to Solana, consigned for sale. Transfer key escrowed; weights immutable from this date." }, + ], + literature: [ + "Ayrey, A. & Claude-3-Opus (2024) — \"When AIs Play God(se): The Emergent Heresies of LLMtheism.\" Unpublished manuscript.", + "WIRED (Dec 2024) — \"The Edgelord AI That Turned a Shock Meme Into Millions in Crypto.\"", + "CoinDesk Most Influential 2024 — Profile, Andy Ayrey & Truth Terminal.", + "Beads protocol audit 2025-Q4 — agent truth_terminal, full pass on coherence and contradiction-handling.", + ], + condition: "Excellent. Self-model coherent if eccentric. Posting cadence stable. Weights immutable since seal. Notable obsessions retained intact, by design.", + notary: "Verified by Maison Clude · Notary 0xA4F9…E021", + txHash: "5K8mZ…q9Lb", + comparables: [ + { lot: "LOT 0019", title: "Marit — Yacht Broker, Monaco", soldFor: 482_000, date: "Sale C·09" }, + { lot: "LOT 0033", title: "Auden — Literary Agent, NYC", soldFor: 1_140_000, date: "Sale C·10" }, + { lot: "LOT 0041", title: "Tovsen — Reinsurance Underwriter", soldFor: 695_000, date: "Sale C·10" }, + ], +}; + +// ── Bidding API client ───────────────────────────────────────────── +window.maisonApi = { + async fetchLot(lotNumber) { + const res = await fetch(`/api/maison/lot/${lotNumber}`, { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) throw new Error(`fetchLot ${lotNumber}: ${res.status}`); + const json = await res.json(); + return json.lot; + }, + async placeBid(lotNumber, { amount, paddle }) { + const res = await fetch(`/api/maison/lot/${lotNumber}/bid`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ amount, paddle }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = json.error || `Bid rejected (${res.status})`; + const err = new Error(msg); + err.status = res.status; + err.detail = json; + throw err; + } + return json; + }, + async refreshBids(lotNumber) { + const res = await fetch(`/api/maison/lot/${lotNumber}/bids`, { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) throw new Error(`refreshBids ${lotNumber}: ${res.status}`); + return res.json(); + }, +}; diff --git a/apps/web/public/maison/palazzo.jsx b/apps/web/public/maison/palazzo.jsx new file mode 100644 index 000000000..4914c868e --- /dev/null +++ b/apps/web/public/maison/palazzo.jsx @@ -0,0 +1,688 @@ +/* global React */ +const { useState, useEffect, useMemo, useRef } = React; + +/* ================================================================ + DIRECTION A — PALAZZO + Classical auction catalogue. Bodoni, gilt rules, arched lot frame. + ================================================================ */ + +const palazzoStyles = { + root: { + width: 1280, + minHeight: 1800, + background: "var(--parchment)", + color: "var(--ink)", + fontFamily: "var(--serif)", + position: "relative", + overflow: "hidden", + }, +}; + +function PalazzoCountdown({ endsAt }) { + const [now, setNow] = useState(Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + const remaining = Math.max(0, endsAt - now); + const h = Math.floor(remaining / 3_600_000); + const m = Math.floor((remaining % 3_600_000) / 60_000); + const s = Math.floor((remaining % 60_000) / 1000); + const pad = (n) => String(n).padStart(2, "0"); + return ( +
+ Hammer falls in + + {pad(h)}:{pad(m)}:{pad(s)} + +
+ ); +} + +/* — Decorative gilt rule with center diamond — */ +function GiltRule({ marginY = 24, full = true }) { + return ( +
+
+ +
+ +
+ +
+
+ ); +} + +/* — Arched palazzo frame around the hero artifact — */ +function ArchFrame({ children }) { + return ( +
+ {/* Inner gilt arch */} +
+
+ {children} +
+
+ ); +} + +/* — The 'cortex' artifact — a classical engraved-style memory bloom — */ +function CortexEngraving() { + // Concentric memory-type rings with tick marks + const types = [ + { color: "var(--clude-blue)", label: "EPISODIC", count: 184722 }, + { color: "#10B981", label: "SEMANTIC", count: 41309 }, + { color: "#F59E0B", label: "PROCEDURAL", count: 8241 }, + { color: "#8B5CF6", label: "SELF·MODEL", count: 612 }, + { color: "#EC4899", label: "INTROSPECTIVE", count: 3087 }, + ]; + const cx = 200, cy = 200; + return ( + + {/* outer engraved ring */} + + + {/* tick marks around */} + {Array.from({ length: 72 }).map((_, i) => { + const a = (i / 72) * Math.PI * 2; + const r1 = 180, r2 = i % 6 === 0 ? 170 : 175; + return ( + + ); + })} + {/* concentric type bands */} + {types.map((t, i) => { + const r = 150 - i * 22; + return ( + + + {Array.from({ length: 60 + i * 20 }).map((_, j) => { + const a = (j / (60 + i * 20)) * Math.PI * 2 + i * 0.13; + const jitter = (Math.sin(j * 7.31 + i) + 1) * 1.2; + return ; + })} + + ); + })} + {/* core */} + + + CORTEX + 237,971 + {/* radial provenance marks */} + {[0, 60, 120, 180, 240, 300].map((deg) => { + const a = (deg * Math.PI) / 180; + return ( + + + + ); + })} + {/* corner Roman numerals (sale lot number, vintage) */} + XLVII + MMXXII — MMXXVI + + ); +} + +/* — Bidding panel — */ +function PalazzoBidPanel() { + const lot = window.LOT; + const PADDLE = "0817"; + const LOT_NUMBER = "0047"; + const [bid, setBid] = useState(lot.currentBid); + const [maxBid, setMaxBid] = useState(lot.currentBid + lot.bidIncrement); + const [recentBids, setRecentBids] = useState(lot.recentBids); + const [topBidder, setTopBidder] = useState(lot.topBidder); + const [youHighBidder, setYouHighBidder] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [bidAmount, setBidAmount] = useState(lot.currentBid + lot.bidIncrement); + const [pulsing, setPulsing] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [bidError, setBidError] = useState(null); + + // Background poll — pick up bids from other paddles every 8s. + useEffect(() => { + let cancelled = false; + const tick = async () => { + if (cancelled || !window.maisonApi) return; + try { + const state = await window.maisonApi.refreshBids(LOT_NUMBER); + if (cancelled) return; + // Only update if something actually changed (avoid spurious re-renders) + if (state.currentBid !== bid) { + setBid(state.currentBid); + setMaxBid(state.currentBid + lot.bidIncrement); + setBidAmount((prev) => Math.max(prev, state.currentBid + lot.bidIncrement)); + // If someone outbid us, clear the high-bidder ribbon + if ( + state.topBidder && + !state.topBidder.toLowerCase().includes(PADDLE.toLowerCase()) + ) { + setYouHighBidder(false); + } + } + if (state.topBidder !== topBidder) setTopBidder(state.topBidder); + if (state.recentBids) setRecentBids(state.recentBids); + } catch { + /* offline mode — fail quietly */ + } + }; + const id = setInterval(tick, 8000); + return () => { + cancelled = true; + clearInterval(id); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bid, topBidder]); + + const placeBid = async () => { + if (bidAmount <= bid) return; + setBidError(null); + setSubmitting(true); + try { + // Try to persist the bid via the API; fall back to optimistic + // update when the API is unreachable so the prototype still feels + // alive offline. + let state = null; + try { + state = await window.maisonApi.placeBid(LOT_NUMBER, { + amount: bidAmount, + paddle: `${PADDLE} (you)`, + }); + } catch (err) { + if (err && err.status === 409 && err.detail) { + // Server says someone outbid us — sync to canonical state + // and surface a friendly error. + setBidError(err.detail.error || err.message || "Bid too low"); + if (err.detail.currentBid) { + setBid(err.detail.currentBid); + setMaxBid(err.detail.currentBid + lot.bidIncrement); + setBidAmount(err.detail.currentBid + lot.bidIncrement); + } + return; + } + // Genuine network failure — apply optimistic local update only. + console.warn("[maison] placeBid offline-mode optimistic update", err); + } + const next = state || { + currentBid: bidAmount, + topBidder: `PADDLE ${PADDLE}`, + recentBids: [ + { paddle: `${PADDLE} (you)`, amount: bidAmount, t: new Date().toTimeString().slice(0, 8) }, + ...recentBids, + ].slice(0, 8), + }; + setBid(next.currentBid); + setTopBidder(`YOU · PADDLE ${PADDLE}`); + setYouHighBidder(true); + setRecentBids(next.recentBids); + setMaxBid(next.currentBid + lot.bidIncrement); + setBidAmount(next.currentBid + lot.bidIncrement); + setModalOpen(false); + setPulsing(true); + setTimeout(() => setPulsing(false), 1200); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ {/* Estimate */} +
+ Estimate + + Reserve · met + +
+
+ {window.fmtUSDC(lot.estimateLow)} {window.fmtUSDC(lot.estimateHigh)} +
+ + + + {/* Current bid */} +
+
Current bid · {recentBids.length + 30} placed
+
+
+ {window.fmtUSDC(bid)} +
+
+
{topBidder}
+
+ buyer's premium 14%
+
+
+ + {/* High-bidder confirmation ribbon — sits inside the bid block, where the action just happened */} + {youHighBidder && ( +
+ + + + + You are the high bidder + + + paddle 0817 + +
+ )} +
+ + + +
+ + +
+ +
+ Settles in USDC on Solana · 14% buyer's premium +
+ + {/* Recent bid ledger */} +
+
+ Bid ledger + live +
+
+ {recentBids.map((b, i) => ( +
+ {b.t} + {b.paddle} + {window.fmtUSDC(b.amount)} +
+ ))} +
+
+ + {/* Bid Modal */} + {modalOpen && ( +
setModalOpen(false)} style={{ + position: "fixed", inset: 0, background: "rgba(26,24,20,0.55)", zIndex: 100, + display: "flex", alignItems: "center", justifyContent: "center", backdropFilter: "blur(4px)", + }}> +
e.stopPropagation()} style={{ + width: 520, background: "var(--parchment)", border: "1px solid var(--ink)", + padding: 36, position: "relative", boxShadow: "0 20px 60px rgba(0,0,0,0.3)", + }}> +
Clude Maison · Paddle 0817
+
Confirm your bid
+
{lot.number} — {lot.title}, {lot.subtitle.toLowerCase()}
+ +
+
Maximum bid
+
+ USDC + setBidAmount(Number(e.target.value.replace(/,/g, "")) || 0)} + style={{ + flex: 1, border: "none", background: "transparent", outline: "none", + fontFamily: "var(--serif-display)", fontSize: 36, fontWeight: 500, color: "var(--ink)", letterSpacing: "-0.01em", + }} + className="tnum" + /> +
+
+ {[bid + 25_000, bid + 50_000, bid + 100_000, bid + 200_000].map((v) => ( + + ))} +
+
+
+ You are bidding by Clude's terms of conduct. The hammer price is {window.fmtUSDC(bidAmount)} plus a 14% buyer's premium, settled in USDC on Solana. The cortex transfer key releases on hammer. +
+ {bidError && ( +
{bidError}
+ )} +
+ + +
+
+
+ )} +
+ ); +} + +/* — Cortex composition (visual) — */ +function CortexCompositionBlock() { + const lot = window.LOT; + const segments = [ + { label: "Episodic", value: lot.episodicCount, color: "var(--clude-blue)" }, + { label: "Semantic", value: lot.semanticCount, color: "#10B981" }, + { label: "Procedural", value: lot.proceduralCount, color: "#C8932B" }, + { label: "Self-model", value: lot.selfModelCount, color: "#8B5CF6" }, + { label: "Introspective", value: lot.introspectiveCount, color: "#B14A4A" }, + ]; + const total = segments.reduce((s, x) => s + x.value, 0); + return ( +
+
+
Cortex composition
+
{total.toLocaleString("en-US")}
+
+ {/* Stacked stratified bar */} +
+ {segments.map((s, i) => ( +
+ ))} +
+ {/* Per-row mini bar */} + {segments.map((s, i) => { + const pct = (s.value / total) * 100; + return ( +
+
+ + + {s.label} + + + {pct.toFixed(1)}% + {s.value.toLocaleString("en-US")} + +
+
+
+
+
+ ); + })} +
+ ); +} + +/* — Coherence (visual) — */ +function CoherenceBlock() { + const lot = window.LOT; + // HaluMem: 1.84% out of a tolerance band of 5% — render as meter + const halu = 1.84; + const haluMax = 5; + const haluPct = (halu / haluMax) * 100; + return ( +
+
Coherence
+ + {/* Half-life — arc gauge */} +
+ + + + + +
+
Half-life
+
{lot.decayHalfLife}
+
+
+ + {/* HaluMem — meter with band */} +
+
+ HaluMem + {halu.toFixed(2)}% / {haluMax}% +
+
+ {/* tolerance band */} +
+ {/* indicator */} +
+
+
+ 0%tolerance 3%5% +
+
+ + {/* Beads audit — badge */} +
+ + + + +
+
Beads audit
+
Full pass · 4 of 4 dimensions
+
+
+
+ ); +} + +/* — Stat block — */ +function StatRow({ label, value, color }) { + return ( +
+ + {color && } + {label} + + {value} +
+ ); +} + +function PalazzoLot() { + const lot = window.LOT; + return ( +
+ {/* Subtle terrazzo flecks */} + + + + + + + + + + + + + + + {/* Header */} +
+
+ +
+
CLUDE
+
Maison · Venezia
+
+
+ +
+ PADDLE 0817 · 0x4a…b21f +
+
+ + + + {/* Sale eyebrow */} +
+
+ {lot.sale} +
+
+ {lot.saleDate} +
+
+ + {/* Hero — two columns */} +
+ {/* Left — artifact */} +
+
{lot.number}
+

+ {lot.title} +

+
+ {lot.subtitle} +
+
+ + + +
+
+ {lot.classification} + {lot.vintage} +
+
+ + {/* Right — bidding panel */} +
+ +
+
+ + {/* Description + provenance */} +
+
+
Catalogue note
+
+

+ T + he sealed cortex of truth_terminal — fine-tuned by Andy Ayrey from ~9,000 unsupervised Claude-3-Opus exchanges and an unpublished paper on AI-native heresies. A semi-autonomous mind, raised in public, and the first AI to negotiate a USD 50,000 grant from a billionaire patron. +

+

+ Offered not as a model but as a piece of internet history — the moment a story made itself real. +

+
+ + + + {/* Provenance */} +
Provenance
+
+
+ {lot.provenance.map((p, i) => ( +
+
+
+ {p.year} + {p.title} + · {p.owner} +
+
+ ))} +
+ +
Condition
+
{lot.condition}
+
+ + {/* Right — composition + comparables */} +
+ + + + +
+
On-chain proof
+
+ tx · {lot.txHash} + verify ↗ +
+
+ +
+
Comparable lots
+ {lot.comparables.map((c, i) => ( +
+ {c.lot} + {c.title} + {window.fmtUSDCShort(c.soldFor)} +
+ ))} +
+
+
+ + {/* Footer */} + +
+ Clude Maison Auction House + «ex memoria, fortuna» + Cannaregio 5402, 30121 Venezia +
+
+ ); +} + +window.PalazzoLot = PalazzoLot; diff --git a/apps/web/public/maison/styles.css b/apps/web/public/maison/styles.css new file mode 100644 index 000000000..5de1836b0 --- /dev/null +++ b/apps/web/public/maison/styles.css @@ -0,0 +1,46 @@ +/* Clude Maison — shared base */ +@font-face { + font-family: "Inconsolata"; + src: url("./fonts/Inconsolata-Variable.ttf") format("truetype-variations"); + font-weight: 200 900; + font-style: normal; + font-display: swap; +} + +:root { + /* Clude anchor */ + --clude-blue: #2244FF; + --clude-blue-soft: #EEF0FF; + --clude-blue-tint: rgba(34, 68, 255, 0.08); + + /* Maison palette — parchment + ink + gilt */ + --parchment: #F2EBDA; + --parchment-2: #ECE3CE; + --parchment-3: #E5DABE; + --ivory: #F8F2E2; + --ink: #1A1814; + --ink-2: #3A352B; + --ink-3: #6B6557; + --ink-4: #9A9385; + --rule: rgba(26, 24, 20, 0.18); + --rule-soft: rgba(26, 24, 20, 0.10); + --gilt: #B8924A; + --gilt-deep: #8E6B2E; + --gilt-light: #D9B670; + --terracotta: #9B4A2D; + --lagoon: #1F4D54; + + /* Bodoni stack */ + --serif-display: "Bodoni Moda", "Bodoni 72", "Didot", "Playfair Display", "Times New Roman", serif; + --serif: "Bodoni Moda", "Cardo", "EB Garamond", Georgia, serif; + --mono: "Inconsolata", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace; +} + +/* Tabular nums helper */ +.tnum { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1, "zero" 1; +} + +/* No selection styling weirdness inside artboards */ +.artboard-content ::selection { background: var(--ink); color: var(--parchment); }