Merge remote-tracking branch 'origin/main' into release/v0.9.0

This commit is contained in:
lefarcen 2026-05-29 18:15:17 +08:00
commit dacd2be814
65 changed files with 2518 additions and 366 deletions

View file

@ -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: '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: '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: '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: '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: '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' }, { 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: '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: '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-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'] }, { id: 'flux-pro', label: 'flux-pro', hint: 'BFL', provider: 'bfl', caps: ['t2i'] },

View file

@ -30,7 +30,8 @@
// * provider 'imagerouter'→ ImageRouter OpenAI-compatible image/video // * provider 'imagerouter'→ ImageRouter OpenAI-compatible image/video
// generation endpoints // generation endpoints
// * provider 'custom-image'→ user-supplied OpenAI-compatible // * 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 // The fallback stub handlers are gated behind OD_MEDIA_ALLOW_STUBS=1; in
// release builds they throw StubProviderDisabledError (mapped to HTTP // release builds they throw StubProviderDisabledError (mapped to HTTP
@ -866,7 +867,7 @@ async function renderCustomOpenAIImage(ctx: MediaContext, credentials: ProviderC
const baseUrl = (credentials.baseUrl || '').trim(); const baseUrl = (credentials.baseUrl || '').trim();
if (!baseUrl) { if (!baseUrl) {
throw new Error( 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 = ( const wireModel = (
@ -891,8 +892,14 @@ async function renderCustomOpenAIImage(ctx: MediaContext, credentials: ProviderC
n: 1, n: 1,
size: openaiSizeFor('gpt-image-1', ctx.aspect), 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', method: 'POST',
headers, headers,
body: JSON.stringify(body), 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 * appending the default api-version for Azure when the user didn't
* specify one. Returns a string ready for `fetch`. * 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 { function buildOpenAICompatibleGenerationUrl(baseUrl: string, endpoint: 'images' | 'videos'): string {
const suffix = `/${endpoint}/generations`;
let parsed; let parsed;
try { try {
parsed = new URL(baseUrl); parsed = new URL(baseUrl);
} catch { } catch {
const stripped = baseUrl.replace(/\/$/, ''); const stripped = baseUrl.replace(/\/$/, '');
return stripped.endsWith(suffix) ? stripped : `${stripped}${suffix}`; return normalizeOpenAICompatiblePath(stripped, endpoint, 'generations');
}
const strippedPath = parsed.pathname.replace(/\/+$/, '');
if (!strippedPath.endsWith(suffix)) {
parsed.pathname = `${strippedPath}${suffix}`;
} }
parsed.pathname = normalizeOpenAICompatiblePath(parsed.pathname, endpoint, 'generations');
return parsed.toString(); return parsed.toString();
} }
@ -1019,6 +1041,18 @@ function buildOpenAIImageUrl(baseUrl: string, isAzure: boolean): string {
return parsed.toString(); 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 { function buildOpenAIVideoUrl(baseUrl: string): string {
return buildOpenAICompatibleGenerationUrl(baseUrl, 'videos'); return buildOpenAICompatibleGenerationUrl(baseUrl, 'videos');
} }

View file

@ -49,11 +49,10 @@ export const grokBuildAgentDef = {
label: 'grok-4.20-multi-agent (xAI · orchestration)', label: 'grok-4.20-multi-agent (xAI · orchestration)',
}, },
], ],
// Prompt delivered via stdin so Windows `spawn ENAMETOOLONG` and Linux // Grok Build CLI v0.1.212 enforces `-p, --single <PROMPT>` as value-
// `spawn E2BIG` can't truncate large composed prompts. `grok -p` with // required — stdin piping no longer satisfies it. Inline the prompt.
// no positional argument reads from piped stdin. buildArgs: (prompt, _imagePaths, _extra = [], options = {}) => {
buildArgs: (_prompt, _imagePaths, _extra = [], options = {}) => { const args = ['-p', prompt];
const args = ['-p'];
if (options.model && options.model !== DEFAULT_MODEL_OPTION.id) { if (options.model && options.model !== DEFAULT_MODEL_OPTION.id) {
args.push('--model', options.model); args.push('--model', options.model);
} }
@ -69,7 +68,21 @@ export const grokBuildAgentDef = {
{ id: 'xhigh', label: 'xhigh' }, { id: 'xhigh', label: 'xhigh' },
{ id: 'max', label: 'max' }, { 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 <value>`), 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 <id> --effort <level>` and internal quoting.
maxPromptArgBytes: 30_000,
streamFormat: 'plain', streamFormat: 'plain',
installUrl: 'https://x.ai/cli', installUrl: 'https://x.ai/cli',
docsUrl: 'https://x.ai/cli', docsUrl: 'https://x.ai/cli',

View file

@ -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.' '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 ( 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). ` + `${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.' 'Reduce the selected skills/design-system context, shorten the conversation, or pick an adapter with stdin support.'

View file

@ -199,6 +199,113 @@ describe('OpenAI-compatible media providers', () => {
expect(fetchMock).toHaveBeenCalledTimes(1); 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 () => { it('renders ImageRouter images through the OpenAI-compatible JSON endpoint', async () => {
process.env.OD_IMAGEROUTER_API_KEY = 'ir-test-key'; process.env.OD_IMAGEROUTER_API_KEY = 'ir-test-key';

View file

@ -86,6 +86,7 @@ export const gemini = requireAgent('gemini');
export const qoder = requireAgent('qoder'); export const qoder = requireAgent('qoder');
export const qwen = requireAgent('qwen'); export const qwen = requireAgent('qwen');
export const opencode = requireAgent('opencode'); export const opencode = requireAgent('opencode');
export const grokBuild = requireAgent('grok-build');
export const aider = requireAgent('aider'); export const aider = requireAgent('aider');
export const antigravity = requireAgent('antigravity'); export const antigravity = requireAgent('antigravity');
export const deepseekMaxPromptArgBytes = (() => { export const deepseekMaxPromptArgBytes = (() => {
@ -95,6 +96,13 @@ export const deepseekMaxPromptArgBytes = (() => {
); );
return deepseek.maxPromptArgBytes; 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 originalDisablePlugins = process.env.OD_CODEX_DISABLE_PLUGINS;
const originalPath = process.env.PATH; const originalPath = process.env.PATH;
const originalHome = process.env.HOME; const originalHome = process.env.HOME;

View file

@ -1,6 +1,6 @@
import { test } from 'vitest'; import { test } from 'vitest';
import { import {
assert, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, claude, deepseek, deepseekMaxPromptArgBytes, vibe, assert, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, claude, deepseek, deepseekMaxPromptArgBytes, grokBuild, grokBuildMaxPromptArgBytes, vibe,
} from './helpers/test-helpers.js'; } from './helpers/test-helpers.js';
import type { TestAgentDef } 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/); assert.match(flagged.message, /stdin-capable adapter/);
}); });
// Grok Build CLI 0.1.212+ enforces `-p, --single <PROMPT>` 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 // Adapters that ship the prompt over stdin (every other code agent
// today) don't declare `maxPromptArgBytes` and must skip the guard // today) don't declare `maxPromptArgBytes` and must skip the guard
// entirely — applying it to them would refuse perfectly valid huge // entirely — applying it to them would refuse perfectly valid huge

View file

@ -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{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} .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{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{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:5px} .brand-mark img{width:100%;height:100%;display:block;object-fit:contain;border-radius:6px}
.brand .sep{color:var(--ink-faint);margin:0 6px;font-weight:400} .nav-links{display:flex;gap:18px;align-items:center;font:500 13.5px/1 var(--sans)}
.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)}
.nav-links a{color:var(--ink-soft);transition:color .15s} .nav-links a{color:var(--ink-soft);transition:color .15s}
.nav-links a:hover{color:var(--coral)} .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{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)} .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 ---------- */
.hero{position:relative;padding:90px 0 110px;overflow:hidden} .hero{position:relative;padding:90px 0 110px;overflow:hidden}
@ -74,7 +79,8 @@
.hero-copy .kicker{margin-bottom:28px} .hero-copy .kicker{margin-bottom:28px}
.hero h1{font-size:clamp(56px, 7.2vw, 104px);margin:14px 0 32px} .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 .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{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{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)} .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 .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} .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 cta ---------- */
.discord{padding:120px 0} .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::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{color:var(--ink-soft)}
.discord-card .kicker .dot{background:var(--ink)} .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-primary:hover{background:var(--bone);color:var(--ink)}
.discord-card .btn-ghost{border-color:var(--ink);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-card .btn-ghost:hover{background:var(--ink);color:var(--coral)}
.discord-side{position:relative;z-index:2} .discord-side{position:relative;z-index:2;display:flex;flex-direction:column;gap:18px}
.discord-side .pop{font:500 12px/1 var(--mono);letter-spacing:.22em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:22px}
.discord-side .stack{background:var(--ink);color:var(--bone);border-radius:14px;padding:22px} .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{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 .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} .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}} @keyframes pulse{0%,100%{opacity:1}50%{opacity:.55}}
/* --------- footer ---------- */ /* --------- 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{padding:72px 0 56px;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 a:hover{color:var(--coral)} .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) ---------- */ /* --------- responsive softening (desktop-first per brief) ---------- */
@media (max-width:1100px){ @media (max-width:1100px){
.wrap{padding:0 32px} .wrap{padding:0 32px}
.hero-grid,.signal-grid,.discord-card{grid-template-columns:1fr;gap:48px} .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} .steps,.maintainers-grid{grid-template-columns:repeat(2,1fr);row-gap:56px}
.step:nth-child(2){border-right:0} .step:nth-child(2){border-right:0}
.section-head{grid-template-columns:1fr} .section-head{grid-template-columns:1fr}
@ -265,6 +327,10 @@
.amb-col:last-child{border-bottom:0} .amb-col:last-child{border-bottom:0}
.amb-more-grid{grid-template-columns:1fr;gap:32px} .amb-more-grid{grid-template-columns:1fr;gap:32px}
.amb-side{align-items:flex-start;text-align:left} .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){ @media (max-width:640px){
.wrap{padding:0 20px} .wrap{padding:0 20px}
@ -280,6 +346,9 @@
.leaderboard-head,.row{grid-template-columns:32px 1fr auto} .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} .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} .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 */ /* loading skeletons */
@ -293,14 +362,20 @@
<nav class="nav"> <nav class="nav">
<div class="wrap nav-inner"> <div class="wrap nav-inner">
<a class="brand" href="https://open-design.ai/"> <a class="brand" href="https://open-design.ai/">
<span class="brand-mark"><img src="/logo.webp" alt="" width="22" height="22" /></span> <span class="brand-mark"><img src="/logo.webp" alt="Open Design" width="32" height="32" /></span>
Open Design Open Design
<span class="sep">/</span>
<span class="crumb">Contributors</span>
</a> </a>
<div class="nav-links"> <div class="nav-links">
<a href="#maintainers">Contributors</a>
<a href="#ambassadors">Ambassadors</a> <a href="#ambassadors">Ambassadors</a>
<a href="https://github.com/nexu-io/open-design">GitHub</a> <a href="#showcase">Showcase</a>
<span class="nav-sep" aria-hidden="true"></span>
<a class="nav-icon" href="https://github.com/nexu-io/open-design" target="_blank" rel="noopener" aria-label="GitHub" title="GitHub">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.8 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2.9-.3 2-.4 3-.4s2.1.1 3 .4c2.3-1.5 3.3-1.2 3.3-1.2.6 1.7.2 2.9.1 3.2.7.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6 4.5-1.5 7.8-5.8 7.8-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
</a>
<a class="nav-icon" href="https://x.com/nexudotio" target="_blank" rel="noopener" aria-label="Follow Open Design on X" title="X / Twitter">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M17.53 3H21l-7.39 8.45L22 21h-6.83l-5.36-6.99L3.7 21H.23l7.9-9.04L0 3h7l4.85 6.41L17.53 3Zm-2.39 16h2.04L5.96 4.9H3.78L15.14 19Z"/></svg>
</a>
<a class="pill" href="#discord"> <a class="pill" href="#discord">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19.27 5.33A18 18 0 0 0 14.72 4l-.2.4a13.7 13.7 0 0 0-5.04 0L9.27 4a18 18 0 0 0-4.54 1.33C2.4 8.94 1.78 12.45 2.09 15.9a18.4 18.4 0 0 0 5.6 2.83l1.13-1.55a11.6 11.6 0 0 1-1.78-.86l.44-.34a13 13 0 0 0 11.04 0l.44.34c-.55.33-1.16.61-1.78.86l1.13 1.55a18.3 18.3 0 0 0 5.6-2.83c.45-4.05-.5-7.53-2.64-10.57ZM9.5 14.07c-1.07 0-1.95-.99-1.95-2.21 0-1.22.86-2.22 1.95-2.22 1.1 0 1.97 1 1.95 2.22 0 1.22-.86 2.21-1.95 2.21Zm5 0c-1.07 0-1.95-.99-1.95-2.21 0-1.22.87-2.22 1.96-2.22 1.1 0 1.96 1 1.95 2.22 0 1.22-.86 2.21-1.96 2.21Z"/></svg> <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19.27 5.33A18 18 0 0 0 14.72 4l-.2.4a13.7 13.7 0 0 0-5.04 0L9.27 4a18 18 0 0 0-4.54 1.33C2.4 8.94 1.78 12.45 2.09 15.9a18.4 18.4 0 0 0 5.6 2.83l1.13-1.55a11.6 11.6 0 0 1-1.78-.86l.44-.34a13 13 0 0 0 11.04 0l.44.34c-.55.33-1.16.61-1.78.86l1.13 1.55a18.3 18.3 0 0 0 5.6-2.83c.45-4.05-.5-7.53-2.64-10.57ZM9.5 14.07c-1.07 0-1.95-.99-1.95-2.21 0-1.22.86-2.22 1.95-2.22 1.1 0 1.97 1 1.95 2.22 0 1.22-.86 2.21-1.95 2.21Zm5 0c-1.07 0-1.95-.99-1.95-2.21 0-1.22.87-2.22 1.96-2.22 1.1 0 1.96 1 1.95 2.22 0 1.22-.86 2.21-1.96 2.21Z"/></svg>
Join Discord Join Discord
@ -316,17 +391,11 @@
<div class="hero-copy"> <div class="hero-copy">
<span class="kicker"><span class="dot"></span>Contributors · <span class="num">2026 cycle</span></span> <span class="kicker"><span class="dot"></span>Contributors · <span class="num">2026 cycle</span></span>
<h1 class="h-display">Open design <em>takes shape</em><br/>when you ship it.</h1> <h1 class="h-display">Open design <em>takes shape</em><br/>when you ship it.</h1>
<p class="lead">Open Design is built by people, in public. Skills, DESIGN.md systems, plugins, docs every commit is a brushstroke. Pick an issue, send a PR, and earn a one-of-one honor card the moment you're merged.</p> <p class="lead">Open Design is built by people, in public. Skills, DESIGN.md systems, plugins, docs: every commit is a brushstroke. Pick an issue, send a PR, and earn a one-of-one honor card the moment you're merged.</p>
<div class="hero-cta"> <div class="hero-cta">
<a class="btn btn-primary" href="#issues"> <a class="btn btn-primary" href="#showcase">Stage your masterpieces</a>
Pick a first issue <a class="btn btn-ghost" href="#ambassadors">Become an ambassador</a>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg> <a class="btn btn-ghost" href="#maintainers">Contributors hall of fame</a>
</a>
<a class="btn btn-ghost" href="#how">How contributing works</a>
<a class="btn btn-coral" href="#discord">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.27 5.33A18 18 0 0 0 14.72 4l-.2.4a13.7 13.7 0 0 0-5.04 0L9.27 4a18 18 0 0 0-4.54 1.33C2.4 8.94 1.78 12.45 2.09 15.9a18.4 18.4 0 0 0 5.6 2.83l1.13-1.55a11.6 11.6 0 0 1-1.78-.86l.44-.34a13 13 0 0 0 11.04 0l.44.34c-.55.33-1.16.61-1.78.86l1.13 1.55a18.3 18.3 0 0 0 5.6-2.83c.45-4.05-.5-7.53-2.64-10.57Z"/></svg>
Join the Discord
</a>
</div> </div>
</div> </div>
<div class="hero-card"> <div class="hero-card">
@ -341,6 +410,87 @@
</div> </div>
</div> </div>
</section> </section>
<!-- ============ SHOWCASE — PLUGIN EVERYTHING ============ -->
<section class="section showcase" id="showcase">
<div class="wrap">
<div class="section-head">
<div>
<span class="kicker"><span class="dot"></span>Plugin everything</span>
<h2 class="h-display">Open Design as a stage. <em>Your work</em> as the show.</h2>
</div>
<p class="right">The atelier is also a gallery. Helping you make the work is half the address; making sure the room comes to look is the other. Every piece you ship lands not in a vault but on a wall, where the world can find it.</p>
</div>
<div class="showcase-grid">
<div class="showcase-tenets">
<div class="showcase-tenet">
<div class="ord">I</div>
<div>
<h3>Anything <em>can be a plugin</em>.</h3>
<p>Whatever the studio yields (content, a finished product, a template, a Skill, a workflow) can be folded back into a plugin. The registry accepts any shape; the door keeps no gatekeeper.</p>
</div>
</div>
<div class="showcase-tenet">
<div class="ord">II</div>
<div>
<h3>Your debut piece, your <em>induction</em>.</h3>
<p>The day your first piece lands in the registry, your name joins the wall. Not a visitor's badge. A permanent line on the contributor list, beside everyone who arrived before.</p>
</div>
</div>
<div class="showcase-tenet">
<div class="ord">III</div>
<div>
<h3>Once it's in, <em>it travels</em>.</h3>
<p>The registry at <a href="https://open-design.ai/plugins/" target="_blank" rel="noopener">open-design.ai/plugins</a> is only the threshold. From there the strongest pieces are carried outward: to X, to Discord's <span class="num">#showcase</span>, to the newsletter, to the video reels. Each handoff widens the room; the world meets your hand.</p>
</div>
</div>
<div class="showcase-tenet">
<div class="ord">IV</div>
<div>
<h3>Need a <em>first stroke</em>?</h3>
<p>Walk the <a href="https://open-design.ai/plugins/" target="_blank" rel="noopener">plugin registry</a>. The works hung there are kindling for your own. Borrow the spark, then make the piece only your hand could.</p>
</div>
</div>
</div>
<aside class="contrib-card" id="contribute">
<div>
<div class="pane-kicker"><span class="dot"></span>The skill</div>
<h3>Let the <em>agent</em> ship for you.</h3>
<p class="pane-lede">For makers who'd rather not touch the code. The whole contribution lives in a single skill, spoken in plain language. The brushwork falls to the agent.</p>
</div>
<div class="contrib-install" data-install="curl -sSL https://raw.githubusercontent.com/nexu-io/open-design/main/.claude/skills/od-contribute/install.sh | bash">
<span class="cmd">curl -sSL https://raw.githubusercontent.com/nexu-io/open-design/main/.claude/skills/od-contribute/install.sh | bash</span>
<button type="button" data-copy>Copy</button>
</div>
<div class="contrib-steps">
<div class="contrib-step">
<span class="n">01</span>
<div>
<h4>Hand the line to the agent</h4>
<p>Paste the command above into the agent within Open Design, or into whichever you already keep at hand: Claude Code, Codex, Cursor. It installs itself.</p>
</div>
</div>
<div class="contrib-step">
<span class="n">02</span>
<div>
<h4>Wake the skill</h4>
<p>Type <code>/od-contribute</code>, or simply tell the agent to run what you just installed. Either phrase opens the door.</p>
</div>
</div>
<div class="contrib-step">
<span class="n">03</span>
<div>
<h4>Half a minute to the gallery</h4>
<p>The agent walks the rest. Your piece is bound for the open-source repository in about thirty seconds; we review at first chance, and the moment it lands, the room meets your hand.</p>
</div>
</div>
</div>
</aside>
</div>
</div>
</section>
<!-- ============ AMBASSADORS ============ --> <!-- ============ AMBASSADORS ============ -->
<section class="section ambassadors" id="ambassadors"> <section class="section ambassadors" id="ambassadors">
<div class="wrap"> <div class="wrap">
@ -348,7 +498,7 @@
<div> <div>
<span class="kicker"><span class="dot"></span>Open Design Ambassadors</span> <span class="kicker"><span class="dot"></span>Open Design Ambassadors</span>
<h2 class="h-display">Be Open Design's <em>voice</em> in your city.</h2> <h2 class="h-display">Be Open Design's <em>voice</em> in your city.</h2>
<p class="amb-tagline">Open a local atelier. Convene the meetups, the demos, the late-night critiques — the studio carries the work with budget, materials, and a line straight to the team.</p> <p class="amb-tagline">Open a local atelier. Convene the meetups, the demos, the late-night critiques. We back you with budget, materials, and a private channel to the core team.</p>
</div> </div>
<div class="right amb-side"> <div class="right amb-side">
<a class="btn btn-coral amb-apply" href="https://discord.gg/2p7Ajbxw3h" target="_blank" rel="noopener"> <a class="btn btn-coral amb-apply" href="https://discord.gg/2p7Ajbxw3h" target="_blank" rel="noopener">
@ -356,7 +506,7 @@
Apply on Discord Apply on Discord
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a> </a>
<p>Ambassadors turn Open Design from a repository into something contributors can meet in a room, with ink on the table and coffee gone cold.</p> <p>Ambassadors turn Open Design from a repository into something contributors can meet in a room, with ink on the table and coffee gone cold.</p>
</div> </div>
</div> </div>
@ -364,38 +514,38 @@
<div class="amb-col"> <div class="amb-col">
<div class="n">I · Vocation</div> <div class="n">I · Vocation</div>
<h3>Painters of <em>the local scene</em>.</h3> <h3>Painters of <em>the local scene</em>.</h3>
<p class="lede">Designers, developers, organizers the kind who already gather others. We give the gathering a flag.</p> <p class="lede">Designers, developers, organizers: the kind who already gather others. We give the gathering a flag.</p>
<ul> <ul>
<li><span class="ic">·</span><span><b>Local Atelier Host</b> you keep a recurring meetup, study group, or late-night hack alive.</span></li> <li><span class="ic">·</span><span><b>Local Atelier Host:</b> you keep a recurring meetup, study group, or late-night hack alive.</span></li>
<li><span class="ic">·</span><span><b>Online community lead</b> Discord, WeChat, Telegram, X spaces.</span></li> <li><span class="ic">·</span><span><b>Online community lead:</b> Discord, WeChat, Telegram, X spaces.</span></li>
<li><span class="ic">·</span><span><b>Practising contributor or evangelist</b> already shipping work, posting craft, ushering newcomers.</span></li> <li><span class="ic">·</span><span><b>Practising contributor or evangelist:</b> already shipping work, posting craft, ushering newcomers.</span></li>
<li><span class="ic">·</span><span><b>Comfortable carrying the name</b> bound to the Code of Conduct, mindful of the brand.</span></li> <li><span class="ic">·</span><span><b>Comfortable carrying the name:</b> bound to the Code of Conduct, mindful of the brand.</span></li>
</ul> </ul>
</div> </div>
<div class="amb-col"> <div class="amb-col">
<div class="n">II · Patronage</div> <div class="n">II · Patronage</div>
<h3>What the <em>atelier</em> extends.</h3> <h3>What the <em>atelier</em> extends.</h3>
<p class="lede">Not a volunteer badge. A working bond with budget, standing, and access.</p> <p class="lede">Not a volunteer badge. A working bond, with budget, standing, and access.</p>
<ul> <ul>
<li><span class="ic">·</span><span><b>A page on the site</b> portrait, city, biography, socials, the chronicle of your events.</span></li> <li><span class="ic">·</span><span><b>A page on the site:</b> portrait, city, biography, socials, the chronicle of your events.</span></li>
<li><span class="ic">·</span><span><b>First sight</b> beta features, internal roadmap previews, releases ahead of the queue.</span></li> <li><span class="ic">·</span><span><b>First sight:</b> beta features, internal roadmap previews, releases ahead of the queue.</span></li>
<li><span class="ic">·</span><span><b>The atelier kit</b> posters, slide decks, demo pieces, swag; a purse for venue, drinks, and photography.</span></li> <li><span class="ic">·</span><span><b>The atelier kit:</b> posters, slide decks, demo pieces, swag; a purse for venue, drinks, and photography.</span></li>
<li><span class="ic">·</span><span><b>A line to the studio</b> private channel, monthly sync, a dedicated path for your feedback.</span></li> <li><span class="ic">·</span><span><b>A line to the studio:</b> private channel, monthly sync, a dedicated path for your feedback.</span></li>
<li><span class="ic">·</span><span><b>A way forward</b> honor cards and tiers, with a path into regional lead, speaker, or paid community roles.</span></li> <li><span class="ic">·</span><span><b>A way forward:</b> honor cards and tiers, with a path into regional lead, speaker, or paid community roles.</span></li>
</ul> </ul>
</div> </div>
<div class="amb-col"> <div class="amb-col">
<div class="n">III · Covenant</div> <div class="n">III · Covenant</div>
<h3>The <em>discipline</em> of the studio.</h3> <h3>The <em>discipline</em> of the studio.</h3>
<p class="lede">A modest commitment, but binding. Extended absence folds into alumni status the circle stays small and serious.</p> <p class="lede">A modest commitment, but binding. Extended absence folds into alumni status; the circle stays small and serious.</p>
<ul> <ul>
<li><span class="ic">·</span><span><b>Convene</b> at least one event per month or quarter local or online.</span></li> <li><span class="ic">·</span><span><b>Convene</b> at least one event per month or quarter, local or online.</span></li>
<li><span class="ic">·</span><span><b>Welcome the new hand</b> — usher newcomers through their first contribution.</span></li> <li><span class="ic">·</span><span><b>Welcome the new hand.</b> Usher newcomers through their first contribution.</span></li>
<li><span class="ic">·</span><span><b>Listen close</b> — gather honest feedback from users, designers, developers, teams.</span></li> <li><span class="ic">·</span><span><b>Listen close.</b> Gather honest feedback from users, designers, developers, teams.</span></li>
<li><span class="ic">·</span><span><b>Leave a record</b> — publish a recap after every gathering: attendance, photographs, links, leads.</span></li> <li><span class="ic">·</span><span><b>Leave a record.</b> Publish a recap after every gathering: attendance, photographs, links, leads.</span></li>
<li><span class="ic">·</span><span><b>Carry the name well</b> — hold to the Code of Conduct; no misuse of the mark, no deals signed on the studio's behalf.</span></li> <li><span class="ic">·</span><span><b>Carry the name well.</b> Hold to the Code of Conduct; no misuse of the mark, no deals signed on the studio's behalf.</span></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -404,7 +554,7 @@
</section> </section>
<!-- ============ MAINTAINERS ============ --> <!-- ============ MAINTAINERS ============ -->
<section class="section"> <section class="section" id="maintainers">
<div class="wrap"> <div class="wrap">
<div class="section-head"> <div class="section-head">
<div> <div>
@ -493,8 +643,8 @@
<div class="wrap"> <div class="wrap">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="kicker"><span class="dot"></span>Recent signal</span> <span class="kicker"><span class="dot"></span>This week's signal</span>
<h2 class="h-display">Ten contributors with <em>recent momentum</em>.</h2> <h2 class="h-display">Ten contributors leading <em>this week</em>.</h2>
</div> </div>
<p class="right">A snapshot of sharp contributors landing PRs, improving the product, and making Open Design feel alive.</p> <p class="right">A snapshot of sharp contributors landing PRs, improving the product, and making Open Design feel alive.</p>
</div> </div>
@ -503,8 +653,8 @@
<article class="signal-feature" id="feature-card"> <article class="signal-feature" id="feature-card">
<div class="top"> <div class="top">
<div class="rank"><span class="badge">01</span> A recent leader</div> <div class="rank"><span class="badge">01</span> This week's leader</div>
<div class="week">Snapshot</div> <div class="week">Last 7 days</div>
</div> </div>
<div class="body"> <div class="body">
<div class="avatar"><img id="feat-avatar" src="" alt="" /></div> <div class="avatar"><img id="feat-avatar" src="" alt="" /></div>
@ -515,7 +665,7 @@
</div> </div>
<div class="feature-stats"> <div class="feature-stats">
<div class="item"><div class="v coral" id="feat-rank">#01</div><div class="l">Rank</div></div> <div class="item"><div class="v coral" id="feat-rank">#01</div><div class="l">Rank</div></div>
<div class="item"><div class="v" id="feat-prs"></div><div class="l">Recent PRs</div></div> <div class="item"><div class="v" id="feat-prs"></div><div class="l">PRs · 7d</div></div>
</div> </div>
</article> </article>
@ -547,7 +697,7 @@
<span class="kicker"><span class="dot"></span>Pick your first contribution</span> <span class="kicker"><span class="dot"></span>Pick your first contribution</span>
<h2 class="h-display">Open issues, <em>tagged for you</em>.</h2> <h2 class="h-display">Open issues, <em>tagged for you</em>.</h2>
</div> </div>
<p class="right">Live from <span class="num">label:&ldquo;good first issue&rdquo;</span> on the Open Design repo. Comment on an issue to claim it a maintainer will assign it within a day.</p> <p class="right">Live from <span class="num">label:&ldquo;good first issue&rdquo;</span> on the Open Design repo. Comment on an issue to claim it, and a maintainer will assign it within a day.</p>
</div> </div>
<div class="issue-list" id="issue-list"> <div class="issue-list" id="issue-list">
@ -586,7 +736,7 @@
<span class="kicker"><span class="dot"></span>Four steps · any skill level</span> <span class="kicker"><span class="dot"></span>Four steps · any skill level</span>
<h2 class="h-display">From zero to <em>merged</em>, in an afternoon.</h2> <h2 class="h-display">From zero to <em>merged</em>, in an afternoon.</h2>
</div> </div>
<p class="right">Whether you're a designer, a writer, an engineer, or someone who just spotted a typo there's a contribution shape for you. Here's the path.</p> <p class="right">Whether you're a designer, a writer, an engineer, or someone who just spotted a typo, there's a contribution shape for you. Here's the path.</p>
</div> </div>
<div class="steps"> <div class="steps">
@ -594,13 +744,13 @@
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg></div> <div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></svg></div>
<div class="n">Step 01</div> <div class="n">Step 01</div>
<h3>Find a <em>spark</em>.</h3> <h3>Find a <em>spark</em>.</h3>
<p>Browse the good-first-issues list above, or open a new issue describing something you'd improve. Designers DESIGN.md systems are the easiest entry.</p> <p>Browse the good-first-issues list above, or open a new issue describing something you'd improve. Designers: DESIGN.md systems are the easiest entry.</p>
</div> </div>
<div class="step"> <div class="step">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 4H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9zM14 4v5h5"/></svg></div> <div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 4H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9zM14 4v5h5"/></svg></div>
<div class="n">Step 02</div> <div class="n">Step 02</div>
<h3>Open a <em>draft</em> PR.</h3> <h3>Open a <em>draft</em> PR.</h3>
<p>Fork, branch, push. Mark it draft — it signals you want feedback early. Mention which issue it closes. The CI is fast; bot-cards stays on its own branch.</p> <p>Fork, branch, push. Mark it draft. It signals you want feedback early. Mention which issue it closes. The CI is fast; bot-cards stays on its own branch.</p>
</div> </div>
<div class="step"> <div class="step">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></div> <div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></div>
@ -612,7 +762,7 @@
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 12l2 2 4-4M3 6l2 2 4-4M3 18l2 2 4-4M13 6h8M13 12h8M13 18h8"/></svg></div> <div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 12l2 2 4-4M3 6l2 2 4-4M3 18l2 2 4-4M13 6h8M13 12h8M13 18h8"/></svg></div>
<div class="n">Step 04</div> <div class="n">Step 04</div>
<h3>Merge → <em>card</em>.</h3> <h3>Merge → <em>card</em>.</h3>
<p>The bot mints your honor card the moment you're merged and pushes it to the bot-cards branch. Share it on X with #openDesign — we repost the best ones.</p> <p>The bot mints your honor card the moment you're merged and pushes it to the bot-cards branch. Share it on X with #OpenDesign, and we repost the best ones.</p>
</div> </div>
</div> </div>
@ -621,7 +771,6 @@
Read the contributing guide Read the contributing guide
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a> </a>
<a class="btn btn-ghost" href="https://github.com/nexu-io/open-design/blob/main/CODE_OF_CONDUCT.md" style="color:var(--paper);border-color:rgba(247,241,222,.25)">Code of Conduct</a>
</div> </div>
</div> </div>
</section> </section>
@ -633,7 +782,7 @@
<div> <div>
<span class="kicker"><span class="dot"></span>Where contributors hang out</span> <span class="kicker"><span class="dot"></span>Where contributors hang out</span>
<h2>Talk to the people who'll <em>review your PR</em>.</h2> <h2>Talk to the people who'll <em>review your PR</em>.</h2>
<p>Our Discord is where contributors show shipped work, discuss plugins, join beta tests, and get help when a PR gets stuck. No fake activity counters — just the channels people can actually use.</p> <p>The front line of the agent-design era opens here. Our Discord is where the world's sharpest AI-native designers gather: shipping work, opening plugins, breaking betas, pulling one another unstuck. Step in. Bring what you're making.</p>
<div style="display:flex;gap:14px;flex-wrap:wrap"> <div style="display:flex;gap:14px;flex-wrap:wrap">
<a class="btn btn-primary" href="https://discord.gg/3C6EWXbdQQ"> <a class="btn btn-primary" href="https://discord.gg/3C6EWXbdQQ">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.27 5.33A18 18 0 0 0 14.72 4l-.2.4a13.7 13.7 0 0 0-5.04 0L9.27 4a18 18 0 0 0-4.54 1.33C2.4 8.94 1.78 12.45 2.09 15.9a18.4 18.4 0 0 0 5.6 2.83l1.13-1.55a11.6 11.6 0 0 1-1.78-.86l.44-.34a13 13 0 0 0 11.04 0l.44.34c-.55.33-1.16.61-1.78.86l1.13 1.55a18.3 18.3 0 0 0 5.6-2.83c.45-4.05-.5-7.53-2.64-10.57Z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.27 5.33A18 18 0 0 0 14.72 4l-.2.4a13.7 13.7 0 0 0-5.04 0L9.27 4a18 18 0 0 0-4.54 1.33C2.4 8.94 1.78 12.45 2.09 15.9a18.4 18.4 0 0 0 5.6 2.83l1.13-1.55a11.6 11.6 0 0 1-1.78-.86l.44-.34a13 13 0 0 0 11.04 0l.44.34c-.55.33-1.16.61-1.78.86l1.13 1.55a18.3 18.3 0 0 0 5.6-2.83c.45-4.05-.5-7.53-2.64-10.57Z"/></svg>
@ -643,7 +792,24 @@
</div> </div>
</div> </div>
<div class="discord-side"> <div class="discord-side">
<div class="pop">Community Discord</div> <div class="mod-row">
<article class="moderator-card">
<div class="mod-avatar">
<img src="https://cdn.discordapp.com/avatars/1433334626641907803/659cec9ed75df0156957ff23e81e27f1.webp?size=2048" alt="Koki — Open Design core team" loading="lazy" />
</div>
<span class="mod-role">From the studio</span>
<h3 class="mod-name">Koki</h3>
<p class="mod-bio">From the Open Design founding team. Hopes the Discord stays a good place to be. Wave at any time, on any question.</p>
</article>
<article class="moderator-card">
<div class="mod-avatar">
<img src="https://cdn.discordapp.com/avatars/1174739309509759008/60d038042d7246391a6c982d6508892e.webp?size=2048" alt="Victor — Discord steward" loading="lazy" />
</div>
<span class="mod-role">Steward of the room</span>
<h3 class="mod-name">Victor</h3>
<p class="mod-bio">A practiced hand at Discord and community-tending. Keeps the room warm, the doors open, the conversation flowing. Passionate about Open Design.</p>
</article>
</div>
<div class="stack"> <div class="stack">
<div class="row-d"><span class="dot-g"></span>#showcase<span class="h">work shipped</span></div> <div class="row-d"><span class="dot-g"></span>#showcase<span class="h">work shipped</span></div>
<div class="row-d"><span class="dot-g"></span>#plugin<span class="h">builders</span></div> <div class="row-d"><span class="dot-g"></span>#plugin<span class="h">builders</span></div>
@ -657,13 +823,46 @@
<!-- ============ FOOTER ============ --> <!-- ============ FOOTER ============ -->
<footer class="foot"> <footer class="foot">
<div class="wrap foot-inner"> <div class="wrap">
<span>© 2026 Open Design · Apache-2.0 · Built by contributors, in public.</span> <div class="foot-cols">
<div class="l"> <div class="foot-col foot-brand">
<a href="https://github.com/nexu-io/open-design">GitHub</a> <a class="brand" href="https://open-design.ai/">
<a href="https://discord.gg/3C6EWXbdQQ">Discord</a> <span class="brand-mark"><img src="/logo.webp" alt="" width="22" height="22" /></span>
<a href="https://x.com/nexudotio">X / Twitter</a> Open Design
<a href="https://open-design.ai/">open-design.ai</a> </a>
<p class="foot-summary">The official open-source, local-first alternative to Claude Design. Apache-2.0, BYOK at every layer.</p>
</div>
<div class="foot-col">
<h5>Products</h5>
<ul>
<li><a href="https://open-design.ai/">Open Design</a></li>
<li><a href="https://open-design.ai/html-anything/">HTML Anything</a></li>
</ul>
</div>
<div class="foot-col">
<h5>Plugins</h5>
<ul>
<li><a href="https://open-design.ai/plugins/templates/">Templates</a></li>
<li><a href="https://open-design.ai/plugins/skills/">Skills</a></li>
<li><a href="https://open-design.ai/plugins/systems/">Systems</a></li>
<li><a href="https://open-design.ai/plugins/craft/">Craft</a></li>
</ul>
</div>
<div class="foot-col">
<h5>Community</h5>
<ul>
<li><a href="https://github.com/nexu-io/open-design" target="_blank" rel="noopener">GitHub</a></li>
<li><a href="https://discord.gg/3C6EWXbdQQ" target="_blank" rel="noopener">Discord</a></li>
<li><a href="https://x.com/nexudotio" target="_blank" rel="noopener">X / Twitter</a></li>
<li><a href="https://open-design.ai/blog/">Blog</a></li>
</ul>
</div>
</div>
<div class="foot-bottom">
<span>© 2026 Open Design · Apache-2.0 · Built by contributors, in public.</span>
<div class="l">
<a href="https://open-design.ai/">open-design.ai</a>
</div>
</div> </div>
</div> </div>
</footer> </footer>
@ -777,8 +976,8 @@ async function loadWeeklyTop(){
document.getElementById('feat-avatar').src = f.avatar; document.getElementById('feat-avatar').src = f.avatar;
document.getElementById('feat-avatar').alt = f.login; document.getElementById('feat-avatar').alt = f.login;
setText('feat-name', f.login); setText('feat-name', f.login);
setText('feat-handle', '@' + f.login + ' · recent contribution'); setText('feat-handle', '@' + f.login + ' · leading this week');
setText('feat-blurb', `${f.login} has set the pace with ${f.prs} merged PR${f.prs === 1 ? '' : 's'} and the kind of steady craft that keeps Open Design moving.`); setText('feat-blurb', `${f.login} is setting the pace this week with ${f.prs} merged PR${f.prs === 1 ? '' : 's'} and the kind of steady craft that keeps Open Design moving.`);
setText('feat-prs-list', exampleCopy(f)); setText('feat-prs-list', exampleCopy(f));
setText('feat-rank', '#01'); setText('feat-rank', '#01');
setText('feat-prs', f.prs); setText('feat-prs', f.prs);
@ -866,8 +1065,24 @@ async function loadMaintainers(){
function setText(id, v){ const el = document.getElementById(id); if (el && v != null) el.textContent = v; } function setText(id, v){ const el = document.getElementById(id); if (el && v != null) el.textContent = v; }
function escapeHtml(s){ return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); } function escapeHtml(s){ return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
/* --------- copy-to-clipboard for the install command --------- */
function wireCopyButtons(){
document.querySelectorAll('[data-install] [data-copy]').forEach(btn => {
btn.addEventListener('click', async () => {
const cmd = btn.parentElement.getAttribute('data-install') || '';
try { await navigator.clipboard.writeText(cmd); }
catch { /* very old browsers — let the user select the text manually */ return; }
const original = btn.textContent;
btn.textContent = 'Copied';
btn.classList.add('is-copied');
setTimeout(() => { btn.textContent = original; btn.classList.remove('is-copied'); }, 1600);
});
});
}
/* --------- boot --------- */ /* --------- boot --------- */
(async function(){ (async function(){
wireCopyButtons();
await Promise.all([ loadWeeklyTop(), loadAllTimeTop(), loadGoodFirstIssues(), loadMaintainers() ]); await Promise.all([ loadWeeklyTop(), loadAllTimeTop(), loadGoodFirstIssues(), loadMaintainers() ]);
})(); })();
</script> </script>

View file

@ -1,8 +1,10 @@
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { useRef } from 'react';
import type { PreviewCommentSnapshot } from '../comments'; import type { PreviewCommentSnapshot } from '../comments';
import type { Dict } from '../i18n/types'; import type { Dict } from '../i18n/types';
import type { PreviewComment, PreviewCommentMember } from '../types'; import type { PreviewComment, PreviewCommentMember } from '../types';
import { isImeComposing } from '../utils/imeComposing';
import { Icon } from './Icon'; import { Icon } from './Icon';
@ -262,6 +264,8 @@ export function BoardComposerPopover({
const pendingCount = notes.length + (draft.trim() ? 1 : 0); const pendingCount = notes.length + (draft.trim() ? 1 : 0);
const hasCommentChange = !existing || draft.trim() !== existing.note.trim(); const hasCommentChange = !existing || draft.trim() !== existing.note.trim();
const podMembers = target.podMembers ?? []; const podMembers = target.podMembers ?? [];
const composingRef = useRef(false);
const sendDisabled = pendingCount === 0 || sending;
return ( return (
<div <div
className={`comment-popover${docked ? ' comment-popover-docked' : ''}`} className={`comment-popover${docked ? ' comment-popover-docked' : ''}`}
@ -335,6 +339,25 @@ export function BoardComposerPopover({
aria-label={t('chat.comments.placeholder')} aria-label={t('chat.comments.placeholder')}
placeholder={t('chat.comments.placeholder')} placeholder={t('chat.comments.placeholder')}
onChange={(event) => onDraft(event.target.value)} onChange={(event) => onDraft(event.target.value)}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
}}
onKeyDown={(event) => {
if (isImeComposing(event, composingRef.current)) return;
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.altKey &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
if (sendDisabled) return;
void onSendBatch();
}
}}
/> />
<div className="comment-popover-actions"> <div className="comment-popover-actions">
<div className="comment-popover-actions-start"> <div className="comment-popover-actions-start">
@ -386,7 +409,7 @@ export function BoardComposerPopover({
type="button" type="button"
className="primary" className="primary"
data-testid="comment-add-send" data-testid="comment-add-send"
disabled={pendingCount === 0 || sending} disabled={sendDisabled}
onClick={() => void onSendBatch()} onClick={() => void onSendBatch()}
> >
{sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')} {sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')}

View file

@ -283,6 +283,9 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const toolsMenuRef = useRef<HTMLDivElement | null>(null); const toolsMenuRef = useRef<HTMLDivElement | null>(null);
const toolsTriggerRef = useRef<HTMLButtonElement | null>(null); const toolsTriggerRef = useRef<HTMLButtonElement | null>(null);
const petEnabled = Boolean(onAdoptPet && onTogglePet); const petEnabled = Boolean(onAdoptPet && onTogglePet);
const [petMenuOpen, setPetMenuOpen] = useState(false);
const petWrapRef = useRef<HTMLDivElement | null>(null);
const [petMenuStyle, setPetMenuStyle] = useState<React.CSSProperties>({});
const linkedDirs = projectMetadata?.linkedDirs ?? []; const linkedDirs = projectMetadata?.linkedDirs ?? [];
// initialDraft is only honored on the first non-empty value the parent // initialDraft is only honored on the first non-empty value the parent
// hands us. After we seed once, the composer is fully under user control // hands us. After we seed once, the composer is fully under user control
@ -326,6 +329,53 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}; };
}, [toolsOpen]); }, [toolsOpen]);
useEffect(() => {
if (!petMenuOpen) return;
function onPointer(e: MouseEvent) {
const target = e.target as Node;
if (petWrapRef.current?.contains(target)) return;
setPetMenuOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setPetMenuOpen(false);
}
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [petMenuOpen]);
// Viewport-aware pet menu positioning — flips the popover to stay
// within screen bounds instead of clipping at the edge.
useEffect(() => {
if (!petMenuOpen) return;
const wrap = petWrapRef.current;
if (!wrap) return;
const rect = wrap.getBoundingClientRect();
const menuW = 260;
const menuH = 200;
const gap = 6;
const viewW = window.innerWidth;
const viewH = window.innerHeight;
// Prefer opening upward (bottom of menu above the button).
// Flip downward when there isn't enough room above.
// When neither direction fits, clamp to viewport bounds.
let top: number;
if (rect.top >= menuH + gap) {
top = rect.top - menuH - gap;
} else if (rect.bottom + menuH + gap <= viewH) {
top = rect.bottom + gap;
} else {
top = Math.max(gap, viewH - menuH - gap);
}
// Right-align by default (menu right edge ≈ button right edge).
// Shift left when the menu would spill past the viewport left edge.
const left = Math.max(8, Math.min(viewW - menuW - 8, rect.right - menuW));
setPetMenuStyle({ position: 'fixed', top, left });
}, [petMenuOpen]);
// Lazy-fetch the user's external MCP servers list once on mount so the // Lazy-fetch the user's external MCP servers list once on mount so the
// `/mcp …` slash palette and the composer's MCP button popover have // `/mcp …` slash palette and the composer's MCP button popover have
// something to render. We deliberately do not reactively re-fetch when // something to render. We deliberately do not reactively re-fetch when
@ -1706,6 +1756,70 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
</div> </div>
) : null} ) : null}
</div> </div>
{petEnabled ? (
<div className="composer-pet-wrap" ref={petWrapRef}>
<button
type="button"
className={`composer-pet${petConfig?.adopted ? ' adopted' : ''}`}
onClick={() => {
if (petConfig?.adopted) {
if (!petConfig.enabled) setPetMenuOpen(true);
else setPetMenuOpen((v) => !v);
} else {
setPetMenuOpen((v) => !v);
}
}}
title={t('pet.composerTitle')}
aria-haspopup="menu"
aria-expanded={petMenuOpen}
aria-label={t('pet.composerTitle')}
>
<span className="composer-pet-glyph">
{petConfig?.adopted ? (petConfig?.custom?.glyph || '🐾') : '🐾'}
</span>
<span className="composer-pet-label">
{petConfig?.adopted ? (petConfig?.custom?.name || 'Buddy') : t('pet.composerMenuTitle')}
</span>
</button>
{petMenuOpen ? (
<div
className="composer-pet-menu"
style={petMenuStyle}
>
<div className="composer-pet-menu-head">
<strong>{t('pet.composerMenuTitle')}</strong>
<span>{t('pet.composerMenuHint')}</span>
</div>
<button
type="button"
className="composer-pet-menu-row toggle"
onClick={() => {
if (petConfig?.adopted) {
onTogglePet?.();
} else {
onOpenPetSettings?.();
}
setPetMenuOpen(false);
}}
>
<Icon name={petConfig?.enabled ? 'eye-off' : 'eye'} size={12} />
<span>{petConfig?.enabled ? t('pet.tuck') : t('pet.wake')}</span>
</button>
<button
type="button"
className="composer-pet-menu-row settings"
onClick={() => {
onOpenPetSettings?.();
setPetMenuOpen(false);
}}
>
<Icon name="settings" size={12} />
<span>{t('pet.composerOpenSettings')}</span>
</button>
</div>
) : null}
</div>
) : null}
<button <button
className="icon-btn" className="icon-btn"
data-testid="chat-attach" data-testid="chat-attach"

View file

@ -561,7 +561,14 @@ export function DesignsTab({
<button <button
type="button" type="button"
className="primary designs-empty-cta" className="primary designs-empty-cta"
onClick={onNewProject} onClick={() => {
trackProjectsListControlsClick(analytics.track, {
page_name: "projects",
area: "list_controls",
element: "create_project",
});
onNewProject();
}}
> >
<span>{t("entry.navNewProject")}</span> <span>{t("entry.navNewProject")}</span>
</button> </button>

View file

@ -111,11 +111,11 @@ import {
startVelaLogin, startVelaLogin,
type VelaLoginStatus, type VelaLoginStatus,
} from '../providers/daemon'; } from '../providers/daemon';
import { AmrAccountControl } from './AmrLoginPill';
import { import {
AMR_LOGIN_POLL_INTERVAL_MS, AMR_LOGIN_POLL_INTERVAL_MS,
amrLoginPollOutcome, amrLoginPollOutcome,
} from './amrLoginPolling'; } from './amrLoginPolling';
import { renderModelOptions } from './modelOptions';
// The topbar chips (GitHub star, model switcher, Use everywhere) // The topbar chips (GitHub star, model switcher, Use everywhere)
// collapse into the settings dropdown when the viewport gets // collapse into the settings dropdown when the viewport gets
@ -129,6 +129,13 @@ import {
// and `/api/runs` fallbacks resolve to the same plugin id when no // and `/api/runs` fallbacks resolve to the same plugin id when no
// `pluginId` is on the request body — plan §3.3 of // `pluginId` is on the request body — plan §3.3 of
// `specs/current/plugin-driven-flow-plan.md`. // `specs/current/plugin-driven-flow-plan.md`.
const ONBOARDING_AMR_MODEL_OPTIONS: NonNullable<AgentInfo['models']> = [
{ id: 'claude-opus-4.8', label: 'Claude Opus 4.8' },
{ id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' },
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ id: 'glm-5.1', label: 'GLM 5.1' },
];
function defaultPluginIdForMetadata(metadata: ProjectMetadata): string | null { function defaultPluginIdForMetadata(metadata: ProjectMetadata): string | null {
return defaultScenarioPluginIdForProjectMetadata(metadata); return defaultScenarioPluginIdForProjectMetadata(metadata);
} }
@ -892,6 +899,24 @@ function OnboardingView({
const showAmrCloudOption = amrAgent !== null || agents.length === 0; const showAmrCloudOption = amrAgent !== null || agents.length === 0;
const amrSignedIn = amrStatus?.loggedIn === true; const amrSignedIn = amrStatus?.loggedIn === true;
const amrSelectedAndSignedOut = runtime === 'amr' && !amrSignedIn; const amrSelectedAndSignedOut = runtime === 'amr' && !amrSignedIn;
const amrAgentChoice = config.agentModels?.amr ?? {};
const amrModels =
amrAgent?.models && amrAgent.models.length > 0
? amrAgent.models
: ONBOARDING_AMR_MODEL_OPTIONS;
const amrModelsSource =
amrAgent?.models && amrAgent.models.length > 0
? amrAgent.modelsSource ?? 'fallback'
: 'fallback';
const amrKnownModelIds = amrModels.map((model) => model.id);
const amrConfiguredModel =
typeof amrAgentChoice.model === 'string' && amrAgentChoice.model
? amrAgentChoice.model
: null;
const amrSelectedModel =
amrConfiguredModel && amrKnownModelIds.includes(amrConfiguredModel)
? amrConfiguredModel
: amrModels[0]?.id ?? '';
const selectedAgent = visibleAgents.find((agent) => agent.id === config.agentId) ?? null; const selectedAgent = visibleAgents.find((agent) => agent.id === config.agentId) ?? null;
const selectedAgentChoice = selectedAgent ? (config.agentModels?.[selectedAgent.id] ?? {}) : {}; const selectedAgentChoice = selectedAgent ? (config.agentModels?.[selectedAgent.id] ?? {}) : {};
@ -1564,8 +1589,10 @@ function OnboardingView({
} }
} }
const primaryActionLabel = step === 0 && amrSelectedAndSignedOut const primaryActionLabel = step === 0 && amrLoginPending
? t('settings.amrSignInToContinue') ? t('settings.amrSigningIn')
: step === 0 && amrSelectedAndSignedOut
? t('settings.amrSignInToContinue')
: step === 1 : step === 1
? t('settings.onboardingContinue') ? t('settings.onboardingContinue')
: isLastStep : isLastStep
@ -1613,31 +1640,25 @@ function OnboardingView({
t('settings.onboardingAmrCloudBenefitModels'), t('settings.onboardingAmrCloudBenefitModels'),
t('settings.onboardingAmrCloudBenefitPricing'), t('settings.onboardingAmrCloudBenefitPricing'),
]} ]}
badge={t('settings.onboardingRecommended')} upcomingLabel={t('settings.onboardingAmrCloudUpcomingLabel')}
officialLabel={t('settings.onboardingAmrCloudOfficialBadge')} upcomingBenefits={[
statusSlot={ t('settings.onboardingAmrCloudUpcomingImageVideo'),
runtime === 'amr' ? ( t('settings.onboardingAmrCloudUpcomingSkills'),
<AmrAccountControl t('settings.onboardingAmrCloudUpcomingRouting'),
status={ ]}
amrLoginError benefitPlacement="aside"
? 'error' metaLabel="AMR v0.1.0"
: amrSignedIn modelSlot={
? 'signed-in' amrModels.length > 0 ? (
: amrLoginPending <OnboardingAmrModelSelect
? 'signing-in' models={amrModels}
: 'signed-out' modelsSource={amrModelsSource}
} selectedModel={amrSelectedModel}
compact onSelectModel={(model) => onAgentModelChange('amr', { model })}
email={
amrSignedIn
? amrStatus?.user?.email || t('settings.amrSignedIn')
: ''
}
showSignInAction={false}
signInDisabled={amrLoginPending}
/> />
) : null ) : null
} }
variant="amr"
featured featured
selected={runtime === 'amr'} selected={runtime === 'amr'}
onClick={() => { onClick={() => {
@ -1905,6 +1926,11 @@ function OnboardingView({
{step === 2 && renderDesignSystemCreation ? null : ( {step === 2 && renderDesignSystemCreation ? null : (
<div className="onboarding-view__actions"> <div className="onboarding-view__actions">
{step === 0 && amrLoginError ? (
<span className="onboarding-view__action-status is-error" role="alert">
{t('settings.amrLoginErrorCompact')}
</span>
) : null}
<button <button
type="button" type="button"
className="onboarding-view__secondary" className="onboarding-view__secondary"
@ -2021,6 +2047,91 @@ function OnboardingCliSetupPanel({
); );
} }
function OnboardingAmrModelSelect({
models,
modelsSource,
selectedModel,
onSelectModel,
}: {
models: NonNullable<AgentInfo['models']>;
modelsSource: AgentInfo['modelsSource'];
selectedModel: string;
onSelectModel: (model: string) => void;
}) {
const t = useT();
const modelSource = modelsSource ?? 'fallback';
const displayModels = models.map((model) => ({
...model,
label: formatOnboardingAmrModelLabel(model),
}));
const modelSourceLabel = t('settings.onboardingAmrModelSourceLabel');
return (
<label
className="onboarding-view__model-picker"
onClick={(event) => event.stopPropagation()}
>
<span className="onboarding-view__model-label">
{t('settings.modelPicker')}
<span className={`onboarding-view__model-source ${modelSource}`}>
{modelSourceLabel}
</span>
</span>
<span className="onboarding-view__model-select-wrap">
<select
value={selectedModel}
onChange={(event) => onSelectModel(event.target.value)}
>
{renderModelOptions(displayModels)}
</select>
<Icon
name="chevron-down"
size={12}
className="onboarding-view__model-select-chevron"
/>
</span>
</label>
);
}
function formatOnboardingAmrModelLabel(
model: NonNullable<AgentInfo['models']>[number],
): string {
const label = model.label?.trim();
if (label && label !== model.id && !/^[a-z0-9._-]+$/.test(label)) {
return label;
}
return model.id
.split('-')
.filter(Boolean)
.map(formatModelToken)
.join(' ');
}
function formatModelToken(token: string): string {
const lower = token.toLowerCase();
const known: Record<string, string> = {
claude: 'Claude',
opus: 'Opus',
sonnet: 'Sonnet',
haiku: 'Haiku',
deepseek: 'DeepSeek',
gemini: 'Gemini',
glm: 'GLM',
gpt: 'GPT',
oss: 'OSS',
kimi: 'Kimi',
minimax: 'MiniMax',
mimo: 'MiMo',
qwen3: 'Qwen3',
seed: 'Seed',
};
if (known[lower]) return known[lower];
if (/^v\d/i.test(token)) return token.toUpperCase();
if (/^\d+b$/i.test(token) || /^a\d+b$/i.test(token)) return token.toUpperCase();
if (/^\d+(\.\d+)*$/.test(token)) return token;
return token.charAt(0).toUpperCase() + token.slice(1);
}
function OnboardingByokSetupPanel({ function OnboardingByokSetupPanel({
apiProtocol, apiProtocol,
apiKey, apiKey,
@ -2452,12 +2563,17 @@ function OnboardingChoiceCard({
title, title,
body, body,
benefits, benefits,
upcomingLabel,
upcomingBenefits,
benefitPlacement = 'copy',
metaLabel,
modelSlot,
actionLabel, actionLabel,
selected, selected,
badge, badge,
officialLabel,
statusSlot, statusSlot,
featured, featured,
variant,
onClick, onClick,
}: { }: {
icon: 'orbit' | 'hammer' | 'sliders' | 'github' | 'upload' | 'sparkles'; icon: 'orbit' | 'hammer' | 'sliders' | 'github' | 'upload' | 'sparkles';
@ -2465,12 +2581,17 @@ function OnboardingChoiceCard({
title: string; title: string;
body: string; body: string;
benefits?: string[]; benefits?: string[];
upcomingLabel?: string;
upcomingBenefits?: string[];
benefitPlacement?: 'copy' | 'aside';
metaLabel?: string;
modelSlot?: ReactNode;
actionLabel?: string; actionLabel?: string;
selected: boolean; selected: boolean;
badge?: string; badge?: string;
officialLabel?: string;
statusSlot?: ReactNode; statusSlot?: ReactNode;
featured?: boolean; featured?: boolean;
variant?: 'amr';
onClick: () => void; onClick: () => void;
}) { }) {
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
@ -2480,55 +2601,106 @@ function OnboardingChoiceCard({
onClick(); onClick();
} }
const hasBenefits =
(benefits && benefits.length > 0) ||
(upcomingBenefits && upcomingBenefits.length > 0);
const benefitStack = hasBenefits ? (
<span className="onboarding-view__benefit-stack">
{benefits && benefits.length > 0 ? (
<span className="onboarding-view__benefits">
{benefits.map((item, index) => (
<span
key={item}
className={`onboarding-view__benefit${
index >= 2 ? ' onboarding-view__benefit--hero' : ''
}`}
>
{item}
</span>
))}
</span>
) : null}
{upcomingBenefits && upcomingBenefits.length > 0 ? (
<span className="onboarding-view__upcoming-benefits">
{upcomingLabel ? (
<span className="onboarding-view__upcoming-label">{upcomingLabel}</span>
) : null}
{upcomingBenefits.map((item) => (
<span key={item} className="onboarding-view__benefit onboarding-view__benefit--upcoming">
{item}
</span>
))}
</span>
) : null}
</span>
) : null;
const modelUnderLogo = variant === 'amr' && modelSlot;
const iconNode = (
<span
className={
'onboarding-view__icon' +
(agentIconId ? ' onboarding-view__icon--asset' : '')
}
>
{agentIconId ? (
<AgentIcon
id={agentIconId}
size={featured ? 52 : 40}
className="onboarding-view__agent-logo"
/>
) : (
<Icon name={icon} size={18} />
)}
</span>
);
const copyNode = (
<span className="onboarding-view__card-copy">
<span className="onboarding-view__card-top">
<strong>{title}</strong>
{badge ? <span className="onboarding-view__badge">{badge}</span> : null}
</span>
{metaLabel ? <span className="onboarding-view__card-meta">{metaLabel}</span> : null}
{modelUnderLogo ? null : modelSlot}
{benefitPlacement === 'copy' && benefitStack ? (
benefitStack
) : !modelSlot ? (
<small>{body}</small>
) : null}
</span>
);
return ( return (
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
className={`onboarding-view__card${selected ? ' is-selected' : ''}${ className={`onboarding-view__card${selected ? ' is-selected' : ''}${
featured ? ' onboarding-view__card--featured' : '' featured ? ' onboarding-view__card--featured' : ''
}${officialLabel ? ' onboarding-view__card--official' : ''}`} }${variant ? ` onboarding-view__card--${variant}` : ''}${
benefitPlacement === 'aside' ? ' onboarding-view__card--benefit-aside' : ''
}`}
onClick={onClick} onClick={onClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-pressed={selected} aria-pressed={selected}
> >
{officialLabel ? ( {variant === 'amr' ? (
<span className="onboarding-view__official-tag"> <span className="onboarding-view__identity">
<img src="/official_badge.svg" alt={officialLabel} draggable={false} /> {iconNode}
{copyNode}
</span>
) : (
<>
{iconNode}
{copyNode}
</>
)}
{modelUnderLogo ? (
<span className="onboarding-view__card-model">
{modelSlot}
</span> </span>
) : null} ) : null}
<span {benefitPlacement === 'aside' && benefitStack ? (
className={ <span className="onboarding-view__benefit-aside">{benefitStack}</span>
'onboarding-view__icon' + ) : null}
(agentIconId ? ' onboarding-view__icon--asset' : '')
}
>
{agentIconId ? (
<AgentIcon
id={agentIconId}
size={featured ? 52 : 40}
className="onboarding-view__agent-logo"
/>
) : (
<Icon name={icon} size={18} />
)}
</span>
<span className="onboarding-view__card-copy">
<span className="onboarding-view__card-top">
<strong>{title}</strong>
{badge ? <span className="onboarding-view__badge">{badge}</span> : null}
</span>
{benefits && benefits.length > 0 ? (
<span className="onboarding-view__benefits">
{benefits.map((item) => (
<span key={item} className="onboarding-view__benefit">
{item}
</span>
))}
</span>
) : (
<small>{body}</small>
)}
</span>
{statusSlot ? ( {statusSlot ? (
<span className="onboarding-view__card-status"> <span className="onboarding-view__card-status">
{statusSlot} {statusSlot}

View file

@ -7168,7 +7168,9 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
) : null} ) : null}
{commentPortalHost && commentSidePanel {commentPortalHost && commentSidePanel
? createPortal(commentSidePanel, commentPortalHost) ? createPortal(commentSidePanel, commentPortalHost)
: commentSidePanel} : commentPortalId
? null
: commentSidePanel}
{inspectMode && activeInspectTarget ? ( {inspectMode && activeInspectTarget ? (
<InspectPanel <InspectPanel
target={activeInspectTarget} target={activeInspectTarget}

View file

@ -4272,6 +4272,12 @@ export function ProjectView({
onBack={onBack} onBack={onBack}
backLabel={t('project.backToProjects')} backLabel={t('project.backToProjects')}
fileActionsBefore={( fileActionsBefore={(
<div
className="app-chrome-file-actions-before workspace-tabs-file-actions"
data-app-chrome-file-actions="true"
/>
)}
actions={(
<> <>
<button <button
type="button" type="button"
@ -4299,15 +4305,8 @@ export function ProjectView({
onRefreshAgents={onRefreshAgents} onRefreshAgents={onRefreshAgents}
onBack={onBack} onBack={onBack}
/> />
<div
className="app-chrome-file-actions-before workspace-tabs-file-actions"
data-app-chrome-file-actions="true"
/>
</> </>
)} )}
actions={(
null
)}
> >
<div className="app-project-title"> <div className="app-project-title">
<span className="app-project-title-line"> <span className="app-project-title-line">

View file

@ -110,10 +110,15 @@ export const ar: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'صيانة رسمية', 'settings.onboardingAmrCloudBenefitOfficial': 'موصى به رسميًا',
'settings.onboardingAmrCloudBenefitReady': 'جاهز للاستخدام', 'settings.onboardingAmrCloudBenefitReady': 'بلا نشر',
'settings.onboardingAmrCloudBenefitModels': 'نماذج عديدة', 'settings.onboardingAmrCloudBenefitModels': 'يدعم Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'أسعار أفضل', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'قريبًا',
'settings.onboardingAmrCloudUpcomingImageVideo': 'الصور والفيديو',
'settings.onboardingAmrCloudUpcomingSkills': 'Skills كثيرة',
'settings.onboardingAmrCloudUpcomingRouting': 'توجيه ذكي',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'تخويل AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'تخويل AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'تم التخويل', 'settings.onboardingAmrCloudAuthorizedAction': 'تم التخويل',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const de: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Offiziell gepflegt', 'settings.onboardingAmrCloudBenefitOfficial': 'Offiziell empfohlen',
'settings.onboardingAmrCloudBenefitReady': 'Sofort einsatzbereit', 'settings.onboardingAmrCloudBenefitReady': 'Ohne Deployment',
'settings.onboardingAmrCloudBenefitModels': 'Viele Modelle', 'settings.onboardingAmrCloudBenefitModels': 'Unterstützt Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'Günstigere Preise', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Demnächst',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Bild und Video',
'settings.onboardingAmrCloudUpcomingSkills': 'Viele Skills',
'settings.onboardingAmrCloudUpcomingRouting': 'Smart Routing',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'AMR autorisieren', 'settings.onboardingAmrCloudAuthorizeAction': 'AMR autorisieren',
'settings.onboardingAmrCloudAuthorizedAction': 'Autorisiert', 'settings.onboardingAmrCloudAuthorizedAction': 'Autorisiert',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -97,10 +97,15 @@ export const en: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Officially maintained', 'settings.onboardingAmrCloudBenefitOfficial': 'Officially recommended',
'settings.onboardingAmrCloudBenefitReady': 'Ready to use', 'settings.onboardingAmrCloudBenefitReady': 'No deploy needed',
'settings.onboardingAmrCloudBenefitModels': 'Many models', 'settings.onboardingAmrCloudBenefitModels': 'Supports Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'Better pricing', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Coming soon',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Image & video',
'settings.onboardingAmrCloudUpcomingSkills': 'Massive skills',
'settings.onboardingAmrCloudUpcomingRouting': 'Smart routing',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'Authorize AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'Authorize AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Authorized', 'settings.onboardingAmrCloudAuthorizedAction': 'Authorized',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const esES: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Mantenimiento oficial', 'settings.onboardingAmrCloudBenefitOfficial': 'Recomendado oficialmente',
'settings.onboardingAmrCloudBenefitReady': 'Listo para usar', 'settings.onboardingAmrCloudBenefitReady': 'Sin despliegue',
'settings.onboardingAmrCloudBenefitModels': 'Muchos modelos', 'settings.onboardingAmrCloudBenefitModels': 'Compatible con Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'Mejor precio', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Próximamente',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Imagen y video',
'settings.onboardingAmrCloudUpcomingSkills': 'Skills masivas',
'settings.onboardingAmrCloudUpcomingRouting': 'Enrutamiento inteligente',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'Autorizar AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'Autorizar AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Autorizado', 'settings.onboardingAmrCloudAuthorizedAction': 'Autorizado',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const fa: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'نگهداری رسمی', 'settings.onboardingAmrCloudBenefitOfficial': 'توصیه‌شده رسمی',
'settings.onboardingAmrCloudBenefitReady': 'آماده استفاده', 'settings.onboardingAmrCloudBenefitReady': 'بدون نیاز به استقرار',
'settings.onboardingAmrCloudBenefitModels': 'مدل‌های فراوان', 'settings.onboardingAmrCloudBenefitModels': 'از Claude Opus 4.8 پشتیبانی می‌کند',
'settings.onboardingAmrCloudBenefitPricing': 'قیمت بهتر', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'به‌زودی',
'settings.onboardingAmrCloudUpcomingImageVideo': 'تصویر و ویدیو',
'settings.onboardingAmrCloudUpcomingSkills': 'Skills گسترده',
'settings.onboardingAmrCloudUpcomingRouting': 'مسیریابی هوشمند',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'مجوزدهی AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'مجوزدهی AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'مجوز داده شد', 'settings.onboardingAmrCloudAuthorizedAction': 'مجوز داده شد',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -92,10 +92,15 @@ export const fr: Dict = {
'settings.onboardingSystemsBody': 'Choisissez ou créez un système de marque pour que les générations respectent les couleurs, la typographie et le langage produit réels.', 'settings.onboardingSystemsBody': 'Choisissez ou créez un système de marque pour que les générations respectent les couleurs, la typographie et le langage produit réels.',
'settings.onboardingExecutionTitle': 'Choisir le mode de génération', 'settings.onboardingExecutionTitle': 'Choisir le mode de génération',
'settings.onboardingExecutionBody': 'CLI officielle avec configuration en un clic et réglages prêts à lemploi. Utilisez une seule clé pour choisir parmi de nombreux modèles à meilleur prix.', 'settings.onboardingExecutionBody': 'CLI officielle avec configuration en un clic et réglages prêts à lemploi. Utilisez une seule clé pour choisir parmi de nombreux modèles à meilleur prix.',
'settings.onboardingAmrCloudBenefitOfficial': 'Maintenance officielle', 'settings.onboardingAmrCloudBenefitOfficial': 'Recommandé officiellement',
'settings.onboardingAmrCloudBenefitReady': 'Prêt à lemploi', 'settings.onboardingAmrCloudBenefitReady': 'Sans déploiement',
'settings.onboardingAmrCloudBenefitModels': 'Nombreux modèles', 'settings.onboardingAmrCloudBenefitModels': 'Prend en charge Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'Prix avantageux', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Bientôt',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Image et vidéo',
'settings.onboardingAmrCloudUpcomingSkills': 'Skills en masse',
'settings.onboardingAmrCloudUpcomingRouting': 'Routage intelligent',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'Autoriser AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'Autoriser AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Autorisé', 'settings.onboardingAmrCloudAuthorizedAction': 'Autorisé',
'settings.onboardingStepConnect': 'Connexion', 'settings.onboardingStepConnect': 'Connexion',

View file

@ -110,10 +110,15 @@ export const hu: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Hivatalosan karbantartott', 'settings.onboardingAmrCloudBenefitOfficial': 'Hivatalosan ajánlott',
'settings.onboardingAmrCloudBenefitReady': 'Azonnal használható', 'settings.onboardingAmrCloudBenefitReady': 'Telepítés nélkül',
'settings.onboardingAmrCloudBenefitModels': 'Sok modell', 'settings.onboardingAmrCloudBenefitModels': 'Támogatja a Claude Opus 4.8-at',
'settings.onboardingAmrCloudBenefitPricing': 'Kedvezőbb ár', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Hamarosan',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Kép és videó',
'settings.onboardingAmrCloudUpcomingSkills': 'Sok Skill',
'settings.onboardingAmrCloudUpcomingRouting': 'Intelligens útválasztás',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'AMR engedélyezése', 'settings.onboardingAmrCloudAuthorizeAction': 'AMR engedélyezése',
'settings.onboardingAmrCloudAuthorizedAction': 'Engedélyezve', 'settings.onboardingAmrCloudAuthorizedAction': 'Engedélyezve',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const id: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Dikelola resmi', 'settings.onboardingAmrCloudBenefitOfficial': 'Direkomendasikan resmi',
'settings.onboardingAmrCloudBenefitReady': 'Siap pakai', 'settings.onboardingAmrCloudBenefitReady': 'Tanpa deploy',
'settings.onboardingAmrCloudBenefitModels': 'Banyak model', 'settings.onboardingAmrCloudBenefitModels': 'Mendukung Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'Harga lebih hemat', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Segera hadir',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Gambar dan video',
'settings.onboardingAmrCloudUpcomingSkills': 'Banyak Skills',
'settings.onboardingAmrCloudUpcomingRouting': 'Routing cerdas',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'Otorisasi AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'Otorisasi AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Diotorisasi', 'settings.onboardingAmrCloudAuthorizedAction': 'Diotorisasi',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -109,10 +109,15 @@ export const it: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Manutenzione ufficiale', 'settings.onboardingAmrCloudBenefitOfficial': 'Consigliato ufficialmente',
'settings.onboardingAmrCloudBenefitReady': 'Pronto alluso', 'settings.onboardingAmrCloudBenefitReady': 'Senza deploy',
'settings.onboardingAmrCloudBenefitModels': 'Molti modelli', 'settings.onboardingAmrCloudBenefitModels': 'Supporta Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'Prezzi migliori', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'In arrivo',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Immagini e video',
'settings.onboardingAmrCloudUpcomingSkills': 'Skills in massa',
'settings.onboardingAmrCloudUpcomingRouting': 'Routing intelligente',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'Autorizza AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'Autorizza AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Autorizzato', 'settings.onboardingAmrCloudAuthorizedAction': 'Autorizzato',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const ja: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': '公式メンテナンス', 'settings.onboardingAmrCloudBenefitOfficial': '公式おすすめ',
'settings.onboardingAmrCloudBenefitReady': 'すぐ使える', 'settings.onboardingAmrCloudBenefitReady': 'デプロイ不要',
'settings.onboardingAmrCloudBenefitModels': '多数のモデル', 'settings.onboardingAmrCloudBenefitModels': 'Claude Opus 4.8 に対応',
'settings.onboardingAmrCloudBenefitPricing': 'お得な価格', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': '近日対応',
'settings.onboardingAmrCloudUpcomingImageVideo': '画像/動画生成',
'settings.onboardingAmrCloudUpcomingSkills': '豊富な Skills',
'settings.onboardingAmrCloudUpcomingRouting': 'スマートルーティング',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'AMR を認証', 'settings.onboardingAmrCloudAuthorizeAction': 'AMR を認証',
'settings.onboardingAmrCloudAuthorizedAction': '認証済み', 'settings.onboardingAmrCloudAuthorizedAction': '認証済み',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const ko: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': '공식 관리', 'settings.onboardingAmrCloudBenefitOfficial': '공식 추천',
'settings.onboardingAmrCloudBenefitReady': '바로 사용', 'settings.onboardingAmrCloudBenefitReady': '배포 없이 사용',
'settings.onboardingAmrCloudBenefitModels': '다양한 모델', 'settings.onboardingAmrCloudBenefitModels': 'Claude Opus 4.8 지원',
'settings.onboardingAmrCloudBenefitPricing': '더 나은 가격', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': '곧 지원',
'settings.onboardingAmrCloudUpcomingImageVideo': '이미지/비디오',
'settings.onboardingAmrCloudUpcomingSkills': '방대한 Skills',
'settings.onboardingAmrCloudUpcomingRouting': '스마트 라우팅',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'AMR 인증', 'settings.onboardingAmrCloudAuthorizeAction': 'AMR 인증',
'settings.onboardingAmrCloudAuthorizedAction': '인증됨', 'settings.onboardingAmrCloudAuthorizedAction': '인증됨',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const pl: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Oficjalnie utrzymane', 'settings.onboardingAmrCloudBenefitOfficial': 'Oficjalnie polecane',
'settings.onboardingAmrCloudBenefitReady': 'Gotowe do użycia', 'settings.onboardingAmrCloudBenefitReady': 'Bez wdrażania',
'settings.onboardingAmrCloudBenefitModels': 'Wiele modeli', 'settings.onboardingAmrCloudBenefitModels': 'Obsługuje Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'Lepsza cena', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Wkrótce',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Obraz i wideo',
'settings.onboardingAmrCloudUpcomingSkills': 'Wiele Skills',
'settings.onboardingAmrCloudUpcomingRouting': 'Inteligentny routing',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'Autoryzuj AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'Autoryzuj AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Autoryzowano', 'settings.onboardingAmrCloudAuthorizedAction': 'Autoryzowano',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const ptBR: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Mantido oficialmente', 'settings.onboardingAmrCloudBenefitOfficial': 'Recomendado oficialmente',
'settings.onboardingAmrCloudBenefitReady': 'Pronto para usar', 'settings.onboardingAmrCloudBenefitReady': 'Sem deploy',
'settings.onboardingAmrCloudBenefitModels': 'Muitos modelos', 'settings.onboardingAmrCloudBenefitModels': 'Compatível com Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'Preço melhor', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Em breve',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Imagem e vídeo',
'settings.onboardingAmrCloudUpcomingSkills': 'Skills em massa',
'settings.onboardingAmrCloudUpcomingRouting': 'Roteamento inteligente',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'Autorizar AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'Autorizar AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Autorizado', 'settings.onboardingAmrCloudAuthorizedAction': 'Autorizado',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const ru: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Официальная поддержка', 'settings.onboardingAmrCloudBenefitOfficial': 'Официально рекомендовано',
'settings.onboardingAmrCloudBenefitReady': 'Готово к работе', 'settings.onboardingAmrCloudBenefitReady': 'Без развертывания',
'settings.onboardingAmrCloudBenefitModels': 'Много моделей', 'settings.onboardingAmrCloudBenefitModels': 'Поддерживает Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'Выгодная цена', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Скоро',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Изображения и видео',
'settings.onboardingAmrCloudUpcomingSkills': 'Много Skills',
'settings.onboardingAmrCloudUpcomingRouting': 'Умная маршрутизация',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'Авторизовать AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'Авторизовать AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Авторизовано', 'settings.onboardingAmrCloudAuthorizedAction': 'Авторизовано',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const th: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'ดูแลอย่างเป็นทางการ', 'settings.onboardingAmrCloudBenefitOfficial': 'แนะนำอย่างเป็นทางการ',
'settings.onboardingAmrCloudBenefitReady': 'พร้อมใช้งาน', 'settings.onboardingAmrCloudBenefitReady': 'ไม่ต้องดีพลอย',
'settings.onboardingAmrCloudBenefitModels': 'มีโมเดลให้เลือกมาก', 'settings.onboardingAmrCloudBenefitModels': 'รองรับ Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'ราคาดีกว่า', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'เร็ว ๆ นี้',
'settings.onboardingAmrCloudUpcomingImageVideo': 'รูปภาพ/วิดีโอ',
'settings.onboardingAmrCloudUpcomingSkills': 'Skills จำนวนมาก',
'settings.onboardingAmrCloudUpcomingRouting': 'การกำหนดเส้นทางอัจฉริยะ',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'อนุญาต AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'อนุญาต AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'อนุญาตแล้ว', 'settings.onboardingAmrCloudAuthorizedAction': 'อนุญาตแล้ว',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const tr: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Resmi bakım', 'settings.onboardingAmrCloudBenefitOfficial': 'Resmi olarak önerilir',
'settings.onboardingAmrCloudBenefitReady': 'Kullanıma hazır', 'settings.onboardingAmrCloudBenefitReady': 'Dağıtım gerekmez',
'settings.onboardingAmrCloudBenefitModels': 'Çok model seçeneği', 'settings.onboardingAmrCloudBenefitModels': 'Claude Opus 4.8 desteği',
'settings.onboardingAmrCloudBenefitPricing': 'Daha uygun fiyat', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Yakında',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Görsel ve video',
'settings.onboardingAmrCloudUpcomingSkills': 'Çok sayıda Skills',
'settings.onboardingAmrCloudUpcomingRouting': 'Akıllı yönlendirme',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'AMR yetkilendir', 'settings.onboardingAmrCloudAuthorizeAction': 'AMR yetkilendir',
'settings.onboardingAmrCloudAuthorizedAction': 'Yetkilendirildi', 'settings.onboardingAmrCloudAuthorizedAction': 'Yetkilendirildi',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -110,10 +110,15 @@ export const uk: Dict = {
'settings.onboardingExecutionTitle': 'Choose how generation runs', 'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.', 'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Офіційна підтримка', 'settings.onboardingAmrCloudBenefitOfficial': 'Офіційно рекомендовано',
'settings.onboardingAmrCloudBenefitReady': 'Готово до роботи', 'settings.onboardingAmrCloudBenefitReady': 'Без розгортання',
'settings.onboardingAmrCloudBenefitModels': 'Багато моделей', 'settings.onboardingAmrCloudBenefitModels': 'Підтримує Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': 'Вигідніша ціна', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': 'Незабаром',
'settings.onboardingAmrCloudUpcomingImageVideo': 'Зображення й відео',
'settings.onboardingAmrCloudUpcomingSkills': 'Багато Skills',
'settings.onboardingAmrCloudUpcomingRouting': 'Розумна маршрутизація',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': 'Авторизувати AMR', 'settings.onboardingAmrCloudAuthorizeAction': 'Авторизувати AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Авторизовано', 'settings.onboardingAmrCloudAuthorizedAction': 'Авторизовано',
'settings.onboardingStepConnect': "Connect", 'settings.onboardingStepConnect': "Connect",

View file

@ -97,10 +97,15 @@ export const zhCN: Dict = {
'settings.onboardingExecutionTitle': '选择生成方式', 'settings.onboardingExecutionTitle': '选择生成方式',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'官方 CLI一键配置、开箱即用。一个 Key 自由选择海量模型,价格更优惠。', '官方 CLI一键配置、开箱即用。一个 Key 自由选择海量模型,价格更优惠。',
'settings.onboardingAmrCloudBenefitOfficial': '官方维护', 'settings.onboardingAmrCloudBenefitOfficial': '官方推荐',
'settings.onboardingAmrCloudBenefitReady': '开箱即用', 'settings.onboardingAmrCloudBenefitReady': '免部署即用',
'settings.onboardingAmrCloudBenefitModels': '海量模型可选', 'settings.onboardingAmrCloudBenefitModels': '支持 Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': '价格优惠', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': '即将支持',
'settings.onboardingAmrCloudUpcomingImageVideo': '生图/视频',
'settings.onboardingAmrCloudUpcomingSkills': '海量 Skills',
'settings.onboardingAmrCloudUpcomingRouting': '智能路由',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': '授权使用', 'settings.onboardingAmrCloudAuthorizeAction': '授权使用',
'settings.onboardingAmrCloudAuthorizedAction': '已授权', 'settings.onboardingAmrCloudAuthorizedAction': '已授权',
'settings.onboardingStepConnect': "连接", 'settings.onboardingStepConnect': "连接",
@ -237,9 +242,9 @@ export const zhCN: Dict = {
'settings.agentAuthUnknown': '认证状态未知', 'settings.agentAuthUnknown': '认证状态未知',
'settings.amrCloud': 'Open Design AMR', 'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': '授权', 'settings.amrAuthorize': '授权',
'settings.amrBenefitOfficial': '官方维护', 'settings.amrBenefitOfficial': '官方推荐',
'settings.amrBenefitLowerPrice': '价格更低', 'settings.amrBenefitLowerPrice': '免部署即用',
'settings.amrBenefitManyModels': '海量模型', 'settings.amrBenefitManyModels': 'SOTA Harness',
'settings.amrPromoBonus': '限时充值赠 100%', 'settings.amrPromoBonus': '限时充值赠 100%',
'settings.amrSignInToContinue': '授权后继续', 'settings.amrSignInToContinue': '授权后继续',
'settings.amrSignIn': '登录', 'settings.amrSignIn': '登录',

View file

@ -113,10 +113,15 @@ export const zhTW: Dict = {
'settings.onboardingExecutionTitle': '選擇生成方式', 'settings.onboardingExecutionTitle': '選擇生成方式',
'settings.onboardingExecutionBody': 'settings.onboardingExecutionBody':
'官方 CLI一鍵配置、開箱即用。一個 Key 自由選擇海量模型,價格更優惠。', '官方 CLI一鍵配置、開箱即用。一個 Key 自由選擇海量模型,價格更優惠。',
'settings.onboardingAmrCloudBenefitOfficial': '官方維護', 'settings.onboardingAmrCloudBenefitOfficial': '官方推薦',
'settings.onboardingAmrCloudBenefitReady': '開箱即用', 'settings.onboardingAmrCloudBenefitReady': '免部署即用',
'settings.onboardingAmrCloudBenefitModels': '海量模型可選', 'settings.onboardingAmrCloudBenefitModels': '支援 Claude Opus 4.8',
'settings.onboardingAmrCloudBenefitPricing': '價格優惠', 'settings.onboardingAmrCloudBenefitPricing': 'SOTA Harness',
'settings.onboardingAmrCloudUpcomingLabel': '即將支援',
'settings.onboardingAmrCloudUpcomingImageVideo': '生圖/影片',
'settings.onboardingAmrCloudUpcomingSkills': '海量 Skills',
'settings.onboardingAmrCloudUpcomingRouting': '智慧路由',
'settings.onboardingAmrModelSourceLabel': 'AMR CLI',
'settings.onboardingAmrCloudAuthorizeAction': '授權使用', 'settings.onboardingAmrCloudAuthorizeAction': '授權使用',
'settings.onboardingAmrCloudAuthorizedAction': '已授權', 'settings.onboardingAmrCloudAuthorizedAction': '已授權',
'settings.onboardingStepConnect': "連接", 'settings.onboardingStepConnect': "連接",

View file

@ -118,6 +118,11 @@ export interface Dict {
'settings.onboardingAmrCloudBenefitReady': string; 'settings.onboardingAmrCloudBenefitReady': string;
'settings.onboardingAmrCloudBenefitModels': string; 'settings.onboardingAmrCloudBenefitModels': string;
'settings.onboardingAmrCloudBenefitPricing': string; 'settings.onboardingAmrCloudBenefitPricing': string;
'settings.onboardingAmrCloudUpcomingLabel': string;
'settings.onboardingAmrCloudUpcomingImageVideo': string;
'settings.onboardingAmrCloudUpcomingSkills': string;
'settings.onboardingAmrCloudUpcomingRouting': string;
'settings.onboardingAmrModelSourceLabel': string;
'settings.onboardingAmrCloudAuthorizeAction': string; 'settings.onboardingAmrCloudAuthorizeAction': string;
'settings.onboardingAmrCloudAuthorizedAction': string; 'settings.onboardingAmrCloudAuthorizedAction': string;
'settings.onboardingStepConnect': string; 'settings.onboardingStepConnect': string;

View file

@ -136,7 +136,7 @@ export const MEDIA_PROVIDERS: MediaProvider[] = [
{ {
id: 'custom-image', id: 'custom-image',
label: 'Custom Image API', label: 'Custom Image API',
hint: 'OpenAI-compatible /v1/images/generations (local or cloud)', hint: 'OpenAI-compatible images/generations + images/edits (local or cloud)',
integrated: true, integrated: true,
docsUrl: 'https://platform.openai.com/docs/api-reference/images', docsUrl: 'https://platform.openai.com/docs/api-reference/images',
supportsCustomModel: true, supportsCustomModel: true,
@ -417,13 +417,13 @@ export const IMAGE_MODELS: MediaModel[] = [
caps: ['t2i'], caps: ['t2i'],
}, },
// Custom OpenAI-compatible /v1/images/generations endpoint. // Custom OpenAI-compatible image generation + edit endpoints.
{ {
id: 'custom-image', id: 'custom-image',
label: 'custom-image', label: 'custom-image',
hint: 'Custom · OpenAI-compatible endpoint', hint: 'Custom · OpenAI-compatible endpoint',
provider: 'custom-image', provider: 'custom-image',
caps: ['t2i'], caps: ['t2i', 'i2i'],
}, },
// Black Forest Labs FLUX family. // Black Forest Labs FLUX family.

View file

@ -3,7 +3,7 @@
// we want a single source of truth for "what file is open" — encoding // we want a single source of truth for "what file is open" — encoding
// that in the URL is the simplest way to make it deep-linkable. // that in the URL is the simplest way to make it deep-linkable.
import { useEffect, useState } from 'react'; import { useSyncExternalStore } from 'react';
// Entry-shell sub-views. The home/project landing renders one of three // Entry-shell sub-views. The home/project landing renders one of three
// columns and each sub-view now owns a top-level path so the browser // columns and each sub-view now owns a top-level path so the browser
@ -137,6 +137,14 @@ export function buildPath(route: Route): string {
// Centralized navigation. Components call this instead of mutating // Centralized navigation. Components call this instead of mutating
// `window.location` directly so we can fan the change out to any // `window.location` directly so we can fan the change out to any
// `useRoute()` subscriber via a custom event. // `useRoute()` subscriber via a custom event.
//
// The `popstate` dispatch is deferred to a microtask so that callers
// can safely invoke `navigate()` from inside a `useState` updater or
// during a render commit phase without triggering React's
// "Cannot update a component while rendering a different component"
// warning. The `history` API call itself stays synchronous so the URL
// bar updates immediately; only the `useRoute()` subscriber updates
// are deferred past the current render.
export function navigate(route: Route, opts: { replace?: boolean } = {}): void { export function navigate(route: Route, opts: { replace?: boolean } = {}): void {
const target = buildPath(route); const target = buildPath(route);
const current = window.location.pathname; const current = window.location.pathname;
@ -146,15 +154,28 @@ export function navigate(route: Route, opts: { replace?: boolean } = {}): void {
} else { } else {
window.history.pushState(null, '', target); window.history.pushState(null, '', target);
} }
window.dispatchEvent(new PopStateEvent('popstate')); queueMicrotask(() => {
window.dispatchEvent(new PopStateEvent('popstate'));
});
}
let cachedPathname: string | null = null;
let cachedRoute: Route | null = null;
function getRouteSnapshot(): Route {
const pathname = window.location.pathname;
if (cachedPathname !== pathname || cachedRoute === null) {
cachedPathname = pathname;
cachedRoute = parseRoute(pathname);
}
return cachedRoute;
}
function subscribeToRouteChanges(onStoreChange: () => void): () => void {
window.addEventListener('popstate', onStoreChange);
return () => window.removeEventListener('popstate', onStoreChange);
} }
export function useRoute(): Route { export function useRoute(): Route {
const [route, setRoute] = useState<Route>(() => parseRoute(window.location.pathname)); return useSyncExternalStore(subscribeToRouteChanges, getRouteSnapshot, getRouteSnapshot);
useEffect(() => {
const onPop = () => setRoute(parseRoute(window.location.pathname));
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
return route;
} }

View file

@ -1016,7 +1016,7 @@ function meaningfulDomFallbackTarget(el) {
var tag = el.tagName ? el.tagName.toLowerCase() : ''; var tag = el.tagName ? el.tagName.toLowerCase() : '';
if (/^(a|button|input|textarea|select|label|img|video|canvas|h1|h2|h3|h4|h5|h6|p|li|td|th|section|article|main|aside|nav)$/.test(tag)) { if (/^(a|button|input|textarea|select|label|img|video|canvas|h1|h2|h3|h4|h5|h6|p|li|td|th)$/.test(tag)) {
return true; return true;
} }
@ -1045,9 +1045,13 @@ function meaningfulDomFallbackTarget(el) {
var text = (el.textContent || '').replace(/\s+/g, ' ').trim(); var text = (el.textContent || '').replace(/\s+/g, ' ').trim();
if (!text) return false; if (!text) return false;
if (/^(span|strong|em|b|i|small|code|mark)$/.test(tag)) return true;
var meaningfulChildren = 0; var meaningfulChildren = 0;
for (var child = el.firstElementChild;child;child = child.nextElementSibling) { for (var child = el.firstElementChild;child;child = child.nextElementSibling) {
if ((child.textContent || '').replace(/\s+/g, ' ').trim()) { var childTag = child.tagName ? child.tagName.toLowerCase() : '';
if (/^(script|style|template|meta|link|title|noscript)$/.test(childTag)) continue;
if ((child.textContent || '').replace(/\s+/g, ' ').trim() || /^(img|video|canvas|svg|input|textarea|select)$/.test(childTag)) {
meaningfulChildren++; meaningfulChildren++;
if (meaningfulChildren > 1) return false; if (meaningfulChildren > 1) return false;
} }
@ -1055,8 +1059,12 @@ function meaningfulDomFallbackTarget(el) {
return true; return true;
} }
function generatedRootAnnotation(el, id){
return id === 'path-0' && el && el.parentElement === document.body && el.id === 'root';
}
function targetFrom(el, allowDomFallback, clickedEl, clickPoint){ function targetFrom(el, allowDomFallback, clickedEl, clickPoint){
var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label'); var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label');
if (allowDomFallback && id && generatedRootAnnotation(el, id)) return null;
var selector = annotatedSelectorFor(el); var selector = annotatedSelectorFor(el);
if (!id && allowDomFallback && meaningfulDomFallbackTarget(el)) { if (!id && allowDomFallback && meaningfulDomFallbackTarget(el)) {
selector = domSelectorFor(el); selector = domSelectorFor(el);
@ -1069,9 +1077,6 @@ function meaningfulDomFallbackTarget(el) {
var html = ''; var html = '';
try { html = (el.outerHTML || '').replace(/\\s+/g, ' ').match(/^<[^>]+>/)?.[0] || ''; } catch (_) {} try { html = (el.outerHTML || '').replace(/\\s+/g, ' ').match(/^<[^>]+>/)?.[0] || ''; } catch (_) {}
var position = { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }; var position = { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) };
if (clickPoint) {
position = { x: Math.round(clickPoint.x), y: Math.round(clickPoint.y), width: 1, height: 1 };
}
var payload = { var payload = {
type: 'od:comment-target', type: 'od:comment-target',
elementId: id, elementId: id,
@ -1194,21 +1199,67 @@ function meaningfulDomFallbackTarget(el) {
window.parent.postMessage({ type: type, points: stroke.slice() }, '*'); window.parent.postMessage({ type: type, points: stroke.slice() }, '*');
} }
function canUseDomFallback(){ function canUseDomFallback(){
return commentEnabled && !inspectEnabled && document.querySelectorAll('[data-od-id], [data-screen-label]').length === 0; return commentEnabled && !inspectEnabled;
}
function eventCandidateElements(event){
var items = [];
function push(node){
if (!node || node.nodeType !== 1) return;
if (items.indexOf(node) >= 0) return;
items.push(node);
}
try {
if (event && typeof event.composedPath === 'function') {
var path = event.composedPath();
for (var i = 0; i < path.length; i++) push(path[i]);
}
} catch (_) {}
push(event && event.target);
try {
if (
event &&
typeof event.clientX === 'number' &&
typeof event.clientY === 'number' &&
document.elementsFromPoint
) {
var stack = document.elementsFromPoint(event.clientX, event.clientY);
for (var s = 0; s < stack.length; s++) push(stack[s]);
} else if (
event &&
typeof event.clientX === 'number' &&
typeof event.clientY === 'number' &&
document.elementFromPoint
) {
push(document.elementFromPoint(event.clientX, event.clientY));
}
} catch (_) {}
return items;
} }
function closestTarget(event){ function closestTarget(event){
var clicked = event.target; var candidates = eventCandidateElements(event);
var el = clicked;
var fallback = null;
var allowDomFallback = mode === 'picker' && canUseDomFallback(); var allowDomFallback = mode === 'picker' && canUseDomFallback();
while (el && el !== document.documentElement) { var annotatedFallback = null;
if (el.getAttribute && (el.hasAttribute('data-od-id') || el.hasAttribute('data-screen-label'))) { for (var i = 0; i < candidates.length; i++) {
return { target: el, clicked: clicked }; var clicked = candidates[i];
var el = clicked;
while (el && el !== document.documentElement) {
if (allowDomFallback && meaningfulDomFallbackTarget(el)) {
return { target: el, clicked: clicked };
}
if (el.getAttribute && (el.hasAttribute('data-od-id') || el.hasAttribute('data-screen-label'))) {
var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label');
if (allowDomFallback && generatedRootAnnotation(el, id)) {
el = el.parentElement;
continue;
}
if (allowDomFallback && !annotatedFallback) annotatedFallback = { target: el, clicked: clicked };
if (allowDomFallback) break;
return { target: el, clicked: clicked };
}
el = el.parentElement;
} }
if (!fallback && allowDomFallback && meaningfulDomFallbackTarget(el)) fallback = el;
el = el.parentElement;
} }
return fallback ? { target: fallback, clicked: clicked } : null; return annotatedFallback;
} }
function applyOverride(elementId, selector, prop, value){ function applyOverride(elementId, selector, prop, value){
if (!elementId || !prop) return; if (!elementId || !prop) return;
@ -1528,9 +1579,42 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
if (structured.length) return structured; if (structured.length) return structured;
return document.querySelectorAll('.slide'); return document.querySelectorAll('.slide');
} }
function scroller(){ function scrollOverflow(el){
if (document.body && document.body.scrollWidth > document.body.clientWidth + 1) return document.body; if (!el) return 0;
return document.scrollingElement || document.documentElement; return Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0));
}
function overflowMode(el){
if (!el || !window.getComputedStyle) return '';
try {
return String(window.getComputedStyle(el).overflowX || '').toLowerCase();
} catch (_) {
return '';
}
}
function isScrollableOverflowMode(mode){
return mode === 'auto' || mode === 'scroll' || mode === 'overlay';
}
function isClippedOverflowMode(mode){
return mode === 'hidden' || mode === 'clip';
}
function isRootScrollContainer(el){
return !!el && (
el === document.scrollingElement ||
el === document.documentElement ||
el === document.body
);
}
function rootScrollerClipped(){
return isClippedOverflowMode(overflowMode(document.documentElement)) ||
isClippedOverflowMode(overflowMode(document.body));
}
function scrollLeftOf(el){
if (!el) return 0;
try {
return Number(el.scrollLeft) || 0;
} catch (_) {
return 0;
}
} }
function scrollTargets(){ function scrollTargets(){
var targets = []; var targets = [];
@ -1560,7 +1644,15 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
return false; return false;
} }
function isScrollDeck(){ function isScrollDeck(){
return hasHorizontalScroll(); var targets = scrollTargets();
for (var i=0; i<targets.length; i++) {
var candidate = targets[i];
if (scrollOverflow(candidate) <= 1) continue;
var mode = overflowMode(candidate);
if (isScrollableOverflowMode(mode)) return true;
if (isRootScrollContainer(candidate) && !isClippedOverflowMode(mode) && !rootScrollerClipped()) return true;
}
return false;
} }
function findActiveByClass(list){ function findActiveByClass(list){
for (var i=0; i<list.length; i++) { for (var i=0; i<list.length; i++) {
@ -1612,8 +1704,27 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
} }
return 'active'; return 'active';
} }
function hasComputedHiddenSibling(list, active){
if (active < 0) return false;
for (var i=0; i<list.length; i++) {
if (i === active) continue;
try {
var cs = window.getComputedStyle(list[i]);
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return true;
} catch (_) {}
}
return false;
}
function canSetActive(list){ function canSetActive(list){
if (findActiveByClass(list) >= 0) return true; // A bare active-class marker is not enough to prove the host can drive the
// deck by class mutation alone. Many generated decks keep that marker in
// sync for counters / dots but move the visible slide via a translated
// stage or track, so flipping classes in the host bridge updates the
// reported slide index while leaving the canvas on the old page. Only
// treat class-driven decks as directly mutable when inactive siblings are
// actually hidden by computed visibility rules.
var active = findActiveByClass(list);
if (active >= 0 && hasComputedHiddenSibling(list, active)) return true;
for (var i=0; i<list.length; i++) { for (var i=0; i<list.length; i++) {
if (list[i].style.display === 'none') return true; if (list[i].style.display === 'none') return true;
if (list[i].style.visibility === 'hidden') return true; if (list[i].style.visibility === 'hidden') return true;

View file

@ -537,26 +537,24 @@
color: transparent; color: transparent;
} }
.composer-inline-mention { .composer-inline-mention {
display: inline; display: inline-block;
max-width: none; max-width: min(240px, 25vw);
margin: 0; margin: 0;
padding: 0; padding: 0 1px;
border: 0; border: 0;
border-radius: 5px; border-radius: 5px;
background: color-mix(in srgb, var(--accent) 8%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
color: color-mix(in srgb, var(--accent) 62%, var(--text)); color: color-mix(in srgb, var(--accent) 62%, var(--text));
font: inherit; font: inherit;
font-size: inherit; font-size: inherit;
font-weight: inherit; font-weight: inherit;
line-height: inherit; line-height: inherit;
letter-spacing: inherit; letter-spacing: inherit;
vertical-align: baseline; vertical-align: bottom;
overflow: visible; overflow: hidden;
text-overflow: clip; text-overflow: ellipsis;
white-space: inherit; white-space: nowrap;
} }
.composer-row { .composer-row {
display: flex; display: flex;

View file

@ -1453,6 +1453,17 @@
margin-top: 4px; margin-top: 4px;
} }
.onboarding-view__action-status {
margin-right: auto;
color: var(--text-muted);
font-size: 12px;
line-height: 1.3;
}
.onboarding-view__action-status.is-error {
color: var(--danger-text, #991b1b);
}
.onboarding-view__primary, .onboarding-view__primary,
.onboarding-view__secondary, .onboarding-view__secondary,
.onboarding-view__ghost { .onboarding-view__ghost {
@ -1868,11 +1879,14 @@
box-shadow: 0 18px 40px color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: 0 18px 40px color-mix(in srgb, var(--accent) 10%, transparent);
} }
.onboarding-view__card--featured.onboarding-view__card--official { .onboarding-view__card--featured.onboarding-view__card--amr {
grid-template-columns: 56px minmax(0, 1fr); grid-template-columns: max-content minmax(0, 1fr);
grid-template-rows: auto auto;
column-gap: 16px;
row-gap: 12px;
align-items: center; align-items: center;
min-height: 148px; min-height: 184px;
padding: 50px 24px 36px; padding: 34px 30px;
overflow: hidden; overflow: hidden;
} }
@ -1902,30 +1916,6 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.onboarding-view__official-tag {
position: absolute;
z-index: 2;
top: 0;
left: 0;
width: 88px;
height: 30px;
display: block;
overflow: hidden;
border-top-left-radius: inherit;
pointer-events: none;
user-select: none;
}
.onboarding-view__official-tag img {
position: absolute;
top: -4px;
left: -12px;
width: 92px;
height: 32px;
display: block;
max-width: none;
}
.onboarding-view__card-top { .onboarding-view__card-top {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1966,6 +1956,11 @@
border-radius: inherit; border-radius: inherit;
} }
.onboarding-view__card--amr > .onboarding-view__icon {
grid-column: 1;
grid-row: 1;
}
.onboarding-view__badge { .onboarding-view__badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -1988,10 +1983,37 @@
min-width: 0; min-width: 0;
} }
.onboarding-view__card--official .onboarding-view__card-copy { .onboarding-view__identity {
grid-column: 1;
grid-row: 1;
display: inline-flex;
align-items: center;
gap: 12px;
min-width: 0;
width: fit-content;
padding: 9px 12px 9px 9px;
border: 1px solid color-mix(in srgb, var(--border) 88%, var(--text-muted) 12%);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-panel) 98%, var(--text-muted) 2%);
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 4%, transparent);
}
.onboarding-view__card--amr.is-selected .onboarding-view__identity {
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
background: color-mix(in srgb, var(--bg-panel) 96%, var(--accent) 4%);
}
.onboarding-view__identity .onboarding-view__card-copy {
align-self: center; align-self: center;
gap: 6px; gap: 3px;
padding-right: 40px; padding-right: 0;
}
.onboarding-view__card-model {
grid-column: 1 / -1;
grid-row: 2;
min-width: 0;
align-self: start;
} }
.onboarding-view__card strong { .onboarding-view__card strong {
@ -2005,6 +2027,14 @@
font-size: 20px; font-size: 20px;
} }
.onboarding-view__card-meta {
display: block;
color: var(--text-muted);
font-size: 11.5px;
font-weight: 620;
line-height: 1.2;
}
.onboarding-view__card small { .onboarding-view__card small {
display: block; display: block;
margin-top: 0; margin-top: 0;
@ -2019,33 +2049,232 @@
font-size: 13px; font-size: 13px;
} }
.onboarding-view__benefit-stack {
display: grid;
gap: 9px;
min-width: 0;
}
.onboarding-view__benefit-aside {
grid-column: 2;
grid-row: 1;
display: grid;
min-width: 0;
width: min(100%, 430px);
justify-self: start;
}
.onboarding-view__benefit-aside .onboarding-view__benefit-stack {
gap: 7px;
}
.onboarding-view__model-picker {
display: grid;
gap: 8px;
width: min(260px, 100%);
cursor: default;
}
.onboarding-view__card-model .onboarding-view__model-picker {
width: 100%;
}
.onboarding-view__model-label {
display: inline-flex;
align-items: center;
gap: 0;
min-width: 0;
color: var(--text-strong);
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.onboarding-view__model-source {
display: inline-flex;
align-items: center;
min-height: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
white-space: nowrap;
}
.onboarding-view__model-source::before {
content: "·";
margin: 0 5px;
color: var(--text-muted);
}
.onboarding-view__model-source.live,
.onboarding-view__model-source.fallback {
color: inherit;
}
.onboarding-view__model-select-wrap {
position: relative;
display: block;
}
.onboarding-view__model-select-wrap select {
appearance: none;
-webkit-appearance: none;
display: block;
width: 100%;
min-height: 42px;
padding: 0 34px 0 13px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
color: var(--text-strong);
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
box-shadow: var(--shadow-xs);
cursor: pointer;
transition: background-color 160ms cubic-bezier(0.23, 1, 0.32, 1), border-color 160ms cubic-bezier(0.23, 1, 0.32, 1), color 160ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.onboarding-view__model-select-wrap select:hover {
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
background: color-mix(in srgb, var(--bg-panel) 97%, var(--accent) 3%);
}
.onboarding-view__model-select-wrap select:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 42%, transparent);
outline-offset: 2px;
}
.onboarding-view__model-select-chevron {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-soft);
pointer-events: none;
}
.onboarding-view__benefits { .onboarding-view__benefits {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 5px; gap: 6px;
width: fit-content;
max-width: 100%;
margin-top: 0; margin-top: 0;
} }
.onboarding-view__benefit-aside .onboarding-view__benefits {
flex-wrap: nowrap;
gap: 7px;
}
.onboarding-view__benefit-aside
.onboarding-view__benefits
> .onboarding-view__benefit {
min-height: 24px;
padding: 0 9px;
border-color: color-mix(in srgb, var(--accent) 32%, transparent);
color: var(--accent-strong);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 13%, var(--bg-panel)) 0%,
color-mix(in srgb, var(--accent) 7%, var(--bg-panel)) 100%
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 10%, transparent),
0 6px 16px color-mix(in srgb, var(--accent) 7%, transparent);
font-size: 11.5px;
font-weight: 760;
}
.onboarding-view__benefit { .onboarding-view__benefit {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 20px; min-height: 22px;
padding: 0 7px; padding: 0 8px;
border-radius: 999px; border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent-strong); color: color-mix(in srgb, var(--accent-strong) 92%, var(--text));
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel)); background: color-mix(in srgb, var(--accent) 5%, transparent);
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent);
font-size: 11px; font-size: 11px;
font-weight: 680; font-weight: 680;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
} }
.onboarding-view__benefit--hero {
min-height: 24px;
padding: 0 9px;
border-color: color-mix(in srgb, var(--accent) 34%, transparent);
color: var(--accent-strong);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 15%, var(--bg-panel)) 0%,
color-mix(in srgb, var(--accent) 7%, var(--bg-panel)) 100%
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 10%, transparent),
0 7px 18px color-mix(in srgb, var(--accent) 8%, transparent);
}
.onboarding-view__upcoming-benefits {
display: flex;
align-items: center;
justify-self: start;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
width: fit-content;
max-width: 100%;
margin-left: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.onboarding-view__upcoming-label {
display: inline-flex;
align-items: center;
min-height: 18px;
color: color-mix(in srgb, var(--text-muted) 74%, var(--text));
font-size: 10.5px;
font-weight: 650;
line-height: 1;
white-space: nowrap;
}
.onboarding-view__upcoming-label::after {
content: "";
width: 2px;
height: 2px;
margin-left: 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-muted) 40%, transparent);
}
.onboarding-view__benefit--upcoming {
min-height: 18px;
padding: 0 6px;
border-color: color-mix(in srgb, var(--text-muted) 8%, transparent);
color: color-mix(in srgb, var(--text-muted) 68%, var(--text));
background: color-mix(in srgb, var(--text-muted) 4%, transparent);
box-shadow: none;
font-size: 10px;
font-weight: 600;
}
.onboarding-view__card-status { .onboarding-view__card-status {
position: absolute; position: absolute;
left: 24px; left: 24px;
bottom: 16px; bottom: 18px;
z-index: 1; z-index: 1;
display: inline-flex; display: inline-flex;
max-width: min(260px, calc(100% - 72px)); max-width: min(260px, calc(100% - 72px));
@ -3249,15 +3478,33 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.onboarding-view__card--featured.onboarding-view__card--official { .onboarding-view__card--featured.onboarding-view__card--amr {
grid-template-columns: 56px minmax(0, 1fr); grid-template-columns: 1fr;
gap: 14px; gap: 14px;
align-items: center; align-items: center;
padding: 50px 18px 36px; min-height: 190px;
padding: 28px 18px 32px;
} }
.onboarding-view__card--official .onboarding-view__card-copy { .onboarding-view__identity {
padding-right: 40px; grid-column: 1 / -1;
width: 100%;
}
.onboarding-view__card--amr .onboarding-view__card-model {
grid-column: 1 / -1;
}
.onboarding-view__card--benefit-aside .onboarding-view__benefit-aside {
grid-column: 1 / -1;
grid-row: auto;
width: 100%;
padding-right: 0;
justify-self: stretch;
}
.onboarding-view__model-picker {
width: 100%;
} }
.onboarding-view__card-status { .onboarding-view__card-status {
@ -3265,4 +3512,5 @@
bottom: 16px; bottom: 16px;
max-width: min(240px, calc(100% - 72px)); max-width: min(240px, calc(100% - 72px));
} }
} }

View file

@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BoardComposerPopover } from '../../src/components/BoardComposerPopover';
import type { PreviewCommentSnapshot } from '../../src/comments';
afterEach(() => {
cleanup();
});
const target: PreviewCommentSnapshot = {
filePath: 'index.html',
elementId: 'hero-title',
selector: '#hero-title',
label: 'Hero title',
text: '',
position: { x: 0, y: 0, width: 100, height: 24 },
htmlHint: '',
selectionKind: 'element',
};
function renderPopover(onSendBatch: () => void, sending = false) {
return render(
<BoardComposerPopover
target={target}
existing={null}
draft="Tighten this heading"
notes={[]}
onDraft={() => {}}
onAddDraft={() => {}}
onRemoveQueuedNote={() => {}}
onClose={() => {}}
onSaveComment={() => {}}
onSendBatch={onSendBatch}
onRemoveMember={() => {}}
sending={sending}
t={((key: string) => String(key)) as never}
/>,
);
}
describe('BoardComposerPopover keyboard submit', () => {
it('sends the drafted comment with the primary Enter shortcut', () => {
const onSendBatch = vi.fn();
renderPopover(onSendBatch);
fireEvent.keyDown(screen.getByTestId('comment-popover-input'), { key: 'Enter', metaKey: true });
expect(onSendBatch).toHaveBeenCalledTimes(1);
});
it('does not send while disabled or while IME text is composing', () => {
const onSendBatch = vi.fn();
const { rerender } = renderPopover(onSendBatch, true);
const input = screen.getByTestId('comment-popover-input');
fireEvent.keyDown(input, { key: 'Enter', metaKey: true });
expect(onSendBatch).not.toHaveBeenCalled();
rerender(
<BoardComposerPopover
target={target}
existing={null}
draft="Tighten this heading"
notes={[]}
onDraft={() => {}}
onAddDraft={() => {}}
onRemoveQueuedNote={() => {}}
onClose={() => {}}
onSaveComment={() => {}}
onSendBatch={onSendBatch}
onRemoveMember={() => {}}
sending={false}
t={((key: string) => String(key)) as never}
/>,
);
fireEvent.compositionStart(input);
fireEvent.keyDown(input, { key: 'Enter', metaKey: true });
expect(onSendBatch).not.toHaveBeenCalled();
});
});

View file

@ -140,13 +140,15 @@ describe('EntryShell onboarding Open Design AMR runtime', () => {
const amrCloud = screen.getByRole('button', { name: /Open Design AMR/i }); const amrCloud = screen.getByRole('button', { name: /Open Design AMR/i });
expect(amrCloud.getAttribute('aria-pressed')).toBe('true'); expect(amrCloud.getAttribute('aria-pressed')).toBe('true');
expect(amrCloud.textContent).toContain('Officially maintained'); expect(amrCloud.textContent).toContain('Officially recommended');
expect(amrCloud.textContent).toContain('Ready to use'); expect(amrCloud.textContent).toContain('No deploy needed');
expect(amrCloud.textContent).toContain('Many models'); expect(amrCloud.textContent).toContain('Supports Claude Opus 4.8');
expect(amrCloud.textContent).toContain('Better pricing'); expect(amrCloud.textContent).toContain('SOTA Harness');
expect(amrCloud.textContent).toContain('Coming soon');
expect(amrCloud.textContent).toContain('AMR v0.1.0');
expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull(); expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull();
expect(screen.getByRole('button', { name: /Sign in to continue/i })).toBeTruthy(); expect(screen.getByRole('button', { name: /Sign in to continue/i })).toBeTruthy();
await screen.findByText('Not signed in'); expect(screen.queryByText('Not signed in')).toBeNull();
expect(screen.queryByRole('button', { name: /^Sign in$/i })).toBeNull(); expect(screen.queryByRole('button', { name: /^Sign in$/i })).toBeNull();
await waitFor(() => { await waitFor(() => {
expect(props.onModeChange).toHaveBeenCalledWith('daemon'); expect(props.onModeChange).toHaveBeenCalledWith('daemon');
@ -365,7 +367,8 @@ describe('EntryShell onboarding Open Design AMR runtime', () => {
) as typeof fetch; ) as typeof fetch;
renderOnboarding(); renderOnboarding();
expect(await screen.findByText('user@example.com')).toBeTruthy(); expect(await screen.findByText('AMR v0.1.0')).toBeTruthy();
expect(screen.queryByText('user@example.com')).toBeNull();
expect(screen.queryByText('Authorized')).toBeNull(); expect(screen.queryByText('Authorized')).toBeNull();
expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull(); expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull();

View file

@ -1859,6 +1859,22 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByText('Already sent to Claude')).toBeNull(); expect(screen.queryByText('Already sent to Claude')).toBeNull();
}); });
it('does not render the comments drawer over the preview while waiting for a configured dock portal', () => {
const { container } = render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
commentPortalId="project-comments-dock"
/>,
);
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
expect(container.querySelector('.comment-preview-layer > .comment-side-panel')).toBeNull();
});
it('shows the open comment count beside the comments icon', () => { it('shows the open comment count beside the comments icon', () => {
const openComment: PreviewComment = { const openComment: PreviewComment = {
id: 'comment-open', id: 'comment-open',

View file

@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { navigate, useRoute } from '../src/router';
function RouteLabel() {
const route = useRoute();
const label = route.kind === 'home' ? route.view : route.kind;
return <div data-testid="route-label">{label}</div>;
}
function NavigateFromUpdater() {
const [didNavigate, setDidNavigate] = useState(false);
useEffect(() => {
if (didNavigate) return;
setDidNavigate(() => {
navigate({ kind: 'home', view: 'onboarding' }, { replace: true });
return true;
});
}, [didNavigate]);
return <RouteLabel />;
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
});
}
describe('navigate / useRoute timing', () => {
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
window.history.replaceState(null, '', '/');
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
cleanup();
consoleError.mockRestore();
window.history.replaceState(null, '', '/');
});
it('updates history synchronously and notifies listeners after the microtask boundary', async () => {
const onPop = vi.fn();
window.addEventListener('popstate', onPop);
navigate({ kind: 'home', view: 'onboarding' }, { replace: true });
expect(window.location.pathname).toBe('/onboarding');
expect(onPop).not.toHaveBeenCalled();
await flushMicrotasks();
expect(onPop).toHaveBeenCalledTimes(1);
window.removeEventListener('popstate', onPop);
});
it('updates route subscribers after render-phase updater navigation without React warnings', async () => {
render(<NavigateFromUpdater />);
await flushMicrotasks();
await waitFor(() => {
expect(screen.getByTestId('route-label').textContent).toBe('onboarding');
});
expect(window.location.pathname).toBe('/onboarding');
const warningCalls = consoleError.mock.calls.filter((call: unknown[]) =>
String(call[0]).includes('Cannot update a component'),
);
expect(warningCalls).toEqual([]);
});
});

View file

@ -7,6 +7,9 @@ import type { DOMWindow } from 'jsdom';
const tasteEditorialExamplePath = fileURLToPath( const tasteEditorialExamplePath = fileURLToPath(
new URL('../../../../design-templates/html-ppt-taste-editorial/example.html', import.meta.url), new URL('../../../../design-templates/html-ppt-taste-editorial/example.html', import.meta.url),
); );
const simpleDeckExamplePath = fileURLToPath(
new URL('../../../../design-templates/simple-deck/example.html', import.meta.url),
);
function setupTasteEditorialDeck() { function setupTasteEditorialDeck() {
const html = readFileSync(tasteEditorialExamplePath, 'utf8'); const html = readFileSync(tasteEditorialExamplePath, 'utf8');
@ -19,6 +22,70 @@ function setupTasteEditorialDeck() {
return dom; return dom;
} }
function setupSimpleDeck() {
const html = readFileSync(simpleDeckExamplePath, 'utf8');
const dom = new JSDOM(html, {
pretendToBeVisual: true,
runScripts: 'dangerously',
url: 'https://example.test/simple-deck.html',
virtualConsole: new VirtualConsole(),
});
const { window: win } = dom;
Object.defineProperty(win, 'innerWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.body, 'scrollWidth', {
configurable: true,
value: 6000,
});
Object.defineProperty(win.document.body, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.documentElement, 'scrollWidth', {
configurable: true,
value: 6000,
});
Object.defineProperty(win.document.documentElement, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document, 'scrollingElement', {
configurable: true,
value: win.document.documentElement,
});
let bodyScrollLeft = 0;
let documentScrollLeft = 0;
Object.defineProperty(win.document.body, 'scrollLeft', {
configurable: true,
get: () => bodyScrollLeft,
set: (_value: number) => {
bodyScrollLeft = 0;
},
});
Object.defineProperty(win.document.documentElement, 'scrollLeft', {
configurable: true,
get: () => documentScrollLeft,
set: (value: number) => {
documentScrollLeft = value;
},
});
Object.defineProperty(win.document.body, 'scrollTo', {
configurable: true,
value: () => {},
});
Object.defineProperty(win.document.documentElement, 'scrollTo', {
configurable: true,
value: ({ left }: { left?: number }) => {
if (typeof left === 'number') {
documentScrollLeft = left;
}
},
});
return dom;
}
function activeSlideIndex(win: DOMWindow) { function activeSlideIndex(win: DOMWindow) {
const slides = Array.from(win.document.querySelectorAll<HTMLElement>('.deck > .slide')); const slides = Array.from(win.document.querySelectorAll<HTMLElement>('.deck > .slide'));
return slides.findIndex((slide) => slide.classList.contains('active')); return slides.findIndex((slide) => slide.classList.contains('active'));
@ -71,4 +138,33 @@ describe('design template deck navigation', () => {
expect(dots[6]?.classList.contains('active')).toBe(true); expect(dots[6]?.classList.contains('active')).toBe(true);
expect(dots[6]?.getAttribute('aria-current')).toBe('true'); expect(dots[6]?.getAttribute('aria-current')).toBe('true');
}); });
it('keeps simple-deck keyboard navigation single-step and synced to documentElement scroll', () => {
const dom = setupSimpleDeck();
const { window: win } = dom;
const counter = win.document.getElementById('counter');
expect(counter?.textContent?.trim()).toBe('1 / 6');
expect(win.document.documentElement.scrollLeft).toBe(0);
win.document.body.dispatchEvent(new win.KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'ArrowRight',
}));
win.document.dispatchEvent(new win.Event('scroll', { bubbles: true }));
expect(counter?.textContent?.trim()).toBe('2 / 6');
expect(win.document.documentElement.scrollLeft).toBe(1000);
win.document.body.dispatchEvent(new win.KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'ArrowRight',
}));
win.document.dispatchEvent(new win.Event('scroll', { bubbles: true }));
expect(counter?.textContent?.trim()).toBe('3 / 6');
expect(win.document.documentElement.scrollLeft).toBe(2000);
});
}); });

View file

@ -16,12 +16,10 @@ import { buildSrcdoc } from '../../src/runtime/srcdoc';
// this, FileViewer's `liveCommentTargets` map never updates and // this, FileViewer's `liveCommentTargets` map never updates and
// the hint banner sticks at its instructive-default state even // the hint banner sticks at its instructive-default state even
// though there's nothing to click. // though there's nothing to click.
// 2. Drop click events on unannotated elements without posting an // 2. In Comment picker mode, fall back to meaningful DOM selectors
// `od:comment-target` — the click handler walks up to <html>, // for unannotated surfaces so imported or partially annotated HTML
// finds nothing tagged, and must bail. Posting a synthetic id // can still be reviewed. Inspect mode still needs a real annotated
// here would change save-to-source semantics for inspect // selector for live style persistence.
// overrides (the persisted CSS keys off the same elementId), so
// this test pins the no-fallback contract.
// //
// The host-side hint switch lives in `apps/web/src/components/FileViewer.tsx` // The host-side hint switch lives in `apps/web/src/components/FileViewer.tsx`
// (search for `inspect-empty-hint-no-targets`); these tests pin the // (search for `inspect-empty-hint-no-targets`); these tests pin the
@ -252,7 +250,7 @@ describe('selection bridge — empty annotation surface (#890)', () => {
expect(clickMessages[0].text).toBe('Launch'); expect(clickMessages[0].text).toBe('Launch');
}); });
it('does not use Picker DOM fallback on mixed annotated and unannotated pages', async () => { it('uses Picker DOM fallback for meaningful unannotated elements on mixed pages', async () => {
const { win, parentPostMessage } = setupBridgeDom( const { win, parentPostMessage } = setupBridgeDom(
'<main><section data-od-id="hero">Hero</section><button id="cta">Launch</button></main>', '<main><section data-od-id="hero">Hero</section><button id="cta">Launch</button></main>',
'comment', 'comment',
@ -269,7 +267,67 @@ describe('selection bridge — empty annotation surface (#890)', () => {
const clickMessages = parentPostMessage.mock.calls const clickMessages = parentPostMessage.mock.calls
.map((call) => call[0]) .map((call) => call[0])
.filter((message) => message?.type === 'od:comment-target'); .filter((message) => message?.type === 'od:comment-target');
expect(clickMessages).toEqual([]); expect(clickMessages).toHaveLength(1);
expect(clickMessages[0]).toMatchObject({
elementId: 'dom:body > main:nth-of-type(1) > button:nth-of-type(1)',
selector: 'body > main:nth-of-type(1) > button:nth-of-type(1)',
text: 'Launch',
});
});
it('uses leaf-first DOM fallback in Comment mode instead of an annotated section ancestor', async () => {
const { win, parentPostMessage } = setupBridgeDom(
'<section data-screen-label="01 Hero"><h1 id="headline"><span>ERP</span> and CRM delivery</h1><p>Subhead</p></section>',
'comment',
['#headline'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
parentPostMessage.mockClear();
win.document.getElementById('headline')!.dispatchEvent(
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
);
const clickMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-target');
expect(clickMessages).toHaveLength(1);
expect(clickMessages[0]).toMatchObject({
elementId: 'dom:body > section:nth-of-type(1) > h1:nth-of-type(1)',
selector: 'body > section:nth-of-type(1) > h1:nth-of-type(1)',
label: 'h1',
text: 'ERP and CRM delivery',
position: {
x: 10,
y: 20,
width: 120,
height: 48,
},
});
});
it('does not broadcast the generated React root as a page-sized Comment target', async () => {
const { win, parentPostMessage } = setupBridgeDom(
'<div id="root" data-od-id="path-0"><header><a>Brand</a></header><main><h1 id="headline">Hero</h1></main><footer>Footer</footer></div>',
'comment',
['#root', '#headline'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
const targetMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-targets');
expect(targetMessages.length).toBeGreaterThan(0);
const last = targetMessages.at(-1);
expect(last.targets).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
elementId: 'path-0',
}),
]),
);
}); });
it('broadcasts DOM fallback targets in comment mode so Pods can hit-test unannotated pages', async () => { it('broadcasts DOM fallback targets in comment mode so Pods can hit-test unannotated pages', async () => {

View file

@ -0,0 +1,207 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { buildSrcdoc } from '../../src/runtime/srcdoc';
function extractDeckBridgeScript(srcdoc: string): string {
const match = srcdoc.match(/<script data-od-deck-bridge>([\s\S]*?)<\/script>/);
if (!match || !match[1]) {
throw new Error('deck bridge script not found in srcdoc');
}
return match[1];
}
function lastSlideState(parentPostMessage: ReturnType<typeof vi.fn>) {
const messages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((m) => m?.type === 'od:slide-state');
return messages.at(-1);
}
describe('deck bridge - scroll container fallback', () => {
it('treats a wide default root scroller as a scroll deck even without explicit overflow-x styling', async () => {
const bodyHtml = `
<section class="slide">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
`;
const srcdoc = buildSrcdoc(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
deck: true,
});
const script = extractDeckBridgeScript(srcdoc);
const dom = new JSDOM(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const win = dom.window;
const parentPostMessage = vi.fn();
Object.defineProperty(win, 'parent', {
configurable: true,
value: { postMessage: parentPostMessage },
});
Object.defineProperty(win, 'innerWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.body, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.body, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.documentElement, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.documentElement, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document, 'scrollingElement', {
configurable: true,
value: win.document.documentElement,
});
let bodyScrollLeft = 0;
let documentScrollLeft = 0;
Object.defineProperty(win.document.body, 'scrollLeft', {
configurable: true,
get: () => bodyScrollLeft,
set: (_value: number) => {
bodyScrollLeft = 0;
},
});
Object.defineProperty(win.document.documentElement, 'scrollLeft', {
configurable: true,
get: () => documentScrollLeft,
set: (value: number) => {
documentScrollLeft = value;
},
});
Object.defineProperty(win.document.body, 'scrollTo', {
configurable: true,
value: () => {},
});
Object.defineProperty(win.document.documentElement, 'scrollTo', {
configurable: true,
value: ({ left }: { left?: number }) => {
if (typeof left === 'number') {
win.document.documentElement.scrollLeft = left;
}
},
});
const evaluate = new win.Function(script);
evaluate.call(win);
win.dispatchEvent(new win.Event('load'));
win.dispatchEvent(new win.MessageEvent('message', {
data: { type: 'od:slide', action: 'next' },
}));
await new Promise<void>((resolve) => win.setTimeout(resolve, 420));
expect(win.document.body.scrollLeft).toBe(0);
expect(win.document.documentElement.scrollLeft).toBe(1000);
expect(lastSlideState(parentPostMessage)).toMatchObject({ active: 1, count: 3 });
});
it('tracks slide state from documentElement when body scrollLeft stays at zero', async () => {
const bodyHtml = `
<style>
body { overflow-x: auto; }
</style>
<section class="slide">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
`;
const srcdoc = buildSrcdoc(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
deck: true,
});
const script = extractDeckBridgeScript(srcdoc);
const dom = new JSDOM(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const win = dom.window;
const parentPostMessage = vi.fn();
Object.defineProperty(win, 'parent', {
configurable: true,
value: { postMessage: parentPostMessage },
});
Object.defineProperty(win, 'innerWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.body, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.body, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.documentElement, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.documentElement, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document, 'scrollingElement', {
configurable: true,
value: win.document.documentElement,
});
let bodyScrollLeft = 0;
let documentScrollLeft = 0;
Object.defineProperty(win.document.body, 'scrollLeft', {
configurable: true,
get: () => bodyScrollLeft,
set: (_value: number) => {
bodyScrollLeft = 0;
},
});
Object.defineProperty(win.document.documentElement, 'scrollLeft', {
configurable: true,
get: () => documentScrollLeft,
set: (value: number) => {
documentScrollLeft = value;
},
});
Object.defineProperty(win.document.body, 'scrollTo', {
configurable: true,
value: () => {},
});
Object.defineProperty(win.document.documentElement, 'scrollTo', {
configurable: true,
value: ({ left }: { left?: number }) => {
if (typeof left === 'number') {
win.document.documentElement.scrollLeft = left;
}
},
});
const evaluate = new win.Function(script);
evaluate.call(win);
win.dispatchEvent(new win.Event('load'));
win.dispatchEvent(new win.MessageEvent('message', {
data: { type: 'od:slide', action: 'next' },
}));
await new Promise<void>((resolve) => win.setTimeout(resolve, 420));
expect(win.document.body.scrollLeft).toBe(0);
expect(win.document.documentElement.scrollLeft).toBe(1000);
expect(lastSlideState(parentPostMessage)).toMatchObject({ active: 1, count: 3 });
win.dispatchEvent(new win.MessageEvent('message', {
data: { type: 'od:slide', action: 'next' },
}));
await new Promise<void>((resolve) => win.setTimeout(resolve, 420));
expect(win.document.documentElement.scrollLeft).toBe(2000);
expect(lastSlideState(parentPostMessage)).toMatchObject({ active: 2, count: 3 });
});
});

View file

@ -0,0 +1,143 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { buildSrcdoc } from '../../src/runtime/srcdoc';
function extractDeckBridgeScript(srcdoc: string): string {
const match = srcdoc.match(/<script data-od-deck-bridge>([\s\S]*?)<\/script>/);
if (!match || !match[1]) {
throw new Error('deck bridge script not found in srcdoc');
}
return match[1];
}
function setupTransformDeck() {
const bodyHtml = `
<style>
html, body { margin: 0; }
body { overflow-x: hidden; }
.deck-shell { width: 100vw; overflow: hidden; }
.deck-track { display: flex; width: 300vw; }
.slide { flex: 0 0 100vw; }
</style>
<div class="deck-shell">
<div class="deck-track" id="deck-track">
<section class="slide active">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
</div>
</div>
`;
const srcdoc = buildSrcdoc(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
deck: true,
});
const script = extractDeckBridgeScript(srcdoc);
const dom = new JSDOM(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const win = dom.window;
const parentPostMessage = vi.fn();
Object.defineProperty(win, 'parent', {
configurable: true,
value: { postMessage: parentPostMessage },
});
Object.defineProperty(win, 'innerWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.body, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.body, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document.documentElement, 'scrollWidth', {
configurable: true,
value: 3000,
});
Object.defineProperty(win.document.documentElement, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(win.document, 'scrollingElement', {
configurable: true,
value: win.document.documentElement,
});
let bodyScrollLeft = 0;
let documentScrollLeft = 0;
Object.defineProperty(win.document.body, 'scrollLeft', {
configurable: true,
get: () => bodyScrollLeft,
set: (value: number) => {
bodyScrollLeft = value;
},
});
Object.defineProperty(win.document.documentElement, 'scrollLeft', {
configurable: true,
get: () => documentScrollLeft,
set: (value: number) => {
documentScrollLeft = value;
},
});
Object.defineProperty(win.document.body, 'scrollTo', {
configurable: true,
value: ({ left }: { left?: number }) => {
if (typeof left === 'number') {
bodyScrollLeft = left;
}
},
});
Object.defineProperty(win.document.documentElement, 'scrollTo', {
configurable: true,
value: ({ left }: { left?: number }) => {
if (typeof left === 'number') {
documentScrollLeft = left;
}
},
});
const slides = Array.from(win.document.querySelectorAll<HTMLElement>('.slide'));
const track = win.document.getElementById('deck-track') as HTMLElement;
let active = 0;
function apply(index: number) {
active = Math.max(0, Math.min(slides.length - 1, index));
slides.forEach((slide, i) => {
slide.classList.toggle('active', i === active);
});
track.style.transform = `translateX(-${active * 100}vw)`;
}
win.document.addEventListener('keydown', (event) => {
if (event.key === 'ArrowRight') apply(active + 1);
else if (event.key === 'ArrowLeft') apply(active - 1);
else if (event.key === 'Home') apply(0);
else if (event.key === 'End') apply(slides.length - 1);
});
apply(0);
const evaluate = new win.Function(script);
evaluate.call(win);
return { win, parentPostMessage, track };
}
describe('deck bridge - transform-driven decks', () => {
it('routes host navigation through the deck runtime even when the transformed track overflows horizontally', async () => {
const { win, track, parentPostMessage } = setupTransformDeck();
win.dispatchEvent(new win.MessageEvent('message', {
data: { type: 'od:slide', action: 'next' },
}));
await new Promise<void>((resolve) => win.setTimeout(resolve, 360));
expect(track.style.transform).toBe('translateX(-100vw)');
expect(win.document.body.scrollLeft).toBe(0);
expect(win.document.documentElement.scrollLeft).toBe(0);
const slideStates = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:slide-state');
expect(slideStates.at(-1)).toMatchObject({ active: 1, count: 3 });
});
});

View file

@ -88,7 +88,8 @@ describe('buildSrcdoc', () => {
const canSetActive = srcdoc.match(/function canSetActive\(list\)\{([\s\S]*?)\n \}/)?.[1] ?? ''; const canSetActive = srcdoc.match(/function canSetActive\(list\)\{([\s\S]*?)\n \}/)?.[1] ?? '';
expect(canSetActive).toContain('findActiveByClass(list) >= 0'); expect(canSetActive).toContain('var active = findActiveByClass(list);');
expect(canSetActive).toContain('hasComputedHiddenSibling(list, active)');
expect(canSetActive).toContain("list[i].style.display === 'none'"); expect(canSetActive).toContain("list[i].style.display === 'none'");
expect(canSetActive).toContain("list[i].style.visibility === 'hidden'"); expect(canSetActive).toContain("list[i].style.visibility === 'hidden'");
expect(canSetActive).toContain("list[i].hasAttribute('hidden')"); expect(canSetActive).toContain("list[i].hasAttribute('hidden')");

View file

@ -295,9 +295,48 @@
var KEY = 'od-deck-pos'; var KEY = 'od-deck-pos';
var active = 0; var active = 0;
function scrollContainers() {
return [document.scrollingElement, document.documentElement, document.body]
.filter(Boolean)
.filter(function (el, idx, arr) { return arr.indexOf(el) === idx; });
}
function overflowX(el) {
if (!el) return 0;
return Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0));
}
function scrollLeftOf(el) {
if (!el) return 0;
try { return Number(el.scrollLeft) || 0; } catch (_) { return 0; }
}
function activeScrollLeft() {
var candidates = scrollContainers();
var best = 0;
for (var i = 0; i < candidates.length; i++) {
var left = scrollLeftOf(candidates[i]);
if (Math.abs(left) > Math.abs(best)) best = left;
}
return best;
}
function scroller() { function scroller() {
if (document.body.scrollWidth > document.body.clientWidth + 1) return document.body; var candidates = scrollContainers();
return document.scrollingElement || document.documentElement; var best = candidates[0] || document.documentElement;
var bestScore = -1;
for (var i = 0; i < candidates.length; i++) {
var el = candidates[i];
var score = overflowX(el) + Math.abs(scrollLeftOf(el)) * 2;
if (score > bestScore) {
best = el;
bestScore = score;
}
}
return best;
}
function scrollToLeft(left, behavior) {
var candidates = scrollContainers();
for (var i = 0; i < candidates.length; i++) {
try { candidates[i].scrollLeft = left; } catch (_) {}
try { candidates[i].scrollTo({ left: left, behavior: behavior || 'smooth' }); } catch (_) {}
}
} }
function setActive(i) { function setActive(i) {
active = i; active = i;
@ -308,13 +347,14 @@
function go(i) { function go(i) {
var next = Math.max(0, Math.min(slides.length - 1, i)); var next = Math.max(0, Math.min(slides.length - 1, i));
setActive(next); setActive(next);
scroller().scrollTo({ left: next * window.innerWidth, behavior: 'smooth' }); scrollToLeft(next * window.innerWidth, 'smooth');
} }
function syncFromScroll() { function syncFromScroll() {
var i = Math.round(scroller().scrollLeft / window.innerWidth); var i = Math.round(activeScrollLeft() / window.innerWidth);
if (i !== active && i >= 0 && i < slides.length) setActive(i); if (i !== active && i >= 0 && i < slides.length) setActive(i);
} }
function onKey(e) { function onKey(e) {
if (e.defaultPrevented) return;
var t = e.target; var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(active + 1); } if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(active + 1); }
@ -342,7 +382,7 @@
var saved = parseInt(localStorage.getItem(KEY) || '0', 10); var saved = parseInt(localStorage.getItem(KEY) || '0', 10);
if (!isNaN(saved) && saved >= 0 && saved < slides.length) { if (!isNaN(saved) && saved >= 0 && saved < slides.length) {
setActive(saved); setActive(saved);
scroller().scrollTo({ left: saved * window.innerWidth, behavior: 'instant' }); scrollToLeft(saved * window.innerWidth, 'instant');
} else { } else {
setActive(0); setActive(0);
} }

View file

@ -96,25 +96,54 @@
// Detect the real scroller — when body has `display: flex` + `overflow-x: auto` // Detect the real scroller — when body has `display: flex` + `overflow-x: auto`
// the scroller can be body OR documentElement depending on the host (in // the scroller can be body OR documentElement depending on the host (in
// particular, the OD srcdoc iframe). Pick whichever actually overflows. // particular, the OD srcdoc iframe). Pick whichever actually overflows.
function scrollContainers() {
return [document.scrollingElement, document.documentElement, document.body]
.filter(Boolean)
.filter((el, idx, arr) => arr.indexOf(el) === idx);
}
function overflowX(el) {
if (!el) return 0;
return Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0));
}
function scrollLeftOf(el) {
if (!el) return 0;
try { return Number(el.scrollLeft) || 0; } catch (_) { return 0; }
}
function activeScrollLeft() {
return scrollContainers().reduce((best, el) => {
const left = scrollLeftOf(el);
return Math.abs(left) > Math.abs(best) ? left : best;
}, 0);
}
function scroller() { function scroller() {
if (document.body.scrollWidth > document.body.clientWidth + 1) return document.body; return scrollContainers().reduce((best, el) => {
return document.scrollingElement || document.documentElement; const bestScore = overflowX(best) + Math.abs(scrollLeftOf(best)) * 2;
const score = overflowX(el) + Math.abs(scrollLeftOf(el)) * 2;
return score > bestScore ? el : best;
}, scrollContainers()[0] || document.documentElement);
}
function scrollToLeft(left, behavior) {
for (const el of scrollContainers()) {
try { el.scrollLeft = left; } catch (_) {}
try { el.scrollTo({ left, behavior: behavior || 'smooth' }); } catch (_) {}
}
} }
function go(i) { function go(i) {
const next = Math.max(0, Math.min(slides.length - 1, i)); const next = Math.max(0, Math.min(slides.length - 1, i));
active = next; active = next;
counter.textContent = (next + 1) + ' / ' + slides.length; counter.textContent = (next + 1) + ' / ' + slides.length;
scroller().scrollTo({ left: next * window.innerWidth, behavior: 'smooth' }); scrollToLeft(next * window.innerWidth, 'smooth');
} }
function syncFromScroll() { function syncFromScroll() {
const i = Math.round(scroller().scrollLeft / window.innerWidth); const i = Math.round(activeScrollLeft() / window.innerWidth);
if (i !== active && i >= 0 && i < slides.length) { if (i !== active && i >= 0 && i < slides.length) {
active = i; active = i;
counter.textContent = (i + 1) + ' / ' + slides.length; counter.textContent = (i + 1) + ' / ' + slides.length;
} }
} }
function onKey(e) { function onKey(e) {
if (e.defaultPrevented) return;
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return; if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(active + 1); } if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(active + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); go(active - 1); } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); go(active - 1); }

View file

@ -988,6 +988,7 @@ export interface ProjectsListControlsClickProps {
| 'your_designs' | 'your_designs'
| 'search_input' | 'search_input'
| 'select' | 'select'
| 'create_project'
| 'grid_view' | 'grid_view'
| 'list_view'; | 'list_view';
} }

View file

@ -295,9 +295,48 @@
var KEY = 'od-deck-pos'; var KEY = 'od-deck-pos';
var active = 0; var active = 0;
function scrollContainers() {
return [document.scrollingElement, document.documentElement, document.body]
.filter(Boolean)
.filter(function (el, idx, arr) { return arr.indexOf(el) === idx; });
}
function overflowX(el) {
if (!el) return 0;
return Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0));
}
function scrollLeftOf(el) {
if (!el) return 0;
try { return Number(el.scrollLeft) || 0; } catch (_) { return 0; }
}
function activeScrollLeft() {
var candidates = scrollContainers();
var best = 0;
for (var i = 0; i < candidates.length; i++) {
var left = scrollLeftOf(candidates[i]);
if (Math.abs(left) > Math.abs(best)) best = left;
}
return best;
}
function scroller() { function scroller() {
if (document.body.scrollWidth > document.body.clientWidth + 1) return document.body; var candidates = scrollContainers();
return document.scrollingElement || document.documentElement; var best = candidates[0] || document.documentElement;
var bestScore = -1;
for (var i = 0; i < candidates.length; i++) {
var el = candidates[i];
var score = overflowX(el) + Math.abs(scrollLeftOf(el)) * 2;
if (score > bestScore) {
best = el;
bestScore = score;
}
}
return best;
}
function scrollToLeft(left, behavior) {
var candidates = scrollContainers();
for (var i = 0; i < candidates.length; i++) {
try { candidates[i].scrollLeft = left; } catch (_) {}
try { candidates[i].scrollTo({ left: left, behavior: behavior || 'smooth' }); } catch (_) {}
}
} }
function setActive(i) { function setActive(i) {
active = i; active = i;
@ -308,13 +347,14 @@
function go(i) { function go(i) {
var next = Math.max(0, Math.min(slides.length - 1, i)); var next = Math.max(0, Math.min(slides.length - 1, i));
setActive(next); setActive(next);
scroller().scrollTo({ left: next * window.innerWidth, behavior: 'smooth' }); scrollToLeft(next * window.innerWidth, 'smooth');
} }
function syncFromScroll() { function syncFromScroll() {
var i = Math.round(scroller().scrollLeft / window.innerWidth); var i = Math.round(activeScrollLeft() / window.innerWidth);
if (i !== active && i >= 0 && i < slides.length) setActive(i); if (i !== active && i >= 0 && i < slides.length) setActive(i);
} }
function onKey(e) { function onKey(e) {
if (e.defaultPrevented) return;
var t = e.target; var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(active + 1); } if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(active + 1); }
@ -342,7 +382,7 @@
var saved = parseInt(localStorage.getItem(KEY) || '0', 10); var saved = parseInt(localStorage.getItem(KEY) || '0', 10);
if (!isNaN(saved) && saved >= 0 && saved < slides.length) { if (!isNaN(saved) && saved >= 0 && saved < slides.length) {
setActive(saved); setActive(saved);
scroller().scrollTo({ left: saved * window.innerWidth, behavior: 'instant' }); scrollToLeft(saved * window.innerWidth, 'instant');
} else { } else {
setActive(0); setActive(0);
} }

View file

@ -398,6 +398,8 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise<void> {
await runPnpm(config, ["--filter", "@open-design/download", "build"]); await runPnpm(config, ["--filter", "@open-design/download", "build"]);
await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]); await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]);
await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]); await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]);
await runPnpm(config, ["--filter", "@open-design/download", "build"]);
await runPnpm(config, ["--filter", "@open-design/host", "build"]);
await runPnpm(config, ["--filter", "@open-design/diagnostics", "build"]); await runPnpm(config, ["--filter", "@open-design/diagnostics", "build"]);
await runPnpm(config, ["--filter", "@open-design/daemon", "build"]); await runPnpm(config, ["--filter", "@open-design/daemon", "build"]);
try { try {

View file

@ -22,7 +22,6 @@ export const MAC_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES = [
"@open-design/daemon", "@open-design/daemon",
"@open-design/desktop", "@open-design/desktop",
"@open-design/packaged", "@open-design/packaged",
"@open-design/platform",
"@open-design/sidecar", "@open-design/sidecar",
"@open-design/sidecar-proto", "@open-design/sidecar-proto",
"@open-design/web", "@open-design/web",

View file

@ -6,6 +6,8 @@ export const INTERNAL_PACKAGES = [
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },
{ directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/sidecar", name: "@open-design/sidecar" },
{ directory: "packages/platform", name: "@open-design/platform" }, { directory: "packages/platform", name: "@open-design/platform" },
{ directory: "packages/download", name: "@open-design/download" },
{ directory: "packages/host", name: "@open-design/host" },
{ directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" },
{ directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" }, { directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" },
{ directory: "packages/diagnostics", name: "@open-design/diagnostics" }, { directory: "packages/diagnostics", name: "@open-design/diagnostics" },

View file

@ -18,6 +18,8 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise<void> {
await runPnpm(config, ["--filter", "@open-design/platform", "build"]); await runPnpm(config, ["--filter", "@open-design/platform", "build"]);
await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]); await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]);
await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]); await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]);
await runPnpm(config, ["--filter", "@open-design/download", "build"]);
await runPnpm(config, ["--filter", "@open-design/host", "build"]);
await runPnpm(config, ["--filter", "@open-design/diagnostics", "build"]); await runPnpm(config, ["--filter", "@open-design/diagnostics", "build"]);
await runPnpm(config, ["--filter", "@open-design/daemon", "build"]); await runPnpm(config, ["--filter", "@open-design/daemon", "build"]);
try { try {

View file

@ -22,7 +22,6 @@ export const WIN_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES = [
"@open-design/daemon", "@open-design/daemon",
"@open-design/desktop", "@open-design/desktop",
"@open-design/packaged", "@open-design/packaged",
"@open-design/platform",
"@open-design/sidecar", "@open-design/sidecar",
"@open-design/sidecar-proto", "@open-design/sidecar-proto",
"@open-design/web", "@open-design/web",

View file

@ -124,6 +124,8 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise<void> {
await runPnpm(config, ["--filter", "@open-design/platform", "build"]); await runPnpm(config, ["--filter", "@open-design/platform", "build"]);
await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]); await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]);
await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]); await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]);
await runPnpm(config, ["--filter", "@open-design/download", "build"]);
await runPnpm(config, ["--filter", "@open-design/host", "build"]);
await runPnpm(config, ["--filter", "@open-design/diagnostics", "build"]); await runPnpm(config, ["--filter", "@open-design/diagnostics", "build"]);
await runPnpm(config, ["--filter", "@open-design/daemon", "build"]); await runPnpm(config, ["--filter", "@open-design/daemon", "build"]);
try { try {

View file

@ -35,6 +35,8 @@ export const INTERNAL_PACKAGES = [
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },
{ directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/sidecar", name: "@open-design/sidecar" },
{ directory: "packages/platform", name: "@open-design/platform" }, { directory: "packages/platform", name: "@open-design/platform" },
{ directory: "packages/download", name: "@open-design/download" },
{ directory: "packages/host", name: "@open-design/host" },
{ directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" },
{ directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" }, { directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" },
{ directory: "packages/diagnostics", name: "@open-design/diagnostics" }, { directory: "packages/diagnostics", name: "@open-design/diagnostics" },

View file

@ -12,8 +12,11 @@ const WORKSPACE_BUILD_PACKAGES = [
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },
{ directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/sidecar", name: "@open-design/sidecar" },
{ directory: "packages/platform", name: "@open-design/platform" }, { directory: "packages/platform", name: "@open-design/platform" },
{ directory: "packages/download", name: "@open-design/download" },
{ directory: "packages/host", name: "@open-design/host" },
{ directory: "packages/agui-adapter", name: "@open-design/agui-adapter" }, { directory: "packages/agui-adapter", name: "@open-design/agui-adapter" },
{ directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" }, { directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" },
{ directory: "packages/diagnostics", name: "@open-design/diagnostics" },
{ directory: "apps/daemon", name: "@open-design/daemon" }, { directory: "apps/daemon", name: "@open-design/daemon" },
{ directory: "apps/web", name: "@open-design/web" }, { directory: "apps/web", name: "@open-design/web" },
{ directory: "apps/desktop", name: "@open-design/desktop" }, { directory: "apps/desktop", name: "@open-design/desktop" },
@ -26,8 +29,11 @@ const BUILD_COMMANDS = [
{ args: ["--filter", "@open-design/sidecar-proto", "build"] }, { args: ["--filter", "@open-design/sidecar-proto", "build"] },
{ args: ["--filter", "@open-design/sidecar", "build"] }, { args: ["--filter", "@open-design/sidecar", "build"] },
{ args: ["--filter", "@open-design/platform", "build"] }, { args: ["--filter", "@open-design/platform", "build"] },
{ args: ["--filter", "@open-design/download", "build"] },
{ args: ["--filter", "@open-design/host", "build"] },
{ args: ["--filter", "@open-design/agui-adapter", "build"] }, { args: ["--filter", "@open-design/agui-adapter", "build"] },
{ args: ["--filter", "@open-design/plugin-runtime", "build"] }, { args: ["--filter", "@open-design/plugin-runtime", "build"] },
{ args: ["--filter", "@open-design/diagnostics", "build"] },
{ args: ["--filter", "@open-design/daemon", "build"] }, { args: ["--filter", "@open-design/daemon", "build"] },
{ args: ["--filter", "@open-design/web", "build"], env: ["OD_WEB_OUTPUT_MODE"] }, { args: ["--filter", "@open-design/web", "build"], env: ["OD_WEB_OUTPUT_MODE"] },
{ args: ["--filter", "@open-design/web", "build:sidecar"] }, { args: ["--filter", "@open-design/web", "build:sidecar"] },
@ -80,7 +86,7 @@ async function createWorkspaceBuildCacheKey(config: ToolPackConfig): Promise<str
packageManager: await readPackageManager(config.workspaceRoot), packageManager: await readPackageManager(config.workspaceRoot),
platform: config.platform, platform: config.platform,
pnpmLock: await hashPath(join(config.workspaceRoot, "pnpm-lock.yaml")), pnpmLock: await hashPath(join(config.workspaceRoot, "pnpm-lock.yaml")),
schemaVersion: 5, schemaVersion: 6,
webOutputMode: config.webOutputMode, webOutputMode: config.webOutputMode,
}); });
} }
@ -101,10 +107,16 @@ function workspaceBuildOutputFiles(config: ToolPackConfig): string[] {
"packages/sidecar/dist/index.d.ts", "packages/sidecar/dist/index.d.ts",
"packages/platform/dist/index.mjs", "packages/platform/dist/index.mjs",
"packages/platform/dist/index.d.ts", "packages/platform/dist/index.d.ts",
"packages/download/dist/index.mjs",
"packages/download/dist/index.d.ts",
"packages/host/dist/index.mjs",
"packages/host/dist/index.d.ts",
"packages/agui-adapter/dist/index.mjs", "packages/agui-adapter/dist/index.mjs",
"packages/agui-adapter/dist/index.d.ts", "packages/agui-adapter/dist/index.d.ts",
"packages/plugin-runtime/dist/index.mjs", "packages/plugin-runtime/dist/index.mjs",
"packages/plugin-runtime/dist/index.d.ts", "packages/plugin-runtime/dist/index.d.ts",
"packages/diagnostics/dist/index.mjs",
"packages/diagnostics/dist/index.d.ts",
"apps/daemon/dist/cli.js", "apps/daemon/dist/cli.js",
"apps/daemon/dist/cli.d.ts", "apps/daemon/dist/cli.d.ts",
"apps/daemon/dist/sidecar/index.js", "apps/daemon/dist/sidecar/index.js",
@ -125,8 +137,11 @@ function workspaceBuildArtifacts(config: ToolPackConfig): WorkspaceBuildArtifact
"packages/sidecar-proto/dist", "packages/sidecar-proto/dist",
"packages/sidecar/dist", "packages/sidecar/dist",
"packages/platform/dist", "packages/platform/dist",
"packages/download/dist",
"packages/host/dist",
"packages/agui-adapter/dist", "packages/agui-adapter/dist",
"packages/plugin-runtime/dist", "packages/plugin-runtime/dist",
"packages/diagnostics/dist",
"apps/daemon/dist", "apps/daemon/dist",
"apps/web/dist", "apps/web/dist",
"apps/desktop/dist", "apps/desktop/dist",

View file

@ -0,0 +1,51 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const ROOT = join(fileURLToPath(import.meta.url), "..", "..", "..", "..");
type PackageJson = {
dependencies?: Record<string, string>;
};
function readPackageJson(relativePath: string): PackageJson {
return JSON.parse(readFileSync(join(ROOT, relativePath, "package.json"), "utf8")) as PackageJson;
}
function collectWorkspaceRuntimeDeps(relativePath: string): string[] {
const pkg = readPackageJson(relativePath);
if (pkg.dependencies == null) return [];
return Object.keys(pkg.dependencies).filter((name) => name.startsWith("@open-design/"));
}
function loadInternalPackageNames(modulePath: string): string[] {
const source = readFileSync(join(ROOT, modulePath), "utf8");
const matches = source.matchAll(/name:\s*"(@open-design\/[^"]+)"/g);
return [...matches].map((m) => m[1]!);
}
const PACKAGED_APPS = ["apps/desktop", "apps/web", "apps/packaged", "apps/daemon"];
const PACK_LANES = [
{ lane: "linux", file: "tools/pack/src/linux.ts" },
{ lane: "mac", file: "tools/pack/src/mac/constants.ts" },
{ lane: "win", file: "tools/pack/src/win/constants.ts" },
];
describe("INTERNAL_PACKAGES covers all workspace runtime deps", () => {
const requiredPackages = new Set<string>();
for (const app of PACKAGED_APPS) {
for (const dep of collectWorkspaceRuntimeDeps(app)) {
requiredPackages.add(dep);
}
}
for (const { lane, file } of PACK_LANES) {
it(`${lane} lane includes all required workspace packages`, () => {
const declared = new Set(loadInternalPackageNames(file));
const missing = [...requiredPackages].filter((pkg) => !declared.has(pkg));
expect(missing, `${lane} INTERNAL_PACKAGES is missing: ${missing.join(", ")}`).toEqual([]);
});
}
});

View file

@ -45,7 +45,6 @@ describe("mac standalone prebundle policy", () => {
"@open-design/daemon", "@open-design/daemon",
"@open-design/desktop", "@open-design/desktop",
"@open-design/packaged", "@open-design/packaged",
"@open-design/platform",
"@open-design/sidecar", "@open-design/sidecar",
"@open-design/sidecar-proto", "@open-design/sidecar-proto",
"@open-design/web", "@open-design/web",
@ -59,10 +58,16 @@ describe("mac standalone prebundle policy", () => {
} }
expect( expect(
shouldInstallInternalPackageForMacPrebundle({ shouldInstallInternalPackageForMacPrebundle({
packageName: "@open-design/contracts", packageName: "@open-design/contracts",
webOutputMode: "standalone", webOutputMode: "standalone",
}), }),
).toBe(true); ).toBe(true);
expect(
shouldInstallInternalPackageForMacPrebundle({
packageName: "@open-design/platform",
webOutputMode: "standalone",
}),
).toBe(true);
}); });
it("documents the explicit code-level bundle boundaries", () => { it("documents the explicit code-level bundle boundaries", () => {

View file

@ -13,6 +13,8 @@ const PACKAGE_DIRS = [
"packages/sidecar-proto", "packages/sidecar-proto",
"packages/sidecar", "packages/sidecar",
"packages/platform", "packages/platform",
"packages/download",
"packages/host",
"packages/agui-adapter", "packages/agui-adapter",
"packages/plugin-runtime", "packages/plugin-runtime",
"packages/diagnostics", "packages/diagnostics",

View file

@ -45,7 +45,6 @@ describe("win standalone prebundle policy", () => {
"@open-design/daemon", "@open-design/daemon",
"@open-design/desktop", "@open-design/desktop",
"@open-design/packaged", "@open-design/packaged",
"@open-design/platform",
"@open-design/sidecar", "@open-design/sidecar",
"@open-design/sidecar-proto", "@open-design/sidecar-proto",
"@open-design/web", "@open-design/web",
@ -59,10 +58,16 @@ describe("win standalone prebundle policy", () => {
} }
expect( expect(
shouldInstallInternalPackageForWinPrebundle({ shouldInstallInternalPackageForWinPrebundle({
packageName: "@open-design/contracts", packageName: "@open-design/contracts",
webOutputMode: "standalone", webOutputMode: "standalone",
}), }),
).toBe(true); ).toBe(true);
expect(
shouldInstallInternalPackageForWinPrebundle({
packageName: "@open-design/platform",
webOutputMode: "standalone",
}),
).toBe(true);
}); });
it("documents the explicit code-level bundle boundaries", () => { it("documents the explicit code-level bundle boundaries", () => {

View file

@ -14,6 +14,8 @@ const PACKAGE_DIRS = [
"packages/sidecar-proto", "packages/sidecar-proto",
"packages/sidecar", "packages/sidecar",
"packages/platform", "packages/platform",
"packages/download",
"packages/host",
"packages/agui-adapter", "packages/agui-adapter",
"packages/plugin-runtime", "packages/plugin-runtime",
"packages/diagnostics", "packages/diagnostics",
@ -34,6 +36,10 @@ const OUTPUT_FILES = [
"packages/sidecar/dist/index.d.ts", "packages/sidecar/dist/index.d.ts",
"packages/platform/dist/index.mjs", "packages/platform/dist/index.mjs",
"packages/platform/dist/index.d.ts", "packages/platform/dist/index.d.ts",
"packages/download/dist/index.mjs",
"packages/download/dist/index.d.ts",
"packages/host/dist/index.mjs",
"packages/host/dist/index.d.ts",
"packages/agui-adapter/dist/index.mjs", "packages/agui-adapter/dist/index.mjs",
"packages/agui-adapter/dist/index.d.ts", "packages/agui-adapter/dist/index.d.ts",
"packages/plugin-runtime/dist/index.mjs", "packages/plugin-runtime/dist/index.mjs",
@ -163,6 +169,32 @@ describe("ensureWorkspaceBuildArtifacts", () => {
} }
}); });
it("materializes cached internal package outputs for pack tarballs", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-workspace-build-package-cache-"));
const cache = new ToolPackCache(join(root, ".cache"));
const config = createConfig(root, cache.root);
let builds = 0;
try {
await writeWorkspace(root);
await ensureWorkspaceBuildArtifacts(config, cache, async () => {
builds += 1;
await writeOutputs(root, `build-${builds}`);
});
await rm(join(root, "packages/host/dist/index.mjs"), { force: true });
await ensureWorkspaceBuildArtifacts(config, cache, async () => {
builds += 1;
await writeOutputs(root, `build-${builds}`);
});
expect(builds).toBe(1);
expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "hit"]);
expect(await readFile(join(root, "packages/host/dist/index.mjs"), "utf8")).toBe("build-1\n");
} finally {
await rm(root, { force: true, recursive: true });
}
});
it("keeps platform-specific workspace build cache nodes separate", async () => { it("keeps platform-specific workspace build cache nodes separate", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-workspace-build-platform-")); const root = await mkdtemp(join(tmpdir(), "open-design-workspace-build-platform-"));
const cache = new ToolPackCache(join(root, ".cache")); const cache = new ToolPackCache(join(root, ".cache"));