mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Merge remote-tracking branch 'origin/main' into release/v0.9.0
This commit is contained in:
commit
dacd2be814
65 changed files with 2518 additions and 366 deletions
|
|
@ -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'] },
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:“good first issue”</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:“good first issue”</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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
function escapeHtml(s){ return String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 à l’emploi. 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 à l’emploi. 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 à l’emploi',
|
'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',
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 all’uso',
|
'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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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': '登录',
|
||||||
|
|
|
||||||
|
|
@ -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': "連接",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
79
apps/web/tests/router.navigate.test.tsx
Normal file
79
apps/web/tests/router.navigate.test.tsx
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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')");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
51
tools/pack/tests/internal-packages-coverage.test.ts
Normal file
51
tools/pack/tests/internal-packages-coverage.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue