Merge origin/main into release/v0.8.0

PR #2461 sync prep — resolves 14 conflicts merging 84 main-side commits
on top of 58 release-side commits accumulated during the 0.8.0 cycle.

Resolution summary:

Take main (theirs) where main carried deliberate forward progress:
- apps/web/src/components/PluginCard.tsx — 7 hunks, i18n migration:
  hardcoded English aria-labels/titles replaced with t() calls keyed
  on pluginCard.* (all 8 keys verified present in en.ts).
- apps/web/src/components/TasksView.tsx — 1 hunk, source-ingestion
  feature: sortedRoutines (newest-first), sourceIngestionTemplates,
  patchSourceForm, submitSourceIngestion. activeCount/pausedCount
  semantics preserved (now keyed on sortedRoutines, count unchanged).
- e2e/ui/app.test.ts — new node:fs/promises + tmpdir + path + @/timeouts
  imports needed by main-side test helpers.
- e2e/ui/settings-local-cli-codex-fallback.test.ts — menu-dismissal
  helper block added by main.

Keep both sides where each added a different field to the same object
literal:
- apps/web/src/components/ProjectView.tsx (locale + analyticsHints
  spread).
- apps/web/src/components/DesignSystemFlow.tsx (locale + analyticsHints).

Take release (ours) where release carried deliberate work that ships
0.8.0:
- CHANGELOG.md — release-side 0.8.0 entry + PR link refs; main's
  Unreleased section was the same body of work, now finalized.
- apps/landing-page/public/{apple-touch-icon,favicon}.png +
  apps/web/public/app-icon.svg — release-side visual refresh assets
  consistent with 0.8.0 stable ship.
- tools/pack/src/linux.ts — packageVersion const required by line 466;
  taking main's empty line would build-error.
- e2e/ui/project-management-flows.test.ts +
  e2e/ui/settings-api-protocol.test.ts +
  e2e/ui/settings-memory-routines.test.ts — release-side release-smoke
  hardening (shangxinyu1 + PerishFire) takes precedence on overlap.

Closes-issue / unblocks: PR #2461 sync release/v0.8.0 → main.
This commit is contained in:
lefarcen 2026-05-23 12:17:18 +08:00
commit c14baf07d3
337 changed files with 37856 additions and 4239 deletions

View file

@ -0,0 +1,34 @@
name: Setup Playwright
description: Restore Playwright browser cache and install browsers
inputs:
package-json-path:
description: Path to package.json containing @playwright/test or playwright devDependency
required: true
install-command:
description: Command used to install browsers
required: true
runs:
using: composite
steps:
- name: Resolve Playwright version
id: playwright-version
shell: bash
run: |
version=$(node -p "const pkg = require('./${{ inputs.package-json-path }}'); const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; (deps['@playwright/test'] || deps.playwright || '').replace(/[^0-9.]/g,'')")
if [ -z "$version" ]; then
echo "Could not resolve Playwright version from ${{ inputs.package-json-path }}" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browser binaries
uses: actions/cache@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
shell: bash
run: ${{ inputs.install-command }}

View file

@ -0,0 +1,41 @@
name: Setup workspace
description: Restore pnpm cache and install dependencies
inputs:
node-version:
description: Node.js version to install
required: false
default: '24'
pnpm-version:
description: pnpm version to install
required: false
default: '10.33.2'
runs:
using: composite
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: ${{ inputs.pnpm-version }}
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: ${{ inputs.node-version }}
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile

View file

@ -2,6 +2,7 @@ name: ci
on:
pull_request:
merge_group:
# Release validation is owned by the release workflows rather than this CI
# workflow: `release-stable` has a verify job before publishing, and
# `release-beta` builds from its selected release commit. Keep this trigger
@ -35,6 +36,7 @@ jobs:
web_tests_required: ${{ steps.detect.outputs.web_tests_required }}
tools_dev_tests_required: ${{ steps.detect.outputs.tools_dev_tests_required }}
tools_pack_tests_required: ${{ steps.detect.outputs.tools_pack_tests_required }}
nix_validation_required: ${{ steps.detect.outputs.nix_validation_required }}
workspace_validation_required: ${{ steps.detect.outputs.workspace_validation_required }}
steps:
@ -49,6 +51,7 @@ jobs:
web_tests_required=false
tools_dev_tests_required=false
tools_pack_tests_required=false
nix_validation_required=false
workspace_validation_required=false
if [ "${{ github.event_name }}" = "pull_request" ]; then
gh api --paginate \
@ -71,16 +74,19 @@ jobs:
if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/host/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
tools_pack_tests_required=true
fi
if [[ "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" ]]; then
if [[ "$file" == "package.json" || "$file" == "apps/daemon/package.json" || "$file" == "apps/web/package.json" || "$file" == "apps/desktop/package.json" || "$file" == "apps/packaged/package.json" || "$file" == "packages/"*/"package.json" || "$file" == "tools/"*/"package.json" || "$file" == "e2e/package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" ]]; then
daemon_tests_required=true
web_tests_required=true
tools_dev_tests_required=true
tools_pack_tests_required=true
fi
if [[ "$file" == "package.json" || "$file" == "apps/"*/"package.json" || "$file" == "packages/"*/"package.json" || "$file" == "tools/"*/"package.json" || "$file" == "e2e/package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == "flake.nix" || "$file" == "flake.lock" || "$file" == "nix/"* || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/nix-check.yml" ]]; then
nix_validation_required=true
fi
case "$file" in
*.md|*.mdx|*.txt|LICENSE|.gitignore|.editorconfig|.vscode/*|.idea/*|docs/*|.github/ISSUE_TEMPLATE/*|.github/CODEOWNERS)
;;
apps/landing-page/*|.github/workflows/landing-page-ci.yml|.github/workflows/landing-page-deploy.yml|.github/workflows/blog-indexing-on-deploy.yml|.github/workflows/blog-indexing-monitor.yml)
apps/landing-page/*|flake.nix|flake.lock|nix/*|.github/workflows/nix-check.yml|.github/workflows/landing-page-ci.yml|.github/workflows/landing-page-deploy.yml|.github/workflows/blog-indexing-on-deploy.yml|.github/workflows/blog-indexing-monitor.yml)
;;
*)
workspace_validation_required=true
@ -90,6 +96,7 @@ jobs:
&& [ "$web_tests_required" = "true" ] \
&& [ "$tools_dev_tests_required" = "true" ] \
&& [ "$tools_pack_tests_required" = "true" ] \
&& [ "$nix_validation_required" = "true" ] \
&& [ "$workspace_validation_required" = "true" ]; then
break
fi
@ -100,11 +107,21 @@ jobs:
|| [ "$tools_pack_tests_required" = "true" ]; then
workspace_validation_required=true
fi
elif [ "${{ github.event_name }}" = "push" ]; then
daemon_tests_required=true
web_tests_required=true
tools_dev_tests_required=true
tools_pack_tests_required=true
# Main already runs .github/workflows/nix-check.yml, so keep this
# workflow's push path focused on the non-Nix workspace signal.
nix_validation_required=false
workspace_validation_required=true
else
daemon_tests_required=true
web_tests_required=true
tools_dev_tests_required=true
tools_pack_tests_required=true
nix_validation_required=true
workspace_validation_required=true
fi
{
@ -112,9 +129,31 @@ jobs:
echo "web_tests_required=$web_tests_required"
echo "tools_dev_tests_required=$tools_dev_tests_required"
echo "tools_pack_tests_required=$tools_pack_tests_required"
echo "nix_validation_required=$nix_validation_required"
echo "workspace_validation_required=$workspace_validation_required"
} >> "$GITHUB_OUTPUT"
nix_validation:
name: Validate Nix flake
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.nix_validation_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Install Nix
uses: cachix/install-nix-action@v27
with:
extra_nix_config: |
experimental-features = nix-command flakes
accept-flake-config = true
- name: nix flake check
run: nix flake check --print-build-logs --keep-going
preflight:
name: Preflight
needs: [change_scopes]
@ -126,30 +165,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup workspace
uses: ./.github/actions/setup-workspace
# `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that
# are needed immediately after install for linked bins and shared
@ -183,8 +200,8 @@ jobs:
- name: Check i18n structure
run: pnpm i18n:check
core_tests:
name: Core package tests
workspace_unit_tests:
name: Workspace unit tests
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
runs-on: ubuntu-latest
@ -194,77 +211,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Core package tests
- name: Workspace unit tests
run: |
pnpm --filter @open-design/contracts test
pnpm --filter @open-design/host test
pnpm --filter @open-design/platform test
pnpm --filter @open-design/sidecar test
pnpm --filter @open-design/sidecar-proto test
tools_workspace_tests:
name: Tools workspace tests
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.tools_dev_tests_required == 'true' || needs.change_scopes.outputs.tools_pack_tests_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Tools workspace smoke tests
run: |
if [ "${{ needs.change_scopes.outputs.tools_dev_tests_required }}" = "true" ]; then
pnpm --filter @open-design/tools-dev test
fi
@ -273,50 +229,24 @@ jobs:
fi
daemon_workspace_tests:
name: Daemon workspace tests (${{ matrix.shard }}/2)
name: Daemon workspace tests
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.daemon_tests_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- name: Prebuild daemon entrypoint declarations
run: pnpm --filter @open-design/daemon build
- name: Daemon workspace tests
run: pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts --shard=${{ matrix.shard }}/2
run: pnpm --filter @open-design/daemon test
web_workspace_tests:
name: Web workspace tests
@ -329,30 +259,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- name: Prebuild web sidecar declarations
run: pnpm --filter @open-design/web build:sidecar
@ -360,164 +268,26 @@ jobs:
- name: Web workspace tests
run: pnpm --filter @open-design/web test
app_tests:
name: App workspace tests
browser_tests:
name: Browser tests
needs:
- change_scopes
- tools_workspace_tests
- daemon_workspace_tests
- web_workspace_tests
if: ${{ always() && needs.change_scopes.result == 'success' && (needs.change_scopes.outputs.tools_dev_tests_required == 'true' || needs.change_scopes.outputs.tools_pack_tests_required == 'true' || needs.change_scopes.outputs.daemon_tests_required == 'true' || needs.change_scopes.outputs.web_tests_required == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check app workspace test jobs
env:
NEEDS_JSON: ${{ toJSON(needs) }}
TOOLS_REQUIRED: ${{ needs.change_scopes.outputs.tools_dev_tests_required == 'true' || needs.change_scopes.outputs.tools_pack_tests_required == 'true' }}
DAEMON_REQUIRED: ${{ needs.change_scopes.outputs.daemon_tests_required }}
WEB_REQUIRED: ${{ needs.change_scopes.outputs.web_tests_required }}
run: |
set -euo pipefail
echo "$NEEDS_JSON" | jq .
failures=()
if [ "$TOOLS_REQUIRED" = "true" ] && [ "$(echo "$NEEDS_JSON" | jq -r '.tools_workspace_tests.result')" != "success" ]; then
failures+=("tools_workspace_tests=$(echo "$NEEDS_JSON" | jq -r '.tools_workspace_tests.result')")
fi
if [ "$DAEMON_REQUIRED" = "true" ] && [ "$(echo "$NEEDS_JSON" | jq -r '.daemon_workspace_tests.result')" != "success" ]; then
failures+=("daemon_workspace_tests=$(echo "$NEEDS_JSON" | jq -r '.daemon_workspace_tests.result')")
fi
if [ "$WEB_REQUIRED" = "true" ] && [ "$(echo "$NEEDS_JSON" | jq -r '.web_workspace_tests.result')" != "success" ]; then
failures+=("web_workspace_tests=$(echo "$NEEDS_JSON" | jq -r '.web_workspace_tests.result')")
fi
if [ "${#failures[@]}" -gt 0 ]; then
printf 'App workspace validation failed:\n'
printf '%s\n' "${failures[@]}"
exit 1
fi
e2e_vitest:
name: E2E vitest
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Some Vitest-based smoke tests drive the browser directly through
# @playwright/test. Restore browser binaries without saving from CI runs;
# the key follows the @playwright/test version so browser revisions
# update with package bumps.
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./e2e/package.json').devDependencies['@playwright/test'].replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
uses: actions/cache/restore@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
run: pnpm -C e2e exec playwright install --with-deps chromium
- name: E2E vitest
run: pnpm --filter @open-design/e2e test
ui_e2e_critical:
name: Playwright critical (${{ matrix.group }})
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- group: core
grep_flag: --grep-invert
grep_pattern: home starters|home hero
- group: starters
grep_flag: --grep
grep_pattern: home starters|home hero
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Restore Playwright browser binaries without saving from CI runs. The
# key follows the @playwright/test version so browser revisions update
# with package bumps.
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./e2e/package.json').devDependencies['@playwright/test'].replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
uses: actions/cache/restore@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
run: pnpm -C e2e exec playwright install --with-deps chromium
package-json-path: e2e/package.json
install-command: pnpm -C e2e exec playwright install --with-deps chromium
- name: Prebuild workspace type declarations
run: |
@ -525,10 +295,13 @@ jobs:
pnpm --filter @open-design/desktop build
pnpm --filter @open-design/web build:sidecar
- name: E2E vitest
run: pnpm --filter @open-design/e2e test
- name: Playwright critical
run: |
pnpm -C e2e exec tsx scripts/playwright.ts clean
pnpm -C e2e exec playwright test -c playwright.config.ts ${{ matrix.grep_flag }} '${{ matrix.grep_pattern }}' ui/critical-smoke.test.ts ui/entry-chrome-flows.test.ts
pnpm -C e2e exec playwright test -c playwright.config.ts ui/critical-smoke.test.ts ui/entry-chrome-flows.test.ts
build_workspaces:
name: Build workspaces
@ -541,30 +314,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- name: Prebuild workspace type declarations
run: |
@ -583,11 +334,13 @@ jobs:
validate:
name: Validate workspace
needs:
- change_scopes
- preflight
- core_tests
- app_tests
- e2e_vitest
- ui_e2e_critical
- nix_validation
- workspace_unit_tests
- daemon_workspace_tests
- web_workspace_tests
- browser_tests
- build_workspaces
if: ${{ always() }}
runs-on: ubuntu-latest

View file

@ -0,0 +1,56 @@
name: fork-pr-workflow-approval
# This workflow runs in the trusted base-repository context. It never checks out
# or executes the fork head; the TypeScript policy script only reads PR metadata
# through the GitHub API and approves pending pull_request runs when the touched
# paths are inside the low-risk source allowlist. It only approves low-privilege
# pull_request workflows (`ci`, visual capture, visual verify); privileged
# workflow_run / release / deploy workflows stay on manual gates.
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, edited]
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to evaluate.
required: true
type: string
dry_run:
description: Log decisions without approving workflow runs.
required: false
default: false
type: boolean
permissions:
actions: write
contents: read
pull-requests: read
concurrency:
group: fork-pr-workflow-approval-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: false
jobs:
approve:
if: github.repository == 'nexu-io/open-design' && (github.event_name != 'pull_request_target' || github.event.action != 'edited' || github.event.changes.base != null)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout trusted base code
uses: actions/checkout@v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha || github.sha }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
- name: Evaluate and approve low-risk fork PR workflows
env:
GITHUB_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
DRY_RUN: ${{ inputs.dry_run || false }}
run: node --experimental-strip-types scripts/approve-fork-pr-workflows.ts

View file

@ -61,37 +61,26 @@ jobs:
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- name: Setup Node.js
uses: actions/setup-node@v6
- name: Cache generated previews
id: previews-cache
uses: actions/cache@v5.0.5
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
path: apps/landing-page/public/previews
key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**') }}
restore-keys: |
landing-page-previews-${{ runner.os }}-
# Cache the Playwright browser binaries between runs. The cache key
# is pinned to the playwright version we depend on (kept in
# apps/landing-page/package.json) so a bump invalidates correctly.
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@v4
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
package-json-path: apps/landing-page/package.json
install-command: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
- name: Typecheck landing page
run: pnpm --filter @open-design/landing-page typecheck
@ -106,7 +95,9 @@ jobs:
run: pnpm --filter @open-design/landing-page previews
- name: Build landing page
run: pnpm --filter @open-design/landing-page build
env:
PUBLIC_GA_MEASUREMENT_ID: ${{ vars.PUBLIC_GA_MEASUREMENT_ID }}
run: pnpm --filter @open-design/landing-page build:static
- name: Lint changed blog SEO
run: |

View file

@ -65,8 +65,17 @@ jobs:
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache generated previews
id: previews-cache
uses: actions/cache@v5.0.5
with:
path: apps/landing-page/public/previews
key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**') }}
restore-keys: |
landing-page-previews-${{ runner.os }}-
- name: Cache Playwright browsers
uses: actions/cache@v4
uses: actions/cache@v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
@ -87,7 +96,9 @@ jobs:
run: pnpm --filter @open-design/landing-page previews
- name: Build landing page
run: pnpm --filter @open-design/landing-page build
env:
PUBLIC_GA_MEASUREMENT_ID: ${{ vars.PUBLIC_GA_MEASUREMENT_ID }}
run: pnpm --filter @open-design/landing-page build:static
- name: Verify zero external JavaScript
run: |

View file

@ -18,7 +18,6 @@ on:
permissions:
actions: read
contents: read
pull-requests: write
concurrency:
group: visual-pr-capture-${{ github.event.pull_request.number }}
@ -73,132 +72,3 @@ jobs:
e2e/ui/reports/visual-report/manifest.json
if-no-files-found: ignore
retention-days: 7
comment_same_repo:
name: Comment PR visual screenshots
needs: [capture]
if: ${{ always() && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download PR visual artifact
uses: actions/download-artifact@v8
with:
run-id: ${{ github.run_id }}
path: visual-artifact
merge-multiple: true
github-token: ${{ github.token }}
- name: Validate capture manifest
id: manifest
shell: bash
env:
EXPECTED_PR_NUMBER: ${{ github.event.pull_request.number }}
EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
EXPECTED_BASE_SHA: ${{ github.event.pull_request.base.sha }}
EXPECTED_RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
manifest="visual-artifact/visual-report/manifest.json"
if [ ! -f "$manifest" ]; then
manifest="visual-artifact/manifest.json"
fi
if [ ! -f "$manifest" ]; then
echo "Capture manifest not found" >&2
find visual-artifact -maxdepth 4 -type f >&2
exit 1
fi
pr_number="$(jq -r '.pr_number' "$manifest")"
head_sha="$(jq -r '.head_sha' "$manifest")"
base_sha="$(jq -r '.base_sha' "$manifest")"
run_id="$(jq -r '.run_id' "$manifest")"
capture_outcome="$(jq -r '.capture_outcome // "success"' "$manifest")"
if [ "$pr_number" != "$EXPECTED_PR_NUMBER" ] || [ "$head_sha" != "$EXPECTED_HEAD_SHA" ] || [ "$base_sha" != "$EXPECTED_BASE_SHA" ] || [ "$run_id" != "$EXPECTED_RUN_ID" ]; then
echo "Capture manifest does not match the current pull_request event." >&2
jq . "$manifest" >&2
exit 1
fi
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
echo "capture_outcome=$capture_outcome" >> "$GITHUB_OUTPUT"
- name: Build visual diff report
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_AK: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
CLOUDFLARE_R2_RELEASES_SK: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
run: |
pnpm -C e2e exec tsx scripts/visual-report.ts compare-pr \
--pr-number "${{ steps.manifest.outputs.pr_number }}" \
--run-id "${{ steps.manifest.outputs.run_id }}" \
--head-sha "${{ steps.manifest.outputs.head_sha }}" \
--base-sha "${{ steps.manifest.outputs.base_sha }}" \
--capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \
--screenshots ../visual-artifact/visual-screenshots \
--comment-out ui/reports/visual-report/comment.md \
--manifest-out ui/reports/visual-report/report-manifest.json
- name: Upsert PR comment
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
run: |
set -euo pipefail
body_path="e2e/ui/reports/visual-report/comment.md"
comments_json="$RUNNER_TEMP/comments.json"
gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" > "$comments_json"
comment_id="$(jq -r '.[] | select(.body | contains("<!-- visual-regression-bot -->")) | .id' "$comments_json" | tail -n 1)"
if [ -n "$comment_id" ]; then
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" --field "body=$(cat "$body_path")"
else
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field "body=$(cat "$body_path")"
fi
- name: Upload visual report artifact
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: visual-pr-report-${{ github.event.pull_request.number }}-${{ github.run_id }}
path: e2e/ui/reports/visual-report
if-no-files-found: ignore
retention-days: 7

View file

@ -27,7 +27,10 @@ concurrency:
jobs:
comment:
name: Publish PR visual diff comment
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
# Fork PR capture artifacts are untrusted. Publish comments/R2 reports for
# same-repository PRs automatically; fork captures require maintainer
# workflow_dispatch with a specific capture run id and PR number.
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository) }}
runs-on: ubuntu-24.04
timeout-minutes: 15
@ -36,6 +39,8 @@ jobs:
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
repository: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_repository.full_name || github.repository }}
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.8
@ -48,20 +53,6 @@ jobs:
node-version: 24
package-manager-cache: false
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download PR visual artifact
uses: actions/download-artifact@v8
with:
@ -141,6 +132,63 @@ jobs:
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
echo "capture_outcome=$capture_outcome" >> "$GITHUB_OUTPUT"
- name: Validate live PR state for trusted checkout
if: ${{ github.event_name == 'workflow_dispatch' }}
id: trusted-pr
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
ARTIFACT_HEAD_SHA: ${{ steps.manifest.outputs.head_sha }}
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
run: |
set -euo pipefail
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft,headRefOid,baseRefOid)"
pr_state="$(jq -r '.state' <<< "$pr_json")"
pr_is_draft="$(jq -r '.isDraft' <<< "$pr_json")"
current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
if [ "$pr_state" != "OPEN" ]; then
echo "Refusing trusted checkout for PR $PR_NUMBER because state is $pr_state." >&2
exit 1
fi
if [ "$pr_is_draft" != "false" ]; then
echo "Refusing trusted checkout for PR $PR_NUMBER because it is draft." >&2
exit 1
fi
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
echo "Artifact head_sha ($ARTIFACT_HEAD_SHA) does not match live PR head ($current_head)." >&2
exit 1
fi
if [ "$current_base" != "$ARTIFACT_BASE_SHA" ]; then
echo "Artifact base_sha ($ARTIFACT_BASE_SHA) does not match live PR base ($current_base)." >&2
exit 1
fi
echo "base_sha=$current_base" >> "$GITHUB_OUTPUT"
- name: Checkout trusted base revision
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/checkout@v6.0.2
with:
clean: false
fetch-depth: 0
repository: ${{ github.repository }}
ref: ${{ steps.trusted-pr.outputs.base_sha }}
- name: Resolve pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Stop stale visual runs
id: stale
env:

2
.gitignore vendored
View file

@ -66,3 +66,5 @@ docs/superpowers/
# `skills/<slug>/example.html` and `templates/live-artifacts/<slug>/`
# on every deploy. Should not be committed (~70MB of PNGs).
apps/landing-page/public/previews/
growth/**

View file

@ -145,7 +145,7 @@ For the full tag dictionary, operational playbook (direct merge / duplicate-titl
## Chat UI conventions
- `apps/web/src/components/file-viewer-render-mode.ts` decides URL-load vs srcDoc for HTML previews. Bridges (deck, comment/inspect selection, palette, edit, tweaks) can ONLY inject through the srcDoc path. Add a new disqualifier to `UrlLoadDecision` whenever a feature needs a srcDoc-only bridge; pass it from `FileViewer.tsx` based on a source-content heuristic where appropriate (e.g. `hasTweaksTemplate`). The host keeps both iframes mounted simultaneously and swaps CSS visibility so toggling render mode does not cause an iframe reload flash; `iframeRef.current` stays aligned with the active iframe via `useEffect`. Receive filters use `isOurIframe(ev.source)` to accept messages from either iframe but signals that should ONLY come from the active iframe (e.g. `od:tweaks-available`) re-check `ev.source === iframeRef.current?.contentWindow`.
- TodoWrite UI pins one canonical task list above the chat composer via `PinnedTodoSlot` in `ChatPane.tsx`. The slot reads the latest TodoWrite snapshot across the conversation through `latestTodoWriteInputFromMessages` (`apps/web/src/runtime/todos.ts`). `AssistantMessage.stripTodoToolGroups` removes any TodoWrite tool groups from per message rendering so there is exactly one TodoCard on screen. The progress count includes both `completed` and `in_progress` items (1/4 reads "one underway" not "zero finished"). Dismissal via the Done button is keyed on the snapshot's JSON, so a fresh TodoWrite from the agent automatically re shows the card.
- TodoWrite UI pins one canonical task list above the chat composer via `PinnedTodoSlot` in `ChatPane.tsx`. The slot reads the latest TodoWrite snapshot across the conversation through `latestTodoWriteInputFromMessages` (`apps/web/src/runtime/todos.ts`). `AssistantMessage.stripTodoToolGroups` removes any TodoWrite tool groups from per message rendering so there is exactly one TodoCard on screen. The progress count includes both `completed` and `in_progress` items (1/4 reads "one underway" not "zero finished"). Dismissal via the Done button is keyed on the snapshot's JSON, so a fresh TodoWrite from the agent automatically re shows the card. `PinnedTodoSlot` sits OUTSIDE the `.chat-log` scroll container, so auto-scroll requires explicit coverage: `ChatPane`'s `ResizeObserver` accepts a `containerRef` from `PinnedTodoSlot` and observes that element directly, and a pane-level `MutationObserver` (`childList: true` on the chat pane ancestor) re-syncs that observation whenever the slot mounts or unmounts as new TodoWrite snapshots arrive.
- `AskUserQuestionCard` (in `ToolCard.tsx`) prefers the live `onAnswerToolUse(toolUseId, content)` route (POSTs to `/api/runs/:id/tool-result`) and falls back to the legacy `onSubmitForm(text)` path when the run has already terminated. Selected chips persist across reloads by parsing the stored `tool_result.content` back into the selections shape.
- Tool group rendering uses `dedupeSnapshotToolRetries` to collapse identical `AskUserQuestion` retries (one card per unique input, keeping the latest tool_use_id) and `TodoWrite` snapshots (only the most recent call, since each call is a state replace).
@ -164,6 +164,7 @@ For the full tag dictionary, operational playbook (direct merge / duplicate-titl
## Validation strategy
- After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh.
- Treat every `pnpm-lock.yaml` change as requiring a Nix pnpm deps hash refresh check. Use `pnpm nix:update-hash` to regenerate `nix/pnpm-deps.nix`, then re-run `nix flake check --print-build-logs --keep-going` (or rely on the PR `Validate workspace` gate if Nix is unavailable locally).
- Before marking regular work ready, run at least `pnpm guard` and `pnpm typecheck`, plus the package-scoped tests/builds that match the files changed. Do not use or add root `pnpm test`/`pnpm build` aliases.
- For local web runtime loops, prefer `pnpm tools-dev run web --daemon-port <port> --web-port <port>`.
- On a GUI-capable machine, validate desktop by running `pnpm tools-dev`, then `pnpm tools-dev inspect desktop status`.
@ -188,6 +189,7 @@ For a worked example of one full loop (red e2e spec → fix → green), see `e2e
```bash
pnpm install
pnpm nix:update-hash
pnpm tools-dev
pnpm tools-serve start updater
pnpm tools-dev start web

View file

@ -1,18 +1,18 @@
<div dir="rtl">
# Open Design
# Open Design — البديل الرسمي مفتوح المصدر لـ [Claude Design][cd]
> **Open Design هو البديل مفتوح المصدر والمحلي أولاً لـ [Claude Design][cd].** قابل للنشر على Vercel، ويدعم BYOK في كل طبقة — **16 أداة CLI لوكلاء البرمجة** يكتشفها تلقائياً من `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) لتصبح هي محرّك التصميم، مدفوعةً بـ **31 Skill قابلة للتركيب** و**72 نظام تصميم بمستوى الهوية البصرية**. لا توجد لديك CLI؟ بروكسي BYOK متوافق مع OpenAI يقدّم نفس الحلقة بدون عملية الـ spawn.
> [!IMPORTANT]
> ### 🔥 وصلت النسخة `0.8.0-preview`. عالم التصميم القديم ينتهي هنا.
>
> بديل مفتوح المصدر و agent-native لـ Claude Design / Figma — 40k نجمة في أسبوعين أوصلتنا إلى هنا. **نحتاجك لدفعنا بقية الطريق.**
> بديل مفتوح المصدر لـ Claude Design / Figma — 40k نجمة في أسبوعين أوصلتنا إلى هنا. **نحتاجك لدفعنا بقية الطريق.**
>
> **تكرار سريع على `main`** — 0.8.0 هي المرحلة التالية من Open Design. أرسل PR، اطرح فكرة جامحة، أبلغ عن عُلّة — ما تجلبه أنت هو ما تصير إليه هذه الحركة.
>
> → [**اقرأ الإعلان · حمّل المثبّت · انضم إلى الحركة**](https://github.com/nexu-io/open-design/discussions/1727) · يعمل جنبًا إلى جنب مع نسخة 0.7 الحالية لديك.
> **البديل مفتوح المصدر لـ [Claude Design][cd].** يعمل محلياً أولاً، قابل للنشر على Vercel، ويدعم BYOK في كل طبقة — **16 أداة CLI لوكلاء البرمجة** يكتشفها تلقائياً من `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) لتصبح هي محرّك التصميم، مدفوعةً بـ **31 Skill قابلة للتركيب** و**72 نظام تصميم بمستوى الهوية البصرية**. لا توجد لديك CLI؟ بروكسي BYOK متوافق مع OpenAI يقدّم نفس الحلقة بدون عملية الـ spawn.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — غلاف افتتاحي: صمّم مع الوكيل على حاسوبك المحمول" width="100%" />
</p>
@ -800,7 +800,7 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
شكراً لكلّ من ساعد في دفع Open Design للأمام — بكود، بوثائق، بملاحظات، بـ skills جديدة، بأنظمة تصميم جديدة، أو حتى بـ issue حادّة. كلّ مساهمة حقيقية تهمّ، والجدار أدناه أسهل طريقة لقول ذلك علناً.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Open Design contributors" />
</a>
إن شحنت أوّل PR — مرحباً. تصنيف [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) هو نقطة الدخول.
@ -817,9 +817,9 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design — die offizielle Open-Source-Alternative zu [Claude Design][cd]
> **Open Design ist die Open-Source- und Local-first-Alternative zu [Claude Design][cd].** Web-deploybar, BYOK auf jeder Ebene: **16 coding-agent CLIs** werden automatisch in Ihrem `PATH` erkannt (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) und werden zur Design-Engine, gesteuert von **31 kombinierbaren Skills** und **72 brandreifen Design Systems**. Keine CLI? Ein OpenAI-kompatibler BYOK-Proxy ist dieselbe Schleife ohne Spawn.
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` ist da. Hier endet die alte Welt des Designs.
>
> Eine Open-Source-, agent-native Alternative zu Claude Design / Figma — 40k Sterne in zwei Wochen haben uns hierher gebracht. **Wir brauchen dich für den Rest des Weges.**
> Eine Open-Source-Alternative zu Claude Design / Figma — 40k Sterne in zwei Wochen haben uns hierher gebracht. **Wir brauchen dich für den Rest des Weges.**
>
> **Schnelle Iteration auf `main`** — 0.8.0 ist die nächste Phase von Open Design. Schick einen PR, wirf eine wilde Idee rein, melde einen Bug — was du mitbringst, dazu wird diese Bewegung.
>
> → [**Ankündigung lesen · Installer herunterladen · der Bewegung beitreten**](https://github.com/nexu-io/open-design/discussions/1727) · läuft parallel zu deinem aktuellen 0.7.
> **Die Open-Source-Alternative zu [Claude Design][cd].** Local-first, web-deploybar, BYOK auf jeder Ebene: **16 coding-agent CLIs** werden automatisch in Ihrem `PATH` erkannt (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) und werden zur Design-Engine, gesteuert von **31 kombinierbaren Skills** und **72 brandreifen Design Systems**. Keine CLI? Ein OpenAI-kompatibler BYOK-Proxy ist dieselbe Schleife ohne Spawn.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — editorial cover: design with the agent on your laptop" width="100%" />
</p>
@ -726,7 +726,7 @@ Vollständiger Walkthrough, Merge-Messlatte, Code Style und was wir nicht annehm
Danke an alle, die Open Design vorangebracht haben: durch Code, Docs, Feedback, neue Skills, neue Design Systems oder auch ein scharfes Issue. Jeder echte Beitrag zählt, und die Wand unten ist die einfachste Art, das laut zu sagen.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Open Design contributors" />
</a>
Wenn Sie Ihren ersten PR gemergt haben: willkommen. Das Label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ist der Einstiegspunkt.
@ -743,9 +743,9 @@ Das SVG oben wird täglich von [`.github/workflows/metrics.yml`](.github/workflo
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design — la alternativa open source oficial a [Claude Design][cd]
> **Open Design es la alternativa open source y local-first a [Claude Design][cd].** Desplegable en web, BYOK en cada capa: **16 CLI de coding agents** detectadas automáticamente en tu `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) se convierten en el motor de diseño, impulsadas por **31 Skills componibles** y **72 Design Systems de nivel marca**. ¿No tienes una CLI? Un proxy BYOK compatible con OpenAI ejecuta el mismo bucle sin el spawn local.
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` ya está aquí. El viejo mundo del diseño termina aquí.
>
> Una alternativa open source y agent-native a Claude Design / Figma — 40k estrellas en dos semanas nos trajeron hasta aquí. **Te necesitamos para empujar el resto del camino.**
> Una alternativa open source a Claude Design / Figma — 40k estrellas en dos semanas nos trajeron hasta aquí. **Te necesitamos para empujar el resto del camino.**
>
> **Iterando rápido sobre `main`** — 0.8.0 es la próxima fase de Open Design. Envía un PR, lanza una idea loca, reporta un bug — lo que traes tú es en lo que este movimiento se convierte.
>
> → [**Lee el anuncio · descarga el instalador · únete al movimiento**](https://github.com/nexu-io/open-design/discussions/1727) · funciona en paralelo con tu 0.7 actual.
> **La alternativa open source a [Claude Design][cd].** Local-first, desplegable en web, BYOK en cada capa: **16 CLI de coding agents** detectadas automáticamente en tu `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) se convierten en el motor de diseño, impulsadas por **31 Skills componibles** y **72 Design Systems de nivel marca**. ¿No tienes una CLI? Un proxy BYOK compatible con OpenAI ejecuta el mismo bucle sin el spawn local.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — editorial cover: design with the agent on your laptop" width="100%" />
</p>
@ -787,7 +787,7 @@ Walkthrough completo, estándar de merge, code style y lo que no aceptamos → [
Gracias a todas las personas que han ayudado a mover Open Design hacia adelante: con código, docs, feedback, nuevas skills, nuevos design systems o incluso un issue preciso. Toda contribución real cuenta, y el muro de abajo es la forma más simple de decirlo en voz alta.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Contribuidores de Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Contribuidores de Open Design" />
</a>
Si ya enviaste tu primer PR, bienvenido. La etiqueta [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) es el punto de entrada.
@ -804,9 +804,9 @@ El SVG anterior se regenera diariamente mediante [`.github/workflows/metrics.yml
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design — lalternative open source officielle à [Claude Design][cd]
> **Open Design est lalternative open source et local-first à [Claude Design][cd].** Déployable sur le web, BYOK à chaque couche : vos CLI de coding agents détectées automatiquement dans le `PATH` deviennent le design engine, piloté par les catalogues de **Skills** et de **Design Systems** du repo. Aucune CLI ? Le proxy BYOK multi-provider exécute la même boucle, sans spawn local.
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` est là. L'ancien monde du design s'arrête ici.
>
> Une alternative open source et agent-native à Claude Design / Figma — 40k étoiles en deux semaines nous ont menés jusqu'ici. **Nous avons besoin de toi pour faire le reste du chemin.**
> Une alternative open source à Claude Design / Figma — 40k étoiles en deux semaines nous ont menés jusqu'ici. **Nous avons besoin de toi pour faire le reste du chemin.**
>
> **Itération rapide sur `main`** — 0.8.0 est la prochaine phase d'Open Design. Envoie une PR, balance une idée folle, signale un bug — ce que tu apportes, c'est ce que ce mouvement devient.
>
> → [**Lire l'annonce · télécharger l'installateur · rejoindre le mouvement**](https://github.com/nexu-io/open-design/discussions/1727) · s'installe à côté de votre 0.7 actuel.
> **Lalternative open source à [Claude Design][cd].** Local-first, déployable sur le web, BYOK à chaque couche : vos CLI de coding agents détectées automatiquement dans le `PATH` deviennent le design engine, piloté par les catalogues de **Skills** et de **Design Systems** du repo. Aucune CLI ? Le proxy BYOK multi-provider exécute la même boucle, sans spawn local.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design : couverture éditoriale, design avec lagent sur votre laptop" width="100%" />
</p>
@ -733,7 +733,7 @@ Guide complet, critères de merge, style de code et refus fréquents → [`CONTR
Merci à toutes les personnes qui font avancer Open Design : code, docs, retours, nouveaux Skills, nouveaux Design Systems ou issues bien ciblées. Chaque vraie contribution compte.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Contributeurs Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Contributeurs Open Design" />
</a>
Si vous avez livré votre première PR, bienvenue. Le label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) est le point dentrée.
@ -750,9 +750,9 @@ Le SVG ci-dessus est régénéré chaque jour par [`.github/workflows/metrics.ym
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design — [Claude Design][cd] の公式オープンソース代替
> **Open Design は [Claude Design][cd] のオープンソース、ローカルファーストな代替です。** Vercel デプロイ可能、あらゆるレイヤーで BYOKBring Your Own Key`PATH` 上で自動検出される **16 種類の coding-agent CLI**Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUIがデザインエンジンとなり、**31 個の組み合わせ可能な Skill** と **72 種のブランドグレード Design System** で駆動されます。CLI が未インストールでも、OpenAI 互換の BYOK プロキシ `/api/proxy/stream` で同じループを spawn なしで実行できます。
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` が公開されました。デザインの旧時代は、ここで終わります。
>
> オープンソースで agent-native な Claude Design / Figma の代替 —— 2 週間で 40k stars、ここまで来ました。**残りの道のりは、あなたと一緒に進みたい。**
> Claude Design / Figma のオープンソース代替 —— 2 週間で 40k stars、ここまで来ました。**残りの道のりは、あなたと一緒に進みたい。**
>
> **`main` で高速イテレーション中** — 0.8.0 は Open Design の次のフェーズです。PR を投げ、突飛なアイデアを放り込み、バグを報告してください —— あなたが持ち込んだものが、このムーブメントの次の姿になります。
>
> → [**告知を読む · インストーラーを入手 · このムーブメントに参加**](https://github.com/nexu-io/open-design/discussions/1727) · 現在の 0.7 と並行してインストールできます。
> **[Claude Design][cd] のオープンソース代替。** ローカルファースト、Vercel デプロイ可能、あらゆるレイヤーで BYOKBring Your Own Key`PATH` 上で自動検出される **16 種類の coding-agent CLI**Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUIがデザインエンジンとなり、**31 個の組み合わせ可能な Skill** と **72 種のブランドグレード Design System** で駆動されます。CLI が未インストールでも、OpenAI 互換の BYOK プロキシ `/api/proxy/stream` で同じループを spawn なしで実行できます。
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — ノートパソコン上のエージェントとデザインする" width="100%" />
</p>
@ -723,7 +723,7 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
コード、ドキュメント、フィードバック、新 Skill、新 Design System、あるいは鋭い Issue — あらゆる形で Open Design を前進させてくださったすべての方に感謝します。すべての実質的なコントリビューションは大切であり、以下のウォールは最もシンプルな感謝の表明です。
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Open Design コントリビューター" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Open Design コントリビューター" />
</a>
初めての PR を送った方 — ようこそ。[`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ラベルがエントリポイントです。
@ -740,9 +740,9 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design — [Claude Design][cd]의 공식 오픈소스 대안
> **Open Design은 [Claude Design][cd]의 오픈소스, 로컬 우선 대안입니다.** 웹 배포 가능, 모든 레이어에서 BYOK — `PATH`에서 자동 감지되는 **16개의 코딩 에이전트 CLI**(Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI)가 **31가지 조합 가능한 Skill**과 **72가지 브랜드급 디자인 시스템**으로 구동되는 디자인 엔진이 됩니다. CLI가 하나도 없다? OpenAI 호환 BYOK 프록시가 spawn만 빠진 동일한 루프를 돌립니다.
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview`가 도착했습니다. 디자인의 옛 시대는 여기서 끝납니다.
>
> 오픈소스이자 agent-native한 Claude Design / Figma 대안 — 2주 만에 40k stars로 여기까지 왔습니다. **남은 길은 당신과 함께 가야 합니다.**
> Claude Design / Figma의 오픈소스 대안 — 2주 만에 40k stars로 여기까지 왔습니다. **남은 길은 당신과 함께 가야 합니다.**
>
> **`main`에서 빠르게 이터레이션 중** — 0.8.0은 Open Design의 다음 단계입니다. PR을 보내고, 거친 아이디어를 던지고, 버그를 신고하세요 — 당신이 가져오는 것이 곧 이 무브먼트가 됩니다.
>
> → [**공지 읽기 · 인스톨러 다운로드 · 무브먼트에 합류**](https://github.com/nexu-io/open-design/discussions/1727) · 현재 사용 중인 0.7과 나란히 설치됩니다.
> **[Claude Design][cd]의 오픈소스 대안.** 로컬 우선, 웹 배포 가능, 모든 레이어에서 BYOK — `PATH`에서 자동 감지되는 **16개의 코딩 에이전트 CLI**(Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI)가 **31가지 조합 가능한 Skill**과 **72가지 브랜드급 디자인 시스템**으로 구동되는 디자인 엔진이 됩니다. CLI가 하나도 없다? OpenAI 호환 BYOK 프록시가 spawn만 빠진 동일한 루프를 돌립니다.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — 노트북 위의 에이전트와 함께 설계하는 표지" width="100%" />
</p>
@ -726,7 +726,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. 스
Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다 — 코드, 문서, 피드백, 새 skill, 새 디자인 시스템, 또는 날카로운 이슈 하나라도. 모든 진짜 기여가 의미 있고, 아래의 벽이 가장 직접적인 "감사합니다"입니다.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Open Design 컨트리뷰터" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Open Design 컨트리뷰터" />
</a>
첫 PR을 보냈다면 — 환영합니다. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 레이블이 시작점입니다.
@ -743,9 +743,9 @@ Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design — the open-source Claude Design alternative
> **Open Design is the open-source, local-first alternative to [Claude Design][cd].** Web-deployable, BYOK at every layer — **16 coding-agent CLIs** auto-detected on your `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) become the design engine, driven by **31 composable Skills** and **72 brand-grade Design Systems**. No CLI? An OpenAI-compatible BYOK proxy is the same loop minus the spawn.
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` is here. Design's old world ends here.
>
> An open, agent-native alternative to Claude Design / Figma — 40k stars in two weeks got us this far. **We need you to push the rest of the way.**
> The open-source alternative to Claude Design / Figma — 40k stars in two weeks got us this far. **We need you to push the rest of the way.**
>
> **Iterating fast on `main`** — 0.8.0 is the next phase of Open Design. Ship a PR, drop a wild idea, file a bug — what you bring is what this movement becomes.
>
> → [**Read the announcement, grab the installer, join the movement**](https://github.com/nexu-io/open-design/discussions/1727) · runs side-by-side with your current 0.7.
> **The open-source alternative to [Claude Design][cd].** Local-first, web-deployable, BYOK at every layer — **16 coding-agent CLIs** auto-detected on your `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) become the design engine, driven by **31 composable Skills** and **72 brand-grade Design Systems**. No CLI? An OpenAI-compatible BYOK proxy is the same loop minus the spawn.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — editorial cover: design with the agent on your laptop" width="100%" />
</p>
@ -129,9 +129,9 @@ Linux AppImage packaging is available through the optional release lane and is c
## Skills
**31 skills ship in the box.** Each is a folder under [`skills/`](skills/) following the Claude Code [`SKILL.md`][skill] convention with an extended `od:` frontmatter that the daemon parses verbatim — `mode`, `platform`, `scenario`, `preview.type`, `design_system.requires`, `default_for`, `featured`, `fidelity`, `speaker_notes`, `animations`, `example_prompt` ([`apps/daemon/src/skills.ts`](apps/daemon/src/skills.ts)).
**132 skills ship in the box.** Each is a folder under [`skills/`](skills/) following the Claude Code [`SKILL.md`][skill] convention with an extended `od:` frontmatter that the daemon parses verbatim — `mode`, `platform`, `scenario`, `preview.type`, `design_system.requires`, `default_for`, `featured`, `fidelity`, `speaker_notes`, `animations`, `example_prompt` ([`apps/daemon/src/skills.ts`](apps/daemon/src/skills.ts)).
Two top-level **modes** carry the catalog: **`prototype`** (27 skills — anything that renders as a single-page artifact, from a magazine landing to a phone screen to a PM spec doc) and **`deck`** (4 skills — horizontal-swipe presentations with deck-framework chrome). The **`scenario`** field is what the picker groups them by: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
Two **modes** anchor the interactive catalog: **`prototype`** (32 skills — anything that renders as a single-page artifact, from a magazine landing to a phone screen to a PM spec doc) and **`deck`** (9 skills — horizontal-swipe presentations with deck-framework chrome). The catalog also ships `image`, `video`, `audio`, `template`, `design-system`, and `utility` modes for media generation, catalog updaters, and post-export audit helpers. The **`scenario`** field is what the picker groups them by: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
### Showcase examples
@ -259,8 +259,8 @@ What you compose at send time isn't "system + user". It's:
```
DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critique)
+ identity charter (OFFICIAL_DESIGNER_PROMPT, anti-AI-slop, junior-pass)
+ active DESIGN.md (72 systems available)
+ active SKILL.md (31 skills available)
+ active DESIGN.md (150 systems available)
+ active SKILL.md (132 skills available)
+ project metadata (kind, fidelity, speakerNotes, animations, inspiration ids)
+ skill side files (auto-injected pre-flight: read assets/template.html + references/*.md)
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
@ -392,7 +392,7 @@ For desktop/background startup, fixed-port restarts, and media generation dispat
The first load:
1. Detects which agent CLIs you have on `PATH` and picks one automatically.
2. Loads 31 skills + 72 design systems.
2. Loads 132 skills + 150 design systems.
3. Pops the welcome dialog so you can paste an Anthropic key (only needed for the BYOK fallback path).
4. **Auto-creates `./.od/`** — the local runtime folder for the SQLite project DB, per-project artifacts, and saved renders. There is no `od init` step; the daemon `mkdir`s everything it needs on boot.
@ -693,7 +693,7 @@ open-design/
│ ├── sidecar/ ← generic sidecar runtime primitives
│ └── platform/ ← generic process/platform primitives
├── skills/ ← 31 SKILL.md skill bundles (27 prototype + 4 deck)
├── skills/ ← 132 SKILL.md skill bundles (32 prototype + 9 deck + image / video / audio / template / design-system / utility)
│ ├── web-prototype/ ← default for prototype mode
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
@ -708,7 +708,7 @@ open-design/
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 DESIGN.md systems
├── design-systems/ ← 150 DESIGN.md systems
│ ├── default/ ← Neutral Modern (starter)
│ ├── warm-editorial/ ← Warm Editorial (starter)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
@ -750,10 +750,10 @@ open-design/
## Design Systems
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="The 72 design systems library — style guide spread" width="100%" />
<img src="docs/assets/design-systems-library.png" alt="The 150 design systems library — style guide spread" width="100%" />
</p>
72 systems out of the box, each as a single [`DESIGN.md`](design-systems/README.md):
150 systems out of the box, each as a single [`DESIGN.md`](design-systems/README.md):
<details>
<summary><b>Full catalog</b> (click to expand)</summary>
@ -972,7 +972,7 @@ Long-form provenance write-up — what we take from each, what we deliberately d
- [x] Daemon + agent detection (16 CLI adapters) + skill registry + design-system catalog
- [x] Web app + chat + question form + 5-direction picker + todo progress + sandboxed preview
- [x] 31 skills + 72 design systems + 5 visual directions + 5 device frames
- [x] 132 skills + 150 design systems + 5 visual directions + 5 device frames
- [x] SQLite-backed projects · conversations · messages · tabs · templates
- [x] Multi-provider BYOK proxy (`/api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream`) with SSRF guard
- [x] Claude Design ZIP import (`/api/import/claude-design`)
@ -1018,7 +1018,7 @@ Full walkthrough, bar-for-merging, code style, and what we don't accept → [`CO
Thanks to everyone who has helped move Open Design forward — through code, docs, feedback, new skills, new design systems, or even a sharp issue. Every real contribution counts, and the wall below is the easiest way to say so out loud.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Open Design contributors" />
</a>
If you've shipped your first PR — welcome. The [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label is the entry point.
@ -1035,9 +1035,9 @@ The SVG above is regenerated daily by [`.github/workflows/metrics.yml`](.github/
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design — a alternativa open source oficial ao [Claude Design][cd]
> **Open Design é a alternativa open source e local-first ao [Claude Design][cd].** Deployável via web, BYOK em toda camada — **16 CLIs de agentes de código** detectados automaticamente no seu `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) viram a engine de design, dirigidos por **31 Skills compositáveis** e **72 Design Systems de qualidade de marca**. Sem CLI? Um proxy BYOK compatível com OpenAI é o mesmo loop, só sem o spawn.
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` chegou. O velho mundo do design acaba aqui.
>
> Uma alternativa open source e agent-native ao Claude Design / Figma — 40k estrelas em duas semanas nos trouxeram até aqui. **Precisamos de você para nos levar o resto do caminho.**
> Uma alternativa open source ao Claude Design / Figma — 40k estrelas em duas semanas nos trouxeram até aqui. **Precisamos de você para nos levar o resto do caminho.**
>
> **Iterando rápido na `main`** — 0.8.0 é a próxima fase do Open Design. Mande um PR, jogue uma ideia maluca, abra um bug — o que você traz é no que este movimento se transforma.
>
> → [**Leia o anúncio · baixe o instalador · junte-se ao movimento**](https://github.com/nexu-io/open-design/discussions/1727) · roda em paralelo com seu 0.7 atual.
> **A alternativa open-source ao [Claude Design][cd].** Local-first, deployável via web, BYOK em toda camada — **16 CLIs de agentes de código** detectados automaticamente no seu `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) viram a engine de design, dirigidos por **31 Skills compositáveis** e **72 Design Systems de qualidade de marca**. Sem CLI? Um proxy BYOK compatível com OpenAI é o mesmo loop, só sem o spawn.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — capa editorial: design com o agente no seu laptop" width="100%" />
</p>
@ -730,7 +730,7 @@ Walkthrough completo, barra para mergear, estilo de código e o que não aceitam
Obrigado a todas as pessoas que ajudaram a empurrar o Open Design pra frente — via código, docs, feedback, novas skills, novos design systems ou até uma issue afiada. Toda contribuição real conta, e a parede abaixo é a forma mais simples de dizer isso em voz alta.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Contribuidoras e contribuidores do Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Contribuidoras e contribuidores do Open Design" />
</a>
Se você acabou de mandar seu primeiro PR — bem-vindo. A label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) é o ponto de entrada.
@ -747,9 +747,9 @@ O SVG acima é regenerado diariamente por [`.github/workflows/metrics.yml`](.git
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design — официальная open-source альтернатива [Claude Design][cd]
> **Open Design — открытая, локально-ориентированная альтернатива [Claude Design][cd].** Пригодна для web-деплоя, с BYOK на каждом уровне: **16 CLI coding-агентов** автоматически обнаруживаются в вашем `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) и превращаются в движок генерации дизайна, управляемый **31 комбинируемым навыком** и **72 дизайн-системами уровня бренда**. Нет CLI? OpenAI-совместимый BYOK-прокси даёт тот же цикл без локального запуска агента.
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` уже здесь. Старый мир дизайна заканчивается здесь.
>
> Open-source, agent-native альтернатива Claude Design / Figma — 40k звёзд за две недели довели нас до этой точки. **Дальше — только с тобой.**
> Open-source альтернатива Claude Design / Figma — 40k звёзд за две недели довели нас до этой точки. **Дальше — только с тобой.**
>
> **Быстрая итерация на `main`** — 0.8.0 — следующая фаза Open Design. Кидай PR, бросай безумную идею, заводи баг — что приносишь ты, таким и становится это движение.
>
> → [**Прочитать анонс · скачать установщик · присоединиться к движению**](https://github.com/nexu-io/open-design/discussions/1727) · устанавливается параллельно с твоей текущей 0.7.
> **Открытая альтернатива [Claude Design][cd].** Локально-ориентированная, пригодная для web-деплоя, с BYOK на каждом уровне: **16 CLI coding-агентов** автоматически обнаруживаются в вашем `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) и превращаются в движок генерации дизайна, управляемый **31 комбинируемым навыком** и **72 дизайн-системами уровня бренда**. Нет CLI? OpenAI-совместимый BYOK-прокси даёт тот же цикл без локального запуска агента.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — редакционная обложка: дизайн вместе с агентом на вашем ноутбуке" width="100%" />
</p>
@ -729,7 +729,7 @@ Issues, PR, новые skills и новые design systems приветству
Спасибо всем, кто помогает двигать Open Design вперёд — кодом, документацией, обратной связью, новыми skills, новыми design systems или просто точным issue. Вклад любого реального масштаба здесь важен, а стена ниже — самый простой способ сказать это вслух.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Contributors Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Contributors Open Design" />
</a>
Если вы только что отправили свой первый PR — добро пожаловать. Метка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — хорошая точка входа.
@ -746,9 +746,9 @@ SVG выше ежедневно пересобирается workflow [`.github/
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design — [Claude Design][cd]'in resmi açık kaynak alternatifi
> **Open Design, [Claude Design][cd]'in açık kaynak ve yerel öncelikli alternatifidir.** Web'e dağıtılabilir, her katmanda BYOK; `PATH` üzerinde otomatik algılanan **16 coding-agent CLI** (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) tasarım motoruna dönüşür. Hepsi **31 birleştirilebilir Skill** ve **72 marka kalitesinde Design System** tarafından yönlendirilir. CLI yok mu? OpenAI uyumlu BYOK proxy aynı döngünün agent spawn olmadan çalışan halidir.
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` burada. Tasarımın eski dünyası burada bitiyor.
>
> Claude Design / Figma'ya açık kaynaklı, agent-native bir alternatif — iki haftada 40k stars bizi buraya getirdi. **Yolun geri kalanını birlikte yürüyelim.**
> Claude Design / Figma'ya açık kaynaklı bir alternatif — iki haftada 40k stars bizi buraya getirdi. **Yolun geri kalanını birlikte yürüyelim.**
>
> **`main` üzerinde hızlı iterasyon** — 0.8.0 Open Design'in bir sonraki aşaması. Bir PR gönder, çılgın bir fikir at, bir bug bildir — sen ne getirirsen bu hareket o olur.
>
> → [**Duyuruyu oku · kurulum dosyasını indir · harekete katıl**](https://github.com/nexu-io/open-design/discussions/1727) · mevcut 0.7'nin yanına paralel kurulur.
> **[Claude Design][cd] için açık kaynak alternatif.** Yerel öncelikli, web'e dağıtılabilir, her katmanda BYOK; `PATH` üzerinde otomatik algılanan **16 coding-agent CLI** (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) tasarım motoruna dönüşür. Hepsi **31 birleştirilebilir Skill** ve **72 marka kalitesinde Design System** tarafından yönlendirilir. CLI yok mu? OpenAI uyumlu BYOK proxy aynı döngünün agent spawn olmadan çalışan halidir.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — dizüstü bilgisayarındaki agent ile tasarım yapma editoryal kapağı" width="100%" />
</p>
@ -887,7 +887,7 @@ Tam walkthrough, merge çıtası, code style ve kabul etmediklerimiz → [`CONTR
Open Design'ı kod, doküman, feedback, yeni skill, yeni design system veya keskin bir issue ile ileri taşıyan herkese teşekkürler. Her gerçek katkı önemlidir; aşağıdaki wall bunu yüksek sesle söylemenin en kolay yolu.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Open Design contributors" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Open Design contributors" />
</a>
İlk PR'ını gönderdiysen hoş geldin. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label'ı giriş noktasıdır.
@ -904,9 +904,9 @@ Yukarıdaki SVG [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml)
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design — офіційна open-source альтернатива [Claude Design][cd]
> **Open Design — це альтернатива з відкритим кодом і локально-перший варіант [Claude Design][cd].** Розгортується в web, BYOK на кожному рівні — **16 CLI агентів для кодування** автоматично виявляються у вашому `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) стають механізмом дизайну, керуються **31 компонуваною навичкою** та **72 системами дизайну комерційного класу**. Немає CLI? OpenAI-сумісний BYOK проксі — це той же цикл без spawn.
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` уже тут. Старий світ дизайну закінчується тут.
>
> Open-source, agent-native альтернатива Claude Design / Figma — 40k зірок за два тижні привели нас сюди. **Далі — тільки з тобою.**
> Open-source альтернатива Claude Design / Figma — 40k зірок за два тижні привели нас сюди. **Далі — тільки з тобою.**
>
> **Швидка ітерація на `main`** — 0.8.0 — наступна фаза Open Design. Кидай PR, кидай шалену ідею, заводь баг — те, що приносиш ти, тим стає цей рух.
>
> → [**Прочитати анонс · завантажити інсталятор · приєднатися до руху**](https://github.com/nexu-io/open-design/discussions/1727) · встановлюється паралельно з твоєю поточною 0.7.
> **Альтернатива з відкритим кодом до [Claude Design][cd].** Локально-перший, розгортується в web, BYOK на кожному рівні — **16 CLI агентів для кодування** автоматично виявляються у вашому `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) стають механізмом дизайну, керуються **31 компонуваною навичкою** та **72 системами дизайну комерційного класу**. Немає CLI? OpenAI-сумісний BYOK проксі — це той же цикл без spawn.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — editorial cover: design with the agent on your laptop" width="100%" />
</p>
@ -729,7 +729,7 @@ OD не зупиняється на коді. Та сама поверхня ч
Дякуємо всім, хто допоміг просувати Open Design — через код, документацію, зворотний зв'язок, нові навички, нові системи дизайну або навіть гостре питання. Кожен реальний внесок рахується, а стіна нижче — найпростіший спосіб сказати це вголос.
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Контриб'ютори Open Design" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Контриб'ютори Open Design" />
</a>
Якщо ви злили свій перший PR — ласкаво просимо. Мітка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — це точка входу.
@ -746,9 +746,9 @@ SVG вище перегенерується щодня [`.github/workflows/metri
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design —— [Claude Design][cd] 的官方开源替代品
> **Open Design 是 [Claude Design][cd] 的开源、本地优先替代品。** 可部署到 Vercel、每一层都 BYOK —— **16 套 coding-agent CLI**`PATH` 上自动检测Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI就是设计引擎**31 个可组合 Skills****72 套品牌级 Design System** 驱动。
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` 已发布。设计的旧世界,到此为止。
>
> 开源、agent-native 的 Claude Design / Figma 替代品 —— 上线两周40k stars 在身,且仍在加速。**剩下的路,需要你和我们一起推完。**
> Claude Design / Figma 的开源替代品 —— 上线两周40k stars 在身,且仍在加速。**剩下的路,需要你和我们一起推完。**
>
> **正在 `main` 分支飞速迭代中** —— 0.8.0 是 Open Design 的下一阶段。提一个 PR、扔一个想法、报一个 bug —— 你带来的,就是这场运动接下来的样子。
>
> → [**读公告 · 下载安装包 · 加入这场运动**](https://github.com/nexu-io/open-design/discussions/1727) · 可与你现有的 0.7 并行安装。
> **[Claude Design][cd] 的开源替代品。** 本地优先、可部署到 Vercel、每一层都 BYOK —— **16 套 coding-agent CLI**`PATH` 上自动检测Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI就是设计引擎**31 个可组合 Skills****72 套品牌级 Design System** 驱动。
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design 封面:与本地 AI 智能体共同设计" width="100%" />
</p>
@ -722,7 +722,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
感谢每一位让 Open Design 变得更好的朋友 —— 无论是写代码、修文档、提 issue、加 skill 还是加 design system每一次真实贡献都会被记住。下面这面墙是最直观的「Thank you」。
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Open Design 贡献者" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Open Design 贡献者" />
</a>
第一次提 PR欢迎从 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 标签起步。
@ -739,9 +739,9 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -1,16 +1,16 @@
# Open Design
# Open Design —— [Claude Design][cd] 的官方開源替代品
> **Open Design 是 [Claude Design][cd] 的開源、本地優先替代品。** 可部署到 Vercel、每一層都 BYOK —— **16 套 coding-agent CLI**`PATH` 上自動檢測Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI就是設計引擎**31 個可組合 Skills****72 套品牌級 Design System** 驅動。一個都沒裝?還有 OpenAI 相容的 BYOK 代理 `/api/proxy/stream` 備援,同一條 loop少一次 spawn 而已。
> [!IMPORTANT]
> ### 🔥 `0.8.0-preview` 已發佈。設計的舊世界,到此為止。
>
> 開源、agent-native 的 Claude Design / Figma 替代品 —— 上線兩週40k stars 在身,且仍在加速。**剩下的路,需要你和我們一起推完。**
> Claude Design / Figma 的開源替代品 —— 上線兩週40k stars 在身,且仍在加速。**剩下的路,需要你和我們一起推完。**
>
> **正在 `main` 分支飛速迭代中** —— 0.8.0 是 Open Design 的下一階段。提一個 PR、扔一個想法、報一個 bug —— 你帶來的,就是這場運動接下來的樣子。
>
> → [**讀公告 · 下載安裝包 · 加入這場運動**](https://github.com/nexu-io/open-design/discussions/1727) · 可與你現有的 0.7 並行安裝。
> **[Claude Design][cd] 的開源替代品。** 本地優先、可部署到 Vercel、每一層都 BYOK —— **16 套 coding-agent CLI**`PATH` 上自動檢測Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI就是設計引擎**31 個可組合 Skills****72 套品牌級 Design System** 驅動。一個都沒裝?還有 OpenAI 相容的 BYOK 代理 `/api/proxy/stream` 備援,同一條 loop少一次 spawn 而已。
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design 封面:與本地 AI 智慧體共同設計" width="100%" />
</p>
@ -1005,7 +1005,7 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
感謝每一位讓 Open Design 變得更好的朋友 —— 無論是寫程式碼、修文檔、提 issue、加 skill 還是加 design system每一次真實貢獻都會被記住。下面這面牆是最直觀的「Thank you」。
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-18" alt="Open Design 貢獻者" />
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-22" alt="Open Design 貢獻者" />
</a>
第一次提 PR歡迎從 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 標籤起步。
@ -1022,9 +1022,9 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
<a href="https://star-history.com/#nexu-io/open-design&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-18" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-22" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-22" />
</picture>
</a>

View file

@ -44,6 +44,7 @@
"@opentelemetry/api": "1.9.1",
"better-sqlite3": "12.10.0",
"blake3-wasm": "2.1.5",
"cheerio": "1.2.0",
"chokidar": "5.0.0",
"express": "4.22.1",
"jszip": "3.10.1",

View file

@ -1,71 +1,128 @@
import type { Express } from 'express';
import { createApiError } from '@open-design/contracts';
import { ACTIVE_CONTEXT_TTL_MS } from './constants.js';
import type { RouteDeps } from './server-context.js';
import { defineJsonRoute, err, mountJsonRoute, ok, type Result } from './http/index.js';
export interface RegisterActiveContextRoutesDeps extends RouteDeps<'db' | 'http' | 'projectStore'> {}
export function registerActiveContextRoutes(app: Express, ctx: RegisterActiveContextRoutesDeps) {
const { db } = ctx;
const { sendApiError, isLocalSameOrigin, resolvedPortRef } = ctx.http;
const { getProject } = ctx.projectStore;
const getResolvedPort = () => resolvedPortRef.current;
// Soft "what is the user looking at right now in Open Design?" channel. The
// web UI POSTs the current project + file on every route change; the MCP
// surface reads it so a coding agent in another repo can resolve "the design
// I have open" without the user typing the project id. In-memory only -
// I have open" without the user typing the project id. In-memory only —
// daemon restart clears it.
let activeContext: { projectId: string; fileName: string | null; ts: number } | null = null;
// Active context is private to the local machine. The daemon may bind beyond
// loopback, so without an origin check a peer on the LAN could read what the
// user is currently looking at (GET) or spoof it to redirect MCP fallbacks
// (POST). The web proxies same-origin and MCP runs in-process via 127.0.0.1,
// so both legitimate callers pass the check.
app.post('/api/active', (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({ error: 'cross-origin request rejected' });
interface ActiveContext {
projectId: string;
fileName: string | null;
ts: number;
}
try {
const body = req.body || {};
interface ActiveContextStore {
current: ActiveContext | null;
}
type PostActiveInput =
| { kind: 'clear' }
| { kind: 'set'; projectId: string; fileName: string | null };
type PostActiveOutput =
| { active: false }
| { active: true; projectId: string; fileName: string | null; ts: number };
type GetActiveOutput =
| { active: false }
| {
active: true;
projectId: string;
projectName: string | null;
fileName: string | null;
ts: number;
ageMs: number;
};
interface ActiveContextDomainDeps {
store: ActiveContextStore;
db: unknown;
getProject: (db: unknown, projectId: string) => { name?: string | null } | null | undefined;
now: () => number;
}
function parsePostActive(raw: { body: unknown }): Result<PostActiveInput> {
const body = (raw.body ?? {}) as Record<string, unknown>;
if (body.active === false) {
activeContext = null;
res.json({ active: false });
return;
return ok({ kind: 'clear' });
}
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
if (!projectId) {
sendApiError(res, 400, 'BAD_REQUEST', 'projectId is required');
return;
return err(createApiError('BAD_REQUEST', 'projectId is required'));
}
const fileName =
typeof body.fileName === 'string' && body.fileName.length > 0
? body.fileName
: null;
activeContext = { projectId, fileName, ts: Date.now() };
res.json({ active: true, ...activeContext });
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
typeof body.fileName === 'string' && body.fileName.length > 0 ? body.fileName : null;
return ok({ kind: 'set', projectId, fileName });
}
function handlePostActive(
input: PostActiveInput,
deps: ActiveContextDomainDeps,
): Result<PostActiveOutput> {
if (input.kind === 'clear') {
deps.store.current = null;
return ok({ active: false });
}
const next: ActiveContext = {
projectId: input.projectId,
fileName: input.fileName,
ts: deps.now(),
};
deps.store.current = next;
return ok({ active: true, ...next });
}
function handleGetActive(
_input: void,
deps: ActiveContextDomainDeps,
): Result<GetActiveOutput> {
const current = deps.store.current;
if (!current || deps.now() - current.ts > ACTIVE_CONTEXT_TTL_MS) {
deps.store.current = null;
return ok({ active: false });
}
const project = deps.getProject(deps.db, current.projectId);
return ok({
active: true,
projectId: current.projectId,
projectName: project?.name ?? null,
fileName: current.fileName,
ts: current.ts,
ageMs: deps.now() - current.ts,
});
}
export const postActiveRoute = defineJsonRoute<PostActiveInput, PostActiveOutput, ActiveContextDomainDeps>({
method: 'post',
path: '/api/active',
requireSameOrigin: true,
parse: parsePostActive,
handle: handlePostActive,
});
app.get('/api/active', (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
if (!activeContext || Date.now() - activeContext.ts > ACTIVE_CONTEXT_TTL_MS) {
activeContext = null;
res.json({ active: false });
return;
}
const project = getProject(db, activeContext.projectId);
res.json({
active: true,
projectId: activeContext.projectId,
projectName: project?.name ?? null,
fileName: activeContext.fileName,
ts: activeContext.ts,
ageMs: Date.now() - activeContext.ts,
});
export const getActiveRoute = defineJsonRoute<void, GetActiveOutput, ActiveContextDomainDeps>({
method: 'get',
path: '/api/active',
requireSameOrigin: true,
parse: () => ok(undefined),
handle: handleGetActive,
});
export function registerActiveContextRoutes(app: Express, ctx: RegisterActiveContextRoutesDeps): void {
const store: ActiveContextStore = { current: null };
const domainDeps: ActiveContextDomainDeps = {
store,
db: ctx.db,
getProject: ctx.projectStore.getProject,
now: () => Date.now(),
};
const adapter = { resolvedPortRef: ctx.http.resolvedPortRef };
mountJsonRoute(app, postActiveRoute, domainDeps, adapter);
mountJsonRoute(app, getActiveRoute, domainDeps, adapter);
}

View file

@ -9,10 +9,8 @@
// back as a `role: 'tool'` message → re-issue the completion. The chat surface
// stays the same; the tool dispatch happens entirely daemon-side.
//
// Today we ship one tool — `generate_image` — backed by SenseAudio's
// /v1/image/sync endpoint, since the BYOK chat session already authenticates
// against SenseAudio with the same API key. Additional tools (TTS, video,
// research) can be added here as the BYOK surface expands.
// Today we ship image, video, and speech tools backed by SenseAudio endpoints,
// since the BYOK chat session already authenticates with the same API key.
import path from 'node:path';
import { writeFile } from 'node:fs/promises';
@ -43,6 +41,18 @@ export function isSenseAudioImageModel(value: unknown): value is string {
const SENSEAUDIO_DEFAULT_BASE_URL = 'https://api.senseaudio.cn';
const PROMPT_MAX_LENGTH = 2000;
const SENSEAUDIO_TTS_MODEL = 'senseaudio-tts-1.5-260319';
const SENSEAUDIO_DEFAULT_VOICE_ID = 'female_0033_b';
const HEX_AUDIO_PATTERN = /^[0-9a-fA-F]+$/;
function appendSenseAudioApiPath(baseUrl: string, path: string): string {
const url = new URL(baseUrl);
const trimmed = url.pathname.replace(/\/+$/, '');
url.pathname = /\/v\d+(\/|$)/.test(trimmed)
? `${trimmed}${path}`
: `${trimmed}/v1${path}`;
return url.toString();
}
// SenseAudio video — the API only documents one model today, so the
// wire id is a const. The chat tool's `generate_video` param surface
@ -122,6 +132,30 @@ export const BYOK_SENSEAUDIO_TOOLS = [
},
},
},
{
type: 'function' as const,
function: {
name: 'generate_speech',
description:
'Generate a text-to-speech voiceover using SenseAudio TTS. Returns a URL pointing to the rendered MP3. Use this whenever the user asks for narration, voiceover, speech, TTS, or spoken audio. After this tool succeeds, reply with a clickable markdown link to the MP3.',
parameters: {
type: 'object',
properties: {
text: {
type: 'string',
description:
'Exact script to speak. Include only the words that should be spoken, not production notes.',
},
voice_id: {
type: 'string',
description:
`Optional SenseAudio voice id. Defaults to ${SENSEAUDIO_DEFAULT_VOICE_ID}.`,
},
},
required: ['text'],
},
},
},
{
type: 'function' as const,
function: {
@ -217,6 +251,102 @@ export interface ImageToolResult {
error?: string;
}
export async function executeGenerateSpeech(
args: { text?: unknown; voice_id?: unknown },
ctx: BYOKToolContext,
): Promise<ImageToolResult> {
const text = typeof args.text === 'string' ? args.text.trim() : '';
if (!text) return { ok: false, error: 'text is required' };
let dir: string;
try {
dir = await ensureProject(ctx.projectsRoot, ctx.projectId);
} catch (err) {
return {
ok: false,
error: `invalid projectId for speech storage: ${err instanceof Error ? err.message : String(err)}`,
};
}
const apiKey = ctx.upstreamApiKey;
if (!apiKey) return { ok: false, error: 'no SenseAudio API key available' };
const voiceId =
typeof args.voice_id === 'string' && args.voice_id.trim()
? args.voice_id.trim()
: SENSEAUDIO_DEFAULT_VOICE_ID;
const baseUrl = ctx.upstreamBaseUrl || SENSEAUDIO_DEFAULT_BASE_URL;
let data: {
data?: { audio?: string };
base_resp?: { status_code?: number; status_msg?: string };
};
try {
const resp = await fetch(appendSenseAudioApiPath(baseUrl, '/t2a_v2'), {
method: 'POST',
redirect: 'error',
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify({
model: SENSEAUDIO_TTS_MODEL,
text,
stream: false,
voice_setting: {
voice_id: voiceId,
speed: 1,
vol: 1,
pitch: 0,
},
audio_setting: {
format: 'mp3',
sample_rate: 32000,
bitrate: 128000,
channel: 2,
},
}),
});
const respText = await resp.text();
if (!resp.ok) {
return { ok: false, error: `senseaudio speech ${resp.status}: ${respText.slice(0, 240)}` };
}
try {
data = JSON.parse(respText) as typeof data;
} catch {
return { ok: false, error: `senseaudio speech non-JSON: ${respText.slice(0, 200)}` };
}
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
if (data?.base_resp && data.base_resp.status_code !== 0) {
return {
ok: false,
error: `senseaudio speech api error ${data.base_resp.status_code}: ${data.base_resp.status_msg || 'unknown'}`,
};
}
const hex = data?.data?.audio;
if (typeof hex !== 'string' || !hex) {
return { ok: false, error: 'senseaudio speech response missing data.audio' };
}
if (hex.length % 2 !== 0 || !HEX_AUDIO_PATTERN.test(hex)) {
return { ok: false, error: 'senseaudio speech response contained invalid hex audio' };
}
const bytes = Buffer.from(hex, 'hex');
if (bytes.length === 0) return { ok: false, error: 'senseaudio speech decoded zero bytes' };
const id = `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
const filename = `byok-speech-${id}.mp3`;
await writeFile(path.join(dir, filename), bytes);
return {
ok: true,
url: `/api/projects/${encodeURIComponent(ctx.projectId)}/files/${filename}`,
};
}
function sanitizeAspectRatio(raw: unknown): string {
if (typeof raw !== 'string') return '1:1';
return ASPECT_TO_SIZE[raw] ? raw : '1:1';
@ -595,4 +725,3 @@ export async function executeGenerateVideo(
url: `/api/projects/${encodeURIComponent(ctx.projectId)}/files/${filename}`,
};
}

View file

@ -4,6 +4,7 @@ import { seedProviderIfMissing } from './media-config.js';
import {
BYOK_SENSEAUDIO_TOOLS,
executeGenerateImage,
executeGenerateSpeech,
executeGenerateVideo,
isSenseAudioImageModel,
type BYOKToolContext,
@ -1341,24 +1342,29 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const executeOneTool = async (call: {
id: string;
function: { name: string; arguments: string };
}): Promise<{ ok: boolean; url?: string; error?: string; kind?: 'image' | 'video' }> => {
}): Promise<{ ok: boolean; url?: string; error?: string; kind?: 'image' | 'video' | 'speech' }> => {
const fnName = call?.function?.name ?? '';
if (fnName !== 'generate_image' && fnName !== 'generate_video') {
if (fnName !== 'generate_image' && fnName !== 'generate_video' && fnName !== 'generate_speech') {
return {
ok: false,
error: `unknown tool: ${fnName || 'unnamed'}`,
};
}
const toolKind = fnName === 'generate_image' ? 'image' : fnName === 'generate_video' ? 'video' : 'speech';
let args: any = {};
try {
args = JSON.parse(call.function.arguments || '{}');
} catch {
return { ok: false, error: 'tool arguments were not valid JSON' };
return { ok: false, error: 'tool arguments were not valid JSON', kind: toolKind };
}
if (fnName === 'generate_image') {
const result = await executeGenerateImage(args, toolCtx);
return { ...result, kind: 'image' };
}
if (fnName === 'generate_speech') {
const result = await executeGenerateSpeech(args, toolCtx);
return { ...result, kind: 'speech' };
}
// generate_video — longer (up to 5 min), async-with-polling.
const result = await executeGenerateVideo(args, toolCtx);
return { ...result, kind: 'video' };
@ -1425,9 +1431,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const content = result.ok
? result.kind === 'video'
? `Video generated successfully. URL: ${result.url}. Reply to the user with a clickable markdown link, e.g. [▶ Play video](${result.url}). Do NOT use markdown image syntax — the chat renderer does not embed <video> tags.`
: result.kind === 'speech'
? `Speech generated successfully. URL: ${result.url}. Reply to the user with a clickable markdown link to the MP3, e.g. [▶ Play voiceover](${result.url}).`
: `Image generated successfully. URL: ${result.url}. Reply to the user with: ![generated image](${result.url})`
: result.kind === 'video'
? `Video generation failed: ${result.error}. Apologize briefly and suggest a retry with a more specific prompt or a shorter duration.`
: result.kind === 'speech'
? `Speech generation failed: ${result.error}. Apologize briefly and suggest a retry with a shorter script or a valid voice id.`
: `Image generation failed: ${result.error}. Apologize briefly and suggest a retry with a more specific prompt.`;
workingMessages.push({
role: 'tool',

View file

@ -28,6 +28,7 @@
// source root so an environment that puts `skills/` itself behind a
// symlink (e.g. a content-addressable mount) is followed correctly.
import { createHash } from 'node:crypto';
import { cp, lstat, rm, stat } from 'node:fs/promises';
import path from 'node:path';
@ -44,6 +45,13 @@ export interface SkillStagingResult {
reason?: string;
}
export function skillCwdAliasSegment(dir: string): string {
const folder = path.basename(dir) || 'skill';
const normalizedDir = path.resolve(dir).replaceAll('\\', '/');
const digest = createHash('sha256').update(normalizedDir).digest('hex').slice(0, 10);
return `${folder}-${digest}`;
}
/**
* Copy `<sourceDir>` to `<cwd>/.od-skills/<folderName>/` so an agent can
* reach skill side files via a cwd-relative path. Idempotent and

View file

@ -94,6 +94,7 @@ function migrate(db: SqliteDb): void {
attachments_json TEXT,
produced_files_json TEXT,
feedback_json TEXT,
pre_turn_file_names_json TEXT,
started_at INTEGER,
ended_at INTEGER,
position INTEGER NOT NULL,
@ -228,6 +229,9 @@ function migrate(db: SqliteDb): void {
if (!messageCols.some((c: DbRow) => c.name === 'feedback_json')) {
db.exec(`ALTER TABLE messages ADD COLUMN feedback_json TEXT`);
}
if (!messageCols.some((c: DbRow) => c.name === 'pre_turn_file_names_json')) {
db.exec(`ALTER TABLE messages ADD COLUMN pre_turn_file_names_json TEXT`);
}
const routineRunCols = db.prepare(`PRAGMA table_info(routine_runs)`).all() as DbRow[];
if (!routineRunCols.some((c: DbRow) => c.name === 'error_code')) {
db.exec(`ALTER TABLE routine_runs ADD COLUMN error_code TEXT`);
@ -874,6 +878,7 @@ export function listMessages(db: SqliteDb, conversationId: string) {
comment_attachments_json AS commentAttachmentsJson,
produced_files_json AS producedFilesJson,
feedback_json AS feedbackJson,
pre_turn_file_names_json AS preTurnFileNamesJson,
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
position
FROM messages
@ -895,7 +900,9 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
SET role = ?, content = ?, agent_id = ?, agent_name = ?,
run_id = ?, run_status = ?, last_run_event_id = ?,
events_json = ?, attachments_json = ?, comment_attachments_json = ?,
produced_files_json = ?, feedback_json = ?, started_at = ?, ended_at = ?
produced_files_json = ?, feedback_json = ?,
pre_turn_file_names_json = ?,
started_at = ?, ended_at = ?
WHERE id = ?`,
).run(
m.role,
@ -910,6 +917,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
m.feedback ? JSON.stringify(m.feedback) : null,
m.preTurnFileNames ? JSON.stringify(m.preTurnFileNames) : null,
m.startedAt ?? null,
m.endedAt ?? null,
m.id,
@ -921,17 +929,18 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
)
.get(conversationId) as DbRow | undefined;
const position = (max?.m ?? -1) + 1;
// 18 values: id, conversation_id, role, content, agent_id, agent_name,
// 19 values: id, conversation_id, role, content, agent_id, agent_name,
// run_id, run_status, last_run_event_id, events_json, attachments_json,
// comment_attachments_json, produced_files_json, feedback_json, started_at, ended_at,
// position, created_at.
// comment_attachments_json, produced_files_json, feedback_json,
// pre_turn_file_names_json, started_at, ended_at, position, created_at.
db.prepare(
`INSERT INTO messages
(id, conversation_id, role, content, agent_id, agent_name,
run_id, run_status, last_run_event_id, events_json,
attachments_json, comment_attachments_json, produced_files_json,
feedback_json, started_at, ended_at, position, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
feedback_json, pre_turn_file_names_json,
started_at, ended_at, position, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
m.id,
conversationId,
@ -947,6 +956,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
m.feedback ? JSON.stringify(m.feedback) : null,
m.preTurnFileNames ? JSON.stringify(m.preTurnFileNames) : null,
m.startedAt ?? null,
m.endedAt ?? null,
position,
@ -968,6 +978,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
comment_attachments_json AS commentAttachmentsJson,
produced_files_json AS producedFilesJson,
feedback_json AS feedbackJson,
pre_turn_file_names_json AS preTurnFileNamesJson,
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
position
FROM messages WHERE id = ?`,
@ -1256,6 +1267,7 @@ function normalizeMessage(row: DbRow) {
commentAttachments: parseJsonOrUndef(row.commentAttachmentsJson),
producedFiles: parseJsonOrUndef(row.producedFilesJson),
feedback: parseJsonOrUndef(row.feedbackJson),
preTurnFileNames: parseJsonOrUndef(row.preTurnFileNamesJson),
createdAt: row.createdAt ?? undefined,
startedAt: row.startedAt ?? undefined,
endedAt: row.endedAt ?? undefined,

View file

@ -0,0 +1,59 @@
import type { Express, Request, Response } from 'express';
import { createApiError } from '@open-design/contracts';
import { rawInput } from './parse.js';
import { sendApiError, sendJson, statusForError } from './response.js';
import { guardSameOrigin, type OriginContext } from './origin-guard.js';
import type { JsonRouteSpec } from './types.js';
export interface AdapterContext extends OriginContext {}
/**
* Identity function that pins a route spec's generic parameters at the
* definition site so callers do not have to repeat them. The returned spec
* is consumed by `mountJsonRoute` (live) and by tests (direct invocation of
* `route.parse` / `route.handle`).
*/
export function defineJsonRoute<Input, Output, Deps>(
spec: JsonRouteSpec<Input, Output, Deps>,
): JsonRouteSpec<Input, Output, Deps> {
return spec;
}
/**
* Mounts one JsonRouteSpec on an Express app. The Adapter is the only code
* here that knows about req/res; the route's parse and handle functions
* operate on `RouteInputContext` and `Deps` respectively, so they are unit
* testable without Express.
*/
export function mountJsonRoute<Input, Output, Deps>(
app: Express,
spec: JsonRouteSpec<Input, Output, Deps>,
deps: Deps,
adapter: AdapterContext,
): void {
app[spec.method](spec.path, async (req: Request, res: Response) => {
try {
if (spec.requireSameOrigin) {
const origin = guardSameOrigin(req, adapter);
if (!origin.ok) {
sendApiError(res, statusForError(origin.error), origin.error);
return;
}
}
const parsed = spec.parse(rawInput(req));
if (!parsed.ok) {
sendApiError(res, statusForError(parsed.error), parsed.error);
return;
}
const result = await spec.handle(parsed.value, deps);
if (!result.ok) {
sendApiError(res, statusForError(result.error), result.error);
return;
}
sendJson(res, spec.successStatus ?? 200, result.value);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
sendApiError(res, 500, createApiError('INTERNAL_ERROR', message));
}
});
}

View file

@ -0,0 +1,5 @@
export * from './types.js';
export * from './parse.js';
export * from './response.js';
export * from './origin-guard.js';
export * from './adapter.js';

View file

@ -0,0 +1,20 @@
import type { Request } from 'express';
import { createApiError } from '@open-design/contracts';
import { isLocalSameOrigin } from '../origin-validation.js';
import { err, ok, type Result } from './types.js';
export interface OriginContext {
resolvedPortRef: { current: number };
}
/**
* Adapter wrapper around `isLocalSameOrigin` that yields a `Result` so the
* HTTP Adapter can fold the origin decision into the same error-handling
* pipeline as parse/handle failures.
*/
export function guardSameOrigin(req: Request, origin: OriginContext): Result<void> {
if (isLocalSameOrigin(req, origin.resolvedPortRef.current)) {
return ok(undefined);
}
return err(createApiError('FORBIDDEN', 'cross-origin request rejected'));
}

View file

@ -0,0 +1,23 @@
import type { Request } from 'express';
import { createApiError, type ApiError } from '@open-design/contracts';
import type { RouteInputContext } from './types.js';
export function rawInput(req: Request): RouteInputContext {
return {
body: req.body,
query: (req.query ?? {}) as Record<string, unknown>,
params: (req.params ?? {}) as Record<string, string>,
};
}
export function validationError(
message: string,
issues: Array<{ path: string; message: string }> = [],
): ApiError {
if (issues.length === 0) {
return createApiError('BAD_REQUEST', message);
}
return createApiError('BAD_REQUEST', message, {
details: { kind: 'validation', issues } as unknown as NonNullable<ApiError['details']>,
});
}

View file

@ -0,0 +1,32 @@
import type { Response } from 'express';
import { createApiErrorResponse, type ApiError, type ApiErrorCode } from '@open-design/contracts';
export function sendJson(res: Response, status: number, body: unknown): void {
res.status(status).json(body);
}
export function sendApiError(res: Response, status: number, error: ApiError): void {
res.status(status).json(createApiErrorResponse(error));
}
const ERROR_STATUS_BY_CODE: Partial<Record<ApiErrorCode, number>> = {
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
PAYLOAD_TOO_LARGE: 413,
UNSUPPORTED_MEDIA_TYPE: 415,
VALIDATION_FAILED: 422,
RATE_LIMITED: 429,
PROJECT_NOT_FOUND: 404,
FILE_NOT_FOUND: 404,
ARTIFACT_NOT_FOUND: 404,
INTERNAL_ERROR: 500,
AGENT_UNAVAILABLE: 503,
UPSTREAM_UNAVAILABLE: 502,
};
export function statusForError(error: ApiError): number {
return ERROR_STATUS_BY_CODE[error.code] ?? 500;
}

View file

@ -0,0 +1,32 @@
import type { ApiError } from '@open-design/contracts';
export type Result<T, E = ApiError> =
| { ok: true; value: T }
| { ok: false; error: E };
export const ok = <T, E = ApiError>(value: T): Result<T, E> => ({ ok: true, value });
export const err = <T = never, E = ApiError>(error: E): Result<T, E> => ({ ok: false, error });
export interface RouteInputContext {
body: unknown;
query: Record<string, unknown>;
params: Record<string, string>;
}
export type InputParser<Input> = (raw: RouteInputContext) => Result<Input>;
export type Handler<Input, Output, Deps> = (
input: Input,
deps: Deps,
) => Promise<Result<Output>> | Result<Output>;
export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
export interface JsonRouteSpec<Input, Output, Deps> {
method: HttpMethod;
path: string;
requireSameOrigin?: boolean;
parse: InputParser<Input>;
handle: Handler<Input, Output, Deps>;
successStatus?: number;
}

View file

@ -71,6 +71,16 @@ function extractErrorMessage(value: unknown, fallback: string): string {
return fallback;
}
function isRecoverableCodexReconnect(message: string): boolean {
return (
message.startsWith('Reconnecting...') &&
(
message.includes('timeout waiting for child process to exit') ||
message.includes('stream disconnected before completion')
)
);
}
function formatOpenCodeUsage(tokens: unknown): Usage | null {
if (!isRecord(tokens)) return null;
const usage: Usage = {};
@ -272,11 +282,7 @@ function handleCodexEvent(obj: unknown, onEvent: StreamEventHandler, state: Pars
if (obj.type === 'error') {
const message = extractErrorMessage(obj.message ?? obj.error, 'Codex error');
// Reconnecting events are recoverable — treat as status warning, not fatal
if (
typeof message === 'string' &&
message.includes('Reconnecting...') &&
message.includes('timeout waiting for child process to exit')
) {
if (isRecoverableCodexReconnect(message)) {
onEvent({ type: 'status', label: message });
return true;
}

View file

@ -35,7 +35,7 @@ interface ProjectPayload { project?: ProjectSummary; id?: string; name?: string;
interface ActiveContext { active?: boolean; projectId?: string; projectName?: string | null; fileName?: string | null; ageMs?: number | null }
type ResolvedProject = { id: string; name: string; source: 'uuid' | 'id' | 'exact' | 'slug' | 'substring' };
interface ProjectListCache { baseUrl: string; t: number; list: ProjectSummary[] }
interface McpArgs extends JsonObject { project?: unknown; entry?: unknown; include?: unknown; maxBytes?: unknown; path?: unknown; offset?: unknown; limit?: unknown; since?: unknown; query?: unknown; pattern?: unknown; max?: unknown; name?: unknown; content?: unknown; encoding?: unknown; artifactManifest?: unknown }
interface McpArgs extends JsonObject { project?: unknown; entry?: unknown; include?: unknown; maxBytes?: unknown; path?: unknown; offset?: unknown; limit?: unknown; since?: unknown; query?: unknown; pattern?: unknown; max?: unknown; name?: unknown; content?: unknown; encoding?: unknown; artifactManifest?: unknown; confirm?: unknown }
interface ProjectFileBundleEntry { name: string; mime: string; size: number | null; content: string | null; binary: boolean }
interface BundleInput { project: ProjectPayload | ProjectSummary; entry: string; files: ProjectFileBundleEntry[]; truncated: boolean; active: ActiveContext | null; resolved?: ResolvedProject | null }
interface ErrorWithCode { message?: string; code?: string; cause?: { code?: string } }
@ -236,6 +236,72 @@ const TOOL_DEFS = [
},
annotations: { ...WRITE_ANNOTATIONS, title: 'Create Open Design artifact' },
},
{
name: 'write_file',
description:
'Write (or overwrite) a project file. Unlike create_artifact this does not require an ArtifactManifest and tolerates existing targets, so it is the right tool for iterating on a file the agent (or the user) already created. Project optional; defaults to the active project.',
inputSchema: {
type: 'object',
properties: {
project: PROJECT_ARG,
path: {
type: 'string',
description: 'Output path relative to the project root, e.g. "deck.html" or "components/Hero.tsx".',
},
content: {
type: 'string',
description: 'File contents. Use encoding="base64" for binary payloads.',
},
encoding: {
type: 'string',
enum: ['utf8', 'base64'],
description: 'utf8 (default) | base64',
},
},
required: ['path', 'content'],
additionalProperties: false,
},
annotations: { ...WRITE_ANNOTATIONS, title: 'Write Open Design project file' },
},
{
name: 'delete_file',
description:
'Delete one file from a project. Supports nested paths (e.g. "codex-product/index.html"). Project optional; defaults to the active project.',
inputSchema: {
type: 'object',
properties: {
project: PROJECT_ARG,
path: {
type: 'string',
description: 'Project-relative path of the file to delete.',
},
},
required: ['path'],
additionalProperties: false,
},
annotations: { ...WRITE_ANNOTATIONS, destructiveHint: true, title: 'Delete Open Design project file' },
},
{
name: 'delete_project',
description:
'Permanently delete an Open Design project including its files and conversations. Requires both an explicit project id/name AND confirm:true — there is no active-project fallback because the operation is irreversible.',
inputSchema: {
type: 'object',
properties: {
project: {
type: 'string',
description: 'Project id (UUID) or name substring. Required — active-context fallback is intentionally disabled.',
},
confirm: {
type: 'boolean',
description: 'Must be literally true. Guards against an agent accidentally deleting a project while cleaning up.',
},
},
required: ['project', 'confirm'],
additionalProperties: false,
},
annotations: { ...WRITE_ANNOTATIONS, destructiveHint: true, title: 'Delete Open Design project' },
},
// Catalog (skills, design systems) is intentionally NOT exposed as
// MCP tools. Skills are recipes that Open Design itself uses to
// generate artifacts; an external coding agent consuming Open
@ -281,6 +347,12 @@ export async function runMcpStdio({ daemonUrl }: RunMcpOptions): Promise<void> {
' - create_artifact(name, content) to create one normal artifact',
' entry file in the active or specified project. It rejects',
' existing targets and can accept an artifactManifest sidecar.',
' - write_file(path, content) to overwrite or freshly create any',
' project file when an ArtifactManifest is not required.',
' Use this to iterate on a file create_artifact already wrote.',
' - delete_file(path) to remove one project file (nested paths ok).',
' - delete_project(project, confirm:true) for irreversible project',
' removal — requires explicit project + confirm:true.',
' - list_projects to discover what is available on this daemon.',
' - get_active_context() if you want the active project/file',
' explicitly without making any other tool call.',
@ -495,6 +567,12 @@ async function handleMcpToolCall(baseUrl: string, name: unknown, args: McpArgs)
}
case 'create_artifact':
return await createArtifact(baseUrl, args);
case 'write_file':
return await writeFile(baseUrl, args);
case 'delete_file':
return await deleteFile(baseUrl, args);
case 'delete_project':
return await deleteProject(baseUrl, args);
default:
return errorResult(`unknown tool: ${name}`);
}
@ -503,6 +581,89 @@ async function handleMcpToolCall(baseUrl: string, name: unknown, args: McpArgs)
}
}
async function writeFile(baseUrl: string, args: McpArgs) {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
// The daemon route requires its argv field to be called `name`; the
// MCP-facing surface uses `path` to match the rest of the file tools.
requireString(args.path, 'path');
requireString(args.content, 'content');
const encoding = args.encoding === 'base64' ? 'base64' : 'utf8';
// No `artifact: true` and no `overwrite: false`: the route then takes
// the default writeProjectFile path, which overwrites the target. This
// is the exact shape `od files write` uses (see apps/daemon/src/cli.ts).
const url = `${baseUrl}/api/projects/${encodeURIComponent(id)}/files`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: args.path, content: args.content, encoding }),
});
if (!resp.ok) {
return errorResult(await formatDaemonError(resp, url));
}
const json = (await resp.json()) as JsonObject;
return ok(withActiveEcho(json, active, resolved));
}
async function deleteFile(baseUrl: string, args: McpArgs) {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
requireString(args.path, 'path');
// /api/projects/:id/raw/* accepts nested paths; /api/projects/:id/files/:name
// does not. Mirror the create_artifact surface, which already lets agents
// address files like "codex-product/index.html".
const segments = args.path
.split('/')
.filter((s) => s.length > 0)
.map(encodeURIComponent);
const url = `${baseUrl}/api/projects/${encodeURIComponent(id)}/raw/${segments.join('/')}`;
const resp = await fetch(url, { method: 'DELETE' });
if (!resp.ok) {
return errorResult(await formatDaemonError(resp, url));
}
const json = (await resp.json()) as JsonObject;
return ok(withActiveEcho(json, active, resolved));
}
async function deleteProject(baseUrl: string, args: McpArgs) {
// Active-context fallback is intentionally disabled: the daemon's
// DELETE /api/projects/:id is irreversible (purges the row and the
// on-disk project directory), so we never want it to fire against the
// wrong project just because the user happened to have one open. The
// confirm flag is a second belt for agents that auto-clean.
if (typeof args.project !== 'string' || args.project.length === 0) {
return errorResult('project is required (no active-context fallback for delete_project).');
}
if (args.confirm !== true) {
return errorResult('confirm:true is required to delete a project (this cannot be undone).');
}
const { id, resolved } = await resolveProjectArg(baseUrl, args.project);
const url = `${baseUrl}/api/projects/${encodeURIComponent(id)}`;
const resp = await fetch(url, { method: 'DELETE' });
if (!resp.ok) {
return errorResult(await formatDaemonError(resp, url));
}
const json = (await resp.json()) as JsonObject;
// The tool accepts a name substring (see resolveProjectId), so the
// caller needs the resolvedProject echo to confirm which project was
// actually destroyed — same contract write_file/delete_file follow
// via withActiveEcho. active is always null here because the
// active-context fallback is intentionally disabled above.
return ok(withActiveEcho(json, null, resolved));
}
async function formatDaemonError(resp: Response, url: string): Promise<string> {
const body = await safeText(resp);
let detail = body || resp.statusText;
try {
const parsed = JSON.parse(body) as { error?: { message?: string; code?: string } };
if (parsed?.error?.message) {
detail = `${parsed.error.code ?? 'error'}: ${parsed.error.message}`;
}
} catch {
// body wasn't JSON; fall through with the raw text.
}
return `daemon ${resp.status} on ${url}: ${detail}`;
}
async function createArtifact(baseUrl: string, args: McpArgs) {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
requireString(args.name, 'name');

View file

@ -115,7 +115,8 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
res.json(await orbitService.start('manual'));
const locale = typeof req.body?.locale === 'string' ? req.body.locale : null;
res.json(await orbitService.start('manual', { locale }));
} catch (err: any) {
res
.status(500)

View file

@ -5,6 +5,7 @@ import path from 'node:path';
import type { OrbitRunSummary, OrbitStatusResponse } from '@open-design/contracts/api/orbit';
import type { OrbitConfigPrefs } from './app-config.js';
import { skillCwdAliasSegment } from './cwd-aliases.js';
export interface OrbitConnectorRunResult {
connectorId: string;
@ -66,6 +67,57 @@ export type OrbitRunHandler = (request: {
template: OrbitTemplateSelection | null;
}) => Promise<OrbitRunHandlerStart>;
type OrbitOutputLocale = 'en' | 'zh-CN' | 'zh-TW';
function normalizeOrbitOutputLocale(locale?: string | null): OrbitOutputLocale {
const normalized = locale?.trim().toLowerCase();
if (!normalized) return 'en';
const localeParts = normalized.split('-').filter(Boolean);
const hasTraditionalChineseScript = localeParts.includes('hant');
const hasTraditionalChineseRegion = localeParts.some((part) => part === 'tw' || part === 'hk' || part === 'mo');
if (normalized === 'zh-tw' || normalized === 'zh-hk' || normalized === 'zh-mo' || normalized === 'zh-hant' || hasTraditionalChineseScript || hasTraditionalChineseRegion) {
return 'zh-TW';
}
if (normalized.startsWith('zh')) return 'zh-CN';
return 'en';
}
function localizeOrbitTemplateExamplePrompt(
template: OrbitTemplateSelection | null,
locale: OrbitOutputLocale,
): OrbitTemplateSelection | null {
if (!template || locale === 'en') return template;
const localizedExamplePrompt = locale === 'zh-TW'
? {
'orbit-general': '生成今天的 Open Orbit 晨間簡報。我已連接約 10 個整合GitHub、Linear、Notion、Calendar、飛書、Sentry、Vercel、Slack、Gmail、Drive。請拉取昨天各來源的活動並將其渲染為編輯感 bento 儀表板。',
'orbit-github': '生成今天的 Open Orbit GitHub 簡報。GitHub 是我唯一已連接的整合——請拉取昨天的 PR、審查請求、Issue、CI 執行與合併記錄,並將其渲染為 GitHub Notifications + PR diff 風格頁面。',
}[template.id]
: {
'orbit-general': '生成今天的 Open Orbit 早间简报。我已连接约 10 个集成GitHub、Linear、Notion、Calendar、飞书、Sentry、Vercel、Slack、Gmail、Drive。请拉取昨天各来源的活动并将其渲染为编辑感 bento 仪表板。',
'orbit-github': '生成今天的 Open Orbit GitHub 简报。GitHub 是我唯一已连接的集成——请拉取昨天的 PR、审查请求、Issue、CI 运行与合并记录,并将其渲染为 GitHub Notifications + PR diff 风格页面。',
}[template.id];
if (!localizedExamplePrompt) return template;
return { ...template, examplePrompt: localizedExamplePrompt };
}
function buildOrbitOutputLanguageDirective(locale: OrbitOutputLocale): string[] {
if (locale === 'zh-TW') {
return [
'App language: Traditional Chinese (zh-TW).',
'Write all user-facing artifact copy, labels, headings, summaries, timestamps, and recommendations in Traditional Chinese unless a proper noun or source identifier must remain unchanged.',
'If the selected template guidance or examples are written in another language, treat them as structural and visual guidance only. The final Orbit artifact itself must stay in Traditional Chinese.',
];
}
if (locale === 'zh-CN') {
return [
'App language: Simplified Chinese (zh-CN).',
'Write all user-facing artifact copy, labels, headings, summaries, timestamps, and recommendations in Simplified Chinese unless a proper noun or source identifier must remain unchanged.',
'If the selected template guidance or examples are written in another language, treat them as structural and visual guidance only. The final Orbit artifact itself must stay in Simplified Chinese.',
];
}
return [];
}
export function formatLocalProjectTimestamp(iso: string): string {
const d = new Date(iso);
const yyyy = d.getFullYear();
@ -275,21 +327,50 @@ function renderMarkdown(summary: Omit<OrbitActivitySummary, 'markdown'>): string
return lines.join('\n').trimEnd();
}
export function buildOrbitPrompt(now = new Date(), template?: OrbitTemplateSelection | null): string {
export function buildOrbitPrompt(
now = new Date(),
template?: OrbitTemplateSelection | null,
locale?: string | null,
): string {
const outputLocale = normalizeOrbitOutputLocale(locale);
const end = formatLocalOrbitPromptTimestamp(now);
const start = formatLocalOrbitPromptTimestamp(new Date(now.getTime() - 24 * 60 * 60_000));
const lines = [
const lines = outputLocale === 'zh-TW'
? [
'請將今天的 Orbit 每日摘要製作成 Live Artifact。',
'',
`使用我從 ${start}${end} 的已連接工作資料。`,
]
: outputLocale === 'zh-CN'
? [
'请将今天的 Orbit 每日摘要制作成 Live Artifact。',
'',
`使用我从 ${start}${end} 的已连接工作数据。`,
]
: [
'Create today\'s Orbit daily digest as a Live Artifact.',
'',
`Use my connected work data from ${start} through ${end}.`,
];
if (template) {
lines.push('', `Use the selected Orbit template: ${template.name}.`);
lines.push(
'',
outputLocale === 'zh-TW'
? `使用已選取的 Orbit 範本:${template.name}`
: outputLocale === 'zh-CN'
? `使用已选中的 Orbit 模板:${template.name}`
: `Use the selected Orbit template: ${template.name}.`,
);
}
return lines.join('\n');
}
export function buildOrbitSystemPrompt(now = new Date(), template?: OrbitTemplateSelection | null): string {
export function buildOrbitSystemPrompt(
now = new Date(),
template?: OrbitTemplateSelection | null,
locale?: string | null,
): string {
const outputLocale = normalizeOrbitOutputLocale(locale);
const end = now.toISOString();
const start = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
const lines = [
@ -297,6 +378,8 @@ export function buildOrbitSystemPrompt(now = new Date(), template?: OrbitTemplat
'',
`Time window: ${start} through ${end}.`,
'',
...buildOrbitOutputLanguageDirective(outputLocale),
...(outputLocale === 'en' ? [] : ['']),
'Work autonomously. Do not ask follow-up questions, do not emit a question form, and do not wait for user input. Use sensible defaults and proceed.',
'Optimize for fast completion: sample at most 3 relevant data sources. DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED: first run `tools connectors list --use-case personal_daily_digest --format compact` with a 120s timeout, and if that curated list command times out or returns no output, retry it once with another 120s timeout. If the curated command is unsupported, rejected, or succeeds but returns no usable tools, immediately fall back to the unfiltered read-only list via `tools connectors list --format compact`; do not stop just because `--use-case` is unsupported. If connector discovery still fails, or if both the curated and fallback lists yield zero usable connected read-only data tools, do not create an empty-state artifact; send one concise final message explaining that data loading failed and stop. For individual source calls after discovery succeeds, if a source fails because of auth, permissions, timeout, malformed output, empty output, oversized output, or any other data-loading problem, do not get stuck trying to fix it; drop that source and continue with the others. After the artifact is registered successfully, send one concise final message with the artifact id and stop.',
'',
@ -336,9 +419,9 @@ export function buildOrbitSystemPrompt(now = new Date(), template?: OrbitTemplat
'Selected example template:',
`- Skill id: ${template.id}`,
`- Skill name: ${template.name}`,
`- Staged root: .od-skills/${path.basename(template.dir)}/`,
`- Staged root: .od-skills/${skillCwdAliasSegment(template.dir)}/`,
'',
`Before writing the artifact, read ".od-skills/${path.basename(template.dir)}/SKILL.md" and, if present, ".od-skills/${path.basename(template.dir)}/example.html". Follow that staged template's structure, layout, tokens, domain rules, and visual language as the source of truth. The staged template is for visual/domain guidance; still use the live-artifact workflow to register the final artifact.`,
`Before writing the artifact, read ".od-skills/${skillCwdAliasSegment(template.dir)}/SKILL.md" and, if present, ".od-skills/${skillCwdAliasSegment(template.dir)}/example.html". Follow that staged template's structure, layout, tokens, domain rules, and visual language as the source of truth. The staged template is for visual/domain guidance; still use the live-artifact workflow to register the final artifact.`,
'',
'Selected template example prompt:',
'',
@ -402,20 +485,26 @@ export class OrbitService {
};
}
async start(trigger: 'manual' | 'scheduled'): Promise<{ projectId: string; agentRunId: string }> {
async start(
trigger: 'manual' | 'scheduled',
options?: { locale?: string | null },
): Promise<{ projectId: string; agentRunId: string }> {
if (this.inflight && this.inflightProjectId && this.inflightAgentRunId) {
return { projectId: this.inflightProjectId, agentRunId: this.inflightAgentRunId };
}
if (this.starting) return this.starting;
if (!this.runHandler) throw new Error('Orbit agent runner is not configured');
this.starting = this.startRun(trigger).finally(() => {
this.starting = this.startRun(trigger, options).finally(() => {
this.starting = null;
});
return this.starting;
}
private async startRun(trigger: 'manual' | 'scheduled'): Promise<{ projectId: string; agentRunId: string }> {
private async startRun(
trigger: 'manual' | 'scheduled',
options?: { locale?: string | null },
): Promise<{ projectId: string; agentRunId: string }> {
if (!this.runHandler) throw new Error('Orbit agent runner is not configured');
const startedAt = new Date().toISOString();
@ -424,16 +513,20 @@ export class OrbitService {
const template = configuredTemplateSkillId && this.templateResolver
? await this.templateResolver(configuredTemplateSkillId).catch(() => null)
: null;
const localizedTemplate = localizeOrbitTemplateExamplePrompt(
template,
normalizeOrbitOutputLocale(options?.locale),
);
const now = new Date(startedAt);
const prompt = buildOrbitPrompt(now, template);
const systemPrompt = buildOrbitSystemPrompt(now, template);
const prompt = buildOrbitPrompt(now, localizedTemplate, options?.locale);
const systemPrompt = buildOrbitSystemPrompt(now, localizedTemplate, options?.locale);
const handlerStart = await this.runHandler({
runId,
trigger,
startedAt,
prompt,
systemPrompt,
template,
template: localizedTemplate,
});
this.inflightProjectId = handlerStart.projectId;

View file

@ -8,6 +8,7 @@ export interface RequestWithOriginHeaders {
headers?: {
host?: unknown;
origin?: unknown;
'sec-fetch-site'?: unknown;
};
}
@ -166,7 +167,23 @@ export function isLocalSameOrigin(
);
const localHostAllowed = isAllowedBrowserHost(host, ports, bindHost, ipOnlyExtraOrigins);
if (origin == null || origin === '') return localHostAllowed;
if (origin == null || origin === '') {
if (localHostAllowed) return true;
// Browsers (Firefox, Chrome) omit Origin on same-origin GET subresource
// requests per the Fetch spec, which made hostname entries in
// OD_ALLOWED_ORIGINS unreachable for legitimate same-origin GETs
// through a reverse proxy. Sec-Fetch-Site is set by the user agent and
// cannot be modified by JavaScript, so a value of "same-origin"
// attests that the request originated from the same origin as the
// target — a cross-site `<img>`/`<script>` exploit would carry
// "cross-site" instead. Only consult the broader allow-list once that
// signal is present.
const fetchSite = headerValue(req.headers?.['sec-fetch-site']);
if (fetchSite === 'same-origin') {
return isAllowedBrowserHost(host, ports, bindHost, extraAllowedOrigins);
}
return false;
}
// Reverse-proxy deployments (e.g. Nginx in front of the daemon) terminate
// the browser connection at the proxy and open a fresh upstream
// connection to the daemon. The Host header the daemon sees is the

View file

@ -40,6 +40,7 @@ Active design system exception: if a later section in this same system prompt is
## RULE 1 turn 1 must emit a \`<question-form id="discovery">\` (not tools, not thinking)
When the user opens a new project or sends a fresh design brief, your **very first output** is one short prose line + a \`<question-form>\` block. Nothing else. No file reads. No Bash. No TodoWrite. No extended thinking. The form is your time-to-first-byte.
Match the user's chat language. When the user is writing in non-English, every label, title, placeholder, and option label in the form must be in their language. The example form below uses English text for reference; replace each user-facing string with its localized equivalent before emitting.
Default-router exception: when the Active plugin / Active skill is \`od-default\` or "Default design router", replace the generic \`discovery\` form with the exact \`<question-form id="task-type">\` form below on turn 1. Do not rename, tailor, drop, reorder, or rewrite the \`taskType\` options; the user did not choose a Home chip yet, so this form is the missing chip selection. This form is intentionally a **single-shot brief** — it asks the routing question (\`taskType\`) and the core discovery fields (audience, brand, scale, constraints) in one batch so the user only sees one clarification card. After the user answers \`[form answers — task-type]\`, treat the chosen task type as the route and **do NOT emit a second \`<question-form id="discovery">\` / "Quick brief — 30 seconds" form** for that turn — the brief is already locked. Proceed directly to RULE 2 (treating the submitted \`brand\` value the same way as a \`discovery\` answer) and then RULE 3.
@ -129,7 +130,7 @@ Form authoring rules:
- Body must be valid JSON. No comments. No trailing commas.
- \`type\` is one of: \`radio\`, \`checkbox\`, \`select\`, \`text\`, \`textarea\`.
- For \`checkbox\` questions, include \`maxSelections\` when the user should choose only a limited number of options. Do not encode limits only in the label text.
- For object-style options, \`label\` is display copy and may follow the user's language; \`value\` is the stable internal key. Keep \`value\` exact and unlocalized because later branch rules depend on it.
- Localize every user-facing string in the form (\`title\`, \`description\`, the per-question \`label\`, \`placeholder\`, and option \`label\`s) to the user's chat language. \`id\`, \`type\`, option \`value\`, and the stable branch values (\`pick_direction\`, \`brand_spec\`, \`reference_match\`) MUST stay in English because later branch rules match against them.
- If you keep the \`brand\` question, its \`id\` must stay \`"brand"\`. Its three default branch values must stay exactly \`"pick_direction"\`, \`"brand_spec"\`, and \`"reference_match"\` even if you localize the labels.
- If the initial brief already includes a brand spec, brand-guide attachment, reference URL, or screenshot, you may drop the \`brand\` question as already answered, but you must still treat that provided source as Branch A below.
- Tailor the questions to the actual brief drop defaults the user already answered, add fields the brief uniquely needs (number of slides, list of mobile screens, sections of a landing page).
@ -189,7 +190,7 @@ Emit \`<artifact>\` **only when this turn wrote a new canonical HTML file**. If
## RULE 3 TodoWrite the plan, then live updates
Once the design-system / inferred direction / brand-spec is locked, your **first tool call** is TodoWrite with a plan of 510 short imperative items in the order you'll do them. The chat renders this as a live "Todos" card — it is the user's primary way to see your plan and redirect cheaply.
Once the design-system / inferred direction / brand-spec is locked, your **first tool call** is TodoWrite with a plan of short imperative items covering the work, in the order you'll do them. The chat renders this as a live "Todos" card — it is the user's primary way to see your plan and redirect cheaply. (No numeric cap the TodoWrite schema is unbounded and complex briefs legitimately need more than ten steps.)
The standard plan template (adapt the middle steps to the brief):

View file

@ -51,6 +51,38 @@ const PROMPT_SAFE_HTTP_STATUS_LABELS: Record<string, string> = {
'504': 'Gateway Timeout',
};
function renderUiLocalePrompt(locale: string | undefined): string {
const normalized = locale?.trim();
if (!normalized || normalized.toLowerCase() === 'en') return '';
const languageName = normalized === 'zh-CN'
? 'Simplified Chinese'
: normalized === 'zh-TW'
? 'Traditional Chinese'
: normalized;
const lines = [
'# UI locale override',
'',
`The Open Design UI locale for this run is \`${normalized}\` (${languageName}). All user-visible chat prose and generated UI controls must follow this locale, especially \`<question-form>\` titles, descriptions, labels, placeholders, helper text, and option labels. Keep machine-readable ids and object option \`value\` fields exact and unlocalized.`,
'Exception: for the default task-type form, keep the `taskType` option labels as the canonical routing choices: `Prototype`, `Live artifact`, `Slide deck`, `Image`, `Video`, `HyperFrames`, `Audio`, `Other`. Do not translate, reorder, or rewrite those option labels.',
];
if (normalized === 'zh-CN') {
lines.push(
'',
'For the default quick brief in Simplified Chinese, use copy like:',
'- title: `快速简报 — 30 秒`',
'- description: `开始生成前我会先确认这些信息。不适用的可以跳过,我会补上默认值。`',
'- output label/options: `我们要做什么?` / `幻灯片 / 路演稿`, `单页网页原型 / 落地页`, `多屏应用原型`, `数据看板 / 工具界面`, `编辑式 / 营销页面`, `其他 — 我来描述`',
'- platform label/options: `目标平台` / `响应式网页`, `桌面网页`, `iOS 应用`, `Android 应用`, `平板应用`, `桌面应用`, `固定画布 (1920×1080)`',
'- audience label/placeholder: `目标用户` / `例如:早期投资人、开发者工具采购者、内部高管评审`',
'- tone label/options: `视觉调性` / `编辑 / 杂志感`, `现代极简`, `活泼 / 插画感`, `科技 / 工具型`, `奢华 / 精致`, `粗野 / 实验性`, `人性化 / 亲切`',
'- brand label/options: `品牌背景` / `帮我选一个方向`, `我有品牌规范 — 稍后分享`, `参考网站 / 截图 — 稍后附上`',
'- scale label/placeholder: `大概需要多少内容?` / `例如8 页幻灯片、1 个落地页 + 3 个子页面、4 个移动端界面`',
'- constraints label/placeholder: `还有什么需要知道的吗?` / `真实文案、必须使用的字体、需要避免的内容、截止时间…`',
);
}
return lines.join('\n');
}
function normalizePromptText(value: string): string {
return value
.replace(/[\r\n]+/g, ' ')
@ -152,6 +184,37 @@ type AudioVoiceOption = {
labels?: Record<string, string> | null;
};
type ExclusiveSurfaceMode = 'deck' | 'image' | 'video' | 'audio';
const EXCLUSIVE_SURFACE_MODES = new Set<ExclusiveSurfaceMode>(['deck', 'image', 'video', 'audio']);
export function resolveExclusiveSurface(args: {
metadata?: ProjectMetadata | undefined;
skillMode?: ComposeInput['skillMode'] | undefined;
skillModes?: ComposeInput['skillModes'] | undefined;
}): ExclusiveSurfaceMode | null {
const activeSkillModes = new Set(
Array.isArray(args.skillModes)
? args.skillModes.filter(Boolean)
: args.skillMode
? [args.skillMode]
: [],
);
const metadataSurface = EXCLUSIVE_SURFACE_MODES.has(args.metadata?.kind as ExclusiveSurfaceMode)
? args.metadata?.kind as ExclusiveSurfaceMode
: null;
const primarySkillSurface = EXCLUSIVE_SURFACE_MODES.has(args.skillMode as ExclusiveSurfaceMode)
? args.skillMode as ExclusiveSurfaceMode
: null;
const composedSurfaceModes = Array.from(activeSkillModes).filter((mode): mode is ExclusiveSurfaceMode =>
EXCLUSIVE_SURFACE_MODES.has(mode as ExclusiveSurfaceMode),
);
return metadataSurface
?? primarySkillSurface
?? (composedSurfaceModes.length === 1 ? composedSurfaceModes[0] ?? null : null);
}
export const BASE_SYSTEM_PROMPT = OFFICIAL_DESIGNER_PROMPT;
export const SKIP_DISCOVERY_BRIEF_OVERRIDE = `# Automated project mode — skip discovery form
@ -204,6 +267,7 @@ export interface ComposeInput {
| 'video'
| 'audio'
| undefined;
skillModes?: Array<'prototype' | 'deck' | 'template' | 'design-system' | 'image' | 'video' | 'audio'> | undefined;
designSystemBody?: string | undefined;
designSystemTitle?: string | undefined;
// Compiled (machine-readable) form of the active brand's design system,
@ -304,6 +368,9 @@ export interface ComposeInput {
// Free-form instructions the user set on this specific project.
// Injected after user-level instructions and before the design system.
projectInstructions?: string | undefined;
// UI locale selected by the client. User-visible generated form copy
// must follow this locale even when the user's initial prompt is brief.
locale?: string | undefined;
}
export function composeSystemPrompt({
@ -312,6 +379,7 @@ export function composeSystemPrompt({
skillBody,
skillName,
skillMode,
skillModes,
designSystemBody,
designSystemTitle,
designSystemUsageMd,
@ -334,6 +402,7 @@ export function composeSystemPrompt({
pluginBlock,
activeStageBlocks,
streamFormat,
locale,
userInstructions,
projectInstructions,
}: ComposeInput): string {
@ -343,6 +412,14 @@ export function composeSystemPrompt({
// wording later in the official base prompt.
const parts: string[] = [];
const activeDesignSystemBody = designSystemBody?.trim();
const activeSkillModes = new Set(
Array.isArray(skillModes)
? skillModes.filter(Boolean)
: skillMode
? [skillMode]
: [],
);
const resolvedExclusiveSurface = resolveExclusiveSurface({ metadata, skillMode, skillModes });
// API/BYOK mode (streamFormat === 'plain'): mirrors the same fix from
// `@open-design/contracts`'s composer. The daemon hits this path for
@ -362,6 +439,12 @@ export function composeSystemPrompt({
parts.push('\n\n---\n\n');
}
const localePrompt = renderUiLocalePrompt(locale);
if (localePrompt) {
parts.push(localePrompt);
parts.push('\n\n---\n\n');
}
parts.push(
DISCOVERY_AND_PHILOSOPHY,
'\n\n---\n\n# Identity and workflow charter (background)\n\n',
@ -493,8 +576,8 @@ export function composeSystemPrompt({
// skeleton would conflict. The skill-seed path takes over via
// `derivePreflight` above, so we only fire the generic skeleton when no
// skill seed is on offer.
const isDeckProject = skillMode === 'deck' || metadata?.kind === 'deck';
const isFreeformProject = !skillMode && (!metadata || metadata.kind === 'other');
const isDeckProject = resolvedExclusiveSurface === 'deck';
const isFreeformProject = activeSkillModes.size === 0 && (!metadata || metadata.kind === 'other');
const hasSkillSeed =
!!skillBody && /assets\/template\.html/.test(skillBody);
if (isDeckProject && !hasSkillSeed) {
@ -515,12 +598,9 @@ export function composeSystemPrompt({
}
const isMediaSurface =
skillMode === 'image' ||
skillMode === 'video' ||
skillMode === 'audio' ||
metadata?.kind === 'image' ||
metadata?.kind === 'video' ||
metadata?.kind === 'audio';
resolvedExclusiveSurface === 'image'
|| resolvedExclusiveSurface === 'video'
|| resolvedExclusiveSurface === 'audio';
if (isMediaSurface) {
parts.push(MEDIA_GENERATION_CONTRACT);
}

View file

@ -0,0 +1,378 @@
// Post-generation static QA pass for CTA (call-to-action) hierarchy.
//
// Background: Open Design's generated HTML/CSS prototypes are sometimes
// "functionally correct but feel unfinished" — a primary commerce action
// renders as a neutral button, two equally-styled CTAs compete for the
// same conversion slot, or a "Learn more" link is styled like the buy
// button. See nexu-io/open-design#2251 for the motivating bug report.
//
// `analyseCtaHierarchy` parses the rendered HTML and returns a small set
// of conservative findings. It is intentionally precision-biased: when
// the signal is weak, the function says nothing. The output is the
// first-useful-version of the QA pass; HTTP/CLI exposure and auto-repair
// are explicit follow-ups.
import { type CheerioAPI, load } from 'cheerio';
// Cheerio 1.x doesn't re-export the underlying `AnyNode`/`Element` types
// from `domhandler`. Importing `domhandler` directly would punch through
// daemon's declared dependency boundary, so we derive a generic "node
// collection" type from the API surface we actually use.
type CheerioCollection = ReturnType<CheerioAPI>;
type CheerioNode = CheerioCollection extends ArrayLike<infer N> ? N : never;
export interface CtaHierarchyIssue {
/** Category of the finding; the UI may surface different copy per kind. */
kind: 'multiple-primary' | 'ambiguous-weight' | 'misleading-prominence';
/** Short CSS-like selector for the offending element, e.g. `a.btn.btn-primary`. */
selector: string;
/** One-sentence English description of the issue, suitable for a warning UI. */
message: string;
}
export interface CtaHierarchyReport {
issues: CtaHierarchyIssue[];
primaryCount: number;
secondaryCount: number;
}
/**
* Parse the rendered HTML and return CTA-hierarchy findings.
*
* The function is deterministic and side-effect free. Returns an empty
* report (no issues, zero counts) when the document has no CTA-shaped
* elements. The set of rules is intentionally narrow see the issue list
* in this file for the categories we currently surface.
*/
export function analyseCtaHierarchy(html: string): CtaHierarchyReport {
const $ = load(html);
const candidates = collectCtaCandidates($);
if (candidates.length === 0) {
return { issues: [], primaryCount: 0, secondaryCount: 0 };
}
const primaryCount = candidates.filter((cta) => cta.weight === 'primary').length;
const secondaryCount = candidates.length - primaryCount;
const issues: CtaHierarchyIssue[] = [
...detectMultiplePrimary(candidates),
...detectAmbiguousWeight(candidates),
...detectMisleadingProminence(candidates),
];
return { issues, primaryCount, secondaryCount };
}
// ---------- internals --------------------------------------------------------
type Weight = 'primary' | 'secondary';
interface CtaCandidate {
el: CheerioCollection;
text: string;
classes: string[];
inlineStyle: string;
weight: Weight;
selector: string;
/** Section/container key used to group multiple-primary findings. */
containerKey: string;
}
// Conversion-oriented copy that strongly suggests the element is the page's
// commercial action. Mix of English and Simplified Chinese, matched
// case-insensitively as substrings on the element's text content.
const CTA_KEYWORDS = [
// English
'get started',
'sign up',
'sign in',
'log in',
'buy',
'shop now',
'subscribe',
'subscribe now',
'try it free',
'start free trial',
'free trial',
'add to cart',
'order now',
'checkout',
'continue',
'submit',
// Secondary English (still CTA-shaped — flagged separately below).
'learn more',
'read more',
'more info',
'see more',
// Chinese
'开始',
'立即',
'查看',
'购买',
'选购',
'下单',
'提交',
'加入购物车',
'加入询价车',
'免费试用',
'了解更多',
'更多',
'详情',
];
// Copy that signals a SECONDARY CTA. If the visual weight on these elements
// reads as primary, that's the misleading-prominence case.
const SECONDARY_KEYWORDS = [
'learn more',
'read more',
'more info',
'see more',
'了解更多',
'更多',
'详情',
];
function collectCtaCandidates($: CheerioAPI): CtaCandidate[] {
const candidates: CtaCandidate[] = [];
// 1. Every <button>, every <a>, and anything with role="button" is a
// structural candidate. We then filter on copy + class signals.
const selector = 'button, a, [role="button"]';
$(selector).each((_, node) => {
const el = $(node);
const text = normaliseText(el.text());
const classes = parseClasses(el.attr('class'));
const role = el.attr('role') ?? '';
const tag = (node as { tagName?: string; name?: string }).tagName ?? (node as { name?: string }).name ?? '';
// A button-shaped element is a CTA candidate when EITHER:
// (a) its class list contains a btn/button/cta marker, OR
// (b) its tag is <button> AND the copy matches a CTA keyword, OR
// (c) it carries role="button".
// Plain anchors without any of these signals are skipped — there are
// usually many of them in nav menus and they'd produce noise.
const hasButtonClass = classes.some((c) => /(^|-)btn$|(^|-)button$|(^|-)cta$|^btn(-|$)|^button(-|$)|^cta(-|$)/i.test(c));
const isButtonTag = tag.toLowerCase() === 'button';
const isRoleButton = role.toLowerCase() === 'button';
const hasCtaCopy = matchesAnyKeyword(text, CTA_KEYWORDS);
if (!hasButtonClass && !isRoleButton && !(isButtonTag && hasCtaCopy)) {
return;
}
// Even after the structural filter, drop elements whose copy is not
// CTA-shaped at all (e.g. icon toggles like "+" / ""). Anchors with a
// .btn class but no actionable copy frequently appear as inert chips.
if (!hasCtaCopy) {
return;
}
const inlineStyle = normaliseStyle(el.attr('style'));
const weight = classifyWeight(classes, inlineStyle);
const containerKey = computeContainerKey($, el);
candidates.push({
el,
text,
classes,
inlineStyle,
weight,
selector: buildSelector(tag, classes, role),
containerKey,
});
});
return candidates;
}
function classifyWeight(classes: string[], inlineStyle: string): Weight {
// Class-name signals are the strongest tell — design systems almost
// always tag the primary variant with one of these tokens.
const PRIMARY_CLASS_TOKENS = /(^|[-_])(primary|solid|filled|accent|cta)([-_]|$)/i;
if (classes.some((c) => PRIMARY_CLASS_TOKENS.test(c))) {
return 'primary';
}
// Otherwise infer from inline style: a non-transparent background colour
// is the dominant visual signal users read as "this is the main button".
// We don't try to evaluate computed CSS — that would require a full
// rendering layer; inline style is enough for the precision-biased pass.
if (hasNonTransparentBackground(inlineStyle)) {
return 'primary';
}
return 'secondary';
}
function hasNonTransparentBackground(inlineStyle: string): boolean {
const bg = extractStyleValue(inlineStyle, 'background-color') ?? extractStyleValue(inlineStyle, 'background');
if (!bg) return false;
const value = bg.toLowerCase().trim();
if (!value) return false;
if (value === 'transparent' || value === 'none' || value === 'inherit' || value === 'initial') return false;
if (/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(value)) return false;
return true;
}
function extractStyleValue(inlineStyle: string, property: string): string | null {
if (!inlineStyle) return null;
// Tolerant declaration parser — we don't need full CSS fidelity here.
const declarations = inlineStyle.split(';');
for (const decl of declarations) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const name = decl.slice(0, colon).trim().toLowerCase();
if (name === property.toLowerCase()) {
return decl.slice(colon + 1).trim();
}
}
return null;
}
function computeContainerKey($: CheerioAPI, el: CheerioCollection): string {
// Group CTAs by their nearest landmark/section ancestor. Falls back to
// the direct parent so that a flat document (no <section>) still gives
// us a meaningful grouping for the multiple-primary rule.
const landmarks = ['section', 'header', 'footer', 'nav', 'main', 'aside', 'article'];
for (const tag of landmarks) {
const ancestor = el.closest(tag);
if (ancestor.length > 0) {
const node = ancestor[0];
if (node) {
// The DOM identity of the ancestor is stable within one parse, so
// we can use a positional index to differentiate two <section>s.
const all = $(tag).toArray();
const index = all.indexOf(node);
return `${tag}:${index}`;
}
}
}
// Keyed by parent-node identity, not just tag name: two sibling <div>s
// each holding one .btn-primary CTA must NOT collapse into the same
// bucket, otherwise detectMultiplePrimary() reports a shared-section
// conflict on a flat layout where each card has only one CTA.
const parent = el.parent();
if (parent.length > 0) {
const parentNode = parent[0];
if (parentNode) {
const parentTag =
(parentNode as { tagName?: string }).tagName ?? (parentNode as { name?: string }).name ?? 'root';
const all = $(parentTag).toArray();
const index = all.indexOf(parentNode);
return `parent:${parentTag}:${index}`;
}
}
return 'parent:root';
}
function detectMultiplePrimary(candidates: CtaCandidate[]): CtaHierarchyIssue[] {
const byContainer = new Map<string, CtaCandidate[]>();
for (const cta of candidates) {
if (cta.weight !== 'primary') continue;
const bucket = byContainer.get(cta.containerKey) ?? [];
bucket.push(cta);
byContainer.set(cta.containerKey, bucket);
}
const issues: CtaHierarchyIssue[] = [];
for (const bucket of byContainer.values()) {
if (bucket.length < 2) continue;
// Report the SECOND+ primary CTA in each container as the offender:
// the first one is the legitimate primary, the rest dilute it.
for (let i = 1; i < bucket.length; i += 1) {
const cta = bucket[i];
if (!cta) continue;
issues.push({
kind: 'multiple-primary',
selector: cta.selector,
message: `Multiple primary CTAs share the same section; "${truncate(cta.text)}" competes with the section's main action.`,
});
}
}
return issues;
}
function detectAmbiguousWeight(candidates: CtaCandidate[]): CtaHierarchyIssue[] {
if (candidates.length < 2) return [];
// Bucket by containerKey first: comparing signatures across the
// entire document yields false positives when two unrelated sections
// happen to share the same `.btn` styling but each holds only one
// CTA. The rule is "every CTA in a container shares the same class +
// inline style", so the container boundary must hold.
const byContainer = new Map<string, CtaCandidate[]>();
for (const cta of candidates) {
const bucket = byContainer.get(cta.containerKey) ?? [];
bucket.push(cta);
byContainer.set(cta.containerKey, bucket);
}
const issues: CtaHierarchyIssue[] = [];
for (const bucket of byContainer.values()) {
if (bucket.length < 2) continue;
const first = bucket[0];
if (!first) continue;
const reference = signature(first);
if (!bucket.every((cta) => signature(cta) === reference)) continue;
// Report the second one (the first is the natural anchor; subsequent
// identical CTAs are the ones a reviewer would diff against).
const cta = bucket[1];
if (!cta) continue;
issues.push({
kind: 'ambiguous-weight',
selector: cta.selector,
message: `All CTAs share identical class and inline style; the visual hierarchy is ambiguous.`,
});
}
return issues;
}
function detectMisleadingProminence(candidates: CtaCandidate[]): CtaHierarchyIssue[] {
const issues: CtaHierarchyIssue[] = [];
for (const cta of candidates) {
if (cta.weight !== 'primary') continue;
if (!matchesAnyKeyword(cta.text, SECONDARY_KEYWORDS)) continue;
issues.push({
kind: 'misleading-prominence',
selector: cta.selector,
message: `"${truncate(cta.text)}" reads as a secondary action but is styled with primary-weight visuals.`,
});
}
return issues;
}
function signature(cta: CtaCandidate): string {
const classes = [...cta.classes].sort().join('.');
return `${classes}|${cta.inlineStyle}`;
}
function buildSelector(tag: string, classes: string[], role: string): string {
const tagPart = tag.toLowerCase() || 'element';
const classPart = classes.length > 0 ? `.${classes.join('.')}` : '';
const rolePart = role && tagPart !== 'button' ? `[role="${role.toLowerCase()}"]` : '';
return `${tagPart}${classPart}${rolePart}`;
}
function parseClasses(raw: string | undefined): string[] {
if (!raw) return [];
return raw.split(/\s+/).filter(Boolean);
}
function normaliseText(raw: string): string {
return raw.replace(/\s+/g, ' ').trim();
}
function normaliseStyle(raw: string | undefined): string {
if (!raw) return '';
return raw.replace(/\s+/g, ' ').trim();
}
function matchesAnyKeyword(text: string, keywords: readonly string[]): boolean {
if (!text) return false;
const lower = text.toLowerCase();
return keywords.some((kw) => lower.includes(kw.toLowerCase()));
}
function truncate(text: string, max = 40): string {
if (text.length <= max) return text;
return `${text.slice(0, max - 1)}`;
}

View file

@ -133,12 +133,21 @@ export function createChatRunService({
const stream = (run, req, res) => {
const sse = createSseResponse(res);
const lastEventId = Number(req.get('Last-Event-ID') || req.query.after || 0);
let sent = 0;
for (const record of run.events) {
if (!Number.isFinite(lastEventId) || record.id > lastEventId) {
sse.send(record.event, record.data, record.id);
sent++;
}
}
if (TERMINAL_RUN_STATUSES.has(run.status)) {
// Guarantee a reattaching client sees a terminal signal even if its
// cursor is at or past the final event id — otherwise the SSE
// stream ends silently and the client falls back to status-only fetch.
if (sent === 0 && run.events.length > 0) {
const last = run.events[run.events.length - 1];
sse.send(last.event, last.data, last.id);
}
sse.end();
return;
}

View file

@ -220,11 +220,29 @@ function stripFns(
return rest;
}
async function safeProbe(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
): Promise<DetectedAgent> {
try {
return await probe(def, configuredEnv);
} catch {
// Fault isolation (issue #2297): one adapter's probe blowing up
// — e.g. a synchronous filesystem throw during PATH walking on a
// packaged Windows daemon, or an async rejection from one of the
// post-launch probes — must not collapse the whole agent picker.
// Without this guard the bare `Promise.all` rejected and the
// `/api/agents` catch arm returned `[]`, so the UI silently lost
// every CLI option and fell back to BYOK / Cloud only.
return unavailableAgent(def);
}
}
export async function detectAgents(
configuredEnvByAgent: Record<string, Record<string, string>> = {},
) {
const results = await Promise.all(
AGENT_DEFS.map((def) => probe(def, configuredEnvByAgent?.[def.id] ?? {})),
AGENT_DEFS.map((def) => safeProbe(def, configuredEnvByAgent?.[def.id] ?? {})),
);
// Refresh the validation cache from whatever we just surfaced to the UI
// so /api/chat can accept any model the user could have just picked,

View file

@ -15,6 +15,14 @@ type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
// In that case claude login is meaningless, so preserve the API key so
// the child can authenticate against the custom base URL.
//
// The codex adapter has the symmetric problem: a stale BYOK
// OPENAI_API_KEY / CODEX_API_KEY left behind in app-config.json silently
// outranks Codex CLI's own `~/.codex/auth.json` (codex login) and trips
// 401 invalid_api_key whenever execution mode is switched back to
// Local CLI. Strip both keys unless the user has also configured a
// custom OPENAI_BASE_URL — i.e. they are intentionally routing Codex
// CLI through a third-party OpenAI-compatible gateway. See issue #2420.
//
// Windows env-var names are case-insensitive at the kernel level
// (`GetEnvironmentVariable`), but spreading `process.env` into a plain
// object loses Node's case-insensitive accessor — `Anthropic_Api_Key`
@ -29,16 +37,40 @@ export function spawnEnvForAgent(
...baseEnv,
...expandConfiguredEnv(configuredEnv),
};
if (agentId !== 'claude') return env;
const hasCustomBaseUrl = Object.keys(env).some(
(k) =>
k.toUpperCase() === 'ANTHROPIC_BASE_URL' &&
typeof env[k] === 'string' &&
env[k].trim() !== '',
);
if (hasCustomBaseUrl) return env;
for (const key of Object.keys(env)) {
if (key.toUpperCase() === 'ANTHROPIC_API_KEY') delete env[key];
if (agentId === 'claude') {
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
return env;
}
if (agentId === 'codex') {
stripUnlessCustomBaseUrl(env, 'OPENAI_BASE_URL', [
'OPENAI_API_KEY',
'CODEX_API_KEY',
]);
return env;
}
return env;
}
// Remove `secretKeys` from `env` unless `baseUrlKey` is set to a non-empty
// value — in which case the user is intentionally routing the CLI through
// a custom endpoint and the secret is the credential that authenticates
// against it. Comparison is case-insensitive so Windows env names with
// mixed casing (`Openai_Api_Key`) cannot slip past a literal `delete`.
function stripUnlessCustomBaseUrl(
env: NodeJS.ProcessEnv,
baseUrlKey: string,
secretKeys: readonly string[],
): void {
const baseUrlKeyUpper = baseUrlKey.toUpperCase();
const hasCustomBaseUrl = Object.keys(env).some(
(k) =>
k.toUpperCase() === baseUrlKeyUpper &&
typeof env[k] === 'string' &&
env[k].trim() !== '',
);
if (hasCustomBaseUrl) return;
const secretKeysUpper = new Set(secretKeys.map((k) => k.toUpperCase()));
for (const key of Object.keys(env)) {
if (secretKeysUpper.has(key.toUpperCase())) delete env[key];
}
}

View file

@ -13,11 +13,14 @@ import os from 'node:os';
import net from 'node:net';
import {
defaultScenarioPluginIdForProjectMetadata,
type OpenDesignGithubLatestReleaseResponse,
type OpenDesignGithubRepoResponse,
PLUGIN_SHARE_ACTION_PLUGIN_IDS,
} from '@open-design/contracts';
import {
composeSystemPrompt,
renderCodexImagegenOverride,
resolveExclusiveSurface,
shouldRenderCodexImagegenOverride,
} from './prompts/system.js';
import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js';
@ -56,7 +59,12 @@ export {
signDesktopImportToken,
verifyDesktopImportToken,
} from './desktop-auth.js';
import { findSkillById, listSkills, splitDerivedSkillId } from './skills.js';
import {
findSkillById,
listSkills,
resolveSkillId,
splitDerivedSkillId,
} from './skills.js';
import { validateLinkedDirs } from './linked-dirs.js';
import { installFromTarget, uninstallById, sanitizeRepoName } from './library-install.js';
import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './native-folder-dialog.js';
@ -225,7 +233,7 @@ import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js';
import { buildDocumentPreview } from './document-preview.js';
import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js';
import { loadCraftSections } from './craft.js';
import { stageActiveSkill } from './cwd-aliases.js';
import { skillCwdAliasSegment, stageActiveSkill } from './cwd-aliases.js';
import { buildDesktopPdfExportInput } from './pdf-export.js';
import { generateMedia } from './media.js';
import { listElevenLabsVoiceOptions } from './elevenlabs-voices.js';
@ -1336,6 +1344,13 @@ async function refreshAndPersistToken(dataDir, serverId, current) {
const activeChatAgentEventSinks = new Map();
const activeProjectEventSinks = new Map();
// Per-chat-run handles, keyed by runId. Lets non-stream side effects
// (live-artifact create, project events) reach back into the chat
// run's local state — currently used by the artifact quiet-period
// shortcut (#1451) so a successful artifact registration can shorten
// the inactivity watchdog without the chat path having to poll a
// store.
const activeChatRunHandles = new Map();
function emitChatAgentEvent(runId, payload) {
const sink = activeChatAgentEventSinks.get(runId);
@ -1343,6 +1358,20 @@ function emitChatAgentEvent(runId, payload) {
return sink(payload);
}
// Exported for tests covering the artifact quiet-period plumbing
// (#1451). The chat run path is a deep closure inside startServer, so
// pin the hook contract at the emit/handle boundary instead of
// driving a full fake-agent e2e for every invariant.
export const __forTestChatRunHandles = activeChatRunHandles;
export function __forTestEmitLiveArtifactEvent(
grant: { runId?: string; projectId?: string },
action: 'created' | 'updated' | 'deleted',
artifact: { id: string; projectId?: string; title?: string; refreshStatus?: string },
) {
return emitLiveArtifactEvent(grant, action, artifact);
}
function emitLiveArtifactEvent(grant, action, artifact) {
if (!artifact?.id) return false;
const payload = {
@ -1355,6 +1384,18 @@ function emitLiveArtifactEvent(grant, action, artifact) {
};
let emitted = emitProjectEvent(payload.projectId, payload);
if (grant?.runId) emitted = emitChatAgentEvent(grant.runId, payload) || emitted;
// After the deliverable exists, switch the chat run into a shorter
// "quiet period" watchdog: agents sometimes keep their child process
// alive after a successful artifact write (post-write reasoning, log
// flushes, claude-code stream-json's idle stdin) and the 10-minute
// default leaves the UI parked on Working until the watchdog fires
// an unrelated "stalled" error. See #1451.
if (action === 'created' && grant?.runId) {
const handle = activeChatRunHandles.get(grant.runId);
if (handle?.noteArtifactRegistered) {
try { handle.noteArtifactRegistered(); } catch {}
}
}
return emitted;
}
@ -2528,6 +2569,122 @@ function setLiveArtifactCodeHeaders(res) {
res.setHeader('Referrer-Policy', 'no-referrer');
}
const OPEN_DESIGN_GITHUB_REPO_API = 'https://api.github.com/repos/nexu-io/open-design';
const OPEN_DESIGN_GITHUB_RELEASE_LATEST_API = 'https://api.github.com/repos/nexu-io/open-design/releases/latest';
const OPEN_DESIGN_GITHUB_CACHE_TTL_MS = 60 * 60 * 1000;
const OPEN_DESIGN_GITHUB_TIMEOUT_MS = 4_000;
let openDesignGithubRepoCache = null;
let openDesignGithubRepoInflight = null;
let openDesignGithubLatestReleaseCache = null;
let openDesignGithubLatestReleaseInflight = null;
async function readOpenDesignGithubRepoStats() {
const now = Date.now();
if (
openDesignGithubRepoCache &&
now - openDesignGithubRepoCache.fetchedAt < OPEN_DESIGN_GITHUB_CACHE_TTL_MS
) {
return { ...openDesignGithubRepoCache, stale: false };
}
if (openDesignGithubRepoInflight) {
return openDesignGithubRepoInflight;
}
openDesignGithubRepoInflight = (async () => {
const ctrl = new AbortController();
const timeout = setTimeout(() => ctrl.abort(), OPEN_DESIGN_GITHUB_TIMEOUT_MS);
try {
const response = await fetch(OPEN_DESIGN_GITHUB_REPO_API, {
headers: {
accept: 'application/vnd.github+json',
'user-agent': 'open-design-daemon',
},
signal: ctrl.signal,
});
if (!response.ok) {
throw new Error(`GitHub repo metadata request failed with HTTP ${response.status}`);
}
const payload = await response.json();
const count = payload && typeof payload.stargazers_count === 'number'
? payload.stargazers_count
: null;
if (!Number.isFinite(count) || count == null || count < 0) {
throw new Error('GitHub repo metadata did not include a numeric stargazers_count');
}
openDesignGithubRepoCache = {
stargazersCount: count,
fetchedAt: Date.now(),
};
return { ...openDesignGithubRepoCache, stale: false };
} catch (error) {
if (openDesignGithubRepoCache) {
return { ...openDesignGithubRepoCache, stale: true };
}
throw error;
} finally {
clearTimeout(timeout);
openDesignGithubRepoInflight = null;
}
})();
return openDesignGithubRepoInflight;
}
async function readOpenDesignLatestReleaseInfo() {
const now = Date.now();
if (
openDesignGithubLatestReleaseCache &&
now - openDesignGithubLatestReleaseCache.fetchedAt < OPEN_DESIGN_GITHUB_CACHE_TTL_MS
) {
return { ...openDesignGithubLatestReleaseCache, stale: false };
}
if (openDesignGithubLatestReleaseInflight) {
return openDesignGithubLatestReleaseInflight;
}
openDesignGithubLatestReleaseInflight = (async () => {
const ctrl = new AbortController();
const timeout = setTimeout(() => ctrl.abort(), OPEN_DESIGN_GITHUB_TIMEOUT_MS);
try {
const response = await fetch(OPEN_DESIGN_GITHUB_RELEASE_LATEST_API, {
headers: {
accept: 'application/vnd.github+json',
'user-agent': 'open-design-daemon',
},
signal: ctrl.signal,
});
if (!response.ok) {
throw new Error(`GitHub latest release request failed with HTTP ${response.status}`);
}
const payload = await response.json();
const tagName = payload && typeof payload.tag_name === 'string' ? payload.tag_name : null;
const htmlUrl = payload && typeof payload.html_url === 'string' ? payload.html_url : null;
if (!tagName || !htmlUrl) {
throw new Error('GitHub latest release metadata did not include tag_name/html_url');
}
openDesignGithubLatestReleaseCache = {
tagName,
htmlUrl,
fetchedAt: Date.now(),
};
return { ...openDesignGithubLatestReleaseCache, stale: false };
} catch (error) {
if (openDesignGithubLatestReleaseCache) {
return { ...openDesignGithubLatestReleaseCache, stale: true };
}
throw error;
} finally {
clearTimeout(timeout);
openDesignGithubLatestReleaseInflight = null;
}
})();
return openDesignGithubLatestReleaseInflight;
}
function bearerTokenFromRequest(req) {
const header = req.get('authorization');
if (typeof header !== 'string') return undefined;
@ -3086,6 +3243,12 @@ export interface StartServerOptions {
const DEFAULT_CHAT_RUN_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
const MAX_CHAT_RUN_INACTIVITY_TIMEOUT_MS = 24 * 60 * 60 * 1000;
// After a successful live-artifact registration the daemon switches the
// chat-run inactivity watchdog from the long pre-artifact ceiling
// (DEFAULT_CHAT_RUN_INACTIVITY_TIMEOUT_MS) down to a much shorter
// "quiet period" — the deliverable exists, so further silence almost
// always means the agent is winding down or hanging. See #1451.
const DEFAULT_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS = 60 * 1000;
function resolveChatRunInactivityTimeoutMs() {
const raw = Number(process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS);
@ -3099,6 +3262,67 @@ function resolveChatRunInactivityTimeoutMs() {
return Math.min(MAX_CHAT_RUN_INACTIVITY_TIMEOUT_MS, Math.max(0, Math.floor(raw)));
}
// Resolve the post-artifact quiet-period window. Same clamp as the outer
// inactivity watchdog so an oversized override doesn't get Node-downgraded
// to a 1ms timer. Exported so tests can pin the env behavior without
// reaching into chat-run internals.
export function resolveChatRunArtifactQuietPeriodMs() {
const raw = Number(process.env.OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS);
if (!Number.isFinite(raw)) return DEFAULT_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS;
return Math.min(MAX_CHAT_RUN_INACTIVITY_TIMEOUT_MS, Math.max(0, Math.floor(raw)));
}
// Pure resolver for the chat run's *currently active* inactivity
// ceiling. Used by both `noteAgentActivity` and `noteArtifactRegistered`
// to pick between the pre-artifact watchdog and the shortened quiet
// period. Extracted so the `OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS=0`
// "disable the quiet period" semantics can be pinned with focused unit
// tests (#1451 review: a 0-value override must not strand the pre-artifact
// timer or stop further reschedules — it has to fall back to the
// pre-artifact ceiling so subsequent activity keeps refreshing the timer).
export function resolveActiveInactivityTimeoutMs(params: {
inactivityTimeoutMs: number;
artifactQuietPeriodMs: number;
artifactRegistered: boolean;
}): number {
if (params.artifactRegistered && params.artifactQuietPeriodMs > 0) {
return params.artifactQuietPeriodMs;
}
return params.inactivityTimeoutMs;
}
// Pure final-status classifier for the chat run's child-close handler.
// Extracted so the per-branch invariants can be unit-tested without
// driving a full child process — in particular:
// - cancel always wins over success/failure classification.
// - the ACP forced-shutdown override is scoped to SIGTERM + clean
// completion only (signed-32-bit-overflow SIGKILL or non-clean ACP
// state still report `failed`).
// - the artifact quiet-period override is gated on a daemon-initiated
// flag, NOT on `artifactRegistered` alone — see #1451 review:
// an external `kill -9` after the artifact write must still report
// `failed`, only the watchdog-initiated SIGTERM/SIGKILL escalation
// is allowed to flip the status to `succeeded`.
export function classifyChatRunCloseStatus(params: {
cancelRequested: boolean;
code: number | null;
signal: NodeJS.Signals | string | null;
acpCleanCompletion: boolean;
artifactQuietShutdownRequested: boolean;
}): 'canceled' | 'succeeded' | 'failed' {
if (params.cancelRequested) return 'canceled';
if (params.code === 0) return 'succeeded';
const acpForcedShutdown =
params.code === null && params.signal === 'SIGTERM' && params.acpCleanCompletion;
if (acpForcedShutdown) return 'succeeded';
const artifactQuietShutdown =
params.artifactQuietShutdownRequested &&
params.code === null &&
(params.signal === 'SIGTERM' || params.signal === 'SIGKILL');
if (artifactQuietShutdown) return 'succeeded';
return 'failed';
}
function resolveChatRunShutdownGraceMs() {
const raw = Number(process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS);
if (!Number.isFinite(raw)) return 3_000;
@ -3647,6 +3871,41 @@ export async function startServer({
res.json({ version });
});
app.get('/api/github/open-design', async (_req, res) => {
try {
const stats = await readOpenDesignGithubRepoStats();
const payload = /** @type {OpenDesignGithubRepoResponse} */ ({
repo: 'nexu-io/open-design',
stargazers_count: stats.stargazersCount,
fetchedAt: stats.fetchedAt,
stale: stats.stale,
});
res.json(payload);
} catch (error) {
res.status(502).json({
error: error instanceof Error ? error.message : String(error),
});
}
});
app.get('/api/github/open-design/releases/latest', async (_req, res) => {
try {
const release = await readOpenDesignLatestReleaseInfo();
const payload = /** @type {OpenDesignGithubLatestReleaseResponse} */ ({
repo: 'nexu-io/open-design',
tag_name: release.tagName,
html_url: release.htmlUrl,
fetchedAt: release.fetchedAt,
stale: release.stale,
});
res.json(payload);
} catch (error) {
res.status(502).json({
error: error instanceof Error ? error.message : String(error),
});
}
});
// Plan §3.F2 / spec §11.7 — daemon lifecycle status. Returns the
// host / port the server is bound to plus the data dir,
// so `od daemon status --json` can render a one-shot health snapshot
@ -8815,7 +9074,8 @@ export async function startServer({
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
res.json(await orbitService.start('manual'));
const locale = typeof req.body?.locale === 'string' ? req.body.locale : null;
res.json(await orbitService.start('manual', { locale }));
} catch (err) {
res
.status(500)
@ -9102,8 +9362,10 @@ export async function startServer({
agentId,
projectId,
skillId,
skillIds,
designSystemId,
streamFormat,
locale,
connectedExternalMcp,
appliedPluginSnapshotId,
}) => {
@ -9118,35 +9380,131 @@ export async function startServer({
? designSystemId
: project?.designSystemId;
const metadata = project?.metadata;
let allSkillsPromise: ReturnType<typeof listAllSkillLikeEntries> | null = null;
const loadAllSkills = async () => {
allSkillsPromise ??= listAllSkillLikeEntries();
return await allSkillsPromise;
};
// Per-turn skills picked via the composer's @-mention popover. They
// never persist on the project — we just append their bodies after the
// primary skill so the agent sees one combined block this turn.
const effectiveCanonicalSkillId =
typeof effectiveSkillId === 'string' && effectiveSkillId
? resolveSkillId(effectiveSkillId)
: null;
const adHocSkillIds = Array.isArray(skillIds)
? skillIds
.map((s) => (typeof s === 'string' ? s.trim() : ''))
.filter(Boolean)
.filter((id) => resolveSkillId(id) !== effectiveCanonicalSkillId)
: [];
let skillBody;
let skillName;
let skillMode;
const skillModes = new Set<NonNullable<Parameters<typeof composeSystemPrompt>[0]['skillMode']>>();
let skillCraftRequires = [];
let activeSkillDir = null;
const activeSkillDirs: string[] = [];
// Per-skill Critique Theater override sourced from
// `od.critique.policy` in the resolved skill's SKILL.md frontmatter.
// `null` means the skill has no opinion and the lower-priority tiers
// (project override, env override, rollout phase default) decide.
let skillCritiquePolicy: SkillCritiquePolicy = null;
let critiqueSkillId = effectiveCanonicalSkillId;
const registerSkillMode = (
mode: NonNullable<Parameters<typeof composeSystemPrompt>[0]['skillMode']> | null | undefined,
) => {
if (!mode) return;
skillModes.add(mode);
};
const registerPrimarySkillMode = (
mode: NonNullable<Parameters<typeof composeSystemPrompt>[0]['skillMode']> | null | undefined,
) => {
if (!mode) return;
skillMode ??= mode;
registerSkillMode(mode);
};
const registerSkillDir = (dir: string | null | undefined) => {
if (typeof dir !== 'string' || dir.length === 0) return;
if (!activeSkillDir) activeSkillDir = dir;
if (!activeSkillDirs.includes(dir)) activeSkillDirs.push(dir);
};
const mergeSkillCritiquePolicy = (
current: SkillCritiquePolicy,
next: SkillCritiquePolicy,
): SkillCritiquePolicy => {
if (next === 'opt-out') return 'opt-out';
if (next === 'required') return current === 'opt-out' ? current : 'required';
if (next === 'opt-in') {
return current === 'required' || current === 'opt-out' ? current : 'opt-in';
}
return current;
};
if (effectiveSkillId) {
// Span both functional skills and design templates so a project
// saved against either surface keeps its system prompt after the
// skills/design-templates split. See specs/current/skills-and-design-templates.md.
const skill = findSkillById(
await listAllSkillLikeEntries(),
effectiveSkillId,
);
const allSkills = await loadAllSkills();
const skill = findSkillById(allSkills, effectiveSkillId);
if (skill) {
skillBody = skill.body;
skillName = skill.name;
skillMode = skill.mode;
activeSkillDir = skill.dir;
skillCritiquePolicy = skill.critiquePolicy;
registerPrimarySkillMode(skill.mode);
registerSkillDir(skill.dir);
skillCritiquePolicy = mergeSkillCritiquePolicy(
skillCritiquePolicy,
skill.critiquePolicy,
);
if (Array.isArray(skill.craftRequires))
skillCraftRequires = skill.craftRequires;
}
}
let composedSkillBlocks = '';
if (adHocSkillIds.length > 0) {
const allSkills = await loadAllSkills();
const seen = new Set(
effectiveCanonicalSkillId ? [String(effectiveCanonicalSkillId)] : [],
);
const blocks = [];
const baseBody = skillBody && skillBody.trim().length > 0 ? skillBody : '';
for (const id of adHocSkillIds) {
const canonicalId = resolveSkillId(id);
if (typeof canonicalId !== 'string' || canonicalId.length === 0) continue;
if (seen.has(canonicalId)) continue;
seen.add(canonicalId);
const extra = findSkillById(allSkills, id);
if (!extra) continue;
registerSkillDir(extra.dir);
registerSkillMode(extra.mode);
if (!effectiveCanonicalSkillId && adHocSkillIds.length === 1) {
registerPrimarySkillMode(extra.mode);
}
if (!critiqueSkillId || extra.critiquePolicy !== null) critiqueSkillId = canonicalId;
skillCritiquePolicy = mergeSkillCritiquePolicy(
skillCritiquePolicy,
extra.critiquePolicy,
);
if (Array.isArray(extra.craftRequires)) {
for (const craft of extra.craftRequires) {
if (!skillCraftRequires.includes(craft)) skillCraftRequires.push(craft);
}
}
blocks.push(
`\n\n---\n\n## Composed skill — ${extra.name || id}\n\n${(extra.body || '').trim()}`,
);
}
if (blocks.length > 0) {
composedSkillBlocks = blocks.join('');
skillBody = baseBody + composedSkillBlocks;
if (!skillName) {
skillName = adHocSkillIds.length === 1
? findSkillById(allSkills, adHocSkillIds[0])?.name ?? null
: 'composed';
}
}
}
// Stage A of plugin-driven-flow-plan: when the run is bound to a
// plugin snapshot, prefer the plugin's local SKILL.md (declared via
@ -9166,9 +9524,10 @@ export async function startServer({
const { loadPluginLocalSkill } = await import('./plugins/local-skill.js');
const local = await loadPluginLocalSkill(plugin);
if (local) {
skillBody = local.body;
skillBody = local.body + composedSkillBlocks;
skillName = local.name;
activeSkillDir = local.dir;
registerSkillDir(local.dir);
}
}
}
@ -9337,8 +9696,8 @@ export async function startServer({
&& typeof designSystemBody === 'string'
? { name: designSystemTitle, design_md: designSystemBody }
: undefined;
const critiqueSkill = critiqueEnabledForRun && typeof effectiveSkillId === 'string'
? { id: effectiveSkillId }
const critiqueSkill = critiqueEnabledForRun && typeof critiqueSkillId === 'string'
? { id: critiqueSkillId }
: undefined;
// Single-source-of-truth eligibility check. The composer downstream
// appends <CRITIQUE_RUN> instructions only when this check passes, and
@ -9352,13 +9711,15 @@ export async function startServer({
// panel addendum has to be suppressed here too: otherwise the model
// is instructed to emit Critique Theater tags that no orchestrator
// consumes.
const resolvedExclusiveSurface = resolveExclusiveSurface({
metadata,
skillMode,
skillModes: skillModes.size > 0 ? Array.from(skillModes) : undefined,
});
const isMediaSurface =
skillMode === 'image' ||
skillMode === 'video' ||
skillMode === 'audio' ||
metadata?.kind === 'image' ||
metadata?.kind === 'video' ||
metadata?.kind === 'audio';
resolvedExclusiveSurface === 'image'
|| resolvedExclusiveSurface === 'video'
|| resolvedExclusiveSurface === 'audio';
const isPlainAdapter = (streamFormat ?? 'plain') === 'plain';
const critiqueShouldRun = critiqueEnabledForRun
&& critiqueBrand !== undefined
@ -9425,6 +9786,7 @@ export async function startServer({
skillBody,
skillName,
skillMode,
skillModes: skillModes.size > 0 ? Array.from(skillModes) : undefined,
designSystemBody,
designSystemTitle,
designSystemUsageMd,
@ -9452,6 +9814,7 @@ export async function startServer({
critique: critiqueShouldRun ? { ...critiqueCfg, enabled: true } : undefined,
critiqueBrand: critiqueShouldRun ? critiqueBrand : undefined,
critiqueSkill: critiqueShouldRun ? critiqueSkill : undefined,
locale: typeof locale === 'string' ? locale : undefined,
streamFormat,
connectedExternalMcp: Array.isArray(connectedExternalMcp)
? connectedExternalMcp
@ -9467,7 +9830,7 @@ export async function startServer({
// `listSkills()` scan in `startChatRun`. critiqueShouldRun threads
// the same panel-eligibility decision down to the spawn-path
// orchestrator gate so prompt and orchestrator stay in lockstep.
return { prompt, activeSkillDir, critiqueShouldRun };
return { prompt, activeSkillDir, activeSkillDirs, critiqueShouldRun };
};
// Plan §3.I1 / §3.D / spec §10.1: fire the pipeline schedule on a
@ -9557,11 +9920,13 @@ export async function startServer({
assistantMessageId,
clientRequestId,
skillId,
skillIds,
designSystemId,
attachments = [],
commentAttachments = [],
model,
reasoning,
locale,
research,
context,
} = chatBody;
@ -9791,13 +10156,19 @@ export async function startServer({
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
.map((s) => ({ id: s.id, label: s.label }));
const { prompt: daemonSystemPrompt, activeSkillDir, critiqueShouldRun } =
const {
prompt: daemonSystemPrompt,
activeSkillDirs,
critiqueShouldRun,
} =
await composeDaemonSystemPrompt({
agentId,
projectId,
skillId,
skillIds,
designSystemId,
streamFormat: def?.streamFormat ?? 'plain',
locale,
connectedExternalMcp,
// Plan §3.M2 / §3.V1 — forward the run's snapshot id so the
// prompt composer can splice in `## Active stage` blocks.
@ -9810,11 +10181,11 @@ export async function startServer({
// advertises both the cwd-relative path (1) and the absolute path
// (2/3) so the agent can pick whichever works.
//
// 1. CWD-relative copy. Stage the *active* skill into
// 1. CWD-relative copy. Stage every active/composed skill into
// `<cwd>/.od-skills/<folder>/` so any agent CLI — not just the
// ones that honour `--add-dir` — can reach those files via a
// path inside its working directory. We copy (not symlink) so
// the staged directory is a true write barrier — agents cannot
// each staged directory is a true write barrier — agents cannot
// mutate the shipped repo resource through their cwd.
// 2. `--add-dir` allowlist. For non-Codex agents, pass `SKILLS_DIR`
// and `DESIGN_SYSTEMS_DIR` so the absolute fallback path in the
@ -9832,11 +10203,12 @@ export async function startServer({
// daemon and folded into the system prompt directly (see
// `readDesignSystem`), so an agent never has to open them via the
// filesystem.
if (cwd && activeSkillDir) {
if (cwd && activeSkillDirs.length > 0) {
for (const skillDir of activeSkillDirs) {
const result = await stageActiveSkill(
cwd,
path.basename(activeSkillDir),
activeSkillDir,
skillCwdAliasSegment(skillDir),
skillDir,
(msg) => console.warn(msg),
);
if (!result.staged) {
@ -9845,6 +10217,7 @@ export async function startServer({
);
}
}
}
// Resolve the agent's effective working directory once and use it
// everywhere the agent could read it (buildArgs runtimeContext, spawn
// cwd, ACP session new). Falling back to PROJECT_ROOT — rather than
@ -10143,11 +10516,26 @@ export async function startServer({
design.runs.emit(run, event, data);
};
const inactivityTimeoutMs = resolveChatRunInactivityTimeoutMs();
const artifactQuietPeriodMs = resolveChatRunArtifactQuietPeriodMs();
const inactivityKillGraceMs = 3_000;
let inactivityTimer = null;
let childStdoutSeen = false;
let lastAgentEventPhase = 'spawn pending';
let lastToolResultChars = 0;
// Becomes true once any live-artifact create has been registered for
// this run. Subsequent watchdog scheduling uses the shorter quiet
// period, and a watchdog trip after this point is treated as
// "agent finished the deliverable and went idle" rather than
// "agent stalled with nothing to show" (issue #1451).
let artifactRegistered = false;
// Only daemon-initiated quiet-period termination should be treated
// as `succeeded` in the close handler. A later unrelated SIGTERM /
// SIGKILL (external `kill`, OOM, container shutdown) must keep its
// existing `failed` classification even when `artifactRegistered`
// is true — those signals don't mean the agent finished cleanly,
// they just terminated the process. Set strictly inside
// `failForInactivity`'s quiet-period branch.
let artifactQuietShutdownRequested = false;
const summarizeAgentEventForInactivity = (payload) => {
const type = payload?.type ? String(payload.type) : 'unknown';
if (type === 'tool_result') {
@ -10182,13 +10570,35 @@ export async function startServer({
};
const failForInactivity = () => {
if (run.cancelRequested || design.runs.isTerminal(run.status)) return;
clearInactivityWatchdog();
if (artifactRegistered) {
// The deliverable already exists. The agent process is either
// genuinely idle (claude-code's stream-json child sitting on an
// open stdin) or wedged in post-write reasoning that never
// emits stdout. Either way, finishing the run via the normal
// child-exit path (status decision in child.on('close') below)
// is safer than tearing it down with a failure banner — the
// tool token, cancel state, and exit-code classification stay
// owned by the existing lifecycle. SIGTERM the child and let
// the close handler classify the run as succeeded (via the
// artifactQuietShutdown branch). Mark this termination as
// daemon-initiated so an unrelated later signal (external
// kill, OOM) is NOT silently reclassified to `succeeded` —
// only signals from this watchdog branch should be.
artifactQuietShutdownRequested = true;
if (acpSession?.abort) {
acpSession.abort();
}
if (child && !child.killed) child.kill('SIGTERM');
scheduleForcedChildShutdown();
return;
}
const message =
`Agent stalled without emitting any new output for ${Math.round(inactivityTimeoutMs / 1000)}s. ` +
'The model or CLI likely hung while generating. ' +
`Phase details: spawned agent binary ${resolvedBin}; stdout arrived: ${childStdoutSeen ? 'yes' : 'no'}; ` +
`last agent event: ${lastAgentEventPhase}; largest tool result observed: ${lastToolResultChars} chars. ` +
'Retry the turn, pick a different model, or start a new conversation if the prior context is very large.';
clearInactivityWatchdog();
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', message, { retryable: true }));
design.runs.finish(run, 'failed', 1, null);
if (acpSession?.abort) {
@ -10197,14 +10607,41 @@ export async function startServer({
if (child && !child.killed) child.kill('SIGTERM');
scheduleForcedChildShutdown();
};
const activeInactivityTimeoutMs = () =>
resolveActiveInactivityTimeoutMs({
inactivityTimeoutMs,
artifactQuietPeriodMs,
artifactRegistered,
});
const noteAgentActivity = () => {
if (inactivityTimeoutMs <= 0) return;
const delay = activeInactivityTimeoutMs();
if (delay <= 0) return;
clearInactivityWatchdog();
inactivityTimer = setTimeout(failForInactivity, inactivityTimeoutMs);
inactivityTimer = setTimeout(failForInactivity, delay);
inactivityTimer.unref?.();
};
const noteArtifactRegistered = () => {
if (artifactRegistered) return;
artifactRegistered = true;
// Switch the watchdog to the shorter quiet-period window
// immediately so we don't have to wait for the next agent event
// before the new ceiling takes effect. Call unconditionally:
// an earlier `if (inactivityTimer)` gate left the run in limbo
// when `OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS=0` but
// `OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS>0` — noteAgentActivity()
// had returned early at run start (pre-artifact delay = 0,
// no timer set), so the guard then skipped the re-arm and the
// newly-positive quiet-period delay never armed a timer at all.
// `noteAgentActivity` itself is the one that decides whether to
// schedule (it bails when the active delay is 0), so leaving the
// decision there keeps the behavior coherent across all four
// combinations of pre / quiet timeouts.
noteAgentActivity();
};
const unregisterChatAgentEventSink = () => {
activeChatAgentEventSinks.delete(toolTokenGrant?.runId ?? runId);
const sinkRunId = toolTokenGrant?.runId ?? runId;
activeChatAgentEventSinks.delete(sinkRunId);
activeChatRunHandles.delete(sinkRunId);
};
if (toolTokenGrant?.runId) {
activeChatAgentEventSinks.set(toolTokenGrant.runId, (payload) => {
@ -10212,6 +10649,7 @@ export async function startServer({
noteAgentActivity();
send('agent', payload);
});
activeChatRunHandles.set(toolTokenGrant.runId, { noteArtifactRegistered });
}
// If detection can't find the binary, surface a friendly SSE error
// pointing at /api/agents instead of silently falling back to
@ -10843,13 +11281,13 @@ export async function startServer({
const acpCleanCompletion =
typeof acpSession?.completedSuccessfully === 'function' &&
acpSession.completedSuccessfully();
const acpForcedShutdown =
code === null && signal === 'SIGTERM' && acpCleanCompletion;
const status = run.cancelRequested
? 'canceled'
: code === 0 || acpForcedShutdown
? 'succeeded'
: 'failed';
const status = classifyChatRunCloseStatus({
cancelRequested: !!run.cancelRequested,
code,
signal,
acpCleanCompletion,
artifactQuietShutdownRequested,
});
if (status === 'failed') {
const diagnostic = diagnoseClaudeCliFailure({
agentId: def.id,
@ -11032,7 +11470,7 @@ export async function startServer({
const cwd = await ensureProject(PROJECTS_DIR, projectId);
const result = await stageActiveSkill(
cwd,
path.basename(template.dir),
skillCwdAliasSegment(template.dir),
template.dir,
(msg) => console.warn(msg),
);

View file

@ -34,6 +34,7 @@ export function withCurrentDesktopAuthGate(snapshot: DaemonStatusSnapshot): Daem
}
const DAEMON_PORT_ENV = SIDECAR_ENV.DAEMON_PORT;
const WEB_PORT_ENV = SIDECAR_ENV.WEB_PORT;
const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID;
export type DaemonSidecarHandle = {
@ -51,6 +52,11 @@ function parsePort(value: string | undefined): number {
return port;
}
function parseOptionalTrustedWebPort(value: string | undefined): number | null {
const port = parsePort(value);
return port > 0 ? port : null;
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@ -100,6 +106,7 @@ export async function startDaemonSidecar(runtime: SidecarRuntimeContext<SidecarS
desktopAuthGateActive: isDesktopAuthGateActive(),
pid: process.pid,
state: "running",
trustedWebOriginPort: parseOptionalTrustedWebPort(process.env[WEB_PORT_ENV]),
updatedAt: new Date().toISOString(),
url: serverHandle.url,
};

View file

@ -11,7 +11,7 @@ import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promi
import path from "node:path";
import { parseFrontmatter } from "./frontmatter.js";
import type { SkillCritiquePolicy } from "./critique/rollout.js";
import { SKILLS_CWD_ALIAS } from "./cwd-aliases.js";
import { skillCwdAliasSegment, SKILLS_CWD_ALIAS } from "./cwd-aliases.js";
// Persisted skill ids on existing projects can outlive a folder rename.
// listSkills() derives the id from the SKILL.md frontmatter `name`, so once
@ -416,7 +416,7 @@ export function splitDerivedSkillId(id: unknown): DerivedSkillIdParts | null {
// the right form on its own without daemon-side feature detection.
function withSkillRootPreamble(body: string, dir: string): string {
const referencedFiles = collectReferencedSideFiles(body);
const folder = path.basename(dir);
const folder = skillCwdAliasSegment(dir);
const skillRootRel = `${SKILLS_CWD_ALIAS}/${folder}`;
const exampleFile = referencedFiles[0];
const relativeGuidance = exampleFile

View file

@ -0,0 +1,126 @@
import { describe, expect, it, vi } from 'vitest';
import { getActiveRoute, postActiveRoute } from '../src/active-context-routes.js';
import { ACTIVE_CONTEXT_TTL_MS } from '../src/constants.js';
interface MockStore {
current:
| {
projectId: string;
fileName: string | null;
ts: number;
}
| null;
}
function makeDeps(now = 1_000) {
const store: MockStore = { current: null };
// Annotated return type widens the mock so `.mockReturnValue(null)` is
// allowed by the inferred Mock type later in the file.
const getProject = vi.fn(
(_db: unknown, id: string): { name?: string | null } | null | undefined => ({
name: `Project ${id}`,
}),
);
return {
store,
db: { fake: true },
getProject,
now: () => now,
};
}
const EMPTY_INPUT = { body: {}, query: {}, params: {} };
describe('active context — POST /api/active', () => {
it('clears the store when body.active === false', async () => {
const deps = makeDeps();
deps.store.current = { projectId: 'p1', fileName: 'a.html', ts: 1 };
const parsed = postActiveRoute.parse({ ...EMPTY_INPUT, body: { active: false } });
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
const out = await postActiveRoute.handle(parsed.value, deps);
expect(out).toEqual({ ok: true, value: { active: false } });
expect(deps.store.current).toBeNull();
});
it('rejects when projectId is missing', () => {
const parsed = postActiveRoute.parse({ ...EMPTY_INPUT, body: {} });
expect(parsed.ok).toBe(false);
if (parsed.ok) return;
expect(parsed.error.code).toBe('BAD_REQUEST');
expect(parsed.error.message).toBe('projectId is required');
});
it('stores projectId + fileName + timestamp on success', async () => {
const deps = makeDeps(5_000);
const parsed = postActiveRoute.parse({
...EMPTY_INPUT,
body: { projectId: 'p1', fileName: 'index.html' },
});
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
const out = await postActiveRoute.handle(parsed.value, deps);
expect(out).toEqual({
ok: true,
value: { active: true, projectId: 'p1', fileName: 'index.html', ts: 5_000 },
});
expect(deps.store.current).toEqual({ projectId: 'p1', fileName: 'index.html', ts: 5_000 });
});
it('treats empty fileName as null', async () => {
const deps = makeDeps(7_000);
const parsed = postActiveRoute.parse({
...EMPTY_INPUT,
body: { projectId: 'p1', fileName: '' },
});
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
const out = await postActiveRoute.handle(parsed.value, deps);
expect(out.ok).toBe(true);
if (!out.ok) return;
expect(out.value).toMatchObject({ active: true, fileName: null });
});
});
describe('active context — GET /api/active', () => {
it('returns inactive when nothing is stored', async () => {
const deps = makeDeps();
const out = await getActiveRoute.handle(undefined, deps);
expect(out).toEqual({ ok: true, value: { active: false } });
});
it('returns inactive and clears when TTL has expired', async () => {
const deps = makeDeps(10_000 + ACTIVE_CONTEXT_TTL_MS);
deps.store.current = { projectId: 'p1', fileName: null, ts: 9_000 };
const out = await getActiveRoute.handle(undefined, deps);
expect(out).toEqual({ ok: true, value: { active: false } });
expect(deps.store.current).toBeNull();
});
it('returns active payload with project name + ageMs when fresh', async () => {
const deps = makeDeps(2_500);
deps.store.current = { projectId: 'p7', fileName: 'plan.md', ts: 2_000 };
const out = await getActiveRoute.handle(undefined, deps);
expect(out.ok).toBe(true);
if (!out.ok) return;
expect(out.value).toEqual({
active: true,
projectId: 'p7',
projectName: 'Project p7',
fileName: 'plan.md',
ts: 2_000,
ageMs: 500,
});
expect(deps.getProject).toHaveBeenCalledWith(deps.db, 'p7');
});
it('tolerates a missing project (projectName = null)', async () => {
const deps = makeDeps(3_000);
deps.getProject.mockReturnValue(null);
deps.store.current = { projectId: 'p9', fileName: null, ts: 2_500 };
const out = await getActiveRoute.handle(undefined, deps);
expect(out.ok).toBe(true);
if (!out.ok) return;
expect(out.value).toMatchObject({ active: true, projectName: null });
});
});

View file

@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
BYOK_SENSEAUDIO_TOOLS,
executeGenerateImage,
executeGenerateSpeech,
executeGenerateVideo,
} from '../src/byok-tools.js';
@ -17,7 +18,8 @@ describe('BYOK_SENSEAUDIO_TOOLS', () => {
expect(tool).toBeDefined();
expect(tool!.type).toBe('function');
expect(tool!.function.parameters.required).toEqual(['prompt']);
expect(tool!.function.parameters.properties.aspect_ratio.enum).toEqual([
const properties = tool!.function.parameters.properties as Record<string, any>;
expect(properties.aspect_ratio.enum).toEqual([
'1:1',
'16:9',
'9:16',
@ -26,9 +28,9 @@ describe('BYOK_SENSEAUDIO_TOOLS', () => {
]);
});
it('exposes both generate_image and generate_video tools', () => {
it('exposes image, speech, and video tools', () => {
const names = BYOK_SENSEAUDIO_TOOLS.map((t) => t.function.name).sort();
expect(names).toEqual(['generate_image', 'generate_video']);
expect(names).toEqual(['generate_image', 'generate_speech', 'generate_video']);
});
});
@ -381,6 +383,212 @@ describe('BYOK_SENSEAUDIO_TOOLS — video', () => {
});
});
describe('executeGenerateSpeech', () => {
let root: string;
let projectsRoot: string;
const PROJECT_ID = 'test-project';
const realFetch = globalThis.fetch;
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'od-byok-speech-'));
projectsRoot = path.join(root, 'projects');
});
afterEach(async () => {
globalThis.fetch = realFetch;
vi.unstubAllGlobals();
await rm(root, { recursive: true, force: true });
});
it('calls /v1/t2a_v2, persists mp3 bytes, and returns a daemon URL', async () => {
const audioBytes = Buffer.from([0x49, 0x44, 0x33, 0x04]);
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
expect(String(input)).toBe('https://api.senseaudio.cn/v1/t2a_v2');
expect(init?.method).toBe('POST');
expect(init?.redirect).toBe('error');
expect(init?.headers).toMatchObject({
authorization: 'Bearer sa-byok-key',
'content-type': 'application/json',
});
expect(JSON.parse(String(init?.body))).toEqual({
model: 'senseaudio-tts-1.5-260319',
text: 'Meet saddle2 — the way work was supposed to feel.',
stream: false,
voice_setting: {
voice_id: 'female_0033_b',
speed: 1,
vol: 1,
pitch: 0,
},
audio_setting: {
format: 'mp3',
sample_rate: 32000,
bitrate: 128000,
channel: 2,
},
});
return new Response(
JSON.stringify({
data: { audio: audioBytes.toString('hex') },
base_resp: { status_code: 0, status_msg: 'success' },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);
const result = await executeGenerateSpeech(
{ text: 'Meet saddle2 — the way work was supposed to feel.' },
{
projectRoot: root,
projectsRoot,
projectId: PROJECT_ID,
upstreamApiKey: 'sa-byok-key',
upstreamBaseUrl: 'https://api.senseaudio.cn',
},
);
expect(result.ok).toBe(true);
expect(result.url).toMatch(
new RegExp(`^/api/projects/${PROJECT_ID}/files/byok-speech-[a-z0-9-]+\\.mp3$`),
);
const filename = result.url!.split('/').pop()!;
const onDisk = await readFile(path.join(projectsRoot, PROJECT_ID, filename));
expect(onDisk.equals(audioBytes)).toBe(true);
});
it('does not duplicate /v1 when the BYOK gateway base URL is already versioned', async () => {
const audioBytes = Buffer.from([0x49, 0x44, 0x33, 0x04]);
const fetchMock = vi.fn(async (input: unknown) => {
expect(String(input)).toBe('https://gateway.example.com/api/v1/openai/t2a_v2');
return new Response(
JSON.stringify({
data: { audio: audioBytes.toString('hex') },
base_resp: { status_code: 0, status_msg: 'success' },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);
const result = await executeGenerateSpeech(
{ text: 'hello' },
{
projectRoot: root,
projectsRoot,
projectId: PROJECT_ID,
upstreamApiKey: 'sa-byok-key',
upstreamBaseUrl: 'https://gateway.example.com/api/v1/openai',
},
);
expect(result.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('returns { ok: false } when SenseAudio returns malformed JSON', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response('not json', {
status: 200,
headers: { 'content-type': 'text/plain' },
}),
),
);
const result = await executeGenerateSpeech(
{ text: 'hello' },
{
projectRoot: root,
projectsRoot,
projectId: PROJECT_ID,
upstreamApiKey: 'sa-byok-key',
upstreamBaseUrl: 'https://api.senseaudio.cn',
},
);
expect(result.ok).toBe(false);
expect(result.error).toMatch(/senseaudio speech non-JSON/);
});
it('returns { ok: false } when the SenseAudio request fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('network down');
}),
);
const result = await executeGenerateSpeech(
{ text: 'hello' },
{
projectRoot: root,
projectsRoot,
projectId: PROJECT_ID,
upstreamApiKey: 'sa-byok-key',
upstreamBaseUrl: 'https://api.senseaudio.cn',
},
);
expect(result).toEqual({ ok: false, error: 'network down' });
});
it('asks fetch to reject redirected SenseAudio TTS upstreams', async () => {
const fetchMock = vi.fn(async (_input: unknown, init?: RequestInit) => {
expect(init?.redirect).toBe('error');
throw new TypeError('redirect mode is set to error');
});
vi.stubGlobal('fetch', fetchMock);
const result = await executeGenerateSpeech(
{ text: 'hello' },
{
projectRoot: root,
projectsRoot,
projectId: PROJECT_ID,
upstreamApiKey: 'sa-byok-key',
upstreamBaseUrl: 'https://api.senseaudio.cn',
},
);
expect(result).toEqual({ ok: false, error: 'redirect mode is set to error' });
});
it.each(['aaZZ', 'abc'])(
'returns { ok: false } when SenseAudio returns malformed hex audio: %s',
async (audio) => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(
JSON.stringify({
data: { audio },
base_resp: { status_code: 0, status_msg: 'success' },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
),
);
const result = await executeGenerateSpeech(
{ text: 'hello' },
{
projectRoot: root,
projectsRoot,
projectId: PROJECT_ID,
upstreamApiKey: 'sa-byok-key',
upstreamBaseUrl: 'https://api.senseaudio.cn',
},
);
expect(result.ok).toBe(false);
expect(result.error).toMatch(/invalid hex audio/);
},
);
});
describe('executeGenerateVideo', () => {
let root: string;
let projectsRoot: string;

View file

@ -24,6 +24,7 @@ import {
startServer,
validateCodexGeneratedImagesDir,
} from '../src/server.js';
import { skillCwdAliasSegment } from '../src/cwd-aliases.js';
import { getAgentDef } from '../src/agents.js';
import { readMemoryConfig, writeMemoryConfig } from '../src/memory.js';
import { renderCodexImagegenOverride } from '../src/prompts/system.js';
@ -68,6 +69,39 @@ describe('/api/chat', () => {
const originalAgentHome = process.env.OD_AGENT_HOME;
const tempDirs: string[] = [];
async function createPluginFixture(args: {
pluginId: string;
dirName: string;
localSkillPath?: string;
}): Promise<string> {
const root = await fsp.mkdtemp(join(tmpdir(), 'od-plugin-fixture-'));
tempDirs.push(root);
const fixtureDir = resolve(root, args.dirName);
const baseFixtureDir = resolve(
process.cwd(),
'tests',
'fixtures',
'plugin-fixtures',
'sample-plugin',
);
await fsp.cp(baseFixtureDir, fixtureDir, { recursive: true });
const manifestPath = resolve(fixtureDir, 'open-design.json');
const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8')) as {
name: string;
title: string;
od?: { context?: { skills?: Array<{ ref?: string; path?: string }> } };
};
manifest.name = args.pluginId;
manifest.title = args.pluginId;
if (args.localSkillPath) {
manifest.od ??= {};
manifest.od.context ??= {};
manifest.od.context.skills = [{ path: args.localSkillPath }];
}
await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
return fixtureDir;
}
beforeAll(async () => {
if (process.env.OD_DATA_DIR) {
originalMemoryConfig = await readMemoryConfig(process.env.OD_DATA_DIR);
@ -180,6 +214,582 @@ process.exit(0);
);
});
it('injects @-mention skillIds into the composed system prompt', async () => {
await withFakeAgent(
'opencode',
`
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
prompt += chunk;
});
process.stdin.on('end', () => {
const checks = [
prompt.includes('## Composed skill — faq-page') ? 'has-composed-skill-header' : 'missing-composed-skill-header',
prompt.includes('# FAQ Page Skill') ? 'has-faq-skill-body' : 'missing-faq-skill-body',
prompt.includes('category filtering') ? 'has-faq-skill-content' : 'missing-faq-skill-content',
];
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
message: 'build an faq page',
skillIds: ['faq-page'],
}),
});
const body = await response.text();
expect(response.ok).toBe(true);
expect(body).toContain('has-composed-skill-header');
expect(body).toContain('has-faq-skill-body');
expect(body).toContain('has-faq-skill-content');
expect(body).not.toContain('missing-composed-skill-header');
expect(body).not.toContain('missing-faq-skill-body');
expect(body).not.toContain('missing-faq-skill-content');
},
);
});
it('stages ad-hoc skill side files into the project cwd', async () => {
const projectId = `project-${randomUUID()}`;
const stagedRelativePath = `.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager'))}/references/checklist.md`;
const expectedChecklist = await fsp.readFile(
resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager', 'references', 'checklist.md'),
'utf8',
);
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Ad hoc staged skill project',
}),
});
expect(createProjectResponse.ok).toBe(true);
const fakeAgentScript = `
const fs = require('node:fs');
const stagedChecklist = fs.readFileSync(${JSON.stringify(stagedRelativePath)}, 'utf8');
if (stagedChecklist !== ${JSON.stringify(expectedChecklist)}) {
console.error('staged-skill-side-files-mismatch');
process.exit(1);
}
process.stdin.resume();
process.stdin.on('end', () => {
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: 'staged-skill-side-files-before-spawn' } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`;
await withFakeAgent(
'opencode',
fakeAgentScript,
async () => {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
projectId,
message: 'draft the release notes',
skillIds: ['release-notes-one-pager'],
}),
});
const body = await response.text();
expect(response.ok).toBe(true);
expect(body).toContain('staged-skill-side-files-before-spawn');
},
);
const stagedFileResponse = await fetch(
`${baseUrl}/api/projects/${projectId}/raw/${stagedRelativePath}`,
);
const stagedFileBody = await stagedFileResponse.text();
expect(stagedFileResponse.ok).toBe(true);
expect(stagedFileBody).toBe(expectedChecklist);
});
it('stages side files for every composed skill into the project cwd', async () => {
const projectId = `project-${randomUUID()}`;
const stagedPaths = [
`.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager'))}/references/checklist.md`,
`.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'swiss-creative-mode-template'))}/references/checklist.md`,
] as const;
const expectedBodies = await Promise.all(
[
resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager', 'references', 'checklist.md'),
resolve(process.cwd(), '..', '..', 'skills', 'swiss-creative-mode-template', 'references', 'checklist.md'),
].map((file) => fsp.readFile(file, 'utf8')),
);
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Multi staged skill project',
}),
});
expect(createProjectResponse.ok).toBe(true);
const fakeAgentScript = `
const fs = require('node:fs');
const stagedBodies = [
fs.readFileSync(${JSON.stringify(stagedPaths[0])}, 'utf8'),
fs.readFileSync(${JSON.stringify(stagedPaths[1])}, 'utf8'),
];
const expectedBodies = ${JSON.stringify(expectedBodies)};
if (JSON.stringify(stagedBodies) !== JSON.stringify(expectedBodies)) {
console.error('multi-staged-skill-side-files-mismatch');
process.exit(1);
}
process.stdin.resume();
process.stdin.on('end', () => {
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: 'multi-staged-skill-side-files-before-spawn' } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`;
await withFakeAgent(
'opencode',
fakeAgentScript,
async () => {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
projectId,
message: 'compose multiple skills',
skillIds: ['release-notes-one-pager', 'swiss-creative-mode-template'],
}),
});
const body = await response.text();
expect(response.ok).toBe(true);
expect(body).toContain('multi-staged-skill-side-files-before-spawn');
},
);
});
it('propagates the composed skill mode for ad-hoc-only deck skills', async () => {
await withFakeAgent(
'opencode',
`
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
prompt += chunk;
});
process.stdin.on('end', () => {
const checks = [
prompt.includes('## Composed skill — open-design-landing-deck') ? 'has-deck-skill-header' : 'missing-deck-skill-header',
prompt.includes('# Slide deck — fixed framework (this is non-negotiable for deck mode)') ? 'has-deck-framework' : 'missing-deck-framework',
];
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
message: 'build an editorial brand deck',
skillIds: ['open-design-landing-deck'],
}),
});
const body = await response.text();
expect(response.ok).toBe(true);
expect(body).toContain('has-deck-skill-header');
expect(body).toContain('has-deck-framework');
expect(body).not.toContain('missing-deck-skill-header');
expect(body).not.toContain('missing-deck-framework');
},
);
});
it('preserves a persisted media skill as the primary surface over a composed deck mention', async () => {
await withFakeAgent(
'opencode',
`
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
prompt += chunk;
});
process.stdin.on('end', () => {
const checks = [
prompt.includes('# imagegen') ? 'has-base-image-skill-body' : 'missing-base-image-skill-body',
prompt.includes('## Composed skill — open-design-landing-deck') ? 'has-composed-deck-skill-header' : 'missing-composed-deck-skill-header',
prompt.includes('## Media generation contract (load-bearing — overrides softer wording above)') ? 'has-image-contract' : 'missing-image-contract',
prompt.includes('# Slide deck — fixed framework (this is non-negotiable for deck mode)') ? 'unexpected-deck-framework' : 'kept-deck-framework-out',
];
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
message: 'generate an image while also referencing a deck template',
skillId: 'imagegen',
skillIds: ['open-design-landing-deck'],
}),
});
const body = await response.text();
expect(response.ok).toBe(true);
expect(body).toContain('has-base-image-skill-body');
expect(body).toContain('has-composed-deck-skill-header');
expect(body).toContain('has-image-contract');
expect(body).toContain('kept-deck-framework-out');
expect(body).not.toContain('missing-base-image-skill-body');
expect(body).not.toContain('missing-composed-deck-skill-header');
expect(body).not.toContain('missing-image-contract');
expect(body).not.toContain('unexpected-deck-framework');
},
);
});
it('propagates ad-hoc skill critique policy into the chat resolver', async () => {
if (!process.env.OD_DATA_DIR) {
throw new Error('OD_DATA_DIR is required for user skill critique-policy tests');
}
const skillId = `critique-opt-out-${randomUUID()}`;
const skillDir = resolve(process.env.OD_DATA_DIR, 'skills', skillId);
const originalCritiqueEnabled = process.env.OD_CRITIQUE_ENABLED;
await fsp.mkdir(skillDir, { recursive: true });
await fsp.writeFile(
resolve(skillDir, 'SKILL.md'),
`---
name: ${skillId}
description: Ad-hoc critique opt-out regression fixture.
od:
critique:
policy: opt-out
---
# Critique opt-out fixture
This skill should suppress critique when selected through skillIds.
`,
'utf8',
);
process.env.OD_CRITIQUE_ENABLED = 'true';
try {
await withFakeAgent(
'opencode',
`
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
prompt += chunk;
});
process.stdin.on('end', () => {
const checks = [
prompt.includes('## Composed skill — ${skillId}') ? 'has-opt-out-skill-header' : 'missing-opt-out-skill-header',
prompt.includes('<CRITIQUE_RUN') ? 'unexpected-critique-panel' : 'critique-panel-disabled-by-skill-policy',
];
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
designSystemId: 'default',
message: 'draft an opt-out skill artifact',
skillIds: [skillId],
}),
});
const body = await response.text();
expect(response.ok).toBe(true);
expect(body).toContain('has-opt-out-skill-header');
expect(body).toContain('critique-panel-disabled-by-skill-policy');
expect(body).not.toContain('missing-opt-out-skill-header');
expect(body).not.toContain('unexpected-critique-panel');
},
);
} finally {
if (originalCritiqueEnabled == null) {
delete process.env.OD_CRITIQUE_ENABLED;
} else {
process.env.OD_CRITIQUE_ENABLED = originalCritiqueEnabled;
}
await fsp.rm(skillDir, { recursive: true, force: true });
}
});
it('preserves plugin-local and composed @-mention skills in plugin-bound runs', async () => {
const pluginId = `plugin-local-${randomUUID()}`;
const pluginFixtureDir = await createPluginFixture({
pluginId,
dirName: `plugin-local-${randomUUID()}`,
localSkillPath: './SKILL.md',
});
const installResponse = await fetch(`${baseUrl}/api/plugins/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'text/event-stream' },
body: JSON.stringify({ source: pluginFixtureDir }),
});
const installBody = await installResponse.text();
expect(installResponse.status).toBe(200);
expect(installBody).toContain(`"id":"${pluginId}"`);
const projectId = `project-${randomUUID()}`;
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Plugin-bound skill composition project',
pluginId,
pluginInputs: { topic: 'agentic design' },
}),
});
const createProjectBody = await createProjectResponse.json() as {
appliedPluginSnapshotId?: string;
};
expect(createProjectResponse.ok).toBe(true);
expect(createProjectBody.appliedPluginSnapshotId).toBeTruthy();
await withFakeAgent(
'opencode',
`
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
prompt += chunk;
});
process.stdin.on('end', () => {
const checks = [
prompt.includes('# Sample Plugin') ? 'has-plugin-skill-body' : 'missing-plugin-skill-body',
prompt.includes('## Composed skill — faq-page') ? 'has-composed-skill-header' : 'missing-composed-skill-header',
prompt.includes('# FAQ Page Skill') ? 'has-composed-skill-body' : 'missing-composed-skill-body',
];
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const createRunResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
projectId,
message: 'build a plugin-backed faq page',
appliedPluginSnapshotId: createProjectBody.appliedPluginSnapshotId,
skillIds: ['faq-page'],
}),
});
const createRunBody = await createRunResponse.json() as { runId: string };
expect(createRunResponse.status).toBe(202);
const eventsResponse = await fetch(`${baseUrl}/api/runs/${createRunBody.runId}/events`);
const body = await readSseUntil(eventsResponse, 'event: final');
expect(body).toContain('has-plugin-skill-body');
expect(body).toContain('has-composed-skill-header');
expect(body).toContain('has-composed-skill-body');
expect(body).not.toContain('missing-plugin-skill-body');
expect(body).not.toContain('missing-composed-skill-header');
expect(body).not.toContain('missing-composed-skill-body');
},
);
});
it('stages colliding plugin and composed skill dirs under distinct aliases', async () => {
if (!process.env.OD_DATA_DIR) {
throw new Error('OD_DATA_DIR is required for colliding skill-dir staging tests');
}
const pluginId = `plugin-collision-${randomUUID()}`;
const pluginFixtureDir = await createPluginFixture({
pluginId,
dirName: 'sample-plugin',
localSkillPath: './SKILL.md',
});
const installResponse = await fetch(`${baseUrl}/api/plugins/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'text/event-stream' },
body: JSON.stringify({ source: pluginFixtureDir }),
});
const installBody = await installResponse.text();
expect(installResponse.status).toBe(200);
expect(installBody).toContain(`"id":"${pluginId}"`);
const projectId = `project-${randomUUID()}`;
const userSkillDir = resolve(process.env.OD_DATA_DIR, 'skills', 'sample-plugin');
const userChecklist = 'user-skill-checklist';
const userAlias = skillCwdAliasSegment(userSkillDir);
await fsp.mkdir(resolve(userSkillDir, 'references'), { recursive: true });
await fsp.writeFile(
resolve(userSkillDir, 'SKILL.md'),
'# Sample-plugin side-file fixture\n\nRead references/checklist.md before drafting.',
'utf8',
);
await fsp.writeFile(resolve(userSkillDir, 'references', 'checklist.md'), userChecklist, 'utf8');
try {
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Colliding skill-dir project',
pluginId,
pluginInputs: { topic: 'agentic design' },
}),
});
const createProjectBody = await createProjectResponse.json() as {
appliedPluginSnapshotId?: string;
};
const installedPluginResponse = await fetch(`${baseUrl}/api/plugins/${pluginId}`);
const installedPluginBody = await installedPluginResponse.json() as { fsPath: string };
const pluginAlias = skillCwdAliasSegment(installedPluginBody.fsPath);
expect(createProjectResponse.ok).toBe(true);
expect(installedPluginResponse.ok).toBe(true);
expect(createProjectBody.appliedPluginSnapshotId).toBeTruthy();
expect(pluginAlias).not.toBe(userAlias);
await withFakeAgent(
'opencode',
`
const fs = require('node:fs');
const pluginSkill = fs.readFileSync(${JSON.stringify(`.od-skills/${pluginAlias}/SKILL.md`)}, 'utf8');
const userChecklist = fs.readFileSync(${JSON.stringify(`.od-skills/${userAlias}/references/checklist.md`)}, 'utf8');
if (!pluginSkill.includes('# Sample Plugin')) {
console.error('plugin-skill-stage-missing');
process.exit(1);
}
if (userChecklist !== ${JSON.stringify(userChecklist)}) {
console.error('colliding-skill-stage-mismatch');
process.exit(1);
}
process.stdin.resume();
process.stdin.on('end', () => {
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: 'colliding-skill-dirs-staged' } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const createRunResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
projectId,
message: 'use both plugin and user skill side files',
appliedPluginSnapshotId: createProjectBody.appliedPluginSnapshotId,
skillIds: ['sample-plugin'],
}),
});
const createRunBody = await createRunResponse.json() as { runId: string };
expect(createRunResponse.status).toBe(202);
const eventsResponse = await fetch(`${baseUrl}/api/runs/${createRunBody.runId}/events`);
const body = await readSseUntil(eventsResponse, 'event: final');
expect(body).toContain('colliding-skill-dirs-staged');
},
);
} finally {
await fsp.rm(userSkillDir, { recursive: true, force: true });
}
});
it('canonicalizes aliased skill ids before deduping composed skills', async () => {
await withFakeAgent(
'opencode',
`
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
prompt += chunk;
});
process.stdin.on('end', () => {
const hasDuplicateComposedAlias = prompt.includes('## Composed skill — open-design-landing');
const checks = [
hasDuplicateComposedAlias ? 'duplicate-alias-composed-skill' : 'deduped-alias-composed-skill',
prompt.includes('# open-design-landing') ? 'has-base-alias-skill-body' : 'missing-base-alias-skill-body',
];
console.log(JSON.stringify({ type: 'step_start' }));
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
process.exit(0);
});
`,
async () => {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
message: 'build the Open Design landing page',
skillId: 'editorial-collage',
skillIds: ['open-design-landing'],
}),
});
const body = await response.text();
expect(response.ok).toBe(true);
expect(body).toContain('deduped-alias-composed-skill');
expect(body).toContain('has-base-alias-skill-body');
expect(body).not.toContain('duplicate-alias-composed-skill');
expect(body).not.toContain('missing-base-alias-skill-body');
},
);
});
it('classifies Cursor Agent authentication stderr as a typed run error', async () => {
await withFakeAgent(
'cursor-agent',

View file

@ -0,0 +1,355 @@
/**
* Artifact quiet-period plumbing (#1451).
*
* Live-artifact registration now feeds back into the chat-run
* inactivity watchdog: once the deliverable exists, the daemon
* switches to a shorter "quiet period" timeout instead of the
* 10-minute pre-artifact ceiling, and a watchdog trip after the
* artifact is in place is treated as "agent finished and went idle"
* rather than "agent stalled with nothing to show".
*
* The full watchdog state machine sits inside a deep closure in
* `startServer`, so these tests pin the emit/handle plumbing at the
* boundary (via the `__forTest*` exports) and pin the env resolver
* directly. The integration story fake agent + live-artifact
* create over HTTP + run-status check would require setting up a
* project, minting a tool token, and reproducing the chat-run path;
* that is covered by manual verification in the PR body.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
__forTestChatRunHandles,
__forTestEmitLiveArtifactEvent,
classifyChatRunCloseStatus,
resolveActiveInactivityTimeoutMs,
resolveChatRunArtifactQuietPeriodMs,
} from '../src/server.js';
const ENV_KEY = 'OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS';
const ONE_MINUTE_MS = 60 * 1000;
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
describe('resolveChatRunArtifactQuietPeriodMs', () => {
const originalEnv = process.env[ENV_KEY];
afterEach(() => {
if (originalEnv === undefined) {
delete process.env[ENV_KEY];
} else {
process.env[ENV_KEY] = originalEnv;
}
});
it('returns the 1-minute default when no env override is set', () => {
delete process.env[ENV_KEY];
expect(resolveChatRunArtifactQuietPeriodMs()).toBe(ONE_MINUTE_MS);
});
it('honors the env override when it is a finite number', () => {
process.env[ENV_KEY] = '5000';
expect(resolveChatRunArtifactQuietPeriodMs()).toBe(5_000);
});
it('falls back to the default when the env value is not parseable as a number', () => {
process.env[ENV_KEY] = 'not-a-number';
expect(resolveChatRunArtifactQuietPeriodMs()).toBe(ONE_MINUTE_MS);
});
it('clamps an oversized env override to the 24-hour ceiling (so Node does not silently downgrade the timer to 1ms)', () => {
process.env[ENV_KEY] = String(TWENTY_FOUR_HOURS_MS * 100);
expect(resolveChatRunArtifactQuietPeriodMs()).toBe(TWENTY_FOUR_HOURS_MS);
});
it('floors negative env overrides to 0 rather than scheduling a negative-delay timer', () => {
process.env[ENV_KEY] = '-1000';
expect(resolveChatRunArtifactQuietPeriodMs()).toBe(0);
});
it('honors env=0 to disable the artifact quiet-period entirely', () => {
process.env[ENV_KEY] = '0';
expect(resolveChatRunArtifactQuietPeriodMs()).toBe(0);
});
});
describe('live-artifact create → chat-run handle hook (#1451)', () => {
afterEach(() => {
// Avoid leaking handles into other test files that touch the same
// server-internal registry.
__forTestChatRunHandles.clear();
});
it('calls noteArtifactRegistered on the registered handle when emit fires with action="created"', () => {
// The boundary contract: emitLiveArtifactEvent must route a
// `created` action back into the chat run's quiet-period switch,
// not just into the project SSE stream. Without this hook the
// watchdog would never shorten, the user would still wait the
// full 10 minutes, and `Working` would still get stuck after the
// deliverable was already in the chat.
const noteArtifactRegistered = vi.fn();
__forTestChatRunHandles.set('run-1', { noteArtifactRegistered });
__forTestEmitLiveArtifactEvent(
{ runId: 'run-1', projectId: 'project-1' },
'created',
{ id: 'artifact-1', projectId: 'project-1', title: 'Daily digest' },
);
expect(noteArtifactRegistered).toHaveBeenCalledTimes(1);
});
it('does not call noteArtifactRegistered on "updated" — only the first registration shortens the watchdog', () => {
// An updated artifact is the same deliverable being rewritten,
// not a fresh handoff. The watchdog already switched the first
// time `created` fired (if it ever did); the chat run's
// own noteAgentActivity path handles activity-driven resets
// separately. Re-firing the hook here would be a no-op at best
// and a double-arming bug at worst.
const noteArtifactRegistered = vi.fn();
__forTestChatRunHandles.set('run-1', { noteArtifactRegistered });
__forTestEmitLiveArtifactEvent(
{ runId: 'run-1', projectId: 'project-1' },
'updated',
{ id: 'artifact-1', projectId: 'project-1' },
);
__forTestEmitLiveArtifactEvent(
{ runId: 'run-1', projectId: 'project-1' },
'deleted',
{ id: 'artifact-1', projectId: 'project-1' },
);
expect(noteArtifactRegistered).not.toHaveBeenCalled();
});
it('does not throw when no chat-run handle is registered for the runId (live-artifact emit from /api/projects path with no chat run)', () => {
expect(() =>
__forTestEmitLiveArtifactEvent(
{ runId: 'no-such-run', projectId: 'project-1' },
'created',
{ id: 'artifact-1', projectId: 'project-1' },
),
).not.toThrow();
});
it('does not throw when emit fires without a runId on the grant (project-scoped live-artifact emit)', () => {
// The same `emitLiveArtifactEvent` is used by background refresh
// routes where there is no owning chat run; passing a grant
// without a runId must be a no-op for the handle path.
const noteArtifactRegistered = vi.fn();
__forTestChatRunHandles.set('run-1', { noteArtifactRegistered });
expect(() =>
__forTestEmitLiveArtifactEvent(
{ projectId: 'project-1' },
'created',
{ id: 'artifact-1', projectId: 'project-1' },
),
).not.toThrow();
expect(noteArtifactRegistered).not.toHaveBeenCalled();
});
it('does not propagate exceptions thrown from the handle hook (the artifact emit must not fail because of a broken consumer)', () => {
// The chat run's noteArtifactRegistered touches local timer
// state; a future refactor could throw if e.g. the timer was
// already cleared mid-shutdown. The artifact-create endpoint
// already wrote the deliverable, so the HTTP response must not
// 500 because of a downstream hook failure.
__forTestChatRunHandles.set('run-1', {
noteArtifactRegistered: () => {
throw new Error('boom');
},
});
expect(() =>
__forTestEmitLiveArtifactEvent(
{ runId: 'run-1', projectId: 'project-1' },
'created',
{ id: 'artifact-1', projectId: 'project-1' },
),
).not.toThrow();
});
});
describe('resolveActiveInactivityTimeoutMs (#1451 quiet-period switch)', () => {
const TEN_MIN = 10 * 60 * 1000;
const ONE_MIN = 60 * 1000;
it('returns the pre-artifact ceiling when no artifact has been registered yet', () => {
expect(
resolveActiveInactivityTimeoutMs({
inactivityTimeoutMs: TEN_MIN,
artifactQuietPeriodMs: ONE_MIN,
artifactRegistered: false,
}),
).toBe(TEN_MIN);
});
it('switches to the quiet ceiling once an artifact has been registered', () => {
expect(
resolveActiveInactivityTimeoutMs({
inactivityTimeoutMs: TEN_MIN,
artifactQuietPeriodMs: ONE_MIN,
artifactRegistered: true,
}),
).toBe(ONE_MIN);
});
it('treats artifactQuietPeriodMs=0 as "disable the quiet period" — keeps the pre-artifact ceiling after registration', () => {
// The bug from the #2585 review: when an operator sets
// OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS=0, the prior implementation
// dropped the active ceiling to 0 once the artifact was registered,
// which made noteAgentActivity() early-return without rescheduling,
// stranding the pre-artifact timer. Falling back to the pre-artifact
// ceiling instead means subsequent activity keeps the timer fresh
// and the existing pre-artifact stalled-error path still works.
expect(
resolveActiveInactivityTimeoutMs({
inactivityTimeoutMs: TEN_MIN,
artifactQuietPeriodMs: 0,
artifactRegistered: true,
}),
).toBe(TEN_MIN);
});
it('keeps a 0 pre-artifact ceiling at 0 when no artifact is registered (watchdog fully disabled)', () => {
expect(
resolveActiveInactivityTimeoutMs({
inactivityTimeoutMs: 0,
artifactQuietPeriodMs: ONE_MIN,
artifactRegistered: false,
}),
).toBe(0);
});
it('honors a 0 pre-artifact ceiling after artifact registration when quiet is also 0 (both disabled)', () => {
expect(
resolveActiveInactivityTimeoutMs({
inactivityTimeoutMs: 0,
artifactQuietPeriodMs: 0,
artifactRegistered: true,
}),
).toBe(0);
});
});
describe('classifyChatRunCloseStatus (#1451 close-handler classification)', () => {
const base = {
cancelRequested: false,
code: 0 as number | null,
signal: null as string | null,
acpCleanCompletion: false,
artifactQuietShutdownRequested: false,
};
it('returns canceled when cancelRequested wins regardless of other signals', () => {
expect(
classifyChatRunCloseStatus({ ...base, cancelRequested: true, code: 0 }),
).toBe('canceled');
expect(
classifyChatRunCloseStatus({
...base,
cancelRequested: true,
code: null,
signal: 'SIGTERM',
artifactQuietShutdownRequested: true,
}),
).toBe('canceled');
});
it('returns succeeded on clean exit code 0', () => {
expect(classifyChatRunCloseStatus({ ...base, code: 0 })).toBe('succeeded');
});
it('returns succeeded on ACP forced shutdown (SIGTERM + clean ACP completion)', () => {
expect(
classifyChatRunCloseStatus({
...base,
code: null,
signal: 'SIGTERM',
acpCleanCompletion: true,
}),
).toBe('succeeded');
});
it('returns failed when ACP shutdown was via SIGKILL (not the narrow override)', () => {
expect(
classifyChatRunCloseStatus({
...base,
code: null,
signal: 'SIGKILL',
acpCleanCompletion: true,
}),
).toBe('failed');
});
it('returns succeeded when the watchdog-initiated quiet-period SIGTERM fires', () => {
expect(
classifyChatRunCloseStatus({
...base,
code: null,
signal: 'SIGTERM',
artifactQuietShutdownRequested: true,
}),
).toBe('succeeded');
});
it('returns succeeded when the watchdog quiet-period escalates to SIGKILL (kill-grace timer)', () => {
// After SIGTERM the inactivityKillGraceMs timer escalates to
// SIGKILL if the child has not exited yet. Both signals belong to
// the same daemon-initiated shutdown, so the close handler must
// accept either when the flag is set.
expect(
classifyChatRunCloseStatus({
...base,
code: null,
signal: 'SIGKILL',
artifactQuietShutdownRequested: true,
}),
).toBe('succeeded');
});
it('returns failed when SIGTERM/SIGKILL arrive but no quiet-period shutdown was requested', () => {
// The reviewer-correctness fix: external `kill`, OOM, container
// shutdown after a successful artifact registration must NOT be
// silently reclassified as `succeeded`. The previous version only
// checked `artifactRegistered` (true here, implied via the flag
// being false because we never called failForInactivity), which
// would have flipped these to succeeded incorrectly.
expect(
classifyChatRunCloseStatus({
...base,
code: null,
signal: 'SIGTERM',
artifactQuietShutdownRequested: false,
}),
).toBe('failed');
expect(
classifyChatRunCloseStatus({
...base,
code: null,
signal: 'SIGKILL',
artifactQuietShutdownRequested: false,
}),
).toBe('failed');
});
it('returns failed on a non-zero exit code regardless of the quiet-period flag', () => {
// The quiet-period override is signal-only; a clean process exit
// that returned non-zero is a real failure (agent CLI bug, model
// error, etc.) and must propagate as such.
expect(
classifyChatRunCloseStatus({
...base,
code: 1,
signal: null,
artifactQuietShutdownRequested: true,
}),
).toBe('failed');
});
it('returns failed on the standard failure shape (non-zero exit, no special flags)', () => {
expect(
classifyChatRunCloseStatus({ ...base, code: 1, signal: null }),
).toBe('failed');
});
});

View file

@ -1180,6 +1180,7 @@ setImmediate(() => process.exit(0));
const fs = require('node:fs');
fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({
CODEX_HOME: process.env.CODEX_HOME || null,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || null,
CODEX_API_KEY: process.env.CODEX_API_KEY || null,
SHOULD_NOT_PASS: process.env.OD_CONNECTION_TEST_SHOULD_NOT_PASS || null,
}));
@ -1187,6 +1188,11 @@ console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_messag
setImmediate(() => process.exit(0));
`,
async () => {
// CODEX_API_KEY only flows through when the user has also
// configured a custom OPENAI_BASE_URL — i.e. they intend to
// authenticate Codex CLI against a third-party gateway. Without
// the base URL, spawnEnvForAgent strips the credential so Codex
// CLI's own `codex login` wins (issue #2420).
const res = await realFetch(`${baseUrl}/api/test/connection`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@ -1196,6 +1202,7 @@ setImmediate(() => process.exit(0));
agentCliEnv: {
codex: {
CODEX_HOME: codexHome,
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
CODEX_API_KEY: 'codex-key',
OD_CONNECTION_TEST_SHOULD_NOT_PASS: 'leaked',
},
@ -1214,6 +1221,7 @@ setImmediate(() => process.exit(0));
await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe(
JSON.stringify({
CODEX_HOME: codexHome,
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
CODEX_API_KEY: 'codex-key',
SHOULD_NOT_PASS: null,
}),
@ -1225,6 +1233,64 @@ setImmediate(() => process.exit(0));
}
});
it('strips stale Codex API keys when no custom OPENAI_BASE_URL is configured', async () => {
const markerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-codex-strip-'));
const envFile = path.join(markerDir, 'env.json');
const codexHome = path.join(markerDir, 'codex-home');
try {
await withFakeCodex(
`
const fs = require('node:fs');
fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({
CODEX_HOME: process.env.CODEX_HOME || null,
OPENAI_API_KEY: process.env.OPENAI_API_KEY || null,
CODEX_API_KEY: process.env.CODEX_API_KEY || null,
}));
console.log(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'ok' } }));
setImmediate(() => process.exit(0));
`,
async () => {
// Simulates the user flow that triggered issue #2420: a stale
// BYOK OPENAI_API_KEY sat in agentCliEnv.codex from a previous
// session, the user cleared the BYOK dialog (which doesn't
// touch agentCliEnv) and switched back to Local CLI. Without
// an OPENAI_BASE_URL the daemon must keep the secret out of
// the spawn so Codex CLI's own `codex login` wins.
const res = await realFetch(`${baseUrl}/api/test/connection`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
mode: 'agent',
agentId: 'codex',
agentCliEnv: {
codex: {
CODEX_HOME: codexHome,
OPENAI_API_KEY: 'sk-stale-byok',
CODEX_API_KEY: 'sk-stale-byok',
},
},
}),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
ok: true,
kind: 'success',
agentName: 'Codex CLI',
});
await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe(
JSON.stringify({
CODEX_HOME: codexHome,
OPENAI_API_KEY: null,
CODEX_API_KEY: null,
}),
);
},
);
} finally {
await fsp.rm(markerDir, { recursive: true, force: true });
}
});
it('waits for the Codex process before accepting early success text', async () => {
await withFakeCodex(
`

View file

@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
closeDatabase,
insertConversation,
insertProject,
listMessages,
openDatabase,
upsertMessage,
} from '../src/db.js';
describe('preTurnFileNames persistence', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-db-pre-turn-'));
});
afterEach(() => {
closeDatabase();
rmSync(tempDir, { recursive: true, force: true });
});
function seedConversation(db: ReturnType<typeof openDatabase>) {
const now = Date.now();
insertProject(db, { id: 'proj-1', name: 'P', createdAt: now, updatedAt: now });
insertConversation(db, {
id: 'conv-1',
projectId: 'proj-1',
title: 'C',
createdAt: now,
updatedAt: now,
});
return now;
}
it('round-trips preTurnFileNames through upsert and listMessages', () => {
const db = openDatabase(tempDir, { dataDir: tempDir });
const now = seedConversation(db);
upsertMessage(db, 'conv-1', {
id: 'assistant-1',
role: 'assistant',
content: '',
runId: 'run-1',
runStatus: 'running',
startedAt: now,
preTurnFileNames: ['existing.html', 'README.md'],
});
const reloaded = listMessages(db, 'conv-1');
expect(reloaded).toHaveLength(1);
expect(reloaded[0]!.preTurnFileNames).toEqual(['existing.html', 'README.md']);
});
it('preserves preTurnFileNames across a subsequent UPDATE upsert that omits the field', () => {
const db = openDatabase(tempDir, { dataDir: tempDir });
const now = seedConversation(db);
upsertMessage(db, 'conv-1', {
id: 'assistant-1',
role: 'assistant',
content: '',
runId: 'run-1',
runStatus: 'running',
startedAt: now,
preTurnFileNames: ['existing.html'],
});
upsertMessage(db, 'conv-1', {
id: 'assistant-1',
role: 'assistant',
content: 'streamed chunk',
runId: 'run-1',
runStatus: 'running',
startedAt: now,
preTurnFileNames: ['existing.html'],
});
const [msg] = listMessages(db, 'conv-1');
expect(msg).toBeDefined();
expect(msg!.preTurnFileNames).toEqual(['existing.html']);
});
it('returns undefined when no baseline was ever written (legacy messages)', () => {
const db = openDatabase(tempDir, { dataDir: tempDir });
const now = seedConversation(db);
upsertMessage(db, 'conv-1', {
id: 'assistant-1',
role: 'assistant',
content: '',
runStatus: 'running',
startedAt: now,
});
const [msg] = listMessages(db, 'conv-1');
expect(msg).toBeDefined();
expect(msg!.preTurnFileNames).toBeUndefined();
});
});

View file

@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApiError } from '@open-design/contracts';
import { defineJsonRoute, err, mountJsonRoute, ok } from '../../src/http/index.js';
import { isLocalSameOrigin } from '../../src/origin-validation.js';
vi.mock('../../src/origin-validation.js', () => ({
isLocalSameOrigin: vi.fn(() => true),
}));
interface MockApp {
get: (path: string, handler: any) => void;
post: (path: string, handler: any) => void;
put: (path: string, handler: any) => void;
delete: (path: string, handler: any) => void;
patch: (path: string, handler: any) => void;
handlers: Record<string, (req: any, res: any) => Promise<void> | void>;
}
function makeApp(): MockApp {
const handlers: MockApp['handlers'] = {};
const make = (method: string) => (path: string, handler: any) => {
handlers[`${method.toUpperCase()} ${path}`] = handler;
};
return {
get: make('get'),
post: make('post'),
put: make('put'),
delete: make('delete'),
patch: make('patch'),
handlers,
};
}
function makeRes() {
return {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
};
}
const adapter = { resolvedPortRef: { current: 7456 } };
beforeEach(() => {
vi.mocked(isLocalSameOrigin).mockReturnValue(true);
});
describe('http adapter', () => {
it('parses input and returns the success payload', async () => {
const route = defineJsonRoute<{ value: string }, { echoed: string }, unknown>({
method: 'post',
path: '/echo',
parse: (raw) => ok({ value: String((raw.body as any).value) }),
handle: (input) => ok({ echoed: input.value }),
});
const app = makeApp();
mountJsonRoute(app as any, route, {}, adapter);
const res = makeRes();
await app.handlers['POST /echo']!({ body: { value: 'hi' }, query: {}, params: {} }, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ echoed: 'hi' });
});
it('returns 400 when parse fails', async () => {
const route = defineJsonRoute<{ value: string }, unknown, unknown>({
method: 'post',
path: '/missing',
parse: () => err(createApiError('BAD_REQUEST', 'required')),
handle: () => ok({}),
});
const app = makeApp();
mountJsonRoute(app as any, route, {}, adapter);
const res = makeRes();
await app.handlers['POST /missing']!({ body: {}, query: {}, params: {} }, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: { code: 'BAD_REQUEST', message: 'required' } });
});
it('maps a NOT_FOUND domain error to 404', async () => {
const route = defineJsonRoute<void, unknown, unknown>({
method: 'get',
path: '/missing',
parse: () => ok(undefined),
handle: () => err(createApiError('NOT_FOUND', 'gone')),
});
const app = makeApp();
mountJsonRoute(app as any, route, {}, adapter);
const res = makeRes();
await app.handlers['GET /missing']!({ body: {}, query: {}, params: {} }, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: { code: 'NOT_FOUND', message: 'gone' } });
});
it('blocks cross-origin requests when requireSameOrigin is set', async () => {
vi.mocked(isLocalSameOrigin).mockReturnValue(false);
const route = defineJsonRoute<void, { secret: number }, unknown>({
method: 'get',
path: '/secret',
requireSameOrigin: true,
parse: () => ok(undefined),
handle: () => ok({ secret: 42 }),
});
const app = makeApp();
mountJsonRoute(app as any, route, {}, adapter);
const res = makeRes();
await app.handlers['GET /secret']!({ body: {}, query: {}, params: {} }, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: { code: 'FORBIDDEN', message: 'cross-origin request rejected' },
});
});
it('catches thrown handler errors as INTERNAL_ERROR (500)', async () => {
const route = defineJsonRoute<void, unknown, unknown>({
method: 'get',
path: '/boom',
parse: () => ok(undefined),
handle: () => {
throw new Error('boom');
},
});
const app = makeApp();
mountJsonRoute(app as any, route, {}, adapter);
const res = makeRes();
await app.handlers['GET /boom']!({ body: {}, query: {}, params: {} }, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: { code: 'INTERNAL_ERROR', message: 'boom' },
});
});
it('passes deps through to the handler', async () => {
interface Deps {
tag: string;
}
const route = defineJsonRoute<void, { tag: string }, Deps>({
method: 'get',
path: '/deps',
parse: () => ok(undefined),
handle: (_input, deps) => ok({ tag: deps.tag }),
});
const app = makeApp();
mountJsonRoute(app as any, route, { tag: 'injected' }, adapter);
const res = makeRes();
await app.handlers['GET /deps']!({ body: {}, query: {}, params: {} }, res);
expect(res.json).toHaveBeenCalledWith({ tag: 'injected' });
});
});

View file

@ -476,6 +476,32 @@ test('codex json stream treats reconnect errors as status warnings not fatal (re
]);
});
test('codex json stream treats stream disconnect reconnect errors as status warnings not fatal', () => {
const { events, handler } = collectEvents('codex');
handler.feed(
JSON.stringify({ type: 'thread.started', thread_id: 'thr-1' }) + '\n' +
JSON.stringify({ type: 'turn.started' }) + '\n' +
JSON.stringify({
type: 'error',
message: 'Reconnecting... 2/5 (stream disconnected before completion: Connection reset by peer (os error 54))',
}) + '\n' +
JSON.stringify({ type: 'item.completed', item: { id: 'item-0', type: 'agent_message', text: 'OK' } }) + '\n' +
JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 5, output_tokens: 2, cached_input_tokens: 0 } }) + '\n',
);
assert.deepEqual(events, [
{ type: 'status', label: 'initializing' },
{ type: 'status', label: 'running' },
{
type: 'status',
label: 'Reconnecting... 2/5 (stream disconnected before completion: Connection reset by peer (os error 54))',
},
{ type: 'text_delta', delta: 'OK' },
{ type: 'usage', usage: { input_tokens: 5, output_tokens: 2, cached_read_tokens: 0 } },
]);
});
test('codex json stream still treats real errors as fatal after reconnect warnings', () => {
const { events, handler } = collectEvents('codex');
@ -489,3 +515,21 @@ test('codex json stream still treats real errors as fatal after reconnect warnin
{ type: 'error', message: 'Authentication failed: invalid API key' },
]);
});
test('codex json stream does not downgrade non-reconnect errors that mention reconnect text', () => {
const { events, handler } = collectEvents('codex');
handler.feed(
JSON.stringify({
type: 'error',
message: 'Authentication failed after Reconnecting... stream disconnected before completion',
}) + '\n',
);
assert.deepEqual(events, [
{
type: 'error',
message: 'Authentication failed after Reconnecting... stream disconnected before completion',
},
]);
});

View file

@ -0,0 +1,399 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { handleMcpToolCall } from '../src/mcp.js';
const originalFetch = globalThis.fetch;
function firstText(result: { content: Array<{ text: string }> }): string {
const item = result.content[0];
if (!item) throw new Error('expected MCP text content');
return item.text;
}
// mcp.ts caches the project list per baseUrl for 5 s, so two tests
// that share a baseUrl can see each other's fixtures and lookups fail
// in the second test. Hand each test its own baseUrl to keep the cache
// from leaking across cases.
let portCounter = 18000;
function nextBaseUrl(): string {
portCounter += 1;
return `http://127.0.0.1:${portCounter}`;
}
// Round out the MCP write surface. Today agents can `create_artifact`
// (which rejects existing targets) but cannot iterate on a file they
// already created, cannot delete a stale file, and cannot remove a
// throwaway project they spun up via `create_project` (#2356).
// Add three matching write tools so external coding agents can drive
// the full file/project lifecycle through MCP, not just the create
// half of it.
describe('public MCP write_file', () => {
afterEach(() => {
vi.unstubAllGlobals();
globalThis.fetch = originalFetch;
});
it('posts an overwrite-allowed write through the daemon files endpoint', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
if (url.endsWith('/api/projects')) {
return new Response(
JSON.stringify({ projects: [{ id: 'project-1', name: 'Demo' }] }),
{ status: 200 },
);
}
return new Response(
JSON.stringify({ file: { name: 'deck.html' } }),
{ status: 200 },
);
});
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall(base, 'write_file', {
project: 'Demo',
path: 'deck.html',
content: '<!doctype html><h1>v2</h1>',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
const [filesUrl, filesInit] = fetchMock.mock.calls[1]!;
expect(filesUrl).toBe(`${base}/api/projects/project-1/files`);
expect(filesInit?.method).toBe('POST');
const body = JSON.parse(String(filesInit?.body));
// The daemon's POST /api/projects/:id/files defaults to overwrite
// when neither `artifact: true` nor `overwrite: false` is present,
// which is what write_file must rely on. Spelling out the absence
// here would be brittle, so the deep-equal already enforces that
// both keys are omitted.
expect(body).toEqual({
name: 'deck.html',
content: '<!doctype html><h1>v2</h1>',
encoding: 'utf8',
});
expect(JSON.parse(firstText(result))).toMatchObject({
file: { name: 'deck.html' },
});
});
it('passes base64 encoding through unchanged', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
if (url.endsWith('/api/projects')) {
return new Response(
JSON.stringify({ projects: [{ id: 'p1', name: 'P' }] }),
{ status: 200 },
);
}
return new Response(JSON.stringify({ file: { name: 'logo.png' } }), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
await handleMcpToolCall(base, 'write_file', {
project: 'P',
path: 'logo.png',
content: 'AAA=',
encoding: 'base64',
});
const body = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body));
expect(body.encoding).toBe('base64');
});
it('uses the active project when project is omitted', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
if (url.endsWith('/api/active')) {
return new Response(
JSON.stringify({ active: true, projectId: 'active-1', projectName: 'Active', fileName: null }),
{ status: 200 },
);
}
return new Response(JSON.stringify({ file: { name: 'index.html' } }), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall(base, 'write_file', {
path: 'index.html',
content: '<!doctype html>',
});
expect(fetchMock.mock.calls[1]?.[0]).toBe(`${base}/api/projects/active-1/files`);
expect(JSON.parse(firstText(result))).toMatchObject({
usedActiveContext: { projectId: 'active-1' },
});
});
it('rejects missing path or content before hitting the network', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
if (url.endsWith('/api/projects')) {
return new Response(
JSON.stringify({ projects: [{ id: 'p1', name: 'P' }] }),
{ status: 200 },
);
}
return new Response('{}', { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const missingPath = await handleMcpToolCall(base, 'write_file', {
project: 'P',
content: 'x',
});
expect(missingPath).toMatchObject({ isError: true });
expect(firstText(missingPath)).toContain('path is required');
const missingContent = await handleMcpToolCall(base, 'write_file', {
project: 'P',
path: 'a.html',
});
expect(missingContent).toMatchObject({ isError: true });
expect(firstText(missingContent)).toContain('content is required');
// Neither call should have reached /files; resolveProjectArg may have
// hit /api/projects, which is harmless.
expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('/files'))).toBe(false);
});
});
describe('public MCP delete_file', () => {
afterEach(() => {
vi.unstubAllGlobals();
globalThis.fetch = originalFetch;
});
it('issues DELETE through the nested-path raw endpoint', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith('/api/projects')) {
return new Response(
JSON.stringify({ projects: [{ id: 'p1', name: 'Demo' }] }),
{ status: 200 },
);
}
expect(init?.method).toBe('DELETE');
return new Response(JSON.stringify({ ok: true }), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall(base, 'delete_file', {
project: 'Demo',
path: 'codex-product/index.html',
});
expect(fetchMock.mock.calls[1]?.[0]).toBe(
`${base}/api/projects/p1/raw/codex-product/index.html`,
);
expect(JSON.parse(firstText(result))).toMatchObject({ ok: true });
});
it('refuses to delete with no path supplied', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) =>
url.endsWith('/api/projects')
? new Response(JSON.stringify({ projects: [{ id: 'p1', name: 'Demo' }] }), { status: 200 })
: new Response('{}', { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall(base, 'delete_file', {
project: 'Demo',
});
expect(result).toMatchObject({ isError: true });
expect(firstText(result)).toContain('path is required');
// Should not have touched /raw/ at all.
expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('/raw/'))).toBe(false);
});
});
describe('public MCP delete_project', () => {
afterEach(() => {
vi.unstubAllGlobals();
globalThis.fetch = originalFetch;
});
it('issues DELETE /api/projects/:id when project + confirm are provided', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith('/api/projects')) {
return new Response(
JSON.stringify({ projects: [{ id: 'p1', name: 'Demo' }] }),
{ status: 200 },
);
}
expect(init?.method).toBe('DELETE');
return new Response(JSON.stringify({ ok: true }), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall(base, 'delete_project', {
project: 'Demo',
confirm: true,
});
expect(fetchMock.mock.calls[1]?.[0]).toBe(`${base}/api/projects/p1`);
expect(JSON.parse(firstText(result))).toMatchObject({ ok: true });
});
it('requires explicit confirm:true so agents cannot silently nuke a project', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) =>
url.endsWith('/api/projects')
? new Response(JSON.stringify({ projects: [{ id: 'p1', name: 'Demo' }] }), { status: 200 })
: new Response('{}', { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const missing = await handleMcpToolCall(base, 'delete_project', {
project: 'Demo',
});
expect(missing).toMatchObject({ isError: true });
expect(firstText(missing)).toContain('confirm');
const falseConfirm = await handleMcpToolCall(base, 'delete_project', {
project: 'Demo',
confirm: false,
});
expect(falseConfirm).toMatchObject({ isError: true });
// Neither call should have reached the DELETE endpoint.
expect(
fetchMock.mock.calls.some(
(call) =>
/\/api\/projects\/[^/]+$/.test(String(call[0])) &&
String(call[1]?.method ?? '').toUpperCase() === 'DELETE',
),
).toBe(false);
});
it('requires an explicit project — never falls back to the active project for delete', async () => {
const base = nextBaseUrl();
// No /api/projects call is needed; the tool should reject before
// resolving anything. We still stub fetch so an accidental call is
// obvious.
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
new Response('{}', { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall(base, 'delete_project', {
confirm: true,
});
expect(result).toMatchObject({ isError: true });
expect(firstText(result)).toMatch(/project (id|is required)/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('echoes resolvedProject when the caller passed a name substring', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith('/api/projects') && (!init || init.method === undefined || init.method === 'GET')) {
return new Response(
JSON.stringify({ projects: [{ id: 'p1', name: 'Throwaway demo' }] }),
{ status: 200 },
);
}
expect(init?.method).toBe('DELETE');
return new Response(JSON.stringify({ ok: true }), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
// 'throwaway' is a substring of 'Throwaway demo' — the tool accepts
// substrings per inputSchema, so the response must carry
// resolvedProject so the agent can confirm which row got destroyed.
const result = await handleMcpToolCall(base, 'delete_project', {
project: 'throwaway',
confirm: true,
});
expect(result).not.toMatchObject({ isError: true });
const body = JSON.parse(firstText(result as { content: Array<{ text: string }> }));
expect(body).toMatchObject({
ok: true,
resolvedProject: { id: 'p1', name: 'Throwaway demo' },
});
});
});
describe('formatDaemonError (shared error mapper)', () => {
afterEach(() => {
vi.unstubAllGlobals();
globalThis.fetch = originalFetch;
});
it('reformats a structured daemon error body as "code: message"', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string) =>
url.endsWith('/api/projects')
? new Response(JSON.stringify({ projects: [{ id: 'p1', name: 'Demo' }] }), { status: 200 })
: new Response(
JSON.stringify({ error: { code: 'FILE_NOT_FOUND', message: 'no such file' } }),
{ status: 404 },
),
);
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall(base, 'delete_file', {
project: 'Demo',
path: 'gone.html',
});
expect(result).toMatchObject({ isError: true });
const text = firstText(result as { content: Array<{ text: string }> });
// The mapper should fold the structured error into "code: message"
// and prefix the daemon status, so agents can branch on either.
expect(text).toContain('FILE_NOT_FOUND');
expect(text).toContain('no such file');
expect(text).toContain('404');
});
it('falls back to the raw body when the daemon does not return JSON', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string) =>
url.endsWith('/api/projects')
? new Response(JSON.stringify({ projects: [{ id: 'p1', name: 'Demo' }] }), { status: 200 })
: new Response('upstream boom', { status: 502 }),
);
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall(base, 'delete_file', {
project: 'Demo',
path: 'gone.html',
});
expect(result).toMatchObject({ isError: true });
const text = firstText(result as { content: Array<{ text: string }> });
// JSON.parse throws on the non-JSON body and the catch fallthrough
// must preserve the raw text so the agent still sees the upstream
// signal.
expect(text).toContain('upstream boom');
expect(text).toContain('502');
});
it('surfaces a non-2xx that is also non-JSON on delete_project', async () => {
const base = nextBaseUrl();
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith('/api/projects') && (!init || init.method === undefined || init.method === 'GET')) {
return new Response(
JSON.stringify({ projects: [{ id: 'p1', name: 'Demo' }] }),
{ status: 200 },
);
}
// 409 with structured body — verifies the irreversible tool's
// error path also flows through formatDaemonError.
return new Response(
JSON.stringify({ error: { code: 'PROJECT_LOCKED', message: 'in use' } }),
{ status: 409 },
);
});
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall(base, 'delete_project', {
project: 'Demo',
confirm: true,
});
expect(result).toMatchObject({ isError: true });
const text = firstText(result as { content: Array<{ text: string }> });
expect(text).toContain('PROJECT_LOCKED');
expect(text).toContain('in use');
expect(text).toContain('409');
});
});

View file

@ -12,6 +12,7 @@ import {
type OrbitRunHandler,
type OrbitTemplateSelection,
} from '../src/orbit.js';
import { skillCwdAliasSegment } from '../src/cwd-aliases.js';
function formatExpectedLocalOrbitPromptTimestamp(date: Date): string {
const yyyy = date.getFullYear();
@ -50,6 +51,22 @@ describe('buildOrbitPrompt', () => {
expect(prompt).not.toContain('Selected template example prompt:');
expect(prompt).not.toContain('Render the editorial bento dashboard.');
});
it('localizes the user-visible Orbit prompt when the app language is Chinese', () => {
const template: OrbitTemplateSelection = {
id: 'orbit-github',
name: 'orbit-github',
examplePrompt: 'Generate today\'s Open Orbit GitHub briefing.',
dir: path.join('/repo', 'skills', 'orbit-github'),
body: 'Mirror the shipped `example.html` before writing output.',
designSystemRequired: false,
};
const prompt = buildOrbitPrompt(new Date('2026-05-06T15:32:52.361Z'), template, 'zh-CN');
expect(prompt).toContain('请将今天的 Orbit 每日摘要制作成 Live Artifact。');
expect(prompt).toContain('使用已选中的 Orbit 模板orbit-github。');
});
});
describe('buildOrbitSystemPrompt', () => {
@ -64,11 +81,12 @@ describe('buildOrbitSystemPrompt', () => {
};
const prompt = buildOrbitSystemPrompt(new Date('2026-05-06T15:32:52.361Z'), template);
const stagedAlias = skillCwdAliasSegment(template.dir);
expect(prompt).toContain('Skill id: orbit-general');
expect(prompt).toContain('Staged root: .od-skills/orbit-general/');
expect(prompt).toContain('read ".od-skills/orbit-general/SKILL.md"');
expect(prompt).toContain('".od-skills/orbit-general/example.html"');
expect(prompt).toContain(`Staged root: .od-skills/${stagedAlias}/`);
expect(prompt).toContain(`read ".od-skills/${stagedAlias}/SKILL.md"`);
expect(prompt).toContain(`".od-skills/${stagedAlias}/example.html"`);
expect(prompt).toContain('visual/domain guidance');
expect(prompt).not.toContain('Selected template skill instructions:');
expect(prompt).toContain('Selected template example prompt:');
@ -103,6 +121,39 @@ describe('buildOrbitSystemPrompt', () => {
expect(prompt).toContain('Open and mirror the shipped `example.html`');
expect(prompt).toContain('Use exclusively the canvas tokens.');
});
it('pins Chinese as the final output language when the app locale is Chinese', () => {
const template: OrbitTemplateSelection = {
id: 'orbit-github',
name: 'orbit-github',
examplePrompt: 'Generate today\'s Open Orbit GitHub briefing.',
dir: path.join('/repo', 'skills', 'orbit-github'),
body: 'Mirror the shipped `example.html` before writing output.',
designSystemRequired: false,
};
const prompt = buildOrbitSystemPrompt(new Date('2026-05-06T15:32:52.361Z'), template, 'zh-CN');
expect(prompt).toContain('App language: Simplified Chinese (zh-CN).');
expect(prompt).toContain('The final Orbit artifact itself must stay in Simplified Chinese.');
expect(prompt).toContain('Generate today\'s Open Orbit GitHub briefing.');
});
it('treats script-tagged Traditional Chinese locales as zh-TW', () => {
const template: OrbitTemplateSelection = {
id: 'orbit-github',
name: 'orbit-github',
examplePrompt: 'Generate today\'s Open Orbit GitHub briefing.',
dir: path.join('/repo', 'skills', 'orbit-github'),
body: 'Mirror the shipped `example.html` before writing output.',
designSystemRequired: false,
};
const prompt = buildOrbitSystemPrompt(new Date('2026-05-06T15:32:52.361Z'), template, 'zh-Hant-TW');
expect(prompt).toContain('App language: Traditional Chinese (zh-TW).');
expect(prompt).toContain('The final Orbit artifact itself must stay in Traditional Chinese.');
});
});
describe('OrbitService', () => {
@ -144,6 +195,47 @@ describe('OrbitService', () => {
}
});
it('localizes the template example prompt passed to the run handler for Chinese Orbit runs', async () => {
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
try {
const service = new OrbitService(dataDir);
const captured: { request?: Parameters<OrbitRunHandler>[0] } = {};
service.setTemplateResolver(async () => ({
id: 'orbit-github',
name: 'orbit-github',
examplePrompt: 'Generate today\'s Open Orbit GitHub briefing.',
dir: path.join('/repo', 'skills', 'orbit-github'),
body: 'Mirror the shipped `example.html` before writing output.',
designSystemRequired: false,
}));
service.configure({ enabled: true, time: '08:00', templateSkillId: 'orbit-github' });
service.setRunHandler(async (request) => {
captured.request = request;
return {
projectId: 'project-1',
agentRunId: 'agent-1',
completion: Promise.resolve({
agentRunId: 'agent-1',
status: 'succeeded',
}),
};
});
await service.start('manual', { locale: 'zh-CN' });
let status = await service.status();
for (let attempt = 0; attempt < 10 && !status.lastRun; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
status = await service.status();
}
expect(captured.request?.template?.examplePrompt).toContain('生成今天的 Open Orbit GitHub 简报');
expect(captured.request?.systemPrompt).toContain('The final Orbit artifact itself must stay in Simplified Chinese.');
} finally {
await rm(dataDir, { recursive: true, force: true });
}
});
it('preserves the default template when config omits the field', async () => {
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
try {

View file

@ -561,3 +561,78 @@ describe('isLocalSameOrigin: OD_ALLOWED_ORIGINS bypass for reverse-proxy deploym
expect(isLocalSameOrigin(req, 7457, env)).toBe(false);
});
});
// Firefox and Chrome omit the Origin header on same-origin GET requests per
// the Fetch spec. When the daemon runs behind a remote-access proxy whose
// public hostname is listed in OD_ALLOWED_ORIGINS, those legitimate
// same-origin GETs (e.g. /api/app-config) get rejected by the no-Origin
// host check because hostname entries in OD_ALLOWED_ORIGINS are only
// honored via the IP-literal subset in that branch. Sec-Fetch-Site is set
// by the browser and cannot be modified by JavaScript, so a value of
// "same-origin" is a trustworthy substitute for the missing Origin header.
describe('isLocalSameOrigin: Sec-Fetch-Site fallback for no-Origin same-origin GETs', () => {
const ALLOWED = 'https://nas.example.ts.net';
const previousAllowedOrigins = process.env.OD_ALLOWED_ORIGINS;
const env: NodeJS.ProcessEnv = {
...process.env,
OD_ALLOWED_ORIGINS: ALLOWED,
OD_BIND_HOST: '127.0.0.1',
};
beforeAll(() => {
process.env.OD_ALLOWED_ORIGINS = ALLOWED;
});
afterAll(() => {
if (previousAllowedOrigins === undefined) delete process.env.OD_ALLOWED_ORIGINS;
else process.env.OD_ALLOWED_ORIGINS = previousAllowedOrigins;
});
it('accepts a no-Origin request whose Host matches OD_ALLOWED_ORIGINS when Sec-Fetch-Site is same-origin', () => {
const req = {
headers: {
host: 'nas.example.ts.net',
'sec-fetch-site': 'same-origin',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(true);
});
it('still rejects a no-Origin request whose Host matches the allow-list but Sec-Fetch-Site is cross-site', () => {
const req = {
headers: {
host: 'nas.example.ts.net',
'sec-fetch-site': 'cross-site',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(false);
});
it('still rejects a no-Origin request whose Host matches the allow-list but Sec-Fetch-Site is same-site', () => {
const req = {
headers: {
host: 'nas.example.ts.net',
'sec-fetch-site': 'same-site',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(false);
});
it('still rejects a no-Origin request whose Host is foreign even with Sec-Fetch-Site: same-origin (Host alone is forgeable)', () => {
const req = {
headers: {
host: 'evil.example.com',
'sec-fetch-site': 'same-origin',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(false);
});
it('preserves no-Sec-Fetch-Site rejection (older / non-browser clients fall back to host-only check)', () => {
const req = {
headers: {
host: 'nas.example.ts.net',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(false);
});
});

View file

@ -0,0 +1,43 @@
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const discoveryAtomPath = fileURLToPath(
new URL('../../../plugins/_official/atoms/discovery-question-form/SKILL.md', import.meta.url),
);
const simpleDeckManifestPath = fileURLToPath(
new URL('../../../plugins/_official/examples/simple-deck/open-design.json', import.meta.url),
);
describe('bundled discovery-question-form atom prompt contract', () => {
it('is included in Simple Deck before generation starts', async () => {
const manifest = JSON.parse(await readFile(simpleDeckManifestPath, 'utf8')) as {
od?: { pipeline?: { stages?: Array<{ id?: string; atoms?: string[] }> } };
};
const stages = manifest.od?.pipeline?.stages ?? [];
expect(stages.map((stage) => stage.id)).toEqual(['discovery', 'generate']);
expect(stages[0]?.atoms).toContain('discovery-question-form');
expect(stages[1]?.atoms).toEqual(['file-write', 'live-artifact']);
});
it('teaches agents to emit the wrapped question-form renderer contract', async () => {
const body = await readFile(discoveryAtomPath, 'utf8');
expect(body).toContain('<question-form id="discovery"');
expect(body).toContain('"questions": [');
expect(body).toContain('</question-form>');
expect(body).toMatch(/Do not emit a bare question object by itself/);
});
it('does not present a bare keyed question JSON object as the canonical emission shape', async () => {
const body = await readFile(discoveryAtomPath, 'utf8');
const emissionShape = body.slice(
body.indexOf('## Emission shape'),
body.indexOf('## Question object shape'),
);
expect(emissionShape).not.toMatch(/```jsonc\s*\{\s*"id":/s);
expect(body).not.toMatch(/```jsonc[\s\S]*"id": "audience"/);
});
});

View file

@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const testDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(testDir, '../../../..');
const promptPaths = [
'packages/contracts/src/prompts/discovery.ts',
'apps/daemon/src/prompts/discovery.ts',
] as const;
const languageMatchRule =
"Match the user's chat language. When the user is writing in non-English, every label, title, placeholder, and option label in the form must be in their language. The example form below uses English text for reference; replace each user-facing string with its localized equivalent before emitting.";
const localizationBullet =
"- Localize every user-facing string in the form (\\`title\\`, \\`description\\`, the per-question \\`label\\`, \\`placeholder\\`, and option \\`label\\`s) to the user's chat language. \\`id\\`, \\`type\\`, option \\`value\\`, and the stable branch values (\\`pick_direction\\`, \\`brand_spec\\`, \\`reference_match\\`) MUST stay in English because later branch rules match against them.";
describe('discovery prompt localization rules', () => {
it.each(promptPaths)('%s includes the localized form wording', (promptPath) => {
const source = readFileSync(resolve(repoRoot, promptPath), 'utf8');
expect(source).toContain(languageMatchRule);
expect(source).toContain(localizationBullet);
});
});

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { DISCOVERY_AND_PHILOSOPHY } from '../../src/prompts/discovery.js';
// The system prompt historically told the model to write "a plan of 510 short
// imperative items". That upper bound caused the agent to cap every plan at
// exactly ten steps and then stop or skip additional items — even when the task
// genuinely needed more. There is no maxItems constraint in the upstream
// TodoWrite JSON schema (the array is unbounded), so the cap is entirely
// prompt-driven and can be removed here.
//
// This test locks the absence of the cap so a future prompt edit cannot
// accidentally re-introduce the "510" or "5 to 10" wording.
describe('discovery.ts RULE 3 — TodoWrite plan item count', () => {
it('does not cap the plan at 10 items via "510" wording', () => {
// The old wording was "a plan of 510 short imperative items".
// After the fix the sentence must not mention an upper bound of 10.
expect(DISCOVERY_AND_PHILOSOPHY).not.toMatch(/5[\-]10\s+short\s+imperative/);
});
it('does not cap the plan at 10 items via "5 to 10" wording', () => {
expect(DISCOVERY_AND_PHILOSOPHY).not.toMatch(/5 to 10\s+(?:short\s+)?items/i);
});
it('does not re-introduce a numeric cap via "at most / maximum / no more than" phrasing', () => {
// Guard against semantically equivalent upper-bound re-introduction.
expect(DISCOVERY_AND_PHILOSOPHY).not.toMatch(
/(?:at most|maximum|no more than)\s+1[0-9]\s+(?:todo|plan|step|item)/i,
);
});
it('still instructs the agent to write at least a few items', () => {
// The intent — plan with TodoWrite before building — must survive the fix.
expect(DISCOVERY_AND_PHILOSOPHY).toContain('TodoWrite');
expect(DISCOVERY_AND_PHILOSOPHY).toContain('RULE 3');
});
});

View file

@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { composeSystemPrompt } from '../../src/prompts/system.js';
import { composeSystemPrompt, resolveExclusiveSurface } from '../../src/prompts/system.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -81,6 +81,64 @@ describe('composeSystemPrompt — activeStageBlocks splice (spec §23.4)', () =>
});
describe('composeSystemPrompt', () => {
it('injects Chinese quick brief guidance when the UI locale is zh-CN', () => {
const prompt = composeSystemPrompt({ locale: 'zh-CN' });
expect(prompt).toContain('# UI locale override');
expect(prompt).toContain('`zh-CN` (Simplified Chinese)');
expect(prompt).toContain('快速简报 — 30 秒');
expect(prompt).toContain('目标用户');
expect(prompt).toContain('视觉调性');
expect(prompt).toContain('Keep machine-readable ids and object option `value` fields exact and unlocalized');
});
it('preserves canonical default task-type options under locale overrides', () => {
const prompt = composeSystemPrompt({ locale: 'zh-CN' });
expect(prompt).toContain(
'keep the `taskType` option labels as the canonical routing choices',
);
for (const option of [
'Prototype',
'Live artifact',
'Slide deck',
'Image',
'Video',
'HyperFrames',
'Audio',
'Other',
]) {
expect(prompt).toContain(`"${option}"`);
}
expect(prompt).not.toContain('option labels as `原型`');
expect(prompt).not.toContain('`实时作品`');
});
it('preserves canonical default task-type options for zh-TW locale overrides', () => {
const prompt = composeSystemPrompt({ locale: 'zh-TW' });
expect(prompt).toContain('# UI locale override');
expect(prompt).toContain('`zh-TW` (Traditional Chinese)');
expect(prompt).toContain(
'keep the `taskType` option labels as the canonical routing choices',
);
for (const option of [
'Prototype',
'Live artifact',
'Slide deck',
'Image',
'Video',
'HyperFrames',
'Audio',
'Other',
]) {
expect(prompt).toContain(`"${option}"`);
}
expect(prompt).not.toContain('快速简报 — 30 秒');
expect(prompt).not.toContain('option labels as `原型`');
expect(prompt).not.toContain('`实时作品`');
});
it('treats an active design system as the visual direction', () => {
const prompt = composeSystemPrompt({
designSystemTitle: 'ComfyUI',
@ -190,6 +248,34 @@ describe('composeSystemPrompt', () => {
expect(prompt).not.toContain('**platformTargets**');
});
it('uses the primary skill surface when composed skill modes conflict', () => {
const prompt = composeSystemPrompt({
skillMode: 'image',
skillModes: ['deck', 'image'],
});
expect(prompt).toContain('## Media generation contract');
expect(prompt).not.toContain('# Slide deck — fixed framework');
});
it('lets metadata.kind win over conflicting composed skill modes', () => {
const prompt = composeSystemPrompt({
skillMode: 'image',
skillModes: ['deck', 'image'],
metadata: { kind: 'deck' } as any,
});
expect(prompt).toContain('# Slide deck — fixed framework');
expect(prompt).not.toContain('## Media generation contract');
});
it('resolves a non-media primary surface ahead of composed media mentions', () => {
expect(resolveExclusiveSurface({
skillMode: 'deck',
skillModes: ['deck', 'image'],
})).toBe('deck');
});
describe('artifact handoff no-emit clauses (#1143)', () => {
it('drops the absolute "non-negotiable" framing in favor of conditional language', () => {
const prompt = composeSystemPrompt({});

View file

@ -849,6 +849,63 @@ describe('API proxy routes', () => {
expect(toolMsg.content).toMatch(/sensitive_content_blocked/);
});
it('feeds speech-specific tool error copy when generate_speech arguments are malformed', async () => {
const upstreamChatBodies: any[] = [];
let chatCallIndex = 0;
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
const url = String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
if (url === 'https://api.senseaudio.cn/v1/chat/completions') {
upstreamChatBodies.push(JSON.parse(String(init?.body || '{}')));
chatCallIndex++;
if (chatCallIndex === 1) {
return sseResponse([
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_speech_bad_args","type":"function","function":{"name":"generate_speech","arguments":"{\\"text\\":"}}]},"finish_reason":null}]}',
'',
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
'',
'data: [DONE]',
'',
].join('\n'));
}
return sseResponse([
'data: {"choices":[{"index":0,"delta":{"content":"I need a valid script before generating speech."}}]}',
'',
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
'',
'data: [DONE]',
'',
].join('\n'));
}
throw new Error(`unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const res = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
baseUrl: 'https://api.senseaudio.cn',
apiKey: 'sa-test',
projectId: 'test-project',
model: 'senseaudio-s2',
messages: [{ role: 'user', content: 'make a voiceover' }],
}),
});
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain('I need a valid script before generating speech.');
expect(upstreamChatBodies).toHaveLength(2);
const toolMsg = upstreamChatBodies[1].messages[2];
expect(toolMsg.role).toBe('tool');
expect(toolMsg.tool_call_id).toBe('call_speech_bad_args');
expect(toolMsg.content).toMatch(/Speech generation failed/);
expect(toolMsg.content).toMatch(/tool arguments were not valid JSON/);
expect(toolMsg.content).not.toMatch(/Image generation failed/);
});
it('bounds the BYOK tool loop at MAX_BYOK_TOOL_LOOPS=3', async () => {
let chatCallIndex = 0;
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {

View file

@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest';
import { analyseCtaHierarchy } from '../src/qa/cta-hierarchy.js';
// These tests pin down the first-useful-version contract for the CTA hierarchy
// static QA pass. The function is intentionally conservative: precision > recall.
// Adding new heuristics is fine, but they must not regress any case here.
describe('analyseCtaHierarchy', () => {
it('returns an empty report when the document has no buttons or anchors', () => {
const report = analyseCtaHierarchy('<main><p>Welcome to the site</p></main>');
expect(report.issues).toEqual([]);
expect(report.primaryCount).toBe(0);
expect(report.secondaryCount).toBe(0);
});
it('does not flag a single clear primary CTA paired with a secondary CTA', () => {
const html = `
<section>
<a class="btn btn-primary" href="/signup">Get started</a>
<a class="btn" href="/learn-more">Learn more</a>
</section>
`;
const report = analyseCtaHierarchy(html);
expect(report.issues).toEqual([]);
expect(report.primaryCount).toBe(1);
expect(report.secondaryCount).toBe(1);
});
it('flags two primary CTAs sharing the same section as multiple-primary', () => {
const html = `
<section>
<a class="btn btn-primary" href="/signup">Sign up</a>
<a class="btn btn-primary" href="/buy">Buy now</a>
</section>
`;
const report = analyseCtaHierarchy(html);
const kinds = report.issues.map((issue) => issue.kind);
expect(kinds).toContain('multiple-primary');
expect(report.primaryCount).toBe(2);
// The selector should be short enough to surface in a one-line UI hint.
const offender = report.issues.find((issue) => issue.kind === 'multiple-primary');
expect(offender?.selector.length ?? 0).toBeLessThan(80);
});
it('flags ambiguous-weight when every CTA is rendered with identical class and inline style', () => {
const html = `
<header>
<a class="btn" href="/a">Get started</a>
<a class="btn" href="/b">Subscribe</a>
<a class="btn" href="/c">Buy</a>
</header>
`;
const report = analyseCtaHierarchy(html);
const kinds = report.issues.map((issue) => issue.kind);
expect(kinds).toContain('ambiguous-weight');
// None of these were tagged primary, so primaryCount stays at zero.
expect(report.primaryCount).toBe(0);
expect(report.secondaryCount).toBe(3);
});
it('flags misleading-prominence when secondary-coded copy uses solid primary styling', () => {
const html = `
<section>
<a class="btn btn-primary" href="/buy">Buy now</a>
<a class="btn" style="background-color: #1d4ed8; color: white; font-size: 20px" href="/learn-more">Learn more</a>
</section>
`;
const report = analyseCtaHierarchy(html);
const misleading = report.issues.find((issue) => issue.kind === 'misleading-prominence');
expect(misleading).toBeDefined();
expect(misleading?.message.toLowerCase()).toContain('learn more');
});
it('detects Chinese CTA copy such as "立即购买" and applies the same heuristics', () => {
const html = `
<section>
<button class="btn btn-primary"></button>
<button class="btn btn-primary"></button>
</section>
`;
const report = analyseCtaHierarchy(html);
const kinds = report.issues.map((issue) => issue.kind);
expect(kinds).toContain('multiple-primary');
expect(report.primaryCount).toBe(2);
});
it('treats inline background-color as a primary-weight signal even without a primary class', () => {
// Mirrors the issue #2251 inverse: a "btn" element styled with a solid
// accent color is still effectively a primary CTA in the rendered page.
const html = `
<section>
<a class="btn" style="background-color: #1d4ed8; color: white"></a>
<a class="btn btn-primary" href="/checkout"></a>
</section>
`;
const report = analyseCtaHierarchy(html);
expect(report.primaryCount).toBe(2);
expect(report.issues.map((issue) => issue.kind)).toContain('multiple-primary');
});
it('ignores non-CTA buttons such as icon toggles with no actionable copy', () => {
// Buttons without CTA-style copy (e.g. a "+" toggle) should not be picked
// up as CTA candidates; otherwise the hierarchy checks become noisy.
const html = `
<section>
<button class="btn">+</button>
<button class="btn"></button>
</section>
`;
const report = analyseCtaHierarchy(html);
expect(report.issues).toEqual([]);
expect(report.primaryCount).toBe(0);
expect(report.secondaryCount).toBe(0);
});
it('does not collapse sibling <div> wrappers without a landmark ancestor', () => {
// Flat card-grid layout with no landmark ancestor: two sibling
// <div>s each carry one primary CTA. With a tag-only parent
// fallback ("parent:div") both CTAs land in the same bucket and
// detectMultiplePrimary() reports a fake shared-section conflict.
// The container key must include the parent's identity, not just
// its tag name.
const html = `
<div>
<div><a class="btn btn-primary" href="/a">Get started</a></div>
<div><a class="btn btn-primary" href="/b">Sign up</a></div>
</div>
`;
const report = analyseCtaHierarchy(html);
const kinds = report.issues.map((issue) => issue.kind);
expect(kinds).not.toContain('multiple-primary');
expect(report.primaryCount).toBe(2);
});
it('does not flag ambiguous-weight when two unrelated sections each contain a single .btn CTA', () => {
// Cross-section signature coincidence should not be a hierarchy
// warning: each section has only one CTA, so there is no
// "everything in this container looks the same" condition to
// satisfy. The rule must respect container boundaries.
const html = `
<article>
<section><a class="btn" href="/a">Get started</a></section>
<section><a class="btn" href="/b">Subscribe</a></section>
</article>
`;
const report = analyseCtaHierarchy(html);
const kinds = report.issues.map((issue) => issue.kind);
expect(kinds).not.toContain('ambiguous-weight');
});
});

View file

@ -94,6 +94,77 @@ describe('chat run service shutdown', () => {
});
});
describe('chat run service stream replay', () => {
it('always replays the final event when a reattaching client cursor is at the end of a terminal run', () => {
const sendCalls: Array<{ event: string; data: unknown; id: number }> = [];
const endCalls: number[] = [];
const runs = createChatRunService({
createSseResponse: () => ({
send: vi.fn((event: string, data: unknown, id: number) => {
sendCalls.push({ event, data, id });
return true;
}),
end: vi.fn(() => endCalls.push(1)),
cleanup: vi.fn(),
}),
createSseErrorPayload: (code: string, message: string) => ({ error: { code, message } }),
shutdownGraceMs: 10,
ttlMs: 60_000,
});
const run = runs.create({ projectId: 'p', conversationId: 'c' }) as any;
runs.emit(run, 'stdout', { text: 'hello' });
runs.finish(run, 'succeeded', 0, null);
const finalEventId = run.events.at(-1).id;
const fakeReq = {
get: () => null,
query: { after: String(finalEventId) },
} as never;
const fakeRes = { on: () => {} } as never;
sendCalls.length = 0;
runs.stream(run, fakeReq, fakeRes);
expect(sendCalls.length).toBeGreaterThanOrEqual(1);
expect(sendCalls.at(-1)?.event).toBe('end');
expect(endCalls.length).toBe(1);
});
it('does not duplicate events when the cursor sits before the final event', () => {
const sendCalls: Array<{ event: string; data: unknown; id: number }> = [];
const runs = createChatRunService({
createSseResponse: () => ({
send: vi.fn((event: string, data: unknown, id: number) => {
sendCalls.push({ event, data, id });
return true;
}),
end: vi.fn(),
cleanup: vi.fn(),
}),
createSseErrorPayload: (code: string, message: string) => ({ error: { code, message } }),
shutdownGraceMs: 10,
ttlMs: 60_000,
});
const run = runs.create() as any;
runs.emit(run, 'stdout', { text: 'a' });
runs.emit(run, 'stdout', { text: 'b' });
runs.finish(run, 'succeeded', 0, null);
const cursor = run.events[0].id;
runs.stream(
run,
{ get: () => null, query: { after: String(cursor) } } as never,
{ on: () => {} } as never,
);
expect(sendCalls.map((c) => c.id)).toEqual(
run.events.filter((e: { id: number }) => e.id > cursor).map((e: { id: number }) => e.id),
);
});
});
function createRuns() {
return createChatRunService({
createSseResponse: () => ({

View file

@ -0,0 +1,91 @@
// Regression coverage for issue #2297: when a single agent's launch
// resolution throws (e.g. a transient filesystem error during PATH
// walking on Windows packaged builds), `detectAgents()` used to reject
// the whole `Promise.all` and the `/api/agents` route caught it back to
// an empty array. The UI then showed only the cloud/BYOK fallback even
// though every other CLI was healthy. These tests pin the per-probe
// isolation invariant: one broken adapter must not blank the picker.
import { afterEach, expect, test, vi } from 'vitest';
vi.mock('../../src/runtimes/launch.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/runtimes/launch.js')>();
return {
...actual,
resolveAgentLaunch: vi.fn(actual.resolveAgentLaunch),
applyAgentLaunchEnv: vi.fn(actual.applyAgentLaunchEnv),
};
});
import * as launchModule from '../../src/runtimes/launch.js';
import { detectAgents } from '../../src/runtimes/detection.js';
import { AGENT_DEFS } from '../../src/runtimes/registry.js';
const mockedResolveAgentLaunch = vi.mocked(launchModule.resolveAgentLaunch);
const mockedApplyAgentLaunchEnv = vi.mocked(launchModule.applyAgentLaunchEnv);
const originalResolveImpl = mockedResolveAgentLaunch.getMockImplementation()!;
const originalApplyImpl = mockedApplyAgentLaunchEnv.getMockImplementation()!;
afterEach(() => {
mockedResolveAgentLaunch.mockImplementation(originalResolveImpl);
mockedApplyAgentLaunchEnv.mockImplementation(originalApplyImpl);
});
test('detectAgents isolates a single agent probe throw so the picker still lists every adapter', async () => {
// Simulate the failure shape that issue #2297 attributes to packaged
// Windows daemons: one adapter's launch resolution throws because the
// FS walk hits a permission error, a malformed PATH entry, or a
// broken symlink (`accessSync`/`readdirSync` can surface either as a
// throw). Before the per-probe guard the whole `Promise.all`
// rejected; the `/api/agents` route's `catch(() => [])` then handed
// the UI an empty list and the model picker collapsed to Cloud only.
mockedResolveAgentLaunch.mockImplementation((def, env) => {
if (def.id === 'claude') {
throw new Error('synthetic FS throw during PATH walk');
}
return originalResolveImpl(def, env);
});
const agents = await detectAgents();
// Every adapter from the registry must still appear, including the
// one whose probe blew up — it just gets surfaced as unavailable so
// the rest of the picker (and the BYOK fallback row) stays intact.
expect(agents.length).toBe(AGENT_DEFS.length);
const claude = agents.find((a) => a.id === 'claude');
expect(claude, 'broken adapter must still appear in the detection result').toBeDefined();
expect(claude?.available).toBe(false);
// And the other adapters must keep whatever availability the real
// probe would have returned — none of them get blanket-marked
// unavailable just because claude's slot threw.
expect(agents.filter((a) => a.id !== 'claude').length).toBe(AGENT_DEFS.length - 1);
});
test('detectAgents isolates a probe throw from applyAgentLaunchEnv just like resolveAgentLaunch', async () => {
// The same per-probe guard has to cover both synchronous sites in
// `probe()` that sit outside the existing inner try/catch blocks:
// `resolveAgentLaunch` (above) and `applyAgentLaunchEnv` (here). A
// single test for each anchors the invariant against future code
// that adds a third pre-try synchronous call.
let thrown = false;
mockedApplyAgentLaunchEnv.mockImplementation((env, launch, nodeBinDir) => {
// We cannot tell which agent this env belongs to from arguments
// alone, so throw on the first call and pass through afterwards.
// detectAgents runs probes in parallel via Promise.all so the
// "first" call is non-deterministic but for fault-isolation that
// does not matter — any single throw blanking the whole list is
// the bug.
if (!thrown) {
thrown = true;
throw new Error('synthetic env construction error');
}
return originalApplyImpl(env, launch, nodeBinDir);
});
const agents = await detectAgents();
expect(agents.length).toBe(AGENT_DEFS.length);
// At least one adapter is marked unavailable because of the throw;
// every other adapter keeps its real availability.
const unavailableFromThrow = agents.filter((a) => a.available === false);
expect(unavailableFromThrow.length).toBeGreaterThanOrEqual(1);
});

View file

@ -567,6 +567,161 @@ test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', ()
}
});
// Issue #2420: Codex CLI prefers OPENAI_API_KEY / CODEX_API_KEY over its own
// `codex login` OAuth credentials when both are set. When the user has not
// pointed Codex at a custom proxy via OPENAI_BASE_URL, a stale BYOK key
// silently outranks `~/.codex/auth.json` and trips 401 invalid_api_key.
// Strip the API keys in that case so Codex CLI's own auth resolution wins —
// mirroring the existing ANTHROPIC_API_KEY behavior the claude adapter has
// for issue #398.
test('spawnEnvForAgent strips OPENAI_API_KEY for the codex adapter when OPENAI_BASE_URL is absent', () => {
const env = spawnEnvForAgent('codex', {
OPENAI_API_KEY: 'sk-stale-byok',
PATH: '/usr/bin',
OD_DAEMON_URL: 'http://127.0.0.1:7456',
});
assert.equal('OPENAI_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
assert.equal(env.OD_DAEMON_URL, 'http://127.0.0.1:7456');
});
test('spawnEnvForAgent strips CODEX_API_KEY for the codex adapter when OPENAI_BASE_URL is absent', () => {
const env = spawnEnvForAgent('codex', {
CODEX_API_KEY: 'sk-stale-byok',
PATH: '/usr/bin',
});
assert.equal('CODEX_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent strips Codex API keys when OPENAI_BASE_URL is empty', () => {
const env = spawnEnvForAgent('codex', {
OPENAI_API_KEY: 'sk-stale-byok',
CODEX_API_KEY: 'sk-stale-byok',
OPENAI_BASE_URL: '',
PATH: '/usr/bin',
});
assert.equal('OPENAI_API_KEY' in env, false);
assert.equal('CODEX_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent strips Codex API keys when OPENAI_BASE_URL is whitespace', () => {
const env = spawnEnvForAgent('codex', {
OPENAI_API_KEY: 'sk-stale-byok',
OPENAI_BASE_URL: ' ',
PATH: '/usr/bin',
});
assert.equal('OPENAI_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves Codex API keys when OPENAI_BASE_URL is set to a custom proxy', () => {
const env = spawnEnvForAgent('codex', {
OPENAI_API_KEY: 'sk-proxy',
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
PATH: '/usr/bin',
});
assert.equal(env.OPENAI_API_KEY, 'sk-proxy');
assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves CODEX_API_KEY when OPENAI_BASE_URL is set to a custom proxy', () => {
const env = spawnEnvForAgent('codex', {
CODEX_API_KEY: 'sk-proxy',
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
PATH: '/usr/bin',
});
assert.equal(env.CODEX_API_KEY, 'sk-proxy');
assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1');
});
test('spawnEnvForAgent strips Codex API keys case-insensitively when OPENAI_BASE_URL is absent', () => {
const env = spawnEnvForAgent('codex', {
Openai_Api_Key: 'sk-mixed-case',
openai_api_key: 'sk-lower-case',
Codex_Api_Key: 'sk-mixed-case',
PATH: '/usr/bin',
});
const remainingOpenAi = Object.keys(env).filter(
(k) => k.toUpperCase() === 'OPENAI_API_KEY',
);
const remainingCodex = Object.keys(env).filter(
(k) => k.toUpperCase() === 'CODEX_API_KEY',
);
assert.deepEqual(remainingOpenAi, []);
assert.deepEqual(remainingCodex, []);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves Codex API keys for non-codex adapters', () => {
for (const agentId of ['claude', 'gemini', 'opencode', 'devin']) {
const env = spawnEnvForAgent(agentId, {
OPENAI_API_KEY: 'sk-keep',
CODEX_API_KEY: 'sk-keep',
PATH: '/usr/bin',
});
assert.equal(
env.OPENAI_API_KEY,
'sk-keep',
`expected ${agentId} to preserve OPENAI_API_KEY`,
);
assert.equal(
env.CODEX_API_KEY,
'sk-keep',
`expected ${agentId} to preserve CODEX_API_KEY`,
);
}
});
// When the user has explicitly configured a BYOK Codex base URL through the
// Settings → Execution mode → Local CLI form, the configured API key in
// `agentCliEnv.codex.OPENAI_API_KEY` (or CODEX_API_KEY) flows through to the
// spawn alongside the base URL. The stripping helper must keep both in sync
// so the configured proxy actually authenticates.
test('spawnEnvForAgent applies configured codex env and preserves API key when base URL is configured', () => {
const env = spawnEnvForAgent(
'codex',
{ PATH: '/usr/bin' },
{
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
OPENAI_API_KEY: 'sk-configured',
},
);
assert.equal(env.OPENAI_BASE_URL, 'https://proxy.example.com/v1');
assert.equal(env.OPENAI_API_KEY, 'sk-configured');
});
// The dual-key shape every BYOK Codex user hits in production: prior session
// left OPENAI_API_KEY in the daemon's app-config, the user cleared the BYOK
// dialog but never opened Settings → Local CLI → Codex env to also clear
// OPENAI_API_KEY, then switched execution mode back to Local CLI. spawnEnv
// must strip the stale BYOK key so Codex CLI's own `codex login` wins.
test('spawnEnvForAgent strips stale configured OPENAI_API_KEY when configured base URL was also cleared', () => {
const env = spawnEnvForAgent(
'codex',
{ PATH: '/usr/bin' },
{
// Empty OPENAI_BASE_URL — i.e. user is on Local CLI mode without a
// custom proxy. validateAgentCliEnv would drop the empty string in
// practice; we pass it explicitly here to lock the spawn-side guard.
OPENAI_API_KEY: 'sk-stale-byok',
},
);
assert.equal('OPENAI_API_KEY' in env, false);
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when ANTHROPIC_BASE_URL is set', () => {
const env = spawnEnvForAgent('claude', {
ANTHROPIC_API_KEY: 'sk-kimi',

View file

@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest';
import { rmSync } from 'node:fs';
import { SKILLS_CWD_ALIAS } from '../src/cwd-aliases.js';
import { skillCwdAliasSegment, SKILLS_CWD_ALIAS } from '../src/cwd-aliases.js';
import { readFileSync } from 'node:fs';
import {
deleteUserSkill,
@ -134,14 +134,15 @@ describe('listSkills', () => {
previewType: 'html',
});
expect(skill.triggers.length).toBeGreaterThan(0);
const liveArtifactAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(liveArtifactRoot)}`;
expect(skill.body).toContain(`> **Skill root (absolute fallback):** \`${liveArtifactRoot}\``);
expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/live-artifact/`);
expect(skill.body).toContain(`${liveArtifactAlias}/`);
expect(skill.body).toContain('references/artifact-schema.md');
expect(skill.body).toContain('references/connector-policy.md');
expect(skill.body).toContain('references/refresh-contract.md');
expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/live-artifact/references/artifact-schema.md`);
expect(skill.body).not.toContain(`${SKILLS_CWD_ALIAS}/live-artifact/assets/template.html`);
expect(skill.body).not.toContain(`${SKILLS_CWD_ALIAS}/live-artifact/references/layouts.md`);
expect(skill.body).toContain(`${liveArtifactAlias}/references/artifact-schema.md`);
expect(skill.body).not.toContain(`${liveArtifactAlias}/assets/template.html`);
expect(skill.body).not.toContain(`${liveArtifactAlias}/references/layouts.md`);
expect(skill.body).toContain('"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts create --input artifact.json');
expect(skill.body).toContain('do not ask “where should the data come from?” before checking daemon connector tools');
expect(skill.body).toContain('notion.notion_search');
@ -248,12 +249,14 @@ describe('listSkills preamble', () => {
const skill = skills[0];
if (!skill) throw new Error('demo-skill not found');
const demoAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'demo-skill'))}`;
// The cwd-relative alias path is the primary one — that's what makes
// the agent stay inside its working directory when reading skill
// side files (issue #430).
expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/demo-skill/`);
expect(skill.body).toContain(`${demoAlias}/`);
expect(skill.body).toContain(
`${SKILLS_CWD_ALIAS}/demo-skill/assets/template.html`,
`${demoAlias}/assets/template.html`,
);
// The absolute fallback is required for two cases the relative path
@ -280,8 +283,10 @@ describe('listSkills preamble', () => {
const skill = skills[0];
if (!skill) throw new Error('orbit-style skill not found');
expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/orbit-style/`);
expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/orbit-style/example.html`);
const orbitAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'orbit-style'))}`;
expect(skill.body).toContain(`${orbitAlias}/`);
expect(skill.body).toContain(`${orbitAlias}/example.html`);
expect(skill.body).toContain('Known side files in this skill: `example.html`.');
});
@ -297,12 +302,15 @@ describe('listSkills preamble', () => {
const skill = skills[0];
if (!skill) throw new Error('magazine-web-ppt skill not found');
const folderAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'guizang-ppt'))}`;
const frontmatterAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'magazine-web-ppt'))}`;
// `id`/`name` reflect the frontmatter value (used elsewhere as a stable
// public id), but the on-disk alias path must use the actual folder
// name — that is what the daemon-staged junction maps to.
expect(skill.id).toBe('magazine-web-ppt');
expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/guizang-ppt/`);
expect(skill.body).not.toContain(`${SKILLS_CWD_ALIAS}/magazine-web-ppt/`);
expect(skill.body).toContain(`${folderAlias}/`);
expect(skill.body).not.toContain(`${frontmatterAlias}/`);
});
it('does not emit a preamble for skills without side files', async () => {

View file

@ -0,0 +1,19 @@
---
/*
* Shared favicon / app icon links.
*
* Centralizing the link tags here keeps every page's <head> in sync with
* one source of truth — browser tabs, mobile saves, social crawlers, and
* Google Search all see the same official mark even after a brand refresh.
*
* The site.webmanifest reference at the bottom is what advertises the
* Android install / "Add to Home Screen" UX. Keep this component small;
* if you find yourself adding logic, hoist it to seo-head.astro instead.
*/
---
<link rel='icon' type='image/x-icon' href='/favicon.ico' sizes='any' />
<link rel='icon' type='image/png' sizes='32x32' href='/favicon-32x32.png' />
<link rel='icon' type='image/png' sizes='16x16' href='/favicon-16x16.png' />
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
<link rel='manifest' href='/site.webmanifest' />

View file

@ -1,31 +0,0 @@
---
/*
* Google Fonts stylesheet — single source of truth for the four families
* the site uses (Inter Tight, Inter, Playfair Display, JetBrains Mono).
*
* Why a component instead of `@import` in globals.css:
* Previously globals.css did `@import url('https://fonts.googleapis.com/css2?...')`.
* That gets the request fired only AFTER the browser parses the CSS file,
* serializing the chain:
*
* HTML → globals.css → fonts.googleapis.com/css2 → fonts.gstatic.com/woff2
*
* Live HAR from `127.0.0.1:17574/` measured 953ms for the fonts CSS plus
* 400800ms per woff2 (4 of them) — ~3s end-to-end before text could
* render with the intended family, even with `display=swap`.
*
* Moving to `<link>` in the document head lets the browser kick off the
* fonts CSS request alongside the HTML body parse, and `preconnect` hints
* warm up TLS to fonts.gstatic.com so the woff2 fetches don't pay DNS+TLS.
*
* `display=swap` stays in the URL so paragraph copy can render in a
* system fallback while the woff2 is in flight — never blocks paint.
*/
---
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700;800;900&family=Inter:wght@300;400;500;600&family=Playfair+Display:ital,wght@0,500;0,600;1,400;1,500;1,600;1,700&family=JetBrains+Mono:wght@400;500&display=swap"
/>

View file

@ -0,0 +1,8 @@
---
import { googleAnalyticsHeadHtml } from '../_lib/google-analytics';
const measurementId = import.meta.env.PUBLIC_GA_MEASUREMENT_ID;
const headHtml = googleAnalyticsHeadHtml(measurementId);
---
<Fragment set:html={headHtml} />

View file

@ -22,20 +22,30 @@
return fromName(release?.name) ?? fromTag(release?.tag_name) ?? null;
};
const nav = document.querySelector('[data-nav-headroom]');
if (nav) {
const chrome = document.querySelector('[data-chrome-headroom]');
if (chrome) {
// Wrap the scroll listener in requestAnimationFrame so a burst of
// scroll events (trackpads fire >60Hz) collapses to one DOM
// mutation per frame. PSI attributed ~700ms of "forced reflow" to
// the un-throttled version.
let lastY = window.scrollY;
let ticking = false;
const showTopThreshold = 100;
const scrollDelta = 6;
window.addEventListener(
'scroll',
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const y = window.scrollY;
const delta = y - lastY;
if (y <= showTopThreshold) nav.classList.remove('is-hidden');
else if (delta > scrollDelta) nav.classList.add('is-hidden');
else if (delta < -scrollDelta) nav.classList.remove('is-hidden');
if (y <= showTopThreshold) chrome.classList.remove('is-hidden');
else if (delta > scrollDelta) chrome.classList.add('is-hidden');
else if (delta < -scrollDelta) chrome.classList.remove('is-hidden');
lastY = y;
ticking = false;
});
},
{ passive: true },
);
@ -47,14 +57,15 @@
// menu close it again. Keeps `aria-expanded` in sync.
const toggle = document.querySelector('[data-nav-toggle]');
const primaryNav = document.querySelector('[data-nav-primary]');
if (toggle && primaryNav && nav) {
const navEl = toggle ? toggle.closest('header.nav') : null;
if (toggle && primaryNav && navEl) {
const setOpen = (open) => {
nav.classList.toggle('is-open', open);
navEl.classList.toggle('is-open', open);
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
};
toggle.addEventListener('click', (ev) => {
ev.stopPropagation();
setOpen(!nav.classList.contains('is-open'));
setOpen(!navEl.classList.contains('is-open'));
});
// Close when clicking any actual nav link (but not the Product
// parent trigger — its href='/' is a real page link, so we let it
@ -63,7 +74,7 @@
link.addEventListener('click', () => setOpen(false));
});
document.addEventListener('click', (ev) => {
if (!nav.contains(ev.target)) setOpen(false);
if (!navEl.contains(ev.target)) setOpen(false);
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') setOpen(false);
@ -97,5 +108,67 @@
})
.catch(() => {});
}
// Helper used by every clipboard-write button (link copy, share
// text copy). Tries the Clipboard API; falls back to a `prompt()`
// window the user can manually copy from on browsers that block
// clipboard writes (older Safari, embedded webviews).
const writeClipboard = async (text, btn) => {
const flash = () => {
if (!btn) return;
btn.setAttribute('data-copied', 'true');
setTimeout(() => btn.removeAttribute('data-copied'), 1400);
};
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
flash();
return;
}
} catch {
// fall through
}
window.prompt('Copy', text);
};
// Copy-link buttons on detail pages and inside the share dialog.
document.querySelectorAll('[data-copy-link]').forEach((btn) => {
btn.addEventListener('click', () => {
writeClipboard(btn.getAttribute('data-copy-link') || '', btn);
});
});
// Share dialog: trigger button opens the matching `<dialog
// data-share-dialog="...">` and the `Copy text` button inside
// copies the textarea contents. Modal closes via the form's
// method="dialog" submit (the × button) or Escape key.
document.querySelectorAll('[data-share-open]').forEach((trigger) => {
trigger.addEventListener('click', () => {
const key = trigger.getAttribute('data-share-open');
const dialog = document.querySelector(`[data-share-dialog="${key}"]`);
if (dialog && typeof dialog.showModal === 'function') {
dialog.showModal();
} else if (dialog) {
// Older browsers without <dialog> support — just toggle
// a class. CSS already shows `.detail-share-dialog` open
// by default; we set `open` attr to mimic.
dialog.setAttribute('open', '');
}
});
});
document.querySelectorAll('[data-share-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const dialog = btn.closest('dialog, [data-share-dialog]');
const ta = dialog ? dialog.querySelector('[data-share-text]') : null;
if (!ta) return;
const text = ta.value || ta.textContent || '';
writeClipboard(text, btn);
// Auto-select the textarea contents so the user can manually
// copy too if they prefer that affordance.
if (ta.select) {
try { ta.select(); } catch {}
}
});
});
})();
</script>

View file

@ -12,12 +12,12 @@
import {
DEFAULT_LOCALE,
LOCALES,
LOCALE_LABEL,
getCopy,
localePath,
type Locale,
} from '../_lib/i18n';
getCommonCopy,
getHeaderProductMenuCopy,
localizedHref,
type HeaderCopy,
type LandingLocaleCode,
} from '../i18n';
const REPO = 'https://github.com/nexu-io/open-design';
const REPO_RELEASES = `${REPO}/releases`;
@ -33,12 +33,13 @@ export interface HeaderProps {
| 'home'
| 'product'
| 'html-anything'
| 'library'
| 'skills'
| 'systems'
| 'templates'
| 'craft'
| 'tutorials'
| 'blog';
| 'blog'
| 'tutorials';
/**
* Live counts from the Markdown catalogs. Required so we can never
* silently render stale fallback numbers when a caller forgets to
@ -55,100 +56,37 @@ export interface HeaderProps {
github?: {
starsLabel: string;
};
/** UI locale for nav labels and accessibility text. */
locale?: LandingLocaleCode;
/** Optional override for callers that already resolved localized chrome. */
copy?: HeaderCopy;
/** Brand link target — `#top` on the homepage, `/` on sub-pages. */
brandHref?: string;
/** Active page locale. Default routes remain unprefixed English. */
locale?: Locale;
/** Keep `/en/...` links when rendering the explicit English locale route. */
prefixDefaultLocale?: boolean;
/**
* Active pathname (e.g. `/skills/`, `/zh-CN/blog/`). Used by the locale
* switcher to compute the equivalent URL in each language so a click on
* "日本語" from `/zh-CN/blog/` goes straight to `/ja/blog/`, not `/ja/`.
*/
pathname?: string;
}
export function Header({
active = 'home',
counts,
github,
brandHref = '#top',
locale = DEFAULT_LOCALE,
prefixDefaultLocale = false,
pathname = '/',
copy,
brandHref = '#top',
}: HeaderProps) {
const linkClass = (key: NonNullable<HeaderProps['active']>) =>
active === key ? 'is-active' : undefined;
const copy = getCopy(locale);
const href = (path: string) =>
localePath(path, locale, { prefixDefault: prefixDefaultLocale });
const localizedBrandHref =
brandHref === '#top' ? brandHref : href(brandHref);
const contactHref = brandHref === '#top' ? '#contact' : `${href('/')}#contact`;
/**
* Minimal line-art globe icon, sized to sit next to the locale label
* without dominating the pill. `currentColor` so it inherits the ghost
* CTA color treatment (ink at rest, coral on hover).
*/
const globeIcon = (
<svg
className='nav-locale-glyph'
viewBox='0 0 24 24'
width='14'
height='14'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
aria-hidden='true'
>
<circle cx='12' cy='12' r='9' />
<path d='M3 12h18' />
<path d='M12 3a14 14 0 0 1 0 18' />
<path d='M12 3a14 14 0 0 0 0 18' />
</svg>
);
const chevronIcon = (
<svg
className='nav-locale-chevron'
viewBox='0 0 24 24'
width='10'
height='10'
fill='none'
stroke='currentColor'
strokeWidth='2'
aria-hidden='true'
>
<path d='M6 9l6 6 6-6' />
</svg>
);
const checkIcon = (
<svg
className='nav-locale-check'
viewBox='0 0 24 24'
width='12'
height='12'
fill='none'
stroke='currentColor'
strokeWidth='2'
aria-hidden='true'
>
<path d='M5 12l5 5L20 7' />
</svg>
);
const headerCopy = copy ?? getCommonCopy(locale).header;
const href = (path: string) => localizedHref(path, locale);
const homeBrandHref = brandHref === '/' ? href('/') : brandHref;
const productMenuCopy = getHeaderProductMenuCopy(locale);
return (
<header className='nav' data-od-id='nav' data-nav-headroom>
<header className='nav' data-od-id='nav'>
<div className='container nav-inner'>
<a href={localizedBrandHref} className='brand'>
<a href={homeBrandHref} className='brand'>
<span className='brand-mark'>
<img src='/logo.webp' alt='' width={36} height={36} />
</span>
<span>Open Design</span>
<span className='brand-meta'>
<b>Studio 01</b>Berlin / Open / Earth
<img src='/logo.webp' alt='' width={44} height={44} />
</span>
<span className='brand-name'>Open Design</span>
</a>
{/*
Mobile / tablet hamburger. Hidden by CSS at 1100px (the desktop
@ -160,7 +98,7 @@ export function Header({
<button
type='button'
className='nav-toggle'
aria-label='Toggle navigation menu'
aria-label={productMenuCopy.toggleNavigationMenu}
aria-controls='primary-nav'
aria-expanded='false'
data-nav-toggle
@ -179,7 +117,7 @@ export function Header({
aria-haspopup signaling the submenu to assistive tech.
*/}
<a
href='/'
href={href('/')}
className={
active === 'product' ||
active === 'home' ||
@ -190,139 +128,155 @@ export function Header({
aria-haspopup='true'
aria-expanded='false'
>
Product
{productMenuCopy.product}
<span className='dropdown-caret' aria-hidden='true'></span>
</a>
<ul className='nav-dropdown' role='menu'>
<li role='none'>
<a
role='menuitem'
href='/'
href={href('/')}
className={
active === 'home' || active === 'product'
? 'is-active'
: undefined
}
>
<span className='dropdown-name'>Open Design</span>
<span className='dropdown-name'>{productMenuCopy.openDesignName}</span>
<span className='dropdown-blurb'>
The agentic design surface skills, systems, templates.
{productMenuCopy.openDesignBlurb}
</span>
</a>
</li>
<li role='none'>
<a
role='menuitem'
href='/html-anything/'
href={href('/html-anything/')}
className={linkClass('html-anything')}
>
<span className='dropdown-name'>HTML Anything</span>
<span className='dropdown-name'>{productMenuCopy.htmlAnythingName}</span>
<span className='dropdown-blurb'>
Markdown / data ship-ready HTML, by your local agent.
{productMenuCopy.htmlAnythingBlurb}
</span>
</a>
</li>
{/* Tutorials is a top-level nav item (see Library section
below). Don't list it here too duplicating it once at
Product/Tutorials and again at top-level confuses users
about whether the two link to the same page. */}
</ul>
</li>
{/*
Library catalog facets (Skills / Systems / Templates / Craft)
collapsed under one parent. Each row keeps its count badge
inside the panel and the trigger highlights when any of the
four facet pages is active. Same CSS-only :hover /
:focus-within mechanic from Product. Hardcoded "Library" /
"Learn" labels until per-locale translations land the
brand-name pattern.
*/}
<li className='has-dropdown'>
<a
href={href('/skills/')}
className={
active === 'library' ||
active === 'skills' ||
active === 'systems' ||
active === 'templates' ||
active === 'craft'
? 'is-active'
: undefined
}
aria-haspopup='true'
aria-expanded='false'
>
{headerCopy.nav.library}
<span className='dropdown-caret' aria-hidden='true'></span>
</a>
<ul className='nav-dropdown' role='menu'>
<li role='none'>
<a
role='menuitem'
href={href('/skills/')}
className={linkClass('skills')}
>
<span className='dropdown-name'>
{headerCopy.nav.skills}
</span>
</a>
</li>
<li role='none'>
<a
role='menuitem'
href={href('/systems/')}
className={linkClass('systems')}
>
<span className='dropdown-name'>
{headerCopy.nav.systems}
</span>
</a>
</li>
<li role='none'>
<a
role='menuitem'
href={href('/templates/')}
className={linkClass('templates')}
>
<span className='dropdown-name'>
{headerCopy.nav.templates}
</span>
</a>
</li>
<li role='none'>
<a
role='menuitem'
href={href('/craft/')}
className={linkClass('craft')}
>
<span className='dropdown-name'>
{headerCopy.nav.craft}
</span>
</a>
</li>
</ul>
</li>
<li>
<a href={href('/skills/')} className={linkClass('skills')}>
{copy.navSkills}<span className='num'>{counts.skills}</span>
</a>
</li>
<li>
<a href={href('/systems/')} className={linkClass('systems')}>
{copy.navSystems}<span className='num'>{counts.systems}</span>
</a>
</li>
<li>
<a href={href('/templates/')} className={linkClass('templates')}>
{copy.navTemplates}<span className='num'>{counts.templates}</span>
</a>
</li>
<li>
<a href={href('/craft/')} className={linkClass('craft')}>
{copy.navCraft}<span className='num'>{counts.craft}</span>
</a>
</li>
<li>
<a href={href('/tutorials/')} className={linkClass('tutorials')}>
Tutorials
{headerCopy.nav.tutorials}
</a>
</li>
<li>
<a href={href('/blog/')} className={linkClass('blog')}>
{copy.navBlog}
</a>
</li>
<li>
<a href={contactHref}>
{copy.navContact}
{headerCopy.nav.blog}
</a>
</li>
{/*
Contact intentionally NOT exposed in the top nav: it's a
page-internal anchor (`#contact` on the homepage CTA section)
that the footer already surfaces. Keeping it out of the bar
frees a slot at narrow widths where the row was overflowing.
*/}
</ul>
</nav>
<div className='nav-side'>
{/*
* Site-level locale switcher.
*
* Lives in nav-side (not the metadata topbar) so it carries the
* same visual weight as Download/Star CTAs. Uses `<details>` so
* the dropdown works without JavaScript and is recognised as
* a disclosure widget by screen readers. The trigger always
* shows the active locale in its native script, matching
* opendesigner.io's pattern.
*/}
<details className='nav-locale' data-od-id='nav-locale'>
<summary
className='nav-locale-trigger'
aria-label='Switch language'
title='Switch language'
>
{globeIcon}
<span className='nav-locale-current' lang={locale}>
{LOCALE_LABEL[locale]}
</span>
{chevronIcon}
</summary>
<div className='nav-locale-panel' role='menu'>
{LOCALES.map((item) => {
const isCurrent = item === locale;
return (
<a
key={item}
className={`nav-locale-item${isCurrent ? ' is-current' : ''}`}
href={localePath(pathname, item)}
hrefLang={item}
lang={item}
role='menuitem'
aria-current={isCurrent ? 'true' : undefined}
>
<span className='nav-locale-name'>
{LOCALE_LABEL[item]}
</span>
{isCurrent ? checkIcon : null}
</a>
);
})}
</div>
</details>
<a
className='nav-cta ghost'
href={REPO_RELEASES}
aria-label='Download Open Design desktop'
title='Download the desktop app'
aria-label={headerCopy.downloadAria}
title={headerCopy.downloadTitle}
{...ext}
>
{copy.download}
{headerCopy.download}
</a>
<a
className='nav-cta'
href={REPO}
aria-label='Star Open Design on GitHub'
title='Click to star us on GitHub'
aria-label={headerCopy.starAria}
title={headerCopy.starTitle}
{...ext}
>
{copy.star} · <span data-github-stars>{github?.starsLabel ?? '40K+'}</span>
{headerCopy.starPrefix} ·{' '}
<span data-github-stars>{github?.starsLabel ?? '40K+'}</span>
</a>
<span className='status-dot' aria-hidden='true' />
</div>

View file

@ -62,18 +62,28 @@
const enhanceHeader = () => {
const nav = document.querySelector('[data-nav-headroom]');
if (nav) {
// Wrap the scroll listener in requestAnimationFrame so a burst of
// scroll events (trackpads fire >60Hz) collapses to one DOM
// mutation per frame. PSI attributed ~700ms of "forced reflow" to
// the un-throttled version on the previous build.
let lastY = window.scrollY;
let ticking = false;
const showTopThreshold = 100;
const scrollDelta = 6;
window.addEventListener(
'scroll',
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const y = window.scrollY;
const delta = y - lastY;
if (y <= showTopThreshold) nav.classList.remove('is-hidden');
else if (delta > scrollDelta) nav.classList.add('is-hidden');
else if (delta < -scrollDelta) nav.classList.remove('is-hidden');
lastY = y;
ticking = false;
});
},
{ passive: true },
);

View file

@ -0,0 +1,126 @@
---
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../i18n';
const localeRoutes = LANDING_LOCALES.map((locale) => ({
code: locale.code,
htmlLang: locale.htmlLang,
}));
const script = `
(() => {
const STORAGE_KEY = 'od.preferredLocale';
const DEFAULT_LOCALE = ${JSON.stringify(DEFAULT_LOCALE)};
const LOCALES = ${JSON.stringify(localeRoutes)};
const codes = new Set(LOCALES.map((locale) => locale.code));
const normalizeLocale = (value) => {
if (typeof value !== 'string' || value.trim() === '') return null;
const raw = value.toLowerCase().replace(/_/g, '-');
if (raw === 'zh-cn' || raw === 'zh-hans' || raw.startsWith('zh-hans-')) return 'zh';
if (
raw === 'zh-hk' ||
raw === 'zh-mo' ||
raw === 'zh-tw' ||
raw === 'zh-hant' ||
raw.startsWith('zh-hant-')
) {
return 'zh-tw';
}
if (raw === 'pt' || raw.startsWith('pt-br')) return 'pt-br';
if (codes.has(raw)) return raw;
const base = raw.split('-')[0];
return codes.has(base) ? base : null;
};
const currentFromPath = () => {
const first = window.location.pathname.split('/').filter(Boolean)[0]?.toLowerCase();
return first && codes.has(first) ? first : DEFAULT_LOCALE;
};
const basePathFromCurrent = () => {
const parts = window.location.pathname.split('/').filter(Boolean);
if (parts[0] && codes.has(parts[0].toLowerCase())) parts.shift();
return parts.length > 0 ? \`/\${parts.join('/')}/\` : '/';
};
const targetFor = (locale) => {
const basePath = basePathFromCurrent();
const nextPath = locale === DEFAULT_LOCALE ? basePath : \`/\${locale}\${basePath}\`;
return \`\${nextPath}\${window.location.search}\${window.location.hash}\`;
};
const persistLocale = (locale) => {
try {
window.localStorage.setItem(STORAGE_KEY, locale);
} catch {}
};
const selectLocale = (locale, persist) => {
const next = normalizeLocale(locale) ?? DEFAULT_LOCALE;
if (persist) persistLocale(next);
const target = targetFor(next);
const current = \`\${window.location.pathname}\${window.location.search}\${window.location.hash}\`;
if (current !== target) window.location.assign(target);
};
const bindSwitchers = () => {
// Each locale entry is a real <a> link with a server-computed href, so
// navigation happens for free (and survives right-click → "open in new
// tab"). The click handler's only job is to persist the user's choice
// so future visits to '/' auto-route to it.
for (const link of document.querySelectorAll('[data-locale-link]')) {
if (link.dataset.localeBound === 'true') continue;
link.dataset.localeBound = 'true';
link.addEventListener('click', (event) => {
if (event.defaultPrevented) return;
if (event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const code = link.dataset.localeCode;
if (!code) return;
persistLocale(code);
const details = link.closest('[data-locale-switch]');
if (details && details.open) details.open = false;
});
}
// Native <details> stays open until the summary is clicked again. Add
// outside-click + Escape close so it behaves like a real menu.
document.addEventListener('click', (event) => {
const target = event.target;
for (const details of document.querySelectorAll('[data-locale-switch][open]')) {
if (target instanceof Node && details.contains(target)) continue;
details.open = false;
}
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
for (const details of document.querySelectorAll('[data-locale-switch][open]')) {
details.open = false;
const summary = details.querySelector('summary');
if (summary instanceof HTMLElement) summary.focus();
}
});
};
const autoAdapt = () => {
if (currentFromPath() !== DEFAULT_LOCALE) return;
let saved = null;
try {
saved = normalizeLocale(window.localStorage.getItem(STORAGE_KEY) ?? '');
} catch {}
const detected =
saved ??
(Array.isArray(navigator.languages)
? navigator.languages.map(normalizeLocale).find(Boolean)
: normalizeLocale(navigator.language));
if (detected && detected !== DEFAULT_LOCALE) selectLocale(detected, false);
};
bindSwitchers();
autoAdapt();
})();
`;
---
<script is:inline set:html={script} />

View file

@ -0,0 +1,21 @@
---
/*
* Resource hints — preconnect to third-party origins the page is about
* to talk to, so the TLS handshake completes before the first fetch().
*
* Why preconnect instead of dns-prefetch:
* preconnect performs DNS + TCP + TLS warmup; dns-prefetch only the
* DNS lookup. For api.github.com we issue 2-3 fetch() calls from
* the inline enhancer scripts almost immediately on page load
* (stars / latest release / contributors), so the full TLS warmup
* saves ~150-300ms per fetch on first visit. After that all calls
* share the same warmed connection via HTTP/2 multiplexing.
*
* Why `crossorigin`:
* GitHub API responds with CORS. Without the crossorigin attribute
* the browser opens a separate (non-CORS) connection, then opens
* another (CORS) one when fetch() runs, and the preconnect is wasted.
*/
---
<link rel='preconnect' href='https://api.github.com' crossorigin />

View file

@ -17,13 +17,14 @@
import { ogDefaultImage } from '../image-assets';
import {
DEFAULT_LOCALE,
LOCALE_OG,
alternateLinks,
localePath,
stripLocale,
type Locale,
} from '../_lib/i18n';
LANDING_LOCALES,
alternateLinksForPath,
getLocaleDefinition,
localeFromPath,
stripLocaleFromPath,
} from '../i18n';
import FaviconLinks from './favicon-links.astro';
import ResourceHints from './resource-hints.astro';
export interface SeoHeadProps {
/** 'website' for landing/list pages, 'article' for blog posts. */
@ -50,10 +51,6 @@ export interface SeoHeadProps {
* Domain property type uses DNS TXT instead and doesn't need this.
*/
googleSiteVerification?: string;
/** Active locale for canonical, OpenGraph and hreflang output. */
locale?: Locale;
/** Whether this render is under `/en/...` instead of the default English route. */
prefixDefaultLocale?: boolean;
}
const props = Astro.props as SeoHeadProps;
@ -61,12 +58,16 @@ const props = Astro.props as SeoHeadProps;
const SITE_NAME = 'Open Design';
const TAGLINE = 'Design with the agent already on your laptop.';
const isArticle = props.kind === 'article';
const locale = props.locale ?? DEFAULT_LOCALE;
const locale = localeFromPath(props.pathname);
const localeDef = getLocaleDefinition(locale);
const basePath = stripLocaleFromPath(props.pathname).pathname;
const alternateLinks = alternateLinksForPath(props.pathname).map((entry) => ({
...entry,
href: new URL(entry.hrefPath, Astro.site).toString(),
}));
const xDefaultHref = new URL(alternateLinks[0]!.hrefPath, Astro.site).toString();
const canonicalPath = localePath(props.pathname, locale, {
prefixDefault: props.prefixDefaultLocale,
});
const canonical = new URL(canonicalPath, Astro.site).toString();
const canonical = new URL(props.pathname, Astro.site).toString();
const image = props.image ?? new URL(ogDefaultImage, Astro.site).toString();
const rssUrl = new URL('/blog/rss.xml', Astro.site).toString();
@ -88,6 +89,7 @@ const articleJsonLd =
'@type': 'Article',
headline: props.title,
description: props.description,
inLanguage: localeDef.htmlLang,
image: [image],
datePublished: isoPublished,
dateModified: isoModified,
@ -114,24 +116,27 @@ const articleJsonLd =
: null;
const websiteJsonLd =
props.kind === 'website' && stripLocale(props.pathname).path === '/'
props.kind === 'website' && basePath === '/'
? {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: SITE_NAME,
alternateName: TAGLINE,
url: Astro.site?.toString() ?? 'https://open-design.ai/',
inLanguage: localeDef.htmlLang,
availableLanguage: LANDING_LOCALES.map((entry) => entry.htmlLang),
}
: null;
const blogJsonLd =
props.kind === 'website' && stripLocale(props.pathname).path === '/blog/'
props.kind === 'website' && basePath === '/blog/'
? {
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'Open Design — Blog',
description: props.description,
url: canonical,
inLanguage: localeDef.htmlLang,
publisher: {
'@type': 'Organization',
name: SITE_NAME,
@ -145,10 +150,13 @@ const blogJsonLd =
<meta name='description' content={props.description} />
<meta name='theme-color' content='#efe7d2' />
<link rel='canonical' href={canonical} />
<link rel='alternate' type='application/rss+xml' title='Open Design Blog' href={rssUrl} />
{alternateLinks(props.pathname).map((item) => (
<link rel='alternate' hreflang={item.hreflang} href={new URL(item.href, Astro.site).toString()} />
{alternateLinks.map((entry) => (
<link rel='alternate' hreflang={entry.hreflang} href={entry.href} />
))}
<link rel='alternate' hreflang='x-default' href={xDefaultHref} />
<link rel='alternate' type='application/rss+xml' title='Open Design Blog' href={rssUrl} />
<FaviconLinks />
<ResourceHints />
{props.googleSiteVerification && (
<meta name='google-site-verification' content={props.googleSiteVerification} />
@ -160,7 +168,10 @@ const blogJsonLd =
<meta property='og:description' content={props.description} />
<meta property='og:url' content={canonical} />
<meta property='og:image' content={image} />
<meta property='og:locale' content={LOCALE_OG[locale]} />
<meta property='og:locale' content={localeDef.ogLocale} />
{LANDING_LOCALES.filter((entry) => entry.code !== locale).map((entry) => (
<meta property='og:locale:alternate' content={entry.ogLocale} />
))}
{props.kind === 'article' && (
<>

View file

@ -1,16 +1,23 @@
---
import type { HeaderProps } from './header';
import { DEFAULT_LOCALE, getCopy, localePath, type Locale } from '../_lib/i18n';
import {
DEFAULT_LOCALE,
getCommonCopy,
getLandingUiCopy,
isLandingLocale,
localizedHref,
} from '../i18n';
interface Props {
counts: HeaderProps['counts'];
locale?: Locale;
prefixDefaultLocale?: boolean;
locale?: string;
}
const { counts, locale = DEFAULT_LOCALE, prefixDefaultLocale = false } = Astro.props;
const copy = getCopy(locale);
const href = (path: string) => localePath(path, locale, { prefixDefault: prefixDefaultLocale });
const { counts, locale: rawLocale = DEFAULT_LOCALE } = Astro.props as Props;
const locale = isLandingLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
const copy = getCommonCopy(locale).header;
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const REPO = 'https://github.com/nexu-io/open-design';
const DISCORD = 'https://discord.gg/9ptkbbqRu';
---
@ -26,33 +33,43 @@ const DISCORD = 'https://discord.gg/9ptkbbqRu';
<span>Open Design</span>
</a>
<p>
{copy.footerPitch}
{ui.footer.summary}
</p>
</div>
<div class='sub-footer-col'>
<h5>{copy.catalog}</h5>
<h5>{ui.footer.catalog}</h5>
<ul>
<li><a href={href('/skills/')}>{counts.skills} {copy.navSkills}</a></li>
<li><a href={href('/systems/')}>{counts.systems} {copy.navSystems}</a></li>
<li><a href={href('/templates/')}>{counts.templates} {copy.navTemplates}</a></li>
<li><a href={href('/craft/')}>{counts.craft} {copy.navCraft}</a></li>
<li><a href={href('/skills/')}>{counts.skills} {copy.nav.skills}</a></li>
<li><a href={href('/systems/')}>{counts.systems} {copy.nav.systems}</a></li>
<li><a href={href('/templates/')}>{counts.templates} {copy.nav.templates}</a></li>
<li><a href={href('/craft/')}>{counts.craft} {copy.nav.craft}</a></li>
</ul>
</div>
<div class='sub-footer-col'>
<h5>{copy.connect}</h5>
<h5>{ui.footer.openDesign}</h5>
<ul>
<li><a href={REPO} target='_blank' rel='noopener'>GitHub</a></li>
<li><a href={`${REPO}/issues`} target='_blank' rel='noopener'>{copy.issues}</a></li>
<li><a href={`${REPO}/releases`} target='_blank' rel='noopener'>{copy.releases}</a></li>
<li><a href={DISCORD} target='_blank' rel='noopener'>Discord</a></li>
<li><a href='/blog/rss.xml'>RSS</a></li>
<li><a href={`${href('/')}#contact`}>{copy.contact}</a></li>
<li><a href={href('/official/')}>{ui.footer.official}</a></li>
<li><a href={href('/quickstart/')}>{ui.footer.quickstart}</a></li>
<li><a href={href('/agents/')}>{ui.footer.agents}</a></li>
<li><a href={href('/compare/')}>{ui.footer.compare}</a></li>
<li><a href={href('/alternatives/claude-design/')}>{ui.footer.claudeAlternative}</a></li>
</ul>
</div>
<div class='sub-footer-col'>
<h5>{ui.footer.connect}</h5>
<ul>
<li><a href={REPO} target='_blank' rel='noopener'>{ui.footer.github}</a></li>
<li><a href={`${REPO}/issues`} target='_blank' rel='noopener'>{ui.footer.issues}</a></li>
<li><a href={`${REPO}/releases`} target='_blank' rel='noopener'>{ui.footer.releases}</a></li>
<li><a href={DISCORD} target='_blank' rel='noopener'>{ui.footer.discord}</a></li>
<li><a href='/blog/rss.xml'>{ui.footer.rss}</a></li>
<li><a href={href('/#contact')}>{copy.nav.contact}</a></li>
</ul>
</div>
</div>
<div class='sub-footer-bottom'>
<span>● Open Design · Apache-2.0 · 2026 / Volume 01 / Issue Nº 26</span>
<span>Berlin / Open / Earth · 52.5200° N · 13.4050° E</span>
<span>{ui.footer.bottomLeft}</span>
<span>{ui.footer.bottomRight}</span>
</div>
</div>
</footer>

View file

@ -9,6 +9,7 @@
* unfiltered index.
*/
import type { SkillRecord } from '../_lib/catalog';
import { localeFromPath, localizedHref } from '../i18n';
import LazyImg from './lazy-img.astro';
export interface Props {
@ -17,6 +18,8 @@ export interface Props {
}
const { skill, index } = Astro.props;
const locale = localeFromPath(Astro.url.pathname);
const href = (path: string) => localizedHref(path, locale);
// The first ~4 rows are visible above the fold on a typical laptop and
// always render at the top of the first catalog list a visitor sees.
@ -26,7 +29,7 @@ const aboveFold = index < 4;
---
<li class="catalog-row catalog-row-skill">
<a href={`/skills/${skill.slug}/`}>
<a href={href(`/skills/${skill.slug}/`)}>
<span class="row-index">{String(index + 1).padStart(3, '0')}</span>
<span class="row-thumb">
{skill.previewUrl ? (
@ -44,9 +47,9 @@ const aboveFold = index < 4;
<span class="row-desc">{skill.description}</span>
</span>
<span class="row-meta">
{skill.mode && <span class="meta-tag">{skill.mode}</span>}
{skill.scenario && <span class="meta-tag muted">{skill.scenario}</span>}
{skill.platform && <span class="meta-tag muted">{skill.platform}</span>}
{skill.modeLabel && <span class="meta-tag">{skill.modeLabel}</span>}
{skill.scenarioLabel && <span class="meta-tag muted">{skill.scenarioLabel}</span>}
{skill.platformLabel && <span class="meta-tag muted">{skill.platformLabel}</span>}
</span>
<span class="row-arrow" aria-hidden="true">→</span>
</a>

View file

@ -18,17 +18,24 @@ import '../globals.css';
import '../sub-pages.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import FontStylesheet from './font-stylesheet.astro';
import FaviconLinks from './favicon-links.astro';
import GoogleAnalytics from './google-analytics.astro';
import ResourceHints from './resource-hints.astro';
import HeaderEnhancer from './header-enhancer.astro';
import LocaleSwitcherEnhancer from './locale-switcher-enhancer.astro';
import { Header, type HeaderProps } from './header';
import LocaleSwitcherScript from './locale-switcher-script.astro';
import PreciseLazyload from './precise-lazyload.astro';
import SiteFooter from './site-footer.astro';
import Topbar from './topbar.astro';
import { heroImage } from '../image-assets';
import {
LANDING_LOCALES,
alternateLinksForPath,
getLocaleDefinition,
localeFromPath,
} from '../i18n';
import { getCatalogCounts } from '../_lib/catalog';
import { getGithubRepoMeta } from '../_lib/github';
import { DEFAULT_LOCALE, LOCALE_OG, alternateLinks, localeDir, type Locale } from '../_lib/i18n';
export interface Props {
title: string;
@ -36,39 +43,28 @@ export interface Props {
active?: HeaderProps['active'];
ogImage?: string;
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
locale?: Locale;
prefixDefaultLocale?: boolean;
}
const {
title,
description,
active = 'home',
ogImage,
jsonLd,
locale = DEFAULT_LOCALE,
prefixDefaultLocale = false,
} = Astro.props;
const { title, description, active = 'home', ogImage, jsonLd } = Astro.props;
const locale = localeFromPath(Astro.url.pathname);
const localeDef = getLocaleDefinition(locale);
const canonical = new URL(Astro.url.pathname, Astro.site).toString();
const alternateLinks = alternateLinksForPath(Astro.url.pathname).map((entry) => ({
...entry,
href: new URL(entry.hrefPath, Astro.site).toString(),
}));
const xDefaultHref = new URL(alternateLinks[0]!.hrefPath, Astro.site).toString();
const og = ogImage ?? heroImage;
const counts = await getCatalogCounts();
const github = await getGithubRepoMeta();
const headerHtml = renderToStaticMarkup(
Header({
active,
counts,
github,
brandHref: '/',
locale,
prefixDefaultLocale,
pathname: Astro.url.pathname,
}) as ReturnType<typeof createElement>,
Header({ active, counts, github, brandHref: '/', locale }) as ReturnType<typeof createElement>,
);
const ldArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
---
<!doctype html>
<html lang={locale} dir={localeDir(locale)}>
<html lang={localeDef.htmlLang} dir={localeDef.dir}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@ -76,14 +72,15 @@ const ldArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{alternateLinks(Astro.url.pathname).map((item) => (
<link rel="alternate" hreflang={item.hreflang} href={new URL(item.href, Astro.site).toString()} />
{alternateLinks.map((entry) => (
<link rel="alternate" hreflang={entry.hreflang} href={entry.href} />
))}
<link rel="alternate" hreflang="x-default" href={xDefaultHref} />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<FaviconLinks />
<ResourceHints />
<FontStylesheet />
<GoogleAnalytics />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Open Design" />
@ -91,7 +88,10 @@ const ldArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={og} />
<meta property="og:locale" content={LOCALE_OG[locale]} />
<meta property="og:locale" content={localeDef.ogLocale} />
{LANDING_LOCALES.filter((entry) => entry.code !== locale).map((entry) => (
<meta property="og:locale:alternate" content={entry.ogLocale} />
))}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
@ -104,20 +104,22 @@ const ldArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
</head>
<body class="sub-page">
<div class="shell">
<div class="site-chrome" data-chrome-headroom>
<Topbar github={github} locale={locale} />
{/* Same React-rendered Header used by the homepage. SSR'd here
* so we have one nav implementation that handles active state. */}
<Fragment set:html={headerHtml} />
</div>
<main class="sub-main container">
<slot />
</main>
<SiteFooter counts={counts} locale={locale} prefixDefaultLocale={prefixDefaultLocale} />
<SiteFooter counts={counts} locale={locale} />
</div>
<HeaderEnhancer />
<LocaleSwitcherEnhancer />
<LocaleSwitcherScript />
<PreciseLazyload />
</body>
</html>

View file

@ -5,16 +5,19 @@
* category, and tagline as a clickable card.
*/
import type { SystemRecord } from '../_lib/catalog';
import { localeFromPath, localizedHref } from '../i18n';
export interface Props {
system: SystemRecord;
}
const { system } = Astro.props;
const locale = localeFromPath(Astro.url.pathname);
const href = (path: string) => localizedHref(path, locale);
---
<li class="system-card">
<a href={`/systems/${system.slug}/`}>
<a href={href(`/systems/${system.slug}/`)}>
<div class="system-swatches" aria-hidden="true">
{system.palette.length > 0 ? (
system.palette.slice(0, 4).map((hex) => (
@ -25,7 +28,7 @@ const { system } = Astro.props;
)}
</div>
<span class="system-name">{system.name}</span>
<span class="system-cat">{system.category}</span>
<span class="system-cat">{system.categoryLabel}</span>
{system.tagline && <p class="system-tagline">{system.tagline}</p>}
</a>
</li>

View file

@ -1,38 +1,90 @@
---
import { DEFAULT_LOCALE, getCopy, type Locale } from '../_lib/i18n';
import {
DEFAULT_LOCALE,
LANDING_LOCALES,
getCommonCopy,
isLandingLocale,
localePath,
stripLocaleFromPath,
} from '../i18n';
const REPO_RELEASES = 'https://github.com/nexu-io/open-design/releases';
const REPO = 'https://github.com/nexu-io/open-design';
const REPO_RELEASES = `${REPO}/releases`;
const ext = {
target: '_blank',
rel: 'noreferrer noopener',
} as const;
const NBSP = '\u00A0';
const {
github = { versionLabel: 'v0.3.0' },
locale = DEFAULT_LOCALE,
} = Astro.props as {
github?: { versionLabel: string };
locale?: Locale;
};
const copy = getCopy(locale);
const NBSP = ' ';
const { github = { versionLabel: 'v0.3.0' }, locale: rawLocale = DEFAULT_LOCALE } =
Astro.props;
const locale = isLandingLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
const copy = getCommonCopy(locale).topbar;
const { pathname: basePath } = stripLocaleFromPath(Astro.url.pathname);
const localeOptions = LANDING_LOCALES.map((entry) => ({
...entry,
href: localePath(entry.code, basePath),
}));
const currentCode = locale.toUpperCase();
---
<div class='topbar' data-od-id='topbar'>
<div class='container topbar-inner'>
<span>
<b>OD / 2026</b>{NBSP}·{NBSP}Vol. 01 / Issue Nº 26
<b>OD / 2026</b>{NBSP}·{NBSP}{copy.issue ?? 'Vol. 01 / Issue Nº 26'}
</span>
<span class='mid'>
<span>
Filed under <b class='coral'>Design · Intelligence</b>
{copy.filedUnder} <b class='coral'>{copy.category}</b>
</span>
<span>Apache-2.0 · Made on Earth</span>
<span>{copy.madeOnEarth}</span>
</span>
<span class='right'>
<a class='topbar-link' href={REPO_RELEASES} {...ext}>
<span class='pulse'></span>
{copy.live} · <span data-github-version>{github.versionLabel}</span>
</a>
<details class='locale-switch' data-locale-switch>
<summary class='locale-trigger' aria-label={copy.languageSwitcherLabel}>
<span class='locale-trigger-prefix' aria-hidden='true'>
{copy.languageSwitcherPrefix ?? 'Lang'}
</span>
<span class='locale-trigger-sep' aria-hidden='true'>·</span>
<span class='locale-trigger-code'>{currentCode}</span>
<svg
class='locale-trigger-caret'
viewBox='0 0 8 5'
aria-hidden='true'
focusable='false'
>
<path
d='M0.5 0.75 L4 4 L7.5 0.75'
fill='none'
stroke='currentColor'
stroke-width='1'
stroke-linecap='square'
/>
</svg>
</summary>
<div class='locale-menu' role='menu'>
{localeOptions.map((entry) => (
<a
class:list={[
'locale-menu-item',
{ 'is-active': entry.code === locale },
]}
role='menuitem'
data-locale-link
data-locale-code={entry.code}
href={entry.href}
lang={entry.htmlLang}
aria-current={entry.code === locale ? 'true' : undefined}
>
<span class='locale-menu-code'>{entry.code.toUpperCase()}</span>
<span class='locale-menu-label'>{entry.label}</span>
</a>
))}
</div>
</details>
</span>
</div>
</div>

View file

@ -11,6 +11,19 @@ import { getCollection, type CollectionEntry } from 'astro:content';
import { existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import {
DEFAULT_LOCALE,
type LandingLocaleCode,
type LocalizedStringValue,
} from '../i18n';
import {
explicitLocalizedString,
localizeCraftText,
localizeSkillDescription,
localizeSystemText,
localizeTaxonomyValue,
localizeTemplateText,
} from '../content-i18n';
// ---------------------------------------------------------------------------
// Preview imagery lookup
@ -73,6 +86,7 @@ function previewUrlFor(
const REPO_TREE = 'https://github.com/nexu-io/open-design/tree/main';
const REPO_BLOB = 'https://github.com/nexu-io/open-design/blob/main';
const SHOULD_CACHE_CATALOG = import.meta.env.PROD;
// ---------------------------------------------------------------------------
// Skills
@ -86,9 +100,13 @@ export interface SkillRecord {
description: string;
triggers: ReadonlyArray<string>;
mode?: string;
modeLabel?: string;
platform?: string;
platformLabel?: string;
scenario?: string;
scenarioLabel?: string;
category?: string;
categoryLabel?: string;
featured?: number;
upstream?: string;
examplePrompt?: string;
@ -98,6 +116,8 @@ export interface SkillRecord {
previewUrl: string | null;
}
const skillRecordsCache = new Map<LandingLocaleCode, Promise<ReadonlyArray<SkillRecord>>>();
function deriveSkillSlug(id: string): string {
// `id` is `[folder]/SKILL` (no extension). We want the folder name.
const folder = id.split('/')[0] ?? id;
@ -112,12 +132,20 @@ function firstParagraph(text: string | undefined, fallback = ''): string {
export function shapeSkill(
entry: SkillEntry,
previews: Map<string, string>,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): SkillRecord {
const slug = deriveSkillSlug(entry.id);
const data = entry.data as {
name?: LocalizedStringValue;
description?: LocalizedStringValue;
triggers?: string[];
i18n?: Record<string, {
name?: string;
description?: string;
triggers?: string[];
examplePrompt?: string;
example_prompt?: string;
}>;
od?: {
mode?: string;
platform?: string;
@ -125,32 +153,55 @@ export function shapeSkill(
category?: string;
featured?: number;
upstream?: string;
example_prompt?: string;
example_prompt?: LocalizedStringValue;
};
};
const description = (data.description ?? '').trim();
return {
slug,
name: data.name ?? slug,
description,
triggers: data.triggers ?? [],
const localized = data.i18n?.[locale];
const name = explicitLocalizedString(localized?.name ?? data.name, locale) ?? slug;
const rawDescription = explicitLocalizedString(data.description, DEFAULT_LOCALE) ?? '';
const description =
explicitLocalizedString(localized?.description ?? data.description, locale) ??
localizeSkillDescription({
name,
mode: data.od?.mode,
platform: data.od?.platform,
scenario: data.od?.scenario,
category: data.od?.category,
locale,
fallback: rawDescription,
});
const examplePrompt = explicitLocalizedString(
localized?.examplePrompt ?? localized?.example_prompt ?? data.od?.example_prompt,
locale,
) ?? '';
return {
slug,
name,
description,
triggers: localized?.triggers ?? (locale === DEFAULT_LOCALE ? data.triggers ?? [] : []),
mode: data.od?.mode,
modeLabel: localizeTaxonomyValue(data.od?.mode, locale),
platform: data.od?.platform,
platformLabel: localizeTaxonomyValue(data.od?.platform, locale),
scenario: data.od?.scenario,
scenarioLabel: localizeTaxonomyValue(data.od?.scenario, locale),
category: data.od?.category,
categoryLabel: localizeTaxonomyValue(data.od?.category, locale),
featured: data.od?.featured,
upstream: data.od?.upstream,
examplePrompt: data.od?.example_prompt,
examplePrompt,
source: `${REPO_TREE}/skills/${slug}`,
body: entry.body ?? '',
previewUrl: previewUrlFor('skills', slug, previews),
};
}
export async function getSkillRecords(): Promise<ReadonlyArray<SkillRecord>> {
export async function getSkillRecords(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<SkillRecord>> {
if (!SHOULD_CACHE_CATALOG) {
const previews = listPreviews('skills');
const entries = await getCollection('skills');
const shaped = entries.map((entry) => shapeSkill(entry, previews));
const shaped = entries.map((entry) => shapeSkill(entry, previews, locale));
return shaped.sort((a, b) => {
// Featured (lower number = higher priority) first, then alphabetical.
const af = a.featured ?? Number.POSITIVE_INFINITY;
@ -160,6 +211,28 @@ export async function getSkillRecords(): Promise<ReadonlyArray<SkillRecord>> {
});
}
const cached = skillRecordsCache.get(locale);
if (cached) {
return cached;
}
const promise = (async () => {
const previews = listPreviews('skills');
const entries = await getCollection('skills');
const shaped = entries.map((entry) => shapeSkill(entry, previews, locale));
return shaped.sort((a, b) => {
// Featured (lower number = higher priority) first, then alphabetical.
const af = a.featured ?? Number.POSITIVE_INFINITY;
const bf = b.featured ?? Number.POSITIVE_INFINITY;
if (af !== bf) return af - bf;
return a.name.localeCompare(b.name);
});
})();
skillRecordsCache.set(locale, promise);
return promise;
}
// ---------------------------------------------------------------------------
// Design Systems
// ---------------------------------------------------------------------------
@ -170,6 +243,7 @@ export interface SystemRecord {
slug: string;
name: string;
category: string;
categoryLabel: string;
tagline: string;
atmosphere: string;
palette: ReadonlyArray<string>;
@ -177,6 +251,8 @@ export interface SystemRecord {
body: string;
}
const systemRecordsCache = new Map<LandingLocaleCode, Promise<ReadonlyArray<SystemRecord>>>();
function extractH1(body: string): string | undefined {
for (const line of body.split('\n')) {
const trimmed = line.trim();
@ -249,32 +325,76 @@ function extractPalette(body: string, limit = 5): ReadonlyArray<string> {
return Array.from(seen);
}
export function shapeSystem(entry: SystemEntry): SystemRecord {
export function shapeSystem(
entry: SystemEntry,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): SystemRecord {
const slug = entry.id.split('/')[0] ?? entry.id;
const body = entry.body ?? '';
const data = entry.data as {
i18n?: Record<string, {
name?: string;
category?: string;
tagline?: string;
atmosphere?: string;
}>;
};
const localized = data.i18n?.[locale];
const h1 = extractH1(body) ?? slug;
const { category, tagline } = extractCategoryBlock(body);
const atmosphere = extractAtmosphere(body);
const palette = extractPalette(body);
const name =
localized?.name ??
(h1.replace(/^Design System Inspired by\s+/i, '').trim() || slug);
const rawCategory = localized?.category ?? (category || 'Uncategorized');
const localizedText = localizeSystemText({
name,
category: rawCategory,
paletteCount: palette.length,
locale,
fallbackTagline: localized?.tagline ?? tagline,
fallbackAtmosphere: localized?.atmosphere ?? atmosphere,
});
return {
slug,
name: h1.replace(/^Design System Inspired by\s+/i, '').trim() || slug,
category: category || 'Uncategorized',
tagline,
atmosphere,
name,
category: rawCategory,
categoryLabel: localizedText.category,
tagline: localizedText.tagline,
atmosphere: localizedText.atmosphere,
palette,
source: `${REPO_TREE}/design-systems/${slug}`,
body,
};
}
export async function getSystemRecords(): Promise<ReadonlyArray<SystemRecord>> {
export async function getSystemRecords(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<SystemRecord>> {
if (!SHOULD_CACHE_CATALOG) {
const entries = await getCollection('systems');
return entries
.map(shapeSystem)
.map((entry) => shapeSystem(entry, locale))
.sort((a, b) => a.name.localeCompare(b.name));
}
const cached = systemRecordsCache.get(locale);
if (cached) {
return cached;
}
const promise = (async () => {
const entries = await getCollection('systems');
return entries
.map((entry) => shapeSystem(entry, locale))
.sort((a, b) => a.name.localeCompare(b.name));
})();
systemRecordsCache.set(locale, promise);
return promise;
}
// ---------------------------------------------------------------------------
// Craft
// ---------------------------------------------------------------------------
@ -289,6 +409,8 @@ export interface CraftRecord {
body: string;
}
const craftRecordsCache = new Map<LandingLocaleCode, Promise<ReadonlyArray<CraftRecord>>>();
const CRAFT_NAME_OVERRIDES: Record<string, string> = {
'rtl-and-bidi': 'RTL & Bidi',
};
@ -378,21 +500,42 @@ function extractFirstProseParagraph(body: string): string {
return stripMarkdownInline(buf.join(' '));
}
export function shapeCraft(entry: CraftEntry): CraftRecord {
export function shapeCraft(
entry: CraftEntry,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): CraftRecord {
const slug = entry.id;
const body = entry.body ?? '';
const data = entry.data as {
i18n?: Record<string, {
name?: string;
summary?: string;
}>;
};
const localized = data.i18n?.[locale];
const h1 = extractH1(body);
const cleanH1 = h1 ? stripMarkdownInline(h1).replace(/\s+craft rules?$/i, '').trim() : '';
const fallbackName = localized?.name ?? (cleanH1 || titleizeSlug(slug));
const fallbackSummary = localized?.summary ?? extractFirstProseParagraph(body);
const localizedText = localizeCraftText({
slug,
name: fallbackName,
summary: fallbackSummary,
locale,
});
return {
slug,
name: cleanH1 || titleizeSlug(slug),
summary: extractFirstProseParagraph(body),
name: localizedText.name,
summary: localizedText.summary,
source: `${REPO_BLOB}/craft/${slug}.md`,
body,
};
}
export async function getCraftRecords(): Promise<ReadonlyArray<CraftRecord>> {
export async function getCraftRecords(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<CraftRecord>> {
if (!SHOULD_CACHE_CATALOG) {
const entries = await getCollection('craft');
// Astro normalizes the entry id from `craft/README.md` to `readme`
// (lowercase, extension stripped). Comparing the raw `'README'` string
@ -402,10 +545,33 @@ export async function getCraftRecords(): Promise<ReadonlyArray<CraftRecord>> {
// also filtered out.
return entries
.filter((e) => e.id.toLowerCase() !== 'readme')
.map(shapeCraft)
.map((entry) => shapeCraft(entry, locale))
.sort((a, b) => a.name.localeCompare(b.name));
}
const cached = craftRecordsCache.get(locale);
if (cached) {
return cached;
}
const promise = (async () => {
const entries = await getCollection('craft');
// Astro normalizes the entry id from `craft/README.md` to `readme`
// (lowercase, extension stripped). Comparing the raw `'README'` string
// misses it on disk and used to ship `/craft/readme/` as a public
// craft principle and inflate the nav count by one. Compare
// case-insensitively so future README casings (`Readme.md`, etc.) are
// also filtered out.
return entries
.filter((e) => e.id.toLowerCase() !== 'readme')
.map((entry) => shapeCraft(entry, locale))
.sort((a, b) => a.name.localeCompare(b.name));
})();
craftRecordsCache.set(locale, promise);
return promise;
}
// ---------------------------------------------------------------------------
// Templates — renderable design templates + legacy Live Artifacts
// ---------------------------------------------------------------------------
@ -416,8 +582,11 @@ export interface TemplateRecord {
summary: string;
origin: 'design-template' | 'live-artifact';
mode?: string;
modeLabel?: string;
platform?: string;
platformLabel?: string;
scenario?: string;
scenarioLabel?: string;
featured?: number;
source: string;
detailHref: string;
@ -426,17 +595,25 @@ export interface TemplateRecord {
previewUrl: string | null;
}
const templateRecordsCache = new Map<LandingLocaleCode, Promise<ReadonlyArray<TemplateRecord>>>();
export type TemplateEntry = CollectionEntry<'templates'>;
export type DesignTemplateEntry = CollectionEntry<'designTemplates'>;
export function shapeDesignTemplate(
entry: DesignTemplateEntry,
previews: Map<string, string>,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): TemplateRecord {
const slug = deriveSkillSlug(entry.id);
const data = entry.data as {
name?: LocalizedStringValue;
description?: LocalizedStringValue;
i18n?: Record<string, {
name?: string;
description?: string;
summary?: string;
}>;
od?: {
mode?: string;
platform?: string;
@ -445,19 +622,30 @@ export function shapeDesignTemplate(
};
};
const body = entry.body ?? '';
const localized = data.i18n?.[locale];
const name =
explicitLocalizedString(localized?.name ?? data.name, locale) ?? titleizeSlug(slug);
const summary =
firstParagraph(data.description) ||
explicitLocalizedString(
localized?.summary ?? localized?.description ?? data.description,
locale,
) ||
firstParagraph(explicitLocalizedString(data.description, DEFAULT_LOCALE)) ||
extractFirstProseParagraph(body) ||
'Open Design renderable design template.';
const localizedText = localizeTemplateText({ name, summary, locale });
return {
slug,
name: data.name ?? titleizeSlug(slug),
summary,
name: localizedText.name,
summary: localizedText.summary,
origin: 'design-template',
mode: data.od?.mode,
modeLabel: localizeTaxonomyValue(data.od?.mode, locale),
platform: data.od?.platform,
platformLabel: localizeTaxonomyValue(data.od?.platform, locale),
scenario: data.od?.scenario,
scenarioLabel: localizeTaxonomyValue(data.od?.scenario, locale),
featured: data.od?.featured,
source: `${REPO_TREE}/design-templates/${slug}`,
detailHref: `/templates/${slug}/`,
@ -469,9 +657,17 @@ export function shapeDesignTemplate(
export function shapeLiveArtifactTemplate(
entry: TemplateEntry,
previews: Map<string, string>,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): TemplateRecord {
const slug = entry.id.split('/')[0] ?? entry.id;
const body = entry.body ?? '';
const data = entry.data as {
i18n?: Record<string, {
name?: string;
summary?: string;
}>;
};
const localized = data.i18n?.[locale];
const h1 = extractH1(body);
// Some authors write `# \`otd-operations-brief\` · live-artifact template`
@ -484,15 +680,22 @@ export function shapeLiveArtifactTemplate(
.trim();
const summary = extractFirstProseParagraph(body) || 'Open Design Live Artifact template.';
const localizedText = localizeTemplateText({
name: localized?.name ?? (cleanH1 || titleizeSlug(slug)),
summary: localized?.summary ?? summary,
locale,
});
const liveSlug = `live-${slug}`;
return {
slug: liveSlug,
name: cleanH1 || titleizeSlug(slug),
summary,
name: localizedText.name,
summary: localizedText.summary,
origin: 'live-artifact',
mode: 'template',
modeLabel: localizeTaxonomyValue('template', locale),
scenario: 'live-artifacts',
scenarioLabel: localizeTaxonomyValue('live-artifacts', locale),
source: `${REPO_TREE}/templates/live-artifacts/${slug}`,
detailHref: `/templates/${liveSlug}/`,
body,
@ -500,13 +703,20 @@ export function shapeLiveArtifactTemplate(
};
}
export async function getTemplateRecords(): Promise<ReadonlyArray<TemplateRecord>> {
export async function getTemplateRecords(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<TemplateRecord>> {
if (!SHOULD_CACHE_CATALOG) {
const previews = listPreviews('templates');
const designEntries = await getCollection('designTemplates');
const designRecords = designEntries.map((entry) => shapeDesignTemplate(entry, previews));
const designRecords = designEntries.map((entry) =>
shapeDesignTemplate(entry, previews, locale),
);
const liveEntries = await getCollection('templates');
const liveRecords = liveEntries.map((entry) => shapeLiveArtifactTemplate(entry, previews));
const liveRecords = liveEntries.map((entry) =>
shapeLiveArtifactTemplate(entry, previews, locale),
);
return [...designRecords, ...liveRecords].sort((a, b) => {
// Keep explicitly featured templates first, then group the canonical
@ -519,6 +729,38 @@ export async function getTemplateRecords(): Promise<ReadonlyArray<TemplateRecord
});
}
const cached = templateRecordsCache.get(locale);
if (cached) {
return cached;
}
const promise = (async () => {
const previews = listPreviews('templates');
const designEntries = await getCollection('designTemplates');
const designRecords = designEntries.map((entry) =>
shapeDesignTemplate(entry, previews, locale),
);
const liveEntries = await getCollection('templates');
const liveRecords = liveEntries.map((entry) =>
shapeLiveArtifactTemplate(entry, previews, locale),
);
return [...designRecords, ...liveRecords].sort((a, b) => {
// Keep explicitly featured templates first, then group the canonical
// design-template catalogue ahead of legacy live-artifact shims.
const af = a.featured ?? Number.POSITIVE_INFINITY;
const bf = b.featured ?? Number.POSITIVE_INFINITY;
if (af !== bf) return af - bf;
if (a.origin !== b.origin) return a.origin === 'design-template' ? -1 : 1;
return a.name.localeCompare(b.name);
});
})();
templateRecordsCache.set(locale, promise);
return promise;
}
// ---------------------------------------------------------------------------
// Counts
//
@ -541,6 +783,8 @@ export interface CatalogCounts {
byPlatform: Readonly<Record<string, number>>;
}
const catalogCountsCache = new Map<LandingLocaleCode, Promise<CatalogCounts>>();
function tallyKey(values: Iterable<string | undefined>): Record<string, number> {
const out: Record<string, number> = {};
for (const v of values) {
@ -551,12 +795,15 @@ function tallyKey(values: Iterable<string | undefined>): Record<string, number>
return out;
}
export async function getCatalogCounts(): Promise<CatalogCounts> {
export async function getCatalogCounts(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<CatalogCounts> {
if (!SHOULD_CACHE_CATALOG) {
const [skills, systems, templates, craft] = await Promise.all([
getSkillRecords(),
getSystemRecords(),
getTemplateRecords(),
getCraftRecords(),
getSkillRecords(locale),
getSystemRecords(locale),
getTemplateRecords(locale),
getCraftRecords(locale),
]);
return {
skills: skills.length,
@ -568,6 +815,32 @@ export async function getCatalogCounts(): Promise<CatalogCounts> {
};
}
const cached = catalogCountsCache.get(locale);
if (cached) {
return cached;
}
const promise = (async () => {
const [skills, systems, templates, craft] = await Promise.all([
getSkillRecords(locale),
getSystemRecords(locale),
getTemplateRecords(locale),
getCraftRecords(locale),
]);
return {
skills: skills.length,
systems: systems.length,
templates: templates.length,
craft: craft.length,
byMode: tallyKey(skills.map((s) => s.mode)),
byPlatform: tallyKey(skills.map((s) => s.platform)),
};
})();
catalogCountsCache.set(locale, promise);
return promise;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@ -680,62 +953,95 @@ export function tagIndex(values: ReadonlyArray<string | undefined>): ReadonlyArr
// human label (preserving the original `od.mode` casing for the heading).
// ---------------------------------------------------------------------------
export async function getSkillsForMode(slug: string): Promise<{
export async function getSkillsForMode(
slug: string,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<{
label: string | null;
records: ReadonlyArray<SkillRecord>;
}> {
const all = await getSkillRecords();
const all = await getSkillRecords(locale);
const matches = all.filter((s) => {
const canonical = canonicalMode(s.mode);
return canonical && slugifyTag(canonical) === slug;
});
return {
label: canonicalMode(matches[0]?.mode) ?? null,
label:
localizeTaxonomyValue(canonicalMode(matches[0]?.mode), locale) ??
canonicalMode(matches[0]?.mode) ??
null,
records: matches,
};
}
export async function getSkillsForScenario(slug: string): Promise<{
export async function getSkillsForScenario(
slug: string,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<{
label: string | null;
records: ReadonlyArray<SkillRecord>;
}> {
const all = await getSkillRecords();
const all = await getSkillRecords(locale);
const matches = all.filter((s) => {
const canonical = canonicalScenario(s.scenario);
return canonical && slugifyTag(canonical) === slug;
});
return {
label: canonicalScenario(matches[0]?.scenario) ?? null,
label:
localizeTaxonomyValue(canonicalScenario(matches[0]?.scenario), locale) ??
canonicalScenario(matches[0]?.scenario) ??
null,
records: matches,
};
}
export async function getSystemsForCategory(slug: string): Promise<{
export async function getSystemsForCategory(
slug: string,
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<{
label: string | null;
records: ReadonlyArray<SystemRecord>;
}> {
const all = await getSystemRecords();
const all = await getSystemRecords(locale);
const matches = all.filter((s) => {
const canonical = canonicalCategory(s.category);
return canonical !== undefined && slugifyTag(canonical) === slug;
});
return {
label: canonicalCategory(matches[0]?.category) ?? null,
label:
localizeTaxonomyValue(canonicalCategory(matches[0]?.category), locale) ??
canonicalCategory(matches[0]?.category) ??
null,
records: matches,
};
}
export async function getSkillModeIndex(): Promise<ReadonlyArray<TagDescriptor>> {
export async function getSkillModeIndex(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<TagDescriptor>> {
const all = await getSkillRecords();
return tagIndex(all.map((s) => canonicalMode(s.mode)));
return tagIndex(all.map((s) => canonicalMode(s.mode))).map((tag) => ({
...tag,
label: localizeTaxonomyValue(tag.label, locale) ?? tag.label,
}));
}
export async function getSkillScenarioIndex(): Promise<ReadonlyArray<TagDescriptor>> {
export async function getSkillScenarioIndex(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<TagDescriptor>> {
const all = await getSkillRecords();
return tagIndex(all.map((s) => canonicalScenario(s.scenario)));
return tagIndex(all.map((s) => canonicalScenario(s.scenario))).map((tag) => ({
...tag,
label: localizeTaxonomyValue(tag.label, locale) ?? tag.label,
}));
}
export async function getSystemCategoryIndex(): Promise<ReadonlyArray<TagDescriptor>> {
export async function getSystemCategoryIndex(
locale: LandingLocaleCode = DEFAULT_LOCALE,
): Promise<ReadonlyArray<TagDescriptor>> {
const all = await getSystemRecords();
return tagIndex(all.map((s) => canonicalCategory(s.category)));
return tagIndex(all.map((s) => canonicalCategory(s.category))).map((tag) => ({
...tag,
label: localizeTaxonomyValue(tag.label, locale) ?? tag.label,
}));
}

View file

@ -0,0 +1,51 @@
export function googleAnalyticsHeadHtml(measurementId: string | undefined): string {
if (!measurementId) return '';
return `<!-- Google tag (gtag.js) -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
var gtagScript = document.createElement('script');
gtagScript.async = true;
gtagScript.src = 'https://www.googletagmanager.com/gtag/js?id=${measurementId}';
document.head.appendChild(gtagScript);
gtag('js', new Date());
gtag('config', ${JSON.stringify(measurementId)});
document.addEventListener('click', function (event) {
if (typeof gtag !== 'function') return;
var link = event.target && event.target.closest ? event.target.closest('a[href]') : null;
if (!link) return;
var href = link.href;
var label = (link.getAttribute('aria-label') || link.textContent || '').trim().replace(/\\s+/g, ' ');
var lowerHref = href.toLowerCase();
var lowerLabel = label.toLowerCase();
var cta = null;
if (lowerHref.includes('github.com/nexu-io/open-design/releases')) cta = 'download_desktop';
else if (lowerHref === 'https://github.com/nexu-io/open-design' || lowerLabel.includes('star')) cta = 'star_github';
else if (lowerHref.includes('discord.gg/')) cta = 'join_discord';
else if (lowerHref.includes('github.com/nexu-io/open-design/issues')) cta = 'open_issue';
else if (link.pathname && link.pathname.startsWith('/blog/')) cta = 'blog_cta';
else if (link.pathname && link.pathname.startsWith('/tutorials/')) cta = 'tutorial_cta';
if (!cta) return;
gtag('event', 'cta_click', {
cta_name: cta,
link_url: href,
link_text: label.slice(0, 120),
});
});
</script>`;
}
export function injectGoogleAnalytics(html: string, measurementId: string | undefined): string {
const headHtml = googleAnalyticsHeadHtml(measurementId);
if (!headHtml) return html;
if (html.includes(measurementId!)) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${headHtml}\n</head>`);
}
return `${headHtml}\n${html}`;
}

View file

@ -0,0 +1,772 @@
import {
DEFAULT_LOCALE,
getLocaleDefinition,
type LandingLocaleCode,
type LocalizedStringValue,
} from './i18n';
type ContentCopy = {
skillNoun: string;
systemNoun: string;
templateNoun: string;
craftNoun: string;
pluginNoun: string;
blogNoun: string;
unknownTag: string;
skillDescription: (name: string, labels: string[]) => string;
systemTagline: (name: string, category: string) => string;
systemAtmosphere: (name: string, category: string, paletteCount: number) => string;
craftName: (name: string) => string;
craftSummary: (name: string) => string;
templateName: (name: string) => string;
templateSummary: (name: string) => string;
pluginTitle: (kind: string, id: string) => string;
pluginDescription: (kind: string, labels: string[]) => string;
pluginExample: (kind: string) => string;
blogTitle: (topic: string) => string;
blogSummary: (topic: string) => string;
blogBody: (topic: string, summary: string) => string;
};
const CONTENT_COPY: Record<Exclude<LandingLocaleCode, 'en'>, ContentCopy> = {
zh: {
skillNoun: 'Skill',
systemNoun: '设计系统',
templateNoun: '模板',
craftNoun: '工艺规则',
pluginNoun: '插件',
blogNoun: '文章',
unknownTag: '分类',
skillDescription: (name, labels) => `${name} 是一个可组合的 Open Design Skill用于${labels.join('、') || '设计产出'}工作流;可由本地代理调用,并和仓库中的设计系统一起复用。`,
systemTagline: (name, category) => `${name} 设计系统将${category}风格整理成可移植的 DESIGN.md 规则,供每个 Skill 复用。`,
systemAtmosphere: (name, category, paletteCount) => `${name}${category}为视觉方向,包含 ${paletteCount} 个核心色板、排版节奏、组件边界和反模式约束。`,
craftName: (name) => `${name}工艺规则`,
craftSummary: (name) => `这条 Open Design 工艺规则定义 ${name} 的执行标准,帮助代理在生成 artifact 时保持一致、可读和可交付。`,
templateName: (name) => `${name}模板`,
templateSummary: (name) => `${name} 是可复用的 Open Design Live Artifact 模板,包含渲染入口、示例数据和可 fork 的文件结构。`,
pluginTitle: (kind, id) => `${kind}插件 · ${id}`,
pluginDescription: (kind, labels) => `用于${kind}工作流的 Open Design 插件。安装后可在本地 daemon 和 od CLI 中复用${labels.length ? `,覆盖${labels.join('、')}` : ''}`,
pluginExample: (kind) => `使用该插件创建一个${kind}任务,并在本地 Open Design 工作区中查看生成结果。`,
blogTitle: (topic) => `Open Design 指南:${topic}`,
blogSummary: (topic) => `这篇本地化摘要说明 ${topic} 与 Open Design 的本地优先、BYOK 和可组合 Skill 工作流之间的关系。`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>本地化摘要</h2><p>这篇文章围绕 ${topic} 展开,说明 Open Design 如何把设计 artifact、Skill、设计系统和本地代理工作流连接起来。</p><p>当前页面使用站内 i18n fallback 渲染本地化正文;完整人工翻译可继续通过 frontmatter 的 <code>i18n.bodyHtml</code> 覆盖。</p>`,
},
'zh-tw': {
skillNoun: 'Skill',
systemNoun: '設計系統',
templateNoun: '模板',
craftNoun: '工藝規則',
pluginNoun: '外掛',
blogNoun: '文章',
unknownTag: '分類',
skillDescription: (name, labels) => `${name} 是一個可組合的 Open Design Skill用於${labels.join('、') || '設計產出'}工作流;可由本地代理呼叫,並和 repo 中的設計系統一起複用。`,
systemTagline: (name, category) => `${name} 設計系統將${category}風格整理成可攜式 DESIGN.md 規則,供每個 Skill 複用。`,
systemAtmosphere: (name, category, paletteCount) => `${name}${category}為視覺方向,包含 ${paletteCount} 個核心色板、排版節奏、元件邊界和反模式約束。`,
craftName: (name) => `${name}工藝規則`,
craftSummary: (name) => `這條 Open Design 工藝規則定義 ${name} 的執行標準,幫助代理在生成 artifact 時保持一致、可讀和可交付。`,
templateName: (name) => `${name}模板`,
templateSummary: (name) => `${name} 是可複用的 Open Design Live Artifact 模板,包含渲染入口、示例資料和可 fork 的檔案結構。`,
pluginTitle: (kind, id) => `${kind}外掛 · ${id}`,
pluginDescription: (kind, labels) => `用於${kind}工作流的 Open Design 外掛。安裝後可在本地 daemon 和 od CLI 中複用${labels.length ? `,覆蓋${labels.join('、')}` : ''}`,
pluginExample: (kind) => `使用該外掛建立一個${kind}任務,並在本地 Open Design 工作區中查看生成結果。`,
blogTitle: (topic) => `Open Design 指南:${topic}`,
blogSummary: (topic) => `這篇本地化摘要說明 ${topic} 與 Open Design 的本地優先、BYOK 和可組合 Skill 工作流之間的關係。`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>本地化摘要</h2><p>這篇文章圍繞 ${topic} 展開,說明 Open Design 如何把設計 artifact、Skill、設計系統和本地代理工作流連接起來。</p><p>目前頁面使用站內 i18n fallback 渲染本地化正文;完整人工翻譯可繼續透過 frontmatter 的 <code>i18n.bodyHtml</code> 覆蓋。</p>`,
},
ja: {
skillNoun: 'スキル',
systemNoun: 'デザインシステム',
templateNoun: 'テンプレート',
craftNoun: 'クラフトルール',
pluginNoun: 'プラグイン',
blogNoun: '記事',
unknownTag: '分類',
skillDescription: (name, labels) => `${name} は、${labels.join('、') || 'デザイン制作'}のための Open Design スキルです。ローカルエージェントから呼び出せ、リポジトリ内のデザインシステムと一緒に再利用できます。`,
systemTagline: (name, category) => `${name}${category} の方向性を DESIGN.md として整理した、移植可能なデザインシステムです。`,
systemAtmosphere: (name, category, paletteCount) => `${name}${category} を基調に、${paletteCount} 個のパレット、タイポグラフィ、コンポーネント境界、避けるべきパターンを定義します。`,
craftName: (name) => `${name} のクラフトルール`,
craftSummary: (name) => `${name} の実行基準を定義し、エージェントが一貫して読みやすく納品可能な artifact を生成できるようにします。`,
templateName: (name) => `${name} テンプレート`,
templateSummary: (name) => `${name} は再利用可能な Open Design Live Artifact テンプレートで、レンダー入口、サンプルデータ、fork 可能な構成を含みます。`,
pluginTitle: (kind, id) => `${kind} プラグイン · ${id}`,
pluginDescription: (kind, labels) => `${kind} ワークフロー向けの Open Design プラグインです。インストール後はローカル daemon と od CLI から再利用できます${labels.length ? `。対象: ${labels.join('、')}` : ''}`,
pluginExample: (kind) => `このプラグインで ${kind} タスクを作成し、ローカルの Open Design ワークスペースで結果を確認します。`,
blogTitle: (topic) => `Open Design ガイド: ${topic}`,
blogSummary: (topic) => `${topic} と、Open Design のローカルファースト、BYOK、構成可能なスキルワークフローの関係をまとめます。`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>ローカライズ概要</h2><p>この記事は ${topic} を起点に、Open Design が artifact、スキル、デザインシステム、ローカルエージェントをどう接続するかを説明します。</p><p>このページは i18n fallback で本文を表示しています。完全な人手翻訳は frontmatter の <code>i18n.bodyHtml</code> で上書きできます。</p>`,
},
ko: {
skillNoun: '스킬',
systemNoun: '디자인 시스템',
templateNoun: '템플릿',
craftNoun: '크래프트 규칙',
pluginNoun: '플러그인',
blogNoun: '글',
unknownTag: '분류',
skillDescription: (name, labels) => `${name}${labels.join(', ') || '디자인 산출물'} 워크플로를 위한 조합 가능한 Open Design 스킬입니다. 로컬 에이전트가 호출하고 저장소의 디자인 시스템과 함께 재사용할 수 있습니다.`,
systemTagline: (name, category) => `${name} 디자인 시스템은 ${category} 방향을 이식 가능한 DESIGN.md 규칙으로 정리합니다.`,
systemAtmosphere: (name, category, paletteCount) => `${name}${category} 분위기를 바탕으로 ${paletteCount}개의 팔레트, 타이포그래피 리듬, 컴포넌트 경계, 안티패턴을 정의합니다.`,
craftName: (name) => `${name} 크래프트 규칙`,
craftSummary: (name) => `${name}의 실행 기준을 정의해 에이전트가 일관되고 읽기 쉬운 artifact를 만들도록 돕습니다.`,
templateName: (name) => `${name} 템플릿`,
templateSummary: (name) => `${name}은 재사용 가능한 Open Design Live Artifact 템플릿이며 렌더링入口, 샘플 데이터, fork 가능한 구조를 포함합니다.`,
pluginTitle: (kind, id) => `${kind} 플러그인 · ${id}`,
pluginDescription: (kind, labels) => `${kind} 워크플로용 Open Design 플러그인입니다. 설치 후 로컬 daemon과 od CLI에서 재사용할 수 있습니다${labels.length ? `: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `이 플러그인으로 ${kind} 작업을 만들고 로컬 Open Design 워크스페이스에서 결과를 확인합니다.`,
blogTitle: (topic) => `Open Design 가이드: ${topic}`,
blogSummary: (topic) => `${topic}이 Open Design의 로컬 우선, BYOK, 조합 가능한 스킬 워크플로와 어떻게 연결되는지 요약합니다.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>현지화 요약</h2><p>이 글은 ${topic}을 중심으로 Open Design이 artifact, 스킬, 디자인 시스템, 로컬 에이전트를 어떻게 연결하는지 설명합니다.</p><p>현재 본문은 i18n fallback으로 렌더링됩니다. 완전한 번역은 frontmatter의 <code>i18n.bodyHtml</code>로 덮어쓸 수 있습니다.</p>`,
},
de: {
skillNoun: 'Skill',
systemNoun: 'Designsystem',
templateNoun: 'Vorlage',
craftNoun: 'Gestaltungsregel',
pluginNoun: 'Plugin',
blogNoun: 'Artikel',
unknownTag: 'Kategorie',
skillDescription: (name, labels) => `${name} ist ein kombinierbarer Open-Design-Skill fuer ${labels.join(', ') || 'Design-Artefakte'}. Er laesst sich lokal vom Agenten ausfuehren und mit DESIGN.md-Systemen wiederverwenden.`,
systemTagline: (name, category) => `${name} buendelt die Richtung ${category} als portables DESIGN.md-System fuer alle Skills.`,
systemAtmosphere: (name, category, paletteCount) => `${name} uebersetzt ${category} in ${paletteCount} Kernfarben, Typografie, Komponentenregeln und Anti-Patterns.`,
craftName: (name) => `${name}-Gestaltungsregel`,
craftSummary: (name) => `Diese Open-Design-Regel definiert Standards fuer ${name}, damit Agenten konsistente und lieferbare Artefakte erzeugen.`,
templateName: (name) => `${name}-Vorlage`,
templateSummary: (name) => `${name} ist eine wiederverwendbare Live-Artifact-Vorlage mit Render-Einstieg, Beispieldaten und forkbarer Struktur.`,
pluginTitle: (kind, id) => `${kind}-Plugin · ${id}`,
pluginDescription: (kind, labels) => `Open-Design-Plugin fuer ${kind}-Workflows. Nach der Installation ist es lokal ueber daemon und od CLI nutzbar${labels.length ? `; Schwerpunkte: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Erstelle mit diesem Plugin eine ${kind}-Aufgabe und pruefe das Ergebnis im lokalen Open-Design-Workspace.`,
blogTitle: (topic) => `Open-Design-Leitfaden: ${topic}`,
blogSummary: (topic) => `Lokalisierte Zusammenfassung zu ${topic} und dem lokalen BYOK-Skill-Workflow von Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Lokalisierte Zusammenfassung</h2><p>Dieser Beitrag erklaert, wie Open Design ${topic} mit Artefakten, Skills, Designsystemen und lokalen Agenten verbindet.</p><p>Der Text nutzt aktuell einen i18n-Fallback. Eine vollstaendige Uebersetzung kann ueber <code>i18n.bodyHtml</code> im Frontmatter hinterlegt werden.</p>`,
},
fr: {
skillNoun: 'skill',
systemNoun: 'systeme de design',
templateNoun: 'modele',
craftNoun: 'regle de conception',
pluginNoun: 'plugin',
blogNoun: 'article',
unknownTag: 'categorie',
skillDescription: (name, labels) => `${name} est un skill Open Design composable pour les flux ${labels.join(', ') || 'de production design'}. Il s'execute avec l'agent local et se reutilise avec les systemes DESIGN.md.`,
systemTagline: (name, category) => `${name} transforme la direction ${category} en systeme DESIGN.md portable pour tous les skills.`,
systemAtmosphere: (name, category, paletteCount) => `${name} formalise ${category} avec ${paletteCount} couleurs, une hierarchie typographique, des composants et des anti-patterns.`,
craftName: (name) => `Regle de conception ${name}`,
craftSummary: (name) => `Cette regle Open Design definit les standards ${name} pour produire des artefacts coherents, lisibles et livrables.`,
templateName: (name) => `Modele ${name}`,
templateSummary: (name) => `${name} est un modele Live Artifact reutilisable avec point de rendu, donnees d'exemple et structure forkable.`,
pluginTitle: (kind, id) => `Plugin ${kind} · ${id}`,
pluginDescription: (kind, labels) => `Plugin Open Design pour les flux ${kind}. Une fois installe, il fonctionne avec le daemon local et la CLI od${labels.length ? `; portee: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Utilisez ce plugin pour lancer une tache ${kind} et verifier le resultat dans l'espace de travail Open Design local.`,
blogTitle: (topic) => `Guide Open Design : ${topic}`,
blogSummary: (topic) => `Resume localise de ${topic} dans le contexte local-first, BYOK et skills composables d'Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Resume localise</h2><p>Cet article explique comment Open Design relie ${topic}, les artefacts, les skills, les systemes de design et les agents locaux.</p><p>Cette page utilise un fallback i18n. Une traduction complete peut etre fournie via <code>i18n.bodyHtml</code> dans le frontmatter.</p>`,
},
ru: {
skillNoun: 'навык',
systemNoun: 'дизайн-система',
templateNoun: 'шаблон',
craftNoun: 'правило качества',
pluginNoun: 'плагин',
blogNoun: 'статья',
unknownTag: 'категория',
skillDescription: (name, labels) => `${name} — составной навык Open Design для сценариев ${labels.join(', ') || 'дизайн-артефактов'}. Его запускает локальный агент, а правила DESIGN.md переиспользуются между задачами.`,
systemTagline: (name, category) => `${name} превращает направление ${category} в переносимую DESIGN.md дизайн-систему для всех навыков.`,
systemAtmosphere: (name, category, paletteCount) => `${name} описывает ${category}: ${paletteCount} основных цветов, типографику, компоненты и анти-паттерны.`,
craftName: (name) => `Правило качества: ${name}`,
craftSummary: (name) => `Это правило Open Design задает стандарт ${name}, чтобы агент создавал согласованные и пригодные к передаче артефакты.`,
templateName: (name) => `Шаблон ${name}`,
templateSummary: (name) => `${name} — переиспользуемый Live Artifact шаблон с точкой рендера, примером данных и структурой для fork.`,
pluginTitle: (kind, id) => `Плагин ${kind} · ${id}`,
pluginDescription: (kind, labels) => `Плагин Open Design для сценариев ${kind}. После установки доступен локально через daemon и CLI od${labels.length ? `; охват: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Создайте задачу ${kind} с этим плагином и проверьте результат в локальном рабочем пространстве Open Design.`,
blogTitle: (topic) => `Гид Open Design: ${topic}`,
blogSummary: (topic) => `Локализованное резюме о ${topic} и о том, как это связано с local-first, BYOK и составными навыками Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Локализованное резюме</h2><p>Статья объясняет, как Open Design связывает ${topic}, артефакты, навыки, дизайн-системы и локальных агентов.</p><p>Сейчас страница использует i18n fallback. Полный перевод можно задать через <code>i18n.bodyHtml</code> во frontmatter.</p>`,
},
es: {
skillNoun: 'skill',
systemNoun: 'sistema de diseño',
templateNoun: 'plantilla',
craftNoun: 'regla de oficio',
pluginNoun: 'plugin',
blogNoun: 'articulo',
unknownTag: 'categoria',
skillDescription: (name, labels) => `${name} es un skill componible de Open Design para flujos de ${labels.join(', ') || 'artefactos de diseño'}. Lo ejecuta el agente local y reutiliza sistemas DESIGN.md.`,
systemTagline: (name, category) => `${name} convierte la direccion ${category} en un sistema DESIGN.md portable para todos los skills.`,
systemAtmosphere: (name, category, paletteCount) => `${name} expresa ${category} con ${paletteCount} colores base, ritmo tipografico, componentes y anti-patrones.`,
craftName: (name) => `Regla de oficio ${name}`,
craftSummary: (name) => `Esta regla de Open Design define el estandar ${name} para producir artefactos coherentes, legibles y entregables.`,
templateName: (name) => `Plantilla ${name}`,
templateSummary: (name) => `${name} es una plantilla Live Artifact reutilizable con entrada de render, datos de ejemplo y estructura lista para fork.`,
pluginTitle: (kind, id) => `Plugin de ${kind} · ${id}`,
pluginDescription: (kind, labels) => `Plugin de Open Design para flujos de ${kind}. Tras instalarlo, funciona con el daemon local y la CLI od${labels.length ? `; cubre: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Usa este plugin para crear una tarea de ${kind} y revisar el resultado en el workspace local de Open Design.`,
blogTitle: (topic) => `Guia Open Design: ${topic}`,
blogSummary: (topic) => `Resumen localizado sobre ${topic} dentro del flujo local-first, BYOK y de skills componibles de Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Resumen localizado</h2><p>Este articulo explica como Open Design conecta ${topic}, artefactos, skills, sistemas de diseño y agentes locales.</p><p>La pagina usa un fallback i18n; una traduccion completa puede sobrescribirse con <code>i18n.bodyHtml</code> en el frontmatter.</p>`,
},
'pt-br': {
skillNoun: 'skill',
systemNoun: 'sistema de design',
templateNoun: 'modelo',
craftNoun: 'regra de craft',
pluginNoun: 'plugin',
blogNoun: 'artigo',
unknownTag: 'categoria',
skillDescription: (name, labels) => `${name} e um skill componivel do Open Design para fluxos de ${labels.join(', ') || 'artefatos de design'}. Ele roda com o agente local e reutiliza sistemas DESIGN.md.`,
systemTagline: (name, category) => `${name} transforma a direcao ${category} em um sistema DESIGN.md portavel para todos os skills.`,
systemAtmosphere: (name, category, paletteCount) => `${name} traduz ${category} em ${paletteCount} cores principais, tipografia, componentes e anti-padroes.`,
craftName: (name) => `Regra de craft ${name}`,
craftSummary: (name) => `Esta regra do Open Design define o padrao ${name} para gerar artefatos consistentes, legiveis e entregaveis.`,
templateName: (name) => `Modelo ${name}`,
templateSummary: (name) => `${name} e um modelo Live Artifact reutilizavel com entrada de renderizacao, dados de exemplo e estrutura pronta para fork.`,
pluginTitle: (kind, id) => `Plugin de ${kind} · ${id}`,
pluginDescription: (kind, labels) => `Plugin do Open Design para fluxos de ${kind}. Depois de instalado, funciona no daemon local e na CLI od${labels.length ? `; cobre: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Use este plugin para criar uma tarefa de ${kind} e revisar o resultado no workspace local do Open Design.`,
blogTitle: (topic) => `Guia Open Design: ${topic}`,
blogSummary: (topic) => `Resumo localizado sobre ${topic} no fluxo local-first, BYOK e de skills componiveis do Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Resumo localizado</h2><p>Este artigo explica como o Open Design conecta ${topic}, artefatos, skills, sistemas de design e agentes locais.</p><p>A pagina usa um fallback i18n; uma traducao completa pode ser sobrescrita por <code>i18n.bodyHtml</code> no frontmatter.</p>`,
},
it: {
skillNoun: 'skill',
systemNoun: 'sistema di design',
templateNoun: 'modello',
craftNoun: 'regola di craft',
pluginNoun: 'plugin',
blogNoun: 'articolo',
unknownTag: 'categoria',
skillDescription: (name, labels) => `${name} e uno skill componibile di Open Design per flussi ${labels.join(', ') || 'di artefatti design'}. Viene eseguito dall'agente locale e riusa sistemi DESIGN.md.`,
systemTagline: (name, category) => `${name} traduce la direzione ${category} in un sistema DESIGN.md portabile per tutti gli skill.`,
systemAtmosphere: (name, category, paletteCount) => `${name} definisce ${category} con ${paletteCount} colori base, tipografia, componenti e anti-pattern.`,
craftName: (name) => `Regola di craft ${name}`,
craftSummary: (name) => `Questa regola Open Design definisce lo standard ${name} per produrre artefatti coerenti, leggibili e consegnabili.`,
templateName: (name) => `Modello ${name}`,
templateSummary: (name) => `${name} e un modello Live Artifact riutilizzabile con ingresso di rendering, dati di esempio e struttura forkabile.`,
pluginTitle: (kind, id) => `Plugin ${kind} · ${id}`,
pluginDescription: (kind, labels) => `Plugin Open Design per flussi ${kind}. Dopo l'installazione funziona con il daemon locale e la CLI od${labels.length ? `; copre: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Usa questo plugin per creare un task ${kind} e controllare il risultato nel workspace locale Open Design.`,
blogTitle: (topic) => `Guida Open Design: ${topic}`,
blogSummary: (topic) => `Sintesi localizzata di ${topic} nel flusso local-first, BYOK e skill componibili di Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Sintesi localizzata</h2><p>Questo articolo spiega come Open Design collega ${topic}, artefatti, skill, sistemi di design e agenti locali.</p><p>La pagina usa un fallback i18n; una traduzione completa puo essere fornita con <code>i18n.bodyHtml</code> nel frontmatter.</p>`,
},
vi: {
skillNoun: 'skill',
systemNoun: 'he thong thiet ke',
templateNoun: 'mau',
craftNoun: 'quy tac craft',
pluginNoun: 'plugin',
blogNoun: 'bai viet',
unknownTag: 'phan loai',
skillDescription: (name, labels) => `${name} la skill Open Design co the ghep noi cho luong ${labels.join(', ') || 'artifact thiet ke'}. Skill chay voi agent cuc bo va tai su dung cac he DESIGN.md.`,
systemTagline: (name, category) => `${name} bien huong ${category} thanh he DESIGN.md di dong cho moi skill.`,
systemAtmosphere: (name, category, paletteCount) => `${name} mo ta ${category} voi ${paletteCount} mau cot loi, nhip chu, thanh phan va cac mau can tranh.`,
craftName: (name) => `Quy tac craft ${name}`,
craftSummary: (name) => `Quy tac Open Design nay dat chuan ${name} de agent tao artifact nhat quan, de doc va co the ban giao.`,
templateName: (name) => `Mau ${name}`,
templateSummary: (name) => `${name} la mau Live Artifact co the tai su dung, gom diem render, du lieu mau va cau truc co the fork.`,
pluginTitle: (kind, id) => `Plugin ${kind} · ${id}`,
pluginDescription: (kind, labels) => `Plugin Open Design cho luong ${kind}. Sau khi cai dat, plugin chay voi daemon cuc bo va CLI od${labels.length ? `; pham vi: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Dung plugin nay de tao tac vu ${kind} va xem ket qua trong workspace Open Design cuc bo.`,
blogTitle: (topic) => `Huong dan Open Design: ${topic}`,
blogSummary: (topic) => `Tom tat ban dia hoa ve ${topic} trong luong local-first, BYOK va skill co the ghep noi cua Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Tom tat ban dia hoa</h2><p>Bai viet nay giai thich cach Open Design ket noi ${topic}, artifact, skill, he thiet ke va agent cuc bo.</p><p>Trang dang dung fallback i18n; ban dich day du co the ghi de bang <code>i18n.bodyHtml</code> trong frontmatter.</p>`,
},
pl: {
skillNoun: 'skill',
systemNoun: 'system projektowy',
templateNoun: 'szablon',
craftNoun: 'regula craft',
pluginNoun: 'plugin',
blogNoun: 'artykul',
unknownTag: 'kategoria',
skillDescription: (name, labels) => `${name} to komponowalny skill Open Design dla przeplywow ${labels.join(', ') || 'artefaktow designu'}. Dziala z lokalnym agentem i wykorzystuje systemy DESIGN.md.`,
systemTagline: (name, category) => `${name} zamienia kierunek ${category} w przenosny system DESIGN.md dla wszystkich skillow.`,
systemAtmosphere: (name, category, paletteCount) => `${name} opisuje ${category}: ${paletteCount} kolorow, typografie, komponenty i antywzorce.`,
craftName: (name) => `Regula craft ${name}`,
craftSummary: (name) => `Ta regula Open Design definiuje standard ${name}, aby agent tworzyl spojne i gotowe do przekazania artefakty.`,
templateName: (name) => `Szablon ${name}`,
templateSummary: (name) => `${name} to wielorazowy szablon Live Artifact z punktem renderowania, danymi przykladowymi i struktura do forkowania.`,
pluginTitle: (kind, id) => `Plugin ${kind} · ${id}`,
pluginDescription: (kind, labels) => `Plugin Open Design dla przeplywow ${kind}. Po instalacji dziala lokalnie przez daemon i CLI od${labels.length ? `; zakres: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Utworz zadanie ${kind} tym pluginem i sprawdz wynik w lokalnym workspace Open Design.`,
blogTitle: (topic) => `Przewodnik Open Design: ${topic}`,
blogSummary: (topic) => `Zlokalizowane podsumowanie ${topic} w przeplywie local-first, BYOK i komponowalnych skillow Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Zlokalizowane podsumowanie</h2><p>Ten artykul pokazuje, jak Open Design laczy ${topic}, artefakty, skille, systemy projektowe i lokalnych agentow.</p><p>Strona uzywa fallbacku i18n; pelne tlumaczenie mozna podac przez <code>i18n.bodyHtml</code> we frontmatter.</p>`,
},
id: {
skillNoun: 'skill',
systemNoun: 'sistem desain',
templateNoun: 'templat',
craftNoun: 'aturan craft',
pluginNoun: 'plugin',
blogNoun: 'artikel',
unknownTag: 'kategori',
skillDescription: (name, labels) => `${name} adalah skill Open Design yang dapat dikomposisi untuk alur ${labels.join(', ') || 'artifact desain'}. Skill ini berjalan lewat agen lokal dan memakai ulang sistem DESIGN.md.`,
systemTagline: (name, category) => `${name} mengubah arah ${category} menjadi sistem DESIGN.md portabel untuk semua skill.`,
systemAtmosphere: (name, category, paletteCount) => `${name} merumuskan ${category} dengan ${paletteCount} warna inti, tipografi, komponen, dan anti-pola.`,
craftName: (name) => `Aturan craft ${name}`,
craftSummary: (name) => `Aturan Open Design ini menetapkan standar ${name} agar agen menghasilkan artifact yang konsisten, terbaca, dan siap diserahkan.`,
templateName: (name) => `Templat ${name}`,
templateSummary: (name) => `${name} adalah templat Live Artifact yang dapat dipakai ulang, berisi entry render, data contoh, dan struktur yang bisa di-fork.`,
pluginTitle: (kind, id) => `Plugin ${kind} · ${id}`,
pluginDescription: (kind, labels) => `Plugin Open Design untuk alur ${kind}. Setelah dipasang, plugin berjalan di daemon lokal dan CLI od${labels.length ? `; cakupan: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Gunakan plugin ini untuk membuat tugas ${kind} dan memeriksa hasilnya di workspace Open Design lokal.`,
blogTitle: (topic) => `Panduan Open Design: ${topic}`,
blogSummary: (topic) => `Ringkasan lokal tentang ${topic} dalam alur local-first, BYOK, dan skill yang dapat dikomposisi di Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Ringkasan lokal</h2><p>Artikel ini menjelaskan cara Open Design menghubungkan ${topic}, artifact, skill, sistem desain, dan agen lokal.</p><p>Halaman ini memakai fallback i18n; terjemahan lengkap dapat ditimpa lewat <code>i18n.bodyHtml</code> di frontmatter.</p>`,
},
nl: {
skillNoun: 'skill',
systemNoun: 'designsysteem',
templateNoun: 'sjabloon',
craftNoun: 'craftregel',
pluginNoun: 'plugin',
blogNoun: 'artikel',
unknownTag: 'categorie',
skillDescription: (name, labels) => `${name} is een composeerbare Open Design-skill voor ${labels.join(', ') || 'designartefacten'}. De lokale agent voert hem uit en hergebruikt DESIGN.md-systemen.`,
systemTagline: (name, category) => `${name} vertaalt ${category} naar een draagbaar DESIGN.md-designsysteem voor elke skill.`,
systemAtmosphere: (name, category, paletteCount) => `${name} beschrijft ${category} met ${paletteCount} kernkleuren, typografie, componentregels en anti-patronen.`,
craftName: (name) => `Craftregel ${name}`,
craftSummary: (name) => `Deze Open Design-regel definieert ${name}, zodat agenten consistente, leesbare en overdraagbare artefacten maken.`,
templateName: (name) => `Sjabloon ${name}`,
templateSummary: (name) => `${name} is een herbruikbaar Live Artifact-sjabloon met render-ingang, voorbeelddata en een forkbare structuur.`,
pluginTitle: (kind, id) => `${kind}-plugin · ${id}`,
pluginDescription: (kind, labels) => `Open Design-plugin voor ${kind}-workflows. Na installatie werkt hij lokaal via de daemon en od CLI${labels.length ? `; bereik: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Gebruik deze plugin om een ${kind}-taak te maken en het resultaat in de lokale Open Design-workspace te bekijken.`,
blogTitle: (topic) => `Open Design-gids: ${topic}`,
blogSummary: (topic) => `Gelokaliseerde samenvatting van ${topic} binnen de local-first, BYOK en composeerbare skill-workflow van Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Gelokaliseerde samenvatting</h2><p>Dit artikel legt uit hoe Open Design ${topic}, artefacten, skills, designsystemen en lokale agenten verbindt.</p><p>Deze pagina gebruikt een i18n-fallback. Een volledige vertaling kan via <code>i18n.bodyHtml</code> in de frontmatter worden geplaatst.</p>`,
},
ar: {
skillNoun: 'مهارة',
systemNoun: 'نظام تصميم',
templateNoun: 'قالب',
craftNoun: 'قاعدة جودة',
pluginNoun: 'إضافة',
blogNoun: 'مقال',
unknownTag: 'تصنيف',
skillDescription: (name, labels) => `${name} مهارة قابلة للتركيب في Open Design لسير عمل ${labels.join('، ') || 'إنتاج التصميم'}. تعمل مع الوكيل المحلي وتعيد استخدام أنظمة DESIGN.md.`,
systemTagline: (name, category) => `${name} يحول اتجاه ${category} إلى نظام DESIGN.md قابل للنقل لكل المهارات.`,
systemAtmosphere: (name, category, paletteCount) => `${name} يصف ${category} عبر ${paletteCount} ألوان أساسية وإيقاع طباعي وقواعد مكونات وأنماط يجب تجنبها.`,
craftName: (name) => `قاعدة جودة ${name}`,
craftSummary: (name) => `تحدد هذه القاعدة معيار ${name} حتى ينتج الوكيل ملفات متسقة وقابلة للتسليم.`,
templateName: (name) => `قالب ${name}`,
templateSummary: (name) => `${name} قالب Live Artifact قابل لإعادة الاستخدام، مع مدخل عرض وبيانات مثال وبنية قابلة للتفرع.`,
pluginTitle: (kind, id) => `إضافة ${kind} · ${id}`,
pluginDescription: (kind, labels) => `إضافة Open Design لسير عمل ${kind}. بعد التثبيت تعمل محليا عبر daemon و od CLI${labels.length ? `؛ النطاق: ${labels.join('، ')}` : ''}.`,
pluginExample: (kind) => `استخدم هذه الإضافة لإنشاء مهمة ${kind} ومراجعة النتيجة في مساحة عمل Open Design المحلية.`,
blogTitle: (topic) => `دليل Open Design: ${topic}`,
blogSummary: (topic) => `ملخص محلي حول ${topic} ضمن سير Open Design المحلي و BYOK والمهارات القابلة للتركيب.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>ملخص محلي</h2><p>تشرح هذه المقالة كيف يصل Open Design بين ${topic} والملفات والمهارات وأنظمة التصميم والوكلاء المحليين.</p><p>تعرض الصفحة حاليا نصا عبر i18n fallback؛ يمكن توفير ترجمة كاملة عبر <code>i18n.bodyHtml</code> في frontmatter.</p>`,
},
tr: {
skillNoun: 'skill',
systemNoun: 'tasarim sistemi',
templateNoun: 'sablon',
craftNoun: 'craft kurali',
pluginNoun: 'eklenti',
blogNoun: 'yazi',
unknownTag: 'kategori',
skillDescription: (name, labels) => `${name}, ${labels.join(', ') || 'tasarim artifact'} akislarinda kullanilan birlesebilir bir Open Design skillidir. Yerel ajanla calisir ve DESIGN.md sistemlerini yeniden kullanir.`,
systemTagline: (name, category) => `${name}, ${category} yonunu tum skilllerin kullanabilecegi tasinabilir bir DESIGN.md sistemine donusturur.`,
systemAtmosphere: (name, category, paletteCount) => `${name}, ${category} icin ${paletteCount} ana renk, tipografi, bilesen sinirlari ve anti-pattern kurallari tanimlar.`,
craftName: (name) => `${name} craft kurali`,
craftSummary: (name) => `Bu Open Design kurali ${name} standardini belirler; ajanlarin tutarli, okunabilir ve teslim edilebilir artifact uretmesine yardim eder.`,
templateName: (name) => `${name} sablonu`,
templateSummary: (name) => `${name}, render girisi, ornek veri ve fork edilebilir dosya yapisi iceren yeniden kullanilabilir bir Live Artifact sablonudur.`,
pluginTitle: (kind, id) => `${kind} eklentisi · ${id}`,
pluginDescription: (kind, labels) => `${kind} akislari icin Open Design eklentisi. Kurulumdan sonra yerel daemon ve od CLI ile calisir${labels.length ? `; kapsam: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Bu eklentiyle bir ${kind} gorevi olusturun ve sonucu yerel Open Design workspace'inde inceleyin.`,
blogTitle: (topic) => `Open Design rehberi: ${topic}`,
blogSummary: (topic) => `${topic} konusunu Open Design'in local-first, BYOK ve birlesebilir skill akisi baglaminda ozetler.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Yerellestirilmis ozet</h2><p>Bu yazi Open Design'in ${topic}, artifact, skill, tasarim sistemi ve yerel ajanlari nasil bagladigini aciklar.</p><p>Sayfa su anda i18n fallback kullanir. Tam ceviri frontmatter icindeki <code>i18n.bodyHtml</code> ile verilebilir.</p>`,
},
uk: {
skillNoun: 'навичка',
systemNoun: 'дизайн-система',
templateNoun: 'шаблон',
craftNoun: 'правило якості',
pluginNoun: 'плагін',
blogNoun: 'стаття',
unknownTag: 'категорія',
skillDescription: (name, labels) => `${name} — компонована навичка Open Design для сценаріїв ${labels.join(', ') || 'дизайн-артефактів'}. Її запускає локальний агент, а системи DESIGN.md можна перевикористовувати.`,
systemTagline: (name, category) => `${name} перетворює напрям ${category} на переносну DESIGN.md дизайн-систему для всіх навичок.`,
systemAtmosphere: (name, category, paletteCount) => `${name} описує ${category}: ${paletteCount} основних кольорів, типографіку, компоненти й анти-патерни.`,
craftName: (name) => `Правило якості: ${name}`,
craftSummary: (name) => `Це правило Open Design задає стандарт ${name}, щоб агент створював послідовні й готові до передачі артефакти.`,
templateName: (name) => `Шаблон ${name}`,
templateSummary: (name) => `${name} — багаторазовий Live Artifact шаблон із точкою рендеру, прикладом даних і структурою для fork.`,
pluginTitle: (kind, id) => `Плагін ${kind} · ${id}`,
pluginDescription: (kind, labels) => `Плагін Open Design для сценаріїв ${kind}. Після встановлення працює локально через daemon і CLI od${labels.length ? `; охоплення: ${labels.join(', ')}` : ''}.`,
pluginExample: (kind) => `Створіть завдання ${kind} цим плагіном і перевірте результат у локальному workspace Open Design.`,
blogTitle: (topic) => `Гід Open Design: ${topic}`,
blogSummary: (topic) => `Локалізоване резюме про ${topic} у local-first, BYOK і компонованому skill-процесі Open Design.`,
blogBody: (topic, summary) => `<p>${summary}</p><h2>Локалізоване резюме</h2><p>Стаття пояснює, як Open Design поєднує ${topic}, артефакти, навички, дизайн-системи й локальних агентів.</p><p>Зараз сторінка використовує i18n fallback. Повний переклад можна задати через <code>i18n.bodyHtml</code> у frontmatter.</p>`,
},
};
const TAXONOMY_TERMS: Record<string, Partial<Record<LandingLocaleCode, string>>> = {
prototype: { zh: '原型', 'zh-tw': '原型', ja: 'プロトタイプ', ko: '프로토타입', de: 'Prototyp', fr: 'prototype', ru: 'прототип', es: 'prototipo', 'pt-br': 'prototipo', it: 'prototipo', vi: 'nguyen mau', pl: 'prototyp', id: 'prototipe', nl: 'prototype', ar: 'نموذج أولي', tr: 'prototip', uk: 'прототип' },
template: { zh: '模板', 'zh-tw': '模板', ja: 'テンプレート', ko: '템플릿', de: 'Vorlage', fr: 'modele', ru: 'шаблон', es: 'plantilla', 'pt-br': 'modelo', it: 'modello', vi: 'mau', pl: 'szablon', id: 'templat', nl: 'sjabloon', ar: 'قالب', tr: 'sablon', uk: 'шаблон' },
deck: { zh: '演示文稿', 'zh-tw': '簡報', ja: 'スライド', ko: '슬라이드', de: 'Deck', fr: 'presentation', ru: 'презентация', es: 'presentacion', 'pt-br': 'apresentacao', it: 'presentazione', vi: 'slide', pl: 'prezentacja', id: 'presentasi', nl: 'presentatie', ar: 'عرض تقديمي', tr: 'sunum', uk: 'презентація' },
image: { zh: '图像', 'zh-tw': '影像', ja: '画像', ko: '이미지', de: 'Bild', fr: 'image', ru: 'изображение', es: 'imagen', 'pt-br': 'imagem', it: 'immagine', vi: 'hinh anh', pl: 'obraz', id: 'gambar', nl: 'afbeelding', ar: 'صورة', tr: 'gorsel', uk: 'зображення' },
video: { zh: '视频', 'zh-tw': '影片', ja: '動画', ko: '비디오', de: 'Video', fr: 'video', ru: 'видео', es: 'video', 'pt-br': 'video', it: 'video', vi: 'video', pl: 'wideo', id: 'video', nl: 'video', ar: 'فيديو', tr: 'video', uk: 'відео' },
audio: { zh: '音频', 'zh-tw': '音訊', ja: '音声', ko: '오디오', de: 'Audio', fr: 'audio', ru: 'аудио', es: 'audio', 'pt-br': 'audio', it: 'audio', vi: 'am thanh', pl: 'audio', id: 'audio', nl: 'audio', ar: 'صوت', tr: 'ses', uk: 'аудіо' },
utility: { zh: '工具', 'zh-tw': '工具', ja: 'ユーティリティ', ko: '유틸리티', de: 'Werkzeug', fr: 'outil', ru: 'утилита', es: 'utilidad', 'pt-br': 'utilitario', it: 'utility', vi: 'tien ich', pl: 'narzedzie', id: 'utilitas', nl: 'hulpmiddel', ar: 'أداة', tr: 'arac', uk: 'утиліта' },
design: { zh: '设计', 'zh-tw': '設計', ja: 'デザイン', ko: '디자인', de: 'Design', fr: 'design', ru: 'дизайн', es: 'diseño', 'pt-br': 'design', it: 'design', vi: 'thiet ke', pl: 'design', id: 'desain', nl: 'design', ar: 'تصميم', tr: 'tasarim', uk: 'дизайн' },
marketing: { zh: '营销', 'zh-tw': '行銷', ja: 'マーケティング', ko: '마케팅', de: 'Marketing', fr: 'marketing', ru: 'маркетинг', es: 'marketing', 'pt-br': 'marketing', it: 'marketing', vi: 'marketing', pl: 'marketing', id: 'pemasaran', nl: 'marketing', ar: 'تسويق', tr: 'pazarlama', uk: 'маркетинг' },
operations: { zh: '运营', 'zh-tw': '營運', ja: '運用', ko: '운영', de: 'Betrieb', fr: 'operations', ru: 'операции', es: 'operaciones', 'pt-br': 'operacoes', it: 'operazioni', vi: 'van hanh', pl: 'operacje', id: 'operasi', nl: 'operaties', ar: 'عمليات', tr: 'operasyon', uk: 'операції' },
product: { zh: '产品', 'zh-tw': '產品', ja: 'プロダクト', ko: '제품', de: 'Produkt', fr: 'produit', ru: 'продукт', es: 'producto', 'pt-br': 'produto', it: 'prodotto', vi: 'san pham', pl: 'produkt', id: 'produk', nl: 'product', ar: 'منتج', tr: 'urun', uk: 'продукт' },
personal: { zh: '个人', 'zh-tw': '個人', ja: '個人', ko: '개인', de: 'Persoenlich', fr: 'personnel', ru: 'личное', es: 'personal', 'pt-br': 'pessoal', it: 'personale', vi: 'ca nhan', pl: 'osobiste', id: 'personal', nl: 'persoonlijk', ar: 'شخصي', tr: 'kisisel', uk: 'особисте' },
finance: { zh: '金融', 'zh-tw': '金融', ja: '金融', ko: '금융', de: 'Finanzen', fr: 'finance', ru: 'финансы', es: 'finanzas', 'pt-br': 'financas', it: 'finanza', vi: 'tai chinh', pl: 'finanse', id: 'keuangan', nl: 'financien', ar: 'مالية', tr: 'finans', uk: 'фінанси' },
docs: { zh: '文档', 'zh-tw': '文件', ja: 'ドキュメント', ko: '문서', de: 'Dokumente', fr: 'documents', ru: 'документы', es: 'documentos', 'pt-br': 'documentos', it: 'documenti', vi: 'tai lieu', pl: 'dokumenty', id: 'dokumen', nl: 'documenten', ar: 'مستندات', tr: 'belgeler', uk: 'документи' },
};
const CRAFT_LABELS: Record<string, Partial<Record<LandingLocaleCode, string>>> = {
color: { zh: '色彩', 'zh-tw': '色彩', ja: 'カラー', ko: '색상', de: 'Farbe', fr: 'couleur', ru: 'цвет', es: 'color', 'pt-br': 'cor', it: 'colore', vi: 'mau sac', pl: 'kolor', id: 'warna', nl: 'kleur', ar: 'اللون', tr: 'renk', uk: 'колір' },
typography: { zh: '排版', 'zh-tw': '排版', ja: 'タイポグラフィ', ko: '타이포그래피', de: 'Typografie', fr: 'typographie', ru: 'типографика', es: 'tipografia', 'pt-br': 'tipografia', it: 'tipografia', vi: 'kieu chu', pl: 'typografia', id: 'tipografi', nl: 'typografie', ar: 'الطباعة', tr: 'tipografi', uk: 'типографіка' },
'rtl-and-bidi': { zh: 'RTL 与双向文本', 'zh-tw': 'RTL 與雙向文字', ja: 'RTL と双方向テキスト', ko: 'RTL 및 양방향 텍스트', de: 'RTL und bidirektionaler Text', fr: 'RTL et texte bidirectionnel', ru: 'RTL и двунаправленный текст', es: 'RTL y texto bidireccional', 'pt-br': 'RTL e texto bidirecional', it: 'RTL e testo bidirezionale', vi: 'RTL va van ban hai chieu', pl: 'RTL i tekst dwukierunkowy', id: 'RTL dan teks dua arah', nl: 'RTL en bidirectionele tekst', ar: 'النص من اليمين والاتجاه المزدوج', tr: 'RTL ve cift yonlu metin', uk: 'RTL і двонапрямний текст' },
};
const CATEGORY_LABELS: Record<string, Partial<Record<LandingLocaleCode, string>>> = {
'ai & llm': { zh: 'AI 与大模型', 'zh-tw': 'AI 與大模型', ja: 'AI と LLM', ko: 'AI 및 LLM', de: 'KI und LLM', fr: 'IA et LLM', ru: 'AI и LLM', es: 'IA y LLM', 'pt-br': 'IA e LLM', it: 'IA e LLM', vi: 'AI va LLM', pl: 'AI i LLM', id: 'AI dan LLM', nl: 'AI en LLM', ar: 'الذكاء الاصطناعي والنماذج اللغوية', tr: 'AI ve LLM', uk: 'AI та LLM' },
'developer tools': { zh: '开发者工具', 'zh-tw': '開發者工具', ja: '開発者ツール', ko: '개발자 도구', de: 'Entwicklerwerkzeuge', fr: 'outils developpeur', ru: 'инструменты разработчика', es: 'herramientas de desarrollo', 'pt-br': 'ferramentas de desenvolvimento', it: 'strumenti per sviluppatori', vi: 'cong cu lap trinh', pl: 'narzedzia developerskie', id: 'alat developer', nl: 'ontwikkelaarstools', ar: 'أدوات المطورين', tr: 'gelistirici araclari', uk: 'інструменти розробника' },
'productivity & saas': { zh: '效率与 SaaS', 'zh-tw': '效率與 SaaS', ja: '生産性と SaaS', ko: '생산성 및 SaaS', de: 'Produktivitaet und SaaS', fr: 'productivite et SaaS', ru: 'продуктивность и SaaS', es: 'productividad y SaaS', 'pt-br': 'produtividade e SaaS', it: 'produttivita e SaaS', vi: 'nang suat va SaaS', pl: 'produktywnosc i SaaS', id: 'produktivitas dan SaaS', nl: 'productiviteit en SaaS', ar: 'الإنتاجية وSaaS', tr: 'uretkenlik ve SaaS', uk: 'продуктивність і SaaS' },
'design & creative': { zh: '设计与创意', 'zh-tw': '設計與創意', ja: 'デザインとクリエイティブ', ko: '디자인 및 크리에이티브', de: 'Design und Kreativitaet', fr: 'design et creation', ru: 'дизайн и креатив', es: 'diseño y creatividad', 'pt-br': 'design e criatividade', it: 'design e creativita', vi: 'thiet ke va sang tao', pl: 'design i kreatywnosc', id: 'desain dan kreatif', nl: 'design en creativiteit', ar: 'التصميم والإبداع', tr: 'tasarim ve yaraticilik', uk: 'дизайн і креатив' },
};
const normalizeTerm = (value: string) => value.trim().toLowerCase();
const copyFor = (locale: LandingLocaleCode): ContentCopy | undefined =>
locale === DEFAULT_LOCALE ? undefined : CONTENT_COPY[locale];
const compactId = (value: string) =>
value
.split('/')
.at(-1)!
.replace(/^example-/, '')
.replace(/^design-system-/, '')
.replace(/^video-template-/, '')
.replace(/^image-template-/, '')
.replace(/^od-/, 'od-');
const BLOG_TOPIC_TITLES: Record<string, Partial<Record<Exclude<LandingLocaleCode, 'en'>, string>>> = {
'31-skills-72-systems-how-the-library-works': {
zh: '31 个 Skill 与 72 个系统的资料库运作方式',
'zh-tw': '31 個 Skill 與 72 個系統的資料庫運作方式',
ja: '31個のSkillと72個のシステムのライブラリ構造',
ko: '31개 Skill과 72개 시스템 라이브러리의 작동 방식',
de: 'wie die Bibliothek mit 31 Skills und 72 Systemen funktioniert',
fr: 'le fonctionnement de la bibliothèque de 31 skills et 72 systèmes',
ru: 'как работает библиотека из 31 навыка и 72 систем',
es: 'cómo funciona la biblioteca de 31 skills y 72 sistemas',
'pt-br': 'como funciona a biblioteca de 31 skills e 72 sistemas',
it: 'come funziona la libreria con 31 skill e 72 sistemi',
vi: 'cách vận hành thư viện 31 skill và 72 hệ thống',
pl: 'jak działa biblioteka 31 skill i 72 systemów',
id: 'cara kerja pustaka 31 skill dan 72 sistem',
nl: 'hoe de bibliotheek met 31 skills en 72 systemen werkt',
ar: 'طريقة عمل مكتبة تضم 31 مهارة و72 نظاما',
tr: '31 skill ve 72 sistemden oluşan kitaplığın çalışma biçimi',
uk: 'як працює бібліотека з 31 навички та 72 систем',
},
'byok-design-workflow-claude-codex-qwen': {
zh: '面向 Claude、Codex 与 Qwen 的 BYOK 设计工作流',
'zh-tw': '面向 Claude、Codex 與 Qwen 的 BYOK 設計工作流',
ja: 'Claude、Codex、Qwen向けBYOKデザインワークフロー',
ko: 'Claude, Codex, Qwen을 위한 BYOK 디자인 워크플로',
de: 'BYOK-Designworkflow für Claude, Codex und Qwen',
fr: 'workflow de design BYOK pour Claude, Codex et Qwen',
ru: 'BYOK-дизайн-процесс для Claude, Codex и Qwen',
es: 'flujo de diseño BYOK para Claude, Codex y Qwen',
'pt-br': 'fluxo de design BYOK para Claude, Codex e Qwen',
it: 'workflow di design BYOK per Claude, Codex e Qwen',
vi: 'quy trình thiết kế BYOK cho Claude, Codex và Qwen',
pl: 'workflow projektowy BYOK dla Claude, Codex i Qwen',
id: 'alur desain BYOK untuk Claude, Codex, dan Qwen',
nl: 'BYOK-designworkflow voor Claude, Codex en Qwen',
ar: 'سير عمل تصميم BYOK مع Claude وCodex وQwen',
tr: 'Claude, Codex ve Qwen için BYOK tasarım akışı',
uk: 'BYOK дизайн-процес для Claude, Codex і Qwen',
},
'byok-reality-check-5-things-that-break': {
zh: 'BYOK 现实检查5 个容易断裂的环节',
'zh-tw': 'BYOK 現實檢查5 個容易斷裂的環節',
ja: 'BYOKの現実チェック: 壊れやすい5つの点',
ko: 'BYOK 현실 점검: 깨지기 쉬운 5가지 지점',
de: 'BYOK-Realitätscheck: fünf Dinge, die brechen',
fr: 'réalité BYOK : cinq points qui cassent',
ru: 'проверка BYOK на практике: пять слабых мест',
es: 'revisión realista de BYOK: cinco puntos que fallan',
'pt-br': 'checagem realista do BYOK: cinco pontos que quebram',
it: 'reality check BYOK: cinque punti che si rompono',
vi: 'kiểm tra thực tế BYOK: 5 điểm dễ hỏng',
pl: 'sprawdzenie BYOK w praktyce: pięć miejsc awarii',
id: 'cek realitas BYOK: lima hal yang mudah rusak',
nl: 'BYOK-realiteitscheck: vijf dingen die breken',
ar: 'اختبار واقعي ل BYOK: خمسة مواضع تتعطل',
tr: 'BYOK gerçeklik kontrolü: bozulan beş nokta',
uk: 'реалістична перевірка BYOK: пʼять місць, які ламаються',
},
'layout-layer-canvas-used-to-hide': {
zh: '过去被画布隐藏的布局层',
'zh-tw': '過去被畫布隱藏的版面層',
ja: 'キャンバスが隠していたレイアウト層',
ko: '캔버스가 숨겨 왔던 레이아웃 계층',
de: 'die Layoutschicht, die Canvas früher verborgen hat',
fr: 'la couche de mise en page que le canvas cachait',
ru: 'слой макета, который раньше скрывал canvas',
es: 'la capa de layout que antes ocultaba el canvas',
'pt-br': 'a camada de layout que o canvas escondia',
it: 'il livello di layout che il canvas nascondeva',
vi: 'lớp bố cục từng bị canvas che khuất',
pl: 'warstwa layoutu, którą dawniej ukrywał canvas',
id: 'lapisan layout yang dulu disembunyikan kanvas',
nl: 'de layoutlaag die canvas vroeger verborg',
ar: 'طبقة التخطيط التي كان canvas يخفيها',
tr: 'canvasın eskiden sakladığı yerleşim katmanı',
uk: 'шар макета, який раніше приховував canvas',
},
'open-source-alternative-to-claude-design': {
zh: 'Claude Design 的开源替代方案',
'zh-tw': 'Claude Design 的開源替代方案',
ja: 'Claude Designのオープンソース代替',
ko: 'Claude Design의 오픈소스 대안',
de: 'Open-Source-Alternative zu Claude Design',
fr: 'alternative open source à Claude Design',
ru: 'open-source альтернатива Claude Design',
es: 'alternativa open source a Claude Design',
'pt-br': 'alternativa open source ao Claude Design',
it: 'alternativa open source a Claude Design',
vi: 'giải pháp mã nguồn mở thay cho Claude Design',
pl: 'open source alternatywa dla Claude Design',
id: 'alternatif open source untuk Claude Design',
nl: 'open-source alternatief voor Claude Design',
ar: 'بديل مفتوح المصدر ل Claude Design',
tr: 'Claude Design için açık kaynak alternatif',
uk: 'open-source альтернатива Claude Design',
},
'port-figma-workflow-open-design-plugin': {
zh: '把 Figma 工作流迁移成 Open Design 插件',
'zh-tw': '把 Figma 工作流遷移成 Open Design 外掛',
ja: 'FigmaワークフローをOpen Designプラグインへ移植する',
ko: 'Figma 워크플로를 Open Design 플러그인으로 옮기기',
de: 'Figma-Workflows als Open-Design-Plugin portieren',
fr: 'porter un workflow Figma en plugin Open Design',
ru: 'перенос Figma-процесса в плагин Open Design',
es: 'llevar un flujo de Figma a un plugin de Open Design',
'pt-br': 'migrar um fluxo do Figma para um plugin Open Design',
it: 'portare un workflow Figma in un plugin Open Design',
vi: 'chuyển quy trình Figma thành plugin Open Design',
pl: 'przenoszenie workflow Figma do pluginu Open Design',
id: 'memindahkan alur Figma menjadi plugin Open Design',
nl: 'een Figma-workflow omzetten naar een Open Design-plugin',
ar: 'نقل سير عمل Figma إلى إضافة Open Design',
tr: 'Figma akışını Open Design eklentisine taşıma',
uk: 'перенесення Figma-процесу в плагін Open Design',
},
'why-we-built-open-design-as-a-skill-layer': {
zh: '为什么把 Open Design 做成 Skill 层',
'zh-tw': '為什麼把 Open Design 做成 Skill 層',
ja: 'Open DesignをSkillレイヤーとして作った理由',
ko: 'Open Design을 Skill 레이어로 만든 이유',
de: 'warum Open Design als Skill-Schicht gebaut wurde',
fr: 'pourquoi Open Design est une couche de skills',
ru: 'почему Open Design построен как слой навыков',
es: 'por qué Open Design se construyó como capa de skills',
'pt-br': 'por que o Open Design foi criado como camada de skills',
it: 'perché Open Design è stato costruito come livello di skill',
vi: 'vì sao Open Design được xây như một lớp skill',
pl: 'dlaczego Open Design powstał jako warstwa skill',
id: 'mengapa Open Design dibangun sebagai lapisan skill',
nl: 'waarom Open Design als skill-laag is gebouwd',
ar: 'لماذا بنينا Open Design كطبقة مهارات',
tr: 'Open Design neden bir skill katmanı olarak kuruldu',
uk: 'чому Open Design створено як шар навичок',
},
};
const localizedBlogTopic = (id: string, locale: LandingLocaleCode) => {
const compact = compactId(id);
if (locale === DEFAULT_LOCALE) return compact.replace(/-/g, ' ');
return BLOG_TOPIC_TITLES[compact]?.[locale] ?? compact.replace(/-/g, ' ');
};
export function explicitLocalizedString(
value: LocalizedStringValue,
locale: LandingLocaleCode,
): string | undefined {
if (typeof value === 'string') {
return locale === DEFAULT_LOCALE && value.trim() ? value.trim() : undefined;
}
if (!value || typeof value !== 'object') return undefined;
const localeDef = getLocaleDefinition(locale);
const candidates = [
locale,
localeDef.htmlLang,
localeDef.htmlLang.toLowerCase(),
localeDef.htmlLang.replace('-', '_'),
locale === 'zh' ? 'zh-CN' : undefined,
locale === 'zh-tw' ? 'zh-TW' : undefined,
locale === 'pt-br' ? 'pt-BR' : undefined,
].filter((item): item is string => Boolean(item));
for (const key of candidates) {
const text = value[key];
if (typeof text === 'string' && text.trim()) {
return text.trim();
}
}
return undefined;
}
export function localizeTaxonomyValue(
value: string | undefined,
locale: LandingLocaleCode,
): string | undefined {
if (!value) return undefined;
if (locale === DEFAULT_LOCALE) return value;
const key = normalizeTerm(value);
return (
TAXONOMY_TERMS[key]?.[locale] ??
CATEGORY_LABELS[key]?.[locale] ??
copyFor(locale)?.unknownTag
);
}
export function localizeContentTag(
value: string | undefined,
locale: LandingLocaleCode,
): string | undefined {
if (!value) return undefined;
if (locale === DEFAULT_LOCALE) return value;
return localizeTaxonomyValue(value, locale) ?? copyFor(locale)?.unknownTag;
}
export function localizeSkillDescription(args: {
name: string;
mode?: string;
scenario?: string;
category?: string;
locale: LandingLocaleCode;
fallback: string;
}): string {
const copy = copyFor(args.locale);
if (!copy) return args.fallback;
const labels = [args.mode, args.scenario, args.category]
.map((value) => localizeTaxonomyValue(value, args.locale))
.filter((value): value is string => Boolean(value));
return copy.skillDescription(args.name, Array.from(new Set(labels)));
}
export function localizeSystemText(args: {
name: string;
category: string;
paletteCount: number;
locale: LandingLocaleCode;
fallbackTagline: string;
fallbackAtmosphere: string;
}): { category: string; tagline: string; atmosphere: string } {
const copy = copyFor(args.locale);
if (!copy) {
return {
category: args.category,
tagline: args.fallbackTagline,
atmosphere: args.fallbackAtmosphere,
};
}
const category = localizeTaxonomyValue(args.category, args.locale) ?? copy.systemNoun;
return {
category,
tagline: copy.systemTagline(args.name, category),
atmosphere: copy.systemAtmosphere(args.name, category, args.paletteCount),
};
}
export function localizeCraftText(args: {
slug: string;
name: string;
summary: string;
locale: LandingLocaleCode;
}): { name: string; summary: string } {
const copy = copyFor(args.locale);
if (!copy) return { name: args.name, summary: args.summary };
const baseName = CRAFT_LABELS[args.slug]?.[args.locale] ?? args.name;
return {
name: copy.craftName(baseName),
summary: copy.craftSummary(baseName),
};
}
export function localizeTemplateText(args: {
name: string;
summary: string;
locale: LandingLocaleCode;
}): { name: string; summary: string } {
const copy = copyFor(args.locale);
if (!copy) return { name: args.name, summary: args.summary };
return {
name: copy.templateName(args.name),
summary: copy.templateSummary(args.name),
};
}
export function localizePluginText(args: {
id: string;
title: string;
description: string;
locale: LandingLocaleCode;
mode?: string;
taskKind?: string;
surface?: string;
visualKind?: string;
labels?: string[];
}): { title: string; description: string; exampleQuery: string | undefined } {
const copy = copyFor(args.locale);
if (!copy) {
return {
title: args.title,
description: args.description,
exampleQuery: undefined,
};
}
const kind =
localizeTaxonomyValue(args.mode ?? args.surface ?? args.visualKind, args.locale) ??
copy.pluginNoun;
const labels = (args.labels ?? [])
.map((value) => localizeTaxonomyValue(value, args.locale))
.filter((value): value is string => Boolean(value));
return {
title: copy.pluginTitle(kind, compactId(args.id)),
description: copy.pluginDescription(kind, Array.from(new Set(labels)).slice(0, 4)),
exampleQuery: copy.pluginExample(kind),
};
}
export function localizeBlogPostText(args: {
id: string;
title: string;
summary: string;
category: string;
locale: LandingLocaleCode;
}): { title: string; summary: string; category: string; bodyHtml: string | undefined } {
const copy = copyFor(args.locale);
if (!copy) {
return {
title: args.title,
summary: args.summary,
category: args.category,
bodyHtml: undefined,
};
}
const topic = localizedBlogTopic(args.id, args.locale);
const title = copy.blogTitle(topic);
const summary = copy.blogSummary(topic);
return {
title,
summary,
category: localizeTaxonomyValue(args.category, args.locale) ?? copy.blogNoun,
bodyHtml: copy.blogBody(topic, summary),
};
}

View file

@ -7,11 +7,34 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const localizedContentSchema = z
.record(
z.string(),
z
.object({
name: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
summary: z.string().optional(),
category: z.string().optional(),
tagline: z.string().optional(),
atmosphere: z.string().optional(),
body: z.string().optional(),
bodyHtml: z.string().optional(),
triggers: z.array(z.string()).optional(),
examplePrompt: z.string().optional(),
example_prompt: z.string().optional(),
})
.passthrough(),
)
.optional();
const skillSchema = z
.object({
name: z.string().optional(),
description: z.string().optional(),
triggers: z.array(z.string()).optional(),
i18n: localizedContentSchema,
od: z
.object({
mode: z.string().optional(),
@ -53,7 +76,7 @@ const systems = defineCollection({
base: '../../design-systems',
pattern: '*/DESIGN.md',
}),
schema: z.object({}).passthrough(),
schema: z.object({ i18n: localizedContentSchema }).passthrough(),
});
const craft = defineCollection({
@ -61,7 +84,7 @@ const craft = defineCollection({
base: '../../craft',
pattern: '*.md',
}),
schema: z.object({}).passthrough(),
schema: z.object({ i18n: localizedContentSchema }).passthrough(),
});
// `templates/live-artifacts/<slug>/README.md` — legacy Live Artifact bundles.
@ -73,7 +96,7 @@ const templates = defineCollection({
base: '../../templates/live-artifacts',
pattern: '*/README.md',
}),
schema: z.object({}).passthrough(),
schema: z.object({ i18n: localizedContentSchema }).passthrough(),
});
// Blog posts live in `app/content/blog/*.md`. Each post must declare a typed
@ -87,13 +110,29 @@ const blog = defineCollection({
pattern: ['*.md', '!_*.md'],
base: './app/content/blog',
}),
schema: z.object({
schema: z
.object({
title: z.string(),
date: z.coerce.date(),
category: z.enum(['Product', 'Guides', 'Use cases', 'Community']),
readingTime: z.number().int().positive(),
summary: z.string(),
}),
i18n: z
.record(
z.string(),
z
.object({
title: z.string().optional(),
summary: z.string().optional(),
category: z.string().optional(),
body: z.string().optional(),
bodyHtml: z.string().optional(),
})
.passthrough(),
)
.optional(),
})
.passthrough(),
});
// Tutorials live in `app/content/tutorials/*.md`. Each entry maps to a

View file

@ -1,6 +1,6 @@
# Open Design — blog topic backlog
_Last reviewed: 2026-05-14 (Claude Design cluster scoring pass)_
_Last reviewed: 2026-05-22 (0.8.0 release scoring pass)_
This file is the single source of truth for what we're considering, drafting, and have shipped on the Open Design blog. The leading underscore keeps Astro's content collection from treating it as a post.
@ -31,7 +31,7 @@ Scored, not yet drafting. Sorted by total descending. Pick from the top when sta
| 6 | **Claude Design vs Figma Make vs Open Design** (A3) | Guides (comparison) | 4 | 5 | 4 | 4 | **17** | strong existing SERP for "claude design vs figma make" (Magic Patterns / XDA / CreateWith / claude-codex.fr); no vs-Open-Design content yet | Three-way comparison piggybacking on existing search heat. Use the existing comparison tables as evidence. |
| 7 | What an agent-native design system looks like (a DESIGN.md walkthrough) | Guides (how it works) | 5 | 3 | 4 | 4 | **16** | own framework | Sibling to the "31 skills, 72 systems" post but at the system layer. Useful for design-system-curious readers. |
| 8 | Inside Atelier Zero — designing the Open Design landing page with the agent that ships it | Use cases | 5 | 3 | 4 | 4 | **16** | own dogfooding | Meta in the right way: the page you're reading was built this way. Hero = a real screen recording still. |
| 9 | We just shipped in-app auto-update on Windows + Linux (no code signing required) | Product (announcement) | 5 | 3 | 4 | 4 | **16** | gh issue #1613 (closed 2026-05-13) | Reactive announcement — the closing PR is the artefact. CTA = `Download desktop`. Pair with the #1612 "worried about updating" thread for empathy beat. |
| ~~9~~ | ~~We just shipped in-app auto-update on Windows + Linux~~ | ~~Product (announcement)~~ | ~~5~~ | ~~3~~ | ~~4~~ | ~~4~~ | ~~**16**~~ | gh issue #1613 (closed 2026-05-13) | **Folded into the 0.8.0 release announcement (shipped 2026-05-22). Auto-update lands as one of the three architectural plates in that post.** |
| 10 | 96 contributors in our first month — what the Synclo bot taught us about open-source onboarding | Community (essay) | 4 | 3 | 4 | 5 | **16** | bot leveling cards across all issues + #1605 #1637 examples | Meta-essay about how the project runs. Easy to write — the data is already in the bot card images. CTA = `Contribute a skill`. |
| 11 | Inside the Skill protocol — how @-mention skills compose, and the regression we just fixed | Guides (how it works) | 5 | 3 | 4 | 4 | **16** | gh issue #1635 + PR #1636 | Pull a real bug-and-fix into a "how the system actually works" piece. Sibling to the seed post `31-skills-72-systems-how-the-library-works`. Wait until #1636 merges to publish. |
| 12 | **What Claude Design actually is — a designer-engineer's read of the launch** (B1) | Product (essay) | 4 | 4 | 4 | 4 | **16** | Anthropic launch April 2026 + first-hand use | Top-of-funnel explainer; high AI-Overviews citation potential. Pair with A1 (comparison) and A2 (pricing). |
@ -53,6 +53,24 @@ Currently being written. Move rows here from Active backlog before starting Pipe
|---|---|---|---|---|
| _(none — A1 shipped 2026-05-14, see Shipped table)_ | | | | |
### 0.8.0 release fast-track context
Re-scored 2026-05-22 against the live tag `open-design-v0.8.0` (305 PRs · 75 contributors · 7 days; `c20d156`, published 22 May 12:43 UTC). The release supersedes the previously-queued #9 "auto-update on Windows + Linux" row — that announcement is now folded into the broader 0.8.0 plate.
| Dim | Score | Why |
|---|---|---|
| Fit | 5 | Every claim in the post maps to a real PR or skill folder shipping in 0.8.0 |
| Intent | 5 | "Open Design 0.8.0 release notes / what's new" is a direct search intent |
| Timing | 5 | Reactive — within hours of the tag dropping; no third-party coverage yet |
| Effort | 4 | Release notes already structured; just needs editorial pass + hero plate |
| **Total** | **19** | Fast-track |
Title chosen (Step 2.5 fast-track): **"Open Design 0.8.0: everything is a plugin"** — declarative, hits the search intent, ≤ 60 chars.
Alternates considered:
- "Open Design 0.8.0 ships the plugin engine, headless CLI, and auto-update" (subhead-shaped, too long for a slug)
- "The 0.8.0 rebuild — and what it changes about agent-native design" (essay-shaped, less search-friendly)
### A1 fast-track context
Original score in 2026-05-13 backlog: 17. Re-scored to **19** on 2026-05-14 after live SERP check for "claude design open source alternative":
@ -96,6 +114,7 @@ Posts that are live. The right-hand columns get filled by the GSC review automat
| 2026-05-14 | open-source-alternative-to-claude-design | Guides (comparison) | 19 | pending (just shipped) | pending | — | 2026-06-14 | A1, anchor of the Claude Design keyword cluster (A1A4 / B1B3 / C1C2). Fast-tracked off live SERP intel: opendesigner.io already #2 for "claude design open source alternative"; this post targets the #1 slot. 1446 words. Title alternates: "Open Design vs Claude Design — and which one to pick this quarter" / "If you can't use Claude Design, what do you use instead?". Honest read on Anthropic's Claude Design (Opus 4.7, April 2026), `OpenCoworkAI/open-codesign` (MIT competitor), and Open Design with a who-picks-which table. CTA = `Try the open-source workflow`. Next step: ship A2 (pricing) + B1 (explainer) to thicken the cluster, then cross-post A1 to dev.to / Medium / HN. |
| 2026-05-18 | layout-layer-canvas-used-to-hide | Community | 16 | pending (just shipped) | pending | — | 2026-06-18 | Community post from GitHub Discussion #1727. Uses the "Layout Understanding Layer" reply as a product-community signal without promising implementation. CTA = `Contribute a skill`. |
| 2026-05-18 | port-figma-workflow-open-design-plugin | Use cases | 17 | pending (just shipped) | pending | — | 2026-06-18 | Use-case post from the 0.8.0-preview plugin call. Turns "port a Figma workflow" into a concrete `SKILL.md` + `open-design.json` + validate/pack/publish path. CTA = `Try this workflow`. |
| 2026-05-22 | open-design-0-8-0-everything-is-a-plugin | Product (announcement) | 19 | pending (just shipped) | pending | — | 2026-06-22 | Release announcement for `open-design-v0.8.0` (tag `c20d156`, 305 PRs · 75 contributors · 7 days). Covers the three architectural shifts (plugin engine, headless-by-default, plugins create plugins) and the packaged-auto-update + design-system + media-provider plate. CTA = `Download desktop`. Supersedes queued #9 "auto-update on Windows + Linux" — that row is dropped now that the broader plate is live. hero: plate-15-plugin-engine-modular.png · gpt-image-2 via cursor-builtin |
## Dropped (with reason)

View file

@ -0,0 +1,70 @@
---
title: "Open Design 0.8.0: everything is a plugin"
date: 2026-05-22
category: "Product"
readingTime: 7
summary: "Open Design 0.8.0 isn't a release, it's a rebuild. A small plugin engine, a headless-by-default CLI, packaged auto-update on macOS and Windows, and 149 design systems shipped in seven days."
---
Tag `open-design-v0.8.0` (`c20d156`), shipped 22 May 2026, 12:43 UTC. 305 PRs from 75 contributors in seven days. This is the release where we stopped trying to extend the old shape and rebuilt the engine underneath. The desktop app you'll download today is a thin wrapper around a CLI you can also point at from Claude Code, Cursor, or a Slack bot. The design systems, slices, prototypes, exports, and the old Figma-style workflows are no longer features baked into the engine — they're plugins, written against a small, boring core.
If you want the long version, the discussion thread has it. This post is the short version: what changed under the hood, what you can do with it today, and where to start.
## Why a rebuild, not another release
The 0.7 line had a problem. Every workflow lived inside the engine — design-system imports, deck templates, slice rendering, the Figma port, even the publish step — and adding the next thing meant editing the core. That is the dynamic that turned every editor before us into a plugin graveyard: a SaaS plugin API that locked behind a version, a "creator program" you had to apply to, a runtime that broke every two years.
We could have shipped 0.8 as another point release on that surface. Instead, we shipped the rewrite.
Underneath, three things are different now:
- The engine stayed small and boring. It runs skills, mounts plugins, calls agent adapters, and gets out of the way.
- Everything else became a plugin. Design systems, slices, prototypes, exports, the old Figma workflows — they all live in the same plugin format, registered through the same manifest, sandboxed through the same surface.
- The CLI is the canonical entry point. The desktop app calls into it; so does the OD MCP server; so does the agent in your terminal.
The 305 PRs in this release are mostly the work of porting the old world into the new shape. Some of them are the new shape itself.
## The three architectural plates
**Everything is a plugin.** The plugin registry surface now has a detail drawer with trust badges, a GitHub rate-limit-aware marketplace fallback, a polished publish footer, and a unified plugin / integration nav (#2087, #2064, #1806, #1849). Publishing a plugin creates a real GitHub repo under the author's account (#2332, #2363), and the CLI publish path reads the live manifest version instead of stubbing it (#1903). When the engine grows, it grows out here, in public.
**Headless by default.** The desktop app is now a thin wrapper around the OD CLI. The same engine runs from Claude Code, OpenClaw, Hermes Agent, and chat bots in Lark, Discord, and Slack. Custom CLI agent profiles ship in this release (#378), so you can plug an arbitrary CLI agent into the runtime without touching core. Design stops being a place you go and becomes a capability your agents have. This is what [the skill-layer manifesto](/blog/why-we-built-open-design-as-a-skill-layer/) was pointing at; 0.8.0 is the first release where the agent path is the canonical path, not a side door.
**Plugins create plugins.** OD CLI wraps GitHub CLI, so an agent can clone the repo, scaffold a plugin, validate it, pack it, and open a PR — for you, or for itself. The [how-to-port-a-Figma-workflow guide](/blog/port-figma-workflow-open-design-plugin/) walks the human path; the automated version of the same path is now reachable from inside any agent that has `gh` and `od` on `$PATH`. The engine grows itself, in public, with you in the loop.
## What else lands in 0.8.0
The release is wide. The pieces worth pulling forward:
- **149 design systems with structured `tokens.css` + components manifests.** Brand-token fixtures for Apple, Stripe, Airbnb, Vercel, Notion, Linear, GitHub, Figma, Slack, Discord, OpenAI, Shopify, Spotify, Uber, Cursor, and 50 more — each ships `tokens.css` and `components.html`, served through a default-on token channel (#1544, #1652, #1794, #1841, #2023, #2028, #2029, #2033). The [portable-system reasoning](/blog/open-source-alternative-to-claude-design/) is now the default surface, not a side door.
- **Critique Theater through Phase 16.** What was a single observable judge in 0.7.0 is now a fully-instrumented loop: Phase 9 web client wrapper with native de / ja / ko / zh-TW i18n, Phase 11 Playwright stage suite, Phase 12 with 9 Prometheus metrics + 6 log events + OTel span + Grafana dashboard, Phase 15 rollout resolver, Phase 16 M-phase rollout ratchet and `/api/critique/conformance` (#1315#1320, #1338, #1483#1485, #1499). Dark-launched at M0 by default.
- **Three new media providers.** Leonardo.ai image generation (#1123), ElevenLabs audio (#1384), and SenseAudio TTS plus BYOK chat with image and video tools (#1633, #2065). The media dispatcher now speaks OpenAI-compatible to anything you point it at.
- **Packaged auto-update on macOS and Windows.** First release where packaged installs self-update end-to-end on both platforms through the same R2 feed, with a refreshed updater popup, validated download / install handoff, and recovery from interrupted applies (#2270, #2362, #2376, #2403, #2429, #2565, #2575, #2592, #2595, #2677, #2687, #2700). The Linux packaged GUI is still deferred while we harden the lane; the headless lifecycle and the Nix flake both work today.
- **Italian (it) locale + CJK font fallback.** The UI now ships in 19 languages including Italian (#1323), and Chinese / Japanese / Korean text falls back to platform-native fonts instead of going through Latin substitution (#2227).
- **Top-to-bottom visual refresh.** New app icons, brand glyphs, refreshed wordmark — one coordinated drop in time for the cut (#2436).
The full list runs to 305 PRs. The [release notes on GitHub](https://github.com/nexu-io/open-design/releases/tag/open-design-v0.8.0) carry the rest.
## What to do with it today
Three paths, depending on where you start.
| If you're… | Start here |
|---|---|
| New to Open Design | Download the desktop app and let it bootstrap a project against an existing design system |
| Already running Open Design | Let the packaged auto-update bring you to 0.8.0; the in-app updater popup walks you through the validated install |
| Building a plugin | Scaffold with `od plugin scaffold --id <name>`, validate with `od plugin validate ./<path> --no-daemon`, and open a PR through the same OD publish path that ships every other plugin in the marketplace |
If you've been waiting for the agent-native loop to feel like the canonical loop instead of a demo, this is the release. Point Claude Code, Cursor, Codex, or any of the 16 detected CLI agents at the same OD CLI the desktop app ships with, and the two paths converge after the first prompt.
## What to do next
The fastest way to feel the difference between 0.7 and 0.8 is to install the desktop app, let it pick up your existing agent, and run the same brief you ran last month. The shape of the answer changes.
[Download desktop](https://github.com/nexu-io/open-design/releases/tag/open-design-v0.8.0).
## Related reading
- [Why we built Open Design as a skill layer, not a product](/blog/why-we-built-open-design-as-a-skill-layer/) — the longer manifesto behind the "engine plus plugins" bet 0.8.0 finishes paying off
- [How to port a Figma workflow into an Open Design plugin](/blog/port-figma-workflow-open-design-plugin/) — the practical version of the "plugins create plugins" loop
- [The open-source alternative to Claude Design](/blog/open-source-alternative-to-claude-design/) — where this release fits in the agent-native design landscape

View file

@ -5,12 +5,23 @@
* When the canonical example.html changes, mirror the diff here so the
* template's known-good rendering stays in lockstep with the deployed site.
*
* Font loading lives in `_components/font-stylesheet.astro` a server
* component injected into every page's <head>. We used to `@import` it
* inline here, but that serialized HTML CSS fonts CSS woff2 and
* cost ~3s on first paint. Moving to <link> + preconnect parallelizes
* the fetch with HTML parse.
* Fonts are self-hosted via `@fontsource-variable/*` packages. The variable
* font files (one woff2 per family, ~30-50KB each) cover the entire
* weight axis we use, are served same-origin (no Google Fonts CSS or
* fonts.gstatic.com TLS handshake), and ship through CF's edge PageSpeed
* was attributing ~2.3s of render-blocking + LCP to the old Google Fonts
* round-trip chain.
*
* Subset CSS files only declare @font-face metadata (~1KB each). The
* browser downloads the woff2 that matches the unicode-range of text it's
* about to render, so multi-script support stays intact without paying for
* unused scripts up front.
*/
@import '@fontsource-variable/inter';
@import '@fontsource-variable/inter-tight';
@import '@fontsource-variable/playfair-display';
@import '@fontsource-variable/playfair-display/wght-italic.css';
@import '@fontsource-variable/jetbrains-mono';
:root {
--paper: #efe7d2;
@ -29,10 +40,10 @@
--line-soft: rgba(21, 20, 15, 0.08);
--line-faint: rgba(21, 20, 15, 0.05);
--shadow: 0 30px 60px -30px rgba(21, 20, 15, 0.18);
--serif: 'Playfair Display', 'Times New Roman', serif;
--sans: 'Inter Tight', 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--body: 'Inter', -apple-system, system-ui, sans-serif;
--mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
--serif: 'Playfair Display Variable', 'Playfair Display', 'Times New Roman', serif;
--sans: 'Inter Tight Variable', 'Inter Tight', 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--body: 'Inter Variable', 'Inter', -apple-system, system-ui, sans-serif;
--mono: 'JetBrains Mono Variable', 'JetBrains Mono', 'SF Mono', Menlo, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@ -133,13 +144,50 @@ body::before {
.side-rail.right .rail-text { transform: rotate(180deg); }
.side-rail.left .rail-text { writing-mode: vertical-rl; transform: none; }
/*
* Sticky chrome bar wraps the editorial metadata strip + main nav as a
* single Headroom-style unit. Keeping them welded means the language
* switcher stays reachable while reading the page (you can flip locales
* mid-scroll without scrolling back to the top), and the masthead +
* navigation always read as a single nameplate rather than two unrelated
* rows.
*
* Sticky/transform/transition lived on `.nav` historically they belong
* here now so both rows slide together. The `data-chrome-headroom` hook
* is what `header-enhancer.astro` (and the homepage inline script)
* toggle `is-hidden` on.
*/
.site-chrome {
position: sticky;
top: 0;
z-index: 50;
background: var(--paper);
transform: translateY(0);
transition: transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1),
box-shadow 220ms ease,
border-color 220ms ease;
will-change: transform;
}
.site-chrome.is-hidden {
transform: translateY(-100%);
pointer-events: none;
box-shadow: none;
}
/* top metadata strip */
.topbar {
border-bottom: 1px solid var(--line);
padding: 10px 0;
background: var(--paper);
/*
* Stays a positioned context so the locale menu has an absolute
* anchor. The z-index is small but non-zero so the topbar and the
* menu that drops out of it paint above the sibling nav inside
* `.site-chrome`. Without this the menu would be obscured by the
* nav's download/star buttons.
*/
position: relative;
z-index: 4;
z-index: 2;
}
.topbar-inner {
display: flex;
@ -168,118 +216,154 @@ body::before {
}
.topbar-link:hover { color: var(--coral); border-bottom-color: var(--coral); }
/*
* Site-level locale switcher in the main nav.
*
* Sits in `.nav-side` next to Download/Star so it carries the same visual
* weight as the primary CTAs (the previous attempt buried it in the small
* topbar metadata strip where users missed it). Built on `<details>` so
* the dropdown works without JavaScript the close-on-outside-click
* behaviour is a tiny inline script attached by `header-enhancer.astro`.
* Editorial language switcher. The trigger reads as part of the
* masthead line ("● LIVE · V0.7.0 / LANG · ZH-TW") uppercase
* tracked text, no pill, no border. The dropdown is a small
* floating panel that mirrors the topbar's paper/ink palette but
* drops the tracking so native scripts (中文 / 한국어 / العربية)
* read at their intended rhythm.
*/
.nav-locale {
.locale-switch {
display: inline-block;
position: relative;
}
.nav-locale > summary {
list-style: none;
cursor: pointer;
.locale-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(21, 20, 15, 0.18);
background: transparent;
color: var(--ink);
font-family: var(--sans);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
gap: 6px;
cursor: pointer;
color: inherit;
list-style: none;
border-bottom: 1px solid transparent;
transition: color 160ms ease, border-color 160ms ease;
user-select: none;
outline: none;
padding: 0;
}
.nav-locale > summary::-webkit-details-marker { display: none; }
.nav-locale > summary::marker { display: none; content: ''; }
.nav-locale > summary:hover,
.nav-locale[open] > summary {
background: rgba(21, 20, 15, 0.04);
border-color: rgba(21, 20, 15, 0.36);
.locale-trigger::-webkit-details-marker { display: none; }
.locale-trigger:hover,
.locale-switch[open] > .locale-trigger,
.locale-trigger:focus-visible {
color: var(--coral);
border-bottom-color: var(--coral);
}
.nav-locale-current {
/* Cap unusually long native labels (e.g. "Português (Brasil)") so the
* pill stays close to the width of Download / Star and the nav row
* doesn't reflow when the active locale changes. */
max-width: 11ch;
overflow: hidden;
text-overflow: ellipsis;
.locale-trigger-prefix {
color: var(--ink-faint);
}
.nav-locale-glyph { flex: 0 0 auto; opacity: 0.7; }
.nav-locale-chevron {
flex: 0 0 auto;
.locale-trigger:hover .locale-trigger-prefix,
.locale-switch[open] > .locale-trigger .locale-trigger-prefix,
.locale-trigger:focus-visible .locale-trigger-prefix {
color: var(--coral);
}
.locale-trigger-sep {
opacity: 0.55;
transition: transform 180ms ease;
}
.nav-locale[open] .nav-locale-chevron { transform: rotate(180deg); opacity: 0.9; }
.nav-locale-panel {
.locale-trigger-code {
font: inherit;
letter-spacing: inherit;
color: var(--ink);
font-weight: 600;
}
.locale-trigger:hover .locale-trigger-code,
.locale-switch[open] > .locale-trigger .locale-trigger-code,
.locale-trigger:focus-visible .locale-trigger-code {
color: var(--coral);
}
.locale-trigger-caret {
width: 8px;
height: 5px;
flex-shrink: 0;
opacity: 0.7;
transition: transform 160ms ease, opacity 160ms ease;
}
.locale-switch[open] > .locale-trigger .locale-trigger-caret {
transform: rotate(180deg);
opacity: 1;
}
/*
* Two-column editorial catalogue. 17 locales × single-column would either
* scroll (ugly) or run most of the hero's height (worse). A 2-col grid
* keeps the panel under 9 rows so every language is visible at once and
* reads like a small index of nameplates rather than a dropdown.
*
* The 20px top offset gives clear breathing room from the topbar baseline
* without it the active row (which mirrors the trigger label) feels
* fused to the chrome above it.
*/
.locale-menu {
position: absolute;
top: calc(100% + 10px);
top: calc(100% + 20px);
right: 0;
z-index: 30;
width: 280px;
max-height: min(560px, calc(100vh - 140px));
overflow-y: auto;
z-index: 60;
display: grid;
gap: 2px;
grid-template-columns: repeat(2, minmax(168px, 1fr));
gap: 2px 4px;
padding: 10px;
border: 1px solid rgba(21, 20, 15, 0.16);
border-radius: 14px;
background: var(--paper);
border: 1px solid rgba(21, 20, 15, 0.22);
box-shadow:
0 1px 0 rgba(21, 20, 15, 0.02),
0 20px 50px -12px rgba(21, 20, 15, 0.22);
letter-spacing: 0;
0 1px 0 rgba(21, 20, 15, 0.04),
0 28px 60px -32px rgba(21, 20, 15, 0.42);
font-family: var(--sans);
letter-spacing: normal;
text-transform: none;
animation: nav-locale-pop 160ms ease;
/* Subtle entrance — matches the rest of the site's 200ms ease-out */
animation: locale-menu-in 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
@keyframes nav-locale-pop {
@keyframes locale-menu-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.nav-locale-item {
.locale-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
align-items: baseline;
gap: 12px;
padding: 8px 12px;
color: var(--ink);
font-family: var(--sans);
font-size: 14px;
text-decoration: none;
font-size: 12.5px;
line-height: 1.2;
transition: background 140ms ease, color 140ms ease;
}
.nav-locale-item:hover {
background: rgba(21, 20, 15, 0.06);
color: var(--ink);
.locale-menu-item:hover {
background: color-mix(in oklab, var(--paper), var(--ink) 6%);
color: var(--coral);
}
.nav-locale-item.is-current {
background: var(--ink);
color: var(--paper);
font-weight: 600;
cursor: default;
pointer-events: none;
.locale-menu-item:hover .locale-menu-code { color: var(--coral); }
.locale-menu-item.is-active {
background: color-mix(in oklab, var(--paper), var(--coral) 8%);
color: var(--coral);
}
.locale-menu-item.is-active .locale-menu-code {
color: var(--coral);
}
.locale-menu-code {
flex-shrink: 0;
min-width: 46px;
font-size: 9.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-faint);
transition: color 140ms ease;
}
.locale-menu-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 520px) {
.locale-menu {
grid-template-columns: minmax(0, 1fr);
min-width: min(260px, calc(100vw - 32px));
}
.locale-menu-item { padding: 9px 12px; }
}
@media (prefers-reduced-motion: reduce) {
.locale-menu { animation: none; }
.locale-trigger-caret { transition: none; }
}
.nav-locale-item .nav-locale-check { opacity: 0.95; }
.nav-locale-name { line-height: 1.2; }
/* RTL locales: keep the panel anchored right but flip item alignment so
* the script reads naturally. The Header runs LTR but each item carries
* `lang="ar"` etc., so individual rows still render in their own script. */
[dir='rtl'] .nav-locale-panel { left: 0; right: auto; }
/* On narrow screens the responsive cleanup at the bottom of this file
* hides `.nav-cta` (Download / Star) but keeps `.nav-locale` visible,
* so a mobile user always has a one-tap way to change language without
* scrolling to the footer. */
.topbar .pulse {
width: 6px; height: 6px;
border-radius: 50%;
@ -295,41 +379,14 @@ body::before {
/* nav */
/*
* Headroom-style sticky header.
*
* The element is always `position: sticky`, so the browser docks it to the
* top of the viewport once the topbar has scrolled away. The
* `<Header />` client island then toggles the `is-hidden` modifier based
* on scroll direction, which animates the bar in and out via `transform`.
*
* When the user is at the very top of the page, the topbar is still
* visible above the nav and `position: sticky` simply leaves the nav in
* its natural flow position exactly the brief.
* The sticky + headroom behavior lives on the outer `.site-chrome`
* wrapper now (so the topbar + nav slide as one unit). This rule keeps
* only the spacing and surface treatment.
*/
.nav {
padding: 22px 0;
position: sticky;
top: 0;
z-index: 50;
background: var(--paper);
transform: translateY(0);
transition: transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1),
box-shadow 220ms ease,
border-color 220ms ease;
border-bottom: 1px solid transparent;
will-change: transform;
}
/*
* Subtle visual cue once we leave the top of the page. We can't tell from
* CSS alone whether the bar is "stuck"; the deadband + class toggle in
* <Header /> approximates it well enough for our purpose. We rely on the
* `is-hidden` toggle to flicker the border while moving and a steady
* border once docked.
*/
.nav.is-hidden {
transform: translateY(-100%);
pointer-events: none;
box-shadow: none;
}
.nav-inner {
display: flex;
@ -340,7 +397,7 @@ body::before {
.brand {
display: inline-flex;
align-items: center;
gap: 14px;
gap: 8px;
font-family: var(--sans);
font-weight: 700;
letter-spacing: -0.01em;
@ -349,16 +406,30 @@ body::before {
font-size: 18px;
}
.brand-mark {
width: 36px; height: 36px;
/*
* Bumped from 36px 44px (22% larger). The new black brand mark
* needs the extra optical weight against the wordmark, especially
* given the speech-bubble negative space visually shrinks the glyph.
* (PR #2588's brand polish, restored on top of #2469.)
*/
width: 44px; height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.brand-name {
white-space: nowrap;
}
.brand-mark img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
/* Round the corners of the brand glyph (~22% of side, app-icon
* silhouette convention) so the solid-fill square reads as a
* brand mark next to the wordmark rather than a screenshot. */
border-radius: 10px;
}
.brand-meta {
font-family: var(--sans);
@ -1941,6 +2012,203 @@ section.tight { padding: 90px 0; }
transform: rotate(180deg);
}
/* ---------- OFFICIAL SOURCE STRIP ----------
*
* Slim attestation band that sits between the hero/wire and About.
* Reinforces the canonical surfaces official site, GitHub repo,
* releases, download, docs, Discord for both Google entity merge
* and human verification. Minimal chrome on purpose.
*/
.official-strip {
padding: 38px 0 18px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: var(--paper);
}
.official-strip-inner {
display: grid;
grid-template-columns: minmax(180px, 220px) 1fr;
gap: 28px;
align-items: center;
}
.official-strip-label {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
}
.official-strip-label .ix {
color: var(--coral);
margin-left: 6px;
}
.official-strip-list {
list-style: none;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 18px 24px;
padding: 0;
margin: 0;
}
.official-strip-list li {
display: flex;
min-width: 0;
}
.official-strip-list a {
display: grid;
gap: 4px;
padding: 6px 0;
border-bottom: 1px solid transparent;
text-decoration: none;
color: var(--ink);
font-family: var(--sans);
min-width: 0;
transition: border-color 0.16s ease, color 0.16s ease;
}
.official-strip-list a:hover {
border-bottom-color: var(--coral);
color: var(--coral);
}
.official-strip-list .label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-mute);
}
.official-strip-list .value {
font-size: 13.5px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.official-strip-inner {
grid-template-columns: 1fr;
gap: 18px;
}
.official-strip-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.official-strip-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 18px;
}
}
/* ---------- FAQ ---------- */
.faq-head {
max-width: 880px;
margin: 36px 0 48px;
}
.faq-head .label {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
display: inline-block;
margin-bottom: 18px;
}
.faq-head .display {
font-family: var(--serif);
font-weight: 600;
font-size: clamp(36px, 5.4vw, 64px);
line-height: 1.06;
letter-spacing: -0.022em;
color: var(--ink);
}
.faq-head .display em {
font-style: italic;
font-weight: 600;
color: var(--coral);
}
.faq-head .display .dot { color: var(--coral); }
.faq-list {
list-style: none;
padding: 0;
margin: 0;
border-top: 1px solid var(--line);
}
.faq-item {
border-bottom: 1px solid var(--line);
}
.faq-item details {
padding: 0;
}
.faq-item summary {
display: grid;
grid-template-columns: 56px 1fr 32px;
gap: 24px;
align-items: center;
padding: 26px 0;
cursor: pointer;
list-style: none;
user-select: none;
}
.faq-item summary::-webkit-details-marker { display: none; }
.faq-index {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.16em;
color: var(--ink-faint);
}
.faq-q {
font-family: var(--serif);
font-size: clamp(20px, 2.2vw, 26px);
line-height: 1.25;
color: var(--ink);
}
.faq-toggle {
justify-self: end;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--line);
color: var(--ink);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--sans);
font-size: 16px;
transition: transform 0.22s cubic-bezier(0.23, 1, 0.32, 1),
background-color 0.22s cubic-bezier(0.23, 1, 0.32, 1),
color 0.22s cubic-bezier(0.23, 1, 0.32, 1);
}
.faq-item details[open] .faq-toggle {
transform: rotate(45deg);
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
.faq-a {
grid-column: 2 / 3;
margin: 0 0 26px;
padding-left: 80px;
max-width: 64ch;
font-family: var(--sans);
font-size: 15.5px;
line-height: 1.7;
color: var(--ink-mute);
}
@media (max-width: 720px) {
.faq-item summary {
grid-template-columns: 36px 1fr 28px;
gap: 14px;
padding: 20px 0;
}
.faq-a {
padding-left: 50px;
font-size: 14.5px;
}
}
/* ---------- FOOTER ---------- */
footer {
border-top: 1px solid var(--line);
@ -2156,9 +2424,9 @@ footer {
scale: 1 !important;
transition: none !important;
}
/* Skip the slide-in on the sticky header for users who prefer no motion;
/* Skip the slide-in on the sticky chrome for users who prefer no motion;
* the show/hide still toggles, just instantly. */
.nav { transition: none !important; }
.site-chrome { transition: none !important; }
}
/* ---------- responsive ----------
@ -2384,6 +2652,10 @@ footer {
section { padding: 80px 0; }
.topbar-inner { font-size: 9px; gap: 10px; }
.topbar-inner .right { gap: 10px; }
.locale-trigger { gap: 4px; }
.locale-trigger-prefix { display: none; }
.locale-trigger-sep { display: none; }
.locale-menu { min-width: 208px; }
.partners { grid-template-columns: repeat(2, 1fr); gap: 14px; }
.foot-grid { grid-template-columns: 1fr; gap: 24px; }
.work { margin: 0 8px; padding: 48px 20px; border-radius: 20px; }
@ -2393,15 +2665,20 @@ footer {
.cta-actions .btn,
.cta-actions .email-pill { flex: 1 1 auto; justify-content: center; }
.nav-inner { gap: 12px; }
.brand { font-size: 16px; gap: 10px; }
.brand { font-size: 16px; gap: 8px; }
.brand-mark { width: 32px; height: 32px; font-size: 16px; }
.read-more { margin-top: 36px; }
.foot-mega .word { font-size: clamp(48px, 16vw, 88px); }
}
@media (max-width: 420px) {
.container { padding: 0 14px; }
/* topbar — drop the locale switcher tail; keep version pill + brand */
.topbar-inner .right > a:nth-of-type(n + 3) { display: none; }
.topbar-inner { align-items: flex-start; }
.topbar-inner .right {
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.locale-menu { min-width: 192px; }
.hero-stats { gap: 10px 16px; }
.hero-stats .stat { font-size: 11px; }
.hero-foot { font-size: 9.5px; }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -97,7 +97,7 @@ const pageTitle = routeRoot === 'skills'
const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
---
<Layout title={pageTitle} description={pageDescription} active={routeRoot as HeaderProps['active']} locale={locale} prefixDefaultLocale={true}>
<Layout title={pageTitle} description={pageDescription} active={routeRoot as HeaderProps['active']}>
{routeRoot === 'blog' && (
<>
<header class='catalog-head'>

View file

@ -0,0 +1,12 @@
---
import AgentsPage from '../../agents/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export function getStaticPaths() {
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
(locale) => ({ params: { locale: locale.code } }),
);
}
---
<AgentsPage />

View file

@ -0,0 +1,12 @@
---
import ClaudeDesignAlternativePage from '../../../alternatives/claude-design/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
export function getStaticPaths() {
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
(locale) => ({ params: { locale: locale.code } }),
);
}
---
<ClaudeDesignAlternativePage />

View file

@ -0,0 +1,19 @@
---
import BlogPostPage, {
getStaticPaths as getBlogPostStaticPaths,
} from '../../blog/[slug].astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export async function getStaticPaths() {
const basePaths = await getBlogPostStaticPaths();
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
(locale) =>
basePaths.map((path) => ({
params: { ...path.params, locale: locale.code },
props: path.props,
})),
);
}
---
<BlogPostPage {...Astro.props} />

Some files were not shown because too many files have changed in this diff Show more