diff --git a/apps/daemon/src/media-models.ts b/apps/daemon/src/media-models.ts index e3a7f0afb..9fff99789 100644 --- a/apps/daemon/src/media-models.ts +++ b/apps/daemon/src/media-models.ts @@ -37,7 +37,7 @@ export const MEDIA_PROVIDERS: MediaProvider[] = [ { id: 'hyperframes', label: 'HyperFrames', hint: 'Local HTML -> MP4 renderer', integrated: true, credentialsRequired: false, settingsVisible: false }, { id: 'nanobanana', label: 'Nano Banana', hint: 'Google official by default; custom gateway configurable', integrated: true, defaultBaseUrl: 'https://generativelanguage.googleapis.com', supportsCustomModel: true }, { id: 'imagerouter', label: 'ImageRouter', hint: 'OpenAI-compatible image + video routing', integrated: true, defaultBaseUrl: 'https://api.imagerouter.io/v1/openai', docsUrl: 'https://docs.imagerouter.io/api-reference/image-generation/', supportsCustomModel: true, customModelPlaceholder: 'openai/gpt-image-2 or xAI/grok-imagine-video' }, - { id: 'custom-image', label: 'Custom Image API', hint: 'OpenAI-compatible /v1/images/generations (local or cloud)', integrated: true, docsUrl: 'https://platform.openai.com/docs/api-reference/images', supportsCustomModel: true, customModelPlaceholder: 'my-image-model' }, + { id: 'custom-image', label: 'Custom Image API', hint: 'OpenAI-compatible images/generations + images/edits (local or cloud)', integrated: true, docsUrl: 'https://platform.openai.com/docs/api-reference/images', supportsCustomModel: true, customModelPlaceholder: 'my-image-model' }, { id: 'comfyui', label: 'ComfyUI', hint: 'Local JSON workflow server (planned adapter)', integrated: false, defaultBaseUrl: 'http://127.0.0.1:8188', docsUrl: 'https://docs.comfy.org/development/core-concepts/workflow' }, { id: 'bfl', label: 'Black Forest Labs', hint: 'FLUX 1.1 Pro / FLUX Pro / Dev', integrated: false, defaultBaseUrl: 'https://api.bfl.ai' }, { id: 'fal', label: 'Fal.ai', hint: 'Sora / Seedance / Veo / FLUX', integrated: false, defaultBaseUrl: 'https://fal.run' }, @@ -93,7 +93,7 @@ export const IMAGE_MODELS: MediaModel[] = [ { id: 'openai/gpt-image-1.5', label: 'openai/gpt-image-1.5', hint: 'ImageRouter · routed GPT Image', provider: 'imagerouter', caps: ['t2i'] }, { id: 'black-forest-labs/FLUX-1.1-pro', label: 'FLUX-1.1-pro', hint: 'ImageRouter · Black Forest Labs', provider: 'imagerouter', caps: ['t2i'] }, - { id: 'custom-image', label: 'custom-image', hint: 'Custom · OpenAI-compatible endpoint', provider: 'custom-image', caps: ['t2i'] }, + { id: 'custom-image', label: 'custom-image', hint: 'Custom · OpenAI-compatible endpoint', provider: 'custom-image', caps: ['t2i', 'i2i'] }, { id: 'flux-1.1-pro', label: 'flux-1.1-pro', hint: 'BFL · flagship', provider: 'bfl', caps: ['t2i', 'i2i'] }, { id: 'flux-pro', label: 'flux-pro', hint: 'BFL', provider: 'bfl', caps: ['t2i'] }, diff --git a/apps/daemon/src/media.ts b/apps/daemon/src/media.ts index be0f72743..10a297d6b 100644 --- a/apps/daemon/src/media.ts +++ b/apps/daemon/src/media.ts @@ -30,7 +30,8 @@ // * provider 'imagerouter'→ ImageRouter OpenAI-compatible image/video // generation endpoints // * provider 'custom-image'→ user-supplied OpenAI-compatible -// /v1/images/generations endpoint +// /v1/images/generations + /v1/images/edits +// endpoints // // The fallback stub handlers are gated behind OD_MEDIA_ALLOW_STUBS=1; in // release builds they throw StubProviderDisabledError (mapped to HTTP @@ -866,7 +867,7 @@ async function renderCustomOpenAIImage(ctx: MediaContext, credentials: ProviderC const baseUrl = (credentials.baseUrl || '').trim(); if (!baseUrl) { throw new Error( - 'Custom Image API base URL required — configure a /v1/images/generations compatible endpoint in Settings', + 'Custom Image API base URL required — configure an OpenAI-compatible /v1/images/generations or /v1/images/edits endpoint in Settings', ); } const wireModel = ( @@ -891,8 +892,14 @@ async function renderCustomOpenAIImage(ctx: MediaContext, credentials: ProviderC n: 1, size: openaiSizeFor('gpt-image-1', ctx.aspect), }; + let url = buildOpenAIImageUrl(baseUrl, false); + if (ctx.imageRef?.dataUrl) { + body.response_format = 'b64_json'; + body.images = [{ image_url: ctx.imageRef.dataUrl }]; + url = buildOpenAIImageEditUrl(baseUrl); + } - const resp = await fetch(buildOpenAIImageUrl(baseUrl, false), withMediaRequestInit(ctx, { + const resp = await fetch(url, withMediaRequestInit(ctx, { method: 'POST', headers, body: JSON.stringify(body), @@ -988,19 +995,34 @@ function detectAzureEndpoint(baseUrl: string): boolean { * appending the default api-version for Azure when the user didn't * specify one. Returns a string ready for `fetch`. */ +function normalizeOpenAICompatiblePath(pathname: string, endpoint: 'images' | 'videos', mode: 'generations' | 'edits'): string { + const strippedPath = pathname.replace(/\/+$/, ''); + const generationsSuffix = `/${endpoint}/generations`; + const editsSuffix = endpoint === 'images' ? '/images/edits' : null; + if (strippedPath.endsWith(generationsSuffix)) { + if (mode === 'generations') return strippedPath; + return endpoint === 'images' + ? `${strippedPath.slice(0, -generationsSuffix.length)}${editsSuffix}` + : strippedPath; + } + if (editsSuffix && strippedPath.endsWith(editsSuffix)) { + if (mode === 'edits') return strippedPath; + return `${strippedPath.slice(0, -editsSuffix.length)}${generationsSuffix}`; + } + return mode === 'edits' && editsSuffix + ? `${strippedPath}${editsSuffix}` + : `${strippedPath}${generationsSuffix}`; +} + function buildOpenAICompatibleGenerationUrl(baseUrl: string, endpoint: 'images' | 'videos'): string { - const suffix = `/${endpoint}/generations`; let parsed; try { parsed = new URL(baseUrl); } catch { const stripped = baseUrl.replace(/\/$/, ''); - return stripped.endsWith(suffix) ? stripped : `${stripped}${suffix}`; - } - const strippedPath = parsed.pathname.replace(/\/+$/, ''); - if (!strippedPath.endsWith(suffix)) { - parsed.pathname = `${strippedPath}${suffix}`; + return normalizeOpenAICompatiblePath(stripped, endpoint, 'generations'); } + parsed.pathname = normalizeOpenAICompatiblePath(parsed.pathname, endpoint, 'generations'); return parsed.toString(); } @@ -1019,6 +1041,18 @@ function buildOpenAIImageUrl(baseUrl: string, isAzure: boolean): string { return parsed.toString(); } +function buildOpenAIImageEditUrl(baseUrl: string): string { + let parsed; + try { + parsed = new URL(baseUrl); + } catch { + const stripped = baseUrl.replace(/\/$/, ''); + return normalizeOpenAICompatiblePath(stripped, 'images', 'edits'); + } + parsed.pathname = normalizeOpenAICompatiblePath(parsed.pathname, 'images', 'edits'); + return parsed.toString(); +} + function buildOpenAIVideoUrl(baseUrl: string): string { return buildOpenAICompatibleGenerationUrl(baseUrl, 'videos'); } diff --git a/apps/daemon/src/runtimes/defs/grok-build.ts b/apps/daemon/src/runtimes/defs/grok-build.ts index 404cbf7f9..bc8a3576e 100644 --- a/apps/daemon/src/runtimes/defs/grok-build.ts +++ b/apps/daemon/src/runtimes/defs/grok-build.ts @@ -49,11 +49,10 @@ export const grokBuildAgentDef = { label: 'grok-4.20-multi-agent (xAI · orchestration)', }, ], - // Prompt delivered via stdin so Windows `spawn ENAMETOOLONG` and Linux - // `spawn E2BIG` can't truncate large composed prompts. `grok -p` with - // no positional argument reads from piped stdin. - buildArgs: (_prompt, _imagePaths, _extra = [], options = {}) => { - const args = ['-p']; + // Grok Build CLI v0.1.212 enforces `-p, --single ` as value- + // required — stdin piping no longer satisfies it. Inline the prompt. + buildArgs: (prompt, _imagePaths, _extra = [], options = {}) => { + const args = ['-p', prompt]; if (options.model && options.model !== DEFAULT_MODEL_OPTION.id) { args.push('--model', options.model); } @@ -69,7 +68,21 @@ export const grokBuildAgentDef = { { id: 'xhigh', label: 'xhigh' }, { id: 'max', label: 'max' }, ], - promptViaStdin: true, + promptViaStdin: false, + // Guard against prompts that would blow Windows' ~32 KB CreateProcess + // limit (or Linux MAX_ARG_STRLEN on extreme edges) before spawn. Same + // shape as the DeepSeek adapter — the previous stdin path is gone (CLI + // 0.1.212 enforces `-p `), so the composed prompt now rides + // argv and a sufficiently large one — system text + history + skills/ + // design-system content + user message — could surface as a generic + // spawn ENAMETOOLONG / E2BIG instead of a Grok-specific, user- + // actionable message. The /api/chat spawn path checks this byte + // budget against the composed prompt and emits AGENT_PROMPT_TOO_LARGE + // ("reduce skills/design-system context, or pick an adapter with + // stdin support") before calling `spawn`. 30_000 bytes leaves ~2.7 KB + // of argv headroom under the Windows command-line limit for `-p + // --model --effort ` and internal quoting. + maxPromptArgBytes: 30_000, streamFormat: 'plain', installUrl: 'https://x.ai/cli', docsUrl: 'https://x.ai/cli', diff --git a/apps/daemon/src/runtimes/prompt-budget.ts b/apps/daemon/src/runtimes/prompt-budget.ts index ed8fa333e..7dfc6ded4 100644 --- a/apps/daemon/src/runtimes/prompt-budget.ts +++ b/apps/daemon/src/runtimes/prompt-budget.ts @@ -10,6 +10,12 @@ function promptArgvBudgetMessage( 'Reduce the selected skills/design-system context or conversation length, or use DeepSeek through an API/provider model connection for large contexts. Pick a stdin-capable adapter when the prompt must include large local context.' ); } + if (def.id === 'grok-build') { + return ( + `${def.name} requires the prompt as the value of -p / --single (xAI CLI 0.1.212+ no longer reads piped stdin), and this run's composed prompt exceeds the safe size (${bytes} > ${def.maxPromptArgBytes} bytes). ` + + 'Reduce the selected skills/design-system context or conversation length, or pick an adapter with stdin support (e.g. claude, codex, hermes) when the prompt must include large local context.' + ); + } return ( `${def.name} requires the prompt as a command-line argument and this run's composed prompt exceeds the safe size (${bytes} > ${def.maxPromptArgBytes} bytes). ` + 'Reduce the selected skills/design-system context, shorten the conversation, or pick an adapter with stdin support.' diff --git a/apps/daemon/tests/media-openai-compatible-providers.test.ts b/apps/daemon/tests/media-openai-compatible-providers.test.ts index a3217be9d..57ec6f688 100644 --- a/apps/daemon/tests/media-openai-compatible-providers.test.ts +++ b/apps/daemon/tests/media-openai-compatible-providers.test.ts @@ -199,6 +199,113 @@ describe('OpenAI-compatible media providers', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('rewrites custom-image text-only requests back to /v1/images/generations when configured with an edits URL', async () => { + await writeConfig({ + providers: { + 'custom-image': { + apiKey: 'proxy-test-key', + baseUrl: 'https://proxy.example.test/v1/images/edits', + model: 'acme-image-model', + }, + }, + }); + + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => { + expect(String(input)).toBe('https://proxy.example.test/v1/images/generations'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toMatchObject({ + authorization: 'Bearer proxy-test-key', + 'content-type': 'application/json', + }); + expect(JSON.parse(String(init?.body))).toEqual({ + prompt: 'A matte product shot on a neutral backdrop', + model: 'acme-image-model', + n: 1, + size: '1024x1024', + }); + return new Response(JSON.stringify({ + data: [{ b64_json: PNG_BASE64 }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await generateMedia({ + projectRoot, + projectsRoot, + projectId: 'project-1', + surface: 'image', + model: 'custom-image', + prompt: 'A matte product shot on a neutral backdrop', + output: 'custom-from-edits-base.png', + }); + + expect(result.providerId).toBe('custom-image'); + expect(result.providerNote).toContain('custom-image/acme-image-model'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('routes custom-image reference-image requests through /v1/images/edits', async () => { + await writeConfig({ + providers: { + 'custom-image': { + apiKey: 'proxy-test-key', + baseUrl: 'https://proxy.example.test/v1', + model: 'acme-image-edit-model', + }, + }, + }); + const projectDir = path.join(projectsRoot, 'project-1'); + await mkdir(projectDir, { recursive: true }); + await writeFile( + path.join(projectDir, 'reference.png'), + Buffer.from(PNG_BASE64, 'base64'), + ); + + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => { + expect(String(input)).toBe('https://proxy.example.test/v1/images/edits'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toMatchObject({ + authorization: 'Bearer proxy-test-key', + 'content-type': 'application/json', + }); + const body = JSON.parse(String(init?.body)); + expect(body.prompt).toBe('Turn this reference into a blueprint-style UI illustration'); + expect(body.model).toBe('acme-image-edit-model'); + expect(body.n).toBe(1); + expect(body.size).toBe('1024x1024'); + expect(body.response_format).toBe('b64_json'); + expect(body.images).toHaveLength(1); + expect(body.images[0]?.image_url).toMatch(/^data:image\/png;base64,/); + return new Response(JSON.stringify({ + data: [{ b64_json: PNG_BASE64 }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await generateMedia({ + projectRoot, + projectsRoot, + projectId: 'project-1', + surface: 'image', + model: 'custom-image', + prompt: 'Turn this reference into a blueprint-style UI illustration', + image: 'reference.png', + output: 'edited.png', + }); + + expect(result.providerId).toBe('custom-image'); + expect(result.providerNote).toContain('custom-image/acme-image-edit-model'); + expect(fetchMock).toHaveBeenCalledTimes(1); + const bytes = await readFile(path.join(projectDir, 'edited.png')); + expect(bytes.length).toBeGreaterThan(0); + }); + it('renders ImageRouter images through the OpenAI-compatible JSON endpoint', async () => { process.env.OD_IMAGEROUTER_API_KEY = 'ir-test-key'; diff --git a/apps/daemon/tests/runtimes/helpers/test-helpers.ts b/apps/daemon/tests/runtimes/helpers/test-helpers.ts index 3c682a1ab..b9852a6db 100644 --- a/apps/daemon/tests/runtimes/helpers/test-helpers.ts +++ b/apps/daemon/tests/runtimes/helpers/test-helpers.ts @@ -86,6 +86,7 @@ export const gemini = requireAgent('gemini'); export const qoder = requireAgent('qoder'); export const qwen = requireAgent('qwen'); export const opencode = requireAgent('opencode'); +export const grokBuild = requireAgent('grok-build'); export const aider = requireAgent('aider'); export const antigravity = requireAgent('antigravity'); export const deepseekMaxPromptArgBytes = (() => { @@ -95,6 +96,13 @@ export const deepseekMaxPromptArgBytes = (() => { ); return deepseek.maxPromptArgBytes; })(); +export const grokBuildMaxPromptArgBytes = (() => { + assert.ok( + grokBuild.maxPromptArgBytes !== undefined, + 'grok-build must define maxPromptArgBytes for argv budget tests', + ); + return grokBuild.maxPromptArgBytes; +})(); const originalDisablePlugins = process.env.OD_CODEX_DISABLE_PLUGINS; const originalPath = process.env.PATH; const originalHome = process.env.HOME; diff --git a/apps/daemon/tests/runtimes/prompt-budget.test.ts b/apps/daemon/tests/runtimes/prompt-budget.test.ts index 26404d695..f4101bb0e 100644 --- a/apps/daemon/tests/runtimes/prompt-budget.test.ts +++ b/apps/daemon/tests/runtimes/prompt-budget.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import { - assert, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, claude, deepseek, deepseekMaxPromptArgBytes, vibe, + assert, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, claude, deepseek, deepseekMaxPromptArgBytes, grokBuild, grokBuildMaxPromptArgBytes, vibe, } from './helpers/test-helpers.js'; import type { TestAgentDef } from './helpers/test-helpers.js'; @@ -107,6 +107,64 @@ test('checkPromptArgvBudget gives DeepSeek-specific guidance for large contexts' assert.match(flagged.message, /stdin-capable adapter/); }); +// Grok Build CLI 0.1.212+ enforces `-p, --single ` as value- +// required, so the prompt rides argv just like DeepSeek. Pin the budget +// field and the byte-vs-codepoint guard so a future runtime-def edit +// can't silently drop the guard or let it drift over the Windows +// CreateProcess limit. +test('grok-build declares a conservative argv-byte budget for the prompt', () => { + assert.equal( + typeof grokBuildMaxPromptArgBytes, + 'number', + 'grok-build must set maxPromptArgBytes so the spawn path can pre-flight oversized prompts before hitting CreateProcess / E2BIG', + ); + assert.ok( + grokBuildMaxPromptArgBytes > 0 && grokBuildMaxPromptArgBytes < 32_768, + `grokBuildMaxPromptArgBytes must stay strictly under the Windows CreateProcess limit (~32 KB); got ${grokBuildMaxPromptArgBytes}`, + ); +}); + +test('checkPromptArgvBudget flags oversized Grok Build prompts and lets short prompts through', () => { + const oversized = 'x'.repeat(grokBuildMaxPromptArgBytes + 1); + const flagged = checkPromptArgvBudget(grokBuild, oversized); + assert.ok(flagged, 'oversized prompts must trip the argv-byte guard'); + assert.equal(flagged.code, 'AGENT_PROMPT_TOO_LARGE'); + assert.equal(flagged.limit, grokBuildMaxPromptArgBytes); + assert.equal(flagged.bytes, grokBuildMaxPromptArgBytes + 1); + assert.match(flagged.message, /Grok Build/); + assert.match(flagged.message, /-p \/ --single/); + assert.match(flagged.message, /stdin/); + + // Happy path: chat must keep working for normal-sized prompts. + assert.equal(checkPromptArgvBudget(grokBuild, 'hello'), null); + + // Exact-budget edge: at-limit prompts pass; guard fires only on strict + // overrun. + const atLimit = 'x'.repeat(grokBuildMaxPromptArgBytes); + assert.equal(checkPromptArgvBudget(grokBuild, atLimit), null); + + // Multi-byte UTF-8 (CJK = 3 bytes) must be byte-counted, not code- + // point-counted — mirrors the DeepSeek byte-count regression guard. + const cjkOversized = '汉'.repeat( + Math.ceil(grokBuildMaxPromptArgBytes / 3) + 1, + ); + const cjkFlagged = checkPromptArgvBudget(grokBuild, cjkOversized); + assert.ok(cjkFlagged, 'byte-counted UTF-8 prompts must also trip the guard'); + assert.equal(cjkFlagged.code, 'AGENT_PROMPT_TOO_LARGE'); +}); + +test('checkPromptArgvBudget gives Grok-Build-specific guidance for large contexts', () => { + const oversized = 'x'.repeat(grokBuildMaxPromptArgBytes + 1); + const flagged = checkPromptArgvBudget(grokBuild, oversized); + + assert.ok(flagged, 'oversized Grok Build prompts must return a diagnostic'); + assert.match(flagged.message, /Grok Build/); + assert.match(flagged.message, /-p \/ --single/); + assert.match(flagged.message, /xAI CLI 0\.1\.212\+/); + assert.match(flagged.message, /no longer reads piped stdin/); + assert.match(flagged.message, /stdin support/); +}); + // Adapters that ship the prompt over stdin (every other code agent // today) don't declare `maxPromptArgBytes` and must skip the guard // entirely — applying it to them would refuse perfectly valid huge diff --git a/apps/landing-page/public/community/index.html b/apps/landing-page/public/community/index.html index d9be74c39..27b94a57b 100644 --- a/apps/landing-page/public/community/index.html +++ b/apps/landing-page/public/community/index.html @@ -58,15 +58,20 @@ .nav{position:sticky;top:0;z-index:50;background:rgba(239,231,210,.86);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1px solid var(--line-soft)} .nav-inner{display:flex;align-items:center;justify-content:space-between;height:64px} .brand{display:flex;align-items:center;gap:10px;font:600 14px/1 var(--sans);letter-spacing:-.01em} - .brand-mark{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center} - .brand-mark img{width:100%;height:100%;display:block;object-fit:contain;border-radius:5px} - .brand .sep{color:var(--ink-faint);margin:0 6px;font-weight:400} - .brand .crumb{color:var(--ink-mute);font-weight:500} - .nav-links{display:flex;gap:28px;align-items:center;font:500 13.5px/1 var(--sans)} + .brand-mark{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center} + .brand-mark img{width:100%;height:100%;display:block;object-fit:contain;border-radius:6px} + .nav-links{display:flex;gap:18px;align-items:center;font:500 13.5px/1 var(--sans)} .nav-links a{color:var(--ink-soft);transition:color .15s} .nav-links a:hover{color:var(--coral)} .nav-links .pill{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:999px;background:var(--ink);color:var(--bone)} .nav-links .pill:hover{background:var(--coral);color:var(--bone)} + /* Icon-only chrome buttons mirror the main landing-page nav: surface + GitHub + X alongside the prominent Discord pill without burning a + text-nav slot. Pattern lifted from PR #3230. */ + .nav-links .nav-icon{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:1px solid rgba(21,20,15,.18);background:transparent;color:var(--ink);transition:background .15s,border-color .15s,color .15s;flex-shrink:0} + .nav-links .nav-icon:hover{background:var(--ink);border-color:var(--ink);color:var(--paper)} + .nav-links .nav-icon svg{display:block} + .nav-sep{width:1px;height:20px;background:var(--line);display:inline-block} /* --------- hero ---------- */ .hero{position:relative;padding:90px 0 110px;overflow:hidden} @@ -74,7 +79,8 @@ .hero-copy .kicker{margin-bottom:28px} .hero h1{font-size:clamp(56px, 7.2vw, 104px);margin:14px 0 32px} .hero .lead{font-size:21px;max-width:46ch;margin-bottom:40px} - .hero-cta{display:flex;gap:14px;flex-wrap:wrap;align-items:center} + .hero-cta{display:flex;gap:12px;flex-wrap:wrap;align-items:center} + .hero-cta .btn{padding:13px 20px;font-size:13.5px;white-space:nowrap} .hero-meta{margin-top:54px;display:flex;gap:48px;border-top:1px solid var(--line-soft);padding-top:28px} .hero-meta .item{display:flex;flex-direction:column;gap:4px} .hero-meta .item .v{font:500 28px/1 var(--mono);font-variant-numeric:tabular-nums;letter-spacing:-.02em;color:var(--ink)} @@ -224,9 +230,49 @@ .amb-side .amb-apply:hover{transform:translateY(-2px)} .amb-side p{font:400 15px/1.55 var(--body);color:var(--ink-mute);max-width:38ch;margin:0} + /* --------- showcase / plugin-everything ---------- */ + .showcase{background:linear-gradient(180deg, var(--bone) 0%, var(--paper) 100%);position:relative;overflow:hidden} + .showcase::before{content:"";position:absolute;left:-220px;top:120px;width:520px;height:520px;border-radius:50%;background:radial-gradient(circle, rgba(233,185,74,.18) 0%, transparent 70%);pointer-events:none} + .showcase::after{content:"";position:absolute;right:-180px;bottom:-100px;width:480px;height:480px;border-radius:50%;background:radial-gradient(circle, rgba(237,111,92,.16) 0%, transparent 70%);pointer-events:none} + .showcase .wrap{position:relative;z-index:1} + .showcase .section-head h2{max-width:24ch} + .showcase-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:64px;align-items:stretch} + .showcase-tenets{display:flex;flex-direction:column;gap:36px} + .showcase-tenet{display:grid;grid-template-columns:46px 1fr;gap:20px;align-items:start} + .showcase-tenet .ord{font:500 14px/1 var(--mono);letter-spacing:.18em;color:var(--coral);padding-top:6px} + .showcase-tenet h3{font:500 26px/1.15 var(--serif);letter-spacing:-.005em;margin-bottom:10px;color:var(--ink)} + .showcase-tenet h3 em{color:var(--coral);font-style:italic} + .showcase-tenet p{font:400 15.5px/1.6 var(--body);color:var(--ink-mute);max-width:46ch} + .contrib-card{background:var(--bone);border:1px solid var(--line);border-radius:18px;padding:36px 34px 32px;display:flex;flex-direction:column;justify-content:space-between;gap:24px;box-shadow:var(--shadow-card);position:relative;overflow:hidden} + .contrib-card .pane-kicker{font:500 11.5px/1 var(--mono);letter-spacing:.22em;text-transform:uppercase;margin-bottom:14px;display:flex;align-items:center;gap:10px} + .contrib-card .pane-kicker .dot{display:inline-block;width:6px;height:6px;border-radius:50%} + .contrib-card h3{font:500 28px/1.15 var(--serif);letter-spacing:-.005em;color:var(--ink)} + .contrib-card h3 em{color:var(--coral);font-style:italic} + .contrib-card .pane-lede{font:400 14.5px/1.55 var(--body);color:var(--ink-mute);margin-top:8px;max-width:42ch} + .contrib-card::before{content:"";position:absolute;left:-60px;bottom:-60px;width:200px;height:200px;border-radius:50%;background:radial-gradient(circle, rgba(237,111,92,.14) 0%, transparent 70%);pointer-events:none} + .contrib-card > *{position:relative;z-index:1} + .contrib-card .pane-kicker{color:var(--coral)} + .contrib-card .pane-kicker .dot{background:var(--coral)} + .contrib-steps{display:flex;flex-direction:column;gap:14px;margin:4px 0 0;padding:22px 0 4px;border-top:1px solid var(--line-soft)} + .contrib-step{display:grid;grid-template-columns:28px 1fr;gap:14px;align-items:start} + .contrib-step .n{font:500 11.5px/1 var(--mono);color:var(--coral);letter-spacing:.16em;padding-top:5px} + .contrib-step h4{font:500 15.5px/1.3 var(--sans);color:var(--ink);margin-bottom:4px;letter-spacing:-.005em} + .contrib-step p{font:400 13.5px/1.5 var(--body);color:var(--ink-mute)} + .contrib-step code{font:500 12.5px/1.4 var(--mono);background:var(--paper);border:1px solid var(--line-soft);border-radius:5px;padding:2px 7px;color:var(--ink);letter-spacing:-.005em} + .contrib-install{display:grid;grid-template-columns:1fr auto;gap:0;border:1px solid var(--ink);border-radius:10px;overflow:hidden;background:var(--ink);color:var(--paper);font:500 13px/1.4 var(--mono);letter-spacing:-.005em} + .contrib-install .cmd{padding:14px 16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--paper);user-select:all} + .contrib-install .cmd::before{content:"$ ";color:var(--coral);user-select:none} + .contrib-install button{appearance:none;border:0;border-left:1px solid rgba(247,241,222,.16);background:transparent;color:var(--paper);padding:0 18px;font:500 11.5px/1 var(--mono);letter-spacing:.16em;text-transform:uppercase;cursor:pointer;transition:background .15s,color .15s;min-width:90px} + .contrib-install button:hover{background:var(--coral);color:var(--ink)} + .contrib-install button.is-copied{background:var(--olive);color:var(--bone)} + .contrib-tail{font:400 12.5px/1.55 var(--body);color:var(--ink-faint)} + .contrib-tail a{color:var(--coral);border-bottom:1px solid transparent;transition:border-color .15s} + .contrib-tail a:hover{border-color:var(--coral)} + /* --------- discord cta ---------- */ .discord{padding:120px 0} - .discord-card{background:var(--coral);color:var(--ink);border-radius:24px;padding:88px 72px;display:grid;grid-template-columns:1.4fr .8fr;gap:64px;align-items:center;position:relative;overflow:hidden;box-shadow:var(--shadow)} + .discord .wrap{max-width:1440px;padding:0 32px} + .discord-card{background:var(--coral);color:var(--ink);border-radius:24px;padding:72px 64px;display:grid;grid-template-columns:1fr 1.05fr;gap:56px;align-items:center;position:relative;overflow:hidden;box-shadow:var(--shadow)} .discord-card::after{content:"";position:absolute;top:-80px;right:-100px;width:340px;height:340px;border-radius:50%;background:radial-gradient(circle, rgba(247,241,222,.32) 0%, transparent 70%);pointer-events:none} .discord-card .kicker{color:var(--ink-soft)} .discord-card .kicker .dot{background:var(--ink)} @@ -237,25 +283,41 @@ .discord-card .btn-primary:hover{background:var(--bone);color:var(--ink)} .discord-card .btn-ghost{border-color:var(--ink);color:var(--ink)} .discord-card .btn-ghost:hover{background:var(--ink);color:var(--coral)} - .discord-side{position:relative;z-index:2} - .discord-side .pop{font:500 12px/1 var(--mono);letter-spacing:.22em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:22px} + .discord-side{position:relative;z-index:2;display:flex;flex-direction:column;gap:18px} .discord-side .stack{background:var(--ink);color:var(--bone);border-radius:14px;padding:22px} .discord-side .stack .row-d{display:flex;align-items:center;gap:14px;padding:8px 0;font:500 13.5px/1 var(--sans)} .discord-side .stack .row-d .dot-g{width:8px;height:8px;border-radius:50%;background:var(--coral)} .discord-side .stack .row-d .h{font:400 11px/1 var(--mono);color:var(--ink-faint);margin-left:auto;text-transform:uppercase;letter-spacing:.12em} + .mod-row{display:grid;grid-template-columns:1fr 1fr;gap:14px} + .moderator-card{background:var(--ink);color:var(--bone);border-radius:14px;padding:22px 20px 20px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center} + .moderator-card .mod-avatar{width:64px;height:64px;border-radius:50%;overflow:hidden;border:2px solid var(--coral);background:var(--paper-dark);flex-shrink:0} + .moderator-card .mod-avatar img{width:100%;height:100%;object-fit:cover} + .moderator-card .mod-role{font:600 10px/1 var(--mono);letter-spacing:.22em;text-transform:uppercase;color:var(--coral)} + .moderator-card .mod-name{font:500 22px/1.1 var(--serif);letter-spacing:-.005em;color:var(--bone);margin:0} + .moderator-card .mod-bio{font:400 12.5px/1.55 var(--body);color:rgba(247,241,222,.78);margin:0} @keyframes pulse{0%,100%{opacity:1}50%{opacity:.55}} /* --------- footer ---------- */ - .foot{padding:56px 0 64px;border-top:1px solid var(--line-soft);font:400 13px/1.5 var(--body);color:var(--ink-mute)} - .foot-inner{display:flex;justify-content:space-between;flex-wrap:wrap;gap:24px} + .foot{padding:72px 0 56px;border-top:1px solid var(--line-soft);font:400 13px/1.5 var(--body);color:var(--ink-mute)} .foot a:hover{color:var(--coral)} - .foot .l{display:flex;gap:24px} + .foot-cols{display:grid;grid-template-columns:1.6fr repeat(3, 1fr);gap:48px;margin-bottom:48px} + .foot-brand{display:flex;flex-direction:column;gap:16px} + .foot-brand .brand{font:600 14px/1 var(--sans);letter-spacing:-.01em;color:var(--ink);display:flex;align-items:center;gap:10px} + .foot-brand .brand-mark{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center} + .foot-brand .brand-mark img{width:100%;height:100%;display:block;object-fit:contain;border-radius:5px} + .foot-summary{font:400 13px/1.55 var(--body);color:var(--ink-mute);max-width:36ch} + .foot-col h5{font:600 11.5px/1 var(--mono);letter-spacing:.22em;text-transform:uppercase;color:var(--ink);margin-bottom:18px} + .foot-col ul{list-style:none;display:flex;flex-direction:column;gap:10px} + .foot-col a{color:var(--ink-mute);transition:color .15s} + .foot-bottom{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:16px;padding-top:28px;border-top:1px solid var(--line-soft);font-size:12.5px} + .foot-bottom .l{display:flex;gap:18px;flex-wrap:wrap} /* --------- responsive softening (desktop-first per brief) ---------- */ @media (max-width:1100px){ .wrap{padding:0 32px} .hero-grid,.signal-grid,.discord-card{grid-template-columns:1fr;gap:48px} + .discord .wrap{padding:0 24px} .steps,.maintainers-grid{grid-template-columns:repeat(2,1fr);row-gap:56px} .step:nth-child(2){border-right:0} .section-head{grid-template-columns:1fr} @@ -265,6 +327,10 @@ .amb-col:last-child{border-bottom:0} .amb-more-grid{grid-template-columns:1fr;gap:32px} .amb-side{align-items:flex-start;text-align:left} + .showcase-grid{grid-template-columns:1fr;gap:48px} + .foot-cols{grid-template-columns:1fr 1fr;gap:36px} + .foot-brand{grid-column:1 / -1} + .nav-links a:not(.pill):not(.nav-icon),.nav-sep{display:none} } @media (max-width:640px){ .wrap{padding:0 20px} @@ -280,6 +346,9 @@ .leaderboard-head,.row{grid-template-columns:32px 1fr auto} .leaderboard-head span:nth-child(3),.leaderboard-head span:nth-child(4),.row .v:not(.coral),.row .arr{display:none} .amb-col{padding:32px 24px 36px} + .foot-cols{grid-template-columns:1fr;gap:32px} + .foot-bottom{flex-direction:column;align-items:flex-start;gap:12px} + .mod-row{grid-template-columns:1fr} } /* loading skeletons */ @@ -293,14 +362,20 @@