Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s

This repository contains the open-design daemon CLI source code, built
and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use
by the HelixMind /design slash command.

Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
marco
2026-05-06 20:50:24 +02:00
commit 5dd70b5016
1336 changed files with 287186 additions and 0 deletions

BIN
.github/screenshots/issue-6-fix.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

104
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,104 @@
name: ci
on:
pull_request:
# 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
# focused on PRs, main, and manual reruns instead of duplicating tag/release
# events that would run after those release workflows have already selected
# or validated their commit.
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
# Prefer current-head signal over preserving superseded logs: PR authors often
# push fixups while this workflow is still running, and stale runs can report
# failures for commits reviewers no longer need to evaluate. Release workflows
# use cancel-in-progress: false where preserving build evidence matters more.
cancel-in-progress: true
jobs:
validate:
name: Validate workspace
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm -C e2e exec playwright install --with-deps chromium
# `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that
# are needed immediately after install for linked bins and shared
# sidecar/platform imports. It intentionally skips app outputs because
# building all apps would make every install run a Next/Electron-adjacent
# app build, even when a developer only needs packages/tools.
#
# Fresh CI typecheck/test still need these specific generated declarations:
# - `apps/daemon/dist/*.d.ts` for packaged/runtime consumers of the daemon
# package export
# - `apps/desktop/dist/main/index.d.ts` for `apps/packaged` imports of
# `@open-design/desktop/main`
# - `apps/web/dist/sidecar/index.d.ts` for `apps/packaged` imports of
# `@open-design/web/sidecar`
# If postinstall grows a targeted app type-generation phase covering these
# three exports without broad app builds, this CI prebuild can be removed.
- name: Prebuild workspace type declarations
run: |
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build
pnpm --filter @open-design/web build:sidecar
- name: Typecheck workspaces
run: pnpm -r --workspace-concurrency=1 --if-present run typecheck
- name: Check repository layout policies
run: pnpm guard
- name: Check i18n structure
run: pnpm i18n:check
- name: Test
run: |
pnpm --filter @open-design/e2e test
pnpm -C e2e exec tsx scripts/playwright.ts clean
pnpm -C e2e exec playwright test -c playwright.config.ts
pnpm --filter @open-design/contracts test
pnpm --filter @open-design/platform test
pnpm --filter @open-design/sidecar test
pnpm --filter @open-design/sidecar-proto test
pnpm --filter @open-design/daemon test
pnpm --filter @open-design/web test
pnpm --filter @open-design/tools-dev test
pnpm --filter @open-design/tools-pack test
# Keep workspace builds serialized so generated dist output and local
# runtime artifacts are produced in a deterministic order. Parallel
# recursive builds would surface late-package failures sooner, but the
# current workspace is small enough that safer logs and fewer shared-FS
# races outweigh the lost parallelism; revisit if the package count grows.
- name: Build workspaces
run: pnpm -r --workspace-concurrency=1 --if-present run build

241
.github/workflows/discord-resolved.yml vendored Normal file
View File

@@ -0,0 +1,241 @@
# Notify Discord #resolved when an issue is closed by a merged PR.
#
# Trigger logic:
# - issues.closed fires whenever an issue is closed (manually, by PR, or as not-planned)
# - We require state_reason == "completed" AND that the issue's most recent
# `closed` timeline event has a commit_id belonging to a merged PR.
# - Then we post a rich Discord embed with: issue title + body excerpt, issue
# author, the PR that resolved it, and the merger.
#
# Why a workflow instead of a raw repo→Discord webhook?
# GitHub's webhook can't tell Discord "this issue was closed *by a merged PR*" —
# the issues.closed payload doesn't carry that linkage. We have to walk the
# timeline ourselves, which a workflow does in <1s.
#
# Why only the `closed` + `commit_id` path (no cross-referenced fallback)?
# `cross-referenced` events fire on plain mentions ("related to #123"),
# so trusting them creates false positives — a manually closed issue mentioned
# by an unrelated merged PR would post to #resolved. The closed-event linkage
# is the only signal GitHub itself uses to display "closed by PR #N", so it's
# the source of truth. We accept the rare miss (e.g. a PR that closed an issue
# via the web UI rather than via "Fixes" keyword) in exchange for zero false
# positives.
name: Discord · resolved
on:
issues:
types: [closed]
# Read-only. Discord post goes via webhook URL (a secret), not GitHub auth.
# - contents:read : required by repos.listPullRequestsAssociatedWithCommit
# - issues:read : timeline + issue body
# - pull-requests:read : PR metadata (merger, title)
permissions:
contents: read
issues: read
pull-requests: read
jobs:
notify:
# state_reason "completed" excludes "not planned" closures.
# We further require an actual merged-PR linkage in the script below.
if: github.event.issue.state_reason == 'completed'
runs-on: ubuntu-latest
steps:
- name: Find the merged PR that closed this issue
id: find-pr
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const timeline = await github.paginate(
github.rest.issues.listEventsForTimeline,
{
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 100,
}
);
// Walk events backwards. The most recent `closed` event with a
// commit_id whose containing PR is merged is our resolver.
// We deliberately ignore `cross-referenced` events: those fire on
// plain mentions, not just closing-keyword links, and trusting
// them produces false positives. See top-of-file comment.
let resolvingPr = null;
for (let i = timeline.length - 1; i >= 0; i--) {
const ev = timeline[i];
if (ev.event !== 'closed' || !ev.commit_id) continue;
try {
const { data: prs } =
await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: ev.commit_id,
});
const merged = prs.find((p) => p.merged_at);
if (merged) {
resolvingPr = merged;
break;
}
} catch (e) {
core.warning(
`listPullRequestsAssociatedWithCommit failed for ${ev.commit_id}: ${e.message}`
);
}
}
if (!resolvingPr) {
core.info(
'No merged PR found via closed-event linkage — skipping Discord post. ' +
'This is expected for manual closes or web-UI "close with comment" events.'
);
core.setOutput('skip', 'true');
return;
}
// Truncate body for the embed (Discord embed description max ~4096,
// but readable cards stay under ~400 chars).
const rawBody = (issue.body || '').trim();
const bodyExcerpt = rawBody.length > 380
? rawBody.slice(0, 380).trim() + '…'
: rawBody || '_(no description)_';
core.setOutput('skip', 'false');
core.setOutput('issue_number', String(issue.number));
core.setOutput('issue_title', issue.title);
core.setOutput('issue_url', issue.html_url);
core.setOutput('issue_author', issue.user.login);
core.setOutput('issue_author_url', issue.user.html_url);
core.setOutput('issue_author_avatar', issue.user.avatar_url);
core.setOutput('issue_body', bodyExcerpt);
core.setOutput('pr_number', String(resolvingPr.number));
core.setOutput('pr_title', resolvingPr.title);
core.setOutput('pr_url', resolvingPr.html_url);
core.setOutput('pr_merger', resolvingPr.merged_by?.login || resolvingPr.user.login);
core.setOutput('pr_merger_url',
resolvingPr.merged_by?.html_url || resolvingPr.user.html_url);
core.setOutput('repo_full_name', `${context.repo.owner}/${context.repo.repo}`);
- name: Post embed to Discord
if: steps.find-pr.outputs.skip == 'false'
env:
WEBHOOK: ${{ secrets.DISCORD_RESOLVED_WEBHOOK }}
ISSUE_NUMBER: ${{ steps.find-pr.outputs.issue_number }}
ISSUE_TITLE: ${{ steps.find-pr.outputs.issue_title }}
ISSUE_URL: ${{ steps.find-pr.outputs.issue_url }}
ISSUE_AUTHOR: ${{ steps.find-pr.outputs.issue_author }}
ISSUE_AUTHOR_URL: ${{ steps.find-pr.outputs.issue_author_url }}
ISSUE_AUTHOR_AVATAR: ${{ steps.find-pr.outputs.issue_author_avatar }}
ISSUE_BODY: ${{ steps.find-pr.outputs.issue_body }}
PR_NUMBER: ${{ steps.find-pr.outputs.pr_number }}
PR_TITLE: ${{ steps.find-pr.outputs.pr_title }}
PR_URL: ${{ steps.find-pr.outputs.pr_url }}
PR_MERGER: ${{ steps.find-pr.outputs.pr_merger }}
PR_MERGER_URL: ${{ steps.find-pr.outputs.pr_merger_url }}
REPO_FULL_NAME: ${{ steps.find-pr.outputs.repo_full_name }}
run: |
set -euo pipefail
# ── Webhook URL sanity check ────────────────────────────────────
# Refuse to post if the secret is missing or doesn't look like a
# Discord webhook URL — guards against accidentally leaking issue
# metadata to a misconfigured endpoint.
if [ -z "${WEBHOOK:-}" ]; then
echo "DISCORD_RESOLVED_WEBHOOK secret not configured — aborting."
exit 1
fi
case "$WEBHOOK" in
https://discord.com/api/webhooks/*) ;;
https://discordapp.com/api/webhooks/*) ;;
*)
echo "WEBHOOK does not look like a Discord webhook URL — aborting."
echo "(Expected prefix: https://discord.com/api/webhooks/...)"
exit 1
;;
esac
# ── Build embed JSON via jq ─────────────────────────────────────
# Color 0x2eb67d (3066993) — green for "resolved".
# `allowed_mentions: { parse: [] }` disables @everyone/@here/role/user
# mentions so a malicious or accidental issue title can't ping the
# channel.
payload=$(jq -n \
--arg title "✅ #${ISSUE_NUMBER}: ${ISSUE_TITLE}" \
--arg url "$ISSUE_URL" \
--arg desc "$ISSUE_BODY" \
--arg author_name "$ISSUE_AUTHOR" \
--arg author_url "$ISSUE_AUTHOR_URL" \
--arg author_icon "$ISSUE_AUTHOR_AVATAR" \
--arg pr_field "[#${PR_NUMBER} ${PR_TITLE}](${PR_URL})" \
--arg merger_field "[@${PR_MERGER}](${PR_MERGER_URL})" \
--arg footer "$REPO_FULL_NAME" \
'{
username: "Issue Resolver",
avatar_url: "https://github.githubassets.com/images/modules/logos_page/Octocat.png",
allowed_mentions: { parse: [] },
embeds: [
{
title: $title,
url: $url,
description: $desc,
color: 3066993,
author: {
name: ("Reported by @" + $author_name),
url: $author_url,
icon_url: $author_icon
},
fields: [
{ name: "Resolved by PR", value: $pr_field, inline: false },
{ name: "Merged by", value: $merger_field, inline: true }
],
footer: { text: $footer },
timestamp: now | todateiso8601
}
]
}')
# ── POST with bounded retry on 429 ──────────────────────────────
# Discord rate-limits webhooks per-channel and may return 429 with a
# `retry-after` header (seconds, integer or float). We retry up to 3
# times honouring that header, with a sane default if the header is
# missing.
attempts=3
for attempt in $(seq 1 "$attempts"); do
: > /tmp/resp_body
: > /tmp/resp_headers
status=$(curl -sS -o /tmp/resp_body -D /tmp/resp_headers \
-w '%{http_code}' \
-H 'Content-Type: application/json' \
-X POST "$WEBHOOK" \
-d "$payload" || echo '000')
if [ "$status" = "204" ]; then
echo "Discord post OK (attempt $attempt)."
exit 0
fi
if [ "$status" = "429" ] && [ "$attempt" -lt "$attempts" ]; then
# Header is case-insensitive; tr to lowercase for matching.
retry_after=$(tr -d '\r' < /tmp/resp_headers \
| awk 'BEGIN{IGNORECASE=1} /^retry-after:/ {print $2; exit}')
# Floor to integer; default to 5s if header missing/unparseable.
retry_after_int=$(printf '%.0f' "${retry_after:-5}" 2>/dev/null || echo 5)
[ "$retry_after_int" -lt 1 ] && retry_after_int=1
[ "$retry_after_int" -gt 60 ] && retry_after_int=60
echo "Rate limited (HTTP 429), retrying in ${retry_after_int}s (attempt ${attempt}/${attempts})…"
sleep "$retry_after_int"
continue
fi
echo "Discord webhook returned HTTP $status (attempt ${attempt}/${attempts}):"
cat /tmp/resp_body
# Non-429 errors are not retryable.
exit 1
done
echo "Discord post failed after ${attempts} attempts (last status: ${status})."
exit 1

93
.github/workflows/landing-page-ci.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
name: landing-page-ci
on:
pull_request:
paths:
- .github/workflows/landing-page-ci.yml
- .github/workflows/landing-page.yml
- apps/landing-page/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
push:
branches:
- main
paths:
- .github/workflows/landing-page-ci.yml
- .github/workflows/landing-page.yml
- apps/landing-page/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: landing-page-ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
validate:
name: Validate landing page
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck landing page
run: pnpm --filter @open-design/landing-page typecheck
- name: Build landing page
run: pnpm --filter @open-design/landing-page build
- name: Verify zero external JavaScript
run: |
node <<'NODE'
const { readFileSync } = require('node:fs');
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
const forbidden = [
/<script\b[^>]*\bsrc=/i,
/type=["']module["']/i,
/\/_astro\/[^"'<>\s]+\.js/i,
];
for (const pattern of forbidden) {
if (pattern.test(html)) {
console.error(`Unexpected client JavaScript matched ${pattern}`);
process.exit(1);
}
}
NODE
- name: Verify Cloudflare image resizing URLs
run: |
node <<'NODE'
const { readFileSync } = require('node:fs');
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
const resizedUrls = html.match(/https:\/\/static\.open-design\.ai\/cdn-cgi\/image\//g) ?? [];
if (resizedUrls.length < 16) {
console.error(`Expected at least 16 Cloudflare resized image URLs, found ${resizedUrls.length}`);
process.exit(1);
}
if (/(?:src|content)=["']\/assets\/[A-Za-z0-9_.-]+\.png/.test(html)) {
console.error('Found local /assets/*.png image reference in generated landing HTML.');
process.exit(1);
}
NODE

View File

@@ -0,0 +1,98 @@
name: landing-page-deploy
on:
push:
branches:
- main
paths:
- .github/workflows/landing-page-deploy.yml
- .github/workflows/landing-page-ci.yml
- apps/landing-page/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
workflow_dispatch:
permissions:
contents: read
deployments: write
concurrency:
group: landing-page-deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Deploy landing page
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck landing page
run: pnpm --filter @open-design/landing-page typecheck
- name: Build landing page
run: pnpm --filter @open-design/landing-page build
- name: Verify zero external JavaScript
run: |
node <<'NODE'
const { readFileSync } = require('node:fs');
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
const forbidden = [
/<script\b[^>]*\bsrc=/i,
/type=["']module["']/i,
/\/_astro\/[^"'<>\s]+\.js/i,
];
for (const pattern of forbidden) {
if (pattern.test(html)) {
console.error(`Unexpected client JavaScript matched ${pattern}`);
process.exit(1);
}
}
NODE
- name: Verify Cloudflare image resizing URLs
run: |
node <<'NODE'
const { readFileSync } = require('node:fs');
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
const resizedUrls = html.match(/https:\/\/static\.open-design\.ai\/cdn-cgi\/image\//g) ?? [];
if (resizedUrls.length < 16) {
console.error(`Expected at least 16 Cloudflare resized image URLs, found ${resizedUrls.length}`);
process.exit(1);
}
if (/(?:src|content)=["']\/assets\/[A-Za-z0-9_.-]+\.png/.test(html)) {
console.error('Found local /assets/*.png image reference in generated landing HTML.');
process.exit(1);
}
NODE
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/landing-page
packageManager: npm
command: >
pages deploy out
--project-name=open-design-landing
--branch=${{ github.ref_name }}

68
.github/workflows/metrics.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: github-metrics
on:
schedule:
# Runs daily at 00:15 UTC; output is committed to docs/assets/github-metrics.svg.
- cron: '15 0 * * *'
workflow_dispatch:
push:
branches:
- main
paths:
- .github/workflows/metrics.yml
permissions:
contents: write
pull-requests: write
jobs:
metrics:
name: Generate repository metrics SVG
runs-on: ubuntu-latest
steps:
- name: Generate GitHub repository metrics
uses: lowlighter/metrics@latest
with:
# Output path; the action opens/updates a PR when this file changes.
# Requires manual review to merge. If metrics unchanged, no PR is created.
filename: docs/assets/github-metrics.svg
# Auth: METRICS_TOKEN must be a fine-grained PAT or GitHub App token that
# can create pull requests in this repository. GITHUB_TOKEN is kept only
# as a read/render fallback because many orgs disallow PR creation from it.
token: ${{ secrets.METRICS_TOKEN || secrets.GITHUB_TOKEN }}
committer_token: ${{ secrets.METRICS_TOKEN }}
output_action: pull-request
output_condition: data-changed
# Use the repository template (per-repo metrics, not user metrics).
# Organization-owned repositories must be targeted explicitly, otherwise
# lowlighter/metrics infers the token owner and treats the target as an org.
template: repository
base: ''
user: nexu-io
repo: open-design
# Plugins. Anything that requires a personal token will silently no-op
# without METRICS_TOKEN — the rest still produce a useful SVG.
plugin_contributors: yes
plugin_contributors_categories: |
{
"Skills": "skills/**",
"Design systems": "design-systems/**",
"Web": "apps/web/**",
"Daemon": "apps/daemon/**",
"Docs": "docs/**"
}
plugin_followup: yes
plugin_followup_sections: pr, issue
plugin_languages: yes
plugin_languages_details: lines, percentage
plugin_languages_limit: 8
plugin_lines: yes
plugin_traffic: yes
plugin_stargazers: yes
plugin_stargazers_charts_type: chartist
config_timezone: Asia/Shanghai
config_display: large

View File

@@ -0,0 +1,55 @@
name: refresh-contributors-wall
on:
# Daily refresh keeps the contributors wall CDN cache moving even when
# contributor data changes outside pull request merges.
schedule:
- cron: '0 1 * * *'
# Manual trigger: Use when you need to force-refresh the contributors wall
# outside the daily schedule (e.g., after a bulk contributor update or
# after fixing the cache_bust pattern in README files).
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: refresh-contributors-wall
cancel-in-progress: true
jobs:
refresh:
name: Refresh contributors wall cache bust
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Refresh cache bust date
run: |
DATE="$(date -u +%F)"
MATCHES="$(perl -0ne '$count += () = /cache_bust=\d{4}-\d{2}-\d{2}/g; END { print $count + 0 }' README*.md)"
if [ "$MATCHES" -eq 0 ]; then
echo "Warning: No cache_bust patterns found. README format may have changed."
exit 1
fi
perl -0pi -e "s/cache_bust=\d{4}-\d{2}-\d{2}/cache_bust=$DATE/g" README*.md
- name: Create refresh pull request
uses: peter-evans/create-pull-request@v8
with:
# Auth mirrors the metrics workflow: prefer a repository token that can
# create pull requests, with GITHUB_TOKEN as a fallback for repos where
# Actions-created PRs are allowed.
token: ${{ secrets.METRICS_TOKEN || secrets.GITHUB_TOKEN }}
add-paths: 'README*.md'
branch: automation/refresh-contributors-wall
delete-branch: true
commit-message: 'docs(readme): refresh contributors wall'
title: 'docs(readme): refresh contributors wall'
body: |
Refreshes the contributors wall cache bust date in README files.
Generated by the scheduled `refresh-contributors-wall` workflow.

556
.github/workflows/release-beta.yml vendored Normal file
View File

@@ -0,0 +1,556 @@
name: release-beta
on:
workflow_dispatch:
inputs:
signed:
description: "Build signed/notarized mac artifacts. Disable only for explicit unsigned validation releases."
required: true
type: boolean
default: true
enable_mac:
description: "Build and publish mac arm64 beta artifacts."
required: true
type: boolean
default: true
enable_win:
description: "Build and publish Windows x64 beta artifacts."
required: true
type: boolean
default: true
enable_linux:
description: "Build and publish Linux x64 AppImage beta artifacts."
required: true
type: boolean
default: false
permissions:
contents: write
concurrency:
group: open-design-release-beta
cancel-in-progress: false
jobs:
metadata:
name: Prepare beta metadata
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
OPEN_DESIGN_RELEASE_SIGNED: ${{ inputs.signed }}
outputs:
asset_version_suffix: ${{ steps.beta.outputs.asset_version_suffix }}
base_version: ${{ steps.beta.outputs.base_version }}
beta_tag: ${{ steps.beta.outputs.beta_tag }}
beta_version: ${{ steps.beta.outputs.beta_version }}
branch: ${{ steps.beta.outputs.branch }}
commit: ${{ steps.beta.outputs.commit }}
release_name: ${{ steps.beta.outputs.release_name }}
signed: ${{ steps.beta.outputs.signed }}
version_tag: ${{ steps.beta.outputs.version_tag }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Prepare beta release metadata
id: beta
run: node --experimental-strip-types ./scripts/release-beta.ts
build_mac:
name: Build beta mac arm64
needs: metadata
if: ${{ inputs.enable_mac }}
runs-on: macos-14
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Apply beta package version
run: npm pkg set "version=${{ needs.metadata.outputs.beta_version }}" --prefix apps/packaged
- name: Prepare Apple signing certificate
if: ${{ inputs.signed }}
env:
APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_BASE64 }}
APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
cert_path="$RUNNER_TEMP/open-design-signing.p12"
if ! printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 --decode > "$cert_path" 2>/dev/null; then
printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 -D > "$cert_path"
fi
{
echo "CSC_LINK=$cert_path"
echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD"
} >> "$GITHUB_ENV"
- name: Build beta mac artifacts
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
signed_flag=""
if [ "${{ inputs.signed }}" = "true" ]; then
signed_flag="--signed"
fi
pnpm exec tools-pack mac build \
--dir "$RUNNER_TEMP/tools-pack" \
--namespace release-beta \
--portable \
--mac-compression normal \
--to all \
--json \
$signed_flag
- name: Smoke beta mac packaged runtime
working-directory: e2e
env:
OD_PACKAGED_E2E_MAC: "1"
OD_PACKAGED_E2E_NAMESPACE: release-beta
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: pnpm test specs/mac.spec.ts
- name: Prepare beta assets
id: assets
run: |
set -euo pipefail
release_dir="$RUNNER_TEMP/release-assets"
mkdir -p "$release_dir"
source_dmg="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-beta/dmg/Open Design-release-beta.dmg"
source_zip="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-beta/zip/Open Design-release-beta.zip"
if [ ! -f "$source_dmg" ]; then
echo "expected dmg not found at $source_dmg" >&2
exit 1
fi
if [ ! -f "$source_zip" ]; then
echo "expected zip not found at $source_zip" >&2
exit 1
fi
asset_suffix="${{ needs.metadata.outputs.asset_version_suffix }}"
versioned_dmg="open-design-${{ needs.metadata.outputs.beta_version }}${asset_suffix}-mac-arm64.dmg"
versioned_zip="open-design-${{ needs.metadata.outputs.beta_version }}${asset_suffix}-mac-arm64.zip"
dmg_checksum_file="$versioned_dmg.sha256"
zip_checksum_file="$versioned_zip.sha256"
cp "$source_dmg" "$release_dir/$versioned_dmg"
cp "$source_zip" "$release_dir/$versioned_zip"
(
cd "$release_dir"
shasum -a 256 "$versioned_dmg" > "$dmg_checksum_file"
shasum -a 256 "$versioned_zip" > "$zip_checksum_file"
)
zip_sha512="$(openssl dgst -sha512 -binary "$release_dir/$versioned_zip" | openssl base64 -A)"
zip_size="$(stat -f%z "$release_dir/$versioned_zip")"
zip_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${{ needs.metadata.outputs.version_tag }}/$versioned_zip"
release_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat > "$release_dir/latest-mac.yml" <<EOF
version: "${{ needs.metadata.outputs.beta_version }}"
files:
- url: "$zip_url"
sha512: "$zip_sha512"
size: $zip_size
path: "$zip_url"
sha512: "$zip_sha512"
releaseDate: "$release_date"
releaseNotes: "Open Design beta ${{ needs.metadata.outputs.beta_version }}${asset_suffix}"
EOF
- name: Upload mac release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-beta-mac-release-assets
path: ${{ runner.temp }}/release-assets
build_win:
name: Build beta win x64
needs: metadata
if: ${{ inputs.enable_win }}
runs-on: windows-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Apply beta package version
run: npm pkg set "version=${{ needs.metadata.outputs.beta_version }}" --prefix apps/packaged
- name: Build beta windows artifacts
shell: pwsh
run: >-
pnpm exec tools-pack win build
--dir "${{ runner.temp }}/tools-pack"
--namespace release-beta-win
--portable
--to nsis
--json
- name: Prepare windows beta assets
shell: pwsh
run: |
$releaseDir = Join-Path $env:RUNNER_TEMP "release-assets"
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
$sourceInstaller = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/release-beta-win/builder/Open Design-release-beta-win-setup.exe"
$sourceBlockmap = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/release-beta-win/builder/Open Design-release-beta-win-setup.exe.blockmap"
if (!(Test-Path $sourceInstaller)) {
throw "expected installer not found at $sourceInstaller"
}
if (!(Test-Path $sourceBlockmap)) {
throw "expected blockmap not found at $sourceBlockmap"
}
$windowsAssetSuffix = ".unsigned"
$versionedInstaller = "open-design-${{ needs.metadata.outputs.beta_version }}$windowsAssetSuffix-win-x64-setup.exe"
$versionedBlockmap = "open-design-${{ needs.metadata.outputs.beta_version }}$windowsAssetSuffix-win-x64-setup.exe.blockmap"
$checksumFile = "$versionedInstaller.sha256"
Copy-Item $sourceInstaller (Join-Path $releaseDir $versionedInstaller)
Copy-Item $sourceBlockmap (Join-Path $releaseDir $versionedBlockmap)
$installerPath = Join-Path $releaseDir $versionedInstaller
$hash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $versionedInstaller" | Set-Content -Path (Join-Path $releaseDir $checksumFile)
$installerBytes = [System.IO.File]::ReadAllBytes($installerPath)
$installerSha512 = [System.Convert]::ToBase64String([System.Security.Cryptography.SHA512]::Create().ComputeHash($installerBytes))
$installerSize = (Get-Item $installerPath).Length
$installerUrl = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/${{ needs.metadata.outputs.version_tag }}/$versionedInstaller"
$releaseDate = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
@(
'version: "${{ needs.metadata.outputs.beta_version }}"'
'files:'
" - url: `"$installerUrl`""
" sha512: `"$installerSha512`""
" size: $installerSize"
"path: `"$installerUrl`""
"sha512: `"$installerSha512`""
"releaseDate: `"$releaseDate`""
"releaseNotes: `"Open Design beta ${{ needs.metadata.outputs.beta_version }}$windowsAssetSuffix`""
) | Set-Content -Path (Join-Path $releaseDir "latest.yml")
- name: Upload windows release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-beta-win-release-assets
path: ${{ runner.temp }}/release-assets
build_linux:
name: Build beta linux x64
needs: metadata
if: ${{ inputs.enable_linux }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Apply beta package version
env:
BETA_VERSION: ${{ needs.metadata.outputs.beta_version }}
run: npm pkg set "version=$BETA_VERSION" --prefix apps/packaged
# `--containerized` builds the AppImage inside the electronuserland/builder
# Docker image (glibc 2.27 baseline) so the resulting binary runs on older
# distros than ubuntu-latest's glibc 2.39. Docker is preinstalled on the
# GitHub-hosted ubuntu-latest runner, so no extra setup is required.
- name: Build beta linux artifacts
run: |
set -euo pipefail
pnpm exec tools-pack linux build \
--dir "$RUNNER_TEMP/tools-pack" \
--namespace release-beta-linux \
--portable \
--to appimage \
--containerized \
--json
- name: Prepare linux beta assets
env:
BETA_VERSION: ${{ needs.metadata.outputs.beta_version }}
run: |
set -euo pipefail
release_dir="$RUNNER_TEMP/release-assets"
mkdir -p "$release_dir"
source_appimage="$RUNNER_TEMP/tools-pack/out/linux/namespaces/release-beta-linux/builder/Open Design-release-beta-linux.AppImage"
if [ ! -f "$source_appimage" ]; then
echo "expected AppImage not found at $source_appimage" >&2
exit 1
fi
# Linux currently has no signing path in tools-pack, so the suffix is
# hardcoded to .unsigned (matching the windows convention above).
linux_asset_suffix=".unsigned"
versioned_appimage="open-design-${BETA_VERSION}${linux_asset_suffix}-linux-x64.AppImage"
checksum_file="$versioned_appimage.sha256"
cp "$source_appimage" "$release_dir/$versioned_appimage"
(
cd "$release_dir"
sha256sum "$versioned_appimage" > "$checksum_file"
)
- name: Upload linux release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-beta-linux-release-assets
path: ${{ runner.temp }}/release-assets
publish:
name: Publish beta release
needs:
- metadata
- build_mac
- build_win
- build_linux
if: >-
${{
always() &&
!cancelled() &&
needs.metadata.result == 'success' &&
(inputs.enable_mac || inputs.enable_win || inputs.enable_linux) &&
(!inputs.enable_mac || needs.build_mac.result == 'success') &&
(!inputs.enable_win || needs.build_win.result == 'success') &&
(!inputs.enable_linux || needs.build_linux.result == 'success')
}}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
ENABLE_MAC: ${{ inputs.enable_mac }}
ENABLE_WIN: ${{ inputs.enable_win }}
ENABLE_LINUX: ${{ inputs.enable_linux }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Download mac release bundle
if: ${{ inputs.enable_mac }}
uses: actions/download-artifact@v8
with:
name: open-design-beta-mac-release-assets
path: ${{ runner.temp }}/release-assets/mac
- name: Download windows release bundle
if: ${{ inputs.enable_win }}
uses: actions/download-artifact@v8
with:
name: open-design-beta-win-release-assets
path: ${{ runner.temp }}/release-assets/win
- name: Download linux release bundle
if: ${{ inputs.enable_linux }}
uses: actions/download-artifact@v8
with:
name: open-design-beta-linux-release-assets
path: ${{ runner.temp }}/release-assets/linux
- name: Move beta tags to current commit
run: |
set -euo pipefail
git tag -f "${{ needs.metadata.outputs.version_tag }}" "$GITHUB_SHA"
git push origin "refs/tags/${{ needs.metadata.outputs.version_tag }}" --force
git tag -f "${{ needs.metadata.outputs.beta_tag }}" "$GITHUB_SHA"
git push origin "refs/tags/${{ needs.metadata.outputs.beta_tag }}" --force
- name: Write release notes
id: notes
run: |
set -euo pipefail
version_notes_file="$RUNNER_TEMP/open-design-beta-version-notes.md"
latest_notes_file="$RUNNER_TEMP/open-design-beta-latest-notes.md"
cat > "$version_notes_file" <<EOF
## Summary
- channel: beta
- version: ${{ needs.metadata.outputs.beta_version }}
- base version: ${{ needs.metadata.outputs.base_version }}
- mac enabled: ${{ inputs.enable_mac }}
- mac signed/notarized: ${{ needs.metadata.outputs.signed }}
- windows enabled: ${{ inputs.enable_win }}
- windows signed: false
- linux enabled: ${{ inputs.enable_linux }}
- branch: ${{ needs.metadata.outputs.branch }}
- commit: ${{ needs.metadata.outputs.commit }}
This beta release ships the enabled platform artifacts, checksums, and updater feed files for enabled auto-update platforms. Linux AppImage has no auto-update feed yet.
EOF
cat > "$latest_notes_file" <<EOF
## Summary
- channel: beta
- latest version: ${{ needs.metadata.outputs.beta_version }}
- latest tag: ${{ needs.metadata.outputs.version_tag }}
This release is the mutable beta channel feed carrier. It should contain enabled feed assets only: latest-mac.yml and/or latest.yml. If neither mac nor Windows is enabled, no feed assets are expected.
EOF
{
echo "version_notes_file=$version_notes_file"
echo "latest_notes_file=$latest_notes_file"
} >> "$GITHUB_OUTPUT"
- name: Create or update immutable beta prerelease
run: |
set -euo pipefail
all_release_dir="$RUNNER_TEMP/release-assets/all"
mkdir -p "$all_release_dir"
for asset_dir in "$RUNNER_TEMP/release-assets/mac" "$RUNNER_TEMP/release-assets/win" "$RUNNER_TEMP/release-assets/linux"; do
if [ -d "$asset_dir" ] && compgen -G "$asset_dir/*" > /dev/null; then
cp "$asset_dir"/* "$all_release_dir/"
fi
done
if ! compgen -G "$all_release_dir/*" > /dev/null; then
echo "no enabled beta release assets were found" >&2
exit 1
fi
declare -A current_release_assets=()
for asset_path in "$all_release_dir"/*; do
current_release_assets["$(basename "$asset_path")"]=1
done
if gh release view "${{ needs.metadata.outputs.version_tag }}" >/dev/null 2>&1; then
gh release edit "${{ needs.metadata.outputs.version_tag }}" \
--title "${{ needs.metadata.outputs.release_name }}" \
--notes-file "${{ steps.notes.outputs.version_notes_file }}" \
--prerelease
else
gh release create "${{ needs.metadata.outputs.version_tag }}" \
--target "$GITHUB_SHA" \
--title "${{ needs.metadata.outputs.release_name }}" \
--notes-file "${{ steps.notes.outputs.version_notes_file }}" \
--prerelease
fi
gh release upload "${{ needs.metadata.outputs.version_tag }}" "$all_release_dir"/* --clobber
while IFS= read -r asset_name; do
if [ -n "$asset_name" ] && [ -z "${current_release_assets[$asset_name]+x}" ]; then
gh release delete-asset "${{ needs.metadata.outputs.version_tag }}" "$asset_name" --yes
fi
done < <(gh release view "${{ needs.metadata.outputs.version_tag }}" --json assets --jq '.assets[].name')
- name: Create or update beta channel feed
run: |
set -euo pipefail
latest_mac_path="$RUNNER_TEMP/release-assets/mac/latest-mac.yml"
latest_win_path="$RUNNER_TEMP/release-assets/win/latest.yml"
feed_assets=()
if [ "$ENABLE_MAC" = "true" ]; then
if [ ! -f "$latest_mac_path" ]; then
echo "expected mac feed not found at $latest_mac_path" >&2
exit 1
fi
feed_assets+=("$latest_mac_path")
fi
if [ "$ENABLE_WIN" = "true" ]; then
if [ ! -f "$latest_win_path" ]; then
echo "expected windows feed not found at $latest_win_path" >&2
exit 1
fi
feed_assets+=("$latest_win_path")
fi
declare -A current_feed_assets=()
for feed_asset in "${feed_assets[@]}"; do
current_feed_assets["$(basename "$feed_asset")"]=1
done
if gh release view "${{ needs.metadata.outputs.beta_tag }}" >/dev/null 2>&1; then
gh release edit "${{ needs.metadata.outputs.beta_tag }}" \
--title "Open Design Beta Latest" \
--notes-file "${{ steps.notes.outputs.latest_notes_file }}" \
--prerelease
else
gh release create "${{ needs.metadata.outputs.beta_tag }}" \
--target "$GITHUB_SHA" \
--title "Open Design Beta Latest" \
--notes-file "${{ steps.notes.outputs.latest_notes_file }}" \
--prerelease
fi
if [ "${#feed_assets[@]}" -gt 0 ]; then
gh release upload "${{ needs.metadata.outputs.beta_tag }}" "${feed_assets[@]}" --clobber
fi
while IFS= read -r asset_name; do
if [ -n "$asset_name" ] && [ -z "${current_feed_assets[$asset_name]+x}" ]; then
gh release delete-asset "${{ needs.metadata.outputs.beta_tag }}" "$asset_name" --yes
fi
done < <(gh release view "${{ needs.metadata.outputs.beta_tag }}" --json assets --jq '.assets[].name')
- name: Publish summary
run: |
{
echo "## Beta release"
echo "- Channel: beta"
echo "- Version: ${{ needs.metadata.outputs.beta_version }}"
echo "- Version tag: ${{ needs.metadata.outputs.version_tag }}"
echo "- Channel feed tag: ${{ needs.metadata.outputs.beta_tag }}"
echo "- mac enabled: $ENABLE_MAC"
echo "- mac signed/notarized: ${{ needs.metadata.outputs.signed }}"
echo "- windows enabled: $ENABLE_WIN"
echo "- windows signed: false"
echo "- linux enabled: $ENABLE_LINUX"
if [ "$ENABLE_MAC" = "true" ]; then
echo "- mac assets: open-design-${{ needs.metadata.outputs.beta_version }}${{ needs.metadata.outputs.asset_version_suffix }}-mac-arm64.dmg, open-design-${{ needs.metadata.outputs.beta_version }}${{ needs.metadata.outputs.asset_version_suffix }}-mac-arm64.zip"
fi
if [ "$ENABLE_WIN" = "true" ]; then
echo "- win assets: open-design-${{ needs.metadata.outputs.beta_version }}.unsigned-win-x64-setup.exe, open-design-${{ needs.metadata.outputs.beta_version }}.unsigned-win-x64-setup.exe.blockmap"
fi
if [ "$ENABLE_LINUX" = "true" ]; then
echo "- linux assets: open-design-${{ needs.metadata.outputs.beta_version }}.unsigned-linux-x64.AppImage"
fi
echo "- Feeds: enabled mac/win feeds only (no latest-linux.yml; AppImage updater not yet wired)"
} >> "$GITHUB_STEP_SUMMARY"

483
.github/workflows/release-stable.yml vendored Normal file
View File

@@ -0,0 +1,483 @@
name: release-stable
on:
workflow_dispatch:
inputs:
mac_signed:
description: "Build signed/notarized mac artifacts. Disable only for explicit unsigned validation releases."
required: true
type: boolean
default: true
permissions:
contents: write
concurrency:
group: open-design-release-stable
cancel-in-progress: false
jobs:
metadata:
name: Prepare stable metadata
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
outputs:
base_version: ${{ steps.stable.outputs.base_version }}
branch: ${{ steps.stable.outputs.branch }}
commit: ${{ steps.stable.outputs.commit }}
mac_signed: ${{ inputs.mac_signed }}
previous_stable: ${{ steps.stable.outputs.previous_stable }}
release_name: ${{ steps.stable.outputs.release_name }}
stable_version: ${{ steps.stable.outputs.stable_version }}
version_tag: ${{ steps.stable.outputs.version_tag }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Prepare stable release metadata
id: stable
run: node --experimental-strip-types ./scripts/release-stable.ts
verify:
name: Verify build (typecheck + tests)
needs: metadata
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
# `scripts/postinstall.mjs` auto-builds `packages/*` and `tools/*`, but
# `apps/daemon` and `apps/desktop` are not in that list. On a fresh clone
# (every CI run), workspace typecheck fails because:
# - packaged/runtime consumers resolve the daemon package export through
# generated `apps/daemon/dist/*.d.ts`
# - `apps/packaged/src/index.ts` dynamic-imports `@open-design/desktop/main`
# which resolves to `apps/desktop/dist/main/index.d.ts`
# Build them explicitly here. Keeps the root `typecheck` script untouched.
- name: Build daemon and desktop (typecheck dependencies)
run: |
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build
- name: Typecheck workspaces
run: pnpm -r --workspace-concurrency=1 --if-present run typecheck
- name: Check repository layout policies
run: pnpm guard
# Workspace tests are intentionally not gated here. apps/web's
# i18n content-coverage tests assert that every locale carries
# display metadata for every prompt template / skill / design
# system. Those tests fail on `main` as of this writing because
# PR #187 added two new prompt templates without translating
# their metadata into the 9 ship-ready locales — an i18n drift
# that's out of scope for the release infrastructure. Tracked as
# a follow-up; revisit once locale metadata is back in sync.
build_mac:
name: Build stable mac arm64
needs: [metadata, verify]
runs-on: macos-14
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare Apple signing certificate
if: ${{ inputs.mac_signed }}
env:
APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_BASE64 }}
APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
cert_path="$RUNNER_TEMP/open-design-signing.p12"
if ! printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 --decode > "$cert_path" 2>/dev/null; then
printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 -D > "$cert_path"
fi
{
echo "CSC_LINK=$cert_path"
echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD"
} >> "$GITHUB_ENV"
- name: Build stable mac artifacts
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
signed_flag=""
if [ "${{ inputs.mac_signed }}" = "true" ]; then
signed_flag="--signed"
fi
pnpm exec tools-pack mac build \
--dir "$RUNNER_TEMP/tools-pack" \
--namespace release-stable \
--portable \
--mac-compression normal \
--to all \
--json \
$signed_flag
- name: Prepare stable mac assets
id: assets
run: |
set -euo pipefail
release_dir="$RUNNER_TEMP/release-assets"
mkdir -p "$release_dir"
source_dmg="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-stable/dmg/Open Design-release-stable.dmg"
source_zip="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-stable/zip/Open Design-release-stable.zip"
if [ ! -f "$source_dmg" ]; then
echo "expected dmg not found at $source_dmg" >&2
exit 1
fi
if [ ! -f "$source_zip" ]; then
echo "expected zip not found at $source_zip" >&2
exit 1
fi
versioned_dmg="open-design-${{ needs.metadata.outputs.stable_version }}-mac-arm64.dmg"
versioned_zip="open-design-${{ needs.metadata.outputs.stable_version }}-mac-arm64.zip"
dmg_checksum_file="$versioned_dmg.sha256"
zip_checksum_file="$versioned_zip.sha256"
cp "$source_dmg" "$release_dir/$versioned_dmg"
cp "$source_zip" "$release_dir/$versioned_zip"
(
cd "$release_dir"
shasum -a 256 "$versioned_dmg" > "$dmg_checksum_file"
shasum -a 256 "$versioned_zip" > "$zip_checksum_file"
)
zip_sha512="$(openssl dgst -sha512 -binary "$release_dir/$versioned_zip" | openssl base64 -A)"
zip_size="$(stat -f%z "$release_dir/$versioned_zip")"
zip_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${{ needs.metadata.outputs.version_tag }}/$versioned_zip"
release_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat > "$release_dir/latest-mac.yml" <<EOF
version: "${{ needs.metadata.outputs.stable_version }}"
files:
- url: "$zip_url"
sha512: "$zip_sha512"
size: $zip_size
path: "$zip_url"
sha512: "$zip_sha512"
releaseDate: "$release_date"
releaseNotes: "Open Design ${{ needs.metadata.outputs.stable_version }}"
EOF
- name: Upload mac release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-stable-mac-release-assets
path: ${{ runner.temp }}/release-assets
build_win:
name: Build stable win x64
needs: [metadata, verify]
runs-on: windows-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build stable windows artifacts
shell: pwsh
run: >-
pnpm exec tools-pack win build
--dir "${{ runner.temp }}/tools-pack"
--namespace release-stable-win
--portable
--to nsis
--json
- name: Prepare windows stable assets
shell: pwsh
run: |
$releaseDir = Join-Path $env:RUNNER_TEMP "release-assets"
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
$sourceInstaller = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/release-stable-win/builder/Open Design-release-stable-win-setup.exe"
$sourceBlockmap = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/release-stable-win/builder/Open Design-release-stable-win-setup.exe.blockmap"
if (!(Test-Path $sourceInstaller)) {
throw "expected installer not found at $sourceInstaller"
}
if (!(Test-Path $sourceBlockmap)) {
throw "expected blockmap not found at $sourceBlockmap"
}
$versionedInstaller = "open-design-${{ needs.metadata.outputs.stable_version }}-win-x64-setup.exe"
$versionedBlockmap = "open-design-${{ needs.metadata.outputs.stable_version }}-win-x64-setup.exe.blockmap"
$checksumFile = "$versionedInstaller.sha256"
Copy-Item $sourceInstaller (Join-Path $releaseDir $versionedInstaller)
Copy-Item $sourceBlockmap (Join-Path $releaseDir $versionedBlockmap)
$installerPath = Join-Path $releaseDir $versionedInstaller
$hash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $versionedInstaller" | Set-Content -Path (Join-Path $releaseDir $checksumFile)
$installerBytes = [System.IO.File]::ReadAllBytes($installerPath)
$installerSha512 = [System.Convert]::ToBase64String([System.Security.Cryptography.SHA512]::Create().ComputeHash($installerBytes))
$installerSize = (Get-Item $installerPath).Length
$installerUrl = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/${{ needs.metadata.outputs.version_tag }}/$versionedInstaller"
$releaseDate = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
@(
'version: "${{ needs.metadata.outputs.stable_version }}"'
'files:'
" - url: `"$installerUrl`""
" sha512: `"$installerSha512`""
" size: $installerSize"
"path: `"$installerUrl`""
"sha512: `"$installerSha512`""
"releaseDate: `"$releaseDate`""
"releaseNotes: `"Open Design ${{ needs.metadata.outputs.stable_version }}`""
) | Set-Content -Path (Join-Path $releaseDir "latest.yml")
- name: Upload windows release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-stable-win-release-assets
path: ${{ runner.temp }}/release-assets
build_linux:
name: Build stable linux x64
needs: [metadata, verify]
# Linux AppImage packaging is temporarily excluded from stable releases.
# Keep the job definition in place so the Linux lane can be re-enabled once
# the containerized pnpm bootstrap is fixed and reviewed.
if: ${{ false }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
# `--containerized` builds the AppImage inside the electronuserland/builder
# Docker image (glibc 2.27 baseline) so the resulting binary runs on older
# distros than ubuntu-latest's glibc 2.39. Docker is preinstalled on the
# GitHub-hosted ubuntu-latest runner, so no extra setup is required.
- name: Build stable linux artifacts
run: |
set -euo pipefail
pnpm exec tools-pack linux build \
--dir "$RUNNER_TEMP/tools-pack" \
--namespace release-stable-linux \
--portable \
--to appimage \
--containerized \
--json
- name: Prepare linux stable assets
env:
STABLE_VERSION: ${{ needs.metadata.outputs.stable_version }}
run: |
set -euo pipefail
release_dir="$RUNNER_TEMP/release-assets"
mkdir -p "$release_dir"
source_appimage="$RUNNER_TEMP/tools-pack/out/linux/namespaces/release-stable-linux/builder/Open Design-release-stable-linux.AppImage"
if [ ! -f "$source_appimage" ]; then
echo "expected AppImage not found at $source_appimage" >&2
exit 1
fi
# Linux currently has no signing path in tools-pack; the asset has no
# signing-related suffix (matches the windows convention above).
versioned_appimage="open-design-${STABLE_VERSION}-linux-x64.AppImage"
checksum_file="$versioned_appimage.sha256"
cp "$source_appimage" "$release_dir/$versioned_appimage"
(
cd "$release_dir"
sha256sum "$versioned_appimage" > "$checksum_file"
)
- name: Upload linux release bundle
uses: actions/upload-artifact@v7
with:
name: open-design-stable-linux-release-assets
path: ${{ runner.temp }}/release-assets
publish:
name: Publish stable release
needs:
- metadata
- verify
- build_mac
- build_win
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Pre-flight tag/release check
run: |
set -euo pipefail
if git ls-remote --exit-code --tags origin "refs/tags/${{ needs.metadata.outputs.version_tag }}" >/dev/null 2>&1; then
echo "tag ${{ needs.metadata.outputs.version_tag }} already exists on origin; aborting" >&2
exit 1
fi
if gh release view "${{ needs.metadata.outputs.version_tag }}" >/dev/null 2>&1; then
echo "release ${{ needs.metadata.outputs.version_tag }} already exists; aborting" >&2
exit 1
fi
- name: Download mac release bundle
uses: actions/download-artifact@v8
with:
name: open-design-stable-mac-release-assets
path: ${{ runner.temp }}/release-assets/mac
- name: Download windows release bundle
uses: actions/download-artifact@v8
with:
name: open-design-stable-win-release-assets
path: ${{ runner.temp }}/release-assets/win
- name: Write release notes shell
id: notes
run: |
set -euo pipefail
notes_file="$RUNNER_TEMP/open-design-stable-notes.md"
cat > "$notes_file" <<EOF
## Summary
- channel: stable
- version: ${{ needs.metadata.outputs.stable_version }}
- mac signed/notarized: ${{ inputs.mac_signed }}
- windows signed: false
- branch: ${{ needs.metadata.outputs.branch }}
- commit: ${{ needs.metadata.outputs.commit }}
See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/${{ needs.metadata.outputs.version_tag }}/CHANGELOG.md) for the full release notes.
This stable release ships mac arm64 DMG/update ZIP, Windows x64 NSIS installer assets, checksums, and updater feed files. Linux AppImage packaging is temporarily deferred from the stable release lane.
EOF
echo "notes_file=$notes_file" >> "$GITHUB_OUTPUT"
- name: Create draft release with tag
id: create_release
run: |
set -euo pipefail
# gh release create creates the tag at $GITHUB_SHA atomically with the release.
# Using --draft keeps the release invisible until all assets upload successfully;
# the cleanup step rolls back the release + tag together if any subsequent step fails.
gh release create "${{ needs.metadata.outputs.version_tag }}" \
--target "$GITHUB_SHA" \
--title "${{ needs.metadata.outputs.release_name }}" \
--notes-file "${{ steps.notes.outputs.notes_file }}" \
--draft
- name: Upload assets to draft release
run: |
set -euo pipefail
all_release_dir="$RUNNER_TEMP/release-assets/all"
mkdir -p "$all_release_dir"
cp "$RUNNER_TEMP/release-assets/mac"/* "$all_release_dir/"
cp "$RUNNER_TEMP/release-assets/win"/* "$all_release_dir/"
gh release upload "${{ needs.metadata.outputs.version_tag }}" "$all_release_dir"/*
- name: Promote draft to published latest
run: |
set -euo pipefail
gh release edit "${{ needs.metadata.outputs.version_tag }}" \
--draft=false \
--latest
- name: Cleanup release + tag on failure
if: failure() && steps.create_release.outcome == 'success'
run: |
set +e
echo "publish failed after release was created; rolling back release and tag"
gh release delete "${{ needs.metadata.outputs.version_tag }}" --cleanup-tag --yes
# belt-and-suspenders: ensure remote tag is gone even if --cleanup-tag missed
git push origin --delete "refs/tags/${{ needs.metadata.outputs.version_tag }}" || true
- name: Publish summary
run: |
{
echo "## Stable release"
echo "- Channel: stable"
echo "- Version: ${{ needs.metadata.outputs.stable_version }}"
echo "- Version tag: ${{ needs.metadata.outputs.version_tag }}"
echo "- mac signed/notarized: ${{ inputs.mac_signed }}"
echo "- windows signed: false"
echo "- mac assets: open-design-${{ needs.metadata.outputs.stable_version }}-mac-arm64.dmg, open-design-${{ needs.metadata.outputs.stable_version }}-mac-arm64.zip"
echo "- win assets: open-design-${{ needs.metadata.outputs.stable_version }}-win-x64-setup.exe, open-design-${{ needs.metadata.outputs.stable_version }}-win-x64-setup.exe.blockmap"
echo "- linux assets: deferred from this stable release"
echo "- Feeds: latest-mac.yml, latest.yml"
} >> "$GITHUB_STEP_SUMMARY"

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
node_modules/
dist/
out/
.next/
.next-*/
.tmp/
.DS_Store
*.log
*.exe
.vite
.astro/
.vscode
# Local runtime data — auto-created by the daemon on first start.
# Holds app.sqlite (project metadata), projects/<id>/ (per-project artifacts,
# the agent's CWD), and artifacts/ (one-off renders). Never commit.
.od
.od-e2e
test-results
playwright-report
e2e/.od-data
e2e/playwright-report
e2e/reports/html
e2e/reports/playwright-html-report
e2e/reports/test-results
e2e/reports/results.json
e2e/reports/junit.xml
e2e/reports/latest.md
e2e/ui/.od-data
e2e/ui/reports
e2e/ui/test-results
apps/web/playwright/
# Legacy folder name from before the rename; keep ignored so existing
# clones don't accidentally stage stale runtime data.
.ocd
tsconfig.tsbuildinfo
.claude-sessions/*
.cursor/
.agents/
.opencode/
.claude/
.codex/
.deepseek/
# Commander task scratchpad; keep local task notes out of git by default.
.task/
task.md
specs/change/active
.ralph/

154
AGENTS.md Normal file
View File

@@ -0,0 +1,154 @@
# Directory guide
This file is the single source of truth for agents entering this repository. Read this file first; after entering `apps/`, `packages/`, `tools/`, or `e2e/`, read that layer's `AGENTS.md` for module-level details. Do not copy module details back into the root file; root stays focused on cross-repository boundaries, workflow, and commands.
## Core documentation index
- Product and onboarding: `README.md`, `README.zh-CN.md`, `QUICKSTART.md`.
- Contribution and environment: `CONTRIBUTING.md`, `CONTRIBUTING.zh-CN.md`.
- Architecture and protocols: `docs/spec.md`, `docs/architecture.md`, `docs/skills-protocol.md`, `docs/agent-adapters.md`, `docs/modes.md`.
- Roadmap and references: `docs/roadmap.md`, `docs/references.md`, `specs/current/maintainability-roadmap.md`.
- Directory-level agent guidance: `apps/AGENTS.md`, `packages/AGENTS.md`, `tools/AGENTS.md`, `e2e/AGENTS.md`.
## Workspace directories
- Workspace packages come from `pnpm-workspace.yaml`: `apps/*`, `packages/*`, `tools/*`, and `e2e`.
- Top-level content directories: `skills/` (artifact-shape skills), `design-systems/` (brand `DESIGN.md` files), `craft/` (universal brand-agnostic craft rules a skill can opt into via `od.craft.requires`).
- `apps/web` is the Next.js 16 App Router + React 18 web runtime; do not restore `apps/nextjs`.
- `apps/daemon` is the local privileged daemon and `od` bin. It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving.
- `apps/desktop` is the Electron shell; it discovers the web URL through sidecar IPC.
- `apps/packaged` is the thin packaged Electron runtime entry; it starts packaged sidecars and owns the `od://` entry glue only.
- `packages/contracts` is the pure TypeScript web/daemon app contract layer.
- `packages/sidecar-proto` owns the Open Design sidecar business protocol; `packages/sidecar` owns the generic sidecar runtime; `packages/platform` owns generic OS process primitives.
- `tools/dev` is the local development lifecycle control plane.
- `tools/pack` is the local packaged build/start/stop/logs control plane and mac beta release artifact preparation surface.
- `e2e` owns user-level end-to-end smoke tests and Playwright UI automation; read `e2e/AGENTS.md` before editing its tests or commands.
## Inactive or placeholder directories
- `apps/nextjs` and `packages/shared` have been removed; do not recreate or reference them.
- `.od/`, `.tmp/`, Playwright reports, and agent scratch directories are local runtime data and must stay out of git.
# Development workflow
## Environment baseline
- Runtime target is Node `~24` and `pnpm@10.33.2`; use Corepack so the pnpm version pinned in `package.json` is selected.
- New project-owned entrypoints, modules, scripts, tests, reporters, and configs should default to TypeScript.
- Residual JavaScript is limited to generated output, vendored dependencies, explicitly documented compatibility build artifacts, and the allowlist in `scripts/guard.ts`.
## Local lifecycle
- Use `pnpm tools-dev` as the only local development lifecycle entry point.
- Do not add or restore root lifecycle aliases: `pnpm dev`, `pnpm dev:all`, `pnpm daemon`, `pnpm preview`, or `pnpm start`.
- Ports are governed by `tools-dev` flags: `--daemon-port` and `--web-port`.
- `tools-dev` exports `OD_PORT` for the web proxy target and `OD_WEB_PORT` for the web listener; do not use `NEXT_PORT`.
## Root command boundary
- Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints: `pnpm guard`, `pnpm typecheck`, `pnpm tools-dev`, and `pnpm tools-pack`.
- Do not add root aggregate `pnpm build` or `pnpm test` aliases. Build/test commands must stay package-scoped (`pnpm --filter <package> ...`) or tool-scoped (`pnpm tools-pack ...`).
- Do not add root e2e aliases; e2e package commands and ownership rules live in `e2e/AGENTS.md`.
## Boundary constraints
- Tests under `apps/`, `packages/`, and `tools/` live in a package/app/tool-level `tests/` directory sibling to `src/`; keep `src/` source-only and do not add new `*.test.ts` or `*.test.tsx` files under `src/`. Playwright UI automation belongs to `e2e/ui/`, not app packages.
- App packages must not import another app's private `src/` or `tests/` implementation as a shared helper. In particular, `apps/web/**` must not import `apps/daemon/src/**`; web/daemon integration belongs behind HTTP APIs, `packages/contracts`, and app-local provider boundaries.
- Cross-app, cross-runtime, or repository-resource consistency checks belong in `e2e/tests/` when they need to observe more than one app/package boundary; promote reusable logic to a pure package instead of borrowing another app's private source.
- Keep shared API DTOs, SSE event unions, error shapes, task shapes, and example payloads in `packages/contracts`; update contracts before wiring divergent web/daemon request or response shapes.
- Keep `packages/contracts` pure TypeScript and free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, and sidecar control-plane dependencies.
- Keep project-owned entrypoints, modules, scripts, tests, reporters, and configs TypeScript-first; generated `dist/*.js` is runtime output, and source edits belong in `.ts` files.
- New `.js`, `.mjs`, or `.cjs` files need an explicit generated/vendor/compatibility reason and must pass `pnpm guard`.
- App business logic must not know about sidecar/control-plane concepts. Keep sidecar awareness in `apps/<app>/sidecar` or the desktop sidecar entry wrapper.
- Shared web/daemon app contracts belong in `packages/contracts`; that package must not depend on Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, or the sidecar control-plane protocol.
- Sidecar process stamps must have exactly five fields: `app`, `mode`, `namespace`, `ipc`, and `source`.
- Orchestration layers (`tools-dev`, `tools-pack`, packaged launchers) must call package primitives; do not hand-build `--od-stamp-*` args or process-scan regexes.
- Packaged runtime paths must be namespace-scoped and independent from daemon/web ports; ports are transient transport details only.
- Default runtime files live under `<project-root>/.tmp/<source>/<namespace>/...`; POSIX IPC sockets are fixed at `/tmp/open-design/ipc/<namespace>/<app>.sock`.
## Git commit policy
- Git commits must not include `Co-authored-by` trailers or any other co-author metadata.
## Validation strategy
- After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh.
- 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`.
- Stamp/namespace changes must validate two concurrent namespaces and run desktop `inspect eval` plus `inspect screenshot` for each namespace.
- Path/log changes must run `pnpm tools-dev logs --namespace <name> --json` and confirm log paths are under `.tmp/tools-dev/<namespace>/...`.
# Common commands
```bash
pnpm install
pnpm tools-dev
pnpm tools-dev start web
pnpm tools-dev run web --daemon-port 17456 --web-port 17573
pnpm tools-dev status --json
pnpm tools-dev logs --json
pnpm tools-dev inspect desktop status --json
pnpm tools-dev inspect desktop screenshot --path /tmp/open-design.png
pnpm tools-dev stop
pnpm tools-dev check
```
```bash
pnpm guard
pnpm typecheck
```
```bash
pnpm --filter @open-design/web typecheck
pnpm --filter @open-design/web test
pnpm --filter @open-design/web build
pnpm --filter @open-design/daemon test
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build
pnpm --filter @open-design/tools-dev build
pnpm --filter @open-design/tools-pack build
```
```bash
pnpm tools-pack mac build --to all
pnpm tools-pack mac install
pnpm tools-pack mac cleanup
pnpm tools-pack win build --to nsis
pnpm tools-pack win install
pnpm tools-pack win cleanup
pnpm tools-pack linux build --to appimage
pnpm tools-pack linux install
pnpm tools-pack linux build --containerized
```
# FAQ
## Why is there no root `pnpm dev` / `pnpm start`?
To avoid starting daemon, web, and desktop through inconsistent env, port, namespace, or log paths. All local lifecycle flows must go through `pnpm tools-dev`.
## Why should `apps/nextjs` not be restored?
The current web runtime is `apps/web`. The historical `apps/nextjs` layout has been removed from the active repo shape; restoring it would reintroduce duplicate app boundaries and stale scripts.
## How does desktop discover the web URL?
Desktop queries runtime status through sidecar IPC. The web URL comes from `tools-dev` launch status, not from desktop guessing ports or reading web internals.
## How are sidecar-proto, sidecar, and platform split?
`@open-design/sidecar-proto` owns Open Design app/mode/source constants, namespace validation, stamp fields/flags, IPC message schema, status shapes, and error semantics. `@open-design/sidecar` provides only generic bootstrap, IPC transport, path/runtime resolution, launch env, and JSON runtime files. `@open-design/platform` provides only generic OS process stamp serialization, command parsing, and process matching/search primitives, consuming the proto descriptor.
## Where is data written?
The daemon writes `.od/` by default: SQLite at `.od/app.sqlite`, agent CWDs under `.od/projects/<id>/`, saved renders under `.od/artifacts/`, and credentials at `.od/media-config.json`. Two env vars override the storage root, in order:
1. `OD_DATA_DIR=<dir>` — relocates *all* daemon runtime data to `<dir>` (used by Playwright for test isolation, and by the packaged daemon and the Home Manager / NixOS modules to point the daemon at a writable directory when the install root is read-only). The path is resolved with `~/` expansion and relative paths anchored to `<projectRoot>`.
2. `OD_MEDIA_CONFIG_DIR=<dir>` — narrower override that relocates *only* `media-config.json`. Same resolution semantics. Most installs do not need this; it exists for setups that want to keep API credentials in a different location from the rest of the runtime data.
Default precedence is OD_MEDIA_CONFIG_DIR > OD_DATA_DIR > `<projectRoot>/.od`.
## When is `pnpm install` required?
Run `pnpm install` after changing package manifests, workspace layout, command entrypoints, bin/link-related content, or after adding/removing workspace packages.

682
CHANGELOG.md Normal file
View File

@@ -0,0 +1,682 @@
# Changelog
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.4.1] - 2026-05-06
0.4.1 is the startup hotfix for the broken 0.4.0 desktop packages. It restores packaged app startup on macOS and Windows, adds release validation so the failure mode is caught before publication, and includes the small UI, agent, documentation, i18n, and craft updates that landed while the hotfix was being verified.
### Added
#### Web / UI
- **Manual edit mode** for direct artifact edits. ([#620])
- **Cmd/Ctrl+P quick file switcher** for faster project navigation. ([#556])
- Resizable chat panel. ([#563])
#### Daemon and agents
- Added model name to PI initial status and RPC abort on cancel. ([#618])
#### Craft and i18n
- Craft `accessibility-baseline` module with opt-ins for dashboard, HR onboarding, and mobile onboarding. ([#587])
- Craft `rtl-and-bidi` module so artifacts handle Arabic, Hebrew, and Persian content more reliably. ([#595])
- Added i18n structure checks. ([#608])
### Changed
- Updated README first-PR links so `help-wanted` issues are surfaced alongside `good-first-issue`. ([#605])
### Fixed
#### Packaging
- Fixed packaged desktop startup by building `@open-design/contracts` to `dist/*.mjs` + `.d.ts`, pointing its exports at compiled JavaScript, and building contracts before all packaged lanes pack workspace tarballs. ([#577])
- Added packaged runtime beta gating so release candidates install, start, inspect `/api/health`, collect logs, stop, and uninstall before promotion. ([#637])
#### Daemon and agents
- Added the required stdio MCP server env field and recover from `-32602` on `session/set_model`. ([#627])
- Normalized ACP `mcpServers` to the stdio shape for Kimi/Hermes ACP. ([#612])
- Fixed agent CLI configuration and workspace focus mode. ([#604])
#### Web and desktop
- Preserved error messages across conversation reloads. ([#623])
- Kept chat recoverable after conversation load failures. ([#637])
- Honored native macOS quit behavior in the packaged desktop shell. ([#637])
### Documentation
- Documented `OD_DATA_DIR` and migration from `.od/` to the Desktop app. ([#570])
- Added Chinese (Simplified) QUICKSTART. ([#578])
- Backported missing zh-TW README sections from the English README. ([#586])
- Synced and improved the Korean README. ([#619])
### Internal
- Refined release workflows, CI scope, e2e layout, and packaged runtime smoke coverage for beta validation. ([#637])
- Refreshed generated GitHub metrics. ([#592])
## [0.4.0] - 2026-05-05
A multi-protocol leap: Open Design now ships as an MCP server, ships Critique Theater (Design Jury) Phase 4, gains live-reload + Tweaks mode + live artifacts in the preview pane, and adds five new agent / runtime adapters. 71 merged PRs from 40+ contributors over two days. Linux AppImage packaging landed in tooling, but the stable Linux artifact is deferred from 0.4.0 while containerized release packaging is hardened.
### Added
#### MCP & agent integration
- **`od mcp` — expose Open Design as a stdio MCP server.** Coding agents in other repos (Claude Code, Codex, Cursor, VS Code, Antigravity, Zed, Windsurf) can read files from local Open Design projects directly, including the project the user has open in the Open Design app right now. ([#399])
- **Link code folder support for agent context** — point agents at any local code folder alongside the design project. ([#455])
- Kilo CLI (ACP) agent adapter. ([#480])
- DeepSeek TUI agent adapter. ([#439])
#### Critique workflow
- **Critique Theater Phase 4** — persistence, transcript, and orchestrator. The "Design Jury" multi-panelist scoring pipeline is now end-to-end. ([#481])
- Critique Theater foundation — shared contracts and streaming v1 parser (Phases 02). ([#387])
#### Preview pane
- **Live-reload preview iframes** when project files change on disk. ([#409])
- **Tweaks mode for HTML previews** — element picker, pod selection, batched chat attachments. ([#513])
- URL-load HTML preview iframes by default (`?forceInline=1` opt-out). ([#384])
- **Live artifacts and Composio connector catalog.** ([#381])
#### Packaging & deployment
- **Linux x64 AppImage tooling** in `tools-pack`; stable release artifact deferred from 0.4.0 while the containerized packaging lane is hardened. ([#369])
- Optimize packaged mac artifact size. ([#424])
#### Daemon
- `OD_MEDIA_CONFIG_DIR` to relocate `media-config.json` (Nix store, immutable images, sandboxes). ([#411])
- Modernized multi-provider API proxy routing (Anthropic, OpenAI-compatible, Azure OpenAI, Google Gemini). ([#385])
- Seed daemon with pre-baked decks and web prototypes. ([#457])
#### Skills, design systems & prompt templates
- **Atelier Zero** editorial collage landing-page design system. ([#366])
- `open-design-landing` rename, **kami skill bundle**, and landing OG assets. ([#428])
- Craft `animation-discipline` module + opt-ins on mobile-app, mobile-onboarding, gamified-app. ([#515])
- Craft `state-coverage` module + opt-ins on dashboard, mobile-app, kanban-board. ([#502])
#### Web / UI
- Skills & design systems management page in Settings. ([#535])
#### Design Files
- Batch ZIP download with multi-select. ([#405])
#### Internationalization
- Complete **French** localization, README, and Quickstart. ([#326], [#397], [#434])
- **Ukrainian** UI localization. ([#395])
- **Russian** UI locale refresh + README + gallery metadata. ([#393], [#396])
- Brazilian Portuguese README translation. ([#460])
- Arabic README translation. ([#458])
### Changed
- Refactor `RUNTIME_DATA_DIR` resolution logic. ([#391])
- Update Codex sandbox invocation. ([#477])
### Fixed
#### Security
- Bind daemon to localhost by default + origin validation. ([#365])
- Strip `ANTHROPIC_API_KEY` when spawning Claude Code. ([#400])
- Preserve `ANTHROPIC_API_KEY` when `ANTHROPIC_BASE_URL` is set. ([#514])
- Preserve `*_API_KEY` env vars for CLI agents in packaged builds. ([#404])
- Normalize daemon proxy origins. ([#392])
#### Daemon
- Resolve daemon `package.json` from any compiled layout so the packaged app reports the correct version. ([#537])
- Correct Claude Code `--add-dir` capability detection. ([#440])
- Handle ACP `-32603` errors gracefully in `session/set_model`. ([#492])
- Expose skill resources via cwd-relative aliases. ([#435])
- Support nested paths in project file serve route. ([#401])
- Respect baseUrl path verbatim in OpenAI-compat proxy. ([#410])
#### Web UI
- Prevent vertical scrollbar on artifact preview frame. ([#453])
- Prevent vertical scrollbar on `ws-tabs-bar`. ([#448])
- Language option button height truncation in Settings. ([#447])
- Aspect-ratio cards no longer overflow into siblings. ([#476])
- Add copy buttons for FileViewer code blocks. ([#471])
- Lowercase `todowrite` compatibility in ToolCard. ([#523])
- Cap `htmlPreviewSlideState` Map to prevent memory leak. ([#488])
- Isolate preview blob export paths. ([#429])
- Split execution-mode tabs and align active chip visuals. ([#418])
- Tighten entry-tab layout and design-system showcase color picker. ([#412])
- Lift coming-soon tip above sticky tabs and make it readable in dark theme. ([#382])
- Fix file tab wheel scrolling. ([#549])
#### Design Files
- Clear selection on project switch. ([#465])
#### Agents
- Copilot prompt processing with correct command format. ([#466])
- Codex Gemini CLI trust handling. ([#352])
#### Desktop
- Show window on macOS dock activate. ([#270])
#### Packaging
- Bundle prompt templates in packaged desktop resources. ([#417])
#### Landing page
- Deploy with `npm wrangler`. ([#421])
### Documentation
- Discord invite badge in README. ([#504])
- Surface desktop downloads in README. ([#522])
- "Running the Project" section in README. ([#468])
- First-PR link points to /contribute page. ([#494])
- Defer README template-driven generation; capture #195 discussion. ([#403])
- Fix typo in zh-TW README. ([#548])
- Auto-generated metrics SVG and contributors wall refresh. ([#406], [#407], [#489], [#490])
### Internal
- Enforce test directory conventions. ([#496])
## [0.3.0] - 2026-05-03
A fast follow-up to 0.2.0 focused on richer design workflows, packaged-agent reliability, export/deploy flows, and broader internationalization. 39 merged PRs from 25 contributors.
### Added
#### Web / UI
- Pet companion with Codex hatch-pet integration. ([#296])
- Brand design-system cards, thumbnails, and DESIGN.md side-by-side preview. ([#289])
- Per-tool renderer registry for generative UI. ([#282])
- Task completion sound and browser notification. ([#359])
#### Agents & daemon
- Persist code-agent startup state. ([#255])
- Mistral Vibe CLI agent adapter. ([#354])
- Devin for Terminal support. ([#301])
- `OD_BIND_HOST` and `--host` for interface binding. ([#328])
#### Skills & exports
- Taste-skill-derived web prototype and HTML PPT examples. ([#358])
- `pptx-html-fidelity-audit` skill wired into export prompts. ([#307])
- Broader PPTX fidelity script coverage beyond CJK. ([#308])
- Native desktop Save As dialog for `.pptx` downloads. ([#330])
- Export as Markdown from the share menu. ([#345])
#### Deployment
- `/api/projects/:id/deploy/preflight` for pre-upload inspection. ([#320])
#### Internationalization
- Arabic (`ar`) UI locale with RTL layout. ([#316])
- French (`fr`) UI locale. ([#376])
### Fixed
#### Agents, packaged runtime & Windows
- Include `nvm` / `fnm` / `mise` agent CLI bins in packaged PATH. ([#364])
- Detect Codex and Gemini CLIs from user toolchain paths. ([#346])
- Upgrade `better-sqlite3` for Node 24 Windows prebuilt support. ([#357])
- Lead Copilot spawn with `-p -` so prompt-via-stdin is consumed. ([#351])
- Drop literal `-` argv from Codex spawn so prompts deliver via stdin pipe alone. ([#342])
- Wrap `cmd.exe` shim invocations to survive `/s /c` quote stripping. ([#339])
#### Web UI & files
- Download as `.zip` now returns the actual project tree. ([#341])
- Keep Design Files view active after deleting a file. ([#329])
- Scroll workspace tabs in place instead of the window. ([#363])
- Treat inlined script content as literal in FileViewer. ([#343])
- Use response-order matching for bulk upload aggregation. ([#323])
- Serve `.jsx` / `.tsx` with JS-family MIME types so browser loaders accept them. ([#340])
- Fix macOS entry view drag region. ([#373])
#### Daemon & deployment
- Increase project upload limit from 20MB to 200MB. ([#319])
- Bundle and rewrite assets referenced from inline `<style>` blocks and `style=""` attributes. ([#314])
#### Internationalization
- Update locale coverage after main merge. ([#251])
- Add missing `designFiles.showMore` keys to `ar`, `hu`, `ko`, `pl`, and `tr`. ([#335])
### Documentation
- Japanese documentation update. ([#309])
- README contributors wall refresh. ([#360])
- Spelling fixes in CLI comments, spec, and video prompt docs. ([#300])
## [0.2.0] - 2026-05-02
A feature-heavy follow-up to 0.1.0 — dark mode, xAI Grok Imagine media generation, headless deploy mode, OpenClaude fallback, four new locales, and a much richer skill / design-system / prompt-template catalog. 45 merged PRs from 27 contributors.
### Added
#### Web / UI
- Dark mode with system / light / dark toggle. ([#259])
- Visible conversation timestamps. ([#120])
- React artifact output support. ([#121])
- Preview comment attachments. ([#284])
#### Agents & daemon
- Auto-detect OpenClaude as a fallback for Claude Code. ([#263])
- Standardize agent communication via stdin and remove Windows-specific shims. ([#258])
#### Media generation
- xAI Grok Imagine integration covering image, video, and native audio. ([#276])
#### Skills, design systems & prompt templates
- `kami` editorial paper design system with deck starter. ([#226])
- `html-ppt` skill (lewislulu/html-ppt-skill) with 15 per-template Examples cards. ([#193])
- `design-brief` skill with structured I-Lang input format. ([#184])
- Brand-agnostic craft references and Refero-derived lint rules. ([#225])
- 11 HyperFrames video prompt templates and media generation README section. ([#227])
- Three Kingdoms ARPG Seedance 2.0 video templates (3). ([#212])
- Three Kingdoms ARPG gameplay screenshot templates (3). ([#207])
- Otaku-dance choreography breakdown infographic template. ([#209])
- Anime fighting game screenshot template. ([#208])
#### Deployment & tooling
- `--prod` flag and `OD_HOST` for headless server deployment in `tools-dev`. ([#222])
- GitHub CI workflow. ([#271])
- Daemon `kindFor` / `mimeFor` file classifier tests. ([#269])
#### Internationalization
- Hungarian (`hu`) UI locale. ([#288])
- Polish (`pl`) UI locale. ([#273])
- Korean (`ko`) UI locale. ([#253])
- Turkish (`tr`) UI locale. ([#233])
### Changed
- Image / video projects now pick from prompt templates (not design systems). ([#192])
- Optimize Electron release artifact size. ([#249])
### Fixed
#### Daemon
- Restore `startServer` Promise contract — return `url` / `{ url, server }`. ([#268])
- Emit `tool_use` from `tool_execution_start` in pi-rpc. ([#186])
- Clamp Codex reasoning effort to model-supported values. ([#223])
- Deliver Claude Code prompt via stdin to avoid spawn `E2BIG` / `ENAMETOOLONG`. ([#143])
- Include `package.json` in tarball so packaged app reports correct version. ([#260])
- Treat `.py` files as previewable code in Design Files. ([#261])
- `OD_DAEMON_URL` uses port 0 instead of actual allocated port (now reports the real port). ([#240])
- Quote agent bin path when spawning with `shell:true` on Windows. ([#232])
- Make `max_tokens` configurable. ([#78])
#### Web UI
- Suppress hydration warning on `<body>`. ([#248])
- Fix language dropdown overflow in Settings modal. ([#281], [#287])
- Add scroll to Settings language menu when it overflows view. ([#247])
- Preserve deck preview pagination per file. ([#119])
- Fix deck preview pagination controls. ([#112])
#### Cross-platform
- Use junction instead of dir symlink on Windows in `tools-dev`. ([#231])
#### Internationalization
- Replace hardcoded `Claude` with `助手` in zh-TW assistant role copy. ([#262])
### Documentation
- Traditional Chinese (繁體中文) README. ([#194])
### Internal
- Auto-generated metrics SVG updates. ([#228], [#241])
- Fix metrics workflow protected branch updates. ([#219])
## [0.1.0] - 2026-05-01
First public release of Open Design — a local-first, open-source alternative to Anthropic's Claude Design. It detects your installed code-agent CLI, runs design skills against curated design systems, and streams artifacts into a sandboxed in-app preview.
### Added
#### Agent runtimes & providers
- Multi-agent runtime detection and dispatch: Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Qwen, GitHub Copilot CLI, Hermes, Kimi CLI, Pi, and Kiro. ([#28], [#71], [#117], [#185])
- Per-CLI model picker for local agents. ([#14])
- OpenAI-compatible provider support and Anthropic-compatible stream proxy for non-native providers. ([#80], [#180])
- App version awareness shared across daemon and web. ([#204])
#### Skills, design systems & prompt templates
- 72 brand-grade design systems and 31 composable skills, including Xiaohongshu and Replit Deck (8 themes). ([#24], [#74])
- 57 DESIGN.md specs imported from awesome-design-skills. ([#92])
- Dance storyboard and ancient-China MMO HUD prompt templates. ([#187])
#### Artifacts & preview
- Artifact platform foundation with sandboxed in-app preview. ([#68])
- First-class SVG and Markdown artifact renderers / viewer. ([#73], [#177])
- HTML preview support for relative-asset references. ([#156])
- Document preview support for uploaded files and multi-file design uploads. ([#31], [#63])
- Claude Design `.zip` import. ([#46])
- Image / video / audio media surfaces with unified `od media generate` dispatcher. ([#12])
#### Packaging & deployment
- Mac arm64 packaged runtime with signed/notarized DMG + update ZIP and beta release flow. ([#170])
- Windows x64 NSIS installer (unsigned beta) and release assets. ([#191])
- Vercel self-deploy flow with `vercel.json` configuration. ([#167], [#169])
#### Internationalization
- UI locales: zh-CN, zh-TW, en, ja, de, es-ES, ru, fa, pt-BR. ([#79], [#80], [#155], [#159], [#182], [#190], [#197])
- Improved language switcher UI. ([#107])
#### Developer experience & tools
- `tools-dev` / `tools-pack` workspace tooling for development and packaging, with native addon diagnostics and improved web startup flow. ([#127], [#128], [#153])
- `dev:all` auto-switches to a free port when defaults are busy. ([#9])
- UI end-to-end automation suite and reporting under `apps/e2e`. ([#64], [#102])
- Frontend toolchain migrated from Vite to Next.js 16 App Router. ([#66])
- Project code migrated to TypeScript with shared contracts. ([#118])
- Refreshed desktop integration control plane. ([#123])
- Star-us prompt to surface GitHub repo. ([#5])
### Fixed
#### Stability & reliability
- Chat runs survive web reconnects. ([#146])
- Daemon project-root resolution when launched from src via tsx. ([#162])
- SSE keepalive behind nginx. ([#111])
- Standalone pnpm binary supported in postinstall; install toolchain pinned. ([#35], [#151])
- Surface unfinished todo runs in chat. ([#76])
#### Cross-platform / Windows
- Spawn agents via resolved absolute path on Windows. ([#13])
- Deliver prompts via stdin for non-Claude agents to avoid `spawn ENAMETOOLONG`. ([#15])
- Mitigate Windows `ENAMETOOLONG` and fix daemon crash on cleanup. ([#75])
- Fix `PROMPT_TEMP_FILE()` call and Claude Code stdin delivery on Windows. ([#97])
- Normalize web dev tsconfig paths on Windows for `tools-dev`. ([#174])
- Support Claude Code CLI <1.0.86 (avoid `--include-partial-messages`, parse assistant wrapper text). ([#34])
#### Daemon & providers
- CORS header on raw project file endpoint. ([#140])
- Preserve non-ASCII filenames on multipart upload. ([#166])
- Stop passing literal dash to `cursor-agent`. ([#160])
- Non-interactive permissions for agent CLIs in web UI. ([#26])
- Codex plugin disable env. ([#133])
- Codex assistant agent labels. ([#70])
#### Web UI
- Welcome dialog: stop overwriting user's agent pick on Save. ([#4])
- Allow Claude Code to read skill seeds and design-system specs. ([#7])
- Question form checkbox selection limits enforced. ([#81])
- SettingsDialog content overflow + scrolling, refactored layout and modal styling. ([#83], [#88])
- Duplicate `H.` heading in `discovery.ts` (→ `I.`). ([#87])
- guizang-ppt: sync host slide counter on transform-paginated decks. ([#19])
- Toolbar button text wrapping prevented for CJK languages. ([#178])
- PreviewModal exits fullscreen on first Esc. ([#168])
- Dev indicator moved to bottom-right corner. ([#108])
- Design Files: align upload picker with dropzone, neutral agent copy, remove unsupported Figma copy. ([#199], [#200], [#201])
- Web locale registry test includes Japanese. ([#202])
### Documentation
- README refresh with stats, agents, skills, and metrics workflow. ([#173])
- Korean (한국어) and Japanese README and docs translations. ([#105], [#183])
- `TRANSLATIONS.md` i18n contribution guide. ([#196])
- Refresh environment setup guidance. ([#104])
- Xiaohongshu design-system docs review feedback. ([#54])
### Internal
- Initial project structure, project rename "Open Claude Design" → "Open Design", naming optimization. ([#1], [#2])
- Initial AGENTS.md and OpenCode agent instructions. ([#114])
- Beta release workflow placeholder. ([#36])
- Git commit co-author policy. ([#131])
[Unreleased]: https://github.com/nexu-io/open-design/compare/open-design-v0.4.1...HEAD
[0.4.1]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.4.1
[0.4.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.4.0
[0.3.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.3.0
[0.2.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.2.0
[0.1.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.1.0
[#1]: https://github.com/nexu-io/open-design/pull/1
[#2]: https://github.com/nexu-io/open-design/pull/2
[#4]: https://github.com/nexu-io/open-design/pull/4
[#5]: https://github.com/nexu-io/open-design/pull/5
[#7]: https://github.com/nexu-io/open-design/pull/7
[#9]: https://github.com/nexu-io/open-design/pull/9
[#12]: https://github.com/nexu-io/open-design/pull/12
[#13]: https://github.com/nexu-io/open-design/pull/13
[#14]: https://github.com/nexu-io/open-design/pull/14
[#15]: https://github.com/nexu-io/open-design/pull/15
[#19]: https://github.com/nexu-io/open-design/pull/19
[#24]: https://github.com/nexu-io/open-design/pull/24
[#26]: https://github.com/nexu-io/open-design/pull/26
[#28]: https://github.com/nexu-io/open-design/pull/28
[#31]: https://github.com/nexu-io/open-design/pull/31
[#34]: https://github.com/nexu-io/open-design/pull/34
[#35]: https://github.com/nexu-io/open-design/pull/35
[#36]: https://github.com/nexu-io/open-design/pull/36
[#46]: https://github.com/nexu-io/open-design/pull/46
[#54]: https://github.com/nexu-io/open-design/pull/54
[#63]: https://github.com/nexu-io/open-design/pull/63
[#64]: https://github.com/nexu-io/open-design/pull/64
[#66]: https://github.com/nexu-io/open-design/pull/66
[#68]: https://github.com/nexu-io/open-design/pull/68
[#70]: https://github.com/nexu-io/open-design/pull/70
[#71]: https://github.com/nexu-io/open-design/pull/71
[#73]: https://github.com/nexu-io/open-design/pull/73
[#74]: https://github.com/nexu-io/open-design/pull/74
[#75]: https://github.com/nexu-io/open-design/pull/75
[#76]: https://github.com/nexu-io/open-design/pull/76
[#79]: https://github.com/nexu-io/open-design/pull/79
[#80]: https://github.com/nexu-io/open-design/pull/80
[#81]: https://github.com/nexu-io/open-design/pull/81
[#83]: https://github.com/nexu-io/open-design/pull/83
[#87]: https://github.com/nexu-io/open-design/pull/87
[#88]: https://github.com/nexu-io/open-design/pull/88
[#92]: https://github.com/nexu-io/open-design/pull/92
[#97]: https://github.com/nexu-io/open-design/pull/97
[#102]: https://github.com/nexu-io/open-design/pull/102
[#104]: https://github.com/nexu-io/open-design/pull/104
[#105]: https://github.com/nexu-io/open-design/pull/105
[#107]: https://github.com/nexu-io/open-design/pull/107
[#108]: https://github.com/nexu-io/open-design/pull/108
[#111]: https://github.com/nexu-io/open-design/pull/111
[#114]: https://github.com/nexu-io/open-design/pull/114
[#117]: https://github.com/nexu-io/open-design/pull/117
[#118]: https://github.com/nexu-io/open-design/pull/118
[#123]: https://github.com/nexu-io/open-design/pull/123
[#127]: https://github.com/nexu-io/open-design/pull/127
[#128]: https://github.com/nexu-io/open-design/pull/128
[#131]: https://github.com/nexu-io/open-design/pull/131
[#133]: https://github.com/nexu-io/open-design/pull/133
[#140]: https://github.com/nexu-io/open-design/pull/140
[#146]: https://github.com/nexu-io/open-design/pull/146
[#151]: https://github.com/nexu-io/open-design/pull/151
[#153]: https://github.com/nexu-io/open-design/pull/153
[#155]: https://github.com/nexu-io/open-design/pull/155
[#156]: https://github.com/nexu-io/open-design/pull/156
[#159]: https://github.com/nexu-io/open-design/pull/159
[#160]: https://github.com/nexu-io/open-design/pull/160
[#162]: https://github.com/nexu-io/open-design/pull/162
[#166]: https://github.com/nexu-io/open-design/pull/166
[#167]: https://github.com/nexu-io/open-design/pull/167
[#168]: https://github.com/nexu-io/open-design/pull/168
[#169]: https://github.com/nexu-io/open-design/pull/169
[#170]: https://github.com/nexu-io/open-design/pull/170
[#173]: https://github.com/nexu-io/open-design/pull/173
[#174]: https://github.com/nexu-io/open-design/pull/174
[#177]: https://github.com/nexu-io/open-design/pull/177
[#178]: https://github.com/nexu-io/open-design/pull/178
[#180]: https://github.com/nexu-io/open-design/pull/180
[#182]: https://github.com/nexu-io/open-design/pull/182
[#183]: https://github.com/nexu-io/open-design/pull/183
[#185]: https://github.com/nexu-io/open-design/pull/185
[#187]: https://github.com/nexu-io/open-design/pull/187
[#190]: https://github.com/nexu-io/open-design/pull/190
[#191]: https://github.com/nexu-io/open-design/pull/191
[#196]: https://github.com/nexu-io/open-design/pull/196
[#197]: https://github.com/nexu-io/open-design/pull/197
[#199]: https://github.com/nexu-io/open-design/pull/199
[#200]: https://github.com/nexu-io/open-design/pull/200
[#201]: https://github.com/nexu-io/open-design/pull/201
[#202]: https://github.com/nexu-io/open-design/pull/202
[#204]: https://github.com/nexu-io/open-design/pull/204
[#78]: https://github.com/nexu-io/open-design/pull/78
[#112]: https://github.com/nexu-io/open-design/pull/112
[#119]: https://github.com/nexu-io/open-design/pull/119
[#120]: https://github.com/nexu-io/open-design/pull/120
[#121]: https://github.com/nexu-io/open-design/pull/121
[#143]: https://github.com/nexu-io/open-design/pull/143
[#184]: https://github.com/nexu-io/open-design/pull/184
[#186]: https://github.com/nexu-io/open-design/pull/186
[#192]: https://github.com/nexu-io/open-design/pull/192
[#193]: https://github.com/nexu-io/open-design/pull/193
[#194]: https://github.com/nexu-io/open-design/pull/194
[#207]: https://github.com/nexu-io/open-design/pull/207
[#208]: https://github.com/nexu-io/open-design/pull/208
[#209]: https://github.com/nexu-io/open-design/pull/209
[#212]: https://github.com/nexu-io/open-design/pull/212
[#219]: https://github.com/nexu-io/open-design/pull/219
[#222]: https://github.com/nexu-io/open-design/pull/222
[#223]: https://github.com/nexu-io/open-design/pull/223
[#225]: https://github.com/nexu-io/open-design/pull/225
[#226]: https://github.com/nexu-io/open-design/pull/226
[#227]: https://github.com/nexu-io/open-design/pull/227
[#228]: https://github.com/nexu-io/open-design/pull/228
[#231]: https://github.com/nexu-io/open-design/pull/231
[#232]: https://github.com/nexu-io/open-design/pull/232
[#233]: https://github.com/nexu-io/open-design/pull/233
[#240]: https://github.com/nexu-io/open-design/pull/240
[#241]: https://github.com/nexu-io/open-design/pull/241
[#247]: https://github.com/nexu-io/open-design/pull/247
[#248]: https://github.com/nexu-io/open-design/pull/248
[#249]: https://github.com/nexu-io/open-design/pull/249
[#253]: https://github.com/nexu-io/open-design/pull/253
[#258]: https://github.com/nexu-io/open-design/pull/258
[#259]: https://github.com/nexu-io/open-design/pull/259
[#260]: https://github.com/nexu-io/open-design/pull/260
[#261]: https://github.com/nexu-io/open-design/pull/261
[#262]: https://github.com/nexu-io/open-design/pull/262
[#263]: https://github.com/nexu-io/open-design/pull/263
[#268]: https://github.com/nexu-io/open-design/pull/268
[#269]: https://github.com/nexu-io/open-design/pull/269
[#271]: https://github.com/nexu-io/open-design/pull/271
[#273]: https://github.com/nexu-io/open-design/pull/273
[#276]: https://github.com/nexu-io/open-design/pull/276
[#281]: https://github.com/nexu-io/open-design/pull/281
[#284]: https://github.com/nexu-io/open-design/pull/284
[#287]: https://github.com/nexu-io/open-design/pull/287
[#288]: https://github.com/nexu-io/open-design/pull/288
[#250]: https://github.com/nexu-io/open-design/pull/250
[#251]: https://github.com/nexu-io/open-design/pull/251
[#255]: https://github.com/nexu-io/open-design/pull/255
[#301]: https://github.com/nexu-io/open-design/pull/301
[#307]: https://github.com/nexu-io/open-design/pull/307
[#308]: https://github.com/nexu-io/open-design/pull/308
[#314]: https://github.com/nexu-io/open-design/pull/314
[#316]: https://github.com/nexu-io/open-design/pull/316
[#319]: https://github.com/nexu-io/open-design/pull/319
[#320]: https://github.com/nexu-io/open-design/pull/320
[#323]: https://github.com/nexu-io/open-design/pull/323
[#328]: https://github.com/nexu-io/open-design/pull/328
[#329]: https://github.com/nexu-io/open-design/pull/329
[#330]: https://github.com/nexu-io/open-design/pull/330
[#335]: https://github.com/nexu-io/open-design/pull/335
[#339]: https://github.com/nexu-io/open-design/pull/339
[#340]: https://github.com/nexu-io/open-design/pull/340
[#341]: https://github.com/nexu-io/open-design/pull/341
[#342]: https://github.com/nexu-io/open-design/pull/342
[#343]: https://github.com/nexu-io/open-design/pull/343
[#345]: https://github.com/nexu-io/open-design/pull/345
[#346]: https://github.com/nexu-io/open-design/pull/346
[#351]: https://github.com/nexu-io/open-design/pull/351
[#354]: https://github.com/nexu-io/open-design/pull/354
[#357]: https://github.com/nexu-io/open-design/pull/357
[#358]: https://github.com/nexu-io/open-design/pull/358
[#359]: https://github.com/nexu-io/open-design/pull/359
[#360]: https://github.com/nexu-io/open-design/pull/360
[#363]: https://github.com/nexu-io/open-design/pull/363
[#364]: https://github.com/nexu-io/open-design/pull/364
[#373]: https://github.com/nexu-io/open-design/pull/373
[#376]: https://github.com/nexu-io/open-design/pull/376
[#282]: https://github.com/nexu-io/open-design/pull/282
[#289]: https://github.com/nexu-io/open-design/pull/289
[#296]: https://github.com/nexu-io/open-design/pull/296
[#300]: https://github.com/nexu-io/open-design/pull/300
[#309]: https://github.com/nexu-io/open-design/pull/309
[#270]: https://github.com/nexu-io/open-design/pull/270
[#326]: https://github.com/nexu-io/open-design/pull/326
[#352]: https://github.com/nexu-io/open-design/pull/352
[#365]: https://github.com/nexu-io/open-design/pull/365
[#366]: https://github.com/nexu-io/open-design/pull/366
[#369]: https://github.com/nexu-io/open-design/pull/369
[#381]: https://github.com/nexu-io/open-design/pull/381
[#382]: https://github.com/nexu-io/open-design/pull/382
[#384]: https://github.com/nexu-io/open-design/pull/384
[#385]: https://github.com/nexu-io/open-design/pull/385
[#387]: https://github.com/nexu-io/open-design/pull/387
[#391]: https://github.com/nexu-io/open-design/pull/391
[#392]: https://github.com/nexu-io/open-design/pull/392
[#393]: https://github.com/nexu-io/open-design/pull/393
[#395]: https://github.com/nexu-io/open-design/pull/395
[#396]: https://github.com/nexu-io/open-design/pull/396
[#397]: https://github.com/nexu-io/open-design/pull/397
[#399]: https://github.com/nexu-io/open-design/pull/399
[#400]: https://github.com/nexu-io/open-design/pull/400
[#401]: https://github.com/nexu-io/open-design/pull/401
[#403]: https://github.com/nexu-io/open-design/pull/403
[#404]: https://github.com/nexu-io/open-design/pull/404
[#405]: https://github.com/nexu-io/open-design/pull/405
[#406]: https://github.com/nexu-io/open-design/pull/406
[#407]: https://github.com/nexu-io/open-design/pull/407
[#409]: https://github.com/nexu-io/open-design/pull/409
[#410]: https://github.com/nexu-io/open-design/pull/410
[#411]: https://github.com/nexu-io/open-design/pull/411
[#412]: https://github.com/nexu-io/open-design/pull/412
[#417]: https://github.com/nexu-io/open-design/pull/417
[#418]: https://github.com/nexu-io/open-design/pull/418
[#421]: https://github.com/nexu-io/open-design/pull/421
[#424]: https://github.com/nexu-io/open-design/pull/424
[#428]: https://github.com/nexu-io/open-design/pull/428
[#429]: https://github.com/nexu-io/open-design/pull/429
[#434]: https://github.com/nexu-io/open-design/pull/434
[#435]: https://github.com/nexu-io/open-design/pull/435
[#439]: https://github.com/nexu-io/open-design/pull/439
[#440]: https://github.com/nexu-io/open-design/pull/440
[#447]: https://github.com/nexu-io/open-design/pull/447
[#448]: https://github.com/nexu-io/open-design/pull/448
[#453]: https://github.com/nexu-io/open-design/pull/453
[#455]: https://github.com/nexu-io/open-design/pull/455
[#457]: https://github.com/nexu-io/open-design/pull/457
[#458]: https://github.com/nexu-io/open-design/pull/458
[#460]: https://github.com/nexu-io/open-design/pull/460
[#465]: https://github.com/nexu-io/open-design/pull/465
[#466]: https://github.com/nexu-io/open-design/pull/466
[#468]: https://github.com/nexu-io/open-design/pull/468
[#471]: https://github.com/nexu-io/open-design/pull/471
[#476]: https://github.com/nexu-io/open-design/pull/476
[#477]: https://github.com/nexu-io/open-design/pull/477
[#480]: https://github.com/nexu-io/open-design/pull/480
[#481]: https://github.com/nexu-io/open-design/pull/481
[#488]: https://github.com/nexu-io/open-design/pull/488
[#489]: https://github.com/nexu-io/open-design/pull/489
[#490]: https://github.com/nexu-io/open-design/pull/490
[#492]: https://github.com/nexu-io/open-design/pull/492
[#494]: https://github.com/nexu-io/open-design/pull/494
[#496]: https://github.com/nexu-io/open-design/pull/496
[#502]: https://github.com/nexu-io/open-design/pull/502
[#504]: https://github.com/nexu-io/open-design/pull/504
[#513]: https://github.com/nexu-io/open-design/pull/513
[#514]: https://github.com/nexu-io/open-design/pull/514
[#515]: https://github.com/nexu-io/open-design/pull/515
[#522]: https://github.com/nexu-io/open-design/pull/522
[#523]: https://github.com/nexu-io/open-design/pull/523
[#537]: https://github.com/nexu-io/open-design/pull/537
[#535]: https://github.com/nexu-io/open-design/pull/535
[#548]: https://github.com/nexu-io/open-design/pull/548
[#549]: https://github.com/nexu-io/open-design/pull/549
[#556]: https://github.com/nexu-io/open-design/pull/556
[#563]: https://github.com/nexu-io/open-design/pull/563
[#570]: https://github.com/nexu-io/open-design/pull/570
[#577]: https://github.com/nexu-io/open-design/pull/577
[#578]: https://github.com/nexu-io/open-design/pull/578
[#586]: https://github.com/nexu-io/open-design/pull/586
[#587]: https://github.com/nexu-io/open-design/pull/587
[#592]: https://github.com/nexu-io/open-design/pull/592
[#595]: https://github.com/nexu-io/open-design/pull/595
[#604]: https://github.com/nexu-io/open-design/pull/604
[#605]: https://github.com/nexu-io/open-design/pull/605
[#608]: https://github.com/nexu-io/open-design/pull/608
[#612]: https://github.com/nexu-io/open-design/pull/612
[#618]: https://github.com/nexu-io/open-design/pull/618
[#619]: https://github.com/nexu-io/open-design/pull/619
[#620]: https://github.com/nexu-io/open-design/pull/620
[#623]: https://github.com/nexu-io/open-design/pull/623
[#627]: https://github.com/nexu-io/open-design/pull/627
[#637]: https://github.com/nexu-io/open-design/pull/637

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

271
CONTRIBUTING.de.md Normal file
View File

@@ -0,0 +1,271 @@
# Zu Open Design beitragen
Danke, dass Sie über einen Beitrag nachdenken. OD ist bewusst klein gehalten — der größte Teil des Werts steckt in **Dateien** (Skills, Designsysteme, Prompt-Fragmente) statt in Framework-Code. Die wirkungsvollsten Beiträge sind deshalb oft ein Ordner, eine Markdown-Datei oder ein PR-großer Adapter.
Dieser Leitfaden zeigt, wo Sie für welche Art Beitrag suchen sollten und welche Messlatte ein PR vor dem Merge erfüllen muss.
<p align="center"><a href="CONTRIBUTING.md">English</a> · <a href="CONTRIBUTING.pt-BR.md">Português (Brasil)</a> · <b>Deutsch</b> · <a href="CONTRIBUTING.fr.md">Français</a> · <a href="CONTRIBUTING.zh-CN.md">简体中文</a> · <a href="CONTRIBUTING.ja-JP.md">日本語</a></p>
---
## Drei Dinge, die Sie an einem Nachmittag liefern können
| Wenn Sie möchten… | Fügen Sie eigentlich hinzu | Ort | Umfang |
|---|---|---|---|
| OD eine neue Artifact-Art rendern lassen (Rechnung, iOS Settings Screen, One-Pager…) | einen **Skill** | [`skills/<your-skill>/`](skills/) | ein Ordner, ca. 2 Dateien |
| OD die visuelle Sprache einer neuen Marke sprechen lassen | ein **Design System** | [`design-systems/<brand>/DESIGN.md`](design-systems/) | eine Markdown-Datei |
| Eine neue coding-agent CLI anbinden | einen **Agent adapter** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | ca. 10 Zeilen in einem Array |
| Feature ergänzen, Bug fixen, UX-Pattern aus [`open-codesign`][ocod] übernehmen | Code | `apps/web/src/`, `apps/daemon/` | normaler PR |
| Dokumentation verbessern, Französisch / Deutsch / 中文 ergänzen, Tippfehler fixen | Dokumentation | `README.md`, `README.fr.md`, `README.de.md`, `README.zh-CN.md`, `docs/`, `QUICKSTART.md` | ein PR |
Wenn Sie nicht sicher sind, in welchen Bereich Ihre Idee fällt, [öffnen Sie zuerst eine Discussion / Issue](https://github.com/nexu-io/open-design/issues/new). Wir zeigen Ihnen dann die passende Oberfläche.
---
## Lokales Setup
Das vollständige One-Page-Setup steht in [`QUICKSTART.de.md`](QUICKSTART.de.md). TL;DR für Mitwirkende:
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable # wählt das gepinnte pnpm aus packageManager
pnpm install
pnpm tools-dev run web # daemon + web foreground loop
pnpm typecheck # tsc -b --noEmit
pnpm --filter @open-design/web build # Web-Paket bei Bedarf bauen
```
Node `~24` und pnpm `10.33.x` sind erforderlich. `nvm` / `fnm` sind optional; nutzen Sie `nvm install 24 && nvm use 24` oder `fnm install 24 && fnm use 24`, wenn Sie Node so verwalten. macOS, Linux und WSL2 sind die primären Pfade. Windows nativ sollte funktionieren, ist aber kein primäres Ziel.
Sie brauchen keine Agent-CLI im `PATH`, um OD selbst zu entwickeln. Der daemon meldet dann "no agents found" und fällt auf den **Anthropic API · BYOK** Pfad zurück, der oft die schnellste Dev-Schleife ist.
---
## Einen neuen Skill hinzufügen
Ein Skill ist ein Ordner unter [`skills/`](skills/) mit `SKILL.md` im Root. Er folgt der Claude Code [`SKILL.md` Konvention][skill] plus optionaler `od:` Erweiterung. **Keine Registrierung nötig.** Ordner ablegen, daemon neu starten, der Picker zeigt ihn an.
### Skill-Ordnerlayout
```text
skills/your-skill/
├── SKILL.md # erforderlich
├── assets/template.html # optional, aber empfohlen — Seed-Datei
├── references/ # optional — Wissensdateien für den Agent
│ ├── layouts.md
│ ├── components.md
│ └── checklist.md
└── example.html # stark empfohlen — echtes, handgebautes Beispiel
```
### `SKILL.md` Frontmatter
Die ersten drei Keys sind die Claude Code Basis-Spec: `name`, `description`, `triggers`. Alles unter `od:` ist OD-spezifisch und optional, aber **`od.mode`** bestimmt, in welcher Gruppe der Skill erscheint.
```yaml
---
name: your-skill
description: |
One-paragraph elevator pitch. The agent reads this verbatim to decide
if the user's brief matches. Be concrete: surface, audience, what's in
the artifact, what's not.
triggers:
- "your trigger phrase"
- "another phrase"
- "中文触发词"
od:
mode: prototype # prototype | deck | template | design-system
platform: desktop # desktop | mobile
scenario: marketing # free-form tag for grouping
featured: 1 # any positive integer surfaces it under "Showcase examples"
preview:
type: html # html | jsx | pptx | markdown
entry: index.html
design_system:
requires: true
sections: [color, typography, layout, components]
example_prompt: "A copy-pastable prompt that nicely shows what this skill does."
---
# Your Skill
Body is free-form Markdown describing the workflow the agent should follow…
```
Die vollständige Grammatik — typed inputs, Slider-Parameter, capability gating — steht in [`docs/skills-protocol.md`](docs/skills-protocol.md).
### Merge-Messlatte für einen neuen Skill
1. **Ein echtes `example.html`.** Handgebaut, direkt von Disk öffnend, mit Designer-Qualität. Kein Lorem ipsum, kein `<svg><rect/></svg>` Placeholder-Hero.
2. **Anti-AI-slop Checklist bestehen.** Keine violetten Gradients, keine generischen Emoji-Icons, keine runde Karte mit linkem Border-Akzent, kein Inter als Display-Font, keine erfundenen Zahlen.
3. **Ehrliche Platzhalter.** Wenn der Agent keine echte Zahl hat, schreiben Sie `—` oder einen beschrifteten grauen Block, nicht "10× faster".
4. **`references/checklist.md` mit mindestens P0 Gates.** Format an [`skills/guizang-ppt/references/checklist.md`](skills/guizang-ppt/) oder [`skills/dating-web/references/checklist.md`](skills/dating-web/) anlehnen.
5. **Screenshot unter `docs/screenshots/skills/<skill>.png`**, wenn der Skill featured ist. PNG, ca. 1024×640 retina, aus dem echten `example.html`.
6. **Ein einzelner, in sich geschlossener Ordner.** Keine CDN-Imports außer bereits verwendeten, keine unlizenzierte Fonts, keine Bilder über ca. 250 KB.
Wenn Sie einen vorhandenen Skill forken, behalten Sie LICENSE und Autorenschaft in `references/` und erwähnen Sie es in der PR-Beschreibung.
### Vorhandene Skills zum Nachahmen
- Visuelle Single-Screen-Prototypen: [`skills/dating-web/`](skills/dating-web/), [`skills/digital-eguide/`](skills/digital-eguide/)
- Multi-Frame Mobile-Flows: [`skills/mobile-onboarding/`](skills/mobile-onboarding/), [`skills/gamified-app/`](skills/gamified-app/)
- Dokument / Template: [`skills/pm-spec/`](skills/pm-spec/), [`skills/weekly-update/`](skills/weekly-update/)
- Deck-Modus: [`skills/guizang-ppt/`](skills/guizang-ppt/) und [`skills/simple-deck/`](skills/simple-deck/)
---
## Ein neues Design System hinzufügen
Ein Designsystem ist eine einzelne [`DESIGN.md`](design-systems/README.md) Datei unter `design-systems/<slug>/`. **Eine Datei, kein Code.** Ablegen, daemon neu starten, der Picker gruppiert es nach Kategorie.
### Designsystem-Ordnerlayout
```text
design-systems/your-brand/
└── DESIGN.md
```
### `DESIGN.md` Form
```markdown
# Design System Inspired by YourBrand
> Category: Developer Tools
> One-line summary that shows in the picker preview.
## 1. Visual Theme & Atmosphere
## 2. Color
- Primary: `#hex` / `oklch(...)`
-
## 3. Typography
## 4. Spacing & Grid
## 5. Layout & Composition
## 6. Components
## 7. Motion & Interaction
## 8. Voice & Brand
## 9. Anti-patterns
```
Das 9-Section-Schema ist fest — Skill-Bodies greifen darauf per Suche zu. Das erste H1 wird zum Picker-Label (der Prefix `Design System Inspired by` wird entfernt), und `> Category: …` entscheidet die Gruppe. Bestehende Kategorien stehen in [`design-systems/README.md`](design-systems/README.md); nutzen Sie nach Möglichkeit eine vorhandene.
### Merge-Messlatte für ein neues Designsystem
1. **Alle 9 Sections vorhanden.** Leere Bodies sind bei schwer auffindbaren Daten akzeptabel, aber die Headings müssen da sein.
2. **Hex-Codes sind echt.** Direkt von Website oder Produkt sampeln, nicht aus Erinnerung oder AI raten.
3. **OKLch-Werte für Akzentfarben** sind nice-to-have und machen Paletten stabiler.
4. **Kein Marketing-Fluff.** Die Tagline einer Marke ist kein Design Token.
5. **Slug nutzt ASCII**`linear.app` wird `linear-app`, `x.ai` wird `x-ai`.
Die gelieferten Produktsysteme werden aus [`VoltAgent/awesome-design-md`][acd2] über [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) importiert. Wenn Ihre Marke upstream passt, schicken Sie den PR zuerst dorthin; OD übernimmt ihn beim nächsten Sync.
---
## Eine neue coding-agent CLI hinzufügen
Eine neue Agent-CLI ist ein Eintrag in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts):
```javascript
{
id: 'foo',
name: 'Foo Coder',
bin: 'foo',
versionArgs: ['--version'],
buildArgs: (prompt) => ['exec', '-p', prompt],
streamFormat: 'plain', // or 'claude-stream-json' if it speaks that
}
```
Der daemon erkennt sie im `PATH`, der Picker zeigt sie an und der Chat-Pfad funktioniert. Wenn die CLI **typed events** ausgibt, ergänzen Sie einen Parser in [`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts) und setzen `streamFormat`.
Merge-Bar:
1. **Eine echte Session läuft end-to-end** mit dem neuen Agent; fügen Sie den daemon log in die PR-Beschreibung ein.
2. **`docs/agent-adapters.md`** dokumentiert die Eigenheiten der CLI.
3. **Die README-Tabelle "Unterstützte Code-Agenten"** erhält eine Zeile.
---
## Wartung von Lokalisierungen
Deutsch verwendet das formelle `Sie`, weil OD eine gemischte Zielgruppe aus Solo-Creators, Agenturen und Engineering-Teams anspricht; solange Projektfeedback keine informelle `du`-Stimme nahelegt, ist formelles Deutsch die am wenigsten überraschende Vorgabe. Locale-PRs sollen UI-Chrome, zentrale Dokumentation und display-only Gallery-Metadaten in `apps/web/src/i18n/content.ts` übersetzen, aber nicht `skills/`, `design-systems/` oder Prompt-Bodies, die Agents ausführen. Diese Quell-Prompts sind Workflow-Eingaben; eine gemeinsame Quellsprache vermeidet multiplizierte Prompt-QA über alle Locales. Wenn ein Skill, Designsystem oder Prompt Template ergänzt oder umbenannt wird, aktualisieren Sie die deutschen Display-Metadaten und führen `pnpm --filter @open-design/web test` aus; `content.test.ts` schlägt fehl, wenn die deutsche Display-Coverage driftet. Daemon-Fehler, Export-Dateinamen und agent-generierte Artifact-Texte sind bekannte Grenzen, sofern ein PR sie nicht ausdrücklich umfasst.
---
## Code Style
Wir sind beim Formatting nicht pedantisch (Prettier on save ist okay), aber zwei Regeln sind nicht verhandelbar:
1. **Single quotes in JS/TS.** Strings sind single-quoted, außer Escaping macht sie hässlich.
2. **Kommentare auf Englisch.** Auch wenn ein PR etwas ins Deutsche oder Chinesische übersetzt, bleiben Code-Kommentare englisch, damit es eine greppable Referenzsprache gibt.
Außerdem:
- **Nicht erzählen.** Kein `// import the module`, kein `// loop through items`.
- **TypeScript** für `apps/web/src/`. Der daemon (`apps/daemon/`) ist plain ESM JavaScript mit JSDoc, wenn Typen wichtig sind.
- **Keine neuen Top-Level Dependencies** ohne Absatz in der PR-Beschreibung, was sie bringen und wie viele Bytes sie kosten.
- **Vor dem Push `pnpm typecheck` ausführen.** CI tut es auch.
---
## Commits & Pull Requests
- **Ein Anliegen pro PR.**
- **Titel ist imperativ + Scope.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`.
- **Body erklärt das Warum.** Der Diff zeigt oft das Was, aber selten den Grund.
- **Issue referenzieren**, falls vorhanden. Bei nicht-trivialen PRs ohne Issue bitte zuerst eines öffnen.
- **Während Review nicht squashen.** Fixups pushen; wir squashen beim Merge.
- **Kein Force-Push auf Shared Branches**, außer Reviewer fragen danach.
Wir erzwingen kein CLA. Apache-2.0 deckt Beiträge ab; Ihr Beitrag ist unter derselben Lizenz.
---
## Bugs melden
Öffnen Sie ein Issue mit:
- Exaktem `pnpm tools-dev ...` Aufruf.
- Ausgewählter Agent-CLI oder BYOK-Pfad.
- Skill + Designsystem, die den Fehler ausgelöst haben.
- Relevanter **daemon stderr tail**.
- Screenshot, wenn es UI betrifft.
Für Prompt-Stack-Bugs fügen Sie die **vollständige Assistant Message** bei, damit klar ist, ob Modell oder Prompt verletzt wurde.
---
## Fragen stellen
- Architekturfrage, Designfrage, "Bug oder Fehlbenutzung?" → [GitHub Discussions](https://github.com/nexu-io/open-design/discussions) (bevorzugt, weil suchbar).
- "Wie schreibe ich einen Skill für X?" → Discussion öffnen. Wir beantworten sie und übernehmen fehlende Muster in [`docs/skills-protocol.md`](docs/skills-protocol.md).
---
## Was wir nicht annehmen
Um das Projekt fokussiert zu halten, öffnen Sie bitte keine PRs, die:
- **Eine Model Runtime vendoren.** OD setzt darauf, dass Ihre vorhandene CLI reicht.
- **Das Frontend ohne vorherige Abstimmung aus dem aktuellen Stack reißen.** Next.js 16 App Router + React 18 + TS ist gesetzt.
- **Den daemon durch eine Serverless Function ersetzen.** Der daemon besitzt ein echtes `cwd` und startet echte CLIs.
- **Telemetry / Analytics / Phone-home hinzufügen.** OD ist local-first.
- **Ein Binary bündeln** ohne Lizenzdatei und Autorenschaft direkt daneben.
Wenn Sie nicht sicher sind, ob eine Idee passt, öffnen Sie vor dem Code eine Discussion.
---
## Lizenz
Mit Ihrem Beitrag erklären Sie sich einverstanden, dass er unter der [Apache-2.0-Lizenz](LICENSE) dieses Repositories steht. Ausnahme sind Dateien in [`skills/guizang-ppt/`](skills/guizang-ppt/), die ihre ursprüngliche MIT-Lizenz und Autorenschaft von [op7418](https://github.com/op7418) behalten.
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
[guizang]: https://github.com/op7418/guizang-ppt-skill
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ocod]: https://github.com/OpenCoworkAI/open-codesign

435
CONTRIBUTING.fr.md Normal file
View File

@@ -0,0 +1,435 @@
# Contribuer à Open Design
Merci d'envisager de contribuer. OD reste volontairement petit : l'essentiel
de la valeur vit dans des **fichiers** (Skills, Design Systems, morceaux de
prompt) plutôt que dans du code de framework. Les contributions les plus utiles
sont donc souvent un dossier, un fichier Markdown ou un petit adapter qui tient
dans une PR.
Ce guide indique où intervenir pour chaque type de contribution et quel niveau
une PR doit atteindre avant dêtre mergée.
<p align="center"><a href="CONTRIBUTING.md">English</a> · <a href="CONTRIBUTING.pt-BR.md">Português (Brasil)</a> · <a href="CONTRIBUTING.de.md">Deutsch</a> · <b>Français</b> · <a href="CONTRIBUTING.zh-CN.md">简体中文</a> · <a href="CONTRIBUTING.ja-JP.md">日本語</a></p>
---
## Trois contributions faisables en un après-midi
| Si vous voulez… | Vous ajoutez en réalité | Où cela vit | Taille |
|---|---|---|---|
| Faire générer à OD un nouveau type d'artifact (facture, écran iOS Settings, one-pager…) | un **Skill** | [`skills/<your-skill>/`](skills/) | un dossier, ~2 fichiers |
| Faire parler à OD le langage visuel d'une nouvelle marque | un **Design System** | [`design-systems/<brand>/DESIGN.md`](design-systems/) | un fichier Markdown |
| Brancher une nouvelle CLI de coding agent | un **Agent adapter** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | ~10 lignes dans un tableau |
| Ajouter une feature, corriger un bug, reprendre un pattern UX de [`open-codesign`][ocod] | du code | `apps/web/src/`, `apps/daemon/` | PR classique |
| Améliorer la doc, porter une section en Français / Deutsch / 中文, corriger une faute | documentation | `README.md`, `README.fr.md`, `README.de.md`, `README.zh-CN.md`, `docs/`, `QUICKSTART.md` | une PR |
Si vous ne savez pas dans quelle catégorie tombe votre idée, [ouvrez d'abord
une discussion ou une issue](https://github.com/nexu-io/open-design/issues/new)
et nous vous orienterons vers la bonne surface.
---
## Configuration locale
Le setup complet en une page se trouve dans [`QUICKSTART.fr.md`](QUICKSTART.fr.md).
TL;DR pour contribuer :
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable # sélectionne la version de pnpm définie par packageManager
pnpm install
pnpm tools-dev run web # boucle daemon + web au premier plan
pnpm typecheck # tsc -b --noEmit
pnpm --filter @open-design/web build # build du paquet web si nécessaire
```
Node `~24` et pnpm `10.33.x` sont requis. `nvm` / `fnm` sont optionnels ;
utilisez `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` si
vous gérez Node comme cela. macOS, Linux et WSL2 sont les environnements
principaux pris en charge.
Windows natif devrait fonctionner, mais ce n'est pas la cible principale :
ouvrez une issue si ce n'est pas le cas.
Vous n'avez pas besoin d'une CLI d'agent dans votre `PATH` pour développer OD.
Le daemon indiquera "no agents found" ; utilisez alors le mode API/BYOK
(Anthropic, OpenAI, Azure OpenAI ou Google Gemini), qui est souvent la boucle
de dev la plus rapide.
---
## Ajouter un nouveau Skill
Un Skill est un dossier sous [`skills/`](skills/) avec un `SKILL.md` à la
racine. Il suit la convention Claude Code [`SKILL.md`][skill], plus notre
extension optionnelle `od:`. **Aucune étape d'enregistrement.** Déposez le
dossier, redémarrez le daemon, et le picker l'affiche.
### Structure d'un dossier Skill
```text
skills/your-skill/
├── SKILL.md # requis
├── assets/template.html # optionnel mais recommandé — seed file
├── references/ # optionnel — fichiers de connaissance lus par l'agent
│ ├── layouts.md
│ ├── components.md
│ └── checklist.md
└── example.html # fortement recommandé — vrai exemple construit à la main
```
### Frontmatter de `SKILL.md`
Les trois premières clés sont la spec Claude Code de base : `name`,
`description`, `triggers`. Tout ce qui est sous `od:` est spécifique à OD et
optionnel, mais **`od.mode`** décide dans quel groupe le Skill apparaît. La
valeur est extensible ; les modes courants incluent Prototype, Deck, Image,
Video, Audio, Design system et Utility.
```yaml
---
name: your-skill
description: |
One-paragraph elevator pitch. The agent reads this verbatim to decide
if the user's brief matches. Be concrete: surface, audience, what's in
the artifact, what's not.
triggers:
- "your trigger phrase"
- "another phrase"
- "中文触发词"
od:
mode: prototype # prototype | deck | image | video | audio | design-system | utility
platform: desktop # desktop | mobile
scenario: marketing # free-form tag for grouping
featured: 1 # any positive integer surfaces it under "Showcase examples"
preview:
type: html # html | jsx | pptx | markdown
entry: index.html
design_system:
requires: true # does the skill read the active DESIGN.md?
sections: [color, typography, layout, components]
example_prompt: "A copy-pastable prompt that nicely shows what this skill does."
---
# Your Skill
Body is free-form Markdown describing the workflow the agent should follow…
```
La grammaire complète — typed inputs, paramètres de sliders, capability gating
— se trouve dans [`docs/skills-protocol.md`](docs/skills-protocol.md).
### Critères de merge pour un nouveau Skill
Nous sommes exigeants sur les Skills parce qu'ils constituent la partie la plus
visible pour l'utilisateur. Un nouveau Skill doit :
1. **Livrer un vrai `example.html`.** Construit à la main, ouvrable directement
depuis le disque, avec un niveau qu'un designer pourrait réellement livrer.
Pas de lorem ipsum, pas de hero placeholder en `<svg><rect/></svg>`. Si vous
ne pouvez pas construire l'exemple vous-même, le Skill n'est probablement
pas prêt.
2. **Passer l'anti-AI-slop checklist** dans le body. Pas de gradients violets,
pas d'icônes emoji génériques, pas de carte arrondie avec accent en bord
gauche, pas d'Inter comme fonte *display*, pas de statistiques inventées.
Lisez la section **Anti-AI-slop machinery** du README pour la liste complète.
3. **Utiliser des placeholders honnêtes.** Si l'agent n'a pas de vraie donnée,
écrivez `—` ou un bloc gris libellé, pas "10× faster".
4. **Avoir un `references/checklist.md`** avec au moins les gates P0, c'est-à-dire
ce que l'agent doit vérifier avant d'émettre `<artifact>`. Reprenez le format
de [`skills/guizang-ppt/references/checklist.md`](skills/guizang-ppt/) ou
[`skills/dating-web/references/checklist.md`](skills/dating-web/).
5. **Ajouter une capture** sous `docs/screenshots/skills/<skill>.png` si le Skill
est featured. PNG, environ 1024×640 retina, capturé depuis le vrai
`example.html` avec un zoom navigateur adapté.
6. **Rester dans un dossier autonome.** Pas d'import CDN au-delà de ce que les
autres Skills utilisent déjà ; pas de fonte sans licence ; pas d'image de
plus d'environ 250 KB.
Si vous forkez un Skill existant (par exemple partir de `dating-web` pour en
faire `recruiting-web`), conservez la LICENSE et l'attribution d'auteur dans
`references/`, et mentionnez-le dans la description de la PR.
### Skills existants à imiter
- Prototype visuel single-screen : [`skills/dating-web/`](skills/dating-web/),
[`skills/digital-eguide/`](skills/digital-eguide/)
- Flow mobile multi-frame : [`skills/mobile-onboarding/`](skills/mobile-onboarding/),
[`skills/gamified-app/`](skills/gamified-app/)
- Document / template sans Design System requis : [`skills/pm-spec/`](skills/pm-spec/),
[`skills/weekly-update/`](skills/weekly-update/)
- Deck mode : [`skills/guizang-ppt/`](skills/guizang-ppt/) (bundle repris tel
quel depuis [op7418/guizang-ppt-skill][guizang]) et
[`skills/simple-deck/`](skills/simple-deck/)
---
## Ajouter un nouveau Design System
Un design system est un seul fichier [`DESIGN.md`](design-systems/README.md)
sous `design-systems/<slug>/`. **Un fichier, pas de code.** Déposez-le,
redémarrez le daemon, le picker l'affiche dans sa catégorie.
### Structure d'un dossier Design System
```text
design-systems/your-brand/
└── DESIGN.md
```
### Forme de `DESIGN.md`
```markdown
# Design System Inspired by YourBrand
> Category: Developer Tools
> One-line summary that shows in the picker preview.
## 1. Visual Theme & Atmosphere
## 2. Color
- Primary: `#hex` / `oklch(...)`
-
## 3. Typography
## 4. Spacing & Grid
## 5. Layout & Composition
## 6. Components
## 7. Motion & Interaction
## 8. Voice & Brand
## 9. Anti-patterns
```
Le schéma à 9 sections est fixe : c'est ce que les Skill bodies cherchent. Le
premier H1 devient le label dans le picker (le préfixe `Design System Inspired by`
est retiré automatiquement), et la ligne `> Category: …` décide du groupe.
Les catégories existantes sont listées dans [`design-systems/README.md`](design-systems/README.md) ;
si votre marque ne rentre vraiment nulle part, vous pouvez en introduire une
nouvelle, mais **essayez d'abord les catégories existantes**.
### Critères de merge pour un nouveau Design System
1. **Les 9 sections sont présentes.** Des sections vides sont acceptables pour
les informations difficiles à trouver (par exemple des tokens de motion),
mais les headings doivent exister, sinon la recherche utilisée par le prompt
risque de casser.
2. **Les hex codes sont réels.** Échantillonnez directement depuis le site ou
le produit de la marque, pas de mémoire ni à partir d'une supposition de l'IA. Le
protocole d'extraction brand-spec en 5 étapes du README s'applique aussi aux
mainteneurs.
3. **Les valeurs OKLch pour les couleurs d'accent** sont un plus : elles rendent
les palettes plus prévisibles entre light/dark.
4. **Pas de fluff marketing.** La tagline d'une marque n'est pas un design token.
Coupez-la.
5. **Le slug utilise l'ASCII** : `linear.app` devient `linear-app`, `x.ai`
devient `x-ai`. Les systèmes importés suivent déjà cette convention ;
imitez-la.
Les product systems livrés sont importés depuis [`VoltAgent/awesome-design-md`][acd2]
via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). Si votre
marque appartient à cet upstream, **envoyez d'abord la PR là-bas** : OD le
récupérera au prochain sync. Le dossier `design-systems/` sert aux systèmes qui
ne rentrent pas upstream, plus nos starters écrits à la main.
---
## Ajouter une nouvelle CLI de coding agent
Brancher un nouvel agent (par exemple une CLI `foo-coder`) revient à ajouter
une entrée dans [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) :
```javascript
{
id: 'foo',
name: 'Foo Coder',
bin: 'foo',
versionArgs: ['--version'],
buildArgs: (prompt) => ['exec', '-p', prompt],
streamFormat: 'plain', // or 'claude-stream-json' if it speaks that
}
```
C'est tout : le daemon la détecte dans le `PATH`, le picker l'affiche et le
chemin chat fonctionne. Si la CLI émet des **typed events** (comme
`--output-format stream-json` de Claude Code), ajoutez un parser dans
[`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts) et mettez
`streamFormat: 'claude-stream-json'`.
Critères de merge :
1. **Une vraie session fonctionne end-to-end** avec le nouvel agent. Collez le
log daemon dans la description de la PR pour montrer qu'il a streamé un artifact.
2. **`docs/agent-adapters.md`** documente les particularités de la CLI : fichier
de clé requis, support de l'image, flag non interactif, etc.
3. **La table "Supported coding agents" du README** reçoit une ligne.
---
## Mettre à jour les métadonnées `max_tokens` des modèles
En mode API, le chat envoie `max_tokens` au provider upstream à chaque requête.
Le client web choisit ce nombre avec une lookup à trois niveaux dans
[`apps/web/src/state/maxTokens.ts`](apps/web/src/state/maxTokens.ts) :
1. L'override explicite de l'utilisateur dans Settings, s'il existe.
2. Sinon, la valeur par modèle dans [`apps/web/src/state/litellm-models.json`](apps/web/src/state/litellm-models.json),
un extrait vendored du `model_prices_and_context_window.json` de
[BerriAI/litellm][litellm] (MIT). Il couvre environ 2k modèles chat chez
Anthropic, OpenAI, DeepSeek, Groq, Together, Mistral, Gemini, Bedrock,
Vertex, OpenRouter et autres.
3. Sinon, `FALLBACK_MAX_TOKENS = 8192`.
Pour récupérer un modèle nouvellement lancé, régénérez le JSON vendored :
```bash
node --experimental-strip-types scripts/sync-litellm-models.ts
```
Le script récupère le catalogue LiteLLM, filtre les entrées `mode: 'chat'`,
projette chacune vers son `max_output_tokens` (ou fallback `max_tokens`), puis
écrit un snapshot trié. Commitez le `litellm-models.json` régénéré avec la PR
qui motive cette mise à jour.
La table `OVERRIDES` dans `maxTokens.ts` est réservée aux rares cas où LiteLLM
est absent ou incorrect pour un model id réellement utilisé, par exemple
`mimo-v2.5-pro`. Gardez-la petite ; tout ce que LiteLLM sait déjà correctement
doit rester upstream.
[litellm]: https://github.com/BerriAI/litellm
---
## Maintenance des localisations
Les PR de locale doivent traduire le chrome UI, la documentation cœur et les
métadonnées display-only de galerie dans `apps/web/src/i18n/content*.ts`, mais
ne doivent pas traduire `skills/`, `design-systems/` ni les prompt bodies que
les agents exécutent. Ces prompts source sont des entrées de workflow ; garder
une langue source commune évite de multiplier la QA de prompts sur toutes les
locales. Lorsqu'un Skill, un Design System ou un prompt template est ajouté ou
renommé, mettez à jour les métadonnées display de la locale concernée et lancez
`pnpm --filter @open-design/web test` ; `content.test.ts` échoue si la coverage
couverture des métadonnées d'affichage d'une locale déclarée dérive. Les erreurs daemon, noms de fichiers
d'export et textes d'artifact générés par agent restent des limites connues,
sauf si une PR les inclut explicitement.
Pour les étapes détaillées d'ajout d'une locale (dictionnaire UI, README,
language switcher, terminologie régionale), voir [`TRANSLATIONS.md`](TRANSLATIONS.md).
---
## Style de code
Nous ne sommes pas maniaques du formatting (Prettier on save est très bien),
mais deux règles ne sont pas négociables parce qu'elles apparaissent dans le
prompt stack et l'API visible :
1. **Single quotes en JS/TS.** Les strings utilisent des single quotes sauf si
l'échappement les rend illisibles. La codebase est déjà cohérente ; suivez-la.
2. **Commentaires en anglais.** Même si une PR traduit quelque chose en français,
allemand ou chinois, les commentaires de code restent en anglais afin de
garder une référence greppable unique.
Au-delà de ça :
- **Ne racontez pas l'évidence.** Pas de `// import the module`, pas de
`// loop through items`. Si le code se lit déjà, le commentaire est du bruit.
Gardez les commentaires pour l'intention non évidente ou les contraintes que
le code ne peut pas exprimer.
- **TypeScript** pour le code source de `apps/web/src/` et `apps/daemon/src/`.
Le JavaScript généré appartient aux dossiers `dist/`; les nouveaux fichiers
`.js`, `.mjs` ou `.cjs` doivent avoir une raison générée, vendored ou
compatibility explicite.
- **Pas de nouvelle dépendance top-level** sans paragraphe dans la description
de la PR expliquant ce qu'elle apporte et combien d'octets elle coûte. La liste
des dépendances dans [`package.json`](package.json) est petite volontairement.
- **Lancez `pnpm typecheck`** avant de push. CI le lance aussi ; s'il échoue,
vous aurez un commentaire "please fix".
---
## Commits et Pull Requests
- **Un seul sujet par PR.** Ajouter un Skill, refactorer le parser et bumper une
dépendance : ce sont trois PR.
- **Titre impératif + scope.** `add dating-web skill`,
`fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`.
- **Le body explique le pourquoi.** Le diff montre souvent le quoi ; le pourquoi
est rarement évident.
- **Référencez une issue** s'il y en a une. S'il n'y en a pas et que la PR est
non trivial, ouvrez-en d'abord une pour valider que le changement est souhaité.
- **Pas de squash pendant la review.** Poussez des fixups ; les maintainers
squashent au merge.
- **Pas de force-push sur une branche partagée** sauf si un reviewer le demande.
Nous n'imposons pas de CLA. Apache-2.0 couvre le projet ; votre contribution
est licenciée sous la même licence.
---
## Signaler un bug
Ouvrez une issue avec :
- La commande exacte lancée (`pnpm tools-dev ...`).
- La CLI d'agent sélectionnée, ou le fait que vous étiez sur le chemin BYOK.
- La paire Skill + Design System qui a déclenché le problème.
- La **fin du stderr du daemon** concerné. La plupart des rapports "l'artifact
n'a jamais rendu" se diagnostiquent en 30 secondes si on voit `spawn ENOENT`
ou l'erreur réelle de la CLI.
- Une capture d'écran si le problème touche l'UI.
Pour les bugs de prompt stack ("l'agent a généré un hero violet alors que la
blacklist slop devait l'interdire"), incluez le **message assistant complet**
afin de voir si la violation vient du modèle ou du prompt.
---
## Poser des questions
- Question d'architecture, question de design, "bug ou mauvaise utilisation ?" →
[GitHub Discussions](https://github.com/nexu-io/open-design/discussions)
(préféré, car searchable pour la personne suivante).
- "Comment écrire un Skill qui fait X ?" → ouvrez une discussion. Nous y
répondrons et transformerons la réponse en ajout dans
[`docs/skills-protocol.md`](docs/skills-protocol.md) si c'est un pattern manquant.
---
## Ce que nous n'acceptons pas
Pour garder le projet focalisé, merci de ne pas ouvrir de PR qui :
- **Vendor un runtime de modèle.** Tout le pari d'OD est "votre CLI existante
suffit". Nous ne livrons pas `pi-ai`, de clés OpenAI ou de model loaders.
- **Réécrit le frontend hors de la stack actuelle sans discussion préalable.**
Next.js 16 App Router + React 18 + TS est la ligne. Pas de réécriture Astro,
Solid, Svelte ou autre framework sauf si les maintainers veulent explicitement
cette migration.
- **Remplace le daemon par une fonction serverless.** Le rôle du daemon est de
posséder un vrai `cwd` et de spawn une vraie CLI. Déployer la SPA sur Vercel
est très bien ; le daemon reste un daemon.
- **Ajoute de la télémétrie / analytics / phone-home.** OD est local-first.
Les seuls appels sortants vont vers des providers explicitement configurés
par l'utilisateur.
- **Bundle un binaire** sans fichier de licence ni attribution d'auteur à côté.
Si vous n'êtes pas sûr que votre idée rentre dans le projet, ouvrez une
discussion avant d'écrire le code.
---
## Licence
En contribuant, vous acceptez que votre contribution soit licenciée sous la
[licence Apache-2.0](LICENSE) de ce repo, à l'exception des fichiers dans
[`skills/guizang-ppt/`](skills/guizang-ppt/), qui conservent leur licence MIT
originale et l'attribution d'auteur à [op7418](https://github.com/op7418).
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
[guizang]: https://github.com/op7418/guizang-ppt-skill
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ocod]: https://github.com/OpenCoworkAI/open-codesign

267
CONTRIBUTING.ja-JP.md Normal file
View File

@@ -0,0 +1,267 @@
# Open Design へのコントリビューション
コントリビューションを検討してくださりありがとうございます。OD は意図的に小さく保っています — 価値の大部分はフレームワークコードではなく**ファイル**Skill、Design System、プロンプトフラグメントにあります。そのため、最も効果の高いコントリビューションは通常、フォルダ 1 つ、Markdown ファイル 1 つ、または PR サイズの adapter です。
このガイドでは、各種コントリビューションの対象場所と、PR がマージされるために満たすべき基準を正確に説明します。
<p align="center"><a href="CONTRIBUTING.md">English</a> · <a href="CONTRIBUTING.pt-BR.md">Português (Brasil)</a> · <a href="CONTRIBUTING.de.md">Deutsch</a> · <a href="CONTRIBUTING.fr.md">Français</a> · <a href="CONTRIBUTING.zh-CN.md">简体中文</a> · <b>日本語</b></p>
---
## 午後一回で出荷できる 3 つのこと
| やりたいこと | 実際に追加するもの | 配置場所 | 規模 |
|---|---|---|---|
| OD に新しい種類の artifact をレンダリングさせる請求書、iOS Settings 画面、ワンページャー…) | **Skill** | [`skills/<your-skill>/`](skills/) | フォルダ 1 つ、約 2 ファイル |
| OD に新しいブランドのビジュアル言語を話させる | **Design System** | [`design-systems/<brand>/DESIGN.md`](design-systems/) | Markdown ファイル 1 つ |
| 新しい coding-agent CLI を接続する | **Agent adapter** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | 1 つの配列に約 10 行 |
| 機能追加、バグ修正、[`open-codesign`][ocod] から UX パターンを移植 | コード | `apps/web/src/``apps/daemon/` | 通常の PR |
| ドキュメント改善、Français / Deutsch / 中文 への翻訳、タイポ修正 | ドキュメント | `README.md``README.fr.md``README.de.md``README.zh-CN.md``docs/``QUICKSTART.md` | PR 1 つ |
アイデアがどのカテゴリに該当するか分からない場合は、[まず discussion / issue を作成](https://github.com/nexu-io/open-design/issues/new)してください。適切な場所をご案内します。
---
## ローカル環境セットアップ
完全なセットアップ手順は [`QUICKSTART.md`](QUICKSTART.md) にあります。コントリビューター向けの要約:
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable # packageManager で指定された pnpm を選択
pnpm install
pnpm tools-dev run web # daemon + web フォアグラウンドループ
pnpm typecheck # tsc -b --noEmit
pnpm --filter @open-design/web build # 必要に応じて web パッケージをビルド
```
Node `~24` と pnpm `10.33.x` が必要です。`nvm` / `fnm` はオプション。使用する場合は `nvm install 24 && nvm use 24` または `fnm install 24 && fnm use 24` を実行してください。macOS、Linux、WSL2 が主要プラットフォームです。Windows ネイティブでも動作するはずですが、主要ターゲットではありません — 動作しない場合は issue を作成してください。
OD 自体の開発に agent CLI は `PATH` 上に不要です — daemon は「no agents found」と表示し、**Anthropic API · BYOK** パスにフォールバックします。このパスが最も高速な開発ループです。
---
## 新しい Skill の追加
Skill は [`skills/`](skills/) 配下のフォルダで、ルートに `SKILL.md` を持ち、Claude Code の [`SKILL.md` 規約][skill]とオプションの `od:` 拡張に従います。**登録ステップは不要です。** フォルダを配置して daemon を再起動すれば、ピッカーに表示されます。
### Skill フォルダ構成
```text
skills/your-skill/
├── SKILL.md # 必須
├── assets/template.html # オプションだが推奨 — seed ファイル
├── references/ # オプション — エージェントが読むナレッジファイル
│ ├── layouts.md
│ ├── components.md
│ └── checklist.md
└── example.html # 強く推奨 — 実際の手作りサンプル
```
### `SKILL.md` frontmatter
最初の 3 キーは Claude Code のベース仕様 — `name``description``triggers``od:` 配下はすべて OD 固有のオプションですが、**`od.mode`** が Skill の表示グループPrototype / Deck / Template / Design systemを決定します。
```yaml
---
name: your-skill
description: |
1 段落のエレベーターピッチ。エージェントはこれをそのまま読んで、
ユーザーの要件にマッチするか判断します。具体的にsurface、
ターゲット、artifact に含まれるもの、含まれないもの。
triggers:
- "your trigger phrase"
- "another phrase"
- "日本語のトリガーフレーズ"
od:
mode: prototype # prototype | deck | template | design-system
platform: desktop # desktop | mobile
scenario: marketing # グループ化用の自由形式タグ
featured: 1 # 正の整数を設定すると「ショーケース」セクションに表示
preview:
type: html # html | jsx | pptx | markdown
entry: index.html
design_system:
requires: true # Skill がアクティブな DESIGN.md を読むか?
sections: [color, typography, layout, components]
example_prompt: "この Skill の機能をわかりやすく示すコピペ可能なプロンプト。"
---
# Your Skill
本文はエージェントが従うべきワークフローを記述する自由形式の Markdown…
```
型付き入力、スライダーパラメータ、ケイパビリティゲーティングの完全な文法は [`docs/skills-protocol.md`](docs/skills-protocol.md) にあります。
### 新しい Skill のマージ基準
Skill はユーザーに直接見える面であるため、厳しく審査します。新しい Skill は以下を満たす必要があります:
1. **実際の `example.html` を同梱すること。** 手作りで、ディスクから直接開けて、デザイナーが実際に納品するレベルの見た目であること。Lorem ipsum や `<svg><rect/></svg>` のプレースホルダー hero は不可。自分で example を作れないなら、その Skill はまだ準備できていません。
2. **本文で anti-AI-slop チェックリストをパスすること。** 紫グラデーション、汎用 emoji アイコン、左ボーダー付き角丸カード、Inter を *display* フォントとして使用、架空の統計データは不可。完全なリストは README の **anti-AI-slop 機構**セクションを参照。
3. **正直なプレースホルダー。** エージェントが実数値を持たない場合は `—` またはラベル付きグレーブロックを書き、「10 倍高速」とは書かない。
4. **`references/checklist.md` を持つこと。** 少なくとも P0 ゲート(エージェントが `<artifact>` を出力する前にパスすべき項目)を含む。フォーマットは [`skills/guizang-ppt/references/checklist.md`](skills/guizang-ppt/) または [`skills/dating-web/references/checklist.md`](skills/dating-web/) を参考にしてください。
5. **スクリーンショットを追加。** Skill が featured の場合、`docs/screenshots/skills/<skill>.png` に配置。PNG、約 1024×640 Retina、実際の `example.html` からズームアウトしたブラウザ縮尺でキャプチャ。
6. **単一の自己完結フォルダであること。** 他の Skill が既に使用しているもの以外の CDN インポート禁止。ライセンスのないフォント禁止。約 250 KB を超える画像禁止。
既存の Skill を fork する場合(例:`dating-web` から `recruiting-web` にリミックス)、元の LICENSE と帰属表示を `references/` に保持し、PR の説明で明記してください。
### 同梱済み Skill — 模倣するものを選ぶ
- ビジュアルショーケース、単一画面プロトタイプ:[`skills/dating-web/`](skills/dating-web/)、[`skills/digital-eguide/`](skills/digital-eguide/)
- マルチフレームモバイルフロー:[`skills/mobile-onboarding/`](skills/mobile-onboarding/)、[`skills/gamified-app/`](skills/gamified-app/)
- ドキュメント / テンプレートDesign System 不要):[`skills/pm-spec/`](skills/pm-spec/)、[`skills/weekly-update/`](skills/weekly-update/)
- Deck モード:[`skills/guizang-ppt/`](skills/guizang-ppt/)[op7418/guizang-ppt-skill][guizang] からそのまま同梱)および [`skills/simple-deck/`](skills/simple-deck/)
---
## 新しい Design System の追加
Design System は `design-systems/<slug>/` 配下の単一の [`DESIGN.md`](design-systems/README.md) ファイルです。**ファイル 1 つ、コード不要。** 配置して daemon を再起動すれば、ピッカーにカテゴリ別にグループ化されて表示されます。
### Design System フォルダ構成
```text
design-systems/your-brand/
└── DESIGN.md
```
### `DESIGN.md` の構造
```markdown
# Design System Inspired by YourBrand
> Category: Developer Tools
> ピッカーのプレビューに表示される 1 行の要約。
## 1. Visual Theme & Atmosphere
## 2. Color
- Primary: `#hex` / `oklch(...)`
-
## 3. Typography
## 4. Spacing & Grid
## 5. Layout & Composition
## 6. Components
## 7. Motion & Interaction
## 8. Voice & Brand
## 9. Anti-patterns
```
9 セクションスキーマは固定です — Skill 本文の grep 対象だからです。最初の H1 がピッカーのラベルになり(`Design System Inspired by` プレフィックスは自動的に除去)、`> Category: …` 行がグループを決定します。既存のカテゴリは [`design-systems/README.md`](design-systems/README.md) に記載されています。ブランドが本当にどのカテゴリにも合わない場合は新しいカテゴリを導入できますが、**まず既存カテゴリに合わないか試してください**。
### 新しい Design System のマージ基準
1. **全 9 セクションが存在すること。** データが見つかりにくいセクション(例:モーショントークン)は本文が空でも構いませんが、見出しは必須です。見出しがないとプロンプトの grep が壊れます。
2. **Hex コードが実物であること。** ブランドのサイトやプロダクトから直接サンプリングし、記憶や AI の推測ではないこと。README の「ブランドアセット抽出」5 ステッププロトコルはメンテナにも適用されます。
3. **アクセントカラーの OKLch 値**はあると良い。ライト/ダーク間で予測可能な補間が可能になります。
4. **マーケティングの美辞麗句は不要。** ブランドのタグラインはデザイントークンではありません。削除してください。
5. **スラッグは ASCII を使用**`linear.app``linear-app``x.ai``x-ai` になります。インポート済みの 69 システムがこの規約に従っています。それに合わせてください。
出荷している 69 のプロダクトシステムは [`VoltAgent/awesome-design-md`][acd2] から [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 経由でインポートされています。ブランドが上流に属する場合は、**まずそちらに PR を送ってください** — 次の sync で自動的に反映されます。`design-systems/` フォルダは上流に合わないシステムと、手作りの 2 つのスターター用です。
---
## 新しい coding-agent CLI の追加
新しいエージェント(例:`foo-coder` CLIの接続は [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) にエントリを 1 つ追加するだけです:
```javascript
{
id: 'foo',
name: 'Foo Coder',
bin: 'foo',
versionArgs: ['--version'],
buildArgs: (prompt) => ['exec', '-p', prompt],
streamFormat: 'plain', // Claude Code と同じプロトコルなら 'claude-stream-json'
}
```
これだけです — daemon が `PATH` 上で検出し、ピッカーに表示され、チャットパスが動作します。CLI が**型付きイベント**を出力する場合Claude Code の `--output-format stream-json` のように)、[`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts) にパーサーを追加して `streamFormat: 'claude-stream-json'` を設定してください。
マージ基準:
1. **新しいエージェントで実際のセッションがエンドツーエンドで動作すること** — artifact がストリーミングされたことを示す daemon ログを PR の説明に貼り付けてください。
2. **`docs/agent-adapters.md`** を CLI の特徴で更新(キーファイルは必要か?画像入力に対応しているか?非対話モードのフラグは何か?)。
3. **README の「対応 Coding Agent」テーブル**に 1 行追加。
---
## コードスタイル
フォーマットについて厳格ではありません(保存時の Prettier で OKが、2 つのルールはプロンプトスタックとユーザー向け API に影響するため交渉の余地がありません:
1. **JS/TS ではシングルクォート。** エスケープが見苦しくなる場合を除き、文字列はシングルクォート。コードベースは既に一貫しています — 合わせてください。
2. **コメントは英語。** PR が何かを日本語に翻訳する場合でも、コードコメントは英語を維持します。grep 可能なリファレンスを 1 セットに保つためです。
その他:
- **ナレーションしない。** `// import the module``// loop through items` は不要。コードが明らかに読める場合、コメントはノイズです。コメントはコードで表現できない非自明な意図や制約のために残してください。
- **TypeScript** は `apps/web/src/` 用。daemon`apps/daemon/`)は型が重要な箇所で JSDoc 付きのプレーン ESM JavaScript です — そのまま維持してください。
- **新しいトップレベル依存関係は追加しない**PR の説明で得られるものと出荷バイト数について 1 段落の説明がない限り)。[`package.json`](package.json) の依存関係リストは意図的に小さく保っています。
- **プッシュ前に `pnpm typecheck` を実行。** CI で実行されます。失敗すると「please fix」コメントが付きます。
---
## コミットとプルリクエスト
- **PR 1 つにつき 1 つの関心事。** Skill の追加 + パーサーのリファクタリング + 依存関係のバンプは 3 つの PR です。
- **タイトルは命令形 + スコープ。** `add dating-web skill``fix daemon SSE backpressure when CLI hangs``docs: clarify .od layout`
- **本文は「なぜ」を説明。** 「何をするか」は通常 diff から明らかです。「なぜこれが必要か」はほとんどの場合そうではありません。
- **issue がある場合は参照。** ない場合で、PR が自明でないなら、先に issue を作成して変更が求められていることを合意してから時間を費やしてください。
- **レビュー中にスカッシュしない。** fixup をプッシュしてください。マージ時にスカッシュします。
- **共有ブランチへの force-push 禁止。** レビュアーが依頼した場合を除きます。
CLA は求めません。Apache-2.0 でカバーされます。あなたのコントリビューションは同じライセンスの下でライセンスされます。
---
## バグ報告
以下の情報を含めて issue を作成してください:
- 実行したコマンド(正確な `pnpm tools-dev ...` の呼び出し)。
- 選択されたエージェント CLIまたは BYOK パスを使用していたか)。
- トリガーとなった Skill + Design System のペア。
- 関連する **daemon stderr のテール** — 「artifact がレンダリングされない」という報告のほとんどは、`spawn ENOENT` や CLI の実際のエラーが見えれば 30 秒で診断できます。
- UI に関する場合はスクリーンショット。
プロンプトスタックのバグ(「エージェントが紫グラデーションの hero を出力した、slop ブラックリストで禁止されているはずなのに」)の場合、**アシスタントメッセージの全文**を含めてください。違反がモデル側かプロンプト側かを判断できます。
---
## 質問する
- アーキテクチャの質問、設計の質問、「これはバグか使い方の問題か」→ [GitHub Discussions](https://github.com/nexu-io/open-design/discussions)(推奨 — 次の人が検索できます)。
- 「X をする Skill はどう書けばいい?」→ Discussion を作成してください。回答し、不足しているパターンであれば [`docs/skills-protocol.md`](docs/skills-protocol.md) に反映します。
---
## 受け入れないもの
プロジェクトの焦点を維持するため、以下のような PR は作成しないでください:
- **モデルランタイムを vendor する。** OD の根幹は「あなたの既存 CLI で十分」です。`pi-ai`、OpenAI キー、モデルローダーは同梱しません。
- **事前の議論なくフロントエンドを現在のスタックから書き換える。** Next.js 16 App Router + React 18 + TS がラインです。メンテナが明示的にそのマイグレーションを望まない限り、Astro、Solid、Svelte、その他のフレームワークへの書き換えは不可。
- **daemon をサーバーレス関数に置き換える。** daemon の存在意義は実際の `cwd` を所有し、実際の CLI を spawn することです。SPA の Vercel デプロイは OK。daemon は daemon のまま。
- **テレメトリ / アナリティクス / phone-home を追加する。** OD はローカルファーストです。外向きの呼び出しはユーザーが明示的に設定したプロバイダへのもののみ。
- **ライセンスファイルと帰属表示なしでバイナリを同梱する。**
アイデアが適合するか分からない場合は、コードを書く前に discussion を作成してください。
---
## ライセンス
コントリビューションすることにより、あなたのコントリビューションがこのリポジトリの [Apache-2.0 License](LICENSE) の下でライセンスされることに同意するものとします。ただし、[`skills/guizang-ppt/`](skills/guizang-ppt/) 内のファイルは元の MIT ライセンスと [op7418](https://github.com/op7418) の帰属表示を保持します。
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
[guizang]: https://github.com/op7418/guizang-ppt-skill
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ocod]: https://github.com/OpenCoworkAI/open-codesign

297
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,297 @@
# Contributing to Open Design
Thanks for thinking about contributing. OD is small on purpose — most of the value lives in **files** (skills, design systems, prompt fragments) rather than framework code. That means the highest-leverage contributions are usually one folder, one Markdown file, or one PR-sized adapter.
This guide tells you exactly where to look for each type of contribution and what bar a PR has to clear before we merge it.
<p align="center"><b>English</b> · <a href="CONTRIBUTING.pt-BR.md">Português (Brasil)</a> · <a href="CONTRIBUTING.de.md">Deutsch</a> · <a href="CONTRIBUTING.fr.md">Français</a> · <a href="CONTRIBUTING.zh-CN.md">简体中文</a> · <a href="CONTRIBUTING.ja-JP.md">日本語</a></p>
---
## Three things you can ship in one afternoon
| If you want to… | You're really adding | Where it lives | Ship size |
|---|---|---|---|
| Make OD render a new kind of artifact (an invoice, an iOS Settings screen, a one-pager…) | a **Skill** | [`skills/<your-skill>/`](skills/) | one folder, ~2 files |
| Make OD speak a new brand's visual language | a **Design System** | [`design-systems/<brand>/DESIGN.md`](design-systems/) | one Markdown file |
| Hook up a new coding-agent CLI | an **Agent adapter** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | ~10 lines in one array |
| Add a feature, fix a bug, lift a UX pattern from [`open-codesign`][ocod] | code | `apps/web/src/`, `apps/daemon/` | normal PR |
| Improve docs, port a section to Français / Deutsch / 中文, fix typos | docs | `README.md`, `README.fr.md`, `README.de.md`, `README.zh-CN.md`, `docs/`, `QUICKSTART.md` | one PR |
If you're not sure which bucket your idea is in, [open a discussion / issue first](https://github.com/nexu-io/open-design/issues/new) and we'll point you at the right surface.
---
## Local setup
The full one-page setup lives in [`QUICKSTART.md`](QUICKSTART.md). The TL;DR for contributors:
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable # selects the pinned pnpm from packageManager
pnpm install
pnpm tools-dev run web # daemon + web foreground loop
pnpm typecheck # tsc -b --noEmit
pnpm --filter @open-design/web build # web package build when needed
```
Node `~24` and pnpm `10.33.x` are required. `nvm` / `fnm` are optional; use `nvm install 24 && nvm use 24` or `fnm install 24 && fnm use 24` if you prefer managing Node that way. macOS, Linux, and WSL2 are the primary paths. Windows native should work but isn't a primary target — file an issue if it doesn't.
You don't need any agent CLI on your `PATH` to develop OD itself — the daemon will tell you "no agents found" and fall back to the **Anthropic API · BYOK** path, which is the fastest dev loop anyway.
---
## Adding a new Skill
A skill is a folder under [`skills/`](skills/) with a `SKILL.md` at the root, following Claude Code's [`SKILL.md` convention][skill] plus our optional `od:` extension. **No registration step.** Drop the folder in, restart the daemon, the picker shows it.
### Skill folder layout
```text
skills/your-skill/
├── SKILL.md # required
├── assets/template.html # optional but recommended — the seed file
├── references/ # optional — knowledge files the agent reads
│ ├── layouts.md
│ ├── components.md
│ └── checklist.md
└── example.html # strongly recommended — a real, hand-built sample
```
### `SKILL.md` frontmatter
The first three keys are the Claude Code base spec — `name`, `description`, `triggers`. Everything under `od:` is OD-specific and optional, but **`od.mode`** decides which group the skill shows up in (Prototype / Deck / Template / Design system).
```yaml
---
name: your-skill
description: |
One-paragraph elevator pitch. The agent reads this verbatim to decide
if the user's brief matches. Be concrete: surface, audience, what's in
the artifact, what's not.
triggers:
- "your trigger phrase"
- "another phrase"
- "中文触发词"
od:
mode: prototype # prototype | deck | template | design-system
platform: desktop # desktop | mobile
scenario: marketing # free-form tag for grouping
featured: 1 # any positive integer surfaces it under "Showcase examples"
preview:
type: html # html | jsx | pptx | markdown
entry: index.html
design_system:
requires: true # does the skill read the active DESIGN.md?
sections: [color, typography, layout, components]
example_prompt: "A copy-pastable prompt that nicely shows what this skill does."
---
# Your Skill
Body is free-form Markdown describing the workflow the agent should follow…
```
The full grammar — typed inputs, slider parameters, capability gating — lives in [`docs/skills-protocol.md`](docs/skills-protocol.md).
### Bar for merging a new skill
We're picky about skills because they're the user-facing surface. A new skill must:
1. **Ship a real `example.html`.** Hand-built, opens straight from disk, looks like something a designer would actually deliver. No lorem ipsum, no `<svg><rect/></svg>` placeholder hero. If you can't build the example yourself, the skill probably isn't ready.
2. **Pass the anti-AI-slop checklist** in the body. No purple gradients, no generic emoji icons, no rounded card with a left-border accent, no Inter as a *display* face, no invented stats. Read the **Anti-AI-slop machinery** section of the README for the full list.
3. **Honest placeholders.** When the agent doesn't have a real number, write `—` or a labelled grey block, not "10× faster".
4. **Have a `references/checklist.md`** with at least P0 gates (the stuff the agent has to pass before emitting `<artifact>`). Lift the format from [`skills/guizang-ppt/references/checklist.md`](skills/guizang-ppt/) or [`skills/dating-web/references/checklist.md`](skills/dating-web/).
5. **Add a screenshot** at `docs/screenshots/skills/<skill>.png` if the skill is featured. PNG, ~1024×640 retina, captured from the real `example.html` at zoomed-out browser scale.
6. **Be a single self-contained folder.** No CDN imports beyond what other skills already use; no fonts you didn't license; no images larger than ~250 KB.
If you fork an existing skill (e.g. start from `dating-web` and remix into a `recruiting-web`), keep the original LICENSE and authorship in `references/` and call it out in your PR description.
### Skills that already ship — pick one to imitate
- Visual showcase, single-screen prototype: [`skills/dating-web/`](skills/dating-web/), [`skills/digital-eguide/`](skills/digital-eguide/)
- Multi-frame mobile flow: [`skills/mobile-onboarding/`](skills/mobile-onboarding/), [`skills/gamified-app/`](skills/gamified-app/)
- Document / template (no design system required): [`skills/pm-spec/`](skills/pm-spec/), [`skills/weekly-update/`](skills/weekly-update/)
- Deck mode: [`skills/guizang-ppt/`](skills/guizang-ppt/) (bundled verbatim from [op7418/guizang-ppt-skill][guizang]) and [`skills/simple-deck/`](skills/simple-deck/)
---
## Adding a new Design System
A design system is a single [`DESIGN.md`](design-systems/README.md) file under `design-systems/<slug>/`. **One file, no code.** Drop it in, restart the daemon, the picker shows it grouped by category.
### Design system folder layout
```text
design-systems/your-brand/
└── DESIGN.md
```
### `DESIGN.md` shape
```markdown
# Design System Inspired by YourBrand
> Category: Developer Tools
> One-line summary that shows in the picker preview.
## 1. Visual Theme & Atmosphere
## 2. Color
- Primary: `#hex` / `oklch(...)`
-
## 3. Typography
## 4. Spacing & Grid
## 5. Layout & Composition
## 6. Components
## 7. Motion & Interaction
## 8. Voice & Brand
## 9. Anti-patterns
```
The 9-section schema is fixed — that's what skill bodies grep for. The first H1 becomes the picker label (the `Design System Inspired by` prefix is stripped automatically), and the `> Category: …` line decides which group it lands in. Existing categories are listed in [`design-systems/README.md`](design-systems/README.md); if your brand truly doesn't fit, you can introduce a new one, but **try existing categories first**.
### Bar for merging a new design system
1. **All 9 sections present.** Empty section bodies are fine for hard-to-find data (e.g. motion tokens), but the headings have to be there or the prompt grep breaks.
2. **Hex codes are real.** Sample directly from the brand's site or product, not from memory or AI guesses. The README's "brand-spec extraction" 5-step protocol applies to maintainers too.
3. **OKLch values for accent colors** are nice-to-have. They make palettes lerp predictably across light/dark.
4. **No marketing fluff.** The brand's tagline is not a design token. Cut it.
5. **Slug uses ASCII**`linear.app` becomes `linear-app`, `x.ai` becomes `x-ai`. The 69 imported systems already follow this convention; mirror it.
The 69 product systems we ship are imported from [`VoltAgent/awesome-design-md`][acd2] via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). If your brand belongs upstream, **send the PR there first** — we'll pick it up automatically on the next sync. The `design-systems/` folder is for systems that don't fit upstream, plus our two hand-authored starters.
---
## Adding a new coding-agent CLI
Hooking up a new agent (e.g. some new shop's `foo-coder` CLI) is one entry in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts):
```javascript
{
id: 'foo',
name: 'Foo Coder',
bin: 'foo',
versionArgs: ['--version'],
buildArgs: (prompt) => ['exec', '-p', prompt],
streamFormat: 'plain', // or 'claude-stream-json' if it speaks that
}
```
That's it — daemon will detect it on `PATH`, the picker shows it, the chat path works. If the CLI emits **typed events** (like Claude Code's `--output-format stream-json`), wire a parser in [`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts) and set `streamFormat: 'claude-stream-json'`.
Bar for merging:
1. **A real session works end-to-end** with the new agent — paste the daemon log into the PR description showing it streamed an artifact through.
2. **`docs/agent-adapters.md`** is updated with the CLI's quirks (does it require a key file? does it support image input? what's its non-interactive flag?).
3. **The README's "Supported coding agents" table** gets one row.
---
## Updating model `max_tokens` metadata
API-mode chat sends `max_tokens` to the upstream provider on every request. The web client picks that number from a three-tier lookup in [`apps/web/src/state/maxTokens.ts`](apps/web/src/state/maxTokens.ts):
1. The user's explicit override in Settings, if set.
2. Otherwise, the per-model default in [`apps/web/src/state/litellm-models.json`](apps/web/src/state/litellm-models.json) — a vendored slice of [BerriAI/litellm][litellm]'s `model_prices_and_context_window.json` (MIT). It covers ~2k chat models across Anthropic, OpenAI, DeepSeek, Groq, Together, Mistral, Gemini, Bedrock, Vertex, OpenRouter, and friends.
3. Otherwise, `FALLBACK_MAX_TOKENS = 8192`.
To pick up a newly-launched model, regenerate the vendored JSON:
```bash
node --experimental-strip-types scripts/sync-litellm-models.ts
```
The script fetches LiteLLM's catalog, filters to `mode: 'chat'` entries, projects each to its `max_output_tokens` (or `max_tokens` fallback), and writes a sorted snapshot. Commit the regenerated `litellm-models.json` alongside whatever PR triggered the refresh.
The OVERRIDES table in `maxTokens.ts` is for the rare case where LiteLLM is missing or wrong for a model id we actually use — for example, `mimo-v2.5-pro` (LiteLLM only ships MiMo via the `openrouter/xiaomi/...` and `novita/xiaomimimo/...` aliases, neither of which matches the canonical id Xiaomi's direct API uses). Keep it small; everything that LiteLLM gets right belongs upstream.
[litellm]: https://github.com/BerriAI/litellm
---
## Localization maintenance
German uses formal `Sie` because OD speaks to a mixed audience of solo creators, agencies, and engineering teams; until project feedback shows that an informal `du` voice fits better, formal German is the least surprising default. Locale PRs should translate UI chrome, core docs, and display-only gallery metadata in `apps/web/src/i18n/content.ts`, but should not translate `skills/`, `design-systems/`, or prompt bodies that agents execute. Those source prompts are maintained as workflow inputs, and keeping one source language avoids multiplying prompt QA across locales. When adding or renaming a skill, design system, or prompt template, update the German display metadata and run `pnpm --filter @open-design/web test`; `content.test.ts` fails if German display coverage drifts. Daemon errors, export filenames, and agent-generated artifact text are known limitations unless a PR explicitly scopes them.
For step-by-step instructions on adding a new locale (UI dictionary, README, language switcher, regional terminology), see [`TRANSLATIONS.md`](TRANSLATIONS.md).
---
## Code style
We're not pedantic about formatting (Prettier on save is fine), but two rules are non-negotiable because they show up in the prompt stack and the user-facing API:
1. **Single quotes in JS/TS.** Strings are single-quoted unless escaping makes them ugly. The codebase is already consistent — please match.
2. **Comments in English.** Even if the PR is translating something into Deutsch or 中文, code comments stay in English so we can keep one set of greppable references.
Beyond that:
- **Don't narrate.** No `// import the module`, no `// loop through items`. If the code reads obviously, the comment is noise. Save comments for non-obvious intent or constraints the code can't express.
- **TypeScript** for `apps/web/src/`. The daemon (`apps/daemon/`) is plain ESM JavaScript with JSDoc when types matter — keep it that way.
- **No new top-level dependencies** without a paragraph in the PR description on what we get vs. what bytes we ship. The dep list in [`package.json`](package.json) is small on purpose.
- **Run `pnpm typecheck`** before pushing. CI runs it; failing it earns a "please fix" comment.
---
## Commits & pull requests
- **One concern per PR.** Adding a skill + refactoring the parser + bumping a dep is three PRs.
- **Title is imperative + scope.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`.
- **Body explains the why.** "What does this do" is usually obvious from the diff; "why does this need to exist" rarely is.
- **Reference an issue** if there is one. If there isn't and the PR is non-trivial, open one first so we can agree the change is wanted before you spend the time.
- **No squash-during-review.** Push fixups; we'll squash on merge.
- **No force-push to a shared branch** unless the reviewer asked.
We don't enforce a CLA. Apache-2.0 covers us; your contribution is licensed under the same.
---
## Reporting bugs
Open an issue with:
- What you ran (the exact `pnpm tools-dev ...` invocation).
- Which agent CLI was selected (or whether you were on the BYOK path).
- The skill + design system pair that triggered it.
- The relevant **daemon stderr tail** — most "the artifact never rendered" reports get diagnosed in 30 seconds when we can see `spawn ENOENT` or the CLI's actual error.
- A screenshot if it's UI.
For prompt-stack bugs ("the agent emitted a purple gradient hero, the slop blacklist was supposed to forbid that"), include the **full assistant message** so we can see whether the violation was the model or the prompt.
---
## Asking questions
- Architecture question, design question, "is this a bug or a misuse" → [GitHub Discussions](https://github.com/nexu-io/open-design/discussions) (preferred — searchable for the next person).
- "How do I write a skill that does X" → Open a discussion. We'll answer it and turn the answer into [`docs/skills-protocol.md`](docs/skills-protocol.md) if it's a missing pattern.
---
## What we don't accept
To keep the project focused, please don't open PRs that:
- **Vendor a model runtime.** OD's whole bet is "your existing CLI is enough". We don't ship `pi-ai`, OpenAI keys, or model loaders.
- **Rewrite the frontend away from the current stack without prior discussion.** Next.js 16 App Router + React 18 + TS is the line. No Astro, Solid, Svelte, or other framework rewrites unless maintainers explicitly want that migration.
- **Replace the daemon with a serverless function.** The daemon's whole point is owning a real `cwd` and spawning a real CLI. Vercel deployment of the SPA is fine; the daemon stays a daemon.
- **Add telemetry / analytics / phone-home.** OD is local-first. The only outbound calls are to providers the user explicitly configured.
- **Bundle a binary** without a license file and authorship attribution next to it.
If you're not sure whether your idea fits, open a discussion before writing the code.
---
## License
By contributing, you agree your contribution is licensed under the [Apache-2.0 License](LICENSE) of this repository, with the exception of files inside [`skills/guizang-ppt/`](skills/guizang-ppt/), which retain their original MIT license and authorship attribution to [op7418](https://github.com/op7418).
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
[guizang]: https://github.com/op7418/guizang-ppt-skill
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ocod]: https://github.com/OpenCoworkAI/open-codesign

297
CONTRIBUTING.pt-BR.md Normal file
View File

@@ -0,0 +1,297 @@
# Contribuindo com o Open Design
Obrigado por considerar contribuir. O OD é pequeno de propósito — a maior parte do valor mora em **arquivos** (skills, design systems, fragmentos de prompt) e não em código de framework. Isso significa que as contribuições com maior alavancagem geralmente são uma pasta, um arquivo Markdown ou um adapter do tamanho de um PR.
Este guia diz exatamente onde olhar para cada tipo de contribuição e qual a barra que um PR precisa atingir antes do merge.
<p align="center"><a href="CONTRIBUTING.md">English</a> · <b>Português (Brasil)</b> · <a href="CONTRIBUTING.de.md">Deutsch</a> · <a href="CONTRIBUTING.fr.md">Français</a> · <a href="CONTRIBUTING.zh-CN.md">简体中文</a> · <a href="CONTRIBUTING.ja-JP.md">日本語</a></p>
---
## Três coisas que dá pra entregar em uma tarde
| Se você quer… | Você está adicionando | Onde mora | Tamanho da entrega |
|---|---|---|---|
| Fazer o OD renderizar um novo tipo de artifact (uma nota fiscal, uma tela de Settings do iOS, um one-pager…) | uma **Skill** | [`skills/<sua-skill>/`](skills/) | uma pasta, ~2 arquivos |
| Fazer o OD falar a linguagem visual de uma nova marca | um **Design System** | [`design-systems/<marca>/DESIGN.md`](design-systems/) | um arquivo Markdown |
| Plugar um novo CLI de agente de código | um **Adapter de agente** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | ~10 linhas em um array |
| Adicionar uma feature, corrigir um bug, trazer um padrão de UX do [`open-codesign`][ocod] | código | `apps/web/src/`, `apps/daemon/` | PR normal |
| Melhorar docs, traduzir uma seção para Français / Deutsch / 中文, corrigir typos | docs | `README.md`, `README.fr.md`, `README.de.md`, `README.zh-CN.md`, `docs/`, `QUICKSTART.md` | um PR |
Se você não tem certeza em qual balde sua ideia se encaixa, [abra primeiro uma discussion / issue](https://github.com/nexu-io/open-design/issues/new) e te apontamos para a superfície certa.
---
## Setup local
O setup completo numa página mora em [`QUICKSTART.pt-BR.md`](QUICKSTART.pt-BR.md). O TL;DR para contribuidores:
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable # selects the pinned pnpm from packageManager
pnpm install
pnpm tools-dev run web # daemon + web foreground loop
pnpm typecheck # tsc -b --noEmit
pnpm --filter @open-design/web build # build do pacote web quando necessário
```
Node `~24` e pnpm `10.33.x` são obrigatórios. `nvm` / `fnm` são opcionais; use `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` se preferir gerenciar Node assim. macOS, Linux e WSL2 são os caminhos principais. Windows nativo costuma funcionar, mas não é alvo principal — abra uma issue se quebrar.
Você não precisa de nenhum CLI de agente no `PATH` para desenvolver o próprio OD — o daemon dirá "no agents found" e cairá no caminho **Anthropic API · BYOK**, que é o loop de dev mais rápido de qualquer jeito.
---
## Adicionando uma nova Skill
Uma skill é uma pasta sob [`skills/`](skills/) com um `SKILL.md` na raiz, seguindo a [convenção `SKILL.md`][skill] do Claude Code mais nossa extensão opcional `od:`. **Não há passo de registro.** Coloque a pasta, reinicie o daemon e o picker mostra.
### Layout da pasta da skill
```text
skills/your-skill/
├── SKILL.md # required
├── assets/template.html # optional but recommended — the seed file
├── references/ # optional — knowledge files the agent reads
│ ├── layouts.md
│ ├── components.md
│ └── checklist.md
└── example.html # strongly recommended — a real, hand-built sample
```
### Frontmatter do `SKILL.md`
As três primeiras chaves são a base spec do Claude Code — `name`, `description`, `triggers`. Tudo sob `od:` é específico do OD e opcional, mas **`od.mode`** decide em qual grupo a skill aparece (Prototype / Deck / Template / Design system).
```yaml
---
name: your-skill
description: |
One-paragraph elevator pitch. The agent reads this verbatim to decide
if the user's brief matches. Be concrete: surface, audience, what's in
the artifact, what's not.
triggers:
- "your trigger phrase"
- "another phrase"
- "中文触发词"
od:
mode: prototype # prototype | deck | template | design-system
platform: desktop # desktop | mobile
scenario: marketing # free-form tag for grouping
featured: 1 # any positive integer surfaces it under "Showcase examples"
preview:
type: html # html | jsx | pptx | markdown
entry: index.html
design_system:
requires: true # does the skill read the active DESIGN.md?
sections: [color, typography, layout, components]
example_prompt: "A copy-pastable prompt that nicely shows what this skill does."
---
# Your Skill
Body is free-form Markdown describing the workflow the agent should follow…
```
A gramática completa — inputs tipados, parâmetros de slider, gating de capacidades — vive em [`docs/skills-protocol.md`](docs/skills-protocol.md).
### Barra para mergear uma nova skill
Somos exigentes com skills porque elas são a superfície voltada para o usuário. Uma nova skill precisa:
1. **Trazer um `example.html` real.** Feito à mão, abre direto do disco e parece algo que um designer entregaria. Sem lorem ipsum, sem hero placeholder `<svg><rect/></svg>`. Se você não consegue construir o exemplo, provavelmente a skill ainda não está pronta.
2. **Passar no checklist anti-AI-slop** no corpo. Sem gradiente roxo, sem ícones genéricos de emoji, sem card arredondado com borda lateral de destaque, sem Inter como fonte de *display*, sem stats inventados. Leia a seção **Anti-AI-slop machinery** do README para a lista completa.
3. **Placeholders honestos.** Quando o agente não tem um número real, escreva `—` ou um bloco cinza com label, não "10× mais rápido".
4. **Ter um `references/checklist.md`** com pelo menos os gates P0 (o que o agente precisa passar antes de emitir `<artifact>`). Pegue o formato em [`skills/guizang-ppt/references/checklist.md`](skills/guizang-ppt/) ou [`skills/dating-web/references/checklist.md`](skills/dating-web/).
5. **Adicionar um screenshot** em `docs/screenshots/skills/<skill>.png` se a skill for featured. PNG, ~1024×640 retina, capturado do `example.html` real em zoom-out do navegador.
6. **Ser uma única pasta self-contained.** Sem imports de CDN além do que outras skills já usam; sem fontes que você não licenciou; sem imagens maiores que ~250 KB.
Se você forkar uma skill existente (por exemplo, partir do `dating-web` e remixar para um `recruiting-web`), preserve o LICENSE original e a autoria em `references/` e mencione isso na descrição do PR.
### Skills já entregues — pegue uma para imitar
- Showcase visual, protótipo de tela única: [`skills/dating-web/`](skills/dating-web/), [`skills/digital-eguide/`](skills/digital-eguide/)
- Fluxo mobile multi-frame: [`skills/mobile-onboarding/`](skills/mobile-onboarding/), [`skills/gamified-app/`](skills/gamified-app/)
- Documento / template (sem design system obrigatório): [`skills/pm-spec/`](skills/pm-spec/), [`skills/weekly-update/`](skills/weekly-update/)
- Modo deck: [`skills/guizang-ppt/`](skills/guizang-ppt/) (bundled literalmente de [op7418/guizang-ppt-skill][guizang]) e [`skills/simple-deck/`](skills/simple-deck/)
---
## Adicionando um novo Design System
Um design system é um único arquivo [`DESIGN.md`](design-systems/README.md) sob `design-systems/<slug>/`. **Um arquivo, sem código.** Coloque, reinicie o daemon, o picker mostra agrupado por categoria.
### Layout da pasta do design system
```text
design-systems/your-brand/
└── DESIGN.md
```
### Formato do `DESIGN.md`
```markdown
# Design System Inspired by YourBrand
> Category: Developer Tools
> One-line summary that shows in the picker preview.
## 1. Visual Theme & Atmosphere
## 2. Color
- Primary: `#hex` / `oklch(...)`
-
## 3. Typography
## 4. Spacing & Grid
## 5. Layout & Composition
## 6. Components
## 7. Motion & Interaction
## 8. Voice & Brand
## 9. Anti-patterns
```
O schema de 9 seções é fixo — é o que os corpos das skills procuram via grep. O primeiro H1 vira o label do picker (o prefixo `Design System Inspired by` é removido automaticamente) e a linha `> Category: …` decide em qual grupo o sistema cai. As categorias existentes estão em [`design-systems/README.md`](design-systems/README.md); se sua marca realmente não couber, dá pra introduzir uma nova, mas **tente as existentes primeiro**.
### Barra para mergear um novo design system
1. **As 9 seções presentes.** Corpos vazios são aceitáveis para dados difíceis (por exemplo, motion tokens), mas os títulos precisam estar lá ou o grep do prompt quebra.
2. **Códigos hex reais.** Amostre direto do site ou produto da marca, não da memória nem de chute de IA. O protocolo de extração de spec da marca em 5 passos do README vale também para mantenedores.
3. **Valores OKLch para cores de destaque** são desejáveis. Eles fazem paletas interpolarem de forma previsível entre claro/escuro.
4. **Sem fluff de marketing.** O slogan da marca não é um design token. Corte.
5. **Slug em ASCII**`linear.app` vira `linear-app`, `x.ai` vira `x-ai`. Os 69 sistemas importados já seguem essa convenção; espelhe.
Os 69 sistemas de produto que entregamos são importados de [`VoltAgent/awesome-design-md`][acd2] via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). Se sua marca pertence ao upstream, **mande o PR para lá primeiro** — pegamos automaticamente no próximo sync. A pasta `design-systems/` é para sistemas que não cabem no upstream, mais nossos dois starters escritos à mão.
---
## Adicionando um novo CLI de agente de código
Plugar um novo agente (por exemplo, o CLI `foo-coder` de alguma loja nova) é uma entrada em [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts):
```javascript
{
id: 'foo',
name: 'Foo Coder',
bin: 'foo',
versionArgs: ['--version'],
buildArgs: (prompt) => ['exec', '-p', prompt],
streamFormat: 'plain', // or 'claude-stream-json' if it speaks that
}
```
É só isso — o daemon detecta no `PATH`, o picker mostra, o caminho de chat funciona. Se o CLI emite **eventos tipados** (como o `--output-format stream-json` do Claude Code), conecte um parser em [`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts) e defina `streamFormat: 'claude-stream-json'`.
Barra para mergear:
1. **Uma sessão real funciona end-to-end** com o novo agente — cole o log do daemon na descrição do PR mostrando que ele conseguiu streamar um artifact.
2. **`docs/agent-adapters.md`** atualizado com as peculiaridades do CLI (precisa de arquivo de chave? aceita imagem? qual a flag não-interativa?).
3. **A tabela "Supported coding agents" do README** ganha uma linha.
---
## Atualizando metadados de `max_tokens` dos modelos
O chat em modo API envia `max_tokens` para o provider upstream em toda requisição. O cliente web pega esse número de uma busca em três níveis em [`apps/web/src/state/maxTokens.ts`](apps/web/src/state/maxTokens.ts):
1. O override explícito do usuário em Settings, se definido.
2. Caso contrário, o default por modelo em [`apps/web/src/state/litellm-models.json`](apps/web/src/state/litellm-models.json) — uma fatia vendored do `model_prices_and_context_window.json` do [BerriAI/litellm][litellm] (MIT). Cobre ~2k modelos de chat de Anthropic, OpenAI, DeepSeek, Groq, Together, Mistral, Gemini, Bedrock, Vertex, OpenRouter etc.
3. Caso contrário, `FALLBACK_MAX_TOKENS = 8192`.
Para incluir um modelo recém-lançado, regere o JSON vendored:
```bash
node --experimental-strip-types scripts/sync-litellm-models.ts
```
O script busca o catálogo do LiteLLM, filtra entradas `mode: 'chat'`, projeta cada uma para `max_output_tokens` (com fallback em `max_tokens`) e grava um snapshot ordenado. Faça commit do `litellm-models.json` regerado junto com o PR que disparou o refresh.
A tabela OVERRIDES em `maxTokens.ts` é para o caso raro em que o LiteLLM está faltando ou errado para um id de modelo que de fato usamos — por exemplo, `mimo-v2.5-pro` (o LiteLLM só entrega o MiMo via aliases `openrouter/xiaomi/...` e `novita/xiaomimimo/...`, e nenhum bate com o id canônico que a API direta da Xiaomi usa). Mantenha-a pequena; tudo que o LiteLLM acerta pertence ao upstream.
[litellm]: https://github.com/BerriAI/litellm
---
## Manutenção de localização
Alemão usa o formal `Sie` porque o OD fala com uma audiência mista de criadores solo, agências e times de engenharia; até feedback do projeto mostrar que uma voz informal `du` se encaixa melhor, alemão formal é o default menos surpreendente. PRs de locale devem traduzir chrome de UI, docs principais e metadados visuais de galeria em `apps/web/src/i18n/content.ts`, mas não devem traduzir `skills/`, `design-systems/` nem corpos de prompt que os agentes executam. Esses prompts-fonte são mantidos como entradas de workflow, e manter um único idioma de fonte evita multiplicar QA de prompt entre locales. Ao adicionar ou renomear uma skill, design system ou prompt template, atualize os metadados de display em alemão e rode `pnpm --filter @open-design/web test`; o `content.test.ts` falha se a cobertura de display em alemão sair de sincronia. Erros do daemon, nomes de arquivos exportados e texto de artifact gerado pelo agente são limitações conhecidas, a menos que um PR explicitamente os englobe.
Para instruções passo a passo sobre adicionar um novo locale (dicionário de UI, README, language switcher, terminologia regional), veja [`TRANSLATIONS.md`](TRANSLATIONS.md).
---
## Estilo de código
Não somos pedantes com formatação (Prettier on save está ok), mas duas regras são inegociáveis porque aparecem na pilha de prompt e na API voltada ao usuário:
1. **Aspas simples em JS/TS.** Strings ficam com aspas simples a menos que escapar fique feio. O codebase já está consistente — siga.
2. **Comentários em inglês.** Mesmo se o PR é para traduzir algo para alemão ou 中文, comentários de código ficam em inglês para mantermos um único conjunto de referências grepáveis.
Além disso:
- **Não narre.** Sem `// import the module`, sem `// loop through items`. Se o código se lê obviamente, o comentário é ruído. Reserve comentários para intenção não-óbvia ou restrições que o código não consegue expressar.
- **TypeScript** em `apps/web/src/`. O daemon (`apps/daemon/`) é JavaScript ESM puro com JSDoc onde tipos importam — mantenha assim.
- **Sem novas dependências top-level** sem um parágrafo na descrição do PR sobre o que ganhamos vs. quantos bytes despachamos. A lista de deps em [`package.json`](package.json) é pequena de propósito.
- **Rode `pnpm typecheck`** antes do push. CI roda; falhar lá rende um comentário "please fix".
---
## Commits & pull requests
- **Uma preocupação por PR.** Adicionar uma skill + refatorar o parser + bumpar uma dep são três PRs.
- **Título é imperativo + escopo.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`.
- **Corpo explica o porquê.** "O que isso faz" geralmente é óbvio do diff; "por que isso precisa existir" raramente é.
- **Referencie uma issue** se houver. Se não houver e o PR for não-trivial, abra uma antes para combinarmos que a mudança é desejada antes de você gastar o tempo.
- **Sem squash durante review.** Empurre fixups; squash no merge.
- **Sem force-push em branch compartilhado** a não ser que o reviewer tenha pedido.
Não exigimos CLA. A Apache-2.0 nos cobre; sua contribuição é licenciada nos mesmos termos.
---
## Reportando bugs
Abra uma issue com:
- O que você executou (a invocação `pnpm tools-dev ...` exata).
- Qual CLI de agente foi selecionado (ou se você estava no caminho BYOK).
- O par skill + design system que disparou.
- A **tail relevante de stderr do daemon** — a maior parte dos relatos "o artifact nunca renderizou" são diagnosticados em 30 segundos quando dá pra ver `spawn ENOENT` ou o erro real do CLI.
- Um screenshot se for UI.
Para bugs da pilha de prompt ("o agente emitiu um hero com gradiente roxo, a blacklist de slop deveria proibir isso"), inclua a **mensagem completa do assistente** para conseguirmos ver se a violação foi do modelo ou do prompt.
---
## Fazendo perguntas
- Pergunta de arquitetura, pergunta de design, "isso é bug ou mau uso" → [GitHub Discussions](https://github.com/nexu-io/open-design/discussions) (preferido — pesquisável para o próximo).
- "Como escrevo uma skill que faz X" → Abra uma discussion. Respondemos e transformamos a resposta em [`docs/skills-protocol.md`](docs/skills-protocol.md) se for um padrão faltante.
---
## O que não aceitamos
Para manter o projeto focado, por favor não abra PRs que:
- **Embutam um runtime de modelo.** Toda a aposta do OD é "seu CLI existente já basta". Não despachamos `pi-ai`, chaves OpenAI nem loaders de modelo.
- **Reescrevam o frontend para fora da stack atual sem discussão prévia.** Next.js 16 App Router + React 18 + TS é a linha. Sem Astro, Solid, Svelte ou outras reescritas de framework a menos que mantenedores explicitamente queiram essa migração.
- **Substituam o daemon por uma função serverless.** O ponto inteiro do daemon é ter um `cwd` real e spawnar um CLI real. Deploy do SPA na Vercel está ok; o daemon continua daemon.
- **Adicionem telemetry / analytics / phone-home.** O OD é local-first. As únicas chamadas de saída são para providers que o usuário configurou explicitamente.
- **Empacotem um binário** sem arquivo de licença e atribuição de autoria ao lado.
Se não tem certeza se sua ideia se encaixa, abra uma discussion antes de escrever o código.
---
## Licença
Ao contribuir, você concorda que sua contribuição é licenciada sob a [Licença Apache-2.0](LICENSE) deste repositório, com a exceção dos arquivos dentro de [`skills/guizang-ppt/`](skills/guizang-ppt/), que mantêm sua licença MIT original e atribuição de autoria a [op7418](https://github.com/op7418).
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
[guizang]: https://github.com/op7418/guizang-ppt-skill
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ocod]: https://github.com/OpenCoworkAI/open-codesign

288
CONTRIBUTING.zh-CN.md Normal file
View File

@@ -0,0 +1,288 @@
# 贡献指南 · Contributing to Open Design
谢谢你愿意参与。OD 是有意做小的 —— 大部分价值在 **文件**skill、design system、提示词片段而不是框架代码。这意味着收益最高的贡献往往就是一个文件夹、一份 Markdown或者一个 PR 大小的 adapter。
这份指南会告诉你:每种贡献该往哪里看、合并之前 PR 需要过哪些线。
<p align="center"><a href="CONTRIBUTING.md">English</a> · <a href="CONTRIBUTING.pt-BR.md">Português (Brasil)</a> · <a href="CONTRIBUTING.de.md">Deutsch</a> · <a href="CONTRIBUTING.fr.md">Français</a> · <b>简体中文</b> · <a href="CONTRIBUTING.ja-JP.md">日本語</a></p>
---
## 一个下午就能交付的三件事
| 你想要…… | 你其实在加的是 | 它住在哪 | 体量 |
|---|---|---|---|
| 让 OD 渲染一种新的 artifact一份发票、一个 iOS 设置页、一张 one-pager…… | 一个 **Skill** | [`skills/<your-skill>/`](skills/) | 一个文件夹,约 2 个文件 |
| 让 OD 说一种新品牌的视觉语言 | 一套 **Design System** | [`design-systems/<brand>/DESIGN.md`](design-systems/) | 一个 Markdown 文件 |
| 接入一个新的 coding-agent CLI | 一个 **Agent adapter** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | 一个数组里 ~10 行 |
| 加功能、修 bug、从 [`open-codesign`][ocod] 移植一个 UX 模式 | 代码 | `apps/web/src/``apps/daemon/` | 普通 PR |
| 改文档、补法语 / 德语 / 中文翻译、修错别字 | 文档 | `README.md``README.fr.md``README.de.md``README.zh-CN.md``docs/``QUICKSTART.md` | 一个 PR |
不确定自己想做的属于哪一桶?[先开 issue / discussion](https://github.com/nexu-io/open-design/issues/new),我们告诉你该改哪个面。
---
## 本地起跑
完整的一页式 setup 在 [`QUICKSTART.md`](QUICKSTART.md)。给贡献者的 TL;DR
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable # 使用 packageManager 固定的 pnpm
pnpm install
pnpm tools-dev run web # daemon + web 前台闭环
pnpm typecheck # tsc -b --noEmit
pnpm --filter @open-design/web build # 需要时构建 web package
```
要求 Node `~24` 和 pnpm `10.33.x``nvm` / `fnm` 是可选路径;如果你习惯用它们,先执行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`。macOS、Linux、WSL2 是主要路径。Windows 原生应该能跑但不是主要目标 —— 跑不起来请开 issue。
**开发 OD 本身不需要在 `PATH` 上装任何 agent CLI** —— daemon 会告诉你「找不到 agent」并落到 **Anthropic API · BYOK** 路径,反而是最快的开发循环。
---
## 加一个 Skill
一个 skill 就是 [`skills/`](skills/) 下的一个文件夹,根目录放一个 `SKILL.md`,遵循 Claude Code 的 [`SKILL.md` 规范][skill],再加上我们可选的 `od:` 扩展。**没有注册步骤。** 文件夹丢进来、重启 daemon、picker 里就出现了。
### Skill 文件夹结构
```text
skills/your-skill/
├── SKILL.md # 必须
├── assets/template.html # 可选但强烈推荐 —— seed 模板
├── references/ # 可选 —— agent 在规划阶段会读的知识文件
│ ├── layouts.md
│ ├── components.md
│ └── checklist.md
└── example.html # 强烈推荐 —— 一份手搓的真实样例
```
### `SKILL.md` 的 frontmatter
前三个字段是 Claude Code 的基础规范 —— `name``description``triggers``od:` 下面所有字段都是 OD 特有的、可选的,但 **`od.mode`** 决定 skill 出现在哪一组Prototype / Deck / Template / Design system
```yaml
---
name: your-skill
description: |
一段电梯演讲。Agent 会原样读这段来判断用户的需求是否匹配。
写具体一点surface、受众、artifact 里有什么、没有什么。
triggers:
- "your trigger phrase"
- "another phrase"
- "中文触发词"
od:
mode: prototype # prototype | deck | template | design-system
platform: desktop # desktop | mobile
scenario: marketing # 自由 tag用来分组
featured: 1 # 任何正整数都会让它出现在「Showcase examples」
preview:
type: html # html | jsx | pptx | markdown
entry: index.html
design_system:
requires: true # 这个 skill 是否会读激活的 DESIGN.md
sections: [color, typography, layout, components]
example_prompt: "一段可复制粘贴的提示词,最能体现这个 skill 的能力。"
---
# Your Skill
正文是自由 Markdown描述 agent 应该走的工作流……
```
完整 grammar —— 类型化输入、滑块参数、能力 gating —— 在 [`docs/skills-protocol.md`](docs/skills-protocol.md)。
### 合并新 skill 的硬线
Skill 是用户直接看到的面,所以我们对它挑剔。一个新 skill 必须:
1. **附一份真实的 `example.html`。** 手搓的、本地直接打开就能看、像设计师真的会交付的东西。不要 lorem ipsum不要 `<svg><rect/></svg>` 占位 hero。如果你自己都不能搓出 example这个 skill 大概率还没准备好。
2. **过 anti-AI-slop checklist**(写在 body 里)。不准紫色渐变、不准通用 emoji 图标、不准左 border 圆角卡片、不准把 Inter 当 *display* 字体、不准自编数据。完整黑名单看 README 的「Anti-AI-slop machinery」一节。
3. **诚实占位。** Agent 没真数字时写 `—` 或一个标注的灰块,绝不写「快 10 倍」。
4. **附 `references/checklist.md`**,至少要有 P0 关卡agent emit `<artifact>` 之前必须过的硬线)。格式照搬 [`skills/guizang-ppt/references/checklist.md`](skills/guizang-ppt/) 或 [`skills/dating-web/references/checklist.md`](skills/dating-web/)。
5. **如果是 featured skill加一张截图**`docs/screenshots/skills/<skill>.png`。PNG 格式,约 1024×640 retina从真实 `example.html` 上以缩小后的浏览器倍率截。
6. **是一个自包含文件夹。** CDN 引入不能超过其他 skill 已经引入的;不准用没授权的字体;图片不要超过约 250 KB。
如果你 fork 了一个现有 skill比如从 `dating-web` 改成 `recruiting-web`),保留原 LICENSE 和原作者归属在 `references/` 里,并在 PR 描述里点出来。
### 已有的 skill —— 挑一个像的来抄
- 视觉 showcase、单屏原型[`skills/dating-web/`](skills/dating-web/)、[`skills/digital-eguide/`](skills/digital-eguide/)
- 多屏移动流程:[`skills/mobile-onboarding/`](skills/mobile-onboarding/)、[`skills/gamified-app/`](skills/gamified-app/)
- 文档 / 模板(不需要 design system[`skills/pm-spec/`](skills/pm-spec/)、[`skills/weekly-update/`](skills/weekly-update/)
- Deck 模式:[`skills/guizang-ppt/`](skills/guizang-ppt/)(来自 [op7418/guizang-ppt-skill][guizang],原样捆绑)和 [`skills/simple-deck/`](skills/simple-deck/)
---
## 加一套 Design System
一套 design system 就是 `design-systems/<slug>/` 下的一个 [`DESIGN.md`](design-systems/README.md) 文件。**一个文件,零代码。** 丢进来、重启 daemon、picker 按 category 分组显示出来。
### Design system 文件夹结构
```text
design-systems/your-brand/
└── DESIGN.md
```
### `DESIGN.md` 形态
```markdown
# Design System Inspired by YourBrand
> Category: Developer Tools
> 一行总结,会显示在 picker 的预览里。
## 1. Visual Theme & Atmosphere
## 2. Color
- Primary: `#hex` / `oklch(...)`
-
## 3. Typography
## 4. Spacing & Grid
## 5. Layout & Composition
## 6. Components
## 7. Motion & Interaction
## 8. Voice & Brand
## 9. Anti-patterns
```
9 段式 schema 是固定的 —— skill body 会按这个结构 grep 内容。第一行 H1 会成为 picker 的标签(`Design System Inspired by` 前缀会被自动剥掉),`> Category: …` 那一行决定它落到哪个组。已有的 category 列表在 [`design-systems/README.md`](design-systems/README.md);如果你的品牌真的塞不进任何一个,可以新增 category但**优先尝试现有 category**。
### 合并新 design system 的硬线
1. **9 个 section 都要在。** Section 内容空着可以(比如真的找不到 motion token但标题必须保留否则提示词的 grep 会断。
2. **Hex 是真的。** 直接从品牌官网或产品里取色,不准从记忆里掏,不准让 AI 猜。README 里那套 5 步「品牌资产协议」对维护者一样适用。
3. **强调色给 OKLch 是加分项。** 让色板在亮 / 暗模式之间能可预测地 lerp。
4. **不要营销废话。** 品牌的 tagline 不是设计 token。删掉。
5. **slug 用 ASCII** —— `linear.app` 写成 `linear-app``x.ai` 写成 `x-ai`。已经导入的 69 套都遵循这个约定,跟着写。
我们内置的 69 套产品系统是通过 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 从 [`VoltAgent/awesome-design-md`][acd2] 导入的。如果你的品牌应该归属在上游,**请先把 PR 发到那里** —— 我们下一次同步会自动收上来。`design-systems/` 文件夹用来放那些**不适合归到上游**的系统、加上我们手写的两套 starter。
---
## 接入一个新的 coding-agent CLI
接入一个新 agent比如某个新 shop 的 `foo-coder` CLI就是在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一项:
```javascript
{
id: 'foo',
name: 'Foo Coder',
bin: 'foo',
versionArgs: ['--version'],
buildArgs: (prompt) => ['exec', '-p', prompt],
streamFormat: 'plain', // 如果它说 claude-stream-json 就写那个
}
```
完事 —— daemon 会在 `PATH` 上检测到它、picker 显示出来、对话路径就通了。如果这个 CLI 吐 **类型化事件**(像 Claude Code 的 `--output-format stream-json`),在 [`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts) 里写一个 parser并把 `streamFormat` 设成 `'claude-stream-json'`
合并硬线:
1. **真的跑通一次端到端会话** —— 把 daemon 日志贴在 PR 描述里,证明它流出了一个 artifact。
2. **更新 [`docs/agent-adapters.md`](docs/agent-adapters.md)**,写清楚这个 CLI 的怪癖(要不要 key 文件?支不支持图片输入?非交互模式的 flag 是什么?)。
3. **README 的「Supported coding agents」表里加一行**
---
## 更新模型 `max_tokens` 元数据
API 模式下每次请求都会带 `max_tokens` 给上游。Web 端通过 [`apps/web/src/state/maxTokens.ts`](apps/web/src/state/maxTokens.ts) 的三层 lookup 决定这个数字:
1. 用户在 Settings 里手填的覆盖值(如果有)。
2. 否则用 [`apps/web/src/state/litellm-models.json`](apps/web/src/state/litellm-models.json) 里的 per-model 默认 —— 这是从 [BerriAI/litellm][litellm] 的 `model_prices_and_context_window.json`MIT摘的一份切片覆盖约 2000 个 chat 模型,包括 Anthropic、OpenAI、DeepSeek、Groq、Together、Mistral、Gemini、Bedrock、Vertex、OpenRouter 等。
3. 都 miss 就走 `FALLBACK_MAX_TOKENS = 8192`
新模型上线想吃到默认值,重新生成 vendored JSON
```bash
node --experimental-strip-types scripts/sync-litellm-models.ts
```
脚本会拉 LiteLLM 的最新 catalog、过滤 `mode: 'chat'`、把每条投影到 `max_output_tokens`(缺失时 fallback 到 `max_tokens`),写成排好序的快照。把重新生成的 `litellm-models.json` 跟着触发它的 PR 一起提。
`maxTokens.ts` 里的 OVERRIDES 表只用于 LiteLLM 没收 / 收错的 model id —— 比如 `mimo-v2.5-pro`LiteLLM 只收了 `openrouter/xiaomi/...``novita/xiaomimimo/...` 两个 aliasmodel id 跟小米直接 API 用的不一样)。表要保持小:凡是 LiteLLM 已经对的,**不要**抄进来。
[litellm]: https://github.com/BerriAI/litellm
---
## 代码风格
格式我们不抠(保存时跑 Prettier 就行),但有两条不能让 —— 因为它们出现在提示词栈和用户可见的 API 里:
1. **JS/TS 用单引号。** 字符串一律单引号,除非转义太丑。代码库已经是一致的,请保持一致。
2. **代码注释用英文。** 即使 PR 是把某段翻译成中文,代码注释也保留英文,这样我们能维护一份可 grep 的引用集。
除此之外:
- **不要写废话注释。** 不要 `// 引入这个模块`、不要 `// 遍历元素`。如果代码本身一眼能读,注释就是噪音。注释只用来说明非显而易见的意图、或者代码本身表达不出来的约束。
- **`apps/web/src/` 用 TypeScript。** Daemon (`apps/daemon/`) 是纯 ESM JavaScript类型重要的地方用 JSDoc —— 保持这样。
- **不要随便加顶层依赖。** PR 描述里至少要有一段,说明引入它能换到什么、又新增了多少 bundle 字节。[`package.json`](package.json) 的依赖少是有意为之。
- **推之前跑 `pnpm typecheck`。** CI 会跑;挂了会换来一句「请修一下」。
---
## Commit 与 PR
- **一个 PR 只做一件事。** 加 skill + 重构 parser + 升依赖,是三个 PR。
- **标题用动词起头 + 范围。** `add dating-web skill``fix daemon SSE backpressure when CLI hangs``docs: clarify .od layout`
- **正文解释 why。** 「这个 PR 改了什么」从 diff 一般能看出来;「为什么要改」很少能。
- **如果有 issue引用它。** 没有、且改动非平凡,请先开 issue 让我们先就「值不值得做」达成一致,再投入时间。
- **Review 期间不要 squash。** 推 fixup commitmerge 时我们会 squash。
- **不要 force-push 共享分支**,除非 reviewer 主动让你这么做。
我们不强制 CLA。Apache-2.0 已经覆盖;你的贡献按同样的 license 授权。
---
## 报 bug
开 issue 时请带上:
- 你跑的命令(精确到 `pnpm tools-dev ...`)。
- 选中的 agent CLI 是哪个(或者你走的是 BYOK 路径)。
- 触发问题时的 skill + design system 组合。
- 相关的 **daemon stderr 末尾几行** —— 大多数「artifact 没渲染出来」的报告,看到 `spawn ENOENT` 或 CLI 实际报错后 30 秒就能定位。
- UI 问题贴一张截图。
提示词栈相关的 bug「agent 吐了一个紫色渐变 heroslop 黑名单不是禁了吗」),请贴 **完整的助手消息**,方便我们判断违规来自模型还是提示词。
---
## 提问
- 架构问题、设计问题、「这是 bug 还是误用」 → 请用 [GitHub Discussions](https://github.com/nexu-io/open-design/discussions)(首选 —— 下一个人能搜到)。
- 「我想写一个干 X 的 skill 怎么写」 → 开一个 discussion。我们会回答且如果是缺失的模式答案会被收进 [`docs/skills-protocol.md`](docs/skills-protocol.md)。
---
## 我们不接收的 PR
为了保持项目聚焦,请不要发以下类型的 PR
- **Vendor 一个模型运行时。** OD 整个赌注就是「你已有的 CLI 就够了」。我们不带 `pi-ai`、不带 OpenAI key、不带模型加载器。
- **未经讨论不要把前端重写到别的栈。** Next.js 16 App Router + React 18 + TS 是当前底线。不要随手改成 Astro / Solid / Svelte 或其他框架。
- **把 daemon 换成 serverless function。** Daemon 的存在意义就是拥有真实的 `cwd` 和 spawn 真实的 CLI。SPA 部署 Vercel 没问题daemon 仍然是 daemon。
- **加 telemetry / 分析 / phone-home。** OD 是 local-first。唯一的对外请求是用户明确配置的 provider。
- **打包二进制** 而没有附 license 文件和原作者归属。
不确定自己的想法合不合适?开个 discussion 再写代码。
---
## License
提交贡献即代表你同意你的贡献按本仓库的 [Apache-2.0 License](LICENSE) 授权。例外是 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下的所有文件,保留它们原始的 MIT license 和原作者 [op7418](https://github.com/op7418) 的归属。
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
[guizang]: https://github.com/op7418/guizang-ppt-skill
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ocod]: https://github.com/OpenCoworkAI/open-codesign

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a
fee for, acceptance of support, warranty, indemnity, or other
liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may act only on Your
own behalf and on Your sole responsibility, not on behalf of any
other Contributor, and only if You agree to indemnify, defend,
and hold each Contributor harmless for any liability incurred by,
or claims asserted against, such Contributor by reason of your
accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Open Design contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

230
QUICKSTART.de.md Normal file
View File

@@ -0,0 +1,230 @@
# Schnellstart
<p align="center"><a href="QUICKSTART.md">English</a> · <a href="QUICKSTART.pt-BR.md">Português (Brasil)</a> · <b>Deutsch</b> · <a href="QUICKSTART.fr.md">Français</a> · <a href="QUICKSTART.ja-JP.md">日本語</a> · <a href="QUICKSTART.zh-CN.md">简体中文</a></p>
Führen Sie das vollständige Produkt lokal aus.
## Umgebungsanforderungen
- **Node.js:** `~24` (Node 24.x). Das Repository erzwingt dies über `package.json#engines`.
- **pnpm:** `10.33.x`. Das Repository pinnt `pnpm@10.33.2` über `packageManager`; verwenden Sie Corepack, damit automatisch die gepinnte Version gewählt wird.
- **OS:** macOS, Linux und WSL2 sind die primären Pfade. Windows nativ sollte für die meisten Abläufe funktionieren, WSL2 ist aber die sicherere Basis.
- **Optionale lokale Agent-CLI:** Claude Code, Codex, Gemini CLI, OpenCode, Cursor Agent, Qwen, GitHub Copilot CLI usw. Wenn keine installiert ist, verwenden Sie den BYOK-API-Modus in den Einstellungen.
`nvm` / `fnm` sind optionale Komfortwerkzeuge, keine Voraussetzung für das Projektsetup. Wenn Sie eines davon verwenden, installieren/selektieren Sie Node 24 vor pnpm:
```bash
# nvm
nvm install 24
nvm use 24
# fnm
fnm install 24
fnm use 24
```
Aktivieren Sie dann Corepack und lassen Sie das Repository pnpm auswählen:
```bash
corepack enable
corepack pnpm --version # sollte 10.33.2 ausgeben
```
## One-shot (Dev-Modus)
```bash
corepack enable
pnpm install
pnpm tools-dev run web # startet daemon + web im Vordergrund
# öffnen Sie die von tools-dev ausgegebene Web-URL
```
Für die Desktop-Shell und alle verwalteten Sidecars im Hintergrund:
```bash
pnpm tools-dev # startet daemon + web + desktop im Hintergrund
```
Beim ersten Laden erkennt die App Ihre installierte Code-Agent-CLI (Claude Code / Codex / Gemini / OpenCode / Cursor Agent / Qwen), wählt sie automatisch und nutzt standardmäßig den `web-prototype` Skill sowie das `Neutral Modern` Design System. Geben Sie einen Prompt ein und klicken Sie auf **Senden**. Der Agent streamt in den linken Bereich; das `<artifact>` Tag wird herausgeparst und das HTML rechts live gerendert. Nach Abschluss können Sie das Artifact mit **Auf Datenträger speichern** unter `./.od/artifacts/<timestamp>-<slug>/index.html` speichern.
Das Dropdown **Designsystem** enthält 71 integrierte Systeme: 2 handgeschriebene Starter (Neutral Modern, Warm Editorial) und 69 Produktsysteme, importiert aus [`awesome-design-md`](https://github.com/VoltAgent/awesome-design-md), gruppiert nach Kategorie (AI & LLM, Developer Tools, Productivity, Backend, Design Tools, Fintech, E-Commerce, Media, Automotive). Wählen Sie eines aus, um jeden Prototyp in der Ästhetik dieser Marke zu gestalten.
Das Dropdown **Skill** gruppiert nach Modus (Prototyp / Deck / Template / Designsystem) und zeigt den Default-Skill pro Modus mit dem Suffix `· default`. Gebündelte Skills:
- **Prototype** — `web-prototype` (generisch), `saas-landing`, `dashboard`, `pricing-page`, `docs-page`, `blog-post`, `mobile-app`.
- **Deck / PPT** — `simple-deck` (single-file horizontal swipe) und `magazine-web-ppt` (das `guizang-ppt` Bundle aus [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) — default für deck mode, bringt eigene Assets/Template + 4 References mit). Skills mit Side Files bekommen automatisch eine "Skill root (absolute)" Präambel, damit der Agent `assets/template.html` und `references/*.md` gegen den echten Pfad auf der Festplatte auflösen kann statt gegen sein CWD.
Kombinieren Sie Skill, Design System und einen einzelnen Prompt, und Sie erhalten einen layoutpassenden Prototyp oder ein Deck in der gewählten visuellen Sprache.
## Weitere Skripte
```bash
pnpm tools-dev # daemon + web + desktop im Hintergrund
pnpm tools-dev start web # daemon + web im Hintergrund
pnpm tools-dev run web # daemon + web im Vordergrund (e2e/dev server)
pnpm tools-dev restart # daemon + web + desktop neu starten
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
pnpm tools-dev status # verwaltete Runtimes prüfen
pnpm tools-dev logs # daemon/web/desktop logs anzeigen
pnpm tools-dev check # status + aktuelle logs + gängige Diagnosen
pnpm tools-dev stop # verwaltete Runtimes stoppen
pnpm --filter @open-design/daemon build # apps/daemon/dist/cli.js für `od` bauen
pnpm --filter @open-design/web build # Web-Paket bei Bedarf bauen
pnpm typecheck # Workspace-Typecheck
```
`pnpm tools-dev` ist der einzige lokale Lifecycle-Einstieg. Verwenden Sie nicht die entfernten Legacy-Root-Aliasse (`pnpm dev`, `pnpm dev:all`, `pnpm daemon`, `pnpm preview`, `pnpm start`).
Während lokaler Entwicklung startet `tools-dev` zuerst den daemon, übergibt dessen Port an `apps/web`, und `apps/web/next.config.ts` rewritet `/api/*`, `/artifacts/*` und `/frames/*` auf diesen daemon-Port. So kann die App-Router-App ohne CORS-Setup mit dem sibling Express-Prozess sprechen.
## Prüfungen für Mediengenerierung und Agent-Dispatcher
Image-, Video-, Audio- und HyperFrames-Skills rufen die lokale `od` CLI über Umgebungsvariablen auf, die der daemon beim Start eines Agent injiziert:
- `OD_BIN` — absoluter Pfad zu `apps/daemon/dist/cli.js`.
- `OD_DAEMON_URL` — die laufende daemon-URL.
- `OD_PROJECT_ID` — die aktive Projekt-ID.
- `OD_PROJECT_DIR` — das Dateiverzeichnis des aktiven Projekts.
Wenn Mediengenerierung mit `OD_BIN: parameter not set`, fehlendem `apps/daemon/dist/cli.js` oder `failed to reach daemon at http://127.0.0.1:0` fehlschlägt, bauen Sie die daemon-CLI neu und starten Sie die verwaltete Runtime neu:
```bash
pnpm --filter @open-design/daemon build
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
ls -la apps/daemon/dist/cli.js
curl -s http://127.0.0.1:7457/api/health
```
Öffnen Sie danach das Projekt erneut aus der Open Design App, statt eine alte Terminal-Agent-Session fortzusetzen. Ein vom daemon gestarteter Agent sollte Werte wie diese sehen:
```bash
echo "OD_BIN=$OD_BIN"
echo "OD_PROJECT_ID=$OD_PROJECT_ID"
echo "OD_PROJECT_DIR=$OD_PROJECT_DIR"
echo "OD_DAEMON_URL=$OD_DAEMON_URL"
ls -la "$OD_BIN"
```
`OD_DAEMON_URL` muss ein echter daemon-Port wie `http://127.0.0.1:7457` sein, nicht `http://127.0.0.1:0`. Der Wert `:0` ist nur ein interner Hinweis für "freien Port wählen" und darf nicht in Agent-Sessions gelangen.
Im daemon-only Production Mode serviert der daemon den statischen Next.js Export selbst unter `http://localhost:7456`; ein Reverse Proxy ist dafür nicht beteiligt.
Wenn Sie nginx vor den daemon setzen, halten Sie SSE-Routen ungepuffert und unkomprimiert. Ein häufiger Fehler ist, dass die Browser-Konsole nach 80-90 Sekunden `net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)` zeigt, weil nginx `gzip on` chunked SSE Antworten puffert, obwohl der daemon `X-Accel-Buffering: no` sendet.
```nginx
location /api/ {
proxy_pass http://127.0.0.1:7456;
proxy_buffering off;
gzip off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## Zwei Ausführungsmodi
| Modus | Picker-Wert | Ablauf einer Anfrage |
|---|---|---|
| **Local CLI** (Standard, wenn der daemon einen Agent erkennt) | "Local CLI" | Frontend → daemon `/api/chat``spawn(<agent>, ...)` → stdout → SSE → artifact parser → preview |
| **Anthropic API** (Fallback / keine CLI) | "Anthropic API · BYOK" | Frontend → `@anthropic-ai/sdk` direkt (`dangerouslyAllowBrowser`) → artifact parser → preview |
Beide Modi speisen denselben `<artifact>` Parser und denselben sandboxed iframe. Unterschiedlich sind nur Transport und System-Prompt-Auslieferung: lokale CLIs haben keinen separaten Systemkanal, daher wird der zusammengesetzte Prompt in die User Message gefaltet.
## Prompt-Zusammensetzung
Bei jedem Senden baut die App einen System Prompt aus drei Schichten und sendet ihn an den Provider:
```
BASE_SYSTEM_PROMPT (output contract: wrap in <artifact>, no code fences)
+ active design system body (DESIGN.md — palette/type/layout)
+ active skill body (SKILL.md — workflow and output rules)
```
Wechseln Sie Skill oder Designsystem in der oberen Leiste, nutzt die nächste Anfrage den neuen Stack. Bodies werden pro Session im Speicher gecacht; pro Auswahl ist also nur ein daemon fetch nötig.
## Dateistruktur
```
open-design/
├── apps/
│ ├── daemon/ # Node/Express — spawns local agents + serves APIs
│ │ └── src/
│ │ ├── cli.ts # `od` bin entry
│ │ ├── server.ts # /api/* + static serving
│ │ ├── agents.ts # PATH scanner for claude/codex/gemini/opencode/cursor-agent/qwen/copilot
│ │ ├── skills.ts # SKILL.md loader (frontmatter parser)
│ │ └── design-systems.ts # DESIGN.md loader
│ │ ├── sidecar/ # tools-dev daemon sidecar wrapper
│ │ └── tests/ # daemon package tests
│ ├── web/ # Next.js 16 App Router + React client
│ ├── app/ # App Router entrypoints
│ ├── src/ # React + TypeScript client/runtime modules
│ │ ├── App.tsx # orchestrates mode / skill / DS pickers + send
│ │ ├── providers/ # daemon + BYOK API transports
│ │ ├── prompts/ # system, discovery, directions, deck framework
│ │ ├── artifacts/ # streaming <artifact> parser + manifests
│ │ ├── runtime/ # iframe srcdoc, markdown, export helpers
│ │ └── state/ # localStorage + daemon-backed project state
│ ├── sidecar/ # tools-dev web sidecar wrapper
│ └── next.config.ts # tools-dev rewrites + prod apps/web/out export config
│ └── desktop/ # Electron runtime, launched/inspected by tools-dev
├── packages/
│ ├── contracts/ # shared web/daemon app contracts
│ ├── sidecar-proto/ # Open Design sidecar protocol contract
│ ├── sidecar/ # generic sidecar runtime primitives
│ └── platform/ # generic process/platform primitives
├── tools/dev/ # `pnpm tools-dev` lifecycle and inspect CLI
├── e2e/ # Playwright UI + external integration/Vitest harness
├── skills/ # SKILL.md — drops in from any Claude Code skill repo
│ ├── web-prototype/ # generic single-screen prototype (default for prototype mode)
│ ├── saas-landing/ # marketing page (hero / features / pricing / CTA)
│ ├── dashboard/ # admin / analytics dashboard
│ ├── pricing-page/ # standalone pricing + comparison
│ ├── docs-page/ # 3-column documentation layout
│ ├── blog-post/ # editorial long-form
│ ├── mobile-app/ # phone-frame single screen
│ ├── simple-deck/ # minimal horizontal-swipe deck
│ └── guizang-ppt/ # magazine-web-ppt — bundled deck/PPT default
│ ├── SKILL.md
│ ├── assets/template.html
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ # DESIGN.md — 9-section schema (awesome-claude-design)
│ ├── default/ # Neutral Modern (starter)
│ ├── warm-editorial/ # Warm Editorial (starter)
│ ├── README.md # catalog overview
│ └── …69 product systems # claude · cohere · linear-app · vercel · stripe · airbnb …
├── scripts/sync-design-systems.ts # re-import from upstream getdesign tarball
├── docs/ # product vision + spec
├── .od/ # runtime data (gitignored, auto-created)
│ ├── app.sqlite # projects / conversations / messages / tabs
│ ├── artifacts/ # one-off "Save to disk" renders
│ └── projects/<id>/ # per-project working dir + agent cwd
├── pnpm-workspace.yaml # apps/* + packages/* + tools/* + e2e
└── package.json # root quality scripts + `od` bin
```
## Fehlerbehebung
- **"no agents found on PATH"** — installieren Sie eine davon: `claude`, `codex`, `gemini`, `opencode`, `cursor-agent`, `qwen`, `copilot`. Alternativ wechseln Sie in der oberen Leiste zu "Anthropic API · BYOK" und fügen in **Einstellungen** einen Key ein.
- **daemon 500 on /api/chat** — prüfen Sie das daemon-Terminal und den stderr-Auszug; meist hat die CLI ihre Argumente abgelehnt. Unterschiedliche CLIs haben unterschiedliche argv-Formen; siehe `apps/daemon/src/agents.ts` `buildArgs`, falls Sie nachjustieren müssen.
- **media generation says `OD_BIN` is missing or daemon URL is `:0`** — führen Sie die Media Dispatcher Checks oben aus. Setzen Sie keine alte CLI-Session fort; öffnen Sie das Projekt aus der Open Design App neu, damit der daemon frische `OD_*` Variablen injiziert.
- **Codex lädt zu viel Plugin-Kontext** — starten Sie Open Design mit `OD_CODEX_DISABLE_PLUGINS=1 pnpm tools-dev`, damit vom daemon gestartete Codex-Prozesse mit `--disable plugins` laufen.
- **artifact never renders** — das Modell hat Text ohne `<artifact>` Wrapper erzeugt. Prüfen Sie, ob der System Prompt ankommt (daemon log), und wechseln Sie ggf. zu einem stärkeren Modell oder strengeren Skill.
## Bezug zur Vision
Dieser Schnellstart ist der lauffähige Einstieg zur Spec in [`docs/`](docs/). Die Spec beschreibt, wohin das Projekt wächst (siehe [`docs/roadmap.md`](docs/roadmap.md)). Highlights:
- `docs/architecture.md` beschreibt den ausgelieferten Stack: Next.js 16 App Router vorne, lokaler daemon dahinter und `apps/web/next.config.ts` Rewrites in dev, damit der Browser mit derselben `/api` Oberfläche spricht.
- `docs/skills-protocol.md` beschreibt das vollständige `od:` Frontmatter (typed inputs, sliders, capability gating). Dieses MVP liest nur `name` / `description` / `triggers` / `od.mode` / `od.design_system.requires`; erweitern Sie [`apps/daemon/src/skills.ts`](apps/daemon/src/skills.ts), um den Rest hinzuzufügen.
- `docs/agent-adapters.md` sieht reicheren Dispatch vor (capability detection, streaming tool-calls). Unser `apps/daemon/src/agents.ts` ist ein minimaler Dispatcher: genug, um die Verdrahtung zu beweisen.
- `docs/modes.md` listet vier Modi: prototype / deck / template / design-system. Wir liefern Skills für die ersten beiden; der Picker filtert bereits nach `mode`.

231
QUICKSTART.fr.md Normal file
View File

@@ -0,0 +1,231 @@
# Quickstart
<p align="center"><a href="QUICKSTART.md">English</a> · <a href="QUICKSTART.pt-BR.md">Português (Brasil)</a> · <a href="QUICKSTART.de.md">Deutsch</a> · <b>Français</b> · <a href="QUICKSTART.ja-JP.md">日本語</a> · <a href="QUICKSTART.zh-CN.md">简体中文</a></p>
Exécutez le produit complet localement.
## Prérequis
- **Node.js :** `~24` (Node 24.x). Le repo limpose via `package.json#engines`.
- **pnpm :** `10.33.x`. Le repo fixe `pnpm@10.33.2` via `packageManager` ; utilisez Corepack pour que la bonne version soit sélectionnée automatiquement.
- **OS :** macOS, Linux et WSL2 sont les environnements principaux pris en charge. Windows natif devrait fonctionner pour la plupart des workflows, mais WSL2 reste loption la plus fiable.
- **CLI dagent locale optionnelle :** Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, GitHub Copilot CLI, etc. Si aucune nest installée, utilisez le mode BYOK API depuis Settings.
`nvm` / `fnm` sont des outils de confort optionnels, pas une étape obligatoire de la configuration du projet. Si vous en utilisez un, installez/sélectionnez Node 24 avant de lancer pnpm :
```bash
# nvm
nvm install 24
nvm use 24
# fnm
fnm install 24
fnm use 24
```
Activez ensuite Corepack et laissez le repo sélectionner pnpm :
```bash
corepack enable
corepack pnpm --version # doit afficher 10.33.2
```
## Démarrage rapide (mode dev)
```bash
corepack enable
pnpm install
pnpm tools-dev run web # démarre daemon + web au premier plan
# ouvrez lURL web affichée par tools-dev
```
Pour le shell desktop et tous les sidecars gérés en arrière-plan :
```bash
pnpm tools-dev # démarre daemon + web + desktop en arrière-plan
```
Au premier chargement, lapp détecte votre CLI de coding agent installée (Claude Code / Codex / Devin for Terminal / Gemini / OpenCode / Cursor Agent / Qwen), la sélectionne automatiquement, puis utilise par défaut le Skill `web-prototype` et le Design System `Neutral Modern`. Tapez un prompt et cliquez sur **Send**. Les sorties de lagent saffichent en streaming dans le panneau gauche ; la balise `<artifact>` est extraite et le HTML saffiche en direct à droite. Une fois la génération terminée, cliquez sur **Save to disk** pour enregistrer lartifact sous `./.od/artifacts/<timestamp>-<slug>/index.html`.
Le menu déroulant **Design System** charge les Design Systems depuis `design-systems/*/DESIGN.md` : starters écrits à la main, product systems intégrés et design skills normalisés. Choisissez-en un pour habiller chaque prototype dans lesthétique de cette marque.
Le menu déroulant **Skill** regroupe les entrées par `mode` / `surface` et affiche le Skill par défaut de chaque mode avec un suffixe `· default`. Le catalogue live vient de [`skills/`](skills/) et couvre les workflows web, deck, Design System, image, vidéo et audio. Exemples inclus :
- **Prototype** — `web-prototype` (générique), `saas-landing`, `dashboard`, `pricing-page`, `docs-page`, `blog-post`, `mobile-app`.
- **Deck / PPT** — `simple-deck` (swipe horizontal single-file) et `magazine-web-ppt` (le bundle `guizang-ppt` depuis [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill), par défaut en mode deck, avec ses propres assets/template + 4 références). Les Skills avec side files reçoivent automatiquement un préambule "Skill root (absolute)" pour que lagent puisse résoudre `assets/template.html` et `references/*.md` depuis le vrai chemin disque au lieu de son CWD.
- **Médias et Design System** — par exemple `image-poster`, `video-shortform`, `audio-jingle`, `hyperframes` et `design-brief`.
Associez un Skill, un Design System et un seul prompt : vous obtenez un prototype, un deck ou un rendu adapté au mode / à la surface choisie.
## Autres scripts
```bash
pnpm tools-dev # daemon + web + desktop en arrière-plan
pnpm tools-dev start web # daemon + web en arrière-plan
pnpm tools-dev run web # daemon + web au premier plan (e2e/dev server)
pnpm tools-dev restart # redémarre daemon + web + desktop
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
pnpm tools-dev status # inspecte les runtimes gérés
pnpm tools-dev logs # affiche les logs daemon/web/desktop
pnpm tools-dev check # statut + logs récents + diagnostics courants
pnpm tools-dev stop # arrête les runtimes gérés
pnpm --filter @open-design/daemon build # build apps/daemon/dist/cli.js pour `od`
pnpm --filter @open-design/web build # build du paquet web si nécessaire
pnpm typecheck # typecheck du workspace
```
`pnpm tools-dev` est le seul point dentrée du lifecycle local. Nutilisez pas les anciens alias root supprimés (`pnpm dev`, `pnpm dev:all`, `pnpm daemon`, `pnpm preview`, `pnpm start`).
Pendant le développement local, `tools-dev` démarre dabord le daemon, transmet son port à `apps/web`, puis `apps/web/next.config.ts` réécrit `/api/*`, `/artifacts/*` et `/frames/*` vers ce port daemon. Lapp App Router peut ainsi parler au processus Express voisin sans configuration CORS.
## Checks de génération média / agent dispatcher
Les Skills image, vidéo, audio et HyperFrames appellent la CLI locale `od` via des variables denvironnement injectées par le daemon lorsquil lance un agent :
- `OD_BIN` — chemin absolu vers `apps/daemon/dist/cli.js`.
- `OD_DAEMON_URL` — URL du daemon en cours dexécution.
- `OD_PROJECT_ID` — id du projet actif.
- `OD_PROJECT_DIR` — dossier de fichiers du projet actif.
Si la génération média échoue avec `OD_BIN: parameter not set`, `apps/daemon/dist/cli.js` manquant, ou `failed to reach daemon at http://127.0.0.1:0`, rebuildez la CLI daemon et redémarrez le runtime géré :
```bash
pnpm --filter @open-design/daemon build
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
ls -la apps/daemon/dist/cli.js
curl -s http://127.0.0.1:7457/api/health
```
Ouvrez ensuite de nouveau le projet depuis lapp Open Design au lieu de reprendre une ancienne session agent dans le terminal. Un agent lancé par le daemon devrait voir des valeurs comme :
```bash
echo "OD_BIN=$OD_BIN"
echo "OD_PROJECT_ID=$OD_PROJECT_ID"
echo "OD_PROJECT_DIR=$OD_PROJECT_DIR"
echo "OD_DAEMON_URL=$OD_DAEMON_URL"
ls -la "$OD_BIN"
```
`OD_DAEMON_URL` doit être un vrai port daemon comme `http://127.0.0.1:7457`, pas `http://127.0.0.1:0`. La valeur `:0` est seulement une indication interne "choisir un port libre" au lancement et ne doit pas se retrouver dans les sessions agent.
En mode production daemon-only, le daemon sert lui-même lexport static Next.js à `http://localhost:7456`; aucun reverse proxy nest impliqué.
Si vous placez nginx devant le daemon, gardez les routes SSE non bufferisées et non compressées. Un échec courant : la console navigateur affiche `net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)` après 80-90 secondes, parce que `gzip on` dans nginx bufferise les réponses SSE chunked même quand le daemon envoie `X-Accel-Buffering: no`.
```nginx
location /api/ {
proxy_pass http://127.0.0.1:7456;
proxy_buffering off;
gzip off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## Deux modes dexécution
| Mode | Valeur du picker | Flux dune requête |
|---|---|---|
| **Local CLI** (par défaut quand le daemon détecte un agent) | "Local CLI" | Frontend → daemon `/api/chat``spawn(<agent>, ...)` → stdout → SSE → parser `<artifact>` → preview |
| **Mode API** (fallback / aucune CLI) | "Anthropic API" / "OpenAI API" / "Azure OpenAI" / "Google Gemini" | Frontend → daemon `/api/proxy/{provider}/stream` → SSE provider normalisé en `delta/end/error` → parser `<artifact>` → preview |
Les deux modes alimentent le **même** parser `<artifact>` et la **même** iframe sandboxée. Seuls le transport et la livraison du system prompt changent : les CLI locales nont pas de canal système séparé, donc le prompt composé est intégré au message utilisateur.
## Composition du prompt
À chaque envoi, lapp construit un system prompt à partir de trois couches et lenvoie au provider :
```
BASE_SYSTEM_PROMPT (contrat de sortie : wrap in <artifact>, no code fences)
+ active design system body (DESIGN.md — palette/type/layout)
+ active skill body (SKILL.md — workflow and output rules)
```
Changez le Skill ou le Design System dans la barre supérieure : le prochain envoi utilise le nouveau stack. Les contenus sont mis en cache en mémoire par session, donc un choix ne coûte quun fetch daemon.
## File map
```
open-design/
├── apps/
│ ├── daemon/ # Node/Express — spawn les agents locaux + sert les APIs
│ │ └── src/
│ │ ├── cli.ts # entrée bin `od`
│ │ ├── server.ts # /api/* + static serving
│ │ ├── agents.ts # scanner PATH + adapters CLI de coding agents
│ │ ├── skills.ts # loader SKILL.md (frontmatter parser)
│ │ └── design-systems.ts # loader DESIGN.md
│ │ ├── sidecar/ # wrapper sidecar daemon pour tools-dev
│ │ └── tests/ # tests du package daemon
│ ├── web/ # Next.js 16 App Router + client React
│ ├── app/ # entrypoints App Router
│ ├── src/ # modules client/runtime React + TypeScript
│ │ ├── App.tsx # orchestre mode / skill / DS pickers + send
│ │ ├── providers/ # transports daemon + BYOK API
│ │ ├── prompts/ # system, discovery, directions, deck framework
│ │ ├── artifacts/ # parser <artifact> streaming + manifests
│ │ ├── runtime/ # iframe srcdoc, markdown, helpers dexport
│ │ └── state/ # localStorage + état projet persisté par le daemon
│ ├── sidecar/ # wrapper sidecar web pour tools-dev
│ └── next.config.ts # rewrites tools-dev + config export prod apps/web/out
│ └── desktop/ # runtime Electron, lancé/inspecté par tools-dev
├── packages/
│ ├── contracts/ # contrats app partagés web/daemon
│ ├── sidecar-proto/ # contrat du protocole sidecar Open Design
│ ├── sidecar/ # primitives runtime sidecar génériques
│ └── platform/ # primitives process/platform génériques
├── tools/dev/ # lifecycle `pnpm tools-dev` et inspect CLI
├── e2e/ # UI Playwright + harness intégration externe/Vitest
├── skills/ # SKILL.md — drop-in depuis nimporte quel repo Claude Code skill
│ ├── web-prototype/ # prototype single-screen générique (défaut du mode prototype)
│ ├── saas-landing/ # page marketing (hero / features / pricing / CTA)
│ ├── dashboard/ # dashboard admin / analytics
│ ├── pricing-page/ # pricing autonome + comparaison
│ ├── docs-page/ # layout documentation 3 colonnes
│ ├── blog-post/ # long-form éditorial
│ ├── mobile-app/ # écran unique dans phone frame
│ ├── simple-deck/ # deck minimal à swipe horizontal
│ └── guizang-ppt/ # magazine-web-ppt — deck/PPT par défaut inclus
│ ├── SKILL.md
│ ├── assets/template.html
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ # DESIGN.md — schéma 9 sections (awesome-claude-design)
│ ├── default/ # Neutral Modern (starter)
│ ├── warm-editorial/ # Warm Editorial (starter)
│ ├── README.md # aperçu du catalogue
│ └── …systems # starters · product systems · design skills normalisés
├── scripts/sync-design-systems.ts # réimport depuis le tarball getdesign upstream
├── docs/ # vision produit + spec
├── .od/ # données runtime (gitignored, auto-créées)
│ ├── app.sqlite # projects / conversations / messages / tabs
│ ├── artifacts/ # rendus ponctuels "Save to disk"
│ └── projects/<id>/ # dossier de travail par projet + cwd de lagent
├── pnpm-workspace.yaml # apps/* + packages/* + tools/* + e2e
└── package.json # scripts qualité root + bin `od`
```
## Dépannage
- **"no agents found on PATH"** — installez une CLI compatible, par exemple `claude`, `codex`, `gemini`, `opencode`, `cursor-agent`, `qwen` ou `copilot`. La liste exacte des adapters détectés vit dans `apps/daemon/src/agents.ts`. Ou passez au mode API/BYOK dans la barre supérieure et collez une clé dans **Settings**.
- **daemon 500 sur /api/chat** — vérifiez la fin de stderr dans le terminal daemon ; la CLI a généralement rejeté ses args. Les CLIs nacceptent pas toutes la même forme dargv ; consultez `apps/daemon/src/agents.ts` `buildArgs` si vous devez ajuster.
- **la génération média dit que `OD_BIN` manque ou que lURL daemon vaut `:0`** — exécutez les checks du dispatcher média ci-dessus. Ne reprenez pas lancienne session CLI ; rouvrez le projet depuis lapp Open Design pour que le daemon injecte des variables `OD_*` fraîches.
- **Codex charge trop de contexte plugin** — démarrez Open Design avec `OD_CODEX_DISABLE_PLUGINS=1 pnpm tools-dev` pour que les processus Codex lancés par le daemon tournent avec `--disable plugins`.
- **lartifact ne rend jamais** — le modèle a produit du texte sans wrapper `<artifact>`. Vérifiez que le system prompt passe bien (log daemon) et envisagez un modèle plus capable ou un Skill plus strict.
## Retour à la vision
Ce Quickstart est la graine exécutable de la spec dans [`docs/`](docs/). La spec décrit vers quoi le projet grandit (voir [`docs/roadmap.md`](docs/roadmap.md)). Points clés :
- `docs/architecture.md` décrit le stack livré : Next.js 16 App Router devant, daemon local derrière, et rewrites `apps/web/next.config.ts` en dev pour que le navigateur parle toujours à la même surface `/api`.
- `docs/skills-protocol.md` décrit le schéma `od:` complet. Le daemon lit les métadonnées runtime utiles depuis `SKILL.md` pour router les Skills, composer le prompt, afficher les exemples et configurer les surfaces web / image / vidéo / audio ; le protocole reste la référence pour les champs avancés.
- `docs/agent-adapters.md` anticipe un dispatch plus riche (capability detection, streaming tool-calls). Notre `apps/daemon/src/agents.ts` est un dispatcher minimal : suffisant pour prouver le câblage.
- `docs/modes.md` décrit les workflows prototype / deck / template / design-system. Le catalogue runtime peut aussi exposer des Skills pour les surfaces image, vidéo et audio ; le picker filtre les entrées par `mode` et `surface`.

230
QUICKSTART.ja-JP.md Normal file
View File

@@ -0,0 +1,230 @@
# クイックスタート
<p align="center"><a href="QUICKSTART.md">English</a> · <a href="QUICKSTART.pt-BR.md">Português (Brasil)</a> · <a href="QUICKSTART.de.md">Deutsch</a> · <a href="QUICKSTART.fr.md">Français</a> · <b>日本語</b> · <a href="QUICKSTART.zh-CN.md">简体中文</a></p>
製品全体をローカルで実行します。
## 環境要件
- **Node.js:** `~24`Node 24.x。リポジトリは `package.json#engines` を通じてこれを強制しています。
- **pnpm:** `10.33.x`。リポジトリは `packageManager` を通じて `pnpm@10.33.2` をピン留めしています。Corepack を使用すれば、ピン留めされたバージョンが自動的に選択されます。
- **OS:** macOS、Linux、WSL2 が主要なパスです。Windows ネイティブはほとんどのフローで動作するはずですが、WSL2 のほうが安全なベースラインです。
- **オプションのローカルエージェント CLI:** Claude Code、Codex、Devin for Terminal、Gemini CLI、OpenCode、Cursor Agent、Qwen、GitHub Copilot CLI など。何もインストールされていない場合は、設定から BYOK API モードを使用してください。
`nvm` / `fnm` はオプションの便利なツールであり、必須のプロジェクトセットアップではありません。使用する場合は、pnpm を実行する前に Node 24 をインストール/選択してください。
```bash
# nvm
nvm install 24
nvm use 24
# fnm
fnm install 24
fnm use 24
```
その後、Corepack を有効化してリポジトリに pnpm を選択させます。
```bash
corepack enable
corepack pnpm --version # 10.33.2 が表示されるはずです
```
## ワンショットdev モード)
```bash
corepack enable
pnpm install
pnpm tools-dev run web # daemon と web をフォアグラウンドで起動します
# tools-dev が出力した web URL を開きます
```
デスクトップシェルとすべての管理対象 sidecar をバックグラウンドで起動する場合:
```bash
pnpm tools-dev # daemon + web + desktop をバックグラウンドで起動します
```
初回起動時、アプリはインストール済みのコードエージェント CLIClaude Code / Codex / Devin for Terminal / Gemini / OpenCode / Cursor Agent / Qwenを検出して自動選択し、デフォルトで `web-prototype` スキルと `Neutral Modern` デザインシステムを採用します。プロンプトを入力して **Send** を押してください。エージェントが左ペインにストリーミングし、`<artifact>` タグが解析されて HTML が右側にライブレンダリングされます。完了したら **Save to disk** をクリックして、アーティファクトを `./.od/artifacts/<timestamp>-<slug>/index.html` に永続化します。
**Design system** ドロップダウンには **129 のデザインシステム** が同梱されています — 手作りのスターター 2 種Neutral Modern、Warm Editorial、バンドルされた製品システム 70 種、[`awesome-design-skills`](https://github.com/bergside/awesome-design-skills) から取得した 57 のデザインスキルです。1 つを選ぶと、すべてのプロトタイプがそのブランドの美学でスキニングされます。
**Skill** ドロップダウンはモードPrototype / Deck / Template / Design systemでグループ化され、モードごとのデフォルトスキルには `· default` サフィックスが付きます。バンドルされているスキル:
- **Prototype** — `web-prototype`(汎用)、`saas-landing``dashboard``pricing-page``docs-page``blog-post``mobile-app`
- **Deck / PPT** — `simple-deck`(単一ファイルの横スワイプ)と `magazine-web-ppt`[`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) からの `guizang-ppt` バンドル — deck モードのデフォルト。独自のアセット/テンプレート + 4 つのリファレンスを同梱。サイドファイルを持つスキルには自動的に「Skill root (absolute)」のプリアンブルが付与され、エージェントが CWD ではなく実際のディスク上のパスに対して `assets/template.html``references/*.md` を解決できるようになります。
スキルとデザインシステムを組み合わせれば、単一のプロンプトから選択した視覚言語でレイアウトに適したプロトタイプまたはデッキが生成されます。
## その他のスクリプト
```bash
pnpm tools-dev # daemon + web + desktop をバックグラウンドで起動
pnpm tools-dev start web # daemon + web をバックグラウンドで起動
pnpm tools-dev run web # daemon + web をフォアグラウンドで起動e2e/dev サーバー)
pnpm tools-dev restart # daemon + web + desktop を再起動
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
pnpm tools-dev status # 管理対象ランタイムを検査
pnpm tools-dev logs # daemon/web/desktop のログを表示
pnpm tools-dev check # status + 最近のログ + 一般的な診断
pnpm tools-dev stop # 管理対象ランタイムを停止
pnpm --filter @open-design/daemon build # `od` 用に apps/daemon/dist/cli.js をビルド
pnpm --filter @open-design/web build # 必要に応じて web パッケージをビルド
pnpm typecheck # workspace の typecheck
```
`pnpm tools-dev` がローカルライフサイクルの唯一のエントリポイントです。削除済みのレガシールートエイリアス(`pnpm dev``pnpm dev:all``pnpm daemon``pnpm preview``pnpm start`)は使用しないでください。
ローカル開発中、`tools-dev` は最初に daemon を起動し、そのポートを `apps/web` に渡します。`apps/web/next.config.ts``/api/*``/artifacts/*``/frames/*` をその daemon ポートに書き換えるため、App Router アプリは CORS 設定なしで隣接する Express プロセスと通信できます。
## メディア生成 / エージェントディスパッチャーチェック
Image、Video、Audio、HyperFrames スキルは、daemon がエージェントを起動する際に注入する環境変数を通じてローカル `od` CLI を呼び出します:
- `OD_BIN``apps/daemon/dist/cli.js` への絶対パス。
- `OD_DAEMON_URL` — 実行中の daemon URL。
- `OD_PROJECT_ID` — アクティブなプロジェクト ID。
- `OD_PROJECT_DIR` — アクティブなプロジェクトのファイルディレクトリ。
メディア生成が `OD_BIN: parameter not set``apps/daemon/dist/cli.js` の欠落、または `failed to reach daemon at http://127.0.0.1:0` で失敗する場合は、daemon CLI を再ビルドして管理対象ランタイムを再起動してください:
```bash
pnpm --filter @open-design/daemon build
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
ls -la apps/daemon/dist/cli.js
curl -s http://127.0.0.1:7457/api/health
```
その後、古いターミナルエージェントセッションを再開する代わりに、Open Design アプリからプロジェクトを再度開いてください。daemon から起動されたエージェントは、次のような値を確認できるはずです:
```bash
echo "OD_BIN=$OD_BIN"
echo "OD_PROJECT_ID=$OD_PROJECT_ID"
echo "OD_PROJECT_DIR=$OD_PROJECT_DIR"
echo "OD_DAEMON_URL=$OD_DAEMON_URL"
ls -la "$OD_BIN"
```
`OD_DAEMON_URL``http://127.0.0.1:0` ではなく、`http://127.0.0.1:7457` のような実際の daemon ポートでなければなりません。`:0` という値は内部的な「空きポートを選択する」起動ヒントにすぎず、エージェントセッションに漏れてはなりません。
daemon のみの本番モードでは、daemon 自身が `http://localhost:7456` で静的な Next.js エクスポートを提供するため、リバースプロキシは関与しません。
daemon の前段に nginx を配置する場合は、SSE ルートをバッファリングなし・圧縮なしに保ってください。一般的な失敗例は、ブラウザコンソールに 80〜90 秒後に `net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)` が表示されるというもので、これは daemon が `X-Accel-Buffering: no` を送信していても、nginx の `gzip on` がチャンク分割された SSE レスポンスをバッファリングしてしまうために発生します。
```nginx
location /api/ {
proxy_pass http://127.0.0.1:7456;
proxy_buffering off;
gzip off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## 2 つの実行モード
| モード | ピッカーの値 | リクエストの流れ |
|---|---|---|
| **Local CLI**daemon がエージェントを検出した場合のデフォルト) | "Local CLI" | フロントエンド → daemon `/api/chat``spawn(<agent>, ...)` → stdout → SSE → アーティファクトパーサー → プレビュー |
| **Anthropic API**(フォールバック / CLI なし) | "Anthropic API · BYOK" | フロントエンド → `@anthropic-ai/sdk` 直接呼び出し(`dangerouslyAllowBrowser` → アーティファクトパーサー → プレビュー |
両モードとも **同じ** `<artifact>` パーサーと **同じ** サンドボックス化された iframe にデータを供給します。異なるのはトランスポートとシステムプロンプトの配信方法だけです(ローカル CLI には独立したシステムチャンネルがないため、合成プロンプトはユーザーメッセージに折り込まれます)。
## プロンプトの構成
送信ごとに、アプリは 3 つのレイヤーからシステムプロンプトを構築してプロバイダーに送信します:
```
BASE_SYSTEM_PROMPT (出力契約:<artifact> でラップ、コードフェンスなし)
+ アクティブなデザインシステム本文 DESIGN.md — パレット/タイポ/レイアウト)
+ アクティブなスキル本文 SKILL.md — ワークフローと出力ルール)
```
トップバーでスキルまたはデザインシステムを切り替えると、次回の送信から新しいスタックが使用されます。本文はセッションごとにメモリ内にキャッシュされるため、選択ごとに 1 回の daemon フェッチで済みます。
## ファイルマップ
```
open-design/
├── apps/
│ ├── daemon/ # Node/Express — ローカルエージェントを起動 + API を提供
│ │ └── src/
│ │ ├── cli.ts # `od` bin エントリ
│ │ ├── server.ts # /api/* + 静的配信
│ │ ├── agents.ts # claude/codex/devin/gemini/opencode/cursor-agent/qwen/copilot 用 PATH スキャナ
│ │ ├── skills.ts # SKILL.md ローダー(フロントマターパーサー)
│ │ └── design-systems.ts # DESIGN.md ローダー
│ │ ├── sidecar/ # tools-dev daemon sidecar ラッパー
│ │ └── tests/ # daemon パッケージのテスト
│ ├── web/ # Next.js 16 App Router + React クライアント
│ ├── app/ # App Router エントリポイント
│ ├── src/ # React + TypeScript クライアント/ランタイムモジュール
│ │ ├── App.tsx # mode / skill / DS ピッカー + send をオーケストレーション
│ │ ├── providers/ # daemon + BYOK API トランスポート
│ │ ├── prompts/ # system、discovery、directions、deck フレームワーク
│ │ ├── artifacts/ # ストリーミング <artifact> パーサー + マニフェスト
│ │ ├── runtime/ # iframe srcdoc、markdown、エクスポートヘルパー
│ │ └── state/ # localStorage + daemon バックエンドのプロジェクト状態
│ ├── sidecar/ # tools-dev web sidecar ラッパー
│ └── next.config.ts # tools-dev rewrites + 本番 apps/web/out エクスポート設定
│ └── desktop/ # Electron ランタイム、tools-dev によって起動/検査される
├── packages/
│ ├── contracts/ # 共有 web/daemon アプリ契約
│ ├── sidecar-proto/ # Open Design sidecar プロトコル契約
│ ├── sidecar/ # 汎用 sidecar ランタイムプリミティブ
│ └── platform/ # 汎用プロセス/プラットフォームプリミティブ
├── tools/dev/ # `pnpm tools-dev` ライフサイクルと inspect CLI
├── e2e/ # Playwright UI + 外部統合Vitest ハーネス
├── skills/ # SKILL.md — 任意の Claude Code スキルリポジトリからドロップイン
│ ├── web-prototype/ # 汎用シングルスクリーンプロトタイプprototype モードのデフォルト)
│ ├── saas-landing/ # マーケティングページhero / features / pricing / CTA
│ ├── dashboard/ # 管理/分析ダッシュボード
│ ├── pricing-page/ # 独立した pricing + 比較
│ ├── docs-page/ # 3 列ドキュメンテーションレイアウト
│ ├── blog-post/ # エディトリアル長文
│ ├── mobile-app/ # 電話フレームのシングルスクリーン
│ ├── simple-deck/ # 最小限の横スワイプデッキ
│ └── guizang-ppt/ # magazine-web-ppt — バンドルされた deck/PPT デフォルト
│ ├── SKILL.md
│ ├── assets/template.html
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ # DESIGN.md — 9 セクションスキーマawesome-claude-design
│ ├── default/ # Neutral Modernスターター
│ ├── warm-editorial/ # Warm Editorialスターター
│ ├── README.md # カタログ概要
│ └── …129 systems # スターター 2 種 · 製品システム 70 種 · デザインスキル 57 種
├── scripts/sync-design-systems.ts # 上流の getdesign tarball から再インポート
├── docs/ # 製品ビジョン + 仕様
├── .od/ # ランタイムデータgitignore 済み、自動作成)
│ ├── app.sqlite # projects / conversations / messages / tabs
│ ├── artifacts/ # ワンショット "Save to disk" レンダリング
│ └── projects/<id>/ # プロジェクトごとの作業ディレクトリ + エージェント cwd
├── pnpm-workspace.yaml # apps/* + packages/* + tools/* + e2e
└── package.json # root quality スクリプト + `od` bin
```
## トラブルシューティング
- **「no agents found on PATH」** — `claude``codex``devin``gemini``opencode``cursor-agent``qwen``copilot` のいずれかをインストールしてください。または、トップバーで「Anthropic API · BYOK」に切り替え、**設定** にキーを貼り付けます。
- **/api/chat で daemon が 500 を返す** — daemon ターミナルで stderr の末尾を確認してください。通常は CLI が引数を拒否しています。CLI ごとに argv の形式が異なります。調整が必要な場合は `apps/daemon/src/agents.ts``buildArgs` を参照してください。
- **メディア生成で `OD_BIN` が欠落、または daemon URL が `:0`** — 上記のメディアディスパッチャーチェックを実行してください。古い CLI セッションを再開せず、Open Design アプリからプロジェクトを再度開いて、daemon が新しい `OD_*` 変数を注入できるようにしてください。
- **Codex がプラグインコンテキストを多く読み込みすぎる** — `OD_CODEX_DISABLE_PLUGINS=1 pnpm tools-dev` で Open Design を起動すると、daemon から起動された Codex プロセスが `--disable plugins` で実行されます。
- **アーティファクトがレンダリングされない** — モデルが `<artifact>` でラップせずにテキストを生成しました。システムプロンプトが通っていることを確認しdaemon ログを確認)、より高性能なモデルまたは厳格なスキルへの切り替えを検討してください。
## ビジョンへのマッピング
このクイックスタートは [`docs/`](docs/) にある仕様の実行可能なシードです。仕様は、これがどこへ成長するかを記述しています([`docs/roadmap.md`](docs/roadmap.md) を参照)。ハイライト:
- `docs/architecture.md` は、出荷されたスタックを説明しています:前面に Next.js 16 App Router、その背後にローカル daemon、そして `apps/web/next.config.ts` の dev 時 rewrites によってブラウザが同じ `/api` 表面と通信し続けるようにします。
- `docs/skills-protocol.md` は、完全な `od:` フロントマター(型付き入力、スライダー、機能ゲーティング)について説明しています。この MVP は `name` / `description` / `triggers` / `od.mode` / `od.design_system.requires` のみを読み取ります — 残りを追加するには `apps/daemon/src/skills.ts` を拡張してください。
- `docs/agent-adapters.md` はより豊かなディスパッチ(機能検出、ストリーミングツール呼び出し)を予見しています。`apps/daemon/src/agents.ts` は最小限のディスパッチャーです — 配線を証明するには十分です。
- `docs/modes.md` は 4 つのモードprototype / deck / template / design-systemを列挙しています。最初の 2 つのスキルを出荷しています。ピッカーはすでに `mode` でフィルタリングしています。

230
QUICKSTART.md Normal file
View File

@@ -0,0 +1,230 @@
# Quickstart
<p align="center"><b>English</b> · <a href="QUICKSTART.pt-BR.md">Português (Brasil)</a> · <a href="QUICKSTART.de.md">Deutsch</a> · <a href="QUICKSTART.fr.md">Français</a> · <a href="QUICKSTART.ja-JP.md">日本語</a> · <a href="QUICKSTART.zh-CN.md">简体中文</a></p>
Run the full product locally.
## Environment requirements
- **Node.js:** `~24` (Node 24.x). The repo enforces this through `package.json#engines`.
- **pnpm:** `10.33.x`. The repo pins `pnpm@10.33.2` through `packageManager`; use Corepack so the pinned version is selected automatically.
- **OS:** macOS, Linux, and WSL2 are the primary paths. Windows native should work for most flows, but WSL2 is the safer baseline.
- **Optional local agent CLI:** Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, Qoder CLI, GitHub Copilot CLI, etc. If none are installed, use the BYOK API mode from Settings.
`nvm` / `fnm` are optional convenience tools, not required project setup. If you use one, install/select Node 24 before running pnpm:
```bash
# nvm
nvm install 24
nvm use 24
# fnm
fnm install 24
fnm use 24
```
Then enable Corepack and let the repo select pnpm:
```bash
corepack enable
corepack pnpm --version # should print 10.33.2
```
## One-shot (dev mode)
```bash
corepack enable
pnpm install
pnpm tools-dev run web # starts daemon + web in the foreground
# open the web URL printed by tools-dev
```
For the desktop shell and all managed sidecars in the background:
```bash
pnpm tools-dev # starts daemon + web + desktop in the background
```
On first load, the app detects your installed code-agent CLI (Claude Code / Codex / Devin for Terminal / Gemini / OpenCode / Cursor Agent / Qwen / Qoder CLI), picks it automatically, and defaults to `web-prototype` skill + `Neutral Modern` design system. Type a prompt and hit **Send**. The agent streams into the left pane; the `<artifact>` tag is parsed out and the HTML renders live on the right. When it finishes, click **Save to disk** to persist the artifact under `./.od/artifacts/<timestamp>-<slug>/index.html`.
The **Design system** dropdown ships with **129 design systems** — 2 hand-authored starters (Neutral Modern, Warm Editorial), 70 bundled product systems, and 57 design skills sourced from [`awesome-design-skills`](https://github.com/bergside/awesome-design-skills). Pick one to skin every prototype in that brand's aesthetic.
The **Skill** dropdown groups by mode (Prototype / Deck / Template / Design system) and shows the default skill per mode with a `· default` suffix. Bundled skills:
- **Prototype** — `web-prototype` (generic), `saas-landing`, `dashboard`, `pricing-page`, `docs-page`, `blog-post`, `mobile-app`.
- **Deck / PPT** — `simple-deck` (single-file horizontal swipe) and `magazine-web-ppt` (the `guizang-ppt` bundle from [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) — default for deck mode, ships its own assets/template + 4 references). Skills with side files get an automatic "Skill root (absolute)" preamble so the agent can resolve `assets/template.html` and `references/*.md` against the real on-disk path instead of its CWD.
Pair a skill with a design system and a single prompt produces a layout-appropriate prototype or deck in the chosen visual language.
## Other scripts
```bash
pnpm tools-dev # daemon + web + desktop in the background
pnpm tools-dev start web # daemon + web in the background
pnpm tools-dev run web # daemon + web in the foreground (e2e/dev server)
pnpm tools-dev restart # restart daemon + web + desktop
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
pnpm tools-dev status # inspect managed runtimes
pnpm tools-dev logs # show daemon/web/desktop logs
pnpm tools-dev check # status + recent logs + common diagnostics
pnpm tools-dev stop # stop managed runtimes
pnpm --filter @open-design/daemon build # build apps/daemon/dist/cli.js for `od`
pnpm --filter @open-design/web build # build the web package when needed
pnpm typecheck # workspace typecheck
```
`pnpm tools-dev` is the only local lifecycle entry point. Do not use the removed legacy root aliases (`pnpm dev`, `pnpm dev:all`, `pnpm daemon`, `pnpm preview`, `pnpm start`).
During local development, `tools-dev` starts the daemon first, passes its port into `apps/web`, and `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to that daemon port so the App Router app can talk to the sibling Express process without CORS setup.
## Media generation / agent dispatcher checks
Image, video, audio, and HyperFrames skills call the local `od` CLI through environment variables injected by the daemon when it spawns an agent:
- `OD_BIN` — absolute path to `apps/daemon/dist/cli.js`.
- `OD_DAEMON_URL` — the running daemon URL.
- `OD_PROJECT_ID` — the active project id.
- `OD_PROJECT_DIR` — the active project's file directory.
If media generation fails with `OD_BIN: parameter not set`, `apps/daemon/dist/cli.js` missing, or `failed to reach daemon at http://127.0.0.1:0`, rebuild the daemon CLI and restart the managed runtime:
```bash
pnpm --filter @open-design/daemon build
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
ls -la apps/daemon/dist/cli.js
curl -s http://127.0.0.1:7457/api/health
```
Then open the project from the Open Design app again instead of resuming an old terminal agent session. A daemon-spawned agent should see values like:
```bash
echo "OD_BIN=$OD_BIN"
echo "OD_PROJECT_ID=$OD_PROJECT_ID"
echo "OD_PROJECT_DIR=$OD_PROJECT_DIR"
echo "OD_DAEMON_URL=$OD_DAEMON_URL"
ls -la "$OD_BIN"
```
`OD_DAEMON_URL` must be a real daemon port such as `http://127.0.0.1:7457`, not `http://127.0.0.1:0`. The `:0` value is only an internal "pick a free port" launch hint and should not leak into agent sessions.
For the daemon-only production mode, the daemon serves the static Next.js export itself at `http://localhost:7456`, so no reverse proxy is involved.
If you place nginx in front of the daemon, keep SSE routes unbuffered and uncompressed. A common failure is the browser console showing `net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)` after 80-90 seconds because nginx `gzip on` buffers chunked SSE responses even when the daemon sends `X-Accel-Buffering: no`.
```nginx
location /api/ {
proxy_pass http://127.0.0.1:7456;
proxy_buffering off;
gzip off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## Two execution modes
| Mode | Picker value | How a request flows |
|---|---|---|
| **Local CLI** (default when daemon detects an agent) | "Local CLI" | Frontend → daemon `/api/chat``spawn(<agent>, ...)` → stdout → SSE → artifact parser → preview |
| **API mode** (fallback / no CLI) | "Anthropic API" / "OpenAI API" / "Azure OpenAI" / "Google Gemini" | Frontend → daemon `/api/proxy/{provider}/stream` → provider SSE normalized to `delta/end/error` → artifact parser → preview |
Both modes feed the **same** `<artifact>` parser and the **same** sandboxed iframe. The only thing that differs is the transport and the system-prompt delivery (local CLIs have no separate system channel, so the composed prompt is folded into the user message).
## Prompt composition
For every send, the app builds a system prompt from three layers and sends it to the provider:
```
BASE_SYSTEM_PROMPT (output contract: wrap in <artifact>, no code fences)
+ active design system body (DESIGN.md — palette/type/layout)
+ active skill body (SKILL.md — workflow and output rules)
```
Swap the skill or the design system in the top bar and the next send uses the new stack. Bodies are cached in-memory per session so this is a single daemon fetch per pick.
## File map
```
open-design/
├── apps/
│ ├── daemon/ # Node/Express — spawns local agents + serves APIs
│ │ └── src/
│ │ ├── cli.ts # `od` bin entry
│ │ ├── server.ts # /api/* + static serving
│ │ ├── agents.ts # PATH scanner for claude/codex/devin/gemini/opencode/cursor-agent/qwen/qoder/copilot
│ │ ├── skills.ts # SKILL.md loader (frontmatter parser)
│ │ └── design-systems.ts # DESIGN.md loader
│ │ ├── sidecar/ # tools-dev daemon sidecar wrapper
│ │ └── tests/ # daemon package tests
│ ├── web/ # Next.js 16 App Router + React client
│ ├── app/ # App Router entrypoints
│ ├── src/ # React + TypeScript client/runtime modules
│ │ ├── App.tsx # orchestrates mode / skill / DS pickers + send
│ │ ├── providers/ # daemon + BYOK API transports
│ │ ├── prompts/ # system, discovery, directions, deck framework
│ │ ├── artifacts/ # streaming <artifact> parser + manifests
│ │ ├── runtime/ # iframe srcdoc, markdown, export helpers
│ │ └── state/ # localStorage + daemon-backed project state
│ ├── sidecar/ # tools-dev web sidecar wrapper
│ └── next.config.ts # tools-dev rewrites + prod apps/web/out export config
│ └── desktop/ # Electron runtime, launched/inspected by tools-dev
├── packages/
│ ├── contracts/ # shared web/daemon app contracts
│ ├── sidecar-proto/ # Open Design sidecar protocol contract
│ ├── sidecar/ # generic sidecar runtime primitives
│ └── platform/ # generic process/platform primitives
├── tools/dev/ # `pnpm tools-dev` lifecycle and inspect CLI
├── e2e/ # Playwright UI + external integration/Vitest harness
├── skills/ # SKILL.md — drops in from any Claude Code skill repo
│ ├── web-prototype/ # generic single-screen prototype (default for prototype mode)
│ ├── saas-landing/ # marketing page (hero / features / pricing / CTA)
│ ├── dashboard/ # admin / analytics dashboard
│ ├── pricing-page/ # standalone pricing + comparison
│ ├── docs-page/ # 3-column documentation layout
│ ├── blog-post/ # editorial long-form
│ ├── mobile-app/ # phone-frame single screen
│ ├── simple-deck/ # minimal horizontal-swipe deck
│ └── guizang-ppt/ # magazine-web-ppt — bundled deck/PPT default
│ ├── SKILL.md
│ ├── assets/template.html
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ # DESIGN.md — 9-section schema (awesome-claude-design)
│ ├── default/ # Neutral Modern (starter)
│ ├── warm-editorial/ # Warm Editorial (starter)
│ ├── README.md # catalog overview
│ └── …129 systems # 2 starters · 70 product systems · 57 design skills
├── scripts/sync-design-systems.ts # re-import from upstream getdesign tarball
├── docs/ # product vision + spec
├── .od/ # runtime data (gitignored, auto-created)
│ ├── app.sqlite # projects / conversations / messages / tabs
│ ├── artifacts/ # one-off "Save to disk" renders
│ └── projects/<id>/ # per-project working dir + agent cwd
├── pnpm-workspace.yaml # apps/* + packages/* + tools/* + e2e
└── package.json # root quality scripts + `od` bin
```
## Troubleshooting
- **"no agents found on PATH"** — install one of: `claude`, `codex`, `devin`, `gemini`, `opencode`, `cursor-agent`, `qwen`, `qodercli`, `copilot`. Or switch to API mode in Settings and paste a provider key.
- **daemon 500 on /api/chat** — check the daemon terminal for the stderr tail; usually the CLI rejected its args. Different CLIs take different argv shapes; see `apps/daemon/src/agents.ts` `buildArgs` if you need to tweak.
- **media generation says `OD_BIN` is missing or daemon URL is `:0`** — run the media dispatcher checks above. Do not resume the old CLI session; reopen the project from the Open Design app so the daemon can inject fresh `OD_*` variables.
- **Codex loads too much plugin context** — start Open Design with `OD_CODEX_DISABLE_PLUGINS=1 pnpm tools-dev` to make daemon-spawned Codex processes run with `--disable plugins`.
- **artifact never renders** — the model produced text without wrapping in `<artifact>`. Confirm the system prompt is going through (check daemon log) and consider switching to a more capable model or a stricter skill.
## Mapping back to the vision
This Quickstart is the runnable seed of the spec in [`docs/`](docs/). The spec describes where this grows (see [`docs/roadmap.md`](docs/roadmap.md)). Highlights:
- `docs/architecture.md` describes the shipped stack: Next.js 16 App Router in front, local daemon behind it, and `apps/web/next.config.ts` rewrites in dev to keep the browser talking to the same `/api` surface.
- `docs/skills-protocol.md` describes the full `od:` frontmatter (typed inputs, sliders, capability gating). This MVP reads `name` / `description` / `triggers` / `od.mode` / `od.design_system.requires` only — extend `apps/daemon/src/skills.ts` to add the rest.
- `docs/agent-adapters.md` foresees richer dispatch (capability detection, streaming tool-calls). Our `apps/daemon/src/agents.ts` is a minimal dispatcher — enough to prove the wiring.
- `docs/modes.md` lists four modes: prototype / deck / template / design-system. We ship skills for the first two; the picker already filters by `mode`.

230
QUICKSTART.pt-BR.md Normal file
View File

@@ -0,0 +1,230 @@
# Início rápido
<p align="center"><a href="QUICKSTART.md">English</a> · <b>Português (Brasil)</b> · <a href="QUICKSTART.de.md">Deutsch</a> · <a href="QUICKSTART.fr.md">Français</a> · <a href="QUICKSTART.ja-JP.md">日本語</a> · <a href="QUICKSTART.zh-CN.md">简体中文</a></p>
Rode o produto inteiro localmente.
## Requisitos de ambiente
- **Node.js:** `~24` (Node 24.x). O repo força isso via `package.json#engines`.
- **pnpm:** `10.33.x`. O repo fixa `pnpm@10.33.2` via `packageManager`; use Corepack para selecionar a versão fixada automaticamente.
- **SO:** macOS, Linux e WSL2 são os caminhos principais. Windows nativo costuma funcionar para a maioria dos fluxos, mas WSL2 é a base mais segura.
- **CLI de agente local (opcional):** Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, GitHub Copilot CLI etc. Sem nenhum instalado, use o modo BYOK API em Settings.
`nvm` / `fnm` são ferramentas opcionais de conveniência, não são parte obrigatória do setup do projeto. Se você usa um deles, instale/selecione o Node 24 antes de rodar pnpm:
```bash
# nvm
nvm install 24
nvm use 24
# fnm
fnm install 24
fnm use 24
```
Em seguida, habilite o Corepack e deixe o repo escolher o pnpm:
```bash
corepack enable
corepack pnpm --version # should print 10.33.2
```
## Em um único comando (modo dev)
```bash
corepack enable
pnpm install
pnpm tools-dev run web # starts daemon + web in the foreground
# open the web URL printed by tools-dev
```
Para a shell desktop e todos os sidecars gerenciados em background:
```bash
pnpm tools-dev # starts daemon + web + desktop in the background
```
No primeiro carregamento, o app detecta o CLI de agente instalado (Claude Code / Codex / Devin for Terminal / Gemini / OpenCode / Cursor Agent / Qwen), seleciona automaticamente e usa por padrão o skill `web-prototype` + design system `Neutral Modern`. Digite um prompt e clique em **Send**. O agente faz streaming no painel da esquerda; a tag `<artifact>` é parseada e o HTML é renderizado ao vivo na direita. Ao terminar, clique em **Save to disk** para persistir o artifact em `./.od/artifacts/<timestamp>-<slug>/index.html`.
O dropdown **Design system** vem com **129 design systems** — 2 starters escritos à mão (Neutral Modern, Warm Editorial), 70 sistemas de produto bundled e 57 design skills vindos de [`awesome-design-skills`](https://github.com/bergside/awesome-design-skills). Escolha um para vestir cada protótipo na estética daquela marca.
O dropdown **Skill** agrupa por modo (Prototype / Deck / Template / Design system) e exibe o skill default de cada modo com um sufixo `· default`. Skills bundled:
- **Prototype** — `web-prototype` (genérico), `saas-landing`, `dashboard`, `pricing-page`, `docs-page`, `blog-post`, `mobile-app`.
- **Deck / PPT** — `simple-deck` (swipe horizontal de arquivo único) e `magazine-web-ppt` (o bundle `guizang-ppt` de [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) — default do modo deck, traz seus próprios assets/template + 4 referências). Skills com arquivos auxiliares ganham um preâmbulo automático "Skill root (absolute)" para que o agente resolva `assets/template.html` e `references/*.md` contra o caminho real em disco em vez do CWD.
Combine um skill com um design system e um único prompt produz um protótipo ou deck com layout adequado, na linguagem visual escolhida.
## Outros scripts
```bash
pnpm tools-dev # daemon + web + desktop in the background
pnpm tools-dev start web # daemon + web in the background
pnpm tools-dev run web # daemon + web in the foreground (e2e/dev server)
pnpm tools-dev restart # restart daemon + web + desktop
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
pnpm tools-dev status # inspect managed runtimes
pnpm tools-dev logs # show daemon/web/desktop logs
pnpm tools-dev check # status + recent logs + common diagnostics
pnpm tools-dev stop # stop managed runtimes
pnpm --filter @open-design/daemon build # build apps/daemon/dist/cli.js for `od`
pnpm --filter @open-design/web build # build do pacote web quando necessário
pnpm typecheck # workspace typecheck
```
`pnpm tools-dev` é o único entrypoint do ciclo de vida local. Não use os antigos atalhos do root removidos (`pnpm dev`, `pnpm dev:all`, `pnpm daemon`, `pnpm preview`, `pnpm start`).
Em desenvolvimento local, o `tools-dev` sobe o daemon primeiro, repassa a porta dele para `apps/web`, e o `apps/web/next.config.ts` reescreve `/api/*`, `/artifacts/*` e `/frames/*` para essa porta de daemon, permitindo que o app do App Router fale com o processo Express irmão sem configurar CORS.
## Verificações de geração de mídia / dispatcher de agente
Skills de imagem, vídeo, áudio e HyperFrames chamam o CLI local `od` por meio de variáveis de ambiente que o daemon injeta ao spawnar um agente:
- `OD_BIN` — caminho absoluto para `apps/daemon/dist/cli.js`.
- `OD_DAEMON_URL` — URL do daemon em execução.
- `OD_PROJECT_ID` — id do projeto ativo.
- `OD_PROJECT_DIR` — diretório de arquivos do projeto ativo.
Se a geração de mídia falhar com `OD_BIN: parameter not set`, com `apps/daemon/dist/cli.js` ausente ou com `failed to reach daemon at http://127.0.0.1:0`, recompile o CLI do daemon e reinicie o runtime gerenciado:
```bash
pnpm --filter @open-design/daemon build
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
ls -la apps/daemon/dist/cli.js
curl -s http://127.0.0.1:7457/api/health
```
Em seguida, abra o projeto pelo app Open Design novamente em vez de retomar uma sessão antiga de agente no terminal. Um agente spawnado pelo daemon deve ver valores como:
```bash
echo "OD_BIN=$OD_BIN"
echo "OD_PROJECT_ID=$OD_PROJECT_ID"
echo "OD_PROJECT_DIR=$OD_PROJECT_DIR"
echo "OD_DAEMON_URL=$OD_DAEMON_URL"
ls -la "$OD_BIN"
```
`OD_DAEMON_URL` precisa ser uma porta de daemon real, como `http://127.0.0.1:7457`, e não `http://127.0.0.1:0`. O `:0` é apenas uma dica interna de "escolha uma porta livre" no launch e não deveria vazar para sessões de agente.
No modo de produção daemon-only, o próprio daemon serve o export estático do Next.js em `http://localhost:7456`, então não há reverse proxy envolvido.
Se você colocar nginx na frente do daemon, mantenha as rotas SSE sem buffering e sem compressão. Uma falha comum é o console do navegador mostrar `net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)` depois de 8090 segundos, porque o `gzip on` do nginx bufferiza respostas SSE em chunks mesmo quando o daemon envia `X-Accel-Buffering: no`.
```nginx
location /api/ {
proxy_pass http://127.0.0.1:7456;
proxy_buffering off;
gzip off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## Dois modos de execução
| Modo | Valor no picker | Como uma requisição flui |
|---|---|---|
| **Local CLI** (default quando o daemon detecta um agente) | "Local CLI" | Frontend → daemon `/api/chat``spawn(<agent>, ...)` → stdout → SSE → parser de artifact → preview |
| **API mode** (fallback / sem CLI) | "Anthropic API" / "OpenAI API" / "Azure OpenAI" / "Google Gemini" | Frontend → daemon `/api/proxy/{provider}/stream` → SSE do provider normalizado para `delta/end/error` → parser de artifact → preview |
Os dois modos alimentam o **mesmo** parser de `<artifact>` e o **mesmo** iframe sandboxed. A única diferença é o transporte e a entrega do system prompt (CLIs locais não têm um canal de sistema separado, então o prompt composto é dobrado dentro da mensagem do usuário).
## Composição de prompt
A cada envio, o app monta um system prompt a partir de três camadas e o envia ao provider:
```
BASE_SYSTEM_PROMPT (output contract: wrap in <artifact>, no code fences)
+ active design system body (DESIGN.md — palette/type/layout)
+ active skill body (SKILL.md — workflow and output rules)
```
Troque o skill ou o design system na barra superior e o próximo envio usa a nova stack. Os corpos ficam em cache em memória por sessão, então é um único fetch ao daemon por escolha.
## Mapa de arquivos
```
open-design/
├── apps/
│ ├── daemon/ # Node/Express — spawns local agents + serves APIs
│ │ └── src/
│ │ ├── cli.ts # `od` bin entry
│ │ ├── server.ts # /api/* + static serving
│ │ ├── agents.ts # PATH scanner for claude/codex/devin/gemini/opencode/cursor-agent/qwen/copilot
│ │ ├── skills.ts # SKILL.md loader (frontmatter parser)
│ │ └── design-systems.ts # DESIGN.md loader
│ │ ├── sidecar/ # tools-dev daemon sidecar wrapper
│ │ └── tests/ # daemon package tests
│ ├── web/ # Next.js 16 App Router + React client
│ ├── app/ # App Router entrypoints
│ ├── src/ # React + TypeScript client/runtime modules
│ │ ├── App.tsx # orchestrates mode / skill / DS pickers + send
│ │ ├── providers/ # daemon + BYOK API transports
│ │ ├── prompts/ # system, discovery, directions, deck framework
│ │ ├── artifacts/ # streaming <artifact> parser + manifests
│ │ ├── runtime/ # iframe srcdoc, markdown, export helpers
│ │ └── state/ # localStorage + daemon-backed project state
│ ├── sidecar/ # tools-dev web sidecar wrapper
│ └── next.config.ts # tools-dev rewrites + prod apps/web/out export config
│ └── desktop/ # Electron runtime, launched/inspected by tools-dev
├── packages/
│ ├── contracts/ # shared web/daemon app contracts
│ ├── sidecar-proto/ # Open Design sidecar protocol contract
│ ├── sidecar/ # generic sidecar runtime primitives
│ └── platform/ # generic process/platform primitives
├── tools/dev/ # `pnpm tools-dev` lifecycle and inspect CLI
├── e2e/ # Playwright UI + external integration/Vitest harness
├── skills/ # SKILL.md — drops in from any Claude Code skill repo
│ ├── web-prototype/ # generic single-screen prototype (default for prototype mode)
│ ├── saas-landing/ # marketing page (hero / features / pricing / CTA)
│ ├── dashboard/ # admin / analytics dashboard
│ ├── pricing-page/ # standalone pricing + comparison
│ ├── docs-page/ # 3-column documentation layout
│ ├── blog-post/ # editorial long-form
│ ├── mobile-app/ # phone-frame single screen
│ ├── simple-deck/ # minimal horizontal-swipe deck
│ └── guizang-ppt/ # magazine-web-ppt — bundled deck/PPT default
│ ├── SKILL.md
│ ├── assets/template.html
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ # DESIGN.md — 9-section schema (awesome-claude-design)
│ ├── default/ # Neutral Modern (starter)
│ ├── warm-editorial/ # Warm Editorial (starter)
│ ├── README.md # catalog overview
│ └── …129 systems # 2 starters · 70 product systems · 57 design skills
├── scripts/sync-design-systems.ts # re-import from upstream getdesign tarball
├── docs/ # product vision + spec
├── .od/ # runtime data (gitignored, auto-created)
│ ├── app.sqlite # projects / conversations / messages / tabs
│ ├── artifacts/ # one-off "Save to disk" renders
│ └── projects/<id>/ # per-project working dir + agent cwd
├── pnpm-workspace.yaml # apps/* + packages/* + tools/* + e2e
└── package.json # root quality scripts + `od` bin
```
## Solução de problemas
- **"no agents found on PATH"** — instale um destes: `claude`, `codex`, `devin`, `gemini`, `opencode`, `cursor-agent`, `qwen`, `copilot`. Ou troque para o modo API em Settings e cole uma chave de provider.
- **daemon 500 em /api/chat** — confira o terminal do daemon para a tail de stderr; geralmente o CLI rejeitou os args. CLIs diferentes aceitam formatos de argv diferentes; veja `buildArgs` em `apps/daemon/src/agents.ts` se precisar ajustar.
- **geração de mídia diz que `OD_BIN` está faltando ou que a URL do daemon é `:0`** — rode as verificações do dispatcher de mídia acima. Não retome a sessão antiga do CLI; reabra o projeto pelo app Open Design para o daemon injetar variáveis `OD_*` novas.
- **Codex carrega muito contexto de plugin** — suba o Open Design com `OD_CODEX_DISABLE_PLUGINS=1 pnpm tools-dev` para que processos Codex spawnados pelo daemon rodem com `--disable plugins`.
- **artifact nunca renderiza** — o modelo emitiu texto sem empacotar em `<artifact>`. Confirme que o system prompt está chegando (cheque o log do daemon) e considere trocar para um modelo mais capaz ou um skill mais estrito.
## Voltando à visão
Este Início rápido é a semente executável da spec em [`docs/`](docs/). A spec descreve para onde isso evolui (veja [`docs/roadmap.md`](docs/roadmap.md)). Destaques:
- `docs/architecture.md` descreve a stack entregue: Next.js 16 App Router na frente, daemon local atrás, e os rewrites de `apps/web/next.config.ts` em dev mantendo o navegador conversando com a mesma superfície `/api`.
- `docs/skills-protocol.md` descreve o frontmatter `od:` completo (inputs tipados, sliders, gating de capacidades). Este MVP lê apenas `name` / `description` / `triggers` / `od.mode` / `od.design_system.requires` — estenda `apps/daemon/src/skills.ts` para cobrir o resto.
- `docs/agent-adapters.md` prevê dispatch mais rico (detecção de capacidade, tool-calls em streaming). Nosso `apps/daemon/src/agents.ts` é um dispatcher mínimo — suficiente para provar a fiação.
- `docs/modes.md` lista quatro modos: prototype / deck / template / design-system. Entregamos skills para os dois primeiros; o picker já filtra por `mode`.

230
QUICKSTART.zh-CN.md Normal file
View File

@@ -0,0 +1,230 @@
# 快速上手 · Quickstart
<p align="center"><a href="QUICKSTART.md">English</a> · <a href="QUICKSTART.pt-BR.md">Português (Brasil)</a> · <a href="QUICKSTART.de.md">Deutsch</a> · <a href="QUICKSTART.fr.md">Français</a> · <a href="QUICKSTART.ja-JP.md">日本語</a> · <b>简体中文</b></p>
在本地运行完整的产品。
## 环境要求
- **Node.js** `~24`Node 24.x。仓库在 `package.json#engines` 中强制要求该版本。
- **pnpm** `10.33.x`。仓库通过 `packageManager` 固定为 `pnpm@10.33.2`;若使用 Corepack该固定版本将被自动选中。
- **操作系统:** 主要支持 macOS、Linux、WSL2。Windows 原生环境大部分流程也可运行,但 WSL2 是更稳定的基线。
- **可选的本地 agent CLI** Claude Code、Codex、Devin for Terminal、Gemini CLI、OpenCode、Cursor Agent、Qwen、Qoder CLI、GitHub Copilot CLI 等。即使未安装任何 CLI也可在 Settings 中切换至 BYOK API 模式。
`nvm` / `fnm` 为可选的便捷工具,并非项目必要依赖。如需使用,请在执行 pnpm 之前安装并切换到 Node 24
```bash
# nvm
nvm install 24
nvm use 24
# fnm
fnm install 24
fnm use 24
```
随后启用 Corepack由仓库自动选择 pnpm
```bash
corepack enable
corepack pnpm --version # 应输出 10.33.2
```
## 一条命令dev 模式)
```bash
corepack enable
pnpm install
pnpm tools-dev run web # 在前台启动 daemon + web
# 打开 tools-dev 输出的 web URL
```
如需将 desktop shell 和所有受管 sidecar 置于后台运行:
```bash
pnpm tools-dev # 在后台启动 daemon + web + desktop
```
首次加载时,应用会扫描已安装的 code-agent CLIClaude Code / Codex / Devin for Terminal / Gemini / OpenCode / Cursor Agent / Qwen / Qoder CLI并自动选择其中之一默认使用 `web-prototype` skill 与 `Neutral Modern` design system。输入 prompt点击 **Send**。Agent 将以流式方式输出至左侧面板;`<artifact>` 标签会被解析HTML 在右侧实时渲染。运行完成后,点击 **Save to disk**artifact 将被写入磁盘 `./.od/artifacts/<timestamp>-<slug>/index.html`
**Design system** 下拉框内置 **129 套 design system** —— 包含 2 套手工编写的 starterNeutral Modern、Warm Editorial、70 套打包的产品级系统,以及来自 [`awesome-design-skills`](https://github.com/bergside/awesome-design-skills) 的 57 个 design skill。选择任意一套所有原型都会应用该品牌的视觉风格。
**Skill** 下拉框按 mode 分组Prototype / Deck / Template / Design system每个 mode 的默认 skill 带有 `· default` 后缀。内置 skill 如下:
- **Prototype** —— `web-prototype`(通用)、`saas-landing``dashboard``pricing-page``docs-page``blog-post``mobile-app`
- **Deck / PPT** —— `simple-deck`(单文件横向翻页)与 `magazine-web-ppt``guizang-ppt` 捆绑包,来自 [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) —— deck mode 的默认 skill自带 assets/template 与 4 份 reference。附带 sidefile 的 skill 会自动添加一段 "Skill root (absolute)" 前言,使 agent 能够基于真实的磁盘路径解析 `assets/template.html``references/*.md`,而非在自身 CWD 中猜测。
将 skill 与 design system 组合使用,仅需一句 prompt 即可产出符合布局规范、并采用所选视觉语言的原型或 deck。
## 其他脚本
```bash
pnpm tools-dev # 在后台启动 daemon + web + desktop
pnpm tools-dev start web # 在后台启动 daemon + web
pnpm tools-dev run web # 在前台启动 daemon + webe2e / dev server
pnpm tools-dev restart # 重启 daemon + web + desktop
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
pnpm tools-dev status # 检查托管的 runtime 状态
pnpm tools-dev logs # 查看 daemon / web / desktop 日志
pnpm tools-dev check # 查看 status + 最近日志 + 常见诊断
pnpm tools-dev stop # 停止托管 runtime
pnpm --filter @open-design/daemon build # 构建 apps/daemon/dist/cli.js供 `od` 使用
pnpm --filter @open-design/web build # 在需要时构建 web package
pnpm typecheck # 对整个 workspace 执行 typecheck
```
`pnpm tools-dev` 是本地生命周期的唯一入口。请勿再使用已被移除的根级别历史别名(`pnpm dev``pnpm dev:all``pnpm daemon``pnpm preview``pnpm start`)。
本地开发时,`tools-dev` 会先启动 daemon并将其端口传递给 `apps/web``apps/web/next.config.ts` 会将 `/api/*``/artifacts/*``/frames/*` 重写到该 daemon 端口,从而使 App Router 能够与相邻的 Express 进程通信,无需配置 CORS。
## 媒体生成 / agent dispatcher 排查
Image、video、audio、HyperFrames 等 skill 在通过 daemon 启动 agent 时,会注入环境变量以调用本地 `od` CLI
- `OD_BIN` —— `apps/daemon/dist/cli.js` 的绝对路径。
- `OD_DAEMON_URL` —— 当前运行的 daemon URL。
- `OD_PROJECT_ID` —— 当前激活的 project id。
- `OD_PROJECT_DIR` —— 当前激活 project 的文件目录。
若媒体生成报错 `OD_BIN: parameter not set`、提示找不到 `apps/daemon/dist/cli.js`、或出现 `failed to reach daemon at http://127.0.0.1:0`,请重新构建 daemon CLI 并重启托管 runtime
```bash
pnpm --filter @open-design/daemon build
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
ls -la apps/daemon/dist/cli.js
curl -s http://127.0.0.1:7457/api/health
```
随后,在 Open Design 应用中**重新打开**该 project不要复用之前 terminal 中的 agent 会话。由 daemon 启动的 agent 应当能够看到类似如下的值:
```bash
echo "OD_BIN=$OD_BIN"
echo "OD_PROJECT_ID=$OD_PROJECT_ID"
echo "OD_PROJECT_DIR=$OD_PROJECT_DIR"
echo "OD_DAEMON_URL=$OD_DAEMON_URL"
ls -la "$OD_BIN"
```
`OD_DAEMON_URL` 必须为真实的 daemon 端口,例如 `http://127.0.0.1:7457`,而非 `http://127.0.0.1:0``:0` 仅是内部用于"自动选择可用端口"的启动占位值,不应泄露到 agent 会话中。
仅运行 daemon 的生产模式下daemon 会自行在 `http://localhost:7456` 提供 Next.js 的静态导出产物,不经过反向代理。
若在 daemon 前部署了 nginx请关闭 SSE 路由的 buffering 与压缩。常见问题:浏览器控制台在 80-90 秒后报错 `net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)`——原因是 nginx 的 `gzip on` 会缓冲分块的 SSE 响应,即使 daemon 已发送 `X-Accel-Buffering: no`
```nginx
location /api/ {
proxy_pass http://127.0.0.1:7456;
proxy_buffering off;
gzip off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## 两种执行模式
| 模式 | picker 中的值 | 请求流转路径 |
|---|---|---|
| **Local CLI**daemon 检测到 agent 时的默认模式) | "Local CLI" | 前端 → daemon `/api/chat``spawn(<agent>, ...)` → stdout → SSE → artifact 解析器 → 预览 |
| **API 模式**fallback / 未安装 CLI | "Anthropic API" / "OpenAI API" / "Azure OpenAI" / "Google Gemini" | 前端 → daemon `/api/proxy/{provider}/stream` → provider SSE 归一化为 `delta/end/error` → artifact 解析器 → 预览 |
两种模式均送入**同一个** `<artifact>` 解析器与**同一个**沙箱 iframe。区别仅在于传输层和 system prompt 的投递方式(本地 CLI 没有独立的 system 通道,因此组合好的 prompt 会被折叠进 user message
## Prompt 组合
每次 send 时,应用都会从三层构建 system prompt然后发送至 provider
```
BASE_SYSTEM_PROMPT (输出契约:用 <artifact> 包裹,不使用 code fence
+ 当前激活的 design system 正文 DESIGN.md —— 色板 / 字体 / 布局)
+ 当前激活的 skill 正文 SKILL.md —— 工作流与输出规则)
```
在顶部 bar 切换 skill 或 design system 后,下一次 send 将使用新的组合。正文会按 session 在内存中缓存,每次切换仅需从 daemon 获取一次。
## 文件结构
```
open-design/
├── apps/
│ ├── daemon/ # Node/Express —— 启动本地 agent + 提供 API
│ │ └── src/
│ │ ├── cli.ts # `od` bin 入口
│ │ ├── server.ts # /api/* + 静态资源
│ │ ├── agents.ts # 扫描 PATH 中的 claude/codex/devin/gemini/opencode/cursor-agent/qwen/qoder/copilot
│ │ ├── skills.ts # SKILL.md loaderfrontmatter 解析器)
│ │ └── design-systems.ts # DESIGN.md loader
│ │ ├── sidecar/ # tools-dev daemon sidecar 包装层
│ │ └── tests/ # daemon 包的测试
│ ├── web/ # Next.js 16 App Router + React 客户端
│ ├── app/ # App Router 入口
│ ├── src/ # React + TypeScript 客户端 / runtime 模块
│ │ ├── App.tsx # 调度 mode / skill / DS picker + send
│ │ ├── providers/ # daemon + BYOK API transport
│ │ ├── prompts/ # system、discovery、directions、deck framework
│ │ ├── artifacts/ # 流式 <artifact> 解析器 + manifest
│ │ ├── runtime/ # iframe srcdoc、markdown、export 辅助函数
│ │ └── state/ # localStorage + 由 daemon 持久化的 project 状态
│ ├── sidecar/ # tools-dev web sidecar 包装层
│ └── next.config.ts # tools-dev rewrites + 生产环境 apps/web/out 导出配置
│ └── desktop/ # Electron runtime由 tools-dev 启动 / 检查
├── packages/
│ ├── contracts/ # 共享的 web/daemon 应用契约
│ ├── sidecar-proto/ # Open Design sidecar 协议契约
│ ├── sidecar/ # 通用 sidecar runtime 原语
│ └── platform/ # 通用 process/platform 原语
├── tools/dev/ # `pnpm tools-dev` 生命周期与 inspect CLI
├── e2e/ # Playwright UI + 外部集成 / Vitest 测试场
├── skills/ # SKILL.md —— 任何 Claude Code skill 仓库均可直接放入
│ ├── web-prototype/ # 通用单屏原型prototype mode 的默认)
│ ├── saas-landing/ # 营销页hero / features / pricing / CTA
│ ├── dashboard/ # 后台 / 分析 dashboard
│ ├── pricing-page/ # 独立的定价 + 对比页
│ ├── docs-page/ # 三栏文档布局
│ ├── blog-post/ # 长文编辑风格
│ ├── mobile-app/ # 手机边框单屏
│ ├── simple-deck/ # 最小化横向翻页 deck
│ └── guizang-ppt/ # magazine-web-ppt —— deck/PPT 默认捆绑包
│ ├── SKILL.md
│ ├── assets/template.html
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ # DESIGN.md —— 9 段式 schemaawesome-claude-design
│ ├── default/ # Neutral Modernstarter
│ ├── warm-editorial/ # Warm Editorialstarter
│ ├── README.md # 目录概览
│ └── …129 systems # 2 套 starter · 70 套产品系统 · 57 个 design skill
├── scripts/sync-design-systems.ts # 从上游 getdesign tarball 重新导入
├── docs/ # 产品愿景 + spec
├── .od/ # runtime 数据gitignore自动创建
│ ├── app.sqlite # projects / conversations / messages / tabs
│ ├── artifacts/ # 一次性 "Save to disk" 产物
│ └── projects/<id>/ # 按 project 划分的工作目录 + agent cwd
├── pnpm-workspace.yaml # apps/* + packages/* + tools/* + e2e
└── package.json # 根级质量脚本 + `od` bin
```
## 排障
- **"no agents found on PATH"** —— 安装以下 CLI 之一:`claude``codex``devin``gemini``opencode``cursor-agent``qwen``qodercli``copilot`。或者在 Settings 中切换至 API mode填入 provider key。
- **daemon 在 /api/chat 上返回 500** —— 查看 daemon 终端的 stderr 尾部;通常是 CLI 拒绝了传入的参数。不同 CLI 的 argv 结构各异;如需调整,请参阅 `apps/daemon/src/agents.ts` 中的 `buildArgs`
- **媒体生成报错 `OD_BIN` 缺失、或 daemon URL 为 `:0`** —— 运行上述媒体 dispatcher 排查步骤。请勿复用已有的 CLI 会话;从 Open Design 应用中重新打开 projectdaemon 才会注入新的 `OD_*` 变量。
- **Codex 加载的插件上下文过多** —— 使用 `OD_CODEX_DISABLE_PLUGINS=1 pnpm tools-dev` 启动 Open Designdaemon 启动 Codex 时会传入 `--disable plugins`
- **artifact 始终不渲染** —— 模型输出了文本但未使用 `<artifact>` 包裹。请确认 system prompt 已正确传递(查看 daemon 日志),然后考虑更换能力更强的模型或更严格的 skill。
## 回到产品愿景
本 Quickstart 对应 [`docs/`](docs/) 中 spec 的可运行起点spec 描述了其演进方向(见 [`docs/roadmap.md`](docs/roadmap.md))。要点如下:
- `docs/architecture.md` 描述了当前已交付的 stack前端为 Next.js 16 App Router后端为本地 daemon`apps/web/next.config.ts` 在 dev 模式下进行 rewrite使浏览器始终通过同一套 `/api` 入口通信。
- `docs/skills-protocol.md` 描述了完整的 `od:` frontmatter类型化输入、slider、能力 gating。当前 MVP 仅读取 `name` / `description` / `triggers` / `od.mode` / `od.design_system.requires` —— 如需支持更多字段,请扩展 `apps/daemon/src/skills.ts`
- `docs/agent-adapters.md` 展望了更丰富的 dispatch能力检测、流式 tool call。我们的 `apps/daemon/src/agents.ts` 是最小化的 dispatcher —— 刚好足够验证链路通畅。
- `docs/modes.md` 列出了四种 modeprototype / deck / template / design-system。前两种已有对应的 skillpicker 已按 `mode` 过滤。

831
README.ar.md Normal file
View File

@@ -0,0 +1,831 @@
<div dir="rtl">
# 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.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — غلاف افتتاحي: صمّم مع الوكيل على حاسوبك المحمول" width="100%" />
</p>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="تنزيل" src="https://img.shields.io/badge/%D8%AA%D9%86%D8%B2%D9%8A%D9%84-open--design.ai-ff6b35?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#الوكلاء-المدعومون"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#أنظمة-التصميم"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#الـ-skills"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-انضم-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.ja-JP.md">日本語</a> · <b>العربية</b> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a></p>
---
## لماذا وُجد هذا المشروع
أظهر [Claude Design][cd] من Anthropic (الذي صدر في 2026-04-17 مبنياً على Opus 4.7) ما يحدث حين يتوقّف الـ LLM عن كتابة النصوص ويبدأ بتسليم منتجات تصميم فعلية. انتشر بسرعة — وبقي **مغلق المصدر**، مدفوعاً، يعمل في السحابة فقط، ومرتبطاً بنماذج Anthropic ومهاراتها الداخلية. لا checkout، لا استضافة ذاتية، لا نشر على Vercel، ولا إمكانية لاستبدال الوكيل.
**Open Design (OD) هو البديل مفتوح المصدر.** نفس الحلقة، نفس النموذج الذهني المتمحور حول الـ artifact، بدون أيّ قيود. نحن لا نشحن وكيلاً — أقوى وكلاء البرمجة موجودون أصلاً على حاسوبك. ما نقدّمه هو ربطهم بسير عمل تصميمي مدفوع بالـ Skills يعمل محلياً عبر `pnpm tools-dev`، يمكن نشر طبقة الويب منه على Vercel، ويبقى BYOK في كل طبقة.
اكتب `اصنع لي pitch deck بأسلوب مجلّة لجولة seed`. ينبثق نموذج الأسئلة التفاعلي قبل أن يرتجل النموذج بكسلاً واحداً. يختار الوكيل أحد خمسة اتجاهات بصرية منتقاة. تنساب خطّة `TodoWrite` حيّة إلى الواجهة. يبني الـ daemon مجلد مشروع حقيقياً على القرص يحوي قالب seed، مكتبة layouts، و checklist للفحص الذاتي. يقرأها الوكيل — pre-flight إلزامي — ثم يجري تقييماً ذاتياً خماسي الأبعاد على ناتجه، ويُصدر `<artifact>` واحداً يُعرض في iframe معزول خلال ثوانٍ.
هذا ليس "ذكاء اصطناعي يحاول التصميم". هذا ذكاء اصطناعي دُرِّب — عبر مكدّس البرومبت — ليتصرّف كمصمّم خبير لديه نظام ملفات يعمل، مكتبة ألوان حتميّة، وثقافة checklist — تماماً المستوى الذي حدّده Claude Design، لكنه هذه المرة مفتوح وملك لك.
يرتكز OD على أربعة مشاريع مفتوحة المصدر:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — بوصلة فلسفة التصميم. سير عمل Junior-Designer، بروتوكول الأصول البصرية المؤلف من 5 خطوات، checklist مكافحة AI-slop، التقييم الذاتي خماسي الأبعاد، وفكرة "5 مدارس × 20 فلسفة تصميم" خلف منتقي الاتجاه — كل ذلك مكثّف في [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — وضع الـ deck. مُضمَّن حرفياً تحت [`skills/guizang-ppt/`](skills/guizang-ppt/) مع الحفاظ على LICENSE الأصلية؛ تخطيطات بأسلوب المجلّات، WebGL hero، و checklist بمستويات P0/P1/P2.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — نجم UX الشمالي وأقرب أقراننا. أوّل بديل مفتوح المصدر لـ Claude-Design. اقتبسنا منه حلقة الـ artifact المُتدفّق، نمط معاينة iframe المعزول (مع React 18 + Babel مضمّنين)، لوحة الوكيل الحيّة (todos + tool calls + إمكانية المقاطعة)، وقائمة التصدير بخمسة صيغ (HTML / PDF / PPTX / ZIP / Markdown). تعمّدنا التباعد في الشكل العام — هم تطبيق سطح مكتب Electron يضمّ [`pi-ai`][piai]، ونحن تطبيق ويب + daemon محلي يفوّض المهمة لـ CLI الموجودة لديك.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — معمارية الـ daemon ومنظومة التشغيل. اكتشاف الوكلاء بمسح `PATH`، والـ daemon المحلي بوصفه العملية المميَّزة الوحيدة، ورؤية "الوكيل كزميل فريق".
## نظرة سريعة
| | ما تحصل عليه |
|---|---|
| **أدوات CLI لوكلاء البرمجة (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — يكتشفها تلقائياً من `PATH`، وتبدّل بينها بنقرة واحدة |
| **بديل BYOK** | بروكسي API خاص بكل بروتوكول على `/api/proxy/{anthropic,openai,azure,google}/stream` — الصق `baseUrl` + `apiKey` + `model`، اختر Anthropic / OpenAI / Azure OpenAI / Google Gemini، ويُطبّع الـ daemon أحداث SSE إلى نفس chat stream. يتمّ صدّ عناوين IP الداخلية وثغرات SSRF عند حدود الـ daemon. |
| **أنظمة تصميم مدمجة** | **129** — 2 starters مكتوبة يدوياً + 70 نظاماً للمنتجات (Linear، Stripe، Vercel، Airbnb، Tesla، Notion، Anthropic، Apple، Cursor، Supabase، Figma، Xiaohongshu، …) من [`awesome-design-md`][acd2]، إضافة إلى 57 design skill من [`awesome-design-skills`][ads] أُضيفت مباشرة تحت `design-systems/` |
| **Skills مدمجة** | **31** — 27 في وضع `prototype` (web-prototype، saas-landing، dashboard، mobile-app، gamified-app، social-carousel، magazine-poster، dating-web، sprite-animation، motion-frames، critique، tweaks، wireframe-sketch، pm-spec، eng-runbook، finance-report، hr-onboarding، invoice، kanban-board، team-okrs، …) + 4 في وضع `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). مُجمَّعة في الـ picker حسب `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **توليد الوسائط** | تشتغل أسطح الصورة والفيديو والصوت بالتوازي مع حلقة التصميم. **gpt-image-2** (Azure / OpenAI) للملصقات والصور الرمزية والإنفوغرافيك وخرائط المدن المرسومة · **Seedance 2.0** (ByteDance) لـ 15 ثانية t2v + i2v بطابع سينمائي · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) لتحويل HTML→MP4 (إعلانات منتجات، طباعة حركية، رسومات بيانية، بطاقات اجتماعية، Logo outros). معرض **93** برومبت جاهزة للاستنساخ — 43 لـ gpt-image-2 + 39 لـ Seedance + 11 لـ HyperFrames — تحت [`prompt-templates/`](prompt-templates/) مع صور معاينة وبيانات المصدر. نفس واجهة الـ chat كما في الكود؛ المخرجات ملفات `.mp4` / `.png` حقيقية تنزل في مساحة عمل المشروع. |
| **الاتجاهات البصرية** | 5 مدارس منتقاة (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — كل واحدة تأتي بلوحة OKLch حتميّة + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **إطارات الأجهزة** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — دقيقة على مستوى البكسل، مُشتركة عبر الـ skills تحت [`assets/frames/`](assets/frames/) |
| **Agent runtime** | الـ daemon المحلي يُشغّل CLI داخل مجلد مشروعك — يحصل الوكيل على أدوات `Read` / `Write` / `Bash` / `WebFetch` حقيقية على نظام ملفات حقيقي، مع fallbacks على Windows لتجاوز قيود `ENAMETOOLONG` (stdin / ملف برومبت مؤقت) في كل adapter |
| **الاستيراد** | اسحب ملف ZIP مُصدَّر من [Claude Design][cd] إلى مربّع الترحيب — `POST /api/import/claude-design` يفكّه إلى مشروع حقيقي ليُكمل وكيلك من حيث توقّف Anthropic |
| **الاستمرارية** | SQLite في `.od/app.sqlite`: projects · conversations · messages · tabs · قوالب المستخدم. افتح المشروع غداً، فتجد بطاقة todo والملفات المفتوحة في مكانها تماماً. |
| **دورة الحياة** | مدخل واحد: `pnpm tools-dev` (start / stop / run / status / logs / inspect / check) — يُقلع daemon + web (+ desktop) بـ stamps مكتوبة |
| **سطح المكتب** | غلاف Electron اختياري بسبيل renderer معزول + sidecar IPC (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN) — يُشغّل `tools-dev inspect desktop screenshot` لاختبارات E2E |
| **أهداف النشر** | محلياً (`pnpm tools-dev`) · طبقة الويب على Vercel · تطبيق سطح مكتب Electron مُحزَّم لـ macOS (Apple Silicon) و Windows (x64) — حمّله من [open-design.ai](https://open-design.ai/) أو من [أحدث release](https://github.com/nexu-io/open-design/releases) |
| **الترخيص** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ads]: https://github.com/bergside/awesome-design-skills
## عرض توضيحي
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · واجهة الدخول" /><br/>
<sub><b>واجهة الدخول</b> — اختر skill، اختر نظام تصميم، واكتب الطلب. نفس السطح يخدم prototypes و decks وتطبيقات الموبايل و dashboards وصفحات editorial.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · نموذج اكتشاف turn-1" /><br/>
<sub><b>نموذج الاكتشاف turn-1</b> — قبل أن يكتب النموذج بكسلاً واحداً، يُثبّت OD التفاصيل: surface، الجمهور، النبرة، السياق البصري، النطاق. 30 ثانية من خانات الاختيار توفّر 30 دقيقة من التراجعات.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · منتقي الاتجاه" /><br/>
<sub><b>منتقي الاتجاه</b> — حين لا يملك المستخدم هوية بصرية، يُطلق الوكيل نموذجاً ثانياً فيه 5 اتجاهات منتقاة (Monocle / Modern Minimal / Tech Utility / Brutalist / Soft Warm). نقرة واحدة → لوحة ألوان حتميّة + font stack، بلا ارتجال.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · تقدّم الـ todo الحيّ" /><br/>
<sub><b>تقدّم الـ todo الحيّ</b> — تنساب خطّة الوكيل كبطاقة حيّة. تنتقل العناصر من <code>in_progress</code> إلى <code>completed</code> آنياً. يمكن للمستخدم التدخّل وتصحيح المسار بتكلفة منخفضة جداً.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · المعاينة المعزولة" /><br/>
<sub><b>المعاينة المعزولة</b> — كلّ <code>&lt;artifact&gt;</code> يُعرض في srcdoc iframe نظيف. قابل للتحرير في المكان عبر مساحة الملفات؛ قابل للتنزيل HTML / PDF / ZIP.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · مكتبة الأنظمة الـ72" /><br/>
<sub><b>مكتبة الأنظمة الـ72</b> — كل نظام منتج يعرض بطاقته رباعية الألوان. اضغط لرؤية ملف <code>DESIGN.md</code> الكامل وشبكة الألوان والعرض الحيّ.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · Magazine deck" /><br/>
<sub><b>وضع Deck (guizang-ppt)</b> — الـ <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> المُضمَّن يدخل دون تعديل. تخطيطات مجلّة، خلفيات WebGL hero، خرج HTML بملف واحد، تصدير PDF.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · نموذج موبايل" /><br/>
<sub><b>نموذج موبايل</b> — chrome دقيق على مستوى البكسل لـ iPhone 15 Pro (Dynamic Island، رموز SVG لشريط الحالة، Home Indicator). النماذج متعدّدة الشاشات تستخدم أصول <code>/frames/</code> المشتركة، فلا يعيد الوكيل رسم الهاتف أبداً.</sub>
</td>
</tr>
</table>
## الـ Skills
**31 skill جاهزة في الصندوق.** كل واحدة مجلد تحت [`skills/`](skills/) يتبع اصطلاح Claude Code [`SKILL.md`][skill] مع frontmatter موسّع `od:` يفسّره الـ daemon حرفياً — `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)).
يحمل الكتالوج وضعان رئيسيان: **`prototype`** (27 skill — أيّ شيء يُعرض كصفحة artifact واحدة، من landing بأسلوب مجلّة إلى شاشة هاتف إلى مستند PM spec) و**`deck`** (4 skills — عروض أفقية مع إطار deck-framework). حقل **`scenario`** هو ما يُجمِّع به الـ picker: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
### أمثلة العرض
الـ skills الأكثر تميّزاً بصرياً والأنسب لأوّل تجربة. كل واحدة تأتي بـ `example.html` حقيقي يمكنك فتحه مباشرة من المستودع لرؤية ما سيُنتجه الوكيل بالضبط — بدون auth ولا إعداد.
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>لوحة معلومات استهلاكية للمواعدة / التوافق — شريط جانبي للتنقّل، شريط أخبار، KPIs، رسم بياني للتطابق المتبادل خلال 30 يوماً، طباعة editorial.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>دليل رقمي من صفحتين — غلاف (عنوان، مؤلف، تشويق TOC) + صفحة درس بـ pull-quote وقائمة خطوات. نبرة المنشئين / lifestyle.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>إيميل HTML لإطلاق منتج — masthead، صورة hero، عنوان مقفَّل، CTA، شبكة مواصفات. عمود واحد متمركز، آمن مع table-fallback.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>نموذج تطبيق موبايل بطابع لعبة من ثلاث شاشات على خلفية عرض داكنة — غلاف، مهام اليوم بـ XP وشريط مستوى، تفاصيل المهمة.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>تدفّق onboarding للموبايل بثلاث شاشات — splash، عرض القيمة، تسجيل الدخول. شريط الحالة، نقاط التمرير، CTA رئيسي.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>إطار motion-design واحد بحركات CSS متكرّرة — حلقة طباعة دوّارة، كرة أرضية متحرّكة، مؤقّت. جاهز للتسليم إلى HyperFrames.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>كاروسيل ثلاثي 1080×1080 لمنصّات التواصل — لوحات سينمائية بعناوين تتداخل عبر السلسلة، علامة هوية، إشارة loop.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>شريحة شرح متحرّكة بأسلوب pixel / 8-bit — مسرح كريمي ممتلئ، تميمة بكسل متحرّكة، طباعة يابانية حركية، CSS keyframes تتكرّر.</sub>
</td>
</tr>
</table>
### أسطح التصميم والتسويق (وضع prototype)
| Skill | المنصّة | السيناريو | المُخرَج |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | desktop | design | HTML بصفحة واحدة — landings، تسويق، صفحات hero (الافتراضي لـ prototype) |
| [`saas-landing`](skills/saas-landing/) | desktop | marketing | تخطيط Hero / features / pricing / CTA |
| [`dashboard`](skills/dashboard/) | desktop | operation | لوحة إدارة / تحليلات بشريط جانبي + بيانات كثيفة |
| [`pricing-page`](skills/pricing-page/) | desktop | sale | صفحة تسعير مستقلّة + جداول مقارنة |
| [`docs-page`](skills/docs-page/) | desktop | engineering | تخطيط توثيق ثلاثي الأعمدة |
| [`blog-post`](skills/blog-post/) | desktop | marketing | مقال طويل بنمط editorial |
| [`mobile-app`](skills/mobile-app/) | mobile | design | شاشة(ات) تطبيق داخل إطار iPhone 15 Pro / Pixel |
| [`mobile-onboarding`](skills/mobile-onboarding/) | mobile | design | تدفّق onboarding متعدّد الشاشات (splash · عرض القيمة · تسجيل الدخول) |
| [`gamified-app`](skills/gamified-app/) | mobile | personal | نموذج تطبيق موبايل بطابع لعبة من ثلاث شاشات |
| [`email-marketing`](skills/email-marketing/) | desktop | marketing | إيميل HTML لإطلاق منتج (آمن مع table-fallback) |
| [`social-carousel`](skills/social-carousel/) | desktop | marketing | كاروسيل ثلاثي 1080×1080 |
| [`magazine-poster`](skills/magazine-poster/) | desktop | marketing | ملصق مجلّة بصفحة واحدة |
| [`motion-frames`](skills/motion-frames/) | desktop | marketing | إطار motion-design بحركات CSS متكرّرة |
| [`sprite-animation`](skills/sprite-animation/) | desktop | marketing | شريحة شرح متحرّكة بأسلوب pixel / 8-bit |
| [`dating-web`](skills/dating-web/) | desktop | personal | mockup لـ dashboard مواعدة استهلاكي |
| [`digital-eguide`](skills/digital-eguide/) | desktop | marketing | دليل رقمي من صفحتين (غلاف + درس) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | desktop | design | إسكتش يدوي للأفكار الأوليّة — يخدم جولة "أرِ شيئاً مرئياً مبكراً" |
| [`critique`](skills/critique/) | desktop | design | بطاقة تقييم ذاتي خماسية الأبعاد (Philosophy · Hierarchy · Detail · Function · Innovation) |
| [`tweaks`](skills/tweaks/) | desktop | design | لوحة tweaks يطلقها الذكاء الاصطناعي — يقترح النموذج بنفسه القيم التي تستحقّ التعديل |
### أسطح Deck (وضع deck)
| Skill | الافتراضي لـ | المُخرَج |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **الافتراضي** لوضع deck | PPT ويب بأسلوب مجلّة — مُضمَّن حرفياً من [op7418/guizang-ppt-skill][guizang] مع الحفاظ على LICENSE الأصلية |
| [`simple-deck`](skills/simple-deck/) | — | deck أفقي بسيط |
| [`replit-deck`](skills/replit-deck/) | — | deck لاستعراض منتج (بأسلوب Replit) |
| [`weekly-update`](skills/weekly-update/) | — | إيقاع أسبوعي لفريق على شكل deck (التقدّم · العوائق · التالي) |
### أسطح المكتب والعمليات (وضع prototype مع سيناريوهات الوثائق)
| Skill | السيناريو | المُخرَج |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | مستند PM spec بفهرس + سجل قرارات |
| [`team-okrs`](skills/team-okrs/) | product | بطاقة OKR |
| [`meeting-notes`](skills/meeting-notes/) | operation | سجل قرارات اجتماع |
| [`kanban-board`](skills/kanban-board/) | operation | لقطة لوحة Kanban |
| [`eng-runbook`](skills/eng-runbook/) | engineering | runbook لحوادث الإنتاج |
| [`finance-report`](skills/finance-report/) | finance | ملخّص مالي تنفيذي |
| [`invoice`](skills/invoice/) | finance | فاتورة بصفحة واحدة |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | خطّة onboarding لدور وظيفي |
إضافة skill جديدة = مجلّد واحد. اقرأ [`docs/skills-protocol.md`](docs/skills-protocol.md) لمعرفة الـ frontmatter الموسّع، fork لـ skill قائمة، أعد تشغيل الـ daemon، وستظهر في الـ picker. نقطة الكتالوج هي `GET /api/skills`؛ تجميع seed لكل skill (template + ملفات references) يقع على `GET /api/skills/:id/example`.
## ستّة أفكار حاملة
### 1 · لا نشحن وكيلاً، وكيلك كافٍ
الـ daemon يمسح `PATH` بحثاً عن [`claude`](https://docs.anthropic.com/en/docs/claude-code) و [`codex`](https://github.com/openai/codex) و `devin` و [`cursor-agent`](https://www.cursor.com/cli) و [`gemini`](https://github.com/google-gemini/gemini-cli) و [`opencode`](https://opencode.ai/) و [`qwen`](https://github.com/QwenLM/qwen-code) و `qodercli` و [`copilot`](https://github.com/features/copilot/cli) و `hermes` و `kimi` و [`pi`](https://github.com/mariozechner/pi-ai) و [`kiro-cli`](https://kiro.dev) و [`vibe-acp`](https://github.com/mistralai/mistral-vibe) عند الإقلاع. ما يجده يصبح محرّك تصميم مرشّحاً — يُشغَّل عبر stdio بـ adapter لكل CLI، قابل للتبديل من الـ model picker. الإلهام من [`multica`](https://github.com/multica-ai/multica) و [`cc-switch`](https://github.com/farion1231/cc-switch). لا CLI مثبتة؟ وضع API هو نفس خط الأنابيب بدون spawn — اختر Anthropic أو متوافق مع OpenAI أو Azure OpenAI أو Google Gemini ويُعيد الـ daemon توجيه قطع SSE المُطبَّعة، مع رفض loopback / link-local / RFC1918 عند الحدّ.
### 2 · الـ Skills ملفات، لا plugins
اتّباعاً لاصطلاح Claude Code [`SKILL.md`](https://docs.anthropic.com/en/docs/claude-code/skills)، كل skill = `SKILL.md` + `assets/` + `references/`. ضع مجلّداً في [`skills/`](skills/)، أعد تشغيل الـ daemon، وستظهر في الـ picker. الـ `magazine-web-ppt` المضمَّنة هي [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) **حرفياً** — مع الحفاظ على الترخيص والإسناد الأصلي.
### 3 · أنظمة التصميم Markdown قابل للنقل، لا theme JSON
مخطّط `DESIGN.md` المؤلف من 9 أقسام من [`VoltAgent/awesome-design-md`][acd2] — color، typography، spacing، layout، components، motion، voice، brand، anti-patterns. كلّ artifact يقرأ من النظام النشط. بدّل النظام → الرندر التالي يستخدم الـ tokens الجديدة. القائمة المنسدلة تأتي بـ **Linear، Stripe، Vercel، Airbnb، Tesla، Notion، Apple، Anthropic، Cursor، Supabase، Figma، Resend، Raycast، Lovable، Cohere، Mistral، ElevenLabs، X.AI، Spotify، Webflow، Sanity، PostHog، Sentry، MongoDB، ClickHouse، Cal، Replicate، Clay، Composio، Xiaohongshu…** — إضافة إلى 57 design skill من [`awesome-design-skills`][ads].
### 4 · نموذج الأسئلة التفاعلي يمنع 80% من التراجعات
يُحدِّد مكدّس برومبت OD `RULE 1` بشكل صارم: كل brief تصميم جديد يبدأ بـ `<question-form id="discovery">` وليس بكود. Surface · الجمهور · النبرة · سياق الهوية · النطاق · القيود. حتى الـ brief الطويل يترك قرارات تصميمية مفتوحة — النبرة البصرية، موقف الألوان، النطاق — وهي تحديداً ما يُثبّته النموذج خلال 30 ثانية. تكلفة الاتجاه الخاطئ هي جولة chat واحدة، لا deck كامل.
هذا هو **وضع Junior-Designer** المستخلص من [`huashu-design`](https://github.com/alchaincyf/huashu-design): اجمع الأسئلة دفعة واحدة في البداية، أرِ شيئاً مرئياً مبكراً (حتى لو wireframe بكتل رمادية)، ودَع المستخدم يصحّح المسار بتكلفة منخفضة. مدمجاً مع بروتوكول الأصول البصرية (locate · download · `grep` للـ hex · كتابة `brand-spec.md` · vocalise)، هذا هو السبب الأكبر في أن المخرج يتوقّف عن الإحساس بكونه AI freestyle ويبدأ يبدو كمصمّم انتبه لمصادره قبل أن يبدأ الرسم.
### 5 · الـ daemon يجعل الوكيل يحسّ أنه على حاسوبك، لأنه فعلاً كذلك
عند `spawn` الـ CLI، يضبط الـ daemon `cwd` على مجلّد artifacts المشروع تحت `.od/projects/<id>/`. يحصل الوكيل على `Read` / `Write` / `Bash` / `WebFetch` — أدوات حقيقية على نظام ملفات حقيقي. يستطيع `Read` لـ `assets/template.html` الخاص بالـ skill، `grep` على CSS لاستخراج قيم hex، كتابة `brand-spec.md`، إنزال صور مولّدة، وإنتاج ملفات `.pptx` / `.zip` / `.pdf` تظهر في مساحة الملفات كقطع تنزيل عند انتهاء الجولة. الجلسات والمحادثات والرسائل والـ tabs تُحفظ كلها في SQLite محلية — افتح المشروع غداً تجد بطاقة todo حيث تركتها.
### 6 · مكدّس البرومبت هو المنتج
ما تُكوِّنه عند الإرسال ليس "system + user". بل:
```
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)
+ 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)
```
كل طبقة قابلة للتركيب. كل طبقة ملف يمكنك تعديله. اقرأ [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) و [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) لرؤية العقد الحقيقي.
## المعمارية
```
┌────────────────────── browser (Next.js 16) ──────────────────────┐
│ chat · file workspace · iframe preview · settings · imports │
└──────────────┬───────────────────────────────────┬───────────────┘
│ /api/* (rewritten in dev) │
▼ ▼
┌──────────────────────────────────┐ /api/proxy/{provider}/stream (SSE)
│ Local daemon (Express + SQLite) │ ─→ any OpenAI-compat
│ │ endpoint (BYOK)
│ /api/agents /api/skills│ w/ SSRF blocking
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/{provider}/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (static) /frames (static)
│ optional: sidecar IPC at /tmp/open-design/ipc/<ns>/<app>.sock
│ (STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN)
└─────────┬────────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · devin (ACP) · gemini · opencode · cursor-agent │
│ qwen · qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) · kiro (ACP) · vibe (ACP) │
│ reads SKILL.md + DESIGN.md, writes artifacts to disk │
└──────────────────────────────────────────────────────────────────┘
```
| الطبقة | المكدّس |
|---|---|
| الواجهة الأمامية | Next.js 16 App Router + React 18 + TypeScript، قابل للنشر على Vercel |
| Daemon | Node 24 · Express · بثّ SSE · `better-sqlite3`؛ الجداول: `projects` · `conversations` · `messages` · `tabs` · `templates` |
| نقل الوكلاء | `child_process.spawn`؛ بمحلّلات أحداث مكتوبة لـ `claude-stream-json` (Claude Code)، `qoder-stream-json` (Qoder CLI)، `copilot-stream-json` (Copilot)، محلّلات `json-event-stream` لكل CLI (Codex / Gemini / OpenCode / Cursor Agent)، `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe عبر Agent Client Protocol)، `pi-rpc` (Pi عبر stdio JSON-RPC)، `plain` (Qwen Code / DeepSeek TUI) |
| BYOK proxy | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → APIs أعلى التيار خاصة بكل provider، SSE مُطبَّعة `delta/end/error`؛ يرفض loopback / link-local / RFC1918 عند حدّ الـ daemon |
| التخزين | ملفات عادية في `.od/projects/<id>/` + SQLite في `.od/app.sqlite` + اعتمادات في `.od/media-config.json` (في gitignore، تُنشأ تلقائياً). `OD_DATA_DIR=<dir>` ينقل كل بيانات الـ daemon (تُستخدم لعزل الاختبارات وإعدادات التثبيت للقراءة فقط)؛ `OD_MEDIA_CONFIG_DIR=<dir>` يضيّق التجاوز إلى `media-config.json` فقط لإبقاء مفاتيح API في موقع منفصل |
| المعاينة | iframe معزولة عبر `srcdoc` + محلّل `<artifact>` لكل skill ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
| التصدير | HTML (مع inlining للأصول) · PDF (طباعة المتصفّح، مع وعي بالـ deck) · PPTX (مدفوع بالوكيل عبر skill) · ZIP (archiver) · Markdown |
| دورة الحياة | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`؛ المنافذ عبر `--daemon-port` / `--web-port`، النطاقات عبر `--namespace` |
| سطح المكتب (اختياري) | غلاف Electron — يكتشف رابط الويب عبر sidecar IPC، بدون تخمين منافذ؛ نفس قناة `STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN` تُشغّل `tools-dev inspect desktop …` لاختبارات E2E |
## Quickstart
### تنزيل تطبيق سطح المكتب (بدون بناء)
أسرع طريقة لتجربة Open Design هي تطبيق سطح المكتب الجاهز — بدون Node، بدون pnpm، بدون clone:
- **[open-design.ai](https://open-design.ai/)** — صفحة التنزيل الرسمية
- **[إصدارات GitHub](https://github.com/nexu-io/open-design/releases)**
### التشغيل من المصدر
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # should print 10.33.2
pnpm install
pnpm tools-dev run web
# open the web URL printed by tools-dev
```
مشغّل Windows: ابنِ `OpenDesign.exe` بنفسك باتباع التعليمات في `tools/launcher/README.md`، أو نزّله من GitHub Releases. بعد ذلك ضعه في جذر المستودع وانقر عليه مرتين ليشغّل `pnpm install` عند الحاجة ثم يبدأ Open Design عبر `pnpm tools-dev`.
متطلّبات البيئة: Node `~24` و pnpm `10.33.x`. أدوات `nvm`/`fnm` اختيارية فقط؛ إن استخدمت إحداها فشغّل `nvm install 24 && nvm use 24` أو `fnm install 24 && fnm use 24` قبل `pnpm install`.
لتشغيل سطح المكتب / الخلفية، إعادة التشغيل بمنافذ ثابتة، وفحوص dispatcher توليد الوسائط (`OD_BIN`، `OD_DAEMON_URL`، `apps/daemon/dist/cli.js`) راجع [`QUICKSTART.md`](QUICKSTART.md).
عند أوّل تحميل:
1. يكتشف أيّ CLI وكلاء على `PATH` ويختار واحدة تلقائياً.
2. يحمّل 31 skill + 72 نظام تصميم.
3. يُظهر مربع الترحيب لتلصق Anthropic key (مطلوب فقط لمسار BYOK البديل).
4. **ينشئ `./.od/` تلقائياً** — مجلد التشغيل المحلي الذي يحوي SQLite للمشاريع، artifacts كل مشروع، والرندرز المحفوظة. لا يوجد `od init`؛ الـ daemon يعمل `mkdir` لما يحتاجه عند الإقلاع.
اكتب طلباً، اضغط **Send**، شاهد نموذج الأسئلة يصل، املأه، شاهد بطاقة todo تنساب، شاهد الـ artifact يُرسم. اضغط **Save to disk** أو نزِّل المشروع كـ ZIP.
### حالة أوّل تشغيل (`./.od/`)
يمتلك الـ daemon مجلداً مخفياً واحداً في جذر المستودع. كلّ ما فيه في gitignore ومحلّي للجهاز — لا تُجرِ commit له أبداً.
```
.od/
├── app.sqlite ← projects · conversations · messages · open tabs
├── artifacts/ ← one-off "Save to disk" renders (timestamped)
└── projects/<id>/ ← per-project working dir, also the agent's cwd
```
| تريد… | افعل |
|---|---|
| فحص ما بداخله | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| الإعادة إلى حالة نظيفة | `pnpm tools-dev stop` ثم `rm -rf .od` ثم `pnpm tools-dev run web` |
| نقله إلى مكان آخر | غير مدعوم بعد — المسار مُكوَّد نسبياً إلى المستودع |
خريطة الملفات الكاملة، السكربتات، واستكشاف الأخطاء → [`QUICKSTART.md`](QUICKSTART.md).
## تشغيل المشروع
يمكن تشغيل Open Design كتطبيق ويب في متصفّحك، أو كتطبيق سطح مكتب Electron. كلا الوضعين يتشاركان نفس معمارية الـ daemon المحلي + الويب.
### الويب / Localhost (الافتراضي)
```bash
# Foreground mode — keeps the lifecycle command in the foreground (logs written to files)
pnpm tools-dev run web
# View recent logs:
pnpm tools-dev logs
# Background mode — daemon + web run as background processes
pnpm tools-dev start web
```
افتراضياً، يربط `tools-dev` نفسه بمنافذ ephemeral متاحة ويطبع الروابط الفعلية عند الإقلاع. لاستخدام منافذ ثابتة من حالة متوقّفة:
```bash
pnpm tools-dev run web --daemon-port 17456 --web-port 17573
```
إذا كان daemon/web يعملان بالفعل، استخدم `restart` لتبديل المنافذ في الجلسة القائمة:
```bash
pnpm tools-dev restart --daemon-port 17456 --web-port 17573
```
### سطح المكتب / Electron
```bash
# Start daemon + web + desktop in the background
pnpm tools-dev
# Check desktop status
pnpm tools-dev inspect desktop status
# Take a screenshot of the desktop app
pnpm tools-dev inspect desktop screenshot --path /tmp/open-design.png
```
تطبيق سطح المكتب يكتشف رابط الويب تلقائياً عبر sidecar IPC — لا حاجة لتخمين المنافذ.
### أوامر مفيدة أخرى
| الأمر | ما يفعله |
|---|---|
| `pnpm tools-dev status` | يُظهر حالات الـ sidecar العاملة |
| `pnpm tools-dev logs` | يُظهر ذيول سجلات daemon/web/desktop |
| `pnpm tools-dev stop` | يوقف كل sidecars العاملة |
| `pnpm tools-dev restart` | يوقف ثم يعيد تشغيل كل sidecars |
| `pnpm tools-dev check` | الحالة + سجلات حديثة + تشخيصات شائعة |
لإعادة التشغيل بمنافذ ثابتة، الإقلاع في الخلفية، واستكشاف الأخطاء الكامل، راجع [`QUICKSTART.md`](QUICKSTART.md).
## استخدام Open Design من وكيل البرمجة لديك
يشحن Open Design خادم MCP عبر stdio. اربطه بـ Claude Code أو Codex أو Cursor أو VS Code أو Antigravity أو Zed أو Windsurf أو أيّ عميل متوافق مع MCP، وسيتمكّن الوكيل في مستودع آخر من قراءة الملفات من مشاريع Open Design المحلية مباشرة. يحلّ هذا محلّ حلقة export-ثم-attach. حين يستدعي الوكيل `search_files` أو `get_file` أو `get_artifact` بدون وسيط مشروع، يأخذ MCP افتراضياً المشروع (والملف) المفتوح حالياً في Open Design، بحيث تعمل برومبتات مثل *«ابنِ هذا في تطبيقي»* أو *«طابِق هذه الأنماط»* مباشرة.
**لماذا MCP؟** تصدير zip وإعادة إرفاقه مع كل دورة تصميم يكسر التدفّق. خادم MCP يكشف مصدر تصميمك مباشرة — tokens CSS، مكوّنات JSX، entry HTML — كـ API منظَّم يمكن للوكيل الاستعلام منه بالاسم. الوكيل يرى دائماً الملف الحيّ، لا نسخة قديمة من آخر export.
افتح **Settings → MCP server** في تطبيق Open Design للحصول على تدفّق تثبيت لكلّ عميل. تُضمِّن اللوحة المسار المطلق لـ `node` ولـ `cli.js` المبني للـ daemon داخل كل snippet، فتعمل على نسخة source جديدة لا يكون فيها `od` على الـ PATH. Cursor يحصل على deeplink بنقرة واحدة؛ والباقي يحصلون على JSON snippet للنسخ واللصق بالشكل الذي يتوقّعه ملفّ تكوينهم (Claude Code يتضمّن سطر `claude mcp add-json` واحداً، فلا تحتاج لتحرير `~/.claude.json` يدوياً). أعد تشغيل أو reload لعميلك بعد التثبيت ليظهر الخادم.
يجب أن يكون الـ daemon يعمل محلياً لتنجح استدعاءات أدوات MCP. إن كان الوكيل قد أُقلع قبل Open Design، أعد تشغيل الوكيل بعد جاهزية Open Design ليصل إلى الـ daemon الحيّ. الاستدعاءات أثناء توقّف الـ daemon تعيد خطأً واضحاً `"daemon not reachable"` بدلاً من crash.
**نموذج الأمان.** خادم MCP للقراءة فقط؛ يكشف قراءة ملفات، metadata، وبحث — لا شيء يكتب على القرص أو يستدعي خدمة خارجية. يعمل كعملية ابن لوكيل البرمجة عبر stdio، لذا أيّ عميل MCP تسجّله يرث صلاحية قراءة لمشاريع Open Design المحلية لديك. عامله مثل تثبيت إضافة VS Code: لا تسجّل إلا العملاء الذين تثق بهم. الـ daemon يربط نفسه بـ `127.0.0.1` افتراضياً؛ التعرّض للشبكة المحلية بأكملها يتطلّب `OD_BIND_HOST` صريحاً.
## بنية المستودع
```
open-design/
├── README.md ← English
├── README.ar.md ← العربية (this file)
├── README.de.md ← Deutsch
├── README.ru.md ← Русский
├── README.zh-CN.md ← 简体中文
├── QUICKSTART.md ← run / build / deploy guide
├── package.json ← pnpm workspace, single bin: od
├── apps/
│ ├── daemon/ ← Node + Express, the only server
│ │ ├── src/ ← TypeScript daemon source
│ │ │ ├── cli.ts ← `od` bin source, compiled to dist/cli.js
│ │ │ ├── server.ts ← /api/* routes (projects, chat, files, exports)
│ │ │ ├── agents.ts ← PATH scanner + per-CLI argv builders
│ │ │ ├── claude-stream.ts ← streaming JSON parser for Claude Code stdout
│ │ │ ├── skills.ts ← SKILL.md frontmatter loader
│ │ │ └── db.ts ← SQLite schema (projects/messages/templates/tabs)
│ │ ├── sidecar/ ← tools-dev daemon sidecar wrapper
│ │ └── tests/ ← daemon package tests
│ │
│ └── web/ ← Next.js 16 App Router + React client
│ ├── app/ ← App Router entrypoints
│ ├── next.config.ts ← dev rewrites + prod static export to out/
│ └── src/ ← React + TypeScript client modules
│ ├── App.tsx ← routing, bootstrap, settings
│ ├── components/ ← chat, composer, picker, preview, sketch, …
│ ├── prompts/
│ │ ├── system.ts ← composeSystemPrompt(base, skill, DS, metadata)
│ │ ├── discovery.ts ← turn-1 form + turn-2 branch + 5-dim critique
│ │ └── directions.ts ← 5 visual directions × OKLch palette + font stack
│ ├── artifacts/ ← streaming <artifact> parser + manifests
│ ├── runtime/ ← iframe srcdoc, markdown, export helpers
│ ├── providers/ ← daemon SSE + BYOK API transports
│ └── state/ ← config + projects (localStorage + daemon-backed)
├── e2e/ ← Playwright UI + external integration/Vitest harness
├── packages/
│ ├── contracts/ ← shared web/daemon app contracts
│ ├── sidecar-proto/ ← Open Design sidecar protocol contract
│ ├── sidecar/ ← generic sidecar runtime primitives
│ └── platform/ ← generic process/platform primitives
├── skills/ ← 31 SKILL.md skill bundles (27 prototype + 4 deck)
│ ├── web-prototype/ ← default for prototype mode
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← deck mode
│ └── guizang-ppt/ ← bundled magazine-web-ppt (default for deck)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 DESIGN.md systems
│ ├── default/ ← Neutral Modern (starter)
│ ├── warm-editorial/ ← Warm Editorial (starter)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md ← catalog overview
├── assets/
│ └── frames/ ← shared device frames (used cross-skill)
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ ├── deck-framework.html ← deck baseline (nav / counter / print)
│ └── kami-deck.html ← kami-flavored deck starter (parchment / ink-blue serif)
├── scripts/
│ └── sync-design-systems.ts ← re-import upstream awesome-design-md tarball
├── docs/
│ ├── spec.md ← product spec, scenarios, differentiation
│ ├── architecture.md ← topologies, data flow, components
│ ├── skills-protocol.md ← extended SKILL.md od: frontmatter
│ ├── agent-adapters.md ← per-CLI detection + dispatch
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← long-form provenance
│ ├── roadmap.md ← phased delivery
│ ├── schemas/ ← JSON schemas
│ └── examples/ ← canonical artifact examples
└── .od/ ← runtime data, gitignored, auto-created
├── app.sqlite ← projects / conversations / messages / tabs
├── projects/<id>/ ← per-project working folder (agent's cwd)
└── artifacts/ ← saved one-off renders
```
## أنظمة التصميم
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="مكتبة أنظمة التصميم الـ72 — افتتاحية style guide" width="100%" />
</p>
72 نظاماً جاهزاً، كلٌّ منها [`DESIGN.md`](design-systems/README.md) واحد:
<details>
<summary><b>الكتالوج الكامل</b> (انقر للتوسيع)</summary>
**AI & LLM**`claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**Developer Tools**`cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**Productivity**`notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**Fintech**`stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**E-Commerce**`shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**Media**`spotify` · `playstation` · `wired` · `theverge` · `meta`
**Automotive**`tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**Other**`apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**Starters**`default` (Neutral Modern) · `warm-editorial`
</details>
تُستورد مكتبة أنظمة المنتجات عبر [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) من [`VoltAgent/awesome-design-md`][acd2]. أعد تشغيل السكربت للتحديث. الـ 57 design skills مصدرها [`bergside/awesome-design-skills`][ads] وأُضيفت مباشرة في `design-systems/`.
## الاتجاهات البصرية
حين لا يملك المستخدم brand spec، يُطلق الوكيل نموذجاً ثانياً بخمسة اتجاهات منتقاة — وهو تكييف OD لـ [نظام huashu-design "5 مدارس × 20 فلسفة تصميم" البديل](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback). كل اتجاه مواصفات حتميّة — لوحة OKLch، font stack، تلميحات هيئة، references — يربطها الوكيل حرفياً بـ `:root` لقالب الـ seed. نقرة واحدة → نظام بصري كامل المواصفات. لا ارتجال، لا AI-slop.
| الاتجاه | المزاج | المراجع |
|---|---|---|
| Editorial — Monocle / FT | مجلّة مطبوعة، حبر + كريمي + صدئ دافئ | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | بارد، منظَّم، تفاصيل بسيطة | Linear · Vercel · Stripe |
| Tech utility | كثافة معلومات، monospace، terminal | Bloomberg · Bauhaus tools |
| Brutalist | خشن، طباعة عملاقة، بدون ظلال، تفاصيل قاسية | Bloomberg Businessweek · Achtung |
| Soft warm | كريم، تباين منخفض، ألوان خوخية محايدة | Notion marketing · Apple Health |
المواصفات الكاملة → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
## توليد الوسائط
OD لا يقف عند الكود. نفس واجهة الـ chat التي تنتج HTML للـ `<artifact>` تقود أيضاً توليد **الصورة** و**الفيديو** و**الصوت**، مع adapters للنماذج موصولة في خط أنابيب الوسائط للـ daemon ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts)، [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). كل رندر ينزل كملف حقيقي في مساحة عمل المشروع — `.png` للصورة، `.mp4` للفيديو — ويظهر كقطعة تنزيل عند انتهاء الجولة.
ثلاث عائلات نماذج تحمل العبء حالياً:
| السطح | النموذج | المزوّد | الاستخدام |
|---|---|---|---|
| **صورة** | `gpt-image-2` | Azure / OpenAI | ملصقات، صور رمزية، خرائط مرسومة، إنفوغرافيك، بطاقات اجتماعية بأسلوب مجلّة، ترميم صور، رسوم منتجات بانفجار |
| **فيديو** | `seedance-2.0` | ByteDance Volcengine | 15 ثانية t2v + i2v سينمائي بالصوت — قصص قصيرة، لقطات شخصية مقرّبة، أفلام منتج، تصميم بأسلوب MV |
| **فيديو** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 motion graphics — إعلانات منتجات، طباعة حركية، مخطّطات بيانية، طبقات اجتماعية، logo outros، عمودي بأسلوب TikTok مع karaoke captions |
معرض البرومبت المتنامي في [`prompt-templates/`](prompt-templates/) يحوي **93 برومبت جاهزة للاستنساخ** — 43 صورة (`prompt-templates/image/*.json`)، 39 لـ Seedance (`prompt-templates/video/*.json` باستثناء `hyperframes-*`)، 11 لـ HyperFrames (`prompt-templates/video/hyperframes-*.json`). كل واحد يحمل صورة معاينة، نصّ البرومبت حرفياً، النموذج المستهدف، نسبة العرض إلى الارتفاع، وكتلة `source` للترخيص والإسناد. الـ daemon يخدمها على `GET /api/prompt-templates`، وتطبيق الويب يعرضها كشبكة بطاقات في تبويبات **Image templates** و**Video templates** بواجهة الدخول؛ نقرة واحدة تضع البرومبت في الـ composer مع النموذج الصحيح مُختاراً مسبقاً.
### gpt-image-2 — معرض الصور (عيّنة من 43)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="3D Stone Staircase Evolution" /><br/><sub><b>3D Stone Staircase Evolution Infographic</b><br/>إنفوغرافيك من 3 خطوات بجمالية الحجر المنحوت</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Illustrated City Food Map" /><br/><sub><b>Illustrated City Food Map</b><br/>ملصق سفر editorial مرسوم باليد</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cinematic Elevator Scene" /><br/><sub><b>Cinematic Elevator Scene</b><br/>لقطة ثابتة سينمائية لأزياء editorial</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Cyberpunk Anime Portrait" /><br/><sub><b>Cyberpunk Anime Portrait</b><br/>صورة رمزية — وجه نيون مع نص</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Glamorous Woman in Black" /><br/><sub><b>Glamorous Woman in Black Portrait</b><br/>بورتريه استوديو editorial</sub></td>
</tr>
</table>
المجموعة الكاملة → [`prompt-templates/image/`](prompt-templates/image/). المصادر: معظمها من [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts) (CC-BY-4.0) مع الحفاظ على إسناد المؤلفين في كل قالب.
### Seedance 2.0 — معرض الفيديو (عيّنة من 39)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Music Podcast Guitar" /></a><br/><sub><b>Music Podcast & Guitar Technique</b><br/>فيلم استوديو سينمائي 4K</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Emotional Face" /></a><br/><sub><b>Emotional Face Close-up</b><br/>دراسة ميكرو-تعابير سينمائية</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Luxury Supercar" /></a><br/><sub><b>Luxury Supercar Cinematic</b><br/>فيلم منتج روائي</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Forbidden City Cat" /></a><br/><sub><b>Forbidden City Cat Satire</b><br/>قصة قصيرة ساخرة بأسلوب stylised</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Japanese Romance" /></a><br/><sub><b>Japanese Romance Short Film</b><br/>15 ثانية بنمط Seedance 2.0 السردي</sub></td>
</tr>
</table>
اضغط أيّ صورة معاينة لتشغيل MP4 المُرَنْدَر فعلاً. المجموعة الكاملة → [`prompt-templates/video/`](prompt-templates/video/) (المداخل `*-seedance-*` والمُعلَّمة Cinematic). المصادر: [`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts) (CC-BY-4.0) مع الحفاظ على روابط التغريدات الأصلية ومعرّفات المؤلفين.
### HyperFrames — HTML→MP4 motion graphics (11 قالباً جاهزاً للاستنساخ)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) هو إطار فيديو agent-native مفتوح المصدر من HeyGen — تكتب أنت (أو الوكيل) HTML + CSS + GSAP، فيرنده HyperFrames إلى MP4 حتمي عبر headless Chrome + FFmpeg. يشحن Open Design لـ HyperFrames كنموذج فيديو من الدرجة الأولى (`hyperframes-html`) موصول في dispatch الـ daemon، إضافة إلى `skills/hyperframes/` التي تعلّم الوكيل عقد timeline، قواعد الانتقال بين المشاهد، أنماط audio-reactive، captions/TTS، وكتل الكتالوج (`npx hyperframes add <slug>`).
11 برومبت hyperframes تُشحن تحت [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/)، كل واحد brief محدّد ينتج archetype بعينه:
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Product reveal" /></a><br/><sub><b>5 ثوانٍ minimal product reveal</b> · 16:9 · بطاقة عنوان push-in بانتقال shader</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="SaaS promo" /></a><br/><sub><b>30 ثانية SaaS product promo</b> · 16:9 · بأسلوب Linear/ClickUp مع كشف UI ثلاثي الأبعاد</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaoke" /></a><br/><sub><b>TikTok karaoke talking-head</b> · 9:16 · TTS + captions متزامنة بالكلمة</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Brand sizzle" /></a><br/><sub><b>30 ثانية brand sizzle reel</b> · 16:9 · طباعة حركية متزامنة مع الإيقاع، audio-reactive</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Data chart" /></a><br/><sub><b>Animated bar-chart race</b> · 16:9 · إنفوغرافيك بيانات بأسلوب NYT</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Flight map" /></a><br/><sub><b>خريطة طيران (مصدر → وجهة)</b> · 16:9 · كشف مسار سينمائي بأسلوب Apple</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>4 ثوانٍ logo outro سينمائي</b> · 16:9 · تجميع جزء بجزء + bloom</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Money counter" /></a><br/><sub><b>عدّاد $0 → $10K</b> · 9:16 · hype بأسلوب Apple مع وميض أخضر + burst</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>عرض تطبيق على 3 هواتف</b> · 16:9 · هواتف عائمة مع نقاط تركيز للميزات</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Social overlay" /></a><br/><sub><b>Social overlay stack</b> · 9:16 · X · Reddit · Spotify · Instagram بالتسلسل</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website to video" /></a><br/><sub><b>خطّ موقع → فيديو</b> · 16:9 · يلتقط الموقع بـ 3 viewports + انتقالات</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
النمط نفسه: اختر قالباً، عدّل الـ brief، أرسل. يقرأ الوكيل `skills/hyperframes/SKILL.md` المضمَّن (الذي يحمل سير عمل OD-specific للرندر — تجميع ملفات المصدر في `.hyperframes-cache/` لتفادي ازدحام مساحة الملفات، يوزّع الـ daemon `npx hyperframes render` لتفادي تعليق macOS sandbox-exec / Puppeteer، وتنزل MP4 النهائية فقط كقطعة مشروع)، ويصوغ التركيب، ويسلّم MP4. صور معاينة كتل الكتالوج © HeyGen، تُخدم من CDN الخاص بهم؛ الإطار OSS بنفسه Apache-2.0.
> **موصول لكنه لم يُسطَّح بعد كقوالب:** Kling 2.0 / 1.6 / 1.5، Veo 3 / Veo 2، Sora 2 / Sora 2-Pro (عبر Fal)، MiniMax video-01 — جميعها داخل `VIDEO_MODELS` ([`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Suno v5 / v4.5، Udio v2، Lyria 2 (موسيقى) و gpt-4o-mini-tts، MiniMax TTS (كلام) تغطي سطح الصوت. القوالب لهذه مفتوحة لمساهمات — ضع JSON في `prompt-templates/video/` أو `prompt-templates/audio/` ويظهر في الـ picker.
## ما وراء الـ chat — ماذا يُشحن أيضاً
تأخذ حلقة الـ chat / artifact الأضواء، لكن حفنة من القدرات الأقل ظهوراً موصولة بالفعل وتستحق أن تعرفها قبل أن تقارن OD بأيّ شيء آخر:
- **استيراد ZIP من Claude Design.** اسحب ملف export من claude.ai إلى مربّع الترحيب. `POST /api/import/claude-design` يستخرجه إلى `.od/projects/<id>/` حقيقي، يفتح ملف الإدخال كتبويب، ويُجهّز برومبت "أكمل من حيث ترك Anthropic" لوكيلك المحلّي. لا إعادة برومبت، ولا "اطلب من النموذج إعادة إنشاء ما كان لدينا للتوّ". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
- **بروكسي BYOK متعدّد المزوّدين.** `POST /api/proxy/{anthropic,openai,azure,google}/stream` يأخذ `{ baseUrl, apiKey, model, messages }`، يبني الطلب الخاص بالمزوّد، يُطبّع قطع SSE إلى `delta/end/error`، ويرفض loopback / link-local / RFC1918 لتفادي SSRF. متوافق OpenAI يغطّي OpenAI و Azure AI Foundry `/openai/v1` و DeepSeek و Groq و MiMo و OpenRouter و vLLM المستضاف ذاتياً؛ Azure OpenAI يضيف رابط deployment + `api-version`؛ Google يستخدم Gemini `:streamGenerateContent`.
- **قوالب يحفظها المستخدم.** ما إن يعجبك رندر، يلتقط `POST /api/templates` HTML + metadata في جدول `templates` بـ SQLite. المشروع التالي يلتقطه من صف "your templates" في الـ picker — نفس السطح كما الـ 31 المشحونة، لكن خاصّة بك.
- **حفظ الـ tabs.** كل مشروع يتذكّر ملفاته المفتوحة والتبويب النشط في جدول `tabs`. أعد فتح المشروع غداً، تجد مساحة العمل كما تركتها بالضبط.
- **API لفحص الـ artifact.** `POST /api/artifacts/lint` يُجري فحوصات بنيوية على artifact مولَّد (كسر إطار `<artifact>`، ملفات side files مفقودة، tokens لوحة قديمة) ويعيد نتائج يمكن للوكيل قراءتها في الجولة التالية. التقييم الذاتي خماسي الأبعاد يستخدم هذا ليؤسّس درجته على دليل حقيقي، لا انطباع.
- **بروتوكول sidecar + أتمتة سطح المكتب.** عمليات الـ daemon والويب وسطح المكتب تحمل stamps خماسية الحقول (`app · mode · namespace · ipc · source`) وتعرض قناة JSON-RPC IPC على `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status \| eval \| screenshot` يقود تلك القناة، فيعمل E2E بدون رأس على غلاف Electron حقيقي بدون harnesses خاصة ([`packages/sidecar-proto/`](packages/sidecar-proto/)، [`apps/desktop/src/main/`](apps/desktop/src/main/)).
- **spawn ودود لـ Windows.** كل adapter قد ينفجر `CreateProcess` عند حدّ ~32 KB لـ argv ببرومبتات طويلة (Codex، Gemini، OpenCode، Cursor Agent، Qwen، Qoder CLI، Pi) يُمرَّر له البرومبت عبر stdin بدلاً من ذلك. Claude Code و Copilot يحتفظان بـ `-p`؛ ويتراجع الـ daemon إلى ملف برومبت مؤقت إن تجاوز ذلك أيضاً.
- **بيانات runtime لكل namespace.** `OD_DATA_DIR` و`--namespace` يمنحانك أشجار `.od/`-style معزولة تماماً، فلا تتشارك Playwright وقنوات beta ومشاريعك الفعلية ملف SQLite واحد.
## ميكانيكا مكافحة AI-slop
كل المنظومة أدناه هي playbook الخاص بـ [`huashu-design`](https://github.com/alchaincyf/huashu-design)، نُقل إلى مكدّس برومبت OD وأصبح قابلاً للإنفاذ لكل skill عبر pre-flight لملفات side. اقرأ [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) للاطّلاع على الصياغة الحيّة:
- **نموذج الأسئلة أوّلاً.** الجولة الأولى `<question-form>` فقط — لا تفكير، لا أدوات، لا سرد. يختار المستخدم الافتراضيات بسرعة الـ radio.
- **استخراج brand-spec.** حين يُرفق المستخدم لقطة شاشة أو URL، يُجري الوكيل بروتوكولاً من خمس خطوات (locate · download · grep hex · تدوين `brand-spec.md` · vocalise) قبل كتابة CSS. **لا يخمّن ألوان الهوية من الذاكرة أبداً.**
- **تقييم خماسي الأبعاد.** قبل إصدار `<artifact>`، يُقيّم الوكيل ناتجه بصمت من 1 إلى 5 عبر philosophy / hierarchy / execution / specificity / restraint. أيّ شيء أقل من 3/5 تراجع — أصلح وأعد التقييم. مرّتان أمر طبيعي.
- **checklist بمستويات P0/P1/P2.** كلّ skill تشحن `references/checklist.md` ببوابات P0 صارمة. على الوكيل المرور بـ P0 قبل الإصدار.
- **قائمة سوداء للـ slop.** تدرّجات بنفسجية عدوانية، أيقونات emoji عامة، بطاقات مدوّرة بحدود يسارية بارزة، أشخاص SVG مرسومون يدوياً، Inter كخط *display*، metrics مخترعة — كلها ممنوعة صراحة في البرومبت.
- **placeholders صادقة > إحصائيات وهمية.** حين لا يملك الوكيل رقماً حقيقياً، يكتب `—` أو كتلة رمادية معنونة، لا "أسرع 10×".
## مقارنة
| المحور | [Claude Design][cd] (Anthropic) | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| الترخيص | مغلق | MIT | **Apache-2.0** |
| الشكل | ويب (claude.ai) | سطح مكتب (Electron) | **تطبيق ويب + daemon محلي** |
| قابل للنشر على Vercel | ❌ | ❌ | **✅** |
| Agent runtime | مُضمَّن (Opus 4.7) | مُضمَّن ([`pi-ai`][piai]) | **مفوَّض إلى CLI الموجودة لدى المستخدم** |
| Skills | خاصّة | 12 وحدة TS مخصّصة + `SKILL.md` | **31 حزمة [`SKILL.md`][skill] قابلة للسحب والإفلات** |
| نظام التصميم | خاصّ | `DESIGN.md` (v0.2 roadmap) | **`DESIGN.md` × 129 نظاماً مشحوناً** |
| مرونة المزوّد | Anthropic فقط | 7+ عبر [`pi-ai`][piai] | **16 CLI adapter + بروكسي BYOK متوافق OpenAI** |
| نموذج أسئلة الإقلاع | ❌ | ❌ | **✅ قاعدة صارمة، الجولة 1** |
| منتقي الاتجاه | ❌ | ❌ | **✅ 5 اتجاهات حتميّة** |
| تقدّم todo حيّ + بثّ الأدوات | ❌ | ✅ | **✅** (نمط UX من open-codesign) |
| معاينة iframe معزولة | ❌ | ✅ | **✅** (نمط من open-codesign) |
| استيراد ZIP من Claude Design | n/a | ❌ | **`POST /api/import/claude-design` — أكمل من حيث ترك Anthropic** |
| تعديلات comment-mode دقيقة | ❌ | ✅ | 🟡 جزئي — تعليقات على عناصر المعاينة + مرفقات chat؛ موثوقية الـ patch الدقيقة لا تزال قيد العمل |
| لوحة tweaks يطلقها الذكاء الاصطناعي | ❌ | ✅ | 🚧 roadmap — لوحة UX مخصّصة في جانب الـ chat لم تُنفَّذ بعد |
| مساحة عمل بمستوى نظام الملفات | ❌ | جزئي (sandbox الـ Electron) | **✅ cwd حقيقي، أدوات حقيقية، SQLite دائم (projects · conversations · messages · tabs · templates)** |
| تقييم ذاتي خماسي الأبعاد | ❌ | ❌ | **✅ بوابة pre-emit** |
| فحص Artifact | ❌ | ❌ | **`POST /api/artifacts/lint` — نتائج تُغذّى للوكيل** |
| Sidecar IPC + سطح مكتب headless | ❌ | ❌ | **✅ عمليات بـ stamps + `tools-dev inspect desktop status \| eval \| screenshot`** |
| صيغ التصدير | محدودة | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTX (مدفوع بالوكيل) / ZIP / Markdown** |
| إعادة استخدام skill PPT | N/A | مدمج | **[`guizang-ppt-skill`][guizang] يدخل (الافتراضي لوضع deck)** |
| الحدّ الأدنى للفوترة | Pro / Max / Team | BYOK | **BYOK — الصق أي `baseUrl` متوافق مع OpenAI** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## الوكلاء المدعومون
يكتشفها الـ daemon تلقائياً من `PATH` عند الإقلاع. لا حاجة لإعداد. dispatch البثّ في [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) (`AGENT_DEFS`)؛ محلّلات كل CLI بجانبه. النماذج تُملأ إمّا بفحص `<bin> --list-models` / `<bin> models` / مصافحة ACP، أو من قائمة fallback منتقاة عند عدم كشف الـ CLI لقائمة.
| الوكيل | Bin | صيغة البثّ | شكل argv (مسار البرومبت المُركَّب) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json` (أحداث مكتوبة) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + محلّل `codex` | `codex exec --json --skip-git-repo-check --full-auto [-C cwd] [--model …] [-c model_reasoning_effort=…] -` (البرومبت على stdin) |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + محلّل `gemini` | `GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo [--model …]` (البرومبت على stdin) |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + محلّل `opencode` | `opencode run --format json --dangerously-skip-permissions [--model …] -` (البرومبت على stdin) |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + محلّل `cursor-agent` | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -` (البرومبت على stdin) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain` (قطع stdout خام) | `qwen --yolo [--model …] -` (البرومبت على stdin) |
| Qoder CLI | `qodercli` | `qoder-stream-json` (أحداث مكتوبة) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]` (البرومبت على stdin) |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json` (أحداث مكتوبة) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc` (Agent Client Protocol) | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain` (raw stdout chunks) | `deepseek exec --auto [--model …] <prompt>` |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc` (stdio JSON-RPC) | `pi --mode rpc [--model …] [--thinking …]` (البرومبت يُرسل كأمر RPC `prompt`) |
| **BYOK متعدّد المزوّدين** | n/a | تطبيع SSE | `POST /api/proxy/{provider}/stream` → Anthropic / متوافق OpenAI / Azure OpenAI / Gemini؛ محمي SSRF ضد loopback / link-local / RFC1918 |
إضافة CLI جديدة = مدخل واحد في [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). صيغة البثّ واحدة من `claude-stream-json` أو `qoder-stream-json` أو `copilot-stream-json` أو `json-event-stream` (مع `eventParser` لكل CLI) أو `acp-json-rpc` أو `pi-rpc` أو `plain`.
## المراجع والنسب
كل مشروع خارجي يقتبس منه هذا المستودع. كل رابط يقود إلى المصدر لتحقّق من الـ provenance.
| المشروع | الدور هنا |
|---|---|
| [`Claude Design`][cd] | المنتج المغلق المصدر الذي يُمثّل هذا المستودع البديل المفتوح له. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | نواة فلسفة التصميم. سير عمل Junior-Designer، بروتوكول الأصول البصرية المؤلف من 5 خطوات، checklist مكافحة AI-slop، التقييم الذاتي خماسي الأبعاد، ومكتبة "5 مدارس × 20 فلسفة تصميم" خلف منتقي الاتجاه — كلّها مكثّفة في [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) و [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | skill Magazine-web-PPT المضمَّن حرفياً تحت [`skills/guizang-ppt/`](skills/guizang-ppt/) مع الحفاظ على LICENSE الأصلية. الافتراضي لوضع deck. ثقافة checklist بمستويات P0/P1/P2 مستعارة لكل skill أخرى. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | معمارية الـ daemon + adapter. اكتشاف الوكلاء بمسح PATH، الـ daemon المحلي بوصفه العملية المميَّزة الوحيدة، ورؤية "الوكيل كزميل فريق". نتبنّى النموذج، لا نضمّ الكود. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | أوّل بديل مفتوح المصدر لـ Claude-Design، وأقرب أقراننا. أنماط UX المُتبنّاة: حلقة الـ artifact المتدفّقة، معاينة iframe المعزولة (مع React 18 + Babel مضمّنين)، لوحة الوكيل الحيّة (todos + tool calls + قابلة للمقاطعة)، قائمة التصدير بخمس صيغ (HTML/PDF/PPTX/ZIP/Markdown)، مركز تخزين محلي أوّلاً، حقن الذوق عبر `SKILL.md`، والتمرير الأوّل لتعليقات comment-mode على المعاينة. أنماط UX لا تزال على roadmap لدينا: موثوقية surgical-edit الكاملة ولوحة tweaks يطلقها الذكاء. **نتعمّد عدم ضمّ [`pi-ai`][piai]** — open-codesign يحزمه كـ agent runtime؛ نحن نفوّض لأيّ CLI لدى المستخدم. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | مصدر مخطّط `DESIGN.md` ذي 9 أقسام و70 نظام منتج مستوردة عبر [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). |
| [`bergside/awesome-design-skills`][ads] | مصدر 57 design skill أُضيفت مباشرة كملفات `DESIGN.md` مُطبَّعة تحت `design-systems/`. |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | الإلهام لتوزيع skills قائم على symlink عبر CLI وكلاء متعدّدين. |
| [Claude Code skills][skill] | اصطلاح `SKILL.md` متبنّى حرفياً — أيّ skill من Claude Code تُسقط في `skills/` ويلتقطها الـ daemon. |
تدوينة provenance طويلة — ما نأخذه من كل واحد، وما نتعمّد عدم أخذه — في [`docs/references.md`](docs/references.md).
## Roadmap
- [x] Daemon + اكتشاف الوكلاء (16 CLI adapter) + سجلّ skills + كتالوج أنظمة التصميم
- [x] تطبيق ويب + chat + نموذج أسئلة + منتقي 5 اتجاهات + تقدّم todo + معاينة معزولة
- [x] 31 skill + 72 نظام تصميم + 5 اتجاهات بصرية + 5 إطارات أجهزة
- [x] مشاريع · محادثات · رسائل · tabs · قوالب مدعومة بـ SQLite
- [x] بروكسي BYOK متعدّد المزوّدين (`/api/proxy/{anthropic,openai,azure,google}/stream`) مع حماية SSRF
- [x] استيراد ZIP من Claude Design (`/api/import/claude-design`)
- [x] بروتوكول sidecar + سطح مكتب Electron مع أتمتة IPC (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN)
- [x] API لفحص Artifact + بوابة pre-emit للتقييم الذاتي خماسي الأبعاد
- [ ] تعديلات comment-mode الدقيقة — جزء جاهز: تعليقات عناصر المعاينة ومرفقات الـ chat؛ patching دقيق موثوق لا يزال قيد العمل
- [ ] UX لوحة tweaks يطلقها الذكاء — لم تُنفَّذ بعد
- [ ] وصفة نشر Vercel + tunnel (Topology B)
- [ ] أمر واحد `npx od init` لإنشاء مشروع بـ `DESIGN.md`
- [ ] متجر skills (`od skills install <github-repo>`) وسطح CLI `od skill add | list | remove | test` (مسوَّد في [`docs/skills-protocol.md`](docs/skills-protocol.md)، التنفيذ معلَّق)
- [x] حزمة Electron من `apps/packaged/` — تنزيلات macOS (Apple Silicon) و Windows (x64) على [open-design.ai](https://open-design.ai/) و [صفحة إصدارات GitHub](https://github.com/nexu-io/open-design/releases)
تسليم بمراحل → [`docs/roadmap.md`](docs/roadmap.md).
## الحالة
هذا تنفيذ مبكّر — الحلقة المغلقة (اكتشاف → اختيار skill + نظام تصميم → chat → تحليل `<artifact>` → معاينة → حفظ) تعمل من البداية إلى النهاية. مكدّس البرومبت ومكتبة الـ skills هما حيث تكمن معظم القيمة، وهما مستقرّان. واجهة المستخدم على مستوى المكوّنات تُشحن يومياً.
## أعطنا ★
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="Star Open Design on GitHub — github.com/nexu-io/open-design" width="100%" /></a>
</p>
إن وفّر هذا عليك ثلاثين دقيقة — أعطه ★. النجوم لا تدفع الإيجار، لكنها تخبر المصمّم والوكيل والمساهم القادم أن هذه التجربة تستحقّ انتباههم. نقرة واحدة، ثلاث ثوانٍ، إشارة حقيقية: [github.com/nexu-io/open-design](https://github.com/nexu-io/open-design).
## المساهمة
Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّها مرحَّب بها. أعلى المساهمات أثراً عادةً تكون مجلّداً واحداً، أو ملف Markdown واحداً، أو adapter بحجم PR:
- **أضِف skill** — ضع مجلّداً في [`skills/`](skills/) متّبعاً اصطلاح [`SKILL.md`][skill].
- **أضِف نظام تصميم** — ضع `DESIGN.md` في [`design-systems/<brand>/`](design-systems/) باستخدام مخطّط 9 أقسام.
- **اربط CLI وكيل برمجة جديد** — مدخل واحد في [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts).
الجولة الكاملة، حدّ الدمج، أسلوب الكود، وما لا نقبله → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([Deutsch](CONTRIBUTING.de.md)، [Français](CONTRIBUTING.fr.md)، [简体中文](CONTRIBUTING.zh-CN.md)).
## المساهمون
شكراً لكلّ من ساعد في دفع 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-06" alt="Open Design contributors" />
</a>
إن شحنت أوّل PR — مرحباً. تصنيف [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) هو نقطة الدخول.
## نشاط المستودع
<picture>
<img alt="Open Design — repository metrics" src="docs/assets/github-metrics.svg" />
</picture>
يُعاد توليد SVG أعلاه يومياً عبر [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) باستخدام [`lowlighter/metrics`](https://github.com/lowlighter/metrics). أطلق تحديثاً يدوياً من تبويب **Actions** إن أردته أسرع؛ لإضافات أغنى (traffic، follow-up time)، أضف سرّ مستودع `METRICS_TOKEN` بـ PAT دقيق التحكّم.
## تاريخ النجوم
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
</picture>
</a>
إن انحنى المنحنى صعوداً، فتلك الإشارة التي نبحث عنها. ★ هذا المستودع لتدفعه.
## شكر وتقدير
عائلة skills HTML PPT Studio — الـ master [`skills/html-ppt/`](skills/html-ppt/) والأغلفة لكل قالب تحت [`skills/html-ppt-*/`](skills/) (15 قالب deck كامل، 36 ثيم، 31 layout صفحة واحدة، 27 حركة CSS + 20 canvas FX، runtime لوحة المفاتيح، ووضع magnetic-card presenter) — مدمجة من المشروع المفتوح [`lewislulu/html-ppt-skill`](https://github.com/lewislulu/html-ppt-skill) (MIT). LICENSE المصدر يُشحن داخل الشجرة في [`skills/html-ppt/LICENSE`](skills/html-ppt/LICENSE) وتعود نسبة التأليف لـ [@lewislulu](https://github.com/lewislulu). كل بطاقة Examples لكل قالب (`html-ppt-pitch-deck`، `html-ppt-tech-sharing`، `html-ppt-presenter-mode`، `html-ppt-xhs-post`، …) تفوّض إرشاد التأليف للـ master skill ليُحفظ سلوك المصدر prompt → output من البداية للنهاية عند ضغط **Use this prompt**.
تدفّق deck الأفقي / المجلّة تحت [`skills/guizang-ppt/`](skills/guizang-ppt/) مدمج من [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) (MIT). نسبة التأليف لـ [@op7418](https://github.com/op7418).
## الترخيص
Apache-2.0. تحتفظ `skills/guizang-ppt/` المضمَّنة بترخيصها الأصلي [LICENSE](skills/guizang-ppt/LICENSE) (MIT) ونسبة التأليف لـ [op7418](https://github.com/op7418). تحتفظ `skills/html-ppt/` المضمَّنة بترخيصها الأصلي [LICENSE](skills/html-ppt/LICENSE) (MIT) ونسبة التأليف لـ [lewislulu](https://github.com/lewislulu).
[cd]: https://x.com/claudeai/status/2045156267690213649
</div>

747
README.de.md Normal file
View File

@@ -0,0 +1,747 @@
# Open Design
> **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>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="Herunterladen" src="https://img.shields.io/badge/download-open--design.ai-ff6b35?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#supported-coding-agents"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.de.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <b>Deutsch</b> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a></p>
---
## Warum es existiert
Anthropics [Claude Design][cd] (veröffentlicht am 2026-04-17, Opus 4.7) hat gezeigt, was passiert, wenn ein LLM aufhört, Prosa zu schreiben, und anfängt, Design-Artefakte zu liefern. Es ging viral und blieb closed-source, nur bezahlt, nur Cloud, fest an Anthropics Modell und Anthropics Skills gebunden. Kein Checkout, kein Self-Hosting, kein Vercel-Deploy, kein Austausch gegen Ihren eigenen Agent.
**Open Design (OD) ist die Open-Source-Alternative.** Dieselbe Schleife, dasselbe artifact-first Denkmodell, aber ohne Lock-in. Wir liefern keinen Agent: Die stärksten coding agents laufen bereits auf Ihrem Laptop. Wir verbinden sie mit einem skillgesteuerten Design-Workflow, der lokal mit `pnpm tools-dev` läuft, die Web-Schicht zu Vercel deployen kann und auf jeder Ebene BYOK bleibt.
Geben Sie `make me a magazine-style pitch deck for our seed round` ein. Das interaktive Fragenformular erscheint, bevor das Modell auch nur ein Pixel improvisiert. Der Agent wählt eine von fünf kuratierten visuellen Richtungen. Ein live `TodoWrite` Plan streamt in die UI. Der daemon baut einen echten Projektordner auf der Festplatte mit Seed-Template, Layout-Bibliothek und Self-Check-Checklist. Der Agent liest sie, der Pre-Flight ist erzwungen, bewertet seine eigene Ausgabe mit einer fünfdimensionalen Kritik und gibt ein einzelnes `<artifact>` aus, das Sekunden später in einem sandboxed iframe rendert.
Das ist nicht "AI versucht, etwas zu designen". Das ist eine AI, die durch den Prompt Stack darauf trainiert wurde, sich wie ein Senior Designer mit funktionierendem Dateisystem, deterministischer Palettenbibliothek und Checklist-Kultur zu verhalten: genau die Messlatte, die Claude Design gesetzt hat, aber offen und unter Ihrer Kontrolle.
OD steht auf den Schultern von vier Open-Source-Projekten:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — der Design-Philosophie-Kompass. Junior-Designer Workflow, das 5-step brand-asset protocol, die anti-AI-slop checklist, die fünfdimensionale Self-Critique und die Idee "5 schools × 20 design philosophies" hinter unserem Direction Picker, alles verdichtet in [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — der Deck-Modus. Unverändert unter [`skills/guizang-ppt/`](skills/guizang-ppt/) gebündelt, mit ursprünglicher LICENSE; magazinartige Layouts, WebGL-Hero, P0/P1/P2-Checklists.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX North Star und nächster Peer. Die erste Open-Source-Alternative zu Claude Design. Wir übernehmen den Streaming-Artifact-Loop, das sandboxed-iframe Preview Pattern (vendored React 18 + Babel), das Live-Agent-Panel (todos + tool calls + unterbrechbare Generierung) und die fünf Exportformate (HTML / PDF / PPTX / ZIP / Markdown). Wir unterscheiden uns bewusst im Formfaktor: Sie sind eine Desktop-Electron-App mit gebündeltem [`pi-ai`][piai]; wir sind eine Web-App + lokaler daemon, die an Ihre vorhandene CLI delegiert.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — die daemon- und runtime-Architektur. PATH-Scan-Agent-Erkennung, der lokale daemon als einziger privilegierter Prozess, die Agent-as-teammate Sichtweise.
## Auf einen Blick
| | Was Sie bekommen |
|---|---|
| **Code-Agent-CLIs (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — automatisch im `PATH` erkannt, mit einem Klick wechselbar |
| **BYOK-Fallback** | OpenAI-kompatibler Proxy unter `/api/proxy/stream` — fügen Sie `baseUrl` + `apiKey` + `model` ein und jeder Anbieter (Anthropic-via-OpenAI, DeepSeek, Groq, MiMo, OpenRouter, Ihr selbst gehostetes vLLM oder jeder andere OpenAI-kompatible Provider) wird zur Engine. Internal-IP/SSRF wird am daemon-Rand blockiert. |
| **Design Systems integriert** | **72** — 2 handgeschriebene Starter + 70 Produktsysteme (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …), importiert aus [`awesome-design-md`][acd2] |
| **Skills integriert** | **31** — 27 im `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 im `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Im Picker nach `scenario` gruppiert: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Medienerzeugung** | Image-, Video- und Audio-Surfaces laufen neben dem Design-Loop. **gpt-image-2** (Azure / OpenAI) für Poster, Avatare, Infografiken, illustrierte Karten · **Seedance 2.0** (ByteDance) für 15s-cinematic text-to-video und image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) für HTML→MP4 Motion Graphics (Produkt-Reveals, kinetische Typografie, Datendiagramme, Social Overlays, Logo-Outros). **93** sofort reproduzierbare Prompts — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — unter [`prompt-templates/`](prompt-templates/), mit Vorschau-Thumbnails und Quellenangabe. Gleiche Chat-Oberfläche wie Code; gibt einen echten `.mp4` / `.png` Chip in den Projekt-Workspace aus. |
| **Visuelle Richtungen** | 5 kuratierte Schulen (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental), jeweils mit deterministischer OKLch-Palette + Font Stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixelgenau, skillübergreifend unter [`assets/frames/`](assets/frames/) geteilt |
| **Agent-Runtime** | Der lokale daemon startet die CLI in Ihrem Projektordner: Der Agent bekommt echte `Read`, `Write`, `Bash`, `WebFetch` gegen eine echte Festplattenumgebung, mit Windows-`ENAMETOOLONG` Fallbacks (stdin / prompt-file) in jedem Adapter |
| **Imports** | Ziehen Sie einen [Claude Design][cd] Export-ZIP in den Welcome Dialog: `POST /api/import/claude-design` parst ihn zu einem echten Projekt, damit Ihr Agent dort weiterarbeiten kann, wo Anthropic aufgehört hat |
| **Persistence** | SQLite in `.od/app.sqlite`: projects · conversations · messages · tabs · saved templates. Morgen wieder öffnen, todo card und offene Dateien sind genau dort, wo Sie sie verlassen haben. |
| **Lebenszyklus** | Ein Einstiegspunkt: `pnpm tools-dev` (start / stop / run / status / logs / inspect / check) — startet daemon + web (+ desktop) unter typisierten sidecar stamps |
| **Desktop** | Optionale Electron Shell mit sandboxed renderer + sidecar IPC (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN) — treibt `tools-dev inspect desktop screenshot` für E2E |
| **Bereitstellbar auf** | Lokal (`pnpm tools-dev`) · Vercel Web Layer · paketierte Electron Desktop-App für macOS (Apple Silicon) und Windows (x64) — Download von [open-design.ai](https://open-design.ai/) oder dem [neuesten Release](https://github.com/nexu-io/open-design/releases) |
| **Lizenz** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
## Demo
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · Entry view" /><br/>
<sub><b>Entry view</b> — Skill wählen, Design System wählen, Brief eingeben. Dieselbe Oberfläche für Prototypen, Decks, mobile Apps, Dashboards und Editorial Pages.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · Turn-1 discovery form" /><br/>
<sub><b>Turn-1 discovery form</b> — bevor das Modell ein Pixel schreibt, fixiert OD den Brief: Oberfläche, Zielgruppe, Ton, Brand-Kontext, Umfang. 30 Sekunden Radio Buttons schlagen 30 Minuten Redirects.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · Direction picker" /><br/>
<sub><b>Direction picker</b> — wenn der Nutzer keine Brand hat, gibt der Agent ein zweites Formular mit 5 kuratierten Richtungen aus (Monocle / Modern Minimal / Tech Utility / Brutalist / Soft Warm). Ein Radio-Klick → deterministische Palette + Font Stack, kein Model-Freestyle.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · Live todo progress" /><br/>
<sub><b>Live todo progress</b> — der Plan des Agent streamt als Live Card. <code>in_progress</code> → <code>completed</code> Updates landen in Echtzeit. Der Nutzer kann mitten im Flug günstig umleiten.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · Sandboxed preview" /><br/>
<sub><b>Sandboxed preview</b> — jedes <code>&lt;artifact&gt;</code> rendert in einem sauberen srcdoc iframe. Direkt im File Workspace editierbar; als HTML, PDF oder ZIP herunterladbar.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · 72-system library" /><br/>
<sub><b>72-system library</b> — jedes Produktsystem zeigt seine 4-Farben-Signatur. Klicken Sie für das vollständige <code>DESIGN.md</code>, Swatch Grid und Live Showcase.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · Magazine deck" /><br/>
<sub><b>Deck mode (guizang-ppt)</b> — der gebündelte <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> wird unverändert übernommen. Magazinlayouts, WebGL-Hero-Hintergründe, Single-File-HTML-Ausgabe, PDF-Export.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · Mobile prototype" /><br/>
<sub><b>Mobile prototype</b> — pixelgenauer iPhone 15 Pro Chrome (Dynamic Island, Statusbar-SVGs, Home Indicator). Multi-Screen-Prototypen nutzen die gemeinsamen <code>/frames/</code> Assets, damit der Agent nie ein Telefon neu zeichnet.</sub>
</td>
</tr>
</table>
## Skills
**31 Skills werden direkt mitgeliefert.** Jeder ist ein Ordner unter [`skills/`](skills/), folgt der Claude Code [`SKILL.md`][skill] Konvention und erweitert sie um ein `od:` Frontmatter, das der daemon unverändert parst: `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)).
Zwei oberste **Modes** tragen den Katalog: **`prototype`** (27 Skills, alles, was als einseitiges Artefakt rendert, von Magazin-Landing bis Phone Screen bis PM Spec Doc) und **`deck`** (4 Skills, horizontale Swipe-Präsentationen mit Deck-Framework-Chrome). Das Feld **`scenario`** gruppiert sie im Picker: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
### Showcase-Beispiele
Die visuell markanten Skills, die Sie wahrscheinlich zuerst ausführen. Jeder bringt ein echtes `example.html` mit, das Sie direkt aus dem Repo öffnen können, um genau zu sehen, was der Agent erzeugt: keine Authentifizierung, kein Setup.
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>Consumer dating / matchmaking dashboard — linke Navigation, Ticker Bar, KPIs, 30-day mutual-matches chart, Editorial-Typografie.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>Zweiseitiger Digital E-Guide — Cover (Titel, Autor, TOC Teaser) + Lesson Spread mit Pull Quote und Schritteliste. Creator / Lifestyle Tone.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>Brand product-launch HTML email — Masthead, Hero Image, Headline Lockup, CTA, Specs Grid. Zentrierte Single Column, table-fallback safe.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>Drei-Frame gamified mobile-app prototype auf dunkler Showcase Stage — Cover, today's quests mit XP Ribbons + Level Bar, Quest Detail.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>Drei-Frame Mobile Onboarding Flow — Splash, Value Prop, Sign-in. Status Bar, Swipe Dots, Primary CTA.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>Single-Frame Motion-Design-Hero mit loopenden CSS-Animationen — rotierender Type Ring, animierter Globus, tickender Timer. Bereit für HyperFrames-Handoff.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>Drei Karten im 1080×1080 Social-Media-Carousel — filmische Panels mit Display Headlines, die sich über die Serie verbinden, Brand Mark, Loop Affordance.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>Pixel / 8-bit Animated Explainer Slide — vollflächige Cream Stage, animiertes Pixel Mascot, kinetische japanische Display Type, loopende CSS Keyframes.</sub>
</td>
</tr>
</table>
### Design- & Marketing-Oberflächen (Prototyp-Modus)
| Skill | Plattform | Szenario | Was er erzeugt |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | desktop | design | Single-page HTML — Landings, Marketing, Hero Pages (default für prototype) |
| [`saas-landing`](skills/saas-landing/) | desktop | marketing | Hero / Features / Pricing / CTA Marketing Layout |
| [`dashboard`](skills/dashboard/) | desktop | operation | Admin / Analytics mit Sidebar + dichtem Datenlayout |
| [`pricing-page`](skills/pricing-page/) | desktop | sale | Eigenständiges Pricing + Vergleichstabellen |
| [`docs-page`](skills/docs-page/) | desktop | engineering | 3-spaltiges Dokumentationslayout |
| [`blog-post`](skills/blog-post/) | desktop | marketing | Editorial Long-form |
| [`mobile-app`](skills/mobile-app/) | mobile | design | iPhone 15 Pro / Pixel gerahmte App-Screen(s) |
| [`mobile-onboarding`](skills/mobile-onboarding/) | mobile | design | Multi-Screen Mobile Onboarding Flow (splash · value-prop · sign-in) |
| [`gamified-app`](skills/gamified-app/) | mobile | personal | Drei-Frame gamified mobile-app prototype |
| [`email-marketing`](skills/email-marketing/) | desktop | marketing | Brand product-launch HTML email (table-fallback safe) |
| [`social-carousel`](skills/social-carousel/) | desktop | marketing | 3-card 1080×1080 social carousel |
| [`magazine-poster`](skills/magazine-poster/) | desktop | marketing | Einseitiges Poster im Magazin-Stil |
| [`motion-frames`](skills/motion-frames/) | desktop | marketing | Motion-design Hero mit loopenden CSS-Animationen |
| [`sprite-animation`](skills/sprite-animation/) | desktop | marketing | Pixel / 8-bit Animated Explainer Slide |
| [`dating-web`](skills/dating-web/) | desktop | personal | Consumer dating dashboard mockup |
| [`digital-eguide`](skills/digital-eguide/) | desktop | marketing | Zweiseitiger Digital E-Guide (cover + lesson) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | desktop | design | Handgezeichnete Ideenskizze — für den "show something visible early" Pass |
| [`critique`](skills/critique/) | desktop | design | Fünfdimensionales Self-Critique Scoresheet (Philosophy · Hierarchy · Detail · Function · Innovation) |
| [`tweaks`](skills/tweaks/) | desktop | design | AI-emitted tweaks panel — das Modell zeigt die Parameter, die sich sinnvoll nachjustieren lassen |
### Deck-Oberflächen (Deck-Modus)
| Skill | Default für | Was er erzeugt |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **default** für deck | Web-PPT im Magazinstil — unverändert aus [op7418/guizang-ppt-skill][guizang] gebündelt, ursprüngliche LICENSE bewahrt |
| [`simple-deck`](skills/simple-deck/) | — | Minimaler horizontal-swipe deck |
| [`replit-deck`](skills/replit-deck/) | — | Product-walkthrough deck (Replit-style) |
| [`weekly-update`](skills/weekly-update/) | — | Team weekly cadence als swipe deck (progress · blockers · next) |
### Office- & Operations-Oberflächen (Prototyp-Modus, dokumentartige Szenarien)
| Skill | Szenario | Was er erzeugt |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | PM specification doc mit TOC + decision log |
| [`team-okrs`](skills/team-okrs/) | product | OKR scoresheet |
| [`meeting-notes`](skills/meeting-notes/) | operation | Meeting decision log |
| [`kanban-board`](skills/kanban-board/) | operation | Board snapshot |
| [`eng-runbook`](skills/eng-runbook/) | engineering | Incident runbook |
| [`finance-report`](skills/finance-report/) | finance | Exec finance summary |
| [`invoice`](skills/invoice/) | finance | Single-page invoice |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | Role onboarding plan |
Einen Skill hinzuzufügen bedeutet: ein Ordner. Lesen Sie [`docs/skills-protocol.md`](docs/skills-protocol.md) für das erweiterte Frontmatter, forken Sie einen vorhandenen Skill, starten Sie den daemon neu, und er erscheint im Picker. Der Katalog-Endpunkt ist `GET /api/skills`; die Seed-Zusammenstellung pro Skill (Template + Side-File-Referenzen) liegt in `GET /api/skills/:id/example`.
## Sechs tragende Ideen
### 1 · Wir liefern keinen Agent. Ihrer ist gut genug.
Der daemon durchsucht beim Start Ihren `PATH` nach [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi` und [`pi`](https://github.com/mariozechner/pi-ai). Was er findet, wird zur möglichen Design-Engine: über stdio mit je einem Adapter pro CLI gesteuert und im Model Picker austauschbar. Inspiriert von [`multica`](https://github.com/multica-ai/multica) und [`cc-switch`](https://github.com/farion1231/cc-switch). Keine CLI installiert? `POST /api/proxy/stream` ist dieselbe Pipeline ohne Spawn: Fügen Sie ein beliebiges OpenAI-kompatibles `baseUrl` + `apiKey` ein, und der daemon leitet SSE-Chunks zurück, wobei loopback / link-local / RFC1918 Ziele am Rand abgelehnt werden.
### 2 · Skills sind Dateien, keine Plugins.
Nach Claude Codes [`SKILL.md` Konvention](https://docs.anthropic.com/en/docs/claude-code/skills) ist jeder Skill `SKILL.md` + `assets/` + `references/`. Legen Sie einen Ordner in [`skills/`](skills/), starten Sie den daemon neu, und er erscheint im Picker. Das gebündelte `magazine-web-ppt` ist [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill), unverändert eingecheckt: ursprüngliche Lizenz bewahrt, Attribution bewahrt.
### 3 · Design Systems sind portables Markdown, kein Theme JSON.
Das 9-Section `DESIGN.md` Schema aus [`VoltAgent/awesome-design-md`][acd2]: color, typography, spacing, layout, components, motion, voice, brand, anti-patterns. Jedes Artefakt liest aus dem aktiven System. System wechseln → das nächste Render nutzt die neuen Tokens. Das Dropdown bringt **Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Apple, Anthropic, Cursor, Supabase, Figma, Resend, Raycast, Lovable, Cohere, Mistral, ElevenLabs, X.AI, Spotify, Webflow, Sanity, PostHog, Sentry, MongoDB, ClickHouse, Cal, Replicate, Clay, Composio, Xiaohongshu…** mit, insgesamt 72.
### 4 · Das interaktive Fragenformular verhindert 80% der Redirects.
ODs Prompt Stack enthält eine harte `RULE 1`: Jeder frische Design Brief beginnt mit einem `<question-form id="discovery">` statt mit Code. Surface · audience · tone · brand context · scale · constraints. Auch ein langer Brief lässt Designentscheidungen offen: visueller Ton, Farbhaltung, Maßstab. Genau diese Dinge fixiert das Formular in 30 Sekunden. Die Kosten einer falschen Richtung sind eine Chat-Runde, nicht ein fertiges Deck.
Das ist der aus [`huashu-design`](https://github.com/alchaincyf/huashu-design) destillierte **Junior-Designer mode**: Fragen vorne bündeln, früh etwas Sichtbares zeigen (selbst ein Wireframe mit grauen Blöcken), den Nutzer günstig umleiten lassen. Zusammen mit dem Brand-Asset-Protokoll (locate · download · `grep` hex · write `brand-spec.md` · vocalise) ist es der wichtigste Grund, warum Output nicht mehr nach AI-Freestyle wirkt, sondern nach einem Designer, der vor dem Malen aufgepasst hat.
### 5 · Der daemon lässt den Agent fühlen, als wäre er auf Ihrem Laptop, weil er es ist.
Der daemon startet die CLI mit `cwd` auf den Artifact-Ordner des Projekts unter `.od/projects/<id>/`. Der Agent bekommt `Read`, `Write`, `Bash`, `WebFetch`: echte Tools gegen ein echtes Dateisystem. Er kann das `assets/template.html` des Skills lesen, Ihre CSS nach Hex-Werten `grep`en, ein `brand-spec.md` schreiben, generierte Bilder ablegen und `.pptx` / `.zip` / `.pdf` Dateien erzeugen, die am Ende des Turns als Download Chips im File Workspace erscheinen. Sessions, Conversations, Messages und Tabs persistieren in einer lokalen SQLite DB: Öffnen Sie das Projekt morgen wieder, und die Todo Card des Agent ist dort, wo Sie sie verlassen haben.
### 6 · Der Prompt Stack ist das Produkt.
Was beim Senden zusammengesetzt wird, ist nicht "system + user". Es ist:
```
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)
+ 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)
```
Jede Ebene ist kombinierbar. Jede Ebene ist eine Datei, die Sie editieren können. Lesen Sie [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) und [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts), um den echten Vertrag zu sehen.
## Architektur
```
┌────────────────────── browser (Next.js 16) ──────────────────────┐
│ chat · file workspace · iframe preview · settings · imports │
└──────────────┬───────────────────────────────────┬───────────────┘
│ /api/* (rewritten in dev) │
▼ ▼
┌──────────────────────────────────┐ /api/proxy/stream (SSE)
│ Local daemon (Express + SQLite) │ ─→ any OpenAI-compat
│ │ endpoint (BYOK)
│ /api/agents /api/skills│ w/ SSRF blocking
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (static) /frames (static)
│ optional: sidecar IPC at /tmp/open-design/ipc/<ns>/<app>.sock
│ (STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN)
└─────────┬────────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · gemini · opencode · cursor-agent · qwen │
│ qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) │
│ reads SKILL.md + DESIGN.md, writes artifacts to disk │
└──────────────────────────────────────────────────────────────────┘
```
| Layer | Stack |
|---|---|
| Frontend | Next.js 16 App Router + React 18 + TypeScript, Vercel-deploybar |
| Daemon | Node 24 · Express · SSE streaming · `better-sqlite3`; Tabellen: `projects` · `conversations` · `messages` · `tabs` · `templates` |
| Agent transport | `child_process.spawn`; typisierte Event-Parser für `claude-stream-json` (Claude Code), `qoder-stream-json` (Qoder CLI), `copilot-stream-json` (Copilot), `json-event-stream` pro-CLI Parser (Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe via Agent Client Protocol), `pi-rpc` (Pi via stdio JSON-RPC), `plain` (Qwen Code / DeepSeek TUI) |
| BYOK proxy | `POST /api/proxy/stream` → OpenAI-kompatibles `/v1/chat/completions`, SSE pass-through; lehnt loopback / link-local / RFC1918 Hosts am daemon-Rand ab |
| Storage | Plain files in `.od/projects/<id>/` + SQLite in `.od/app.sqlite` (gitignored, auto-created). Root mit `OD_DATA_DIR` für Testisolation überschreibbar |
| Preview | Sandboxed iframe via `srcdoc` + per-Skill `<artifact>` Parser ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
| Export | HTML (inline assets) · PDF (browser print, deck-aware) · PPTX (agent-driven via skill) · ZIP (archiver) · Markdown |
| Lifecycle | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`; Ports über `--daemon-port` / `--web-port`, Namespaces über `--namespace` |
| Desktop (optional) | Electron Shell — entdeckt die Web URL über sidecar IPC, kein Port-Raten; derselbe `STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN` Kanal treibt `tools-dev inspect desktop …` für E2E |
## Schnellstart
### Desktop-App herunterladen (kein Build erforderlich)
Der schnellste Weg, Open Design auszuprobieren, ist die vorgefertigte Desktop-App — kein Node, kein pnpm, kein Klonen:
- **[open-design.ai](https://open-design.ai/)** — offizielle Download-Seite
- **[GitHub Releases](https://github.com/nexu-io/open-design/releases)**
### Aus dem Quellcode ausführen
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # should print 10.33.2
pnpm install
pnpm tools-dev run web
# open the web URL printed by tools-dev
```
Windows-Launcher: Erstellen Sie `OpenDesign.exe` selbst mit der Anleitung in `tools/launcher/README.md`, oder laden Sie ihn aus GitHub Releases herunter. Legen Sie die Datei danach in den Repository-Stamm und doppelklicken Sie sie, um bei Bedarf `pnpm install` auszuführen und Open Design mit `pnpm tools-dev` zu starten.
Umgebungsanforderungen: Node `~24` und pnpm `10.33.x`. `nvm`/`fnm` sind nur optionale Helfer; wenn Sie eines davon nutzen, führen Sie vor `pnpm install` `nvm install 24 && nvm use 24` oder `fnm install 24 && fnm use 24` aus.
Für Desktop-/Background-Start, Fixed-Port-Restarts und Media-Generation-Dispatcher-Checks (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`) siehe [`QUICKSTART.de.md`](QUICKSTART.de.md).
Der erste Load:
1. erkennt, welche Agent-CLIs Sie im `PATH` haben, und wählt automatisch eine aus.
2. lädt 31 Skills + 72 Design Systems.
3. öffnet den Welcome Dialog, damit Sie einen Anthropic Key einfügen können (nur für den BYOK-Fallback-Pfad nötig).
4. **erstellt automatisch `./.od/`** — den lokalen Runtime-Ordner für die SQLite Project DB, per-project artifacts und saved renders. Es gibt keinen `od init` Schritt; der daemon `mkdir`t beim Boot alles, was er braucht.
Geben Sie einen Prompt ein, drücken Sie **Senden**, sehen Sie das Fragenformular erscheinen, füllen Sie es aus, sehen Sie die Todo Card streamen, sehen Sie das Artifact rendern. Klicken Sie **Auf Datenträger speichern** oder laden Sie ein Projekt-ZIP herunter.
### First-run state (`./.od/`)
Der daemon besitzt einen versteckten Ordner am Repo-Root. Alles darin ist gitignored und maschinenlokal: niemals committen.
```
.od/
├── app.sqlite ← projects · conversations · messages · open tabs
├── artifacts/ ← one-off "Save to disk" renders (timestamped)
└── projects/<id>/ ← per-project working dir, also the agent's cwd
```
| Wenn Sie möchten… | Tun Sie das |
|---|---|
| Inhalt prüfen | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| Sauber zurücksetzen | `pnpm tools-dev stop`, `rm -rf .od`, dann erneut `pnpm tools-dev run web` |
| Woandershin verschieben | noch nicht unterstützt — der Pfad ist relativ zum Repo hart codiert |
Vollständige Dateistruktur, Skripte und Fehlerbehebung → [`QUICKSTART.de.md`](QUICKSTART.de.md).
## Repository-Struktur
```
open-design/
├── README.md ← English
├── README.de.md ← Deutsch
├── README.zh-CN.md ← 简体中文
├── README.ko.md ← 한국어
├── QUICKSTART.md ← run / build / deploy guide
├── package.json ← pnpm workspace, single bin: od
├── apps/
│ ├── daemon/ ← Node + Express, the only server
│ │ ├── src/ ← TypeScript daemon source
│ │ │ ├── cli.ts ← `od` bin source, compiled to dist/cli.js
│ │ │ ├── server.ts ← /api/* routes (projects, chat, files, exports)
│ │ │ ├── agents.ts ← PATH scanner + per-CLI argv builders
│ │ │ ├── claude-stream.ts ← streaming JSON parser for Claude Code stdout
│ │ │ ├── skills.ts ← SKILL.md frontmatter loader
│ │ │ └── db.ts ← SQLite schema (projects/messages/templates/tabs)
│ │ ├── sidecar/ ← tools-dev daemon sidecar wrapper
│ │ └── tests/ ← daemon package tests
│ │
│ └── web/ ← Next.js 16 App Router + React client
│ ├── app/ ← App Router entrypoints
│ ├── next.config.ts ← dev rewrites + prod static export to out/
│ └── src/ ← React + TypeScript client modules
│ ├── App.tsx ← routing, bootstrap, settings
│ ├── components/ ← chat, composer, picker, preview, sketch, …
│ ├── prompts/
│ │ ├── system.ts ← composeSystemPrompt(base, skill, DS, metadata)
│ │ ├── discovery.ts ← turn-1 form + turn-2 branch + 5-dim critique
│ │ └── directions.ts ← 5 visual directions × OKLch palette + font stack
│ ├── artifacts/ ← streaming <artifact> parser + manifests
│ ├── runtime/ ← iframe srcdoc, markdown, export helpers
│ ├── providers/ ← daemon SSE + BYOK API transports
│ └── state/ ← config + projects (localStorage + daemon-backed)
├── e2e/ ← Playwright UI + external integration/Vitest harness
├── packages/
│ ├── contracts/ ← shared web/daemon app contracts
│ ├── sidecar-proto/ ← Open Design sidecar protocol contract
│ ├── sidecar/ ← generic sidecar runtime primitives
│ └── platform/ ← generic process/platform primitives
├── skills/ ← 31 SKILL.md skill bundles (27 prototype + 4 deck)
│ ├── web-prototype/ ← default for prototype mode
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← deck mode
│ └── guizang-ppt/ ← bundled magazine-web-ppt (default for deck)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 DESIGN.md systems
│ ├── default/ ← Neutral Modern (starter)
│ ├── warm-editorial/ ← Warm Editorial (starter)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md ← catalog overview
├── assets/
│ └── frames/ ← shared device frames (used cross-skill)
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ └── deck-framework.html ← deck baseline (nav / counter / print)
├── scripts/
│ └── sync-design-systems.ts ← re-import upstream awesome-design-md tarball
├── docs/
│ ├── spec.md ← product spec, scenarios, differentiation
│ ├── architecture.md ← topologies, data flow, components
│ ├── skills-protocol.md ← extended SKILL.md od: frontmatter
│ ├── agent-adapters.md ← per-CLI detection + dispatch
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← long-form provenance
│ ├── roadmap.md ← phased delivery
│ ├── schemas/ ← JSON schemas
│ └── examples/ ← canonical artifact examples
└── .od/ ← runtime data, gitignored, auto-created
├── app.sqlite ← projects / conversations / messages / tabs
├── projects/<id>/ ← per-project working folder (agent's cwd)
└── artifacts/ ← saved one-off renders
```
## Designsysteme
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="The 72 design systems library — style guide spread" width="100%" />
</p>
72 Systeme direkt mitgeliefert, jedes als ein einzelnes [`DESIGN.md`](design-systems/README.md):
<details>
<summary><b>Vollständiger Katalog</b> (zum Aufklappen klicken)</summary>
**AI & LLM**`claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**Developer Tools**`cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**Productivity**`notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**Fintech**`stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**E-Commerce**`shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**Media**`spotify` · `playstation` · `wired` · `theverge` · `meta`
**Automotive**`tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**Other**`apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**Starters**`default` (Neutral Modern) · `warm-editorial`
</details>
Die Bibliothek wird über [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) aus [`VoltAgent/awesome-design-md`][acd2] importiert. Führen Sie es erneut aus, um zu aktualisieren.
## Visuelle Richtungen
Wenn der Nutzer keine Brand Spec hat, gibt der Agent ein zweites Formular mit fünf kuratierten Richtungen aus: die OD-Adaption von [`huashu-design`s "5 schools × 20 design philosophies" fallback](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback). Jede Richtung ist eine deterministische Spec: Palette in OKLch, Font Stack, Layout-Posture-Cues, Referenzen. Der Agent bindet sie unverändert in das `:root` des Seed Templates. Ein Radio-Klick → ein vollständig spezifiziertes visuelles System. Keine Improvisation, kein AI-slop.
| Richtung | Stimmung | Referenzen |
|---|---|---|
| Editorial — Monocle / FT | Printmagazin, Tinte + Cream + warmer Rust | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | Kühl, strukturiert, minimaler Akzent | Linear · Vercel · Stripe |
| Tech utility | Informationsdichte, Monospace, Terminal | Bloomberg · Bauhaus tools |
| Brutalist | Roh, übergroße Type, keine Schatten, harte Akzente | Bloomberg Businessweek · Achtung |
| Soft warm | Großzügig, niedriger Kontrast, peachy Neutrals | Notion marketing · Apple Health |
Vollständige Spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
## Medienerzeugung
OD endet nicht beim Code. Dieselbe Chat-Oberfläche, die `<artifact>`-HTML produziert, treibt auch **Image-**, **Video-** und **Audio-**Generierung — die Modell-Adapter sind in der daemon-Media-Pipeline verdrahtet ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Jedes Render landet als echte Datei im Projekt-Workspace — `.png` für Image, `.mp4` für Video — und erscheint als Download-Chip am Ende des Turns.
Drei Modellfamilien tragen heute die Last:
| Surface | Modell | Anbieter | Wofür |
|---|---|---|---|
| **Image** | `gpt-image-2` | Azure / OpenAI | Poster, Profil-Avatare, illustrierte Karten, Infografiken, Magazin-Social-Cards, Foto-Restaurierung, exploded-view Produktillustrationen |
| **Video** | `seedance-2.0` | ByteDance Volcengine | 15s cinematic t2v + i2v mit Audio — narrative Shorts, Charakter-Close-ups, Produktfilme, MV-Choreografie |
| **Video** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 Motion Graphics — Produkt-Reveals, kinetische Typografie, Datendiagramme, Social Overlays, Logo-Outros, TikTok-Verticals mit Karaoke-Captions |
Die wachsende **Prompt-Galerie** unter [`prompt-templates/`](prompt-templates/) liefert **93 sofort reproduzierbare Prompts** — 43 image (`prompt-templates/image/*.json`), 39 Seedance (`prompt-templates/video/*.json` ohne `hyperframes-*`), 11 HyperFrames (`prompt-templates/video/hyperframes-*.json`). Jeder Eintrag trägt ein Vorschau-Thumbnail, den Prompt-Body wortwörtlich, das Zielmodell, die Aspect Ratio und einen `source`-Block für Lizenz + Attribution. Der daemon serviert sie unter `GET /api/prompt-templates`, die Web-App zeigt sie als Card-Grid in den Tabs **Image templates** und **Video templates** der Entry-View; ein Klick legt den Prompt mit dem richtigen vorausgewählten Modell in den Composer.
### gpt-image-2 — Image-Galerie (Auswahl aus 43)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="3D Stone Staircase Evolution" /><br/><sub><b>3D Stone Staircase Evolution Infographic</b><br/>3-stufige Infografik im Stein-Look</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Illustrated City Food Map" /><br/><sub><b>Illustrated City Food Map</b><br/>Editorial-Reiseposter, handillustriert</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cinematic Elevator Scene" /><br/><sub><b>Cinematic Elevator Scene</b><br/>Editorial Fashion Still als Einzelframe</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Cyberpunk Anime Portrait" /><br/><sub><b>Cyberpunk Anime Portrait</b><br/>Profil-Avatar — Neon-Face-Text</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Glamorous Woman in Black" /><br/><sub><b>Glamorous Woman in Black Portrait</b><br/>Editorial Studio-Porträt</sub></td>
</tr>
</table>
Komplettes Set → [`prompt-templates/image/`](prompt-templates/image/). Quellen: meist aus [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts) (CC-BY-4.0), Autor-Attribution pro Template erhalten.
### Seedance 2.0 — Video-Galerie (Auswahl aus 39)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Music Podcast Guitar" /></a><br/><sub><b>Music Podcast & Guitar Technique</b><br/>4K cinematic Studio-Film</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Emotional Face" /></a><br/><sub><b>Emotional Face Close-up</b><br/>Cinematic Mikroexpression-Studie</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Luxury Supercar" /></a><br/><sub><b>Luxury Supercar Cinematic</b><br/>Narrative Produktfilm</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Forbidden City Cat" /></a><br/><sub><b>Forbidden City Cat Satire</b><br/>Stilisierter Satire-Short</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Japanese Romance" /></a><br/><sub><b>Japanese Romance Short Film</b><br/>15s Seedance 2.0 Narrativ</sub></td>
</tr>
</table>
Klicken Sie auf ein Thumbnail, um das tatsächlich gerenderte MP4 abzuspielen. Komplettes Set → [`prompt-templates/video/`](prompt-templates/video/) (die `*-seedance-*` und Cinematic-getaggten Einträge). Quellen: [`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts) (CC-BY-4.0), Original-Tweet-Links und Autor-Handles erhalten.
### HyperFrames — HTML→MP4 Motion Graphics (11 sofort reproduzierbare Templates)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) ist HeyGens Open-Source-, agent-natives Video-Framework — Sie (oder der Agent) schreiben HTML + CSS + GSAP, HyperFrames rendert deterministisch zu MP4 via Headless-Chrome + FFmpeg. Open Design liefert HyperFrames als first-class Video-Modell (`hyperframes-html`) verdrahtet im daemon-Dispatch, plus den `skills/hyperframes/`-Skill, der dem Agent Timeline-Vertrag, Scene-Transition-Regeln, Audio-Reactive-Patterns, Captions/TTS und die Catalog-Blocks (`npx hyperframes add <slug>`) beibringt.
Elf HyperFrames-Prompts liegen unter [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/), jeder ein konkreter Brief, der einen spezifischen Archetyp produziert:
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Product reveal" /></a><br/><sub><b>5s minimaler Produkt-Reveal</b> · 16:9 · Push-in Title-Card mit Shader-Transition</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="SaaS promo" /></a><br/><sub><b>30s SaaS-Produkt-Promo</b> · 16:9 · Linear/ClickUp-Stil mit UI-3D-Reveals</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaoke" /></a><br/><sub><b>TikTok-Karaoke-Talking-Head</b> · 9:16 · TTS + wortgenau synchronisierte Captions</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Brand sizzle" /></a><br/><sub><b>30s Brand-Sizzle-Reel</b> · 16:9 · beat-synchrone kinetische Typografie, audio-reactive</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Data chart" /></a><br/><sub><b>Animiertes Bar-Chart-Race</b> · 16:9 · NYT-Stil Daten-Infografik</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Flight map" /></a><br/><sub><b>Flugkarte (Origin → Dest)</b> · 16:9 · Apple-Stil cinematic Route-Reveal</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>4s cinematic Logo-Outro</b> · 16:9 · Stück-für-Stück-Aufbau + Bloom</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Money counter" /></a><br/><sub><b>$0 → $10K Money-Counter</b> · 9:16 · Apple-Stil Hype mit Green-Flash + Burst</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>3-Phone App-Showcase</b> · 16:9 · schwebende Phones mit Feature-Callouts</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Social overlay" /></a><br/><sub><b>Social-Overlay-Stack</b> · 9:16 · X · Reddit · Spotify · Instagram nacheinander</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website to video" /></a><br/><sub><b>Website-zu-Video-Pipeline</b> · 16:9 · captured Site bei 3 Viewports + Transitions</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
Das Muster ist dasselbe wie sonst: Template wählen, Brief editieren, senden. Der Agent liest das mitgelieferte `skills/hyperframes/SKILL.md` (das den OD-spezifischen Render-Workflow enthält — Composition-Source-Files in einen `.hyperframes-cache/`, damit sie den File-Workspace nicht verschmutzen, daemon dispatcht `npx hyperframes render`, um den macOS-sandbox-exec/Puppeteer-Hang zu umgehen, nur die finale `.mp4` landet als Projekt-Chip), schreibt die Composition und liefert ein MP4. Catalog-Block-Thumbnails © HeyGen, von deren CDN; das OSS-Framework selbst ist Apache-2.0.
> **Auch verdrahtet, aber noch nicht als Templates aufgetaucht:** Kling 2.0 / 1.6 / 1.5, Veo 3 / Veo 2, Sora 2 / Sora 2-Pro (via Fal), MiniMax video-01 — alle in `VIDEO_MODELS` ([`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Suno v5 / v4.5, Udio v2, Lyria 2 (Music) und gpt-4o-mini-tts, MiniMax TTS (Speech) decken die Audio-Surface ab. Templates dafür sind offene Beiträge — JSON in `prompt-templates/video/` oder `prompt-templates/audio/` legen, taucht im Picker auf.
## Jenseits des Chats — was sonst mitgeliefert wird
Der Chat-/Artifact-Loop steht im Rampenlicht, aber einige weniger sichtbare Fähigkeiten sind bereits verdrahtet und wichtig, bevor Sie OD mit etwas anderem vergleichen:
- **Claude Design ZIP import.** Ziehen Sie einen Export von claude.ai in den Welcome Dialog. `POST /api/import/claude-design` extrahiert ihn in ein echtes `.od/projects/<id>/`, öffnet die Entry-Datei als Tab und bereitet einen Continue-where-Anthropic-left-off Prompt für Ihren lokalen Agent vor. Kein erneutes Prompting, kein "ask the model to re-create what we just had". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
- **OpenAI-kompatibler BYOK proxy.** `POST /api/proxy/stream` nimmt `{ baseUrl, apiKey, model, messages }`, normalisiert den Pfad (`…/v1/chat/completions`), leitet SSE-Chunks an den Browser zurück und lehnt loopback / link-local / RFC1918 Ziele ab, um SSRF zu verhindern. Alles, was das OpenAI Chat Schema spricht, funktioniert: Anthropic-via-OpenAI shim, DeepSeek, Groq, MiMo, OpenRouter, Ihr selbst gehostetes vLLM. MiMo bekommt automatisch `tool_choice: 'none'`, weil sein Tool Schema bei freier Generierung Probleme macht.
- **User-saved templates.** Wenn Ihnen ein Render gefällt, snapshottet `POST /api/templates` HTML + Metadata in die SQLite `templates` Tabelle. Das nächste Projekt wählt es aus einer "your templates" Zeile im Picker: dieselbe Oberfläche wie die mitgelieferten 31, aber Ihre eigene.
- **Tab persistence.** Jedes Projekt merkt sich offene Dateien und aktiven Tab in der `tabs` Tabelle. Öffnen Sie das Projekt morgen wieder, und der Workspace sieht genau so aus, wie Sie ihn verlassen haben.
- **Artifact lint API.** `POST /api/artifacts/lint` führt strukturelle Checks auf einem generierten Artifact aus (kaputtes `<artifact>` Framing, fehlende Side Files, stale palette tokens) und gibt Findings zurück, die der Agent in seinen nächsten Turn einlesen kann. Die fünfdimensionale Self-Critique nutzt das, um ihren Score auf echte Evidenz statt Vibes zu stützen.
- **Sidecar protocol + desktop automation.** Daemon-, Web- und Desktop-Prozesse tragen typisierte Five-Field-Stamps (`app · mode · namespace · ipc · source`) und expose'n einen JSON-RPC IPC Channel unter `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status \| eval \| screenshot` steuert diesen Channel, sodass Headless-E2E gegen eine echte Electron Shell funktioniert, ohne bespoke Harnesses ([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)).
- **Windows-friendly spawning.** Jeder Adapter, der sonst am ~32 KB argv Limit von `CreateProcess` bei langen zusammengesetzten Prompts scheitern würde (Codex, Gemini, OpenCode, Cursor Agent, Qwen, Qoder CLI, Pi), füttert den Prompt stattdessen über stdin. Claude Code und Copilot behalten `-p`; der daemon fällt auf eine temp prompt-file zurück, wenn selbst das überläuft.
- **Per-namespace runtime data.** `OD_DATA_DIR` und `--namespace` geben Ihnen vollständig isolierte `.od/`-artige Trees, damit Playwright, Beta Channels und Ihre echten Projekte nie dieselbe SQLite-Datei teilen.
## Anti-AI-Slop-Maschinerie
Die gesamte Maschinerie unten ist das [`huashu-design`](https://github.com/alchaincyf/huashu-design) Playbook, portiert in ODs Prompt Stack und pro Skill über Side-File-Pre-Flight erzwingbar. Lesen Sie [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) für die Live-Formulierung:
- **Question form first.** Turn 1 ist nur `<question-form>`: kein Denken, keine Tools, keine Narration. Der Nutzer wählt Defaults mit Radio-Geschwindigkeit.
- **Brand-spec extraction.** Wenn der Nutzer Screenshot oder URL anhängt, führt der Agent ein fünfstufiges Protokoll aus (locate · download · grep hex · codify `brand-spec.md` · vocalise), bevor er CSS schreibt. **Er rät Brandfarben niemals aus Erinnerung.**
- **Five-dim critique.** Vor dem Ausgeben von `<artifact>` bewertet der Agent seine Ausgabe still 15 über philosophy / hierarchy / execution / specificity / restraint. Alles unter 3/5 ist eine Regression: fixen und neu scoren. Zwei Durchgänge sind normal.
- **P0/P1/P2 checklist.** Jeder Skill liefert ein `references/checklist.md` mit harten P0 Gates. Der Agent muss P0 bestehen, bevor er ausgibt.
- **Slop blacklist.** Aggressive violette Gradients, generische Emoji Icons, runde Karte mit linkem Border Accent, handgezeichnete SVG-Menschen, Inter als *display* Face, erfundene Metriken: im Prompt ausdrücklich verboten.
- **Honest placeholders > fake stats.** Wenn der Agent keine echte Zahl hat, schreibt er `—` oder einen beschrifteten grauen Block, nicht "10× faster".
## Vergleich
| Achse | [Claude Design][cd] (Anthropic) | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| Lizenz | Closed | MIT | **Apache-2.0** |
| Formfaktor | Web (claude.ai) | Desktop (Electron) | **Web-App + lokaler Daemon** |
| Auf Vercel deploybar | ❌ | ❌ | **✅** |
| Agent-Runtime | Gebündelt (Opus 4.7) | Gebündelt ([`pi-ai`][piai]) | **Delegiert an die vorhandene CLI des Nutzers** |
| Skills | Proprietär | 12 Custom-TS-Module + `SKILL.md` | **31 dateibasierte [`SKILL.md`][skill] Bundles, einfach ablegbar** |
| Designsystem | Proprietär | `DESIGN.md` (v0.2 Roadmap) | **`DESIGN.md` × 72 ausgelieferte Systeme** |
| Provider-Flexibilität | Nur Anthropic | 7+ über [`pi-ai`][piai] | **16 CLI-Adapter + OpenAI-kompatibler BYOK-Proxy** |
| Initiales Fragenformular | ❌ | ❌ | **✅ Harte Regel, Turn 1** |
| Richtungswahl | ❌ | ❌ | **✅ 5 deterministische Richtungen** |
| Live-Todo-Fortschritt + Tool-Stream | ❌ | ✅ | **✅** (UX-Pattern aus open-codesign) |
| Sandboxed-iframe-Vorschau | ❌ | ✅ | **✅** (Pattern aus open-codesign) |
| Claude Design ZIP-Import | n/a | ❌ | **`POST /api/import/claude-design` — dort weiterbearbeiten, wo Anthropic aufgehört hat** |
| Chirurgische Edits im Kommentar-Modus | ❌ | ✅ | 🚧 Roadmap (aus [`open-codesign`][ocod] übernehmen) |
| AI-emitted Tweaks Panel | ❌ | ✅ | 🟡 Teilweise — [`tweaks` skill](skills/tweaks/) wird geliefert, dedizierte chatseitige Panel-UX bleibt Roadmap |
| Dateisystemnaher Workspace | ❌ | Teilweise (Electron-Sandbox) | **✅ Echtes cwd, echte Tools, persistentes SQLite (projects · conversations · messages · tabs · templates)** |
| 5-dimensionale Self-Critique | ❌ | ❌ | **✅ Pre-Emit-Gate** |
| Artifact Lint | ❌ | ❌ | **`POST /api/artifacts/lint` — Findings fließen zurück zum Agent** |
| Sidecar-IPC + headless Desktop | ❌ | ❌ | **✅ Gestempelte Prozesse + `tools-dev inspect desktop status \| eval \| screenshot`** |
| Exportformate | Begrenzt | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTX (agent-driven) / ZIP / Markdown** |
| PPT-Skill-Wiederverwendung | N/A | Built-in | **[`guizang-ppt-skill`][guizang] wird eingehängt (Default für deck mode)** |
| Mindestabrechnung | Pro / Max / Team | BYOK | **BYOK — jede OpenAI-kompatible `baseUrl` einfügen** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## Unterstützte Code-Agenten
Beim daemon Boot automatisch aus `PATH` erkannt. Keine Konfiguration nötig. Streaming Dispatch lebt in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) (`AGENT_DEFS`); per-CLI Parser liegen daneben. Modelle werden entweder durch Probing von `<bin> --list-models` / `<bin> models` / ACP Handshake befüllt oder aus einer kuratierten Fallback-Liste, wenn die CLI keine Liste ausgibt.
| Agent | Bin | Stream-Format | Argv-Form (zusammengesetzter Prompt-Pfad) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json` (typed events) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + `codex` Parser | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--model …] [-c model_reasoning_effort=…]` (Prompt über stdin) |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + `gemini` Parser | `GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo [--model …]` (Prompt über stdin) |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + `opencode` Parser | `opencode run --format json --dangerously-skip-permissions [--model …] -` (Prompt über stdin) |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + `cursor-agent` Parser | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -` (Prompt über stdin) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain` (rohe stdout Chunks) | `qwen --yolo [--model …] -` (Prompt über stdin) |
| Qoder CLI | `qodercli` | `qoder-stream-json` (typed events) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]` (Prompt über stdin) |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json` (typed events) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc` (Agent Client Protocol) | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc` (stdio JSON-RPC) | `pi --mode rpc [--model …] [--thinking …]` (Prompt als RPC-`prompt` Befehl gesendet) |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain` (raw stdout chunks) | `deepseek exec --auto [--model …] <prompt>` |
| **OpenAI-compatible BYOK** | n/a | SSE pass-through | `POST /api/proxy/stream``<baseUrl>/v1/chat/completions`; SSRF-guarded against loopback / link-local / RFC1918 |
Eine neue CLI ist ein Eintrag in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). Streaming Format ist eines von `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream` (mit per-CLI `eventParser`), `acp-json-rpc`, `pi-rpc` oder `plain`.
## Referenzen & Herkunft
Jedes externe Projekt, aus dem dieses Repo etwas übernimmt. Jeder Link führt zur Quelle, damit Sie die Provenienz prüfen können.
| Projekt | Rolle hier |
|---|---|
| [`Claude Design`][cd] | Das closed-source Produkt, zu dem dieses Repo die Open-Source-Alternative ist. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Der Design-Philosophie-Kern. Junior-Designer Workflow, 5-step brand-asset protocol, anti-AI-slop checklist, fünfdimensionale Self-Critique und die "5 schools × 20 design philosophies" Bibliothek hinter unserem Direction Picker, alles verdichtet in [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) und [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Web-PPT-Skill im Magazinstil, unverändert unter [`skills/guizang-ppt/`](skills/guizang-ppt/) gebündelt, ursprüngliche LICENSE bewahrt. Default für den Deck-Modus. P0/P1/P2 Checklist-Kultur für jeden anderen Skill übernommen. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Die daemon + adapter Architektur. PATH-Scan-Agent-Erkennung, lokaler daemon als einziger privilegierter Prozess, Agent-as-teammate Sichtweise. Wir übernehmen das Modell, nicht den Code. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | Die erste Open-Source-Alternative zu Claude Design und unser nächster Peer. Übernommene UX Patterns: streaming-artifact loop, sandboxed-iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible), fünf Exportformate (HTML/PDF/PPTX/ZIP/Markdown), local-first storage hub, `SKILL.md` taste-injection. UX Patterns auf unserer Roadmap: comment-mode surgical edits, AI-emitted tweaks panel. **Wir vendoren [`pi-ai`][piai] bewusst nicht**: open-codesign bündelt es als Agent Runtime; wir delegieren an die CLI, die der Nutzer bereits hat. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Quelle des 9-Section `DESIGN.md` Schemas und der 69 Produktsysteme, die über [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) importiert wurden. |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | Inspiration für symlink-basierte Skill-Verteilung über mehrere Agent-CLIs. |
| [Claude Code skills][skill] | Die `SKILL.md` Konvention wurde unverändert übernommen: Jeder Claude Code Skill kann in `skills/` gelegt werden und wird vom daemon gefunden. |
Der ausführliche Provenienztext, was wir jeweils übernehmen und was bewusst nicht, steht in [`docs/references.md`](docs/references.md).
## Roadmap
- [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] SQLite-backed projects · conversations · messages · tabs · templates
- [x] OpenAI-compatible BYOK proxy (`/api/proxy/stream`) with SSRF guard
- [x] Claude Design ZIP import (`/api/import/claude-design`)
- [x] Sidecar protocol + Electron desktop with IPC automation (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN)
- [x] Artifact lint API + 5-dim self-critique pre-emit gate
- [ ] Comment-mode surgical edits (click element → instruction → patch) — pattern from [`open-codesign`][ocod]
- [ ] AI-emitted tweaks panel UX — building block ([`tweaks` skill](skills/tweaks/)) ships; chat-integrated panel still pending
- [ ] Vercel + tunnel deployment recipe (Topology B)
- [ ] One-command `npx od init` to scaffold a project with `DESIGN.md`
- [ ] Skill marketplace (`od skills install <github-repo>`) and `od skill add | list | remove | test` CLI surface (drafted in [`docs/skills-protocol.md`](docs/skills-protocol.md), implementation pending)
- [x] Packaged Electron build out of `apps/packaged/` — macOS (Apple Silicon) und Windows (x64) Downloads auf [open-design.ai](https://open-design.ai/) und der [GitHub Releases-Seite](https://github.com/nexu-io/open-design/releases)
Phased delivery → [`docs/roadmap.md`](docs/roadmap.md).
## Status
Dies ist eine frühe Implementierung: Der geschlossene Loop (detect → pick skill + design system → chat → parse `<artifact>` → preview → save) läuft end-to-end. Prompt Stack und Skill-Bibliothek tragen den größten Wert und sind stabil. Die komponentenbezogene UI wird täglich ausgeliefert.
## Geben Sie uns einen Star
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="Star Open Design on GitHub — github.com/nexu-io/open-design" width="100%" /></a>
</p>
Wenn Ihnen das dreißig Minuten gespart hat, geben Sie ein ★. Stars bezahlen keine Miete, aber sie zeigen dem nächsten Designer, Agent und Contributor, dass dieses Experiment Aufmerksamkeit verdient. Ein Klick, drei Sekunden, echtes Signal: [github.com/nexu-io/open-design](https://github.com/nexu-io/open-design).
## Mitwirken
Issues, PRs, neue Skills und neue Design Systems sind willkommen. Die wirkungsvollsten Beiträge sind meist ein Ordner, eine Markdown-Datei oder ein PR-großer Adapter:
- **Add a skill** — legen Sie einen Ordner in [`skills/`](skills/) an, der der [`SKILL.md`][skill] Konvention folgt.
- **Add a design system** — legen Sie ein `DESIGN.md` in [`design-systems/<brand>/`](design-systems/) nach dem 9-Section Schema ab.
- **Wire up a new coding-agent CLI** — ein Eintrag in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts).
Vollständiger Walkthrough, Merge-Messlatte, Code Style und was wir nicht annehmen → [`CONTRIBUTING.de.md`](CONTRIBUTING.de.md) ([English](CONTRIBUTING.md), [Français](CONTRIBUTING.fr.md), [简体中文](CONTRIBUTING.zh-CN.md)).
## Mitwirkende
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-06" 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.
## Repository-Aktivität
<picture>
<img alt="Open Design — repository metrics" src="docs/assets/github-metrics.svg" />
</picture>
Das SVG oben wird täglich von [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) mit [`lowlighter/metrics`](https://github.com/lowlighter/metrics) regeneriert. Lösen Sie auf dem **Actions** Tab manuell eine Aktualisierung aus, wenn Sie sie früher brauchen; für reichere Plugins (traffic, follow-up time) fügen Sie ein `METRICS_TOKEN` Repository Secret mit einem fine-grained PAT hinzu.
## Star-Historie
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
</picture>
</a>
Wenn die Kurve nach oben biegt, ist das das Signal, nach dem wir suchen. ★ dieses Repo, um sie anzuschieben.
## Lizenz
Apache-2.0. Das gebündelte [`skills/guizang-ppt/`](skills/guizang-ppt/) behält seine ursprüngliche [LICENSE](skills/guizang-ppt/LICENSE) (MIT) und Autorenschaftszuordnung zu [op7418](https://github.com/op7418).

814
README.es.md Normal file
View File

@@ -0,0 +1,814 @@
# Open Design
> **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>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://github.com/nexu-io/open-design/releases/latest"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#coding-agents-soportados"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <b>Español</b> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a></p>
---
## Por qué existe
[Claude Design][cd] de Anthropic (lanzado el 2026-04-17 con Opus 4.7) mostró qué pasa cuando un LLM deja de escribir prosa y empieza a entregar artefactos de diseño. Se volvió viral, pero siguió siendo closed-source, de pago, cloud-only y bloqueado al modelo y las skills de Anthropic. No hay checkout, no hay self-hosting, no hay despliegue en Vercel y no hay forma de cambiarlo por tu propio agente.
**Open Design (OD) es la alternativa open source.** El mismo bucle, el mismo modelo mental artifact-first, sin lock-in. No distribuimos un agente: los coding agents más fuertes ya viven en tu laptop. Los conectamos a un flujo de diseño guiado por skills que corre localmente con `pnpm tools-dev`, puede desplegar la capa web en Vercel y mantiene BYOK en cada capa.
Escribe `make me a magazine-style pitch deck for our seed round`. El formulario interactivo aparece antes de que el modelo improvise un solo píxel. El agente elige una de cinco direcciones visuales curadas. Un plan `TodoWrite` en vivo se transmite en la UI. El daemon crea una carpeta real en disco con una plantilla inicial, una biblioteca de layouts y una checklist de autoevaluación. El agente las lee, con pre-flight obligatorio, ejecuta una crítica de cinco dimensiones sobre su propia salida y emite un único `<artifact>` que se renderiza segundos después en un iframe sandboxed.
Eso no es "AI tries to design something". Es una IA entrenada por el prompt stack para comportarse como un diseñador senior con filesystem real, una biblioteca de paletas determinista y cultura de checklist: exactamente el estándar que Claude Design marcó, pero abierto y tuyo.
OD se apoya en cuatro hombros open source:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design): la brújula de filosofía de diseño. El flujo Junior-Designer, el protocolo de marca en 5 pasos, la checklist anti-AI-slop, la autocrítica de 5 dimensiones y la idea de "5 schools × 20 design philosophies" detrás del selector de dirección, todo destilado en [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill): el modo deck. Incluido literalmente bajo [`skills/guizang-ppt/`](skills/guizang-ppt/) con la LICENSE original preservada; layouts magazine-style, hero WebGL y checklists P0/P1/P2.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign): la estrella norte de UX y nuestro par más cercano. La primera alternativa open source a Claude Design. Tomamos prestados su bucle streaming-artifact, el patrón de preview en iframe sandboxed (React 18 + Babel vendorizados), su panel de agente en vivo (todos + tool calls + generación interrumpible) y su lista de cinco formatos de exportación (HTML / PDF / PPTX / ZIP / Markdown). Divergimos deliberadamente en el formato: ellos son una app Electron de escritorio con [`pi-ai`][piai]; nosotros somos una web app + daemon local que delega en tu CLI existente.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica): la arquitectura daemon-and-runtime. Detección de agentes en `PATH`, el daemon local como único proceso privilegiado y la visión del agente como compañero de equipo.
## De un vistazo
| | Lo que obtienes |
|---|---|
| **Coding-agent CLIs (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — auto-detectadas en `PATH`, intercambiables con un clic |
| **Fallback BYOK** | Proxy API específico por protocolo en `/api/proxy/{anthropic,openai,azure,google}/stream`: pega `baseUrl` + `apiKey` + `model`, elige Anthropic / OpenAI / Azure OpenAI / Google Gemini, y el daemon normaliza SSE de vuelta al mismo stream de chat. IP internas/SSRF bloqueadas en el borde del daemon. |
| **Design systems incluidos** | **129**: 2 starters escritos a mano + 70 sistemas de producto (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) desde [`awesome-design-md`][acd2], más 57 design skills desde [`awesome-design-skills`][ads] añadidas directamente bajo `design-systems/` |
| **Skills incluidas** | **31**: 27 en modo `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 en modo `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Agrupadas en el selector por `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Generación de medios** | Superficies de imagen · video · audio junto al bucle de diseño. **gpt-image-2** (Azure / OpenAI) para pósters, avatares, infografías y mapas ilustrados · **Seedance 2.0** (ByteDance) para text-to-video e image-to-video cinematográfico de 15s · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) para motion graphics HTML→MP4 (product reveals, tipografía cinética, charts de datos, overlays sociales, logo outros). **93** prompts listos para replicar: 43 gpt-image-2 + 39 Seedance + 11 HyperFrames bajo [`prompt-templates/`](prompt-templates/), con thumbnails de preview y atribución de fuente. La misma superficie de chat que el código; produce chips reales `.mp4` / `.png` en el workspace del proyecto. |
| **Direcciones visuales** | 5 escuelas curadas (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental): cada una trae una paleta OKLch determinista + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Frames de dispositivo** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome: pixel-perfect, compartidos entre skills bajo [`assets/frames/`](assets/frames/) |
| **Runtime de agente** | El daemon local spawnea la CLI en la carpeta del proyecto: el agente recibe `Read`, `Write`, `Bash`, `WebFetch` reales contra un entorno real en disco, con fallbacks de Windows `ENAMETOOLONG` (stdin / prompt-file) en cada adapter |
| **Imports** | Suelta un ZIP exportado desde [Claude Design][cd] en el diálogo de bienvenida: `POST /api/import/claude-design` lo parsea en un proyecto real para que tu agente siga editando donde Anthropic lo dejó |
| **Persistencia** | SQLite en `.od/app.sqlite`: projects · conversations · messages · tabs · saved templates. Reabre mañana y la tarjeta de todo y los archivos abiertos estarán exactamente donde los dejaste. |
| **Lifecycle** | Un punto de entrada: `pnpm tools-dev` (start / stop / run / status / logs / inspect / check): arranca daemon + web (+ desktop) bajo sidecar stamps tipados |
| **Desktop** | Shell Electron opcional con renderer sandboxed + sidecar IPC (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN) — impulsa `tools-dev inspect desktop screenshot` para E2E |
| **Desplegable en** | Local (`pnpm tools-dev`) · capa web en Vercel · Electron empaquetado (placeholder, en curso) |
| **Licencia** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ads]: https://github.com/bergside/awesome-design-skills
## Demo
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · Vista de entrada" /><br/>
<sub><b>Vista de entrada</b> — elige una skill, elige un design system y escribe el brief. La misma superficie para prototipos, decks, apps móviles, dashboards y páginas editoriales.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · Formulario de descubrimiento del primer turno" /><br/>
<sub><b>Formulario de descubrimiento del primer turno</b> — antes de que el modelo escriba un píxel, OD fija el brief: superficie, audiencia, tono, contexto de marca y escala. 30 segundos de radios superan 30 minutos de redirecciones.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · Selector de dirección" /><br/>
<sub><b>Selector de dirección</b> — cuando el usuario no tiene marca, el agente emite un segundo formulario con 5 direcciones curadas (Monocle / Modern Minimal / Tech Utility / Brutalist / Soft Warm). Un clic de radio → una paleta determinista + font stack, sin freestyle del modelo.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · Progreso todo en vivo" /><br/>
<sub><b>Progreso todo en vivo</b> — el plan del agente se transmite como una tarjeta en vivo. Las actualizaciones <code>in_progress</code> → <code>completed</code> llegan en tiempo real. El usuario puede redirigir barato, a mitad del vuelo.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · Preview sandboxed" /><br/>
<sub><b>Preview sandboxed</b> — cada <code>&lt;artifact&gt;</code> se renderiza en un iframe srcdoc limpio. Editable en sitio mediante el file workspace; descargable como HTML, PDF o ZIP.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · Biblioteca de 72 sistemas" /><br/>
<sub><b>Biblioteca de 72 sistemas</b> — cada sistema de producto muestra su firma de 4 colores. Haz clic para ver el <code>DESIGN.md</code> completo, la cuadrícula de muestras y el showcase en vivo.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · Magazine deck" /><br/>
<sub><b>Modo deck (guizang-ppt)</b> — el <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> incluido entra sin cambios. Layouts magazine, fondos hero WebGL, salida HTML single-file y export PDF.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · Prototipo móvil" /><br/>
<sub><b>Prototipo móvil</b> — chrome de iPhone 15 Pro pixel-perfect (Dynamic Island, SVGs de status bar, home indicator). Los prototipos multi-screen usan los assets compartidos de <code>/frames/</code>, así el agente nunca redibuja un teléfono.</sub>
</td>
</tr>
</table>
## Skills
**31 skills vienen incluidas.** Cada una es una carpeta bajo [`skills/`](skills/) siguiendo la convención [`SKILL.md`][skill] de Claude Code, con un frontmatter extendido `od:` que el daemon parsea literalmente: `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)).
Dos **modos** principales sostienen el catálogo: **`prototype`** (27 skills: cualquier cosa que renderiza como artefacto single-page, desde una landing editorial hasta una pantalla móvil o un PM spec doc) y **`deck`** (4 skills: presentaciones con swipe horizontal y chrome de deck-framework). El campo **`scenario`** es lo que el selector usa para agruparlas: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
### Ejemplos showcase
Las skills visualmente distintivas que probablemente probarás primero. Cada una trae un `example.html` real que puedes abrir directamente desde el repo para ver exactamente lo que producirá el agente, sin auth ni setup.
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>Dashboard consumer dating / matchmaking — navegación lateral izquierda, ticker bar, KPIs, chart de mutual matches a 30 días y tipografía editorial.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>E-guide digital de dos spreads — portada (título, autor, teaser de TOC) + spread de lección con pull-quote y lista de pasos. Tono creator / lifestyle.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>Email HTML de lanzamiento de producto de marca — masthead, hero image, headline lockup, CTA y specs grid. Columna única centrada, seguro con table fallback.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>Prototipo de app móvil gamificada en tres frames sobre un escenario showcase oscuro — portada, misiones de hoy con ribbons de XP + barra de nivel y detalle de misión.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>Flujo de onboarding móvil en tres frames — splash, value-prop, sign-in. Status bar, swipe dots y CTA principal.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>Hero motion-design de un frame con animaciones CSS en loop — anillo tipográfico rotatorio, globo animado y temporizador en marcha. Listo para hand-off a HyperFrames.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>Carrusel social de tres cards 1080×1080 — paneles cinematográficos con titulares display que conectan la serie, marca y affordance de loop.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>Slide explicativo animado pixel / 8-bit — escenario crema full-bleed, mascota pixel animada, display type japonés cinético y keyframes CSS en loop.</sub>
</td>
</tr>
</table>
### Superficies de diseño y marketing (modo prototype)
| Skill | Plataforma | Escenario | Qué produce |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | desktop | design | HTML single-page: landings, marketing, hero pages (default para prototype) |
| [`saas-landing`](skills/saas-landing/) | desktop | marketing | Layout de hero / features / pricing / CTA |
| [`dashboard`](skills/dashboard/) | desktop | operation | Admin / analytics con sidebar + layout denso de datos |
| [`pricing-page`](skills/pricing-page/) | desktop | sale | Pricing independiente + tablas comparativas |
| [`docs-page`](skills/docs-page/) | desktop | engineering | Documentación de 3 columnas |
| [`blog-post`](skills/blog-post/) | desktop | marketing | Long-form editorial |
| [`mobile-app`](skills/mobile-app/) | mobile | design | Pantalla(s) de app en frame iPhone 15 Pro / Pixel |
| [`mobile-onboarding`](skills/mobile-onboarding/) | mobile | design | Flujo mobile onboarding multi-screen (splash · value-prop · sign-in) |
| [`gamified-app`](skills/gamified-app/) | mobile | personal | Prototipo gamificado mobile en tres frames |
| [`email-marketing`](skills/email-marketing/) | desktop | marketing | Email HTML de lanzamiento de producto (seguro con table fallback) |
| [`social-carousel`](skills/social-carousel/) | desktop | marketing | Carrusel social 1080×1080 de 3 cards |
| [`magazine-poster`](skills/magazine-poster/) | desktop | marketing | Póster single-page estilo revista |
| [`motion-frames`](skills/motion-frames/) | desktop | marketing | Hero motion-design con animaciones CSS en loop |
| [`sprite-animation`](skills/sprite-animation/) | desktop | marketing | Slide explicativo pixel / 8-bit animado |
| [`dating-web`](skills/dating-web/) | desktop | personal | Mockup de dashboard consumer dating |
| [`digital-eguide`](skills/digital-eguide/) | desktop | marketing | E-guide digital de dos spreads (cover + lesson) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | desktop | design | Boceto de ideación hand-drawn para el pase de "mostrar algo visible temprano" |
| [`critique`](skills/critique/) | desktop | design | Hoja de autocrítica de cinco dimensiones (Philosophy · Hierarchy · Detail · Function · Innovation) |
| [`tweaks`](skills/tweaks/) | desktop | design | Panel de tweaks emitido por la IA: el modelo expone los parámetros que vale la pena ajustar |
### Superficies deck (modo deck)
| Skill | Default para | Qué produce |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **default** para deck | Web PPT estilo revista: incluido literalmente desde [op7418/guizang-ppt-skill][guizang], LICENSE original preservada |
| [`simple-deck`](skills/simple-deck/) | — | Deck minimal de swipe horizontal |
| [`replit-deck`](skills/replit-deck/) | — | Deck de walkthrough de producto (estilo Replit) |
| [`weekly-update`](skills/weekly-update/) | — | Cadencia semanal de equipo como swipe deck (progress · blockers · next) |
### Superficies de oficina y operaciones (modo prototype, escenarios tipo documento)
| Skill | Escenario | Qué produce |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | Documento de PM spec con TOC + decision log |
| [`team-okrs`](skills/team-okrs/) | product | Hoja de OKR |
| [`meeting-notes`](skills/meeting-notes/) | operation | Registro de decisiones de reunión |
| [`kanban-board`](skills/kanban-board/) | operation | Snapshot de tablero |
| [`eng-runbook`](skills/eng-runbook/) | engineering | Runbook de incidente |
| [`finance-report`](skills/finance-report/) | finance | Resumen financiero ejecutivo |
| [`invoice`](skills/invoice/) | finance | Factura single-page |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | Plan de onboarding de rol |
Añadir una skill toma una carpeta. Lee [`docs/skills-protocol.md`](docs/skills-protocol.md) para el frontmatter extendido, haz fork de una skill existente, reinicia el daemon y aparecerá en el selector. El endpoint de catálogo es `GET /api/skills`; el armado de seed por skill (template + referencias side-file) vive en `GET /api/skills/:id/example`.
## Seis ideas centrales
### 1 · No distribuimos un agente. El tuyo es suficiente.
El daemon escanea tu `PATH` buscando [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/mariozechner/pi-ai), [`kiro-cli`](https://kiro.dev), `kilo`, [`vibe-acp`](https://github.com/mistralai/mistral-vibe) y `deepseek` al iniciar. Los que encuentra se vuelven motores de diseño candidatos, controlados por stdio con un adapter por CLI y reemplazables desde el selector de modelo. Inspirado por [`multica`](https://github.com/multica-ai/multica) y [`cc-switch`](https://github.com/farion1231/cc-switch). ¿Sin CLI instalada? El modo API es el mismo pipeline sin spawn: elige Anthropic, OpenAI-compatible, Azure OpenAI o Google Gemini y el daemon devuelve chunks SSE normalizados, rechazando loopback / link-local / RFC1918 en el borde.
### 2 · Las Skills son archivos, no plugins.
Siguiendo la convención [`SKILL.md`](https://docs.anthropic.com/en/docs/claude-code/skills) de Claude Code, cada skill es `SKILL.md` + `assets/` + `references/`. Suelta una carpeta en [`skills/`](skills/), reinicia el daemon y aparece en el selector. El `magazine-web-ppt` incluido es [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) commiteado literalmente: licencia original preservada, atribución preservada.
### 3 · Los Design Systems son Markdown portable, no theme JSON.
El schema `DESIGN.md` de 9 secciones de [`VoltAgent/awesome-design-md`][acd2]: color, typography, spacing, layout, components, motion, voice, brand, anti-patterns. Cada artefacto lee desde el sistema activo. Cambia el sistema → el siguiente render usa los nuevos tokens. El dropdown viene con **Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Apple, Anthropic, Cursor, Supabase, Figma, Resend, Raycast, Lovable, Cohere, Mistral, ElevenLabs, X.AI, Spotify, Webflow, Sanity, PostHog, Sentry, MongoDB, ClickHouse, Cal, Replicate, Clay, Composio, Xiaohongshu…**, más 57 design skills tomadas de [`awesome-design-skills`][ads].
### 4 · El formulario interactivo evita el 80% de redirecciones.
El prompt stack de OD fija una `RULE 1`: cada brief de diseño nuevo empieza con un `<question-form id="discovery">` en lugar de código. Surface · audience · tone · brand context · scale · constraints. Incluso un brief largo deja decisiones de diseño abiertas: tono visual, postura de color, escala. Son exactamente las cosas que el formulario cierra en 30 segundos. El costo de una dirección equivocada es una ronda de chat, no un deck terminado.
Este es el **Junior-Designer mode** destilado de [`huashu-design`](https://github.com/alchaincyf/huashu-design): agrupar preguntas al inicio, mostrar algo visible temprano (incluso un wireframe con bloques grises) y permitir redirección barata. Combinado con el protocolo de brand assets (locate · download · `grep` hex · write `brand-spec.md` · vocalise), es la principal razón por la que el output deja de sentirse como freestyle de IA y empieza a sentirse como un diseñador que prestó atención antes de pintar.
### 5 · El daemon hace que el agente se sienta en tu laptop, porque lo está.
El daemon spawnea la CLI con `cwd` apuntando a la carpeta de artefactos del proyecto bajo `.od/projects/<id>/`. El agente recibe `Read`, `Write`, `Bash`, `WebFetch`: herramientas reales contra un filesystem real. Puede `Read` el `assets/template.html` de la skill, hacer `grep` de tus CSS para valores hex, escribir `brand-spec.md`, guardar imágenes generadas y producir archivos `.pptx` / `.zip` / `.pdf` que aparecen en el workspace como chips de descarga al terminar el turno. Sesiones, conversaciones, mensajes y pestañas persisten en SQLite local: abre el proyecto mañana y la tarjeta de todo del agente estará donde la dejaste.
### 6 · El prompt stack es el producto.
Lo que se compone al enviar no es "system + user". Es:
```
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)
+ 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)
```
Cada capa es componible. Cada capa es un archivo que puedes editar. Lee [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) y [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para ver el contrato real.
## Arquitectura
```
┌────────────────────── browser (Next.js 16) ──────────────────────┐
│ chat · file workspace · iframe preview · settings · imports │
└──────────────┬───────────────────────────────────┬───────────────┘
│ /api/* (rewritten in dev) │
▼ ▼
┌──────────────────────────────────┐ /api/proxy/{provider}/stream (SSE)
│ Local daemon (Express + SQLite) │ ─→ any OpenAI-compat
│ │ endpoint (BYOK)
│ /api/agents /api/skills│ w/ SSRF blocking
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/{provider}/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (static) /frames (static)
│ optional: sidecar IPC at /tmp/open-design/ipc/<ns>/<app>.sock
│ (STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN)
└─────────┬────────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · devin (ACP) · gemini · opencode · cursor-agent │
│ qwen · qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) · kiro (ACP) · kilo (ACP) · vibe (ACP) · deepseek │
│ reads SKILL.md + DESIGN.md, writes artifacts to disk │
└──────────────────────────────────────────────────────────────────┘
```
| Capa | Stack |
|---|---|
| Frontend | Next.js 16 App Router + React 18 + TypeScript, desplegable en Vercel |
| Daemon | Node 24 · Express · SSE streaming · `better-sqlite3`; tablas: `projects` · `conversations` · `messages` · `tabs` · `templates` |
| Transporte de agente | `child_process.spawn`; parsers de eventos tipados para `claude-stream-json` (Claude Code), `qoder-stream-json` (Qoder CLI), `copilot-stream-json` (Copilot), `json-event-stream` por CLI (Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe via Agent Client Protocol), `pi-rpc` (Pi via stdio JSON-RPC), `plain` (Qwen Code / DeepSeek TUI) |
| Proxy BYOK | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → APIs upstream específicas por proveedor, SSE `delta/end/error` normalizado; rechaza hosts loopback / link-local / RFC1918 en el borde del daemon |
| Storage | Archivos planos en `.od/projects/<id>/` + SQLite en `.od/app.sqlite` + credenciales en `.od/media-config.json` (gitignored, auto-creado). `OD_DATA_DIR=<dir>` reubica todos los datos del daemon; `OD_MEDIA_CONFIG_DIR=<dir>` limita el override solo a `media-config.json` |
| Preview | Iframe sandboxed via `srcdoc` + parser `<artifact>` por skill ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
| Export | HTML (assets inline) · PDF (browser print, deck-aware) · PPTX (agent-driven via skill) · ZIP (archiver) · Markdown |
| Lifecycle | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`; puertos via `--daemon-port` / `--web-port`, namespaces via `--namespace` |
| Desktop (opcional) | Shell Electron: descubre la URL web mediante sidecar IPC, sin adivinar puertos; el mismo canal `STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN` impulsa `tools-dev inspect desktop …` para E2E |
## Quickstart
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # should print 10.33.2
pnpm install
pnpm tools-dev run web
# open the web URL printed by tools-dev
```
Lanzador de Windows: compila `OpenDesign.exe` con las instrucciones de `tools/launcher/README.md` o descárgalo desde GitHub Releases. Después colócalo en la raíz del repo y haz doble clic para ejecutar `pnpm install` si hace falta e iniciar Open Design con `pnpm tools-dev`.
Requisitos de entorno: Node `~24` y pnpm `10.33.x`. `nvm`/`fnm` son helpers opcionales; si usas uno, ejecuta `nvm install 24 && nvm use 24` o `fnm install 24 && fnm use 24` antes de `pnpm install`.
Para arranque desktop/background, reinicios con puerto fijo y checks del dispatcher de media generation (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), consulta [`QUICKSTART.md`](QUICKSTART.md).
La primera carga:
1. Detecta qué agent CLIs tienes en `PATH` y elige una automáticamente.
2. Carga 31 skills + 72 design systems.
3. Muestra el diálogo de bienvenida para pegar una Anthropic key (solo necesaria para el fallback BYOK).
4. **Auto-crea `./.od/`**: la carpeta runtime local para SQLite, artefactos por proyecto y renders guardados. No hay paso `od init`; el daemon hace `mkdir` de todo lo que necesita al arrancar.
Escribe un prompt, pulsa **Enviar**, mira llegar el question form, complétalo, mira el todo card en stream y luego el artefacto renderizado. Haz clic en **Guardar en disco** o descarga como ZIP del proyecto.
### Estado de primera ejecución (`./.od/`)
El daemon posee una carpeta oculta en la raíz del repo. Todo dentro está gitignored y es local a la máquina: nunca lo commitees.
```
.od/
├── app.sqlite ← projects · conversations · messages · open tabs
├── artifacts/ ← one-off "Save to disk" renders (timestamped)
└── projects/<id>/ ← per-project working dir, also the agent's cwd
```
| Quieres… | Haz esto |
|---|---|
| Inspeccionar qué hay ahí | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| Resetear a limpio | `pnpm tools-dev stop`, `rm -rf .od`, vuelve a ejecutar `pnpm tools-dev run web` |
| Moverlo a otro lugar | todavía no soportado: la ruta está hard-codeada relativa al repo |
Mapa completo de archivos, scripts y troubleshooting → [`QUICKSTART.md`](QUICKSTART.md).
## Ejecutar el proyecto
Open Design puede ejecutarse como web app en tu navegador o como aplicación desktop de Electron. Ambos modos comparten la misma arquitectura de daemon local + web.
### Web / Localhost (Default)
```bash
# Foreground mode — keeps the lifecycle command in the foreground (logs written to files)
pnpm tools-dev run web
# View recent logs:
pnpm tools-dev logs
# Background mode — daemon + web run as background processes
pnpm tools-dev start web
```
Por defecto, `tools-dev` se enlaza a puertos efímeros disponibles e imprime las URLs reales al arrancar. Para usar puertos fijos desde un estado detenido:
```bash
pnpm tools-dev run web --daemon-port 17456 --web-port 17573
```
Si daemon/web ya están corriendo, usa `restart` para cambiar puertos en la sesión existente:
```bash
pnpm tools-dev restart --daemon-port 17456 --web-port 17573
```
### Desktop / Electron
```bash
# Start daemon + web + desktop in the background
pnpm tools-dev
# Check desktop status
pnpm tools-dev inspect desktop status
# Take a screenshot of the desktop app
pnpm tools-dev inspect desktop screenshot --path /tmp/open-design.png
```
La app desktop descubre la URL web automáticamente mediante sidecar IPC — no hace falta adivinar puertos.
### Otros comandos útiles
| Comando | Qué hace |
|---|---|
| `pnpm tools-dev status` | Muestra los estados de sidecar en ejecución |
| `pnpm tools-dev logs` | Muestra las colas de logs de daemon/web/desktop |
| `pnpm tools-dev stop` | Detiene todos los sidecars en ejecución |
| `pnpm tools-dev restart` | Detiene y luego reinicia todos los sidecars |
| `pnpm tools-dev check` | Estado + logs recientes + diagnósticos comunes |
Para reinicios con puertos fijos, arranque en background y troubleshooting completo, consulta [`QUICKSTART.md`](QUICKSTART.md).
## Usar Open Design desde tu coding agent
Open Design trae un servidor MCP stdio. Conéctalo a Claude Code, Codex, Cursor, VS Code, Antigravity, Zed, Windsurf o cualquier cliente compatible con MCP y el agente en otro repo podrá leer archivos de tus proyectos locales de Open Design directamente. Reemplaza el ciclo exportar-zip-y-adjuntar. Cuando el agente llama `search_files`, `get_file` o `get_artifact` sin argumento de proyecto, el MCP usa por defecto el proyecto (y archivo) que tienes abierto ahora en Open Design, así que prompts como *"build this in my app"* o *"match these styles"* simplemente funcionan.
**¿Por qué MCP?** Exportar y re-adjuntar un zip en cada iteración rompe el flujo. El MCP server expone tu fuente de diseño directamente -- tokens CSS, componentes JSX, entry HTML -- como API estructurada que el agente puede consultar por nombre. El agente siempre ve el archivo vivo, no una copia obsoleta del último export.
Abre **Ajustes → MCP server** en la app Open Design para un flujo de instalación por cliente. El panel inserta la ruta absoluta de tu binario `node` y del `cli.js` compilado del daemon en cada snippet, así funciona en un source clone nuevo donde `od` no está en tu PATH. Cursor recibe un deeplink de un clic; los demás reciben un snippet JSON copy-paste en el schema que espera su archivo de config (Claude Code incluye un one-liner `claude mcp add-json` para no editar a mano `~/.claude.json`). Reinicia o recarga tu cliente después de instalar para que el servidor aparezca.
El daemon debe estar corriendo localmente para que las tool calls MCP funcionen. Si el agente se inició antes que Open Design, reinicia el agente cuando Open Design ya esté arriba para que alcance el daemon vivo. Las tool calls hechas con el daemon offline devuelven un error claro `"daemon not reachable"` en lugar de crashear.
**Modelo de seguridad.** El MCP server es read-only; expone lectura de archivos, metadata y búsqueda, nada que escriba a disco o llame servicios externos. Corre como child process del coding agent sobre stdio, así que cualquier cliente MCP que registres hereda acceso de lectura a tus proyectos locales de Open Design. Trátalo como instalar una extensión de VS Code: solo registra clientes en los que confíes. El daemon se enlaza a `127.0.0.1` por defecto; exponerlo en LAN requiere opt-in explícito con `OD_BIND_HOST`.
## Estructura del repositorio
```
open-design/
├── README.md ← this file
├── README.de.md ← Deutsch
├── README.ru.md ← Русский
├── README.zh-CN.md ← 简体中文
├── QUICKSTART.md ← run / build / deploy guide
├── package.json ← pnpm workspace, single bin: od
├── apps/
│ ├── daemon/ ← Node + Express, the only server
│ │ ├── src/ ← TypeScript daemon source
│ │ │ ├── cli.ts ← `od` bin source, compiled to dist/cli.js
│ │ │ ├── server.ts ← /api/* routes (projects, chat, files, exports)
│ │ │ ├── agents.ts ← PATH scanner + per-CLI argv builders
│ │ │ ├── claude-stream.ts ← streaming JSON parser for Claude Code stdout
│ │ │ ├── skills.ts ← SKILL.md frontmatter loader
│ │ │ └── db.ts ← SQLite schema (projects/messages/templates/tabs)
│ │ ├── sidecar/ ← tools-dev daemon sidecar wrapper
│ │ └── tests/ ← daemon package tests
│ │
│ └── web/ ← Next.js 16 App Router + React client
│ ├── app/ ← App Router entrypoints
│ ├── next.config.ts ← dev rewrites + prod static export to out/
│ └── src/ ← React + TypeScript client modules
│ ├── App.tsx ← routing, bootstrap, settings
│ ├── components/ ← chat, composer, picker, preview, sketch, …
│ ├── prompts/
│ │ ├── system.ts ← composeSystemPrompt(base, skill, DS, metadata)
│ │ ├── discovery.ts ← turn-1 form + turn-2 branch + 5-dim critique
│ │ └── directions.ts ← 5 visual directions × OKLch palette + font stack
│ ├── artifacts/ ← streaming <artifact> parser + manifests
│ ├── runtime/ ← iframe srcdoc, markdown, export helpers
│ ├── providers/ ← daemon SSE + BYOK API transports
│ └── state/ ← config + projects (localStorage + daemon-backed)
├── e2e/ ← Playwright UI + external integration/Vitest harness
├── packages/
│ ├── contracts/ ← shared web/daemon app contracts
│ ├── sidecar-proto/ ← Open Design sidecar protocol contract
│ ├── sidecar/ ← generic sidecar runtime primitives
│ └── platform/ ← generic process/platform primitives
├── skills/ ← 31 SKILL.md skill bundles (27 prototype + 4 deck)
│ ├── web-prototype/ ← default for prototype mode
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← deck mode
│ └── guizang-ppt/ ← bundled magazine-web-ppt (default for deck)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 DESIGN.md systems
│ ├── default/ ← Neutral Modern (starter)
│ ├── warm-editorial/ ← Warm Editorial (starter)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md ← catalog overview
├── assets/
│ └── frames/ ← shared device frames (used cross-skill)
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ ├── deck-framework.html ← deck baseline (nav / counter / print)
│ └── kami-deck.html ← kami-flavored deck starter (parchment / ink-blue serif)
├── scripts/
│ └── sync-design-systems.ts ← re-import upstream awesome-design-md tarball
├── docs/
│ ├── spec.md ← product spec, scenarios, differentiation
│ ├── architecture.md ← topologies, data flow, components
│ ├── skills-protocol.md ← extended SKILL.md od: frontmatter
│ ├── agent-adapters.md ← per-CLI detection + dispatch
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← long-form provenance
│ ├── roadmap.md ← phased delivery
│ ├── schemas/ ← JSON schemas
│ └── examples/ ← canonical artifact examples
└── .od/ ← runtime data, gitignored, auto-created
├── app.sqlite ← projects / conversations / messages / tabs
├── projects/<id>/ ← per-project working folder (agent's cwd)
└── artifacts/ ← saved one-off renders
```
## Design Systems
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="The 72 design systems library — style guide spread" width="100%" />
</p>
72 sistemas listos, cada uno como un único [`DESIGN.md`](design-systems/README.md):
<details>
<summary><b>Catálogo completo</b> (clic para expandir)</summary>
**AI & LLM**`claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**Developer Tools**`cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**Productivity**`notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**Fintech**`stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**E-Commerce**`shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**Media**`spotify` · `playstation` · `wired` · `theverge` · `meta`
**Automotive**`tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**Other**`apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**Starters**`default` (Neutral Modern) · `warm-editorial`
</details>
La biblioteca de sistemas de producto se importa mediante [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) desde [`VoltAgent/awesome-design-md`][acd2]. Vuelve a ejecutarlo para refrescar. Las 57 design skills vienen de [`bergside/awesome-design-skills`][ads] y se agregan directamente en `design-systems/`.
## Direcciones visuales
Cuando el usuario no tiene brand spec, el agente emite un segundo formulario con cinco direcciones curadas: la adaptación OD del fallback ["5 schools × 20 design philosophies"](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback) de [`huashu-design`](https://github.com/alchaincyf/huashu-design). Cada dirección es una spec determinista: paleta en OKLch, font stack, pistas de layout y referencias, que el agente enlaza literalmente al `:root` de la plantilla seed. Un radio click → un sistema visual completamente especificado. Sin improvisación, sin AI-slop.
| Dirección | Mood | Referencias |
|---|---|---|
| Editorial — Monocle / FT | Revista impresa, tinta + crema + rust cálido | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | Frío, estructurado, acento mínimo | Linear · Vercel · Stripe |
| Tech utility | Densidad informativa, monospace, terminal | Bloomberg · Bauhaus tools |
| Brutalist | Crudo, tipografía oversized, sin sombras, acentos duros | Bloomberg Businessweek · Achtung |
| Soft warm | Generoso, bajo contraste, neutros melocotón | Notion marketing · Apple Health |
Spec completa → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
## Generación de medios
OD no se detiene en código. La misma superficie de chat que produce HTML `<artifact>` también impulsa generación de **imagen**, **video** y **audio**, con adapters de modelos conectados al pipeline de media del daemon ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Cada render aterriza como archivo real en el workspace del proyecto: `.png` para imagen, `.mp4` para video, y aparece como chip de descarga al terminar el turno.
Tres familias de modelos llevan la carga hoy:
| Superficie | Modelo | Proveedor | Para qué sirve |
|---|---|---|---|
| **Image** | `gpt-image-2` | Azure / OpenAI | Pósters, avatares, mapas ilustrados, infografías, social cards estilo revista, restauración fotográfica, arte de producto exploded-view |
| **Video** | `seedance-2.0` | ByteDance Volcengine | t2v + i2v cinematográfico de 15s con audio: shorts narrativos, close-ups de personajes, product films, coreografía estilo MV |
| **Video** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 motion graphics: product reveals, tipografía cinética, data charts, overlays sociales, logo outros, verticales TikTok con captions karaoke |
Una **galería de prompts** creciente en [`prompt-templates/`](prompt-templates/) trae **93 prompts listos para replicar**: 43 de imagen (`prompt-templates/image/*.json`), 39 Seedance (`prompt-templates/video/*.json` excluyendo `hyperframes-*`) y 11 HyperFrames (`prompt-templates/video/hyperframes-*.json`). Cada uno incluye thumbnail de preview, el cuerpo del prompt literal, el modelo objetivo, aspect ratio y un bloque `source` para licencia + atribución. El daemon los sirve en `GET /api/prompt-templates`; la web app los muestra como card grid en las pestañas **Plantillas de imagen** y **Plantillas de vídeo** del entry view; un clic suelta el prompt en el composer con el modelo correcto preseleccionado.
### gpt-image-2 — galería de imagen (muestra de 43)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="Evolución de escalera de piedra 3D" /><br/><sub><b>Infografía de evolución de escalera de piedra 3D</b><br/>Infografía de 3 pasos, estética de piedra tallada</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Mapa gastronómico urbano ilustrado" /><br/><sub><b>Mapa gastronómico urbano ilustrado</b><br/>Póster de viaje editorial ilustrado a mano</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Escena cinematográfica de ascensor" /><br/><sub><b>Escena cinematográfica de ascensor</b><br/>Still editorial de moda de un frame</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Retrato cyberpunk anime" /><br/><sub><b>Retrato cyberpunk anime</b><br/>Avatar de perfil — texto neón sobre rostro</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Mujer glamurosa de negro" /><br/><sub><b>Retrato de mujer glamurosa de negro</b><br/>Retrato editorial de estudio</sub></td>
</tr>
</table>
Set completo → [`prompt-templates/image/`](prompt-templates/image/). Fuentes: la mayoría provienen de [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts) (CC-BY-4.0), con atribución de autor preservada por template.
### Seedance 2.0 — galería de video (muestra de 39)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Podcast musical y guitarra" /></a><br/><sub><b>Podcast musical y técnica de guitarra</b><br/>Film de estudio cinematográfico 4K</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Rostro emocional" /></a><br/><sub><b>Close-up de rostro emocional</b><br/>Estudio cinematográfico de microexpresión</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Supercar de lujo" /></a><br/><sub><b>Cinemática de supercar de lujo</b><br/>Film narrativo de producto</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Gato de la Ciudad Prohibida" /></a><br/><sub><b>Sátira del gato de la Ciudad Prohibida</b><br/>Short de sátira estilizada</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Romance japonés" /></a><br/><sub><b>Corto de romance japonés</b><br/>Narrativa Seedance 2.0 de 15s</sub></td>
</tr>
</table>
Haz clic en cualquier thumbnail para reproducir el MP4 renderizado. Set completo → [`prompt-templates/video/`](prompt-templates/video/) (las entradas `*-seedance-*` y etiquetadas Cinematic). Fuentes: [`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts) (CC-BY-4.0), con links a tweets originales y handles de autor preservados.
### HyperFrames — motion graphics HTML→MP4 (11 templates listos)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) es el framework open source agent-native de HeyGen para video: tú (o el agente) escribes HTML + CSS + GSAP, y HyperFrames lo renderiza a un MP4 determinista mediante headless Chrome + FFmpeg. Open Design incluye HyperFrames como modelo de video first-class (`hyperframes-html`) conectado al dispatch del daemon, además de la skill `skills/hyperframes/`, que enseña al agente el contrato de timeline, reglas de transición de escena, patrones audio-reactive, captions/TTS y bloques de catálogo (`npx hyperframes add <slug>`).
Once prompts hyperframes vienen bajo [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/), cada uno como brief concreto que produce un arquetipo específico:
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Reveal de producto" /></a><br/><sub><b>Reveal minimal de producto de 5s</b> · 16:9 · title card push-in con transición shader</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="Promo SaaS" /></a><br/><sub><b>Promo de producto SaaS de 30s</b> · 16:9 · estilo Linear/ClickUp con reveals UI 3D</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="Karaoke TikTok" /></a><br/><sub><b>Talking-head karaoke para TikTok</b> · 9:16 · TTS + captions sincronizadas por palabra</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Sizzle de marca" /></a><br/><sub><b>Sizzle reel de marca de 30s</b> · 16:9 · tipografía cinética sincronizada al beat, audio-reactive</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Chart de datos" /></a><br/><sub><b>Bar-chart race animado</b> · 16:9 · infografía de datos estilo NYT</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Mapa de vuelo" /></a><br/><sub><b>Mapa de vuelo (origen → destino)</b> · 16:9 · reveal cinematográfico de ruta estilo Apple</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Outro de logo" /></a><br/><sub><b>Outro cinematográfico de logo de 4s</b> · 16:9 · ensamblaje pieza por pieza + bloom</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Contador de dinero" /></a><br/><sub><b>Contador de dinero $0 → $10K</b> · 9:16 · hype estilo Apple con flash verde + burst</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="Showcase de app" /></a><br/><sub><b>Showcase de app con 3 teléfonos</b> · 16:9 · teléfonos flotantes con callouts de features</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Overlay social" /></a><br/><sub><b>Stack de overlays sociales</b> · 9:16 · X · Reddit · Spotify · Instagram en secuencia</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website a video" /></a><br/><sub><b>Pipeline website-to-video</b> · 16:9 · captura el sitio en 3 viewports + transiciones</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
El patrón es el mismo que en el resto: elige un template, edita el brief y envía. El agente lee `skills/hyperframes/SKILL.md` (con el workflow de render específico de OD: archivos fuente de composición a `.hyperframes-cache/` para no ensuciar el file workspace, el daemon despacha `npx hyperframes render` para evitar el cuelgue macOS sandbox-exec / Puppeteer, y solo el `.mp4` final llega como chip del proyecto), crea la composición y entrega un MP4. Thumbnails de catálogo © HeyGen, servidos desde su CDN; el framework OSS es Apache-2.0.
> **También conectado pero aún no expuesto como templates:** Kling 2.0 / 1.6 / 1.5, Veo 3 / Veo 2, Sora 2 / Sora 2-Pro (via Fal), MiniMax video-01: todos viven en `VIDEO_MODELS` ([`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Suno v5 / v4.5, Udio v2, Lyria 2 (music) y gpt-4o-mini-tts, MiniMax TTS (speech) cubren la superficie de audio. Los templates para esto son contribuciones abiertas: suelta un JSON en `prompt-templates/video/` o `prompt-templates/audio/` y aparecerá en el selector.
## Más allá del chat: qué más incluye
El bucle chat / artifact se lleva el foco, pero ya hay varias capacidades menos visibles conectadas que vale la pena conocer antes de comparar OD con cualquier otra cosa:
- **Import de ZIP de Claude Design.** Suelta una exportación de claude.ai en el diálogo de bienvenida. `POST /api/import/claude-design` la extrae en un `.od/projects/<id>/` real, abre el entry file como tab y prepara un prompt para continuar donde Anthropic lo dejó. Sin re-prompting, sin "pedirle al modelo que recree lo que ya teníamos". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts): `/api/import/claude-design`)
- **Proxy BYOK multi-provider.** `POST /api/proxy/{anthropic,openai,azure,google}/stream` recibe `{ baseUrl, apiKey, model, messages }`, construye la request upstream específica por proveedor, normaliza chunks SSE a `delta/end/error` y rechaza destinos loopback / link-local / RFC1918 para evitar SSRF. OpenAI-compatible cubre OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter y vLLM self-hosted; Azure OpenAI agrega deployment URL + `api-version`; Google usa Gemini `:streamGenerateContent`.
- **Templates guardados por usuario.** Cuando te gusta un render, `POST /api/templates` guarda snapshot del HTML + metadata en la tabla SQLite `templates`. El siguiente proyecto lo elige desde una fila "your templates" en el selector: la misma superficie que las 31 shipped, pero tuya.
- **Persistencia de pestañas.** Cada proyecto recuerda archivos abiertos y la pestaña activa en la tabla `tabs`. Reabre mañana y el workspace luce exactamente como lo dejaste.
- **Artifact lint API.** `POST /api/artifacts/lint` ejecuta checks estructurales sobre un artefacto generado (framing `<artifact>` roto, side files requeridos faltantes, tokens de paleta stale) y devuelve findings que el agente puede leer en su siguiente turno. La autocrítica five-dim usa esto para anclar su score en evidencia real, no vibes.
- **Sidecar protocol + desktop automation.** Los procesos daemon, web y desktop llevan stamps tipados de cinco campos (`app · mode · namespace · ipc · source`) y exponen un canal JSON-RPC IPC en `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status \| eval \| screenshot` usa ese canal, así E2E headless corre contra un shell Electron real sin harness bespoke ([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)).
- **Spawning amigable con Windows.** Todo adapter que normalmente rompería el límite de argv de `CreateProcess` (~32 KB) con prompts compuestos largos (Codex, Gemini, OpenCode, Cursor Agent, Qwen, Qoder CLI, Pi) envía el prompt por stdin. Claude Code y Copilot mantienen `-p`; el daemon cae a un prompt-file temporal cuando incluso eso se desborda.
- **Datos runtime por namespace.** `OD_DATA_DIR` y `--namespace` te dan árboles `.od/` totalmente aislados, así Playwright, canales beta y tus proyectos reales nunca comparten SQLite.
## Maquinaria anti-AI-slop
Todo lo siguiente es el playbook de [`huashu-design`](https://github.com/alchaincyf/huashu-design), portado al prompt-stack de OD y hecho exigible por skill mediante el pre-flight de side files. Lee [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para ver el texto vivo:
- **Question form first.** El turno 1 es solo `<question-form>`: sin thinking, sin tools, sin narración. El usuario elige defaults a velocidad de radio buttons.
- **Extracción de brand spec.** Cuando el usuario adjunta screenshot o URL, el agente ejecuta un protocolo de cinco pasos (locate · download · grep hex · codify `brand-spec.md` · vocalise) antes de escribir CSS. **Nunca adivina colores de marca de memoria.**
- **Crítica five-dim.** Antes de emitir `<artifact>`, el agente puntúa silenciosamente su output de 1 a 5 en philosophy / hierarchy / execution / specificity / restraint. Cualquier cosa bajo 3/5 es una regresión: corrige y repuntúa. Dos pasadas es normal.
- **Checklist P0/P1/P2.** Cada skill trae `references/checklist.md` con gates P0 duros. El agente debe pasar P0 antes de emitir.
- **Blacklist de slop.** Gradientes morados agresivos, iconos emoji genéricos, cards redondeadas con acento de borde izquierdo, humanos SVG hand-drawn, Inter como *display* face, métricas inventadas: prohibido explícitamente en el prompt.
- **Placeholders honestos > stats falsos.** Cuando el agente no tiene un número real, escribe `—` o un bloque gris etiquetado, no "10× faster".
## Comparación
| Eje | [Claude Design][cd] (Anthropic) | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| Licencia | Cerrado | MIT | **Apache-2.0** |
| Formato | Web (claude.ai) | Desktop (Electron) | **App web + daemon local** |
| Desplegable en Vercel | ❌ | ❌ | **✅** |
| Runtime de agente | Incluido (Opus 4.7) | Incluido ([`pi-ai`][piai]) | **Delegado a la CLI existente del usuario** |
| Skills | Propietarias | 12 módulos TS custom + `SKILL.md` | **31 bundles [`SKILL.md`][skill] basados en archivos, droppable** |
| Design system | Propietario | `DESIGN.md` (roadmap v0.2) | **`DESIGN.md` × 129 sistemas incluidos** |
| Flexibilidad de proveedor | Solo Anthropic | 7+ via [`pi-ai`][piai] | **16 adapters CLI + proxy BYOK OpenAI-compatible** |
| Formulario inicial de preguntas | ❌ | ❌ | **✅ Regla dura, turno 1** |
| Selector de dirección | ❌ | ❌ | **✅ 5 direcciones deterministas** |
| Progreso todo en vivo + stream de tools | ❌ | ✅ | **✅** (patrón UX de open-codesign) |
| Preview en iframe sandboxed | ❌ | ✅ | **✅** (patrón de open-codesign) |
| Import de ZIP de Claude Design | n/a | ❌ | **`POST /api/import/claude-design`: seguir editando donde Anthropic lo dejó** |
| Ediciones quirúrgicas en comment-mode | ❌ | ✅ | 🟡 parcial: comentarios en elementos del preview + adjuntos de chat; patching dirigido confiable sigue en progreso |
| Panel de tweaks emitido por IA | ❌ | ✅ | 🚧 roadmap: el panel UX dedicado en el lado del chat aún no está implementado |
| Workspace de nivel filesystem | ❌ | parcial (Electron sandbox) | **✅ cwd real, tools reales, SQLite persistido (projects · conversations · messages · tabs · templates)** |
| Autocrítica five-dim | ❌ | ❌ | **✅ Gate pre-emit** |
| Artifact lint | ❌ | ❌ | **`POST /api/artifacts/lint`: findings devueltos al agente** |
| Sidecar IPC + desktop headless | ❌ | ❌ | **✅ Procesos stamped + `tools-dev inspect desktop status \| eval \| screenshot`** |
| Formatos de exportación | Limitado | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTX (agent-driven) / ZIP / Markdown** |
| Reuso de PPT skill | N/A | Incluido | **[`guizang-ppt-skill`][guizang] entra directo (default para deck mode)** |
| Facturación mínima | Pro / Max / Team | BYOK | **BYOK: pega cualquier `baseUrl` OpenAI-compatible** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## Coding agents soportados
Auto-detectados desde `PATH` al arrancar el daemon. Sin configuración requerida. El dispatch streaming vive en [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) (`AGENT_DEFS`); los parsers por CLI viven al lado. Los modelos se cargan probando `<bin> --list-models` / `<bin> models` / handshake ACP, o desde una lista fallback curada cuando la CLI no expone una lista.
| Agente | Bin | Formato de stream | Forma de argv (ruta de prompt compuesto) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json` (typed events) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + parser `codex` | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--model …] [-c model_reasoning_effort=…]` (prompt por stdin) |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + parser `gemini` | `GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo [--model …]` (prompt por stdin) |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + parser `opencode` | `opencode run --format json --dangerously-skip-permissions [--model …] -` (prompt por stdin) |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + parser `cursor-agent` | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -` (prompt por stdin) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain` (chunks raw de stdout) | `qwen --yolo [--model …] -` (prompt por stdin) |
| Qoder CLI | `qodercli` | `qoder-stream-json` (typed events) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]` (prompt por stdin) |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json` (typed events) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc` (Agent Client Protocol) | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain` (chunks raw de stdout) | `deepseek exec --auto [--model …] <prompt>` (prompt como argumento posicional) |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc` (stdio JSON-RPC) | `pi --mode rpc --no-session [--model …] [--thinking …]` (prompt enviado como comando RPC `prompt`) |
| **BYOK multi-provider** | n/a | Normalización SSE | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI-compatible / Azure OpenAI / Gemini; protegido contra SSRF hacia loopback / link-local / RFC1918 |
Añadir una CLI nueva es una entrada en [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). El formato de streaming es uno de `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream` (con `eventParser` por CLI), `acp-json-rpc`, `pi-rpc` o `plain`.
## Referencias y linaje
Cada proyecto externo del que este repo toma ideas. Cada link va a la fuente para verificar la procedencia.
| Proyecto | Rol aquí |
|---|---|
| [`Claude Design`][cd] | El producto closed-source del que este repo es alternativa open source. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Núcleo de filosofía de diseño. Junior-Designer workflow, protocolo de brand assets en 5 pasos, checklist anti-AI-slop, autocrítica de 5 dimensiones y la biblioteca "5 schools × 20 design philosophies" detrás del direction picker, destilado en [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) y [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Skill Magazine-web-PPT incluida literalmente bajo [`skills/guizang-ppt/`](skills/guizang-ppt/) con LICENSE original preservada. Default para deck mode. La cultura de checklist P0/P1/P2 se toma para cada otra skill. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Arquitectura daemon + adapter. Detección por PATH, daemon local como único proceso privilegiado, visión agent-as-teammate. Adoptamos el modelo; no vendorizamos el código. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | La primera alternativa open source a Claude Design y nuestro par más cercano. Patrones UX adoptados: streaming-artifact loop, sandboxed-iframe preview (React 18 + Babel vendorizados), panel de agente en vivo (todos + tool calls + interruptible), lista de export de cinco formatos (HTML/PDF/PPTX/ZIP/Markdown), hub local-first, taste-injection `SKILL.md` y primer pase de anotaciones comment-mode en preview. Patrones todavía en roadmap: confiabilidad completa de surgical-edit y AI-emitted tweaks panel. **Deliberadamente no vendorizamos [`pi-ai`][piai]**: open-codesign lo incluye como agent runtime; nosotros delegamos en la CLI que ya tenga el usuario. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Fuente del schema `DESIGN.md` de 9 secciones y de 70 sistemas de producto importados via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). |
| [`bergside/awesome-design-skills`][ads] | Fuente de 57 design skills añadidas directamente como archivos `DESIGN.md` normalizados bajo `design-systems/`. |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | Inspiración para distribución de skills con symlinks entre varias agent CLIs. |
| [Claude Code skills][skill] | La convención `SKILL.md` adoptada literalmente: cualquier skill de Claude Code se suelta en `skills/` y el daemon la detecta. |
El write-up largo de procedencia, qué tomamos de cada uno y qué no, vive en [`docs/references.md`](docs/references.md).
## Roadmap
- [x] Daemon + detección de agentes (16 adapters CLI) + skill registry + catálogo de design systems
- [x] Web app + chat + question form + picker de 5 direcciones + progreso todo + sandboxed preview
- [x] 31 skills + 72 design systems + 5 direcciones visuales + 5 frames de dispositivo
- [x] SQLite-backed projects · conversations · messages · tabs · templates
- [x] Proxy BYOK multi-provider (`/api/proxy/{anthropic,openai,azure,google}/stream`) con guard SSRF
- [x] Import de ZIP Claude Design (`/api/import/claude-design`)
- [x] Sidecar protocol + Electron desktop con IPC automation (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN)
- [x] Artifact lint API + gate pre-emit de autocrítica five-dim
- [ ] Comment-mode surgical edits: parcial enviado: comentarios de elementos preview y adjuntos de chat; patching dirigido confiable sigue en progreso
- [ ] UX de AI-emitted tweaks panel: aún no implementado
- [ ] Receta de despliegue Vercel + tunnel (Topology B)
- [ ] `npx od init` de un comando para scaffold de proyecto con `DESIGN.md`
- [ ] Skill marketplace (`od skills install <github-repo>`) y superficie CLI `od skill add | list | remove | test` (borrador en [`docs/skills-protocol.md`](docs/skills-protocol.md), implementación pendiente)
- [ ] Build Electron empaquetado desde `apps/packaged/`
Entrega por fases → [`docs/roadmap.md`](docs/roadmap.md).
## Estado
Esta es una implementación temprana: el bucle cerrado (detect → pick skill + design system → chat → parse `<artifact>` → preview → save) corre end-to-end. El prompt stack y la biblioteca de skills son donde vive la mayor parte del valor, y están estables. La UI a nivel componente se publica a diario.
## Danos una estrella
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="Star Open Design on GitHub — github.com/nexu-io/open-design" width="100%" /></a>
</p>
Si esto te ahorró treinta minutos, dale una ★. Las estrellas no pagan la renta, pero le dicen al próximo diseñador, agente y contributor que este experimento merece atención. Un clic, tres segundos, señal real: [github.com/nexu-io/open-design](https://github.com/nexu-io/open-design).
## Contribuir
Issues, PRs, nuevas skills y nuevos design systems son bienvenidos. Las contribuciones de mayor impacto suelen ser una carpeta, un archivo Markdown o un adapter del tamaño de un PR:
- **Añadir una skill**: suelta una carpeta en [`skills/`](skills/) siguiendo la convención [`SKILL.md`][skill].
- **Añadir un design system**: suelta un `DESIGN.md` en [`design-systems/<brand>/`](design-systems/) usando el schema de 9 secciones.
- **Conectar una nueva coding-agent CLI**: una entrada en [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts).
Walkthrough completo, estándar de merge, code style y lo que no aceptamos → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([Deutsch](CONTRIBUTING.de.md), [Français](CONTRIBUTING.fr.md), [简体中文](CONTRIBUTING.zh-CN.md)).
## Contribuidores
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-06" 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.
## Actividad del repositorio
<picture>
<img alt="Open Design — repository metrics" src="docs/assets/github-metrics.svg" />
</picture>
El SVG anterior se regenera diariamente mediante [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) usando [`lowlighter/metrics`](https://github.com/lowlighter/metrics). Ejecuta un refresh manual desde la pestaña **Actions** si lo quieres antes; para plugins más ricos (traffic, follow-up time), añade un secret `METRICS_TOKEN` con un PAT fine-grained.
## Historial de estrellas
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<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-06" />
</picture>
</a>
Si la curva sube, esa es la señal que buscamos. Dale ★ a este repo para impulsarlo.
## Créditos
La familia de skills HTML PPT Studio: la skill maestra [`skills/html-ppt/`](skills/html-ppt/) y los wrappers por template bajo [`skills/html-ppt-*/`](skills/) (15 templates full-deck, 36 themes, 31 layouts single-page, 27 animaciones CSS + 20 canvas FX, el runtime de teclado y el presenter mode de magnetic-card), está integrada desde el proyecto open source [`lewislulu/html-ppt-skill`](https://github.com/lewislulu/html-ppt-skill) (MIT). La LICENSE upstream viene en el repo en [`skills/html-ppt/LICENSE`](skills/html-ppt/LICENSE) y el crédito de autoría va a [@lewislulu](https://github.com/lewislulu). Cada card Examples por template (`html-ppt-pitch-deck`, `html-ppt-tech-sharing`, `html-ppt-presenter-mode`, `html-ppt-xhs-post`, …) delega la guía de autoría a la skill maestra para preservar end-to-end el comportamiento prompt → output upstream cuando haces clic en **Usar este prompt**.
El flujo magazine / horizontal-swipe deck bajo [`skills/guizang-ppt/`](skills/guizang-ppt/) está integrado desde [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) (MIT). El crédito de autoría va a [@op7418](https://github.com/op7418).
## Licencia
Apache-2.0. El bundle `skills/guizang-ppt/` conserva su [LICENSE](skills/guizang-ppt/LICENSE) original (MIT) y la atribución de autoría a [op7418](https://github.com/op7418). El bundle `skills/html-ppt/` conserva su [LICENSE](skills/html-ppt/LICENSE) original (MIT) y la atribución de autoría a [lewislulu](https://github.com/lewislulu).

760
README.fr.md Normal file
View File

@@ -0,0 +1,760 @@
# Open Design
> **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>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="Télécharger" src="https://img.shields.io/badge/t%C3%A9l%C3%A9charger-open--design.ai-ff6b35?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#coding-agents-pris-en-charge"><img alt="Agents" src="https://img.shields.io/badge/agents-CLI%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-catalogue-orange?style=flat-square" /></a>
<a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-catalogue-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-rejoindre-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.fr.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <b>Français</b> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a></p>
---
## Pourquoi ce projet existe
[Claude Design][cd] dAnthropic, lancé le 17 avril 2026 avec Opus 4.7, a montré ce qui se passe lorsquun LLM cesse de produire seulement du texte et commence à livrer des design artifacts. Le produit est devenu viral, tout en restant closed-source, paid-only, cloud-only et lié au modèle comme aux Skills dAnthropic. Aucun checkout possible, aucun self-hosting, aucun déploiement Vercel, aucun remplacement par votre propre agent.
**Open Design (OD) est lalternative open source.** Même boucle, même mental model artifact-first, sans lock-in. Nous ne livrons pas dagent : les meilleurs coding agents vivent déjà sur votre machine. OD les branche sur un workflow de design piloté par des Skills, exécutable localement avec `pnpm tools-dev`, déployable sur Vercel côté web, avec BYOK à chaque couche.
Tapez `make me a magazine-style pitch deck for our seed round`. Le question form interactif apparaît avant que le modèle nimprovise le moindre pixel. Lagent choisit lune des cinq directions visuelles soigneusement sélectionnées. Un plan `TodoWrite` live arrive dans lUI. Le daemon crée un vrai dossier projet sur disque avec un seed template, une layout library et une checklist de self-check. Lagent les lit, le pre-flight est obligatoire, puis il lance une critique en cinq dimensions sur sa propre sortie et émet un seul `<artifact>`, rendu quelques secondes plus tard dans une iframe sandboxée.
Le résultat dépasse lidée dune IA qui tente simplement de faire du design. Le prompt stack pousse lIA à se comporter comme un senior designer avec un vrai filesystem, une bibliothèque de palettes déterministe et une culture de checklist, au niveau fixé par Claude Design, en version ouverte et sous votre contrôle.
OD sappuie sur quatre projets open source :
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design), la boussole de design philosophy. Le workflow Junior-Designer, le protocole en 5 étapes pour les assets de marque, la checklist anti-AI-slop, la self-critique en 5 dimensions et lidée « 5 écoles × 20 philosophies design » derrière notre direction picker sont condensés dans [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill), le mode deck. Inclus tel quel sous [`skills/guizang-ppt/`](skills/guizang-ppt/), avec licence originale préservée ; layouts magazine, hero WebGL, checklists P0/P1/P2.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign), notre UX north star et le projet le plus proche. Nous reprenons sa streaming-artifact loop, son pattern de preview en iframe sandboxée (React 18 + Babel vendored), son live agent panel (todos + tool calls + génération interruptible) et ses cinq formats dexport (HTML / PDF / PPTX / ZIP / Markdown). Nous divergeons volontairement sur le format : ils livrent une app desktop Electron avec [`pi-ai`][piai] intégré ; nous sommes une web app + daemon local qui délègue à la CLI déjà installée chez vous.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica), larchitecture daemon et runtime. Détection des agents dans le `PATH`, daemon local comme seul processus privilégié, vision agent-as-teammate.
## En un coup dœil
| | Ce que vous obtenez |
|---|---|
| **CLI de coding agents (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI, détectées automatiquement dans `PATH`, interchangeables en un clic |
| **BYOK fallback** | Proxy API par protocole sur `/api/proxy/{anthropic,openai,azure,google}/stream` : collez `baseUrl` + `apiKey` + `model`, choisissez Anthropic / OpenAI / Azure OpenAI / Google Gemini, et le daemon normalise le SSE vers le même chat stream. Les destinations internal IP / SSRF sont bloquées côté daemon. |
| **Design Systems intégrés** | Le menu déroulant charge les Design Systems depuis `design-systems/*/DESIGN.md` : starters écrits à la main, product systems importés depuis [`awesome-design-md`][acd2] et design skills normalisés depuis [`awesome-design-skills`][ads]. |
| **Skills intégrés** | Le picker charge les Skills depuis `skills/*/SKILL.md` et les regroupe par `mode` / `scenario` : prototype, deck, image, video, audio, Design System, utility, puis notamment design / marketing / operations / engineering / product / finance / hr / sales / personal. |
| **Génération média** | Les surfaces image, vidéo et audio sont livrées avec la design loop. **gpt-image-2** (Azure / OpenAI) pour posters, avatars, infographies et cartes illustrées ; **Seedance 2.0** (ByteDance) pour du text-to-video et image-to-video cinématique de 15 s ; **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) pour des motion graphics HTML→MP4. La galerie [`prompt-templates/`](prompt-templates/) fournit des prompts prêts à reproduire, avec thumbnails et attribution. Même surface de chat que le code ; les sorties deviennent de vrais fichiers `.mp4` / `.png` dans le workspace du projet. |
| **Directions visuelles** | 5 écoles soigneusement sélectionnées (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental), chacune avec palette OKLch déterministe + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Frames dappareils** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome, pixel-accurate et partagés entre Skills sous [`assets/frames/`](assets/frames/) |
| **Agent runtime** | Le daemon local lance la CLI dans le dossier projet. Lagent reçoit de vrais `Read`, `Write`, `Bash`, `WebFetch` sur un environnement disque réel, avec fallback Windows `ENAMETOOLONG` (stdin / prompt-file) sur chaque adapter |
| **Imports** | Déposez un ZIP exporté depuis [Claude Design][cd] dans le welcome dialog : `POST /api/import/claude-design` le convertit en vrai projet pour que votre agent continue là où Anthropic sest arrêté |
| **Persistance** | SQLite dans `.od/app.sqlite` : projects · conversations · messages · tabs · saved templates. Rouvrez demain, la todo card et les fichiers ouverts sont au même endroit. |
| **Lifecycle** | Un seul point dentrée : `pnpm tools-dev` (start / stop / run / status / logs / inspect / check), qui démarre daemon + web (+ desktop) avec des typed sidecar stamps |
| **Desktop** | Shell Electron optionnel avec renderer sandboxé + sidecar IPC (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN), utilisé par `tools-dev inspect desktop screenshot` pour lE2E |
| **Déployable sur** | Local (`pnpm tools-dev`) · couche web Vercel · application desktop Electron empaquetée pour macOS (Apple Silicon) et Windows (x64) — téléchargement sur [open-design.ai](https://open-design.ai/) ou la [dernière release](https://github.com/nexu-io/open-design/releases) |
| **Licence** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ads]: https://github.com/bergside/awesome-design-skills
## Démo
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · Vue dentrée" /><br/>
<sub><b>Vue dentrée</b> : choisissez un Skill, un Design System, puis saisissez le brief. La même surface sert aux prototypes, decks, apps mobiles, dashboards et pages éditoriales.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · Question form de découverte du premier tour" /><br/>
<sub><b>Question form de découverte</b> : avant que le modèle nécrive un pixel, OD verrouille le brief : surface, audience, ton, contexte de marque, échelle. 30 secondes de boutons radio valent mieux que 30 minutes dallers-retours.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · Sélecteur de direction" /><br/>
<sub><b>Direction picker</b> : quand lutilisateur na pas de marque, lagent émet un second formulaire avec 5 directions soigneusement sélectionnées. Un clic radio → palette + font stack déterministes, sans freestyle du modèle.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · Progression todo live" /><br/>
<sub><b>Progression todo live</b> : le plan de lagent arrive comme carte live. Les états <code>in_progress</code> → <code>completed</code> se mettent à jour en temps réel. Lutilisateur peut corriger le tir à faible coût pendant le travail.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · Preview sandboxée" /><br/>
<sub><b>Preview sandboxée</b> : chaque <code>&lt;artifact&gt;</code> est rendu dans une iframe srcdoc propre. Modifiable sur place via le file workspace ; téléchargeable en HTML, PDF, ZIP.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · Bibliothèque de Design Systems" /><br/>
<sub><b>Bibliothèque de Design Systems</b> : chaque product system montre sa signature en 4 couleurs. Cliquez pour le <code>DESIGN.md</code> complet, la grille de swatches et le showcase live.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · Deck magazine" /><br/>
<sub><b>Mode deck (guizang-ppt)</b> : le <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> inclus fonctionne tel quel. Layouts magazine, arrière-plans hero WebGL, sortie HTML single-file, export PDF.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · Prototype mobile" /><br/>
<sub><b>Prototype mobile</b> : chrome iPhone 15 Pro pixel-accurate (Dynamic Island, SVGs de status bar, home indicator). Les prototypes multi-écrans utilisent les assets partagés <code>/frames/</code>.</sub>
</td>
</tr>
</table>
## Skills
Les Skills livrés avec le repo sont des dossiers sous [`skills/`](skills/) suivant la convention [`SKILL.md`][skill] de Claude Code, avec un frontmatter `od:` étendu que le daemon lit tel quel : `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)).
Le champ **`mode`** structure le catalogue (`prototype`, `deck`, `image`, `video`, `audio`, `design-system`, `utility`, etc.). Le champ **`scenario`** sert au regroupement dans le picker, avec des labels comme `design` · `marketing` · `operations` · `engineering` · `product` · `finance` · `hr` · `sales` · `personal`, et dautres selon les Skills.
### Exemples showcase
Les Skills visuellement distinctifs que vous lancerez probablement en premier. Chacun livre un vrai `example.html` que vous pouvez ouvrir depuis le repo pour voir ce que lagent produira, sans auth ni setup.
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>Dashboard consumer dating / matchmaking : navigation gauche, ticker bar, KPIs, graphique de mutual matches sur 30 jours, typographie éditoriale.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>E-guide numérique en deux spreads : couverture (titre, auteur, teaser de sommaire) + page de leçon avec pull-quote et étapes. Ton creator / lifestyle.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>Email HTML de lancement produit : masthead, image hero, bloc titre, CTA, grille de specs. Colonne unique centrée, compatible fallback table.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>Prototype mobile gamifié en trois frames sur scène sombre : cover, quêtes du jour avec rubans XP + barre de niveau, détail de quête.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>Onboarding mobile en trois frames : splash, value prop, sign-in. Status bar, dots de swipe, CTA principal.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>Hero motion-design single-frame avec animations CSS en boucle : anneau typo rotatif, globe animé, timer. Prêt pour handoff HyperFrames.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>Carousel social 1080×1080 en trois cartes : panneaux cinématiques avec titres display liés entre eux, marque, affordance de boucle.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>Slide explicative pixel / 8-bit animée : scène crème plein cadre, mascotte pixel animée, typographie display japonaise cinétique, keyframes CSS en boucle.</sub>
</td>
</tr>
</table>
### Surfaces design & marketing (mode prototype)
| Skill | Plateforme | Scénario | Produit |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | desktop | design | HTML single-page : landings, marketing, hero pages (défaut pour prototype) |
| [`saas-landing`](skills/saas-landing/) | desktop | marketing | Layout marketing hero / features / pricing / CTA |
| [`dashboard`](skills/dashboard/) | desktop | operations | Admin / analytics avec sidebar + data dense |
| [`pricing-page`](skills/pricing-page/) | desktop | sales | Page pricing autonome + tableaux de comparaison |
| [`docs-page`](skills/docs-page/) | desktop | engineering | Documentation en 3 colonnes |
| [`blog-post`](skills/blog-post/) | desktop | marketing | Long-form éditorial |
| [`mobile-app`](skills/mobile-app/) | mobile | design | Écran(s) app dans frame iPhone 15 Pro / Pixel |
| [`mobile-onboarding`](skills/mobile-onboarding/) | mobile | design | Flow onboarding mobile multi-écrans (splash · value-prop · sign-in) |
| [`gamified-app`](skills/gamified-app/) | mobile | personal | Prototype mobile gamifié en trois frames |
| [`email-marketing`](skills/email-marketing/) | desktop | marketing | Email HTML de lancement produit (table-fallback safe) |
| [`social-carousel`](skills/social-carousel/) | desktop | marketing | Carousel social 1080×1080 en 3 cartes |
| [`magazine-poster`](skills/magazine-poster/) | desktop | marketing | Poster single-page style magazine |
| [`motion-frames`](skills/motion-frames/) | desktop | marketing | Hero motion-design avec animations CSS en boucle |
| [`sprite-animation`](skills/sprite-animation/) | desktop | marketing | Slide explicative pixel / 8-bit animée |
| [`dating-web`](skills/dating-web/) | desktop | personal | Mockup dashboard dating consumer |
| [`digital-eguide`](skills/digital-eguide/) | desktop | marketing | E-guide en deux spreads (couverture + leçon) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | desktop | design | Sketch didéation dessiné à la main pour montrer quelque chose tôt |
| [`critique`](skills/critique/) | desktop | design | Scorecard de self-critique en cinq dimensions (Philosophie · Hiérarchie · Détail · Fonction · Innovation) |
| [`tweaks`](skills/tweaks/) | desktop | design | Panneau dajustements émis par lIA, où le modèle expose les paramètres à retoucher |
### Surfaces deck (mode deck)
| Skill | Défaut pour | Produit |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **défaut** pour deck | PPT web style magazine, inclus tel quel depuis [op7418/guizang-ppt-skill][guizang] |
| [`simple-deck`](skills/simple-deck/) | n/a | Deck HTML minimal à swipe horizontal |
| [`replit-deck`](skills/replit-deck/) | n/a | Deck walkthrough produit (style Replit) |
| [`weekly-update`](skills/weekly-update/) | n/a | Cadence weekly déquipe en deck swipe (progress · blockers · next) |
### Surfaces office & opérations
| Skill | Scénario | Produit |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | Spec PM avec table des matières + decision log |
| [`team-okrs`](skills/team-okrs/) | product | Scorecard OKR |
| [`meeting-notes`](skills/meeting-notes/) | operations | Notes de réunion et decision log |
| [`kanban-board`](skills/kanban-board/) | operations | Snapshot de board |
| [`eng-runbook`](skills/eng-runbook/) | engineering | Runbook dincident |
| [`finance-report`](skills/finance-report/) | finance | Résumé finance exécutif |
| [`invoice`](skills/invoice/) | finance | Facture single-page |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | Plan donboarding par rôle |
Ajouter un Skill revient à ajouter un dossier. Lisez [`docs/skills-protocol.md`](docs/skills-protocol.md) pour le frontmatter `od:` étendu, forkez un Skill existant, redémarrez le daemon, il apparaît dans le picker. Lendpoint catalogue est `GET /api/skills`; lassemblage seed par Skill est exposé par `GET /api/skills/:id/example`.
## Six idées structurantes
### 1 · Nous ne livrons pas dagent. Le vôtre suffit.
Au démarrage, le daemon scanne votre `PATH` avec les définitions de [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) : Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro CLI, Mistral Vibe CLI et les adapters ajoutés plus tard. Ceux quil trouve deviennent des design engines candidats, pilotés via stdio avec un adapter par CLI et interchangeables depuis le model picker. Inspiré par [`multica`](https://github.com/multica-ai/multica) et [`cc-switch`](https://github.com/farion1231/cc-switch). Aucune CLI installée ? Le mode API suit la même pipeline, sans spawn local : choisissez Anthropic, OpenAI-compatible, Azure OpenAI ou Google Gemini, et le daemon renvoie les chunks SSE normalisés, avec rejet des destinations loopback / link-local / RFC1918.
### 2 · Les Skills sont des fichiers, pas des plugins.
Selon la convention [`SKILL.md`](https://docs.anthropic.com/en/docs/claude-code/skills) de Claude Code, un Skill est au minimum un `SKILL.md` ; `assets/` et `references/` sont des side files optionnels. Déposez un dossier dans [`skills/`](skills/), redémarrez le daemon, il apparaît dans le picker. Le `magazine-web-ppt` inclus est [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) committé tel quel, avec licence originale et attribution préservées.
### 3 · Les Design Systems sont du Markdown portable, pas du JSON de thème.
Le schéma `DESIGN.md` en 9 sections vient de [`VoltAgent/awesome-design-md`][acd2] : color, typography, spacing, layout, components, motion, voice, brand, anti-patterns. Chaque artifact lit le Design System actif. Changez de Design System, le prochain rendu utilise les nouveaux tokens. Le menu déroulant charge les dossiers `design-systems/*/DESIGN.md` : **Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Apple, Anthropic, Cursor, Supabase, Figma, Resend, Raycast, Lovable, Cohere, Mistral, ElevenLabs, X.AI, Spotify, Webflow, Sanity, PostHog, Sentry, MongoDB, ClickHouse, Cal, Replicate, Clay, Composio, Xiaohongshu…**, ainsi que des design skills normalisés depuis [`awesome-design-skills`][ads].
### 4 · Le question form évite 80 % des allers-retours.
Le prompt stack dOD impose `RULE 1` : tout nouveau design brief commence par un `<question-form id="discovery">` au lieu de code. Surface · audience · tone · brand context · scale · contraintes. Même un long brief laisse des décisions design ouvertes, comme le ton visuel, la posture couleur ou léchelle ; le formulaire les verrouille en 30 secondes. Une mauvaise direction coûte un tour de chat, pas un deck terminé.
Cest le **mode Junior-Designer** tiré de [`huashu-design`](https://github.com/alchaincyf/huashu-design) : poser les questions dès le départ, montrer vite quelque chose de visible, même un wireframe en blocs gris, et permettre à lutilisateur de corriger le tir à faible coût. Combiné au protocole brand-asset (locate · download · `grep` hex · write `brand-spec.md` · vocalise), cest la raison principale pour laquelle la sortie cesse de ressembler à du freestyle IA et commence à ressembler à un designer qui a observé avant de peindre.
### 5 · Le daemon donne limpression que lagent est sur votre laptop, parce quil lest.
Le daemon lance la CLI avec `cwd` pointant vers le dossier artifact du projet sous `.od/projects/<id>/`. Lagent reçoit `Read`, `Write`, `Bash`, `WebFetch`, de vrais outils sur un vrai filesystem. Il peut lire le `assets/template.html` du skill, chercher les valeurs hex dans votre CSS, écrire `brand-spec.md`, déposer des images générées, produire des `.pptx` / `.zip` / `.pdf` qui apparaissent dans le workspace comme download chips à la fin du tour. Sessions, conversations, messages et tabs persistent dans une DB SQLite locale : rouvrez le projet demain, la todo card de lagent est encore là.
### 6 · Le prompt stack est le produit.
À lenvoi, OD compose plusieurs couches :
```text
DISCOVERY directives (formulaire tour 1, branche marque tour 2, TodoWrite, critique 5 dimensions)
+ identity charter (OFFICIAL_DESIGNER_PROMPT, anti-AI-slop, junior-pass)
+ active DESIGN.md (catalogue Design Systems)
+ active SKILL.md (catalogue Skills)
+ project metadata (kind, fidelity, speakerNotes, animations, inspiration ids)
+ skill side files (pre-flight auto-injecté : lire assets/template.html + references/*.md)
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
```
Chaque couche est composable. Chaque couche est un fichier éditable. Lisez [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) et [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) pour voir le contrat réel.
## Architecture
```text
┌────────────────────── browser (Next.js 16) ──────────────────────┐
│ chat · file workspace · iframe preview · settings · imports │
└──────────────┬───────────────────────────────────┬───────────────┘
│ /api/* (rewritten in dev) │
▼ ▼
┌──────────────────────────────────┐ /api/proxy/{provider}/stream (SSE)
│ Local daemon (Express + SQLite) │ ─→ provider-specific APIs
│ │ (BYOK)
│ /api/agents /api/skills│ w/ SSRF blocking
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/{provider}/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (static) /frames (static)
│ optional: sidecar IPC at /tmp/open-design/ipc/<ns>/<app>.sock
│ (STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN)
└─────────┬────────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · devin (ACP) · gemini · opencode · cursor-agent │
│ qwen · qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) · kiro · vibe (ACP) │
│ reads SKILL.md + DESIGN.md, writes artifacts to disk │
└──────────────────────────────────────────────────────────────────┘
```
| Couche | Stack |
|---|---|
| Frontend | Next.js 16 App Router + React 18 + TypeScript, déployable sur Vercel |
| Daemon | Node 24 · Express · streaming SSE · `better-sqlite3`; tables `projects` · `conversations` · `messages` · `tabs` · `templates` |
| Transport agent | `child_process.spawn`; parseurs typed-event pour `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream`, `acp-json-rpc`, `pi-rpc`, `plain` |
| Proxy BYOK | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → APIs provider-specific, SSE normalisé `delta/end/error` ; rejet loopback / link-local / RFC1918 au bord du daemon |
| Stockage | Fichiers simples dans `.od/projects/<id>/` + SQLite dans `.od/app.sqlite` (gitignored, auto-créé). `OD_DATA_DIR` permet lisolation des tests |
| Aperçu | Iframe sandboxée via `srcdoc` + parser `<artifact>` par Skill ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
| Export | HTML (assets inline) · PDF (browser print, deck-aware) · PPTX (piloté par agent via Skill) · ZIP (archiver) · Markdown |
| Lifecycle | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`; ports via `--daemon-port` / `--web-port`, namespaces via `--namespace` |
| Desktop (optionnel) | Shell Electron, découvre lURL web par sidecar IPC, sans deviner le port ; le même canal `STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN` alimente `tools-dev inspect desktop …` pour lE2E |
## Quickstart
### Télécharger l'application desktop (aucun build requis)
Le moyen le plus rapide d'essayer Open Design est l'application desktop préconstruite — pas de Node, pas de pnpm, pas de clone :
- **[open-design.ai](https://open-design.ai/)** — page de téléchargement officielle
- **[Releases GitHub](https://github.com/nexu-io/open-design/releases)**
### Exécuter depuis les sources
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # should print 10.33.2
pnpm install
pnpm tools-dev run web
# open the web URL printed by tools-dev
```
Lanceur Windows : compilez `OpenDesign.exe` avec les instructions de `tools/launcher/README.md`, ou téléchargez-le depuis GitHub Releases. Placez-le ensuite à la racine du dépôt et double-cliquez dessus pour lancer `pnpm install` si nécessaire, puis démarrer Open Design avec `pnpm tools-dev`.
Prérequis : Node `~24` et pnpm `10.33.x`. `nvm` / `fnm` ne sont que des aides facultatives ; si vous en utilisez un, lancez `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` avant `pnpm install`.
Pour le démarrage desktop/background, les redémarrages sur ports fixes et les checks du dispatcher de génération média (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), voir [`QUICKSTART.fr.md`](QUICKSTART.fr.md).
Au premier chargement :
1. OD détecte les CLI dagents présentes dans votre `PATH` et en choisit une automatiquement.
2. Il charge les catalogues Skills + Design Systems depuis les dossiers du repo.
3. Il affiche le welcome dialog pour configurer une clé API, nécessaire seulement pour le fallback BYOK.
4. Il **crée automatiquement `./.od/`**, le dossier runtime local pour la DB SQLite, les artifacts par projet et les rendus enregistrés. Pas détape `od init` ; le daemon crée ce dont il a besoin au boot.
Tapez un prompt, cliquez **Send**, regardez le formulaire arriver, remplissez-le, puis suivez la todo card et le rendu de lartifact. Cliquez **Save to disk** ou téléchargez le projet en ZIP.
### État premier lancement (`./.od/`)
Le daemon possède un dossier caché à la racine du repo. Tout son contenu est gitignored et local à votre machine, ne le committez jamais.
```text
.od/
├── app.sqlite ← projects · conversations · messages · open tabs
├── media-config.json ← credentials média / BYOK
├── artifacts/ ← rendus ponctuels "Save to disk" (horodatés)
└── projects/<id>/ ← dossier de travail par projet, aussi cwd de lagent
```
| Besoin | Action |
|---|---|
| Inspecter ce quil contient | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| Repartir de zéro | `pnpm tools-dev stop`, `rm -rf .od`, relancer `pnpm tools-dev run web` |
| Déplacer toutes les données daemon | lancer avec `OD_DATA_DIR=<dir>` ; utilisez `OD_MEDIA_CONFIG_DIR=<dir>` si vous voulez seulement déplacer `media-config.json` |
Carte complète des fichiers, scripts et dépannage → [`QUICKSTART.fr.md`](QUICKSTART.fr.md).
## Structure du dépôt
```text
open-design/
├── README.md ← English
├── README.de.md ← Deutsch
├── README.zh-CN.md ← 简体中文
├── README.zh-TW.md ← 繁體中文
├── README.ko.md ← 한국어
├── README.ja-JP.md ← 日本語
├── README.fr.md ← ce fichier
├── QUICKSTART.fr.md ← guide run / build / deploy
├── package.json ← workspace pnpm, bin unique : od
├── apps/
│ ├── daemon/ ← Node + Express, seul serveur
│ │ ├── src/ ← source TypeScript du daemon
│ │ │ ├── cli.ts ← source du bin `od`, compilé vers dist/cli.js
│ │ │ ├── server.ts ← routes /api/* (projects, chat, files, exports)
│ │ │ ├── agents.ts ← PATH scanner + argv builders par CLI
│ │ │ ├── claude-stream.ts ← parser JSON streaming pour stdout Claude Code
│ │ │ ├── skills.ts ← loader du frontmatter SKILL.md
│ │ │ └── db.ts ← schéma SQLite (projects/messages/templates/tabs)
│ │ ├── sidecar/ ← wrapper tools-dev du daemon sidecar
│ │ └── tests/ ← tests du package daemon
│ │
│ └── web/ ← Next.js 16 App Router + client React
│ ├── app/ ← entrypoints App Router
│ ├── next.config.ts ← rewrites dev + export statique prod vers out/
│ └── src/ ← modules client React + TypeScript
│ ├── App.tsx ← routing, bootstrap, settings
│ ├── components/ ← chat, composer, picker, preview, sketch, …
│ ├── prompts/
│ │ ├── system.ts ← composeSystemPrompt(base, skill, DS, metadata)
│ │ ├── discovery.ts ← turn-1 form + turn-2 branch + critique 5 dimensions
│ │ └── directions.ts ← 5 visual directions × palette OKLch + font stack
│ ├── artifacts/ ← parser streaming <artifact> + manifests
│ ├── runtime/ ← iframe srcdoc, markdown, helpers dexport
│ ├── providers/ ← transports daemon SSE + BYOK API
│ └── state/ ← config + projects (localStorage + daemon-backed)
├── e2e/ ← Playwright UI + harness Vitest / intégration externe
├── packages/
│ ├── contracts/ ← contrats app partagés web/daemon
│ ├── sidecar-proto/ ← contrat du sidecar protocol Open Design
│ ├── sidecar/ ← primitives runtime sidecar génériques
│ └── platform/ ← primitives process/platform génériques
├── skills/ ← bundles SKILL.md chargés par le daemon
│ ├── web-prototype/ ← défaut pour le mode prototype
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← mode deck
│ └── guizang-ppt/ ← magazine-web-ppt intégré (défaut pour deck)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← catalogues DESIGN.md chargés par le daemon
│ ├── default/ ← Neutral Modern (starter)
│ ├── warm-editorial/ ← Warm Editorial (starter)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md ← aperçu du catalogue
├── assets/
│ └── frames/ ← device frames partagées entre Skills
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ ├── deck-framework.html ← base deck (nav / counter / print)
│ └── kami-deck.html ← starter deck façon kami (parchemin / serif ink-blue)
├── scripts/
│ └── sync-design-systems.ts ← réimporte le tarball upstream awesome-design-md
├── docs/
│ ├── spec.md ← product spec, scenarios, différenciation
│ ├── architecture.md ← topologies, data flow, composants
│ ├── skills-protocol.md ← frontmatter od: étendu pour SKILL.md
│ ├── agent-adapters.md ← détection + dispatch par CLI
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← provenance longue
│ ├── roadmap.md ← livraison par phases
│ ├── schemas/ ← JSON schemas
│ └── examples/ ← exemples dartifacts canoniques
└── .od/ ← runtime data, gitignored, auto-créé
├── app.sqlite ← projects / conversations / messages / tabs
├── projects/<id>/ ← dossier de travail par projet, aussi cwd de lagent
└── artifacts/ ← rendus ponctuels Save to disk
```
## Design Systems
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="Bibliothèque de Design Systems : style guide spread" width="100%" />
</p>
Les Design Systems livrés avec le repo sont chargés depuis [`design-systems/*/DESIGN.md`](design-systems/README.md) :
<details>
<summary><b>Exemples du catalogue</b> (cliquer pour ouvrir)</summary>
**AI & LLM** : `claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**Developer Tools** : `cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**Productivity** : `notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**Fintech** : `stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**E-Commerce** : `shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**Media** : `spotify` · `playstation` · `wired` · `theverge` · `meta`
**Automotive** : `tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**Other** : `apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**Starters** : `default` (Neutral Modern) · `warm-editorial`
</details>
La bibliothèque de product systems est importée depuis [`VoltAgent/awesome-design-md`][acd2] via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). Relancez ce script pour rafraîchir le catalogue. Les design skills issus de [`bergside/awesome-design-skills`][ads] sont ajoutés directement dans `design-systems/`.
## Directions visuelles
Quand lutilisateur na pas de brand spec, lagent émet un second formulaire avec cinq directions soigneusement sélectionnées, ladaptation OD du fallback « 5 schools × 20 design philosophies » de [`huashu-design`](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback). Chaque direction est une spec déterministe : palette OKLch, font stack, posture layout, références, que lagent injecte tel quel dans le `:root` du seed template. Un clic radio → système visuel entièrement spécifié. Pas dimprovisation, pas dAI-slop.
| Direction | Mood | Références |
|---|---|---|
| Editorial · Monocle / FT | Magazine imprimé, encre + crème + rouille chaude | Monocle · FT Weekend · NYT Magazine |
| Modern minimal · Linear / Vercel | Froid, structuré, accent minimal | Linear · Vercel · Stripe |
| Tech utility | Densité dinformation, monospace, terminal | Bloomberg · Bauhaus tools |
| Brutalist | Brut, typographie oversized, pas dombres, accents durs | Bloomberg Businessweek · Achtung |
| Soft warm | Généreux, faible contraste, neutres pêche | Notion marketing · Apple Health |
Spec complète → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
## Génération média
OD ne sarrête pas au code. La même surface de chat qui produit du HTML `<artifact>` pilote aussi la génération **image**, **vidéo** et **audio**, avec des adapters modèle reliés à la pipeline média du daemon ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Chaque rendu arrive comme vrai fichier dans le workspace projet, `.png` pour limage, `.mp4` pour la vidéo, et apparaît comme chip de téléchargement à la fin du tour.
Trois familles de modèles portent la charge aujourdhui :
| Surface | Modèle | Fournisseur | Usage |
|---|---|---|---|
| **Image** | `gpt-image-2` | Azure / OpenAI | Posters, avatars, cartes illustrées, infographies, social cards style magazine, restauration photo, art produit éclaté |
| **Vidéo** | `seedance-2.0` | ByteDance Volcengine | t2v + i2v cinématique de 15 s avec audio, shorts narratifs, close-ups personnage, films produit, chorégraphies MV |
| **Vidéo** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | Motion graphics HTML→MP4, product reveals, typographie cinétique, data charts, overlays sociaux, logo outros, verticaux TikTok avec captions karaoke |
Une **galerie de prompts** sous [`prompt-templates/`](prompt-templates/) livre des prompts prêts à reproduire pour les surfaces image et vidéo. Chaque entrée contient un thumbnail, le prompt body exact, le modèle cible, le ratio daspect et un bloc `source` pour licence + attribution. Le daemon les sert via `GET /api/prompt-templates`, et la web app les expose comme grille de cartes dans les onglets **Image templates** et **Video templates**.
### gpt-image-2 · galerie image (échantillon)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="3D Stone Staircase Evolution" /><br/><sub><b>Infographie évolution en escalier de pierre 3D</b><br/>Infographie 3 étapes, esthétique pierre taillée</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Illustrated City Food Map" /><br/><sub><b>Carte culinaire urbaine illustrée</b><br/>Poster de voyage éditorial dessiné à la main</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cinematic Elevator Scene" /><br/><sub><b>Scène dascenseur cinématique</b><br/>Still mode éditorial single-frame</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Cyberpunk Anime Portrait" /><br/><sub><b>Portrait anime cyberpunk</b><br/>Avatar profil, texte néon sur le visage</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Glamorous Woman in Black" /><br/><sub><b>Portrait glamour en noir</b><br/>Portrait studio éditorial</sub></td>
</tr>
</table>
Set complet → [`prompt-templates/image/`](prompt-templates/image/). Sources : la plupart viennent de [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts) (CC-BY-4.0), avec attribution auteur conservée par template.
### Seedance 2.0 · galerie vidéo (échantillon de 39)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Music Podcast Guitar" /></a><br/><sub><b>Podcast musique & technique guitare</b><br/>Film studio cinématique 4K</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Emotional Face" /></a><br/><sub><b>Close-up émotionnel</b><br/>Étude cinématique de micro-expression</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Luxury Supercar" /></a><br/><sub><b>Supercar de luxe cinématique</b><br/>Film produit narratif</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Forbidden City Cat" /></a><br/><sub><b>Satire à la Cité interdite</b><br/>Court stylisé satirique</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Japanese Romance" /></a><br/><sub><b>Court métrage romance japonaise</b><br/>Narration Seedance 2.0 de 15 s</sub></td>
</tr>
</table>
Cliquez sur un thumbnail pour lire le MP4 rendu. Set complet → [`prompt-templates/video/`](prompt-templates/video/). Sources : [`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts) (CC-BY-4.0), avec liens tweets originaux et handles auteurs conservés.
### HyperFrames · motion graphics HTML→MP4 (11 templates prêts à reproduire)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) est le framework vidéo open source agent-native de HeyGen : vous, ou lagent, écrivez HTML + CSS + GSAP, HyperFrames rend un MP4 déterministe via Chrome headless + FFmpeg. Open Design le livre comme modèle vidéo de première classe (`hyperframes-html`) relié au dispatch daemon, plus le skill `skills/hyperframes/` qui enseigne à lagent le contrat de timeline, les transitions de scènes, les patterns audio-réactifs, captions/TTS et les catalog blocks (`npx hyperframes add <slug>`).
Onze prompts hyperframes sont fournis sous [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/), chacun comme brief concret pour un archétype précis :
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Product reveal" /></a><br/><sub><b>Product reveal minimal 5 s</b> · 16:9 · title card push-in avec transition shader</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="SaaS promo" /></a><br/><sub><b>Promo produit SaaS 30 s</b> · 16:9 · style Linear/ClickUp avec reveals UI 3D</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaoke" /></a><br/><sub><b>Talking-head TikTok karaoke</b> · 9:16 · TTS + captions synchronisées mot à mot</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Brand sizzle" /></a><br/><sub><b>Brand sizzle reel 30 s</b> · 16:9 · typographie cinétique beat-sync, audio-réactive</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Data chart" /></a><br/><sub><b>Bar-chart race animé</b> · 16:9 · infographie data style NYT</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Flight map" /></a><br/><sub><b>Carte de vol (origine → destination)</b> · 16:9 · reveal de route cinématique style Apple</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>Logo outro cinématique 4 s</b> · 16:9 · assemblage progressif + bloom</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Money counter" /></a><br/><sub><b>Compteur $0 → $10K</b> · 9:16 · hype style Apple avec flash vert + burst</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>Showcase app 3 phones</b> · 16:9 · téléphones flottants avec callouts feature</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Social overlay" /></a><br/><sub><b>Stack doverlays sociaux</b> · 9:16 · X · Reddit · Spotify · Instagram en séquence</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website to video" /></a><br/><sub><b>Pipeline website-to-video</b> · 16:9 · capture le site en 3 viewports + transitions</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
Le pattern reste le même : choisissez un template, éditez le brief, envoyez. Lagent lit le `skills/hyperframes/SKILL.md` intégré, écrit la composition et livre un MP4. Les thumbnails de catalog blocks sont © HeyGen, servis depuis leur CDN ; le framework OSS est Apache-2.0.
> **Déjà câblés mais pas encore exposés comme templates :** Kling 2.0 / 1.6 / 1.5, Veo 3 / Veo 2, Sora 2 / Sora 2-Pro (via Fal), MiniMax video-01, tous dans `VIDEO_MODELS` ([`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Les modèles audio sont catalogués, mais lUI audio intégrée expose aujourdhui les providers speech pris en charge, comme MiniMax et FishAudio. La galerie de templates reste image / vidéo : ajoutez un JSON dans `prompt-templates/video/` pour le faire apparaître dans le picker vidéo.
## Au-delà du chat
La boucle chat / artifact est la plus visible, mais plusieurs capacités moins exposées sont déjà câblées :
- **Import ZIP Claude Design.** Déposez un export de claude.ai sur le welcome dialog. `POST /api/import/claude-design` lextrait dans un vrai `.od/projects/<id>/`, ouvre le fichier dentrée en tab et prépare un prompt pour continuer là où Anthropic sest arrêté.
- **Proxy BYOK multi-provider.** `POST /api/proxy/{anthropic,openai,azure,google}/stream` prend `{ baseUrl, apiKey, model, messages }`, construit la requête upstream propre au provider, normalise les chunks SSE vers `delta/end/error` et rejette les destinations loopback / link-local / RFC1918 pour prévenir SSRF.
- **Templates utilisateur.** Une fois un rendu validé, `POST /api/templates` prend un snapshot du HTML + metadata dans la table SQLite `templates`. Le projet suivant peut le choisir depuis une ligne « your templates ».
- **Persistance des tabs.** Chaque projet mémorise ses fichiers ouverts et son onglet actif dans la table `tabs`.
- **Artifact lint API.** `POST /api/artifacts/lint` exécute des checks structurels sur un artifact généré et renvoie des findings que lagent peut relire au tour suivant.
- **Sidecar protocol + automation desktop.** Les processus daemon, web et desktop portent des stamps typés à cinq champs (`app · mode · namespace · ipc · source`) et exposent un canal JSON-RPC IPC sous `/tmp/open-design/ipc/<namespace>/<app>.sock`.
- **Spawning compatible Windows.** Les adapters qui dépasseraient la limite argv de `CreateProcess` envoient le prompt via stdin ; le daemon retombe sur un fichier prompt temporaire si besoin.
- **Runtime data par namespace.** `OD_DATA_DIR` et `--namespace` donnent des arbres `.od/` isolés, pour que Playwright, les canaux beta et vos vrais projets ne partagent jamais le même SQLite.
## Anti-AI-slop machinery
Tout le mécanisme ci-dessous vient du playbook [`huashu-design`](https://github.com/alchaincyf/huashu-design), porté dans le prompt stack dOD et rendu vérifiable par Skill via le pre-flight des side files :
- **Question form first.** Le tour 1 est seulement `<question-form>`, sans thinking, outils ni narration. Lutilisateur choisit des valeurs par défaut à la vitesse de boutons radio.
- **Brand-spec extraction.** Quand lutilisateur attache un screenshot ou une URL, lagent suit un protocole en cinq étapes (locate · download · grep hex · codify `brand-spec.md` · vocalise) avant décrire du CSS. **Il ne devine jamais les couleurs de marque depuis la mémoire.**
- **Critique 5 dimensions.** Avant démettre `<artifact>`, lagent attribue silencieusement un score à sa sortie de 1 à 5 sur philosophie / hiérarchie / exécution / spécificité / retenue. Tout score sous 3/5 est une régression : il faut corriger puis évaluer à nouveau.
- **Checklist P0/P1/P2.** Les Skills qui fournissent des side files peuvent inclure un `references/checklist.md` avec des P0 gates strictes. Lagent doit passer P0 avant démettre quand cette checklist existe.
- **Slop blacklist.** Gradients violets agressifs, icônes emoji génériques, cartes arrondies avec accent left-border, humains SVG dessinés à la main, Inter comme display face, métriques inventées : explicitement interdits dans le prompt.
- **Placeholders honnêtes > fausses stats.** Quand lagent na pas de vrai chiffre, il écrit `N/A` ou un bloc gris libellé, pas « 10× faster ».
## Comparaison
| Axe | [Claude Design][cd] (Anthropic) | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| Licence | Fermé | MIT | **Apache-2.0** |
| Format | Web (claude.ai) | Desktop (Electron) | **Web app + daemon local** |
| Déployable sur Vercel | ❌ | ❌ | **✅** |
| Runtime agent | Intégré (Opus 4.7) | Intégré ([`pi-ai`][piai]) | **Délégué à la CLI existante de lutilisateur** |
| Skills | Propriétaires | 12 modules TS custom + `SKILL.md` | **Bundles [`SKILL.md`][skill] file-based, droppables** |
| Design System | Propriétaire | `DESIGN.md` (roadmap v0.2) | **Catalogue `DESIGN.md` chargé depuis `design-systems/`** |
| Flexibilité fournisseur | Anthropic seulement | 7+ via [`pi-ai`][piai] | **CLI adapters + proxy BYOK multi-provider** |
| Formulaire initial | ❌ | ❌ | **✅ Règle dure, tour 1** |
| Direction picker | ❌ | ❌ | **✅ 5 directions déterministes** |
| Todo progress + tool stream live | ❌ | ✅ | **✅** |
| Aperçu iframe sandboxé | ❌ | ✅ | **✅** |
| Import ZIP Claude Design | n/a | ❌ | **`POST /api/import/claude-design`** |
| Éditions chirurgicales comment-mode | ❌ | ✅ | 🟡 partiel |
| Panneau tweaks émis par IA | ❌ | ✅ | 🚧 roadmap |
| Workspace file-system-grade | ❌ | partiel | **✅ Vrai cwd, vrais outils, SQLite persistant** |
| Self-critique 5 dimensions | ❌ | ❌ | **✅ Gate pre-emit** |
| Artifact lint | ❌ | ❌ | **`POST /api/artifacts/lint`** |
| Sidecar IPC + desktop headless | ❌ | ❌ | **✅ Processus stampés + `tools-dev inspect desktop status \| eval \| screenshot`** |
| Formats dexport | Limités | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTX (agent-driven) / ZIP / Markdown** |
| Réutilisation Skill PPT | N/A | Built-in | **[`guizang-ppt-skill`][guizang] intégré** |
| Facturation minimale | Pro / Max / Team | BYOK | **BYOK** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## Coding agents pris en charge
Auto-détectés depuis `PATH` au boot du daemon. Aucune config nécessaire. Le dispatch streaming vit dans [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) (`AGENT_DEFS`) ; les parseurs par CLI vivent à côté. Les modèles sont peuplés soit par probe (`<bin> --list-models`, `<bin> models`, handshake ACP), soit par fallback prédéfini quand la CLI nexpose pas de liste.
| Agent | Bin | Format stream | Forme argv (chemin prompt composé) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json` | `claude -p --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` (prompt sur stdin) |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + parseur `codex` | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--model …] [-c model_reasoning_effort=…]` (prompt sur stdin) |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + parseur `gemini` | `gemini --output-format stream-json --skip-trust --yolo [--model …]` (prompt sur stdin) |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + parseur `opencode` | `opencode run --format json --dangerously-skip-permissions [--model …] -` |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + parseur `cursor-agent` | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …]` (prompt sur stdin) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain` | `qwen --yolo [--model …] -` |
| Qoder CLI | `qodercli` | `qoder-stream-json` | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]` (prompt sur stdin) |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json` | `copilot -p - --allow-all-tools --output-format json [--model …] [--add-dir …]` (prompt sur stdin) |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc` | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain` (raw stdout chunks) | `deepseek exec --auto [--model …] <prompt>` |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc` | `pi --mode rpc [--model …] [--thinking …]` |
| **BYOK multi-provider** | n/a | SSE normalisé | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI-compatible / Azure OpenAI / Gemini ; protégé contre loopback / link-local / RFC1918 |
Ajouter une nouvelle CLI revient à ajouter une entrée dans [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). Le format de stream est lun de `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream`, `acp-json-rpc`, `pi-rpc` ou `plain`.
## Références & lignée
Chaque projet externe dont ce repo sinspire. Chaque lien pointe vers la source pour vérifier la provenance.
| Projet | Rôle ici |
|---|---|
| [`Claude Design`][cd] | Le produit fermé dont ce repo est lalternative open source. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Le cœur philosophie design. Workflow Junior-Designer, protocole brand-asset en 5 étapes, checklist anti-AI-slop, self-critique 5 dimensions et bibliothèque « 5 écoles × 20 philosophies design ». |
| [**`op7418/guizang-ppt-skill`**][guizang] | Skill Magazine-web-PPT inclus tel quel sous [`skills/guizang-ppt/`](skills/guizang-ppt/). Défaut pour le mode deck. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Architecture daemon + adapter. Détection PATH, daemon local comme seul processus privilégié, vision agent-as-teammate. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | Première alternative open source à Claude Design et pair le plus proche. Patterns UX adoptés : streaming-artifact loop, preview iframe sandboxée, panneau agent live, cinq exports, storage hub local, goût injecté par `SKILL.md`. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Source du schéma `DESIGN.md` en 9 sections et des product systems importés. |
| [`bergside/awesome-design-skills`][ads] | Source des design skills ajoutés comme `DESIGN.md` normalisés sous `design-systems/`. |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | Inspiration pour la distribution de Skills par symlink entre plusieurs CLI agent. |
| [Claude Code skills][skill] | Convention `SKILL.md` adoptée telle quelle. |
Le récit long de provenance vit dans [`docs/references.md`](docs/references.md).
## Roadmap
- [x] Daemon + détection agents CLI + registre Skills + catalogue Design Systems
- [x] Web app + chat + question form + 5-direction picker + todo progress + preview sandboxée
- [x] Catalogues Skills + Design Systems + 5 directions visuelles + 5 device frames
- [x] Projets · conversations · messages · tabs · templates sur SQLite
- [x] Proxy BYOK multi-provider (`/api/proxy/{anthropic,openai,azure,google}/stream`) avec SSRF guard
- [x] Import ZIP Claude Design (`/api/import/claude-design`)
- [x] Sidecar protocol + desktop Electron avec IPC automation
- [x] Artifact lint API + gate pre-emit de self-critique 5 dimensions
- [ ] Éditions chirurgicales comment-mode
- [ ] UX panneau tweaks émis par IA
- [ ] Recette Vercel + tunnel deployment
- [ ] `npx od init` en une commande pour scaffold un projet avec `DESIGN.md`
- [ ] Skill marketplace (`od skills install <github-repo>`) et surface CLI `od skill add | list | remove | test`
- [x] Build Electron empaqueté depuis `apps/packaged/` — téléchargements macOS (Apple Silicon) et Windows (x64) sur [open-design.ai](https://open-design.ai/) et la [page des releases GitHub](https://github.com/nexu-io/open-design/releases)
Livraison par phases → [`docs/roadmap.md`](docs/roadmap.md).
## Statut
Cest une implémentation encore jeune, mais la boucle fermée fonctionne de bout en bout : détecter → choisir Skill + Design System → chat → parser `<artifact>` → preview → sauvegarder. Le prompt stack et la Skill library concentrent lessentiel de la valeur, et ils sont stables. Les composants UI évoluent tous les jours.
## Star us
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="Star Open Design on GitHub : github.com/nexu-io/open-design" width="100%" /></a>
</p>
Si ce projet vous a économisé trente minutes, donnez-lui une ★. Les stars ne paient pas le loyer, mais elles indiquent au prochain designer, agent ou contributeur que cette expérience mérite son attention : [github.com/nexu-io/open-design](https://github.com/nexu-io/open-design).
## Contribuer
Issues, PRs, nouveaux Skills et nouveaux Design Systems sont bienvenus. Les contributions les plus utiles sont souvent un dossier, un fichier Markdown ou un petit adapter qui tient dans une PR :
- **Ajouter un Skill** : déposer un dossier dans [`skills/`](skills/) selon la convention [`SKILL.md`][skill].
- **Ajouter un Design System** : déposer un `DESIGN.md` dans [`design-systems/<brand>/`](design-systems/) avec le schéma en 9 sections.
- **Brancher une nouvelle coding-agent CLI** : une entrée dans [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts).
Guide complet, critères de merge, style de code et refus fréquents → [`CONTRIBUTING.fr.md`](CONTRIBUTING.fr.md) ([English](CONTRIBUTING.md), [Deutsch](CONTRIBUTING.de.md), [简体中文](CONTRIBUTING.zh-CN.md)).
## Contributeurs
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-06" 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.
## Activité du dépôt
<picture>
<img alt="Open Design : métriques du dépôt" src="docs/assets/github-metrics.svg" />
</picture>
Le SVG ci-dessus est régénéré chaque jour par [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) avec [`lowlighter/metrics`](https://github.com/lowlighter/metrics). Lancez un refresh manuel depuis longlet **Actions** si vous le voulez plus tôt ; pour des plugins plus riches, ajoutez un secret `METRICS_TOKEN` avec un PAT fine-grained.
## Star History
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<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-06" />
</picture>
</a>
Si la courbe monte, cest le signal que nous cherchons. ★ ce repo pour laider à monter.
## Crédits
La famille de Skills HTML PPT Studio, le Skill maître [`skills/html-ppt/`](skills/html-ppt/) et les wrappers par template sous [`skills/html-ppt-*/`](skills/), est intégrée depuis le projet open source [`lewislulu/html-ppt-skill`](https://github.com/lewislulu/html-ppt-skill) (MIT). La LICENSE upstream est incluse dans le repo à [`skills/html-ppt/LICENSE`](skills/html-ppt/LICENSE) et le crédit auteur revient à [@lewislulu](https://github.com/lewislulu).
Le flow deck magazine / horizontal-swipe sous [`skills/guizang-ppt/`](skills/guizang-ppt/) est intégré depuis [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) (MIT). Crédit auteur : [@op7418](https://github.com/op7418).
## Licence
Apache-2.0. Le bundle `skills/guizang-ppt/` conserve sa [LICENSE](skills/guizang-ppt/LICENSE) originale (MIT) et lattribution à [op7418](https://github.com/op7418). Le bundle `skills/html-ppt/` conserve sa [LICENSE](skills/html-ppt/LICENSE) originale (MIT) et lattribution à [lewislulu](https://github.com/lewislulu).

744
README.ja-JP.md Normal file
View File

@@ -0,0 +1,744 @@
# 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 なしで実行できます。
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — ノートパソコン上のエージェントとデザインする" width="100%" />
</p>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="ダウンロード" src="https://img.shields.io/badge/%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89-open--design.ai-ff6b35?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#対応-coding-agent"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#design-system">
<img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#組み込み-skill"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.ja-JP.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.ko.md">한국어</a> · <b>日本語</b> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a></p>
---
## なぜこれを作ったのか
Anthropic の [Claude Design][cd]2026-04-17 リリース、Opus 4.7 搭載は、LLM が文章を書くのをやめてデザイン成果物を直接出力し始めたらどうなるかを世に示しました。瞬く間にバズり — そして**クローズドソース**、有料限定、クラウド限定、Anthropic のモデルと Anthropic の Skill に縛られたままでした。checkout もセルフホストも Vercel デプロイも、エージェントの差し替えもできません。
**Open DesignODはそのオープンソース代替です。** 同じループ、同じ「artifact-first」のメンタルモデル、しかしロックインなし。私たちはエージェントを同梱しません — あなたのノートパソコンにある最強の coding agent がすでにインストール済みです。それを Skill 駆動のデザインワークフローに接続するのが私たちの仕事です。ローカルでは `pnpm tools-dev` で完結し、Web レイヤーは Vercel にデプロイ可能で、すべてのレイヤーが BYOK です。
`雑誌風のシードラウンド pitch deck を作って`」と入力してください。モデルが最初の 1 ピクセルを描く前に、**初期化質問フォーム**がポップアップします。エージェントは 5 つの厳選されたビジュアルディレクションから 1 つを選びます。ライブの `TodoWrite` 計画カードが UI にストリーミングされます。Daemon がディスク上に実際のプロジェクトフォルダを構築し、seed テンプレート、レイアウトライブラリ、セルフチェック用チェックリストを配置します。エージェントはそれらを**pre-flight で強制的に**読み取り、自身の出力に対して**五次元評価**を実行し、数秒後に `<artifact>` を 1 つ出力してサンドボックス iframe にレンダリングします。
これは「AI がデザインを試みる」ではありません。プロンプトスタックによって、使えるファイルシステムと、決定論的なカラーパレットライブラリと、チェックリスト文化を持つシニアデザイナーのように振る舞うよう訓練された AI です — まさに Claude Design が設定した水準そのもの、ただしオープンで、あなたのものです。
OD は 4 つのオープンソースプロジェクトの上に立っています:
- [**`alchaincyf/huashu-design`**(花叔の画術)](https://github.com/alchaincyf/huashu-design) — デザイン哲学の羅針盤。Junior-Designer ワークフロー、5 ステップのブランドアセットプロトコル、anti-AI-slop チェックリスト、五次元セルフ評価、そしてディレクションピッカーの背後にある「5 流派 × 20 のデザイン哲学」のアイデア — すべて [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) に蒸留されています。
- [**`op7418/guizang-ppt-skill`**(歸藏の雑誌風 PPT Skill](https://github.com/op7418/guizang-ppt-skill) — Deck モード。[`skills/guizang-ppt/`](skills/guizang-ppt/) 以下にオリジナルのまま同梱、元の LICENSE を保持。雑誌レイアウト、WebGL hero、P0/P1/P2 チェックリスト。
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX の北極星であり、最も近い同類プロジェクト。初のオープンソース Claude-Design 代替。ストリーミング artifact ループ、サンドボックス iframe プレビューReact 18 + Babel 同梱、ライブエージェントパネルtodo + tool calls + 中断可能な生成、5 種類のエクスポート形式リストHTML / PDF / PPTX / ZIP / Markdownを借用。形態では意図的に分岐しています — 彼らは [`pi-ai`][piai] を同梱するデスクトップ Electron アプリ、私たちは既存の CLI に委任する Web アプリ + ローカル daemon です。
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — Daemon とランタイムのアーキテクチャ。PATH スキャンによるエージェント検出、ローカル daemon を唯一の特権プロセスとする思想、agent-as-teammate の世界観。
## 概要
| | 提供される機能 |
|---|---|
| **Coding-agent CLI16 種類)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — `PATH` 上で自動検出、ピッカーでワンクリック切り替え |
| **BYOK フォールバック** | OpenAI 互換プロキシ `/api/proxy/stream``baseUrl` + `apiKey` + `model` を貼れば、任意のベンダーAnthropic-via-OpenAI、DeepSeek、Groq、MiMo、OpenRouter、セルフホスト vLLM、その他の OpenAI 互換プロバイダがエンジンになります。daemon 側で loopback / link-local / RFC1918 を拒否し SSRF を防御。 |
| **組み込み Design System** | **72 種** — 2 つの手書きスターター + [`awesome-design-md`][acd2] からインポートした 70 のプロダクトシステムLinear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma、小紅書… |
| **組み込み Skill** | **31 個**`prototype` モード 27 個web-prototype、saas-landing、dashboard、mobile-app、gamified-app、social-carousel、magazine-poster、dating-web、sprite-animation、motion-frames、critique、tweaks、wireframe-sketch、pm-spec、eng-runbook、finance-report、hr-onboarding、invoice、kanban-board、team-okrs…+ `deck` モード 4 個(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`)。ピッカーは `scenario` でグループ化design / marketing / operation / engineering / product / finance / hr / sale / personal。 |
| **メディア生成** | 画像 · 動画 · 音声サーフェスがデザインループと並走。**gpt-image-2**Azure / OpenAIでポスター・アバター・インフォグラフィック・イラスト都市マップ · **Seedance 2.0**ByteDanceで 15 秒のシネマティック text-to-video / image-to-video · **HyperFrames**[heygen-com/hyperframes](https://github.com/heygen-com/hyperframes))で HTML→MP4 のモーショングラフィック(プロダクトリビール、キネティックタイポグラフィ、データチャート、ソーシャルオーバーレイ、ロゴアウトロ)。**93 件**のすぐ複製できる prompt ギャラリー — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames、すべて [`prompt-templates/`](prompt-templates/) にプレビュー画像と出典付きで配置。Chat の入口はコードと同じ;実体の `.mp4` / `.png` がプロジェクトワークスペースに chip として落ちます。 |
| **ビジュアルディレクション** | 5 つの厳選流派Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental— 各々に OKLch パレット + フォントスタック付き([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts) |
| **デバイスフレーム** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — ピクセル単位で正確、Skill 間で共有、[`assets/frames/`](assets/frames/) に集約 |
| **エージェントランタイム** | ローカル daemon がプロジェクトフォルダ内で CLI を spawn — エージェントは実際のディスク上で `Read` / `Write` / `Bash` / `WebFetch` を使用。各 adapter に Windows `ENAMETOOLONG` フォールバックstdin / 一時 prompt ファイル)あり |
| **インポート** | [Claude Design][cd] のエクスポート ZIP をウェルカムダイアログにドロップ — `POST /api/import/claude-design` が実プロジェクトとして展開し、Anthropic の中断箇所からエージェントが編集を続行 |
| **永続化** | SQLite`.od/app.sqlite`projects · conversations · messages · tabs · ユーザー templates。翌日開いても、todo カードと開いていたファイルはそのまま。 |
| **ライフサイクル** | 唯一のエントリポイント `pnpm tools-dev`start / stop / run / status / logs / inspect / check— 型付き sidecar stamp で daemon + web+ desktopを起動 |
| **デスクトップ** | オプションの Electron シェル:サンドボックスレンダラ + sidecar IPCSTATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN— 同じチャネルで `tools-dev inspect desktop screenshot` を駆動、E2E テスト対応 |
| **デプロイ先** | ローカル(`pnpm tools-dev`)· Vercel Web レイヤー · macOSApple Siliconと Windowsx64向けパッケージ版 Electron デスクトップアプリ — [open-design.ai](https://open-design.ai/) または [最新リリース](https://github.com/nexu-io/open-design/releases) からダウンロード |
| **ライセンス** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
## デモ
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · エントリビュー" /><br/>
<sub><b>エントリビュー</b> — Skill を選び、Design System を選び、要件を入力。プロトタイプ、デッキ、モバイルアプリ、ダッシュボード、エディトリアルページ — すべて同じ画面で。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · 初期化質問フォーム" /><br/>
<sub><b>初期化質問フォーム</b> — モデルが 1 ピクセルも描く前に、OD が要件をロックsurface、ターゲット、トーン、ブランドコンテキスト、規模。30 秒のラジオ選択が 30 分の手戻りを消し去ります。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · ディレクションピッカー" /><br/>
<sub><b>ディレクションピッカー</b> — ユーザーにブランドコンテキストがない場合、エージェントが 5 つの厳選ディレクションMonocle / Modern Minimal / Tech Utility / Brutalist / Soft Warmを提示する 2 つ目のフォームを表示。ラジオ 1 クリックでパレット + フォントスタックが確定、フリースタイルの余地なし。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · ライブ todo 進捗" /><br/>
<sub><b>ライブ todo 進捗</b> — エージェントの計画がライブカードとして UI に流れ込みます。<code>in_progress</code> → <code>completed</code> がリアルタイムで更新。ユーザーは最小コストで途中介入・軌道修正が可能。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · サンドボックスプレビュー" /><br/>
<sub><b>サンドボックスプレビュー</b> — すべての <code>&lt;artifact&gt;</code> がクリーンな srcdoc iframe でレンダリングされます。ファイルワークスペースでその場編集可能。HTML / PDF / ZIP でダウンロード。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · 72 種 Design System ライブラリ" /><br/>
<sub><b>72 種 Design System ライブラリ</b> — 各プロダクトシステムが 4 色のカラーカードを表示。クリックで完全な <code>DESIGN.md</code>、スウォッチグリッド、ライブショーケースを閲覧。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · 雑誌風デッキ" /><br/>
<sub><b>Deck モードguizang-ppt</b> — 同梱の <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> をそのまま統合。雑誌レイアウト、WebGL hero 背景、単一ファイル HTML 出力、PDF エクスポート対応。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · モバイルプロトタイプ" /><br/>
<sub><b>モバイルプロトタイプ</b> — ピクセル単位で正確な iPhone 15 Pro クロームDynamic Island、ステータスバー SVG、ホームインジケータ。マルチスクリーンプロトタイプは <code>/frames/</code> の共有アセットを再利用するため、エージェントが端末を描き直す必要は一切ありません。</sub>
</td>
</tr>
</table>
## 組み込み Skill
**31 個の Skill が同梱されています。** 各 Skill は [`skills/`](skills/) 配下のフォルダで、Claude Code の [`SKILL.md`][skill] 規約に従いつつ、daemon がそのままパースする OD 拡張 `od:` frontmatter を持ちます — `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))。
2 つのトップレベル **mode** がカタログを構成します:**`prototype`**27 個 — 雑誌風ランディングからモバイル画面、PM 仕様書まで、単一ページ artifact としてレンダリングされるすべて)と **`deck`**4 個 — デッキフレームワーク付きの横スワイプ型プレゼンテーション)。**`scenario`** フィールドがピッカーのグループ化に使われます:`design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`
### ショーケース
ビジュアル的に最も特徴的で、最初に試す Skill として最適なものです。各 Skill には実際の `example.html` が付属しており、リポジトリから直接開いてエージェントの出力を確認できます — 認証もセットアップも不要。
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>コンシューマー向けマッチングダッシュボード — 左サイドバー、ティッカーバー、KPI、30 日間の相互マッチチャート、エディトリアルタイポグラフィ。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>2 見開きのデジタル e-guide — 表紙タイトル、著者、TOC ティーザー)+ レッスン見開き(プルクオート + ステップリスト)。クリエイター / ライフスタイルトーン。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>ブランド新製品発売 HTML メール — ワードマーク、hero 画像、見出しロックアップ、CTA、スペックグリッド。中央揃え単一カラム + テーブルフォールバックでメールクライアント安全。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>ダークステージ上の 3 画面ゲーミフィケーションモバイルアプリプロトタイプ — カバー / 今日のクエストXP リボン + レベルバー)/ クエスト詳細。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>3 画面モバイルオンボーディングフロー — スプラッシュ、バリュープロポジション、サインイン。ステータスバー、スワイプドット、プライマリ CTA。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>ループ CSS アニメーション付きの単一フレームモーションデザイン hero — 回転タイプリング、地球、タイマー。HyperFrames 等へのハンドオフ対応。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>1080×1080 の 3 枚 SNS カルーセル — シネマティックなパネル、シリーズを横断する大見出し、ブランドマーク、ループインジケータ。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>ピクセル / 8-bit アニメーション解説スライド — クリーム地フルブリード、アニメーションピクセルマスコット、キネティックな日本語ディスプレイタイプ、ループ CSS keyframes。</sub>
</td>
</tr>
</table>
### デザイン & マーケティング系prototype モード)
| Skill | プラットフォーム | シナリオ | 出力 |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | デスクトップ | design | 単一ページ HTML — ランディング、マーケティング、heroprototype のデフォルト) |
| [`saas-landing`](skills/saas-landing/) | デスクトップ | marketing | hero / features / pricing / CTA マーケティングレイアウト |
| [`dashboard`](skills/dashboard/) | デスクトップ | operation | サイドバー + データ密度の高い管理画面 |
| [`pricing-page`](skills/pricing-page/) | デスクトップ | sale | 単独料金ページ + 比較表 |
| [`docs-page`](skills/docs-page/) | デスクトップ | engineering | 3 カラムドキュメントレイアウト |
| [`blog-post`](skills/blog-post/) | デスクトップ | marketing | エディトリアル長文 |
| [`mobile-app`](skills/mobile-app/) | モバイル | design | iPhone 15 Pro / Pixel フレーム付きアプリ画面 |
| [`mobile-onboarding`](skills/mobile-onboarding/) | モバイル | design | マルチスクリーンモバイルオンボーディング(スプラッシュ · バリュープロポジション · サインイン) |
| [`gamified-app`](skills/gamified-app/) | モバイル | personal | 3 画面ゲーミフィケーションアプリプロトタイプ |
| [`email-marketing`](skills/email-marketing/) | デスクトップ | marketing | ブランド新製品発売メール(テーブルフォールバック対応) |
| [`social-carousel`](skills/social-carousel/) | デスクトップ | marketing | 1080×1080 3 枚 SNS カルーセル |
| [`magazine-poster`](skills/magazine-poster/) | デスクトップ | marketing | 単一ページ雑誌風ポスター |
| [`motion-frames`](skills/motion-frames/) | デスクトップ | marketing | CSS ループアニメーション付きモーション hero |
| [`sprite-animation`](skills/sprite-animation/) | デスクトップ | marketing | ピクセル / 8-bit アニメーション解説 |
| [`dating-web`](skills/dating-web/) | デスクトップ | personal | コンシューマー向けマッチングダッシュボード |
| [`digital-eguide`](skills/digital-eguide/) | デスクトップ | marketing | 2 見開きデジタル e-guide表紙 + レッスン見開き) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | デスクトップ | design | 手描きスケッチ風ワイヤーフレーム — 「まず目に見えるものを早く出す」初期パス |
| [`critique`](skills/critique/) | デスクトップ | design | 五次元セルフ評価スコアシートPhilosophy · Hierarchy · Detail · Function · Innovation |
| [`tweaks`](skills/tweaks/) | デスクトップ | design | AI が出力する tweaks パネル — モデル自身が調整すべきパラメータを提示 |
### Deck 系deck モード)
| Skill | デフォルト | 出力 |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **deck のデフォルト** | 雑誌風 Web PPT — [op7418/guizang-ppt-skill][guizang] からそのまま同梱、元の LICENSE 保持 |
| [`simple-deck`](skills/simple-deck/) | — | ミニマル横スワイプデッキ |
| [`replit-deck`](skills/replit-deck/) | — | プロダクトウォークスルーデッキReplit スタイル) |
| [`weekly-update`](skills/weekly-update/) | — | チーム週次報告デッキ(進捗 · ブロッカー · 次のステップ) |
### ドキュメント & 業務系prototype モード、ドキュメント系シナリオ)
| Skill | シナリオ | 出力 |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | PM 仕様書 + 目次 + 意思決定ログ |
| [`team-okrs`](skills/team-okrs/) | product | OKR スコアシート |
| [`meeting-notes`](skills/meeting-notes/) | operation | 会議議事録 |
| [`kanban-board`](skills/kanban-board/) | operation | カンバンボードスナップショット |
| [`eng-runbook`](skills/eng-runbook/) | engineering | インシデント Runbook |
| [`finance-report`](skills/finance-report/) | finance | 経営層向け財務サマリー |
| [`invoice`](skills/invoice/) | finance | 単一ページ請求書 |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | 職位オンボーディング計画 |
Skill の追加はフォルダ 1 つで完了します。拡張 frontmatter の詳細は [`docs/skills-protocol.md`](docs/skills-protocol.md) を参照し、既存の Skill を fork して daemon を再起動すればピッカーに表示されます。カタログエンドポイントは `GET /api/skills`、個別 Skill の seed 組み立て(テンプレート + 副ファイル)は `GET /api/skills/:id/example` です。
## 6 つの基本設計思想
### 1 · エージェントは同梱しない — あなたのもので十分
Daemon は起動時に `PATH` を走査し、[`claude`](https://docs.anthropic.com/en/docs/claude-code)、[`codex`](https://github.com/openai/codex)、[`cursor-agent`](https://www.cursor.com/cli)、[`gemini`](https://github.com/google-gemini/gemini-cli)、[`opencode`](https://opencode.ai/)、[`qwen`](https://github.com/QwenLM/qwen-code)、`qodercli`、[`copilot`](https://github.com/features/copilot/cli)、`hermes``kimi`、[`pi`](https://github.com/mariozechner/pi-ai) を検索します。見つかったものすべてが候補デザインエンジンになります — stdio 経由で CLI ごとに 1 つの adapter を持ち、モデルピッカーからワンクリックで切り替え可能。[`multica`](https://github.com/multica-ai/multica) と [`cc-switch`](https://github.com/farion1231/cc-switch) に着想を得ています。CLI が 1 つもない?`POST /api/proxy/stream` が spawn を除いた同じパイプラインです — 任意の OpenAI 互換 `baseUrl` + `apiKey` を貼れば、daemon が SSE チャンクをブラウザに転送し、loopback / link-local / RFC1918 はエッジで拒否されます。
### 2 · Skill はファイルであり、プラグインではない
Claude Code の [`SKILL.md` 規約](https://docs.anthropic.com/en/docs/claude-code/skills)に従い、各 Skill は `SKILL.md` + `assets/` + `references/` です。[`skills/`](skills/) にフォルダを入れて daemon を再起動すれば、ピッカーに表示されます。同梱の `magazine-web-ppt` は [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) を**そのまま**同梱 — 元の LICENSE 保持、元の帰属表示保持。
### 3 · Design System は移植可能な Markdown であり、theme JSON ではない
[`VoltAgent/awesome-design-md`][acd2] の 9 セクション `DESIGN.md` スキーマ — color、typography、spacing、layout、components、motion、voice、brand、anti-patterns。すべての artifact はアクティブなシステムからトークンを読み取ります。システムを切り替えれば、次のレンダリングは新しいトークンを使用します。ドロップダウンには **Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Apple、Anthropic、Cursor、Supabase、Figma、Resend、Raycast、Lovable、Cohere、Mistral、ElevenLabs、X.AI、Spotify、Webflow、Sanity、PostHog、Sentry、MongoDB、ClickHouse、Cal、Replicate、Clay、Composio、小紅書…** — 全 72 種が揃っています。
### 4 · 初期化質問フォームが手戻りの 80% を解消
OD のプロンプトスタックは `RULE 1` をハードコードしています:新しいデザイン要件はすべて `<question-form id="discovery">` で始まり、**コードではありません**。Surface · ターゲット · トーン · ブランドコンテキスト · 規模 · 制約。長い要件でもデザイン上の判断は残ります — ビジュアルトーン、カラースタンス、スケール — まさにフォームが 30 秒のラジオ選択で確定させるポイントです。方向を間違えたコストは 1 往復のチャットであり、完成済みのデッキではありません。
これは [`huashu-design`](https://github.com/alchaincyf/huashu-design) から蒸留された **Junior-Designer モード**です:着手前に質問を一括で済ませ、早い段階で何か目に見えるもの(グレーブロックのワイヤーフレームでも可)を提示し、ユーザーが最小コストで軌道修正できるようにします。ブランドアセットプロトコル(特定 · ダウンロード · `grep` hex · `brand-spec.md` 作成 · 復唱と組み合わせることで、出力が「AI のフリースタイル」から「資料を見てから描くデザイナー」に変わる最大の要因です。
### 5 · Daemon がエージェントをあなたのノートパソコン上に感じさせる — 実際にそこにいるから
Daemon は CLI を spawn する際、`cwd``.od/projects/<id>/` 配下のプロジェクト artifact フォルダに設定します。エージェントが使う `Read` / `Write` / `Bash` / `WebFetch` は実際のファイルシステムに作用する本物のツールです。Skill の `assets/template.html``Read` し、CSS から `grep` で hex 値を取得し、`brand-spec.md` を作成し、生成画像を配置し、`.pptx` / `.zip` / `.pdf` を出力できます — これらのファイルはターン終了時にファイルワークスペース上のダウンロードチップとして表示されます。セッション、会話、メッセージ、タブはすべてローカル SQLite に永続化されます — 翌日プロジェクトを開けば、エージェントの todo カードは昨日閉じた場所にそのまま残っています。
### 6 · プロンプトスタック自体がプロダクト
送信時に組み立てられるのは「system + user」ではありません。以下の構成です
```
DISCOVERY ディレクティブ turn-1 フォーム、turn-2 ブランド分岐、TodoWrite、五次元評価
+ アイデンティティ憲章 OFFICIAL_DESIGNER_PROMPT、anti-AI-slop、Junior Designer モード)
+ アクティブな DESIGN.md 72 種から選択)
+ アクティブな SKILL.md 31 個から選択)
+ プロジェクトメタデータ kind、fidelity、speakerNotes、animations、インスピレーション system id
+ Skill 副ファイル (自動注入 pre-flightassets/template.html + references/*.md を先読み)
+ deck kind かつ Skill seed なし時) DECK_FRAMEWORK_DIRECTIVE nav / counter / scroll / print
```
すべてのレイヤーが組み合わせ可能で、すべてのレイヤーが編集可能なファイルです。実際の契約は [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) と [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) で確認できます。
## アーキテクチャ
```
┌───────────────── ブラウザNext.js 16─────────────────────────┐
│ chat · ファイルワークスペース · iframe プレビュー · 設定 · インポート │
└──────────────┬──────────────────────────────────┬──────────────┘
│ /api/*dev は rewrites 経由) │
▼ ▼
┌──────────────────────────────────┐ /api/proxy/stream (SSE)
│ ローカル daemonExpress + SQLite│ ─→ 任意の OpenAI 互換
│ │ エンドポイントBYOK
│ /api/agents /api/skills │ SSRF 防御付き
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (静的) /frames (静的)
│ オプション sidecar IPC/tmp/open-design/ipc/<ns>/<app>.sock
STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN
└─────────┬───────────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · gemini · opencode · cursor-agent · qwen │
│ qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) │
│ SKILL.md + DESIGN.md を読み、artifact をディスクに書き出す │
└──────────────────────────────────────────────────────────────────┘
```
| レイヤー | 技術スタック |
|---|---|
| フロントエンド | Next.js 16 App Router + React 18 + TypeScript、Vercel デプロイ可能 |
| Daemon | Node 24 · Express · SSE ストリーミング · `better-sqlite3`;テーブル:`projects` · `conversations` · `messages` · `tabs` · `templates` |
| エージェント転送 | `child_process.spawn`Claude Code は `claude-stream-json`、Qoder CLI は `qoder-stream-json`、Copilot は `copilot-stream-json`、Codex / Gemini / OpenCode / Cursor Agent は `json-event-stream`CLI ごとのパーサー、Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe は `acp-json-rpc`Agent Client Protocol、Pi は `pi-rpc`stdio JSON-RPC、Qwen Code / DeepSeek TUI は `plain` |
| BYOK プロキシ | `POST /api/proxy/stream` → OpenAI 互換 `/v1/chat/completions` SSE パススルーdaemon エッジで loopback / link-local / RFC1918 を拒否 |
| ストレージ | プレーンファイル `.od/projects/<id>/` + SQLite `.od/app.sqlite`gitignore 済み、daemon 起動時に自動作成)。`OD_DATA_DIR` でルートを変更可能(テスト分離用) |
| プレビュー | サンドボックス iframe`srcdoc`+ Skill ごとの `<artifact>` パーサー([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts) |
| エクスポート | HTMLインラインアセット· PDFブラウザ印刷、デッキ対応· PPTXエージェント駆動、Skill 経由)· ZIParchiver· Markdown |
| ライフサイクル | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`;ポートは `--daemon-port` / `--web-port`、ネームスペースは `--namespace` |
| デスクトップ(オプション) | Electron シェル — sidecar IPC 経由で Web URL を取得、ポート推測なし;同じチャネル(`STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN`)で `tools-dev inspect desktop …` を駆動し E2E 対応 |
## クイックスタート
### デスクトップアプリのダウンロード(ビルド不要)
Open Design を最速で試す方法は、ビルド済みのデスクトップアプリです — Node、pnpm、clone は不要:
- **[open-design.ai](https://open-design.ai/)** — 公式ダウンロードページ
- **[GitHub リリース](https://github.com/nexu-io/open-design/releases)**
### ソースから実行
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # 10.33.2 と表示されるはず
pnpm install
pnpm tools-dev run web
# tools-dev が出力した Web URL を開く
```
Windows ランチャー: `tools/launcher/README.md` の手順で `OpenDesign.exe` を自分でビルドするか、GitHub Releases からダウンロードします。その後、リポジトリのルートに置いてダブルクリックすると、必要に応じて `pnpm install` を実行し、`pnpm tools-dev` で Open Design を起動します。
環境要件Node `~24`、pnpm `10.33.x``nvm` / `fnm` はあくまでオプションのヘルパーです。使用する場合は `pnpm install` の前に `nvm install 24 && nvm use 24` または `fnm install 24 && fnm use 24` を実行してください。
デスクトップ / バックグラウンド起動、固定ポート再起動、メディア生成ディスパッチャの確認(`OD_BIN``OD_DAEMON_URL``apps/daemon/dist/cli.js`)は [`QUICKSTART.ja-JP.md`](QUICKSTART.ja-JP.md) を参照。
初回ロード時:
1. `PATH` 上のエージェント CLI を検出し、自動的に 1 つを選択。
2. 31 個の Skill + 72 種の Design System をロード。
3. ウェルカムダイアログが表示され、Anthropic キーの貼り付けを促すBYOK フォールバックパスのみ必要)。
4. **`./.od/` を自動作成** — SQLite プロジェクト DB、プロジェクトごとの artifact、保存されたレンダリングを格納するローカルランタイムフォルダ。`od init` ステップは不要、daemon が起動時に必要なディレクトリをすべて `mkdir` します。
プロンプトを入力し、**Send** を押し、質問フォームの到着を確認、記入し、todo カードのストリーミングを見守り、artifact のレンダリングを確認。**Save to disk** をクリックするか、プロジェクト ZIP としてダウンロード。
### 初回起動時の状態(`./.od/`
Daemon はリポジトリルートに 1 つの隠しフォルダを管理します。中身はすべて gitignore 済みのマシンローカルデータです — **絶対に commit しないでください**
```
.od/
├── app.sqlite ← プロジェクト · 会話 · メッセージ · 開いているタブ
├── artifacts/ ← Save to disk の一回限りレンダリング(タイムスタンプ付き)
└── projects/<id>/ ← プロジェクトごとの作業ディレクトリ(エージェントの cwd
```
| やりたいこと | 方法 |
|---|---|
| 中身を確認する | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| 完全にリセット | `pnpm tools-dev stop``rm -rf .od``pnpm tools-dev run web` を再実行 |
| 別の場所に移動 | 未対応 — パスはリポジトリルートからの相対パスで固定 |
完全なファイルマップ、スクリプト、トラブルシューティング → [`QUICKSTART.ja-JP.md`](QUICKSTART.ja-JP.md)。
## リポジトリ構成
```
open-design/
├── README.md ← 英語
├── README.zh-CN.md ← 简体中文
├── README.ja-JP.md ← 本ファイル
├── QUICKSTART.md ← 実行 / ビルド / デプロイガイド
├── package.json ← 単一 bin: od
├── apps/
│ ├── daemon/ ← Node + Express、唯一のサーバー
│ │ ├── src/ ← TypeScript daemon ソース
│ │ │ ├── cli.ts ← `od` bin ソース、dist/cli.js にコンパイル
│ │ │ ├── server.ts ← /api/* ルートprojects、chat、files、exports
│ │ │ ├── agents.ts ← PATH スキャナ + CLI ごとの argv ビルダー
│ │ │ ├── claude-stream.ts ← Claude Code stdout ストリーミング JSON パーサー
│ │ │ ├── skills.ts ← SKILL.md frontmatter ローダー
│ │ │ └── db.ts ← SQLite スキーマprojects/messages/templates/tabs
│ │ ├── sidecar/ ← tools-dev daemon sidecar ラッパー
│ │ └── tests/ ← daemon パッケージテスト
│ │
│ └── web/ ← Next.js 16 App Router + React クライアント
│ ├── app/ ← App Router エントリポイント
│ ├── next.config.ts ← dev rewrites + 本番 out/ 静的エクスポート
│ └── src/ ← React + TS クライアントモジュール
│ ├── App.tsx ← ルーティング、ブートストラップ、設定
│ ├── components/ ← chat、composer、picker、preview、sketch…
│ ├── prompts/ ← system、discovery、directions、deck framework
│ ├── artifacts/ ← ストリーミング <artifact> パーサー + マニフェスト
│ ├── runtime/ ← iframe srcdoc、markdown、エクスポートヘルパー
│ ├── providers/ ← daemon SSE + BYOK API トランスポート
│ └── state/ ← localStorage + daemon バックドプロジェクト状態
├── e2e/ ← Playwright UI + 外部統合/Vitest ハーネス
├── packages/
│ ├── contracts/ ← web/daemon 共有アプリ contracts
│ ├── sidecar-proto/ ← Open Design sidecar プロトコル contract
│ ├── sidecar/ ← 汎用 sidecar ランタイムプリミティブ
│ └── platform/ ← 汎用 process/platform プリミティブ
├── skills/ ← 31 個の SKILL.md Skill バンドル27 prototype + 4 deck
│ ├── web-prototype/ ← prototype のデフォルト
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← deck モード
│ └── guizang-ppt/ ← 同梱 magazine-web-pptdeck のデフォルト)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 種の DESIGN.md
│ ├── default/ ← Neutral Modernスターター
│ ├── warm-editorial/ ← Warm Editorialスターター
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md
├── assets/
│ └── frames/ ← Skill 間共有のデバイスフレーム
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ └── deck-framework.html ← デッキベースラインnav / counter / print
├── scripts/
│ └── sync-design-systems.ts ← 上流 awesome-design-md tarball からの再インポート
├── docs/
│ ├── spec.md ← プロダクト定義、シナリオ、差別化
│ ├── architecture.md ← トポロジ、データフロー、コンポーネント
│ ├── skills-protocol.md ← SKILL.md 拡張 od: frontmatter
│ ├── agent-adapters.md ← CLI ごとの検出 + ディスパッチ
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← 詳細な出典・系譜
│ ├── roadmap.md ← フェーズ別デリバリー
│ ├── schemas/ ← JSON スキーマ
│ └── examples/ ← 標準 artifact サンプル
└── .od/ ← ランタイムデータ、gitignore 済み、daemon 起動時に自動作成
├── app.sqlite ← プロジェクト / 会話 / メッセージ / タブ
├── projects/<id>/ ← プロジェクトごとの作業ディレクトリ(エージェントの cwd
└── artifacts/ ← 一回限りのレンダリング保存
```
## Design System
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="72 種の Design System ライブラリ — スタイルガイド見開き" width="100%" />
</p>
72 種がすぐ使えます。各システムは 1 つの [`DESIGN.md`](design-systems/README.md)
<details>
<summary><b>全カタログ</b>(クリックで展開)</summary>
**AI & LLM**`claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**開発者ツール**`cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**プロダクティビティ**`notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**フィンテック**`stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**E コマース / モビリティ**`shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**メディア**`spotify` · `playstation` · `wired` · `theverge` · `meta`
**自動車**`tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**その他**`apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**スターター**`default`Neutral Modern· `warm-editorial`
</details>
ライブラリ全体は [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) を通じて [`VoltAgent/awesome-design-md`][acd2] からインポートされています。再実行で更新可能。
## ビジュアルディレクション
ユーザーにブランドアセットがない場合、エージェントは 5 つの厳選ディレクションを提示する 2 つ目のフォームを出力します — [`huashu-design` の「デザインディレクション顧問 · 5 流派 × 20 のデザイン哲学」フォールバック](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback)を OD に適用したものです。各ディレクションは決定論的な仕様です — OKLch パレット、フォントスタック、レイアウトポスチャのヒント、リファレンス — エージェントはこれを seed テンプレートの `:root` にそのままバインドします。ラジオを 1 つクリックすれば、完全なビジュアルシステムが確定します。即興なし、AI slop なし。
| ディレクション | ムード | リファレンス |
|---|---|---|
| Editorial — Monocle / FT | 印刷雑誌、インク + クリーム + ウォームラスト | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | クール、構造的、ミニマルアクセント | Linear · Vercel · Stripe |
| Tech utility | 情報密度、モノスペース、ターミナル風 | Bloomberg · Bauhaus ツール |
| Brutalist | 生々しい、巨大タイプ、シャドウなし、鮮烈なアクセント | Bloomberg Businessweek · Achtung |
| Soft warm | おおらか、低コントラスト、ピーチ系ニュートラル | Notion マーケティングページ · Apple Health |
完全な仕様 → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。
## メディア生成
OD はコードで止まりません。`<artifact>` の HTML を生み出すのと同じ chat 入口が、**画像**・**動画**・**音声**の生成も駆動します — モデル adapter は daemon のメディアパイプライン([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts)、[`apps/web/src/media/models.ts`](apps/web/src/media/models.ts))に組み込み済みです。各レンダリングはプロジェクトワークスペースに実ファイル(`.png` / `.mp4`)として落ち、ターン終了時にダウンロード chip として現れます。
主力は今のところこの 3 つのモデルファミリーです:
| サーフェス | モデル | 提供元 | 用途 |
|---|---|---|---|
| **画像** | `gpt-image-2` | Azure / OpenAI | ポスター、プロフィールアバター、イラスト都市マップ、インフォグラフィック、雑誌風ソーシャルカード、写真修復、製品爆発図 |
| **動画** | `seedance-2.0` | ByteDance Volcengine | 15 秒のシネマティック t2v + i2v + 音声 — 物語ショート、人物クローズアップ、プロダクト映像、MV 振付 |
| **動画** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 モーショングラフィック — プロダクトリビール、キネティックタイポグラフィ、データチャート、ソーシャルオーバーレイ、ロゴアウトロ、カラオケキャプション付き縦型 TikTok |
成長中の **prompt ギャラリー** は [`prompt-templates/`](prompt-templates/) — **93 件のすぐ複製できる prompt** が同梱43 件の画像(`prompt-templates/image/*.json`、39 件の Seedance`prompt-templates/video/*.json` のうち `hyperframes-*` 以外、11 件の HyperFrames`prompt-templates/video/hyperframes-*.json`。各エントリにプレビュー画像、prompt 本文、対象モデル、アスペクト比、ライセンス + 帰属を記録した `source` ブロックが付きます。daemon は `GET /api/prompt-templates` で配信し、Web アプリはエントリビューの **Image templates** / **Video templates** タブにカードグリッドとして表示。1 クリックで対応モデルが選択された状態の prompt が composer に流し込まれます。
### gpt-image-2 — 画像ギャラリー43 件中 5 件)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="3D Stone Staircase Evolution" /><br/><sub><b>3D Stone Staircase Evolution Infographic</b><br/>3 段構成・石材調インフォグラフィック</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Illustrated City Food Map" /><br/><sub><b>Illustrated City Food Map</b><br/>編集級の手描き旅行ポスター</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cinematic Elevator Scene" /><br/><sub><b>Cinematic Elevator Scene</b><br/>シネマティックなファッション 1 フレーム</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Cyberpunk Anime Portrait" /><br/><sub><b>Cyberpunk Anime Portrait</b><br/>プロフィールアバター — ネオン顔字</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Glamorous Woman in Black" /><br/><sub><b>Glamorous Woman in Black Portrait</b><br/>編集級スタジオポートレート</sub></td>
</tr>
</table>
完全リスト → [`prompt-templates/image/`](prompt-templates/image/)。出典:多くは [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts)CC-BY-4.0)から、テンプレート単位で作者帰属を保持。
### Seedance 2.0 — 動画ギャラリー39 件中 5 件)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Music Podcast Guitar" /></a><br/><sub><b>Music Podcast & Guitar Technique</b><br/>4K シネマティックスタジオ映像</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Emotional Face" /></a><br/><sub><b>Emotional Face Close-up</b><br/>シネマティック微表情研究</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Luxury Supercar" /></a><br/><sub><b>Luxury Supercar Cinematic</b><br/>物語仕立てのプロダクト映像</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Forbidden City Cat" /></a><br/><sub><b>Forbidden City Cat Satire</b><br/>スタイライズされた風刺ショート</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Japanese Romance" /></a><br/><sub><b>Japanese Romance Short Film</b><br/>15 秒の Seedance 2.0 物語</sub></td>
</tr>
</table>
サムネイルをクリックすると実レンダリング MP4 が再生されます。完全リスト → [`prompt-templates/video/`](prompt-templates/video/)`*-seedance-*` と Cinematic タグ付きエントリ)。出典:[`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts)CC-BY-4.0)、原ツイートリンクと作者ハンドルを保持。
### HyperFrames — HTML→MP4 モーショングラフィック11 件のすぐ複製できるテンプレート)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) は HeyGen がオープンソース化したエージェントネイティブな動画フレームワークです — あなた(あるいは agentが HTML + CSS + GSAP を書くと、HyperFrames は headless Chrome + FFmpeg で確定的に MP4 にレンダリングします。Open Design は HyperFrames を一級の動画モデル(`hyperframes-html`)として daemon dispatch に接続し、さらに `skills/hyperframes/` skill を同梱して timeline 規約・シーンタンスィション規則・オーディオリアクティブパターン・キャプション/TTS・カタログブロック`npx hyperframes add <slug>`)を agent に教えます。
11 件の HyperFrames prompt は [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/) に置かれ、それぞれ特定アーキタイプを生む具体的な brief です:
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Product reveal" /></a><br/><sub><b>5s ミニマルなプロダクトリビール</b> · 16:9 · 押し込みタイトルカード + シェーダトランジション</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="SaaS promo" /></a><br/><sub><b>30s SaaS プロダクト動画</b> · 16:9 · Linear/ClickUp 風 + UI 3D リビール</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaoke" /></a><br/><sub><b>TikTok カラオケトーキングヘッド</b> · 9:16 · TTS + 単語同期キャプション</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Brand sizzle" /></a><br/><sub><b>30s ブランド sizzle リール</b> · 16:9 · ビート同期キネティックタイポグラフィ、audio-reactive</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Data chart" /></a><br/><sub><b>アニメーション bar-chart race</b> · 16:9 · NYT 風データインフォグラフィック</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Flight map" /></a><br/><sub><b>フライトマップ(出発 → 到着)</b> · 16:9 · Apple 風シネマティック経路リビール</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>4s シネマティックロゴアウトロ</b> · 16:9 · ピース単位のアセンブル + bloom</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Money counter" /></a><br/><sub><b>$0 → $10K マネーカウンター</b> · 9:16 · Apple 風 hype + グリーンフラッシュ + バースト</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>3 端末アプリショーケース</b> · 16:9 · 浮遊スマホ + 機能コールアウト</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Social overlay" /></a><br/><sub><b>ソーシャルオーバーレイスタック</b> · 9:16 · X · Reddit · Spotify · Instagram を順に</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website to video" /></a><br/><sub><b>ウェブサイト→動画パイプライン</b> · 16:9 · 3 ビューポート取得 + トランジション</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
パターンは他と同じですテンプレートを選び、brief を編集し、送信。Agent は同梱の `skills/hyperframes/SKILL.md`OD 専用のレンダリングフロー — composition のソースファイルは `.hyperframes-cache/` に隔離してファイルワークスペースを汚さない、daemon が `npx hyperframes render` を肩代わりして macOS sandbox-exec / Puppeteer のハングを回避、最終 `.mp4` だけがプロジェクトの chip として現れるを読み、composition を書き、MP4 を出力します。カタログブロックのサムネイルは © HeyGen で同社 CDN から配信、OSS フレームワーク本体は Apache-2.0 です。
> **接続済みだがまだ prompt 化していないモデル:** Kling 2.0 / 1.6 / 1.5、Veo 3 / Veo 2、Sora 2 / Sora 2-Provia Fal、MiniMax video-01 — いずれも `VIDEO_MODELS`[`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)にあります。Suno v5 / v4.5、Udio v2、Lyria 2音楽と gpt-4o-mini-tts、MiniMax TTS音声が音声サーフェスをカバー。これらの prompt テンプレートはオープンコントリビューションです — JSON を `prompt-templates/video/` か `prompt-templates/audio/` に置けば picker に出ます。
## チャット以外に同梱されているもの
チャット / artifact ループが最も目立ちますが、OD を他と比較する前に把握しておく価値のある、目立たないが既に実装済みの機能がいくつかあります:
- **Claude Design ZIP インポート。** claude.ai からのエクスポート ZIP をウェルカムダイアログにドロップ。`POST /api/import/claude-design``.od/projects/<id>/` に展開し、エントリファイルをタブとして開き、ローカルエージェント向けに「Anthropic の中断箇所から編集を続行」するプロンプトを用意します。再プロンプティング不要、「モデルに作り直してもらう」必要なし。([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`
- **OpenAI 互換 BYOK プロキシ。** `POST /api/proxy/stream``{ baseUrl, apiKey, model, messages }` を受け取り、パスを正規化(`…/v1/chat/completions`、SSE チャンクをブラウザに転送、loopback / link-local / RFC1918 を拒否して SSRF を防御。OpenAI chat スキーマを話す任意のベンダーが使えます — Anthropic-via-OpenAI shim、DeepSeek、Groq、MiMo、OpenRouter、セルフホスト vLLM。MiMo は自動的に `tool_choice: 'none'` が付加されますtool スキーマがフリーフォーム生成と相性が悪いため)。
- **ユーザー保存テンプレート。** レンダリング結果が気に入ったら、`POST /api/templates` で HTML + メタデータを SQLite `templates` テーブルにスナップショット。次のプロジェクトのピッカーに「あなたのテンプレート」行が追加されます — 同梱の 31 個と同じ選択画面で、ただしあなたのもの。
- **タブ永続化。** 各プロジェクトは開いているファイルとアクティブタブを `tabs` テーブルに記録。翌日開いてもワークスペースは昨日の状態そのまま。
- **Artifact lint API。** `POST /api/artifacts/lint` は生成された artifact に対して構造チェックを実行(`<artifact>` フレーミングの破損、必須副ファイルの欠落、古いパレットトークン)し、エージェントが次のターンで読み返せる findings を返します。五次元セルフ評価はこれを使ってスコアを vibes ではなくエビデンスに基づかせます。
- **Sidecar プロトコル + デスクトップ自動化。** Daemon、web、desktop プロセスは型付き 5 フィールドスタンプ(`app · mode · namespace · ipc · source`)を持ち、`/tmp/open-design/ipc/<namespace>/<app>.sock` に JSON-RPC IPC チャネルを公開。`tools-dev inspect desktop status \| eval \| screenshot` はこのチャネル上で動作するため、ヘッドレス E2E テストが実際の Electron シェルに対して、カスタムハーネスなしで実行可能([`packages/sidecar-proto/`](packages/sidecar-proto/)、[`apps/desktop/src/main/`](apps/desktop/src/main/))。
- **Windows フレンドリーな spawn。** 長いプロンプトで `CreateProcess` の約 32 KB argv 上限に達する adapterCodex、Gemini、OpenCode、Cursor Agent、Qwen、Qoder CLI、Piはすべて stdin 経由でプロンプトを渡します。Claude Code と Copilot は `-p` を維持。stdin でも溢れる場合、daemon は一時 prompt ファイルにフォールバック。
- **ネームスペースごとのランタイムデータ分離。** `OD_DATA_DIR` + `--namespace` で完全に分離された `.od/` スタイルのディレクトリツリーを提供。Playwright、beta チャネル、本番プロジェクトが同一 SQLite ファイルを共有することはありません。
## anti-AI-slop 機構
以下の機構はすべて [`huashu-design`](https://github.com/alchaincyf/huashu-design) のプレイブックを OD のプロンプトスタックに移植し、Skill 副ファイルの pre-flight で各 Skill に適用可能にしたものです。実際の文言は [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) を参照:
- **まずフォーム。** Turn 1 は `<question-form>` のみ — thinking 禁止、tools 禁止、ナレーション禁止。ユーザーはラジオの速度でデフォルトを選択。
- **ブランドアセットプロトコル。** ユーザーがスクリーンショットや URL を添付した場合、エージェントは 5 ステップのプロトコル(特定 · ダウンロード · grep hex · `brand-spec.md` 作成 · 復唱)を実行してから CSS を書きます。**記憶からブランドカラーを推測することは絶対にありません。**
- **五次元評価。** `<artifact>` を出力する前に、エージェントはサイレントに 5 次元(哲学 / 階層 / 実行 / 具体性 / 抑制)で 15 点の自己評価を行います。いずれかが 3/5 未満なら退行と見なし、修正して再評価。2 パスが通常。
- **P0/P1/P2 チェックリスト。** 各 Skill には `references/checklist.md` が付属し、ハードな P0 ゲートを含みます。エージェントは P0 をすべてパスしてから emit 可能。
- **Slop ブラックリスト。** 攻撃的な紫グラデーション、汎用 emoji アイコン、左ボーダー付き角丸カード、手描き SVG 人物、Inter を *display* フォントとして使用、架空のメトリクス — すべてプロンプトで明示的に禁止。
- **正直なプレースホルダー > 偽データ。** エージェントが実数値を持たない場合は `—` またはラベル付きグレーブロックを書き、「10 倍高速」とは書きません。
## 比較
| 軸 | [Claude Design][cd]Anthropic | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| ライセンス | クローズド | MIT | **Apache-2.0** |
| 形態 | Web (claude.ai) | デスクトップ (Electron) | **Web アプリ + ローカル daemon** |
| Vercel デプロイ | ❌ | ❌ | **✅** |
| エージェントランタイム | 同梱 (Opus 4.7) | 同梱 ([`pi-ai`][piai]) | **ユーザーの既存 CLI に委任** |
| Skill | プロプライエタリ | 12 個のカスタム TS モジュール + `SKILL.md` | **31 個のファイルベース [`SKILL.md`][skill] バンドル、ドロップイン** |
| Design System | プロプライエタリ | `DESIGN.md`v0.2 ロードマップ) | **`DESIGN.md` × 72 種、すぐに利用可能** |
| プロバイダ柔軟性 | Anthropic のみ | 7+[`pi-ai`][piai] | **11 種の CLI adapter + OpenAI 互換 BYOK プロキシ** |
| 初期化質問フォーム | ❌ | ❌ | **✅ ハードルール、turn 1** |
| ディレクションピッカー | ❌ | ❌ | **✅ 5 つの決定論的ディレクション** |
| ライブ todo 進捗 + tool ストリーム | ❌ | ✅ | **✅**UX パターンは open-codesign 由来) |
| サンドボックス iframe プレビュー | ❌ | ✅ | **✅**(パターンは open-codesign 由来) |
| Claude Design ZIP インポート | n/a | ❌ | **`POST /api/import/claude-design` — Anthropic の中断箇所から編集続行** |
| コメントモード精密編集 | ❌ | ✅ | 🚧 ロードマップopen-codesign から移植予定) |
| AI 出力 tweaks パネル | ❌ | ✅ | 🟡 部分的 — [`tweaks` Skill](skills/tweaks/) は出荷済み、専用チャットサイドパネル UX はロードマップ |
| ファイルシステムレベルのワークスペース | ❌ | 部分的Electron サンドボックス) | **✅ 実 cwd、実ツール、SQLite 永続化projects · conversations · messages · tabs · templates** |
| 五次元セルフ評価 | ❌ | ❌ | **✅ emit 前ゲート** |
| Artifact lint | ❌ | ❌ | **`POST /api/artifacts/lint` — findings をエージェントにフィードバック** |
| Sidecar IPC + ヘッドレスデスクトップ | ❌ | ❌ | **✅ スタンプ付きプロセス + `tools-dev inspect desktop status \| eval \| screenshot`** |
| エクスポート形式 | 限定的 | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTXエージェント駆動/ ZIP / Markdown** |
| PPT Skill 再利用 | N/A | 組み込み | **[`guizang-ppt-skill`][guizang] がドロップインdeck モードのデフォルト)** |
| 最低課金 | Pro / Max / Team | BYOK | **BYOK — 任意の OpenAI 互換 `baseUrl` を貼り付け** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## 対応 Coding Agent
Daemon 起動時に `PATH` から自動検出。設定不要。ストリーミングディスパッチは [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) の `AGENT_DEFS` に、CLI ごとのパーサーも同ディレクトリにあります。モデルリストは `<bin> --list-models` / `<bin> models` / ACP ハンドシェイクのいずれかで取得するか、CLI がリスト機能を持たない場合は厳選フォールバックリストを使用。
| エージェント | バイナリ | ストリーム形式 | argv 形態(組み立て済みプロンプトパス) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json`(型付きイベント) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + `codex` パーサー | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--model …] [-c model_reasoning_effort=…]`(プロンプトは stdin |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + `gemini` パーサー | `GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo [--model …]`(プロンプトは stdin |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + `opencode` パーサー | `opencode run --format json --dangerously-skip-permissions [--model …] -`(プロンプトは stdin |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + `cursor-agent` パーサー | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -`(プロンプトは stdin |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain`(生 stdout チャンク) | `qwen --yolo [--model …] -`(プロンプトは stdin |
| Qoder CLI | `qodercli` | `qoder-stream-json`(型付きイベント) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]`(プロンプトは stdin |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json`(型付きイベント) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc`Agent Client Protocol | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc`stdio JSON-RPC | `pi --mode rpc [--model …] [--thinking …]`(プロンプトは RPC `prompt` コマンドで送信) |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain`(生 stdout チャンク) | `deepseek exec --auto [--model …] <prompt>` |
| **OpenAI 互換 BYOK** | n/a | SSE パススルー | `POST /api/proxy/stream``<baseUrl>/v1/chat/completions`loopback / link-local / RFC1918 を拒否 |
新しい CLI の追加 = [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) にエントリを 1 つ追加。ストリーム形式は `claude-stream-json` / `qoder-stream-json` / `copilot-stream-json` / `json-event-stream`CLI ごとの `eventParser` 付き)/ `acp-json-rpc` / `pi-rpc` / `plain` から選択。
## 参考文献 & 系譜
本リポジトリが参考にしたすべての外部プロジェクト。各リンクからソースを確認できます。
| プロジェクト | 本リポジトリでの役割 |
|---|---|
| [`Claude Design`][cd] | 本リポジトリがオープンソース代替を提供するクローズドソースプロダクト。 |
| [**`alchaincyf/huashu-design`**(花叔の画術)](https://github.com/alchaincyf/huashu-design) | デザイン哲学のコア。Junior-Designer ワークフロー、5 ステップブランドアセットプロトコル、anti-AI-slop チェックリスト、五次元セルフ評価、ディレクションピッカーの背後にある「5 流派 × 20 のデザイン哲学」ライブラリ — すべて [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) と [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts) に蒸留。 |
| [**`op7418/guizang-ppt-skill`**(歸藏)][guizang] | Magazine-web-PPT Skill を [`skills/guizang-ppt/`](skills/guizang-ppt/) にそのまま同梱、元の LICENSE 保持。Deck モードのデフォルト。P0/P1/P2 チェックリスト文化を他のすべての Skill に波及。 |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + adapter アーキテクチャ。PATH スキャンによるエージェント検出、ローカル daemon を唯一の特権プロセスとする思想、agent-as-teammate の世界観。モデルを採用、コードは vendor せず。 |
| [**`OpenCoworkAI/open-codesign`**][ocod] | 初のオープンソース Claude-Design 代替、最も近い同類。採用済み UX パターン:ストリーミング artifact ループ、サンドボックス iframe プレビューReact 18 + Babel 同梱、ライブエージェントパネルtodo + tool calls + 中断可能、5 種エクスポート形式リストHTML/PDF/PPTX/ZIP/Markdown、ローカルファーストストレージハブ、`SKILL.md` テイスト注入。ロードマップ上の UX パターンコメントモード精密編集、AI 出力 tweaks パネル。**[`pi-ai`][piai] は意図的に vendor していません** — open-codesign はそれをエージェントランタイムとして同梱していますが、私たちはユーザーの既存 CLI に委任します。 |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9 セクション `DESIGN.md` スキーマのソース。69 のプロダクトシステムが [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 経由でインポート。 |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | 複数エージェント CLI 間の symlink ベース Skill 配布のインスピレーション源。 |
| [Claude Code skills][skill] | `SKILL.md` 規約をそのまま採用 — 任意の Claude Code Skill を `skills/` に入れれば daemon が認識。 |
詳細な系譜(各プロジェクトから何を採用し、何を意図的に採用しなかったか)は [`docs/references.md`](docs/references.md) にあります。
## ロードマップ
- [x] Daemon + エージェント検出11 種 CLI adapter+ Skill レジストリ + Design System カタログ
- [x] Web アプリ + チャット + 質問フォーム + 5 つのディレクションピッカー + todo 進捗 + サンドボックスプレビュー
- [x] 31 個の Skill + 72 種の Design System + 5 つのビジュアルディレクション + 5 つのデバイスフレーム
- [x] SQLite バックドの projects · conversations · messages · tabs · templates
- [x] OpenAI 互換 BYOK プロキシ(`/api/proxy/stream`SSRF 防御付き
- [x] Claude Design ZIP インポート(`/api/import/claude-design`
- [x] Sidecar プロトコル + Electron デスクトップ + IPC 自動化STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN
- [x] Artifact lint API + 五次元セルフ評価 emit 前ゲート
- [ ] コメントモード精密編集(要素をクリック → 指示 → パッチ)— パターンは [`open-codesign`][ocod] から
- [ ] AI 出力 tweaks パネル UX — ビルディングブロック([`tweaks` Skill](skills/tweaks/))は出荷済み、チャット統合パネルは未完
- [ ] Vercel + トンネルデプロイレシピTopology B
- [ ] ワンコマンド `npx od init``DESIGN.md` 付きプロジェクトをスキャフォールド
- [ ] Skill マーケットプレイス(`od skills install <github-repo>`)と `od skill add | list | remove | test` CLI サーフェス([`docs/skills-protocol.md`](docs/skills-protocol.md) にドラフトあり、daemon 実装は未着手)
- [x] `apps/packaged/` からの配布可能 Electron ビルド — macOSApple Siliconと Windowsx64のダウンロードは [open-design.ai](https://open-design.ai/) および [GitHub リリースページ](https://github.com/nexu-io/open-design/releases) から
フェーズ別デリバリー計画 → [`docs/roadmap.md`](docs/roadmap.md)。
## プロジェクトの状態
これは初期実装です — クローズドループ(検出 → Skill + Design System を選択 → チャット → `<artifact>` をパース → プレビュー → 保存)はエンドツーエンドで動作しています。プロンプトスタックと Skill ライブラリが最も価値の大きい部分であり、安定しています。コンポーネントレベルの UI は日々更新中です。
## Star をお願いします
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="Open Design に Star を — github.com/nexu-io/open-design" width="100%" /></a>
</p>
30 分の時間を節約できたなら、★ をお願いします。Star は家賃を払いませんが、次のデザイナー、エージェント、コントリビューターに「この実験は注目する価値がある」と伝えます。1 クリック、3 秒、リアルなシグナル:[github.com/nexu-io/open-design](https://github.com/nexu-io/open-design)。
## コントリビューション
Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の高いコントリビューションは通常、フォルダ 1 つ、Markdown ファイル 1 つ、または PR サイズの adapter です:
- **Skill を追加** — [`skills/`](skills/) にフォルダをドロップし、[`SKILL.md`][skill] 規約に従う。
- **Design System を追加** — [`design-systems/<brand>/`](design-systems/) に 9 セクションスキーマの `DESIGN.md` をドロップ。
- **新しい coding-agent CLI を接続** — [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) にエントリを 1 つ追加。
完全なワークフロー、マージ基準、コードスタイル、受け入れない PR の種類 → [`CONTRIBUTING.ja-JP.md`](CONTRIBUTING.ja-JP.md)[English](CONTRIBUTING.md) · [Deutsch](CONTRIBUTING.de.md) · [Français](CONTRIBUTING.fr.md) · [简体中文](CONTRIBUTING.zh-CN.md))。
## コントリビューター
コード、ドキュメント、フィードバック、新 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-06" 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) ラベルがエントリポイントです。
## リポジトリ活動
<picture>
<img alt="Open Design リポジトリメトリクス" src="docs/assets/github-metrics.svg" />
</picture>
上記の SVG は [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) が [`lowlighter/metrics`](https://github.com/lowlighter/metrics) を使って毎日自動再生成しています。すぐに更新したい場合は **Actions** タブから手動トリガーしてください。より充実したプラグインtraffic、follow-up time など)を有効にするには、リポジトリシークレットに細粒度 PAT を `METRICS_TOKEN` として追加してください。
## Star History
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
</picture>
</a>
カーブが上向きなら — それが私たちの求めるシグナルです。★ で後押ししてください。
## ライセンス
Apache-2.0。同梱の [`skills/guizang-ppt/`](skills/guizang-ppt/) は元の [LICENSE](skills/guizang-ppt/LICENSE)MITと [op7418](https://github.com/op7418) の帰属表示を保持しています。

751
README.ko.md Normal file
View File

@@ -0,0 +1,751 @@
# 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만 빠진 동일한 루프를 돌립니다.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — 노트북 위의 에이전트와 함께 설계하는 표지" width="100%" />
</p>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="다운로드" src="https://img.shields.io/badge/%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-open--design.ai-ff6b35?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#지원하는-코딩-에이전트"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#디자인-시스템"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#내장-skills"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <b>한국어</b> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a></p>
---
## 왜 만들었는가
Anthropic의 [Claude Design][cd](2026-04-17 출시, Opus 4.7 기반)은 LLM이 장문의 글쓰기를 멈추고 디자인 산출물을 직접 내놓기 시작했을 때 어떤 일이 일어나는지 보여주었습니다. 순식간에 화제가 되었지만, 여전히 **클로즈드 소스**, 유료, 클라우드 전용, Anthropic 모델과 Anthropic 내부 skill에 종속된 상태입니다. 체크아웃도, 자가 호스팅도, Vercel 배포도, 에이전트 교체도 불가능합니다.
**Open Design(OD)은 그 오픈소스 대안입니다.** 동일한 루프, 동일한 '아티팩트 우선' 사고방식, 벤더 종속 없음. 우리는 에이전트를 만들지 않습니다 — 가장 강력한 코딩 에이전트는 이미 여러분의 노트북에 있습니다. 우리는 그것을 skill 기반 디자인 워크플로에 연결할 뿐입니다. 로컬에서는 `pnpm tools-dev`로 실행하고, 웹 레이어는 Vercel에 배포할 수 있으며, 모든 레이어에서 BYOK(자체 키 사용)가 가능합니다.
`시드 라운드를 위한 매거진 스타일 피치덱 만들어줘`라고 입력하세요. 모델이 픽셀 하나 그리기 전에 **초기화 질문 폼**이 먼저 등장합니다. 에이전트는 5가지 엄선된 시각적 방향 중 하나를 선택합니다. 실시간 `TodoWrite` 계획 카드가 UI에 스트리밍됩니다. Daemon이 디스크에 실제 프로젝트 폴더를 생성하며, seed 템플릿, 레이아웃 라이브러리, 자가 점검 체크리스트가 포함됩니다. 에이전트는 **pre-flight 점검을 반드시 수행**하고, 자신의 출력물에 대해 **5차원 검토**를 실행하며, 몇 초 후 샌드박스 iframe에 렌더링되는 단일 `<artifact>`를 내보냅니다.
이건 "AI가 디자인을 시도한다"가 아닙니다. 프롬프트 스택에 의해 훈련된 AI가 사용 가능한 파일시스템, 결정론적 팔레트 라이브러리, 체크리스트 문화를 갖춘 수석 디자이너처럼 동작하는 것입니다 — Claude Design이 세운 기준 그대로, 하지만 오픈소스로, 여러분의 것으로.
OD는 네 개의 오픈소스 프로젝트의 어깨 위에 서 있습니다:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — 디자인 철학의 나침반. Junior-Designer 워크플로, 5단계 브랜드 에셋 프로토콜, anti-AI-slop 체크리스트, 5차원 자기 검토, 그리고 방향 선택기 뒤의 "5가지 학파 × 20가지 디자인 철학" 아이디어 — 모두 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)에 녹아들었습니다.
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — 덱 모드. [`skills/guizang-ppt/`](skills/guizang-ppt/) 아래에 원본 그대로 번들됨, 원 LICENSE 보존; 매거진 레이아웃, WebGL hero, P0/P1/P2 체크리스트.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX의 북극성이자 가장 가까운 동류. 최초의 오픈소스 Claude-Design 대안. 스트리밍 아티팩트 루프, 샌드박스 iframe 미리보기 패턴(React 18 + Babel 내장), 실시간 에이전트 패널(todos + tool calls + 중단 가능한 생성), 5가지 내보내기 형식(HTML / PDF / PPTX / ZIP / Markdown)을 차용했습니다. 폼 팩터에서는 의도적으로 차별화했습니다 — 그쪽은 [`pi-ai`][piai]를 번들링한 Electron 데스크탑 앱이고, 우리는 에이전트 런타임을 이미 설치된 CLI에 **위임**하는 웹앱 + 로컬 daemon입니다.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — Daemon 및 런타임 아키텍처. PATH 스캔 방식의 에이전트 감지, 단일 특권 프로세스로서의 로컬 daemon, 에이전트-동료 세계관.
## 한눈에 보기
| | 제공 내용 |
|---|---|
| **코딩 에이전트 CLI(16개)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — `PATH`에서 자동 감지, 한 번의 클릭으로 전환 |
| **BYOK 폴백** | OpenAI 호환 프록시 `/api/proxy/stream``baseUrl` + `apiKey` + `model`만 붙여 넣으면 어떤 벤더(Anthropic-via-OpenAI 어댑터, DeepSeek, Groq, MiMo, OpenRouter, 자체 호스팅 vLLM, 또는 OpenAI 호환 프로바이더 무엇이든)든 엔진이 됩니다. daemon 경계에서 내부 IP / SSRF를 차단합니다. |
| **내장 디자인 시스템** | **72개** — 2개의 수작업 스타터 + [`awesome-design-md`][acd2]에서 가져온 70개의 제품 시스템(Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu …) |
| **내장 Skill** | **31개**`prototype` 모드 27개(web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs …) + `deck` 모드 4개(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). picker에서 `scenario`로 그룹화: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **미디어 생성** | 이미지 · 비디오 · 오디오 surface가 디자인 루프와 함께 작동합니다. **gpt-image-2**(Azure / OpenAI)로 포스터, 아바타, 인포그래픽, 일러스트 도시 지도 · **Seedance 2.0**(ByteDance)로 15초 시네마틱 text-to-video / image-to-video · **HyperFrames**([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes))로 HTML→MP4 모션 그래픽(제품 리빌, 키네틱 타이포그래피, 데이터 차트, 소셜 오버레이, 로고 아웃트로). **93개**의 즉시 복제 가능한 prompt 갤러리 — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — 모두 [`prompt-templates/`](prompt-templates/) 아래에 미리보기 썸네일과 출처 표기와 함께 배치. 채팅 입구는 코드와 동일; 실제 `.mp4` / `.png`이 프로젝트 워크스페이스에 chip으로 떨어집니다. |
| **시각적 방향** | 5가지 엄선된 학파(Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — 각각 결정론적 OKLch 팔레트 + 폰트 스택 제공([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **기기 프레임** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — 픽셀 정확도, skill 간 공유, [`assets/frames/`](assets/frames/)에 통합 |
| **에이전트 런타임** | 로컬 daemon이 프로젝트 폴더에서 CLI를 실행 — 에이전트가 실제 디스크 환경에 대한 실제 `Read`, `Write`, `Bash`, `WebFetch` 도구 사용; 모든 어댑터에 Windows `ENAMETOOLONG` 폴백(stdin / 임시 prompt 파일) |
| **임포트** | [Claude Design][cd] 익스포트 ZIP을 환영 다이얼로그에 드롭하면 `POST /api/import/claude-design`이 진짜 프로젝트로 풀어주고, 로컬 에이전트는 Anthropic이 멈춘 지점에서 그대로 편집을 이어받습니다. |
| **영속성** | `.od/app.sqlite`의 SQLite: projects · conversations · messages · tabs · 사용자 templates. 내일 다시 열면 todo 카드와 열린 파일 모두 어제 그 자리. |
| **라이프사이클** | 단일 입구 `pnpm tools-dev`(start / stop / run / status / logs / inspect / check) — 타입화된 sidecar 스탬프로 daemon + web(+ desktop) 구동 |
| **데스크탑** | 선택적 Electron 셸: 샌드박스 렌더러 + sidecar IPC(STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN) — 같은 채널이 `tools-dev inspect desktop screenshot`을 구동해 E2E를 돌립니다 |
| **배포 대상** | 로컬 (`pnpm tools-dev`) · Vercel 웹 레이어 · macOS (Apple Silicon)와 Windows (x64)용 패키지된 Electron 데스크톱 앱 — [open-design.ai](https://open-design.ai/) 또는 [최신 릴리스](https://github.com/nexu-io/open-design/releases)에서 다운로드 |
| **라이선스** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
## 데모
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · 진입 화면" /><br/>
<sub><b>진입 화면</b> — skill 선택, 디자인 시스템 선택, 브리프 입력. 프로토타입, 덱, 모바일 앱, 대시보드, 에디토리얼 페이지를 위한 동일한 인터페이스.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · Turn-1 초기화 폼" /><br/>
<sub><b>Turn-1 초기화 폼</b> — 모델이 픽셀 하나 그리기 전에 OD가 브리프를 확정합니다: 화면, 대상, 톤, 브랜드 컨텍스트, 규모. 30초의 라디오 버튼 클릭이 30분의 수정 작업을 대체합니다.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · 방향 선택기" /><br/>
<sub><b>방향 선택기</b> — 사용자에게 브랜드가 없을 때, 에이전트가 두 번째 폼을 띄워 5가지 엄선된 방향(Monocle / Modern Minimal / Tech Utility / Brutalist / Soft Warm)을 제시합니다. 라디오 하나 클릭 → 결정론적 팔레트 + 폰트 스택, 모델 자유 재량 없음.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · 실시간 할 일 진행" /><br/>
<sub><b>실시간 할 일 진행</b> — 에이전트의 계획이 실시간 카드로 스트리밍됩니다. <code>in_progress</code> → <code>completed</code> 업데이트가 실시간으로 반영됩니다. 작업 중에도 저렴한 비용으로 방향을 조정할 수 있습니다.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · 샌드박스 미리보기" /><br/>
<sub><b>샌드박스 미리보기</b> — 모든 <code>&lt;artifact&gt;</code>가 깨끗한 srcdoc iframe에서 렌더링됩니다. 파일 워크스페이스에서 바로 편집 가능; HTML, PDF, ZIP으로 다운로드 가능.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · 72개 시스템 라이브러리" /><br/>
<sub><b>72개 시스템 라이브러리</b> — 모든 제품 시스템이 4색 시그니처를 표시합니다. 클릭하면 전체 <code>DESIGN.md</code>, 색상 견본 그리드, 라이브 쇼케이스를 볼 수 있습니다.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · 매거진 덱" /><br/>
<sub><b>덱 모드(guizang-ppt)</b> — 번들된 <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a>이 그대로 들어갑니다. 매거진 레이아웃, WebGL 히어로 배경, 단일 파일 HTML 출력, PDF 내보내기.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · 모바일 프로토타입" /><br/>
<sub><b>모바일 프로토타입</b> — 픽셀 정확도의 iPhone 15 Pro 크롬(Dynamic Island, 상태바 SVG, 홈 인디케이터). 다화면 프로토타입은 공유 <code>/frames/</code> 에셋을 사용하므로 에이전트가 폰을 다시 그릴 필요가 없습니다.</sub>
</td>
</tr>
</table>
## 내장 Skills
**31개의 skill이 기본 제공됩니다.** 각각은 Claude Code의 [`SKILL.md`][skill] 규약을 따르는 [`skills/`](skills/) 아래의 폴더이며, daemon이 그대로 파싱하는 확장된 `od:` 프론트매터를 포함합니다 — `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)).
두 가지 최상위 **mode**가 카탈로그를 떠받칩니다: **`prototype`**(27개 — 매거진 랜딩부터 폰 화면, PM 스펙 문서까지 단일 페이지 아티팩트로 렌더링되는 모든 것) 그리고 **`deck`**(4개 — 덱 프레임워크 크롬을 입은 수평 스와이프 프레젠테이션). picker가 그룹화에 사용하는 필드는 **`scenario`**: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
### 쇼케이스 예시
시각적으로 가장 눈에 띄어 먼저 실행해 볼 skill들입니다. 각각은 저장소에서 바로 열 수 있는 실제 `example.html`을 제공합니다 — 인증 없이, 설정 없이, 에이전트가 무엇을 생산하는지 미리 확인할 수 있습니다.
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>소비자용 데이팅 / 매칭 대시보드 — 좌측 레일 내비게이션, 티커 바, KPI, 30일 상호 매칭 차트, 에디토리얼 타이포그래피.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>2페이지 디지털 e-가이드 — 표지(제목, 저자, TOC 티저) + 풀 쿼트 및 단계 목록이 있는 레슨 스프레드. 크리에이터 / 라이프스타일 톤.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>브랜드 제품 출시 HTML 이메일 — 마스트헤드, 히어로 이미지, 헤드라인 락업, CTA, 스펙 그리드. 중앙 단일 컬럼, 테이블 폴백 안전.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>다크 쇼케이스 스테이지의 3화면 게임화 모바일 앱 프로토타입 — 표지, 오늘의 퀘스트(XP 리본 + 레벨 바), 퀘스트 상세.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>3화면 모바일 온보딩 플로우 — 스플래시, 가치 제안, 로그인. 상태바, 스와이프 점, 기본 CTA.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>루핑 CSS 애니메이션의 단일 프레임 모션 디자인 히어로 — 회전 타입 링, 애니메이션 글로브, 째깍거리는 타이머. HyperFrames 핸드오프 준비 완료.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>3장의 1080×1080 소셜 미디어 캐러셀 — 시리즈를 가로지르는 표시 헤드라인이 있는 영화적 패널, 브랜드 마크, 루프 어포던스.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>픽셀 / 8비트 애니메이션 설명 슬라이드 — 전면 크림 스테이지, 애니메이션 픽셀 마스코트, 역동적인 일본어 표시 타이포그래피, 루핑 CSS 키프레임.</sub>
</td>
</tr>
</table>
### 디자인 & 마케팅 표면(prototype 모드)
| Skill | 플랫폼 | Scenario | 생산물 |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | 데스크탑 | design | 단일 페이지 HTML — 랜딩, 마케팅, 히어로 페이지(prototype 기본) |
| [`saas-landing`](skills/saas-landing/) | 데스크탑 | marketing | Hero / features / pricing / CTA 마케팅 레이아웃 |
| [`dashboard`](skills/dashboard/) | 데스크탑 | operation | 사이드바 + 데이터 밀집 레이아웃의 어드민 / 분석 |
| [`pricing-page`](skills/pricing-page/) | 데스크탑 | sale | 독립형 가격 + 비교 테이블 |
| [`docs-page`](skills/docs-page/) | 데스크탑 | engineering | 3컬럼 문서 레이아웃 |
| [`blog-post`](skills/blog-post/) | 데스크탑 | marketing | 에디토리얼 장문 |
| [`mobile-app`](skills/mobile-app/) | 모바일 | design | iPhone 15 Pro / Pixel 프레임 앱 화면 |
| [`mobile-onboarding`](skills/mobile-onboarding/) | 모바일 | design | 다중 화면 모바일 온보딩 플로우(스플래시 · 가치 제안 · 로그인) |
| [`gamified-app`](skills/gamified-app/) | 모바일 | personal | 3화면 게임화 모바일 앱 프로토타입 |
| [`email-marketing`](skills/email-marketing/) | 데스크탑 | marketing | 브랜드 제품 출시 HTML 이메일(테이블 폴백 안전) |
| [`social-carousel`](skills/social-carousel/) | 데스크탑 | marketing | 1080×1080 3장 소셜 캐러셀 |
| [`magazine-poster`](skills/magazine-poster/) | 데스크탑 | marketing | 단일 페이지 매거진 스타일 포스터 |
| [`motion-frames`](skills/motion-frames/) | 데스크탑 | marketing | 루핑 CSS 애니메이션의 모션 디자인 히어로 |
| [`sprite-animation`](skills/sprite-animation/) | 데스크탑 | marketing | 픽셀 / 8비트 애니메이션 설명 슬라이드 |
| [`dating-web`](skills/dating-web/) | 데스크탑 | personal | 소비자용 데이팅 대시보드 목업 |
| [`digital-eguide`](skills/digital-eguide/) | 데스크탑 | marketing | 2페이지 디지털 e-가이드(표지 + 레슨) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | 데스크탑 | design | 손그림 아이데이션 스케치 — "회색 블록이라도 일찍 보여주기" 패스를 위한 |
| [`critique`](skills/critique/) | 데스크탑 | design | 5차원 자기 검토 점수표(Philosophy · Hierarchy · Detail · Function · Innovation) |
| [`tweaks`](skills/tweaks/) | 데스크탑 | design | AI 송출 tweaks 패널 — 모델이 직접 조정할 만한 파라미터를 떠올림 |
### 덱 표면(deck 모드)
| Skill | 기본 | 생산물 |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | 덱 **기본** | 매거진 스타일 웹 PPT — [op7418/guizang-ppt-skill][guizang]에서 그대로 번들됨, 원 LICENSE 보존 |
| [`simple-deck`](skills/simple-deck/) | — | 미니멀 수평 스와이프 덱 |
| [`replit-deck`](skills/replit-deck/) | — | 제품 워크스루 덱(Replit 스타일) |
| [`weekly-update`](skills/weekly-update/) | — | 팀 주간 업데이트(진행 · 블로커 · 다음 단계)를 스와이프 덱으로 |
### 사무 & 운영 표면(prototype 모드, 문서 지향 시나리오)
| Skill | Scenario | 생산물 |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | TOC + 의사결정 로그가 있는 PM 스펙 문서 |
| [`team-okrs`](skills/team-okrs/) | product | OKR 스코어시트 |
| [`meeting-notes`](skills/meeting-notes/) | operation | 회의 의사결정 로그 |
| [`kanban-board`](skills/kanban-board/) | operation | 보드 스냅샷 |
| [`eng-runbook`](skills/eng-runbook/) | engineering | 장애 런북 |
| [`finance-report`](skills/finance-report/) | finance | 임원 재무 요약 |
| [`invoice`](skills/invoice/) | finance | 단일 페이지 인보이스 |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | 역할 온보딩 계획 |
skill 추가는 폴더 하나면 됩니다. [`docs/skills-protocol.md`](docs/skills-protocol.md)에서 확장 프론트매터를 읽고, 기존 skill을 포크하고, daemon을 재시작하면 picker에 나타납니다. 카탈로그 엔드포인트는 `GET /api/skills`이며, 스킬별 시드 조립(template + 사이드 파일 references)은 `GET /api/skills/:id/example`에 있습니다.
## 6가지 핵심 아이디어
### 1 · 에이전트를 제공하지 않습니다. 여러분의 것으로 충분합니다.
Daemon은 시작 시 `PATH`에서 [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/mariozechner/pi-ai)를 스캔합니다. 찾은 것들 모두가 후보 디자인 엔진이 됩니다 — stdio를 통해 구동되며 CLI당 하나의 어댑터, 모델 picker에서 즉시 전환 가능. [`multica`](https://github.com/multica-ai/multica)와 [`cc-switch`](https://github.com/farion1231/cc-switch)에서 영감을 받았습니다. CLI가 하나도 설치되어 있지 않다면? `POST /api/proxy/stream`이 spawn만 없는 동일한 파이프라인입니다 — 임의의 OpenAI 호환 `baseUrl` + `apiKey`만 붙여 넣으면 daemon이 SSE 청크를 브라우저로 그대로 전달하며, loopback / link-local / RFC1918 목적지는 경계에서 거부됩니다.
### 2 · Skill은 파일이지 플러그인이 아닙니다.
Claude Code의 [`SKILL.md` 규약](https://docs.anthropic.com/en/docs/claude-code/skills)을 따라 각 skill은 `SKILL.md` + `assets/` + `references/`입니다. [`skills/`](skills/)에 폴더를 드롭하고 daemon을 재시작하면 picker에 나타납니다. 번들된 `magazine-web-ppt`는 [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill)을 그대로 커밋한 것입니다 — 원본 라이선스와 저작권 표시 보존.
### 3 · 디자인 시스템은 테마 JSON이 아닌 이식 가능한 Markdown입니다.
[`VoltAgent/awesome-design-md`][acd2]의 9섹션 `DESIGN.md` 스키마 — color, typography, spacing, layout, components, motion, voice, brand, anti-patterns. 모든 아티팩트가 활성 시스템에서 읽습니다. 시스템 전환 → 다음 렌더에 새 토큰 사용. 드롭다운에는 **Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Apple, Anthropic, Cursor, Supabase, Figma, Resend, Raycast, Lovable, Cohere, Mistral, ElevenLabs, X.AI, Spotify, Webflow, Sanity, PostHog, Sentry, MongoDB, ClickHouse, Cal, Replicate, Clay, Composio, Xiaohongshu …** 총 72개가 있습니다.
### 4 · 초기화 질문 폼이 수정 작업의 80%를 막아줍니다.
OD의 프롬프트 스택에는 `RULE 1`이 하드코딩되어 있습니다: 모든 새 디자인 브리프는 코드 대신 `<question-form id="discovery">`로 시작합니다. 화면 · 대상 · 톤 · 브랜드 컨텍스트 · 규모 · 제약 조건. 긴 브리프라도 시각적 톤, 색상 입장, 규모 같은 디자인 결정 사항은 여전히 열려 있습니다 — 폼이 정확히 이것들을 30초 안에 고정합니다. 잘못된 방향의 비용은 한 번의 채팅 라운드이지, 완성된 덱 하나가 아닙니다.
이것이 [`huashu-design`](https://github.com/alchaincyf/huashu-design)에서 추출한 **Junior-Designer 모드**입니다: 미리 일괄 질문하고, 일찍 가시적인 것을 보여주며(와이어프레임에 회색 블록이라도), 사용자가 저렴한 비용으로 방향을 바꿀 수 있도록 합니다. 브랜드 에셋 프로토콜(위치 파악 · 다운로드 · `grep` hex · `brand-spec.md` 작성 · 발성)과 결합하면, 출력이 "AI 자유 창작"에서 "그리기 전에 주의를 기울인 디자이너"처럼 느껴지게 되는 가장 큰 이유입니다.
### 5 · Daemon은 에이전트가 여러분의 노트북에 있는 것처럼 느끼게 합니다. 실제로 그러니까요.
Daemon은 프로젝트의 아티팩트 폴더 `.od/projects/<id>/``cwd`를 설정해 CLI를 spawn합니다. 에이전트는 실제 파일시스템에 대한 실제 도구인 `Read`, `Write`, `Bash`, `WebFetch`를 사용합니다. skill의 `assets/template.html``Read`하고, CSS에서 hex 값을 `grep`하고, `brand-spec.md`를 작성하고, 생성된 이미지를 저장하고, `.pptx` / `.zip` / `.pdf` 파일을 생성할 수 있습니다 — 이 파일들은 턴이 끝날 때 파일 워크스페이스에 다운로드 칩으로 나타납니다. 세션, 대화, 메시지, 탭은 로컬 SQLite DB에 영구 저장됩니다 — 내일 프로젝트를 열면 에이전트의 할 일 카드가 어제 멈춘 곳에 그대로 있습니다.
### 6 · 프롬프트 스택 자체가 제품입니다.
전송 시 구성되는 것은 "system + user"가 아닙니다. 다음과 같습니다:
```
DISCOVERY 지시문 (turn-1 폼, turn-2 브랜드 분기, TodoWrite, 5차원 검토)
+ 신원 헌장 (OFFICIAL_DESIGNER_PROMPT, anti-AI-slop, junior-pass)
+ 활성 DESIGN.md (72개 시스템 사용 가능)
+ 활성 SKILL.md (31개 skill 사용 가능)
+ 프로젝트 메타데이터 (kind, fidelity, speakerNotes, animations, inspiration ids)
+ skill 사이드 파일 (pre-flight 자동 주입: assets/template.html + references/*.md 읽기)
+ (덱 kind, skill seed 없음) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
```
모든 레이어는 조합 가능합니다. 모든 레이어는 편집 가능한 파일입니다. 실제 계약을 보려면 [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts)와 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)를 읽으세요.
## 아키텍처
```
┌────────────── 브라우저(Next.js 16) ─────────────────────────────┐
│ 채팅 · 파일 워크스페이스 · iframe 미리보기 · 설정 · 임포트 │
└──────────────┬───────────────────────────────┬────────────────┘
│ /api/*(dev에서 rewrite) │
▼ ▼
┌─────────────────────────────────┐ /api/proxy/stream (SSE)
│ 로컬 daemon(Express + SQLite) │ ─→ 임의의 OpenAI 호환
│ │ 엔드포인트(BYOK)
│ /api/agents /api/skills│ SSRF 차단 포함
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (정적) /frames (정적)
│ 선택적 sidecar IPC: /tmp/open-design/ipc/<ns>/<app>.sock
│ (STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN)
└─────────┬───────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · gemini · opencode · cursor-agent · qwen │
│ qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) │
│ SKILL.md + DESIGN.md 읽기, 디스크에 아티팩트 쓰기 │
└──────────────────────────────────────────────────────────────────┘
```
| 레이어 | 스택 |
|---|---|
| 프론트엔드 | Next.js 16 App Router + React 18 + TypeScript, Vercel 배포 가능 |
| Daemon | Node 24 · Express · SSE 스트리밍 · `better-sqlite3`; 테이블: `projects` · `conversations` · `messages` · `tabs` · `templates` |
| 에이전트 전송 | `child_process.spawn`; 타입 이벤트 파서: `claude-stream-json`(Claude Code), `qoder-stream-json` (Qoder CLI), `copilot-stream-json`(Copilot), `json-event-stream` + 각 CLI 파서(Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc`(Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe via Agent Client Protocol), `pi-rpc`(Pi via stdio JSON-RPC), `plain`(Qwen Code / DeepSeek TUI) |
| BYOK 프록시 | `POST /api/proxy/stream` → OpenAI 호환 `/v1/chat/completions`, SSE 통과; daemon 경계에서 loopback / link-local / RFC1918 호스트 거부 |
| 저장소 | `.od/projects/<id>/`의 평문 파일 + `.od/app.sqlite`의 SQLite(gitignore됨, 자동 생성). 테스트 격리를 위해 `OD_DATA_DIR`로 루트 변경 가능 |
| 미리보기 | `srcdoc`를 통한 샌드박스 iframe + 스킬별 `<artifact>` 파서([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
| 내보내기 | HTML(인라인 에셋) · PDF(브라우저 인쇄, deck-aware) · PPTX(에이전트 주도 + skill) · ZIP(archiver) · Markdown |
| 라이프사이클 | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`; 포트는 `--daemon-port` / `--web-port`, 네임스페이스는 `--namespace` |
| 데스크탑(선택) | Electron 셸 — sidecar IPC를 통해 web URL 발견, 포트 추측 없음; 같은 채널(`STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN`)이 `tools-dev inspect desktop …`로 E2E 구동 |
## 빠른 시작
### 데스크톱 앱 다운로드 (빌드 불필요)
Open Design을 가장 빠르게 사용해 보는 방법은 사전 빌드된 데스크톱 앱입니다 — Node도, pnpm도, clone도 필요 없습니다:
- **[open-design.ai](https://open-design.ai/)** — 공식 다운로드 페이지
- **[GitHub 릴리스](https://github.com/nexu-io/open-design/releases)**
### 소스에서 실행
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # 10.33.2가 출력되어야 합니다
pnpm install
pnpm tools-dev run web
# tools-dev가 출력한 web URL을 여세요
```
Windows 런처: `tools/launcher/README.md`의 안내에 따라 `OpenDesign.exe`를 직접 빌드하거나 GitHub Releases에서 다운로드하세요. 그런 다음 저장소 루트에 두고 두 번 클릭하면 필요할 때 `pnpm install`을 실행한 뒤 `pnpm tools-dev`로 Open Design을 시작합니다.
환경 요구사항: Node `~24`와 pnpm `10.33.x`. `nvm` / `fnm`은 선택적 보조 도구일 뿐입니다; 사용한다면 `pnpm install` 전에 `nvm install 24 && nvm use 24` 또는 `fnm install 24 && fnm use 24`를 실행하세요.
첫 번째 로드 시:
1. `PATH`에 어떤 에이전트 CLI가 있는지 감지하고 자동으로 하나를 선택합니다.
2. 31개의 skill + 72개의 디자인 시스템을 로드합니다.
3. Anthropic 키를 붙여넣을 수 있는 환영 다이얼로그를 표시합니다(BYOK 폴백 경로에만 필요).
4. **`./.od/`를 자동 생성합니다** — SQLite 프로젝트 DB, 프로젝트별 아티팩트, 저장된 렌더를 위한 로컬 런타임 폴더. `od init` 단계는 없습니다; daemon이 부팅 시 필요한 모든 것을 `mkdir`합니다.
프롬프트를 입력하고 **전송**을 누르면 질문 폼이 도착하고, 채우면 할 일 카드가 스트리밍되고, 아티팩트가 렌더링됩니다. **디스크에 저장** 클릭 또는 프로젝트 ZIP으로 다운로드하세요.
### 첫 실행 상태(`./.od/`)
Daemon은 저장소 루트에 하나의 숨겨진 폴더를 소유합니다. 그 안의 모든 것은 gitignore되고 로컬 머신 전용입니다 — 커밋하지 마세요.
```
.od/
├── app.sqlite ← 프로젝트 · 대화 · 메시지 · 열린 탭
├── artifacts/ ← 일회성 "디스크에 저장" 렌더(타임스탬프)
└── projects/<id>/ ← 프로젝트별 작업 디렉터리, 에이전트의 cwd
```
| 원하는 작업 | 방법 |
|---|---|
| 내용 확인 | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| 초기 상태로 재설정 | `pnpm tools-dev stop`, `rm -rf .od`, `pnpm tools-dev run web` 재실행 |
| 다른 위치로 이동 | 아직 지원되지 않음 — 경로가 저장소 상대 경로로 하드코딩됨 |
전체 파일 맵, 스크립트, 트러블슈팅 → [`QUICKSTART.md`](QUICKSTART.md).
## 저장소 구조
```
open-design/
├── README.md ← 영어
├── README.de.md ← Deutsch
├── README.zh-CN.md ← 简体中文
├── README.ko.md ← 한국어 (이 파일)
├── QUICKSTART.md ← 실행 / 빌드 / 배포 가이드
├── package.json ← pnpm 워크스페이스, 단일 bin: od
├── apps/
│ ├── daemon/ ← Node + Express, 유일한 서버
│ │ ├── src/ ← TypeScript daemon 소스
│ │ │ ├── cli.ts ← `od` bin 소스, dist/cli.js로 컴파일
│ │ │ ├── server.ts ← /api/* 라우트(projects, chat, files, exports)
│ │ │ ├── agents.ts ← PATH 스캐너 + CLI별 argv 빌더
│ │ │ ├── claude-stream.ts ← Claude Code stdout 스트리밍 JSON 파서
│ │ │ ├── skills.ts ← SKILL.md 프론트매터 로더
│ │ │ └── db.ts ← SQLite 스키마(projects/messages/templates/tabs)
│ │ ├── sidecar/ ← tools-dev daemon sidecar 래퍼
│ │ └── tests/ ← daemon 패키지 테스트
│ │
│ └── web/ ← Next.js 16 App Router + React 클라이언트
│ ├── app/ ← App Router 진입점
│ ├── next.config.ts ← 개발 rewrite + 프로덕션 정적 내보내기 to out/
│ └── src/ ← React + TypeScript 클라이언트 모듈
│ ├── App.tsx ← 라우팅, 부트스트랩, 설정
│ ├── components/ ← 채팅, 작성기, 선택기, 미리보기, 스케치, …
│ ├── prompts/
│ │ ├── system.ts ← composeSystemPrompt(base, skill, DS, metadata)
│ │ ├── discovery.ts ← turn-1 폼 + turn-2 분기 + 5차원 검토
│ │ └── directions.ts ← 5가지 시각적 방향 × OKLch 팔레트 + 폰트 스택
│ ├── artifacts/ ← 스트리밍 <artifact> 파서 + 매니페스트
│ ├── runtime/ ← iframe srcdoc, 마크다운, 내보내기 헬퍼
│ ├── providers/ ← daemon SSE + BYOK API 전송
│ └── state/ ← config + 프로젝트(localStorage + daemon 백업)
├── e2e/ ← Playwright UI + 외부 통합/Vitest 하네스
├── packages/
│ ├── contracts/ ← 공유 web/daemon app contracts
│ ├── sidecar-proto/ ← Open Design sidecar protocol contract
│ ├── sidecar/ ← generic sidecar runtime primitives
│ └── platform/ ← generic process/platform primitives
├── skills/ ← 31개 SKILL.md skill 번들(27 prototype + 4 deck)
│ ├── web-prototype/ ← prototype 기본
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← deck 모드
│ └── guizang-ppt/ ← 번들된 magazine-web-ppt(덱 기본)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72개 DESIGN.md 시스템
│ ├── default/ ← Neutral Modern(스타터)
│ ├── warm-editorial/ ← Warm Editorial(스타터)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md ← 카탈로그 개요
├── assets/
│ └── frames/ ← 공유 기기 프레임(스킬 간 사용)
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ └── deck-framework.html ← 덱 기준선(nav / counter / print)
├── scripts/
│ └── sync-design-systems.ts ← 상위 awesome-design-md tarball 재가져오기
├── docs/
│ ├── spec.md ← 제품 스펙, 시나리오, 차별화
│ ├── architecture.md ← 토폴로지, 데이터 흐름, 컴포넌트
│ ├── skills-protocol.md ← 확장된 SKILL.md od: 프론트매터
│ ├── agent-adapters.md ← CLI별 감지 + 디스패치
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← 장문 출처
│ ├── roadmap.md ← 단계별 배포
│ ├── schemas/ ← JSON 스키마
│ └── examples/ ← 표준 아티팩트 예시
└── .od/ ← 런타임 데이터, gitignore됨, 자동 생성
├── app.sqlite ← 프로젝트 / 대화 / 메시지 / 탭
├── projects/<id>/ ← 프로젝트별 작업 폴더(에이전트의 cwd)
└── artifacts/ ← 저장된 일회성 렌더
```
## 디자인 시스템
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="72개 디자인 시스템 라이브러리 — 에디토리얼 스프레드" width="100%" />
</p>
기본 제공 72개 시스템, 각각 단일 [`DESIGN.md`](design-systems/README.md)로:
<details>
<summary><b>전체 카탈로그</b> (클릭하여 펼치기)</summary>
**AI & LLM**`claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**개발자 도구**`cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**생산성**`notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**핀테크**`stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**이커머스**`shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**미디어**`spotify` · `playstation` · `wired` · `theverge` · `meta`
**자동차**`tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**기타**`apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**스타터**`default`(Neutral Modern) · `warm-editorial`
</details>
라이브러리는 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts)를 통해 [`VoltAgent/awesome-design-md`][acd2]에서 가져옵니다. 재실행하면 새로 고침됩니다.
## 시각적 방향
사용자에게 브랜드 스펙이 없을 때, 에이전트가 5가지 엄선된 방향이 있는 두 번째 폼을 내보냅니다 — [`huashu-design`의 "5가지 학파 × 20가지 디자인 철학" 폴백](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback)의 OD 적용. 각 방향은 결정론적 스펙입니다 — OKLch의 팔레트, 폰트 스택, 레이아웃 포스처 단서, 참고 자료 — 에이전트가 이를 seed 템플릿의 `:root`에 그대로 바인딩합니다. 라디오 하나 클릭 → 완전히 지정된 시각 시스템. 즉흥 없음, AI-slop 없음.
| 방향 | 무드 | 참고 |
|---|---|---|
| Editorial — Monocle / FT | 인쇄 매거진, 잉크 + 크림 + 따뜻한 러스트 | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | 쿨, 구조적, 미니멀 액센트 | Linear · Vercel · Stripe |
| Tech utility | 정보 밀도, 모노스페이스, 터미널 | Bloomberg · Bauhaus 도구 |
| Brutalist | 날것, 거대한 타입, 그림자 없음, 강한 액센트 | Bloomberg Businessweek · Achtung |
| Soft warm | 여유롭고, 낮은 대비, 복숭아 계열 뉴트럴 | Notion 마케팅 · Apple Health |
전체 스펙 → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
## 미디어 생성
OD는 코드에서 끝나지 않습니다. `<artifact>` HTML을 만드는 동일한 채팅 입구가 **이미지**, **비디오**, **오디오** 생성도 구동합니다 — 모델 어댑터는 daemon의 미디어 파이프라인([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts))에 연결되어 있습니다. 모든 렌더링은 프로젝트 워크스페이스에 실제 파일로 떨어지며 — 이미지는 `.png`, 비디오는 `.mp4` — 턴이 끝날 때 다운로드 chip으로 표시됩니다.
오늘날 부하를 짊어진 세 모델 패밀리:
| Surface | 모델 | 제공자 | 용도 |
|---|---|---|---|
| **이미지** | `gpt-image-2` | Azure / OpenAI | 포스터, 프로필 아바타, 일러스트 도시 지도, 인포그래픽, 매거진 풍 소셜 카드, 사진 복원, 분해도 제품 일러스트 |
| **비디오** | `seedance-2.0` | ByteDance Volcengine | 15초 시네마틱 t2v + i2v + 오디오 — 내러티브 쇼트, 인물 클로즈업, 제품 영상, MV 안무 |
| **비디오** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 모션 그래픽 — 제품 리빌, 키네틱 타이포그래피, 데이터 차트, 소셜 오버레이, 로고 아웃트로, 카라오케 자막을 단 세로형 TikTok |
성장하는 **prompt 갤러리**는 [`prompt-templates/`](prompt-templates/)에서 — **즉시 복제 가능한 93개 prompt** 동봉: 43개 이미지(`prompt-templates/image/*.json`), 39개 Seedance(`prompt-templates/video/*.json``hyperframes-*` 제외), 11개 HyperFrames(`prompt-templates/video/hyperframes-*.json`). 각 항목은 미리보기 썸네일, 원본 prompt 본문, 대상 모델, 화면비, 라이선스 + 저작자 표기를 담은 `source` 블록을 포함합니다. daemon은 `GET /api/prompt-templates`로 서빙하고, 웹 앱은 진입 화면의 **Image templates** / **Video templates** 탭에서 카드 그리드로 보여줍니다; 한 번 클릭하면 적합한 모델이 미리 선택된 prompt가 composer에 떨어집니다.
### gpt-image-2 — 이미지 갤러리(43개 중 5개)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="3D Stone Staircase Evolution" /><br/><sub><b>3D Stone Staircase Evolution Infographic</b><br/>3단계 석재 풍 인포그래픽</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Illustrated City Food Map" /><br/><sub><b>Illustrated City Food Map</b><br/>편집급 손그림 여행 포스터</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cinematic Elevator Scene" /><br/><sub><b>Cinematic Elevator Scene</b><br/>편집급 패션 단일 프레임</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Cyberpunk Anime Portrait" /><br/><sub><b>Cyberpunk Anime Portrait</b><br/>프로필 아바타 — 네온 페이스 텍스트</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Glamorous Woman in Black" /><br/><sub><b>Glamorous Woman in Black Portrait</b><br/>편집급 스튜디오 초상</sub></td>
</tr>
</table>
전체 목록 → [`prompt-templates/image/`](prompt-templates/image/). 출처: 대부분 [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts)(CC-BY-4.0)에서, 템플릿마다 작성자 표기를 보존.
### Seedance 2.0 — 비디오 갤러리(39개 중 5개)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Music Podcast Guitar" /></a><br/><sub><b>Music Podcast & Guitar Technique</b><br/>4K 시네마틱 스튜디오 영상</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Emotional Face" /></a><br/><sub><b>Emotional Face Close-up</b><br/>시네마틱 미세 표정 연구</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Luxury Supercar" /></a><br/><sub><b>Luxury Supercar Cinematic</b><br/>내러티브 제품 영상</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Forbidden City Cat" /></a><br/><sub><b>Forbidden City Cat Satire</b><br/>스타일라이즈드 풍자 쇼트</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Japanese Romance" /></a><br/><sub><b>Japanese Romance Short Film</b><br/>15초 Seedance 2.0 내러티브</sub></td>
</tr>
</table>
썸네일을 클릭하면 실제 렌더된 MP4가 재생됩니다. 전체 목록 → [`prompt-templates/video/`](prompt-templates/video/)(`*-seedance-*` 및 Cinematic 태그가 붙은 항목). 출처: [`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts)(CC-BY-4.0), 원 트윗 링크와 작성자 핸들 보존.
### HyperFrames — HTML→MP4 모션 그래픽(11개의 즉시 복제 가능한 템플릿)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes)는 HeyGen이 오픈소스화한 에이전트 네이티브 비디오 프레임워크입니다 — 당신(또는 에이전트)이 HTML + CSS + GSAP을 작성하면 HyperFrames가 headless Chrome + FFmpeg로 결정론적으로 MP4를 렌더링합니다. Open Design은 HyperFrames를 일급 비디오 모델(`hyperframes-html`)로 daemon dispatch에 연결하고, 추가로 `skills/hyperframes/` skill을 동봉해 timeline 계약, 씬 트랜지션 규칙, audio-reactive 패턴, 자막/TTS, 카탈로그 블록(`npx hyperframes add <slug>`)을 에이전트에게 가르칩니다.
11개의 HyperFrames prompt가 [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/)에 들어 있고, 각각이 특정 아키타입을 만들어내는 구체적인 brief입니다:
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Product reveal" /></a><br/><sub><b>5초 미니멀 제품 리빌</b> · 16:9 · 푸시인 타이틀 카드 + 셰이더 트랜지션</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="SaaS promo" /></a><br/><sub><b>30초 SaaS 제품 프로모</b> · 16:9 · Linear/ClickUp 풍 + UI 3D 리빌</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaoke" /></a><br/><sub><b>TikTok 카라오케 토킹헤드</b> · 9:16 · TTS + 단어 동기화 자막</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Brand sizzle" /></a><br/><sub><b>30초 브랜드 sizzle 릴</b> · 16:9 · 비트 동기화 키네틱 타이포, audio-reactive</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Data chart" /></a><br/><sub><b>애니메이션 bar-chart race</b> · 16:9 · NYT 풍 데이터 인포그래픽</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Flight map" /></a><br/><sub><b>비행 경로 지도(출발 → 도착)</b> · 16:9 · Apple 풍 시네마틱 경로 리빌</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>4초 시네마틱 로고 아웃트로</b> · 16:9 · 조각별 어셈블 + bloom</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Money counter" /></a><br/><sub><b>$0 → $10K 머니 카운터</b> · 9:16 · Apple 풍 hype + 그린 플래시 + 버스트</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>폰 3대 앱 쇼케이스</b> · 16:9 · 떠 있는 폰 + 기능 콜아웃</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Social overlay" /></a><br/><sub><b>소셜 오버레이 스택</b> · 9:16 · X · Reddit · Spotify · Instagram 순차</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website to video" /></a><br/><sub><b>웹사이트→비디오 파이프라인</b> · 16:9 · 3가지 뷰포트 캡처 + 트랜지션</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
패턴은 다른 것과 동일합니다: 템플릿을 고르고, brief를 편집하고, 보냅니다. 에이전트는 동봉된 `skills/hyperframes/SKILL.md`(OD 전용 렌더링 워크플로 — composition 소스 파일을 `.hyperframes-cache/`에 격리해 파일 워크스페이스를 어지럽히지 않고, daemon이 `npx hyperframes render`를 대신 실행해 macOS sandbox-exec / Puppeteer 행 현상을 우회하고, 최종 `.mp4`만 프로젝트 chip으로 표시되도록)를 읽고, composition을 작성하고, MP4를 출력합니다. 카탈로그 블록 썸네일은 © HeyGen, 그들의 CDN에서 제공; OSS 프레임워크 자체는 Apache-2.0입니다.
> **연결되었지만 아직 템플릿으로 노출되지 않은 모델:** Kling 2.0 / 1.6 / 1.5, Veo 3 / Veo 2, Sora 2 / Sora 2-Pro(via Fal), MiniMax video-01 — 모두 `VIDEO_MODELS`([`apps/web/src/media/models.ts`](apps/web/src/media/models.ts))에 있습니다. Suno v5 / v4.5, Udio v2, Lyria 2(음악)와 gpt-4o-mini-tts, MiniMax TTS(음성)가 오디오 surface를 커버합니다. 이들 prompt 템플릿은 오픈 컨트리뷰션입니다 — JSON을 `prompt-templates/video/` 또는 `prompt-templates/audio/`에 떨구면 picker에 나타납니다.
## 채팅 그 이상 — 더 들어 있는 것들
채팅 / 아티팩트 루프가 가장 눈에 잘 띄지만, 이 저장소에는 다른 제품과 비교하기 전에 한번쯤 스캔해 볼 가치가 있는 잘 안 보이는 능력들이 더 있습니다:
- **Claude Design ZIP 임포트.** claude.ai에서 익스포트한 ZIP을 환영 다이얼로그에 드롭하세요. `POST /api/import/claude-design`이 그것을 진짜 `.od/projects/<id>/`로 풀어주고, 엔트리 파일을 탭으로 열고, 로컬 에이전트에게 "Anthropic이 멈춘 곳에서 그대로 이어서 편집해" 프롬프트를 미리 박아둡니다. 다시 묻지 않아도 됩니다, "방금 만든 것을 다시 만들어줘"도 안 합니다. ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
- **OpenAI 호환 BYOK 프록시.** `POST /api/proxy/stream``{ baseUrl, apiKey, model, messages }`를 받아 경로를 정규화(`…/v1/chat/completions`)하고, SSE 청크를 브라우저로 전달하며, SSRF를 막기 위해 loopback / link-local / RFC1918 목적지를 거부합니다. OpenAI chat 스키마를 말하는 모든 것이 작동합니다 — Anthropic-via-OpenAI 어댑터, DeepSeek, Groq, MiMo, OpenRouter, 자체 호스팅 vLLM. MiMo는 자유 생성에서 tool 스키마가 잘 동작하지 않아 자동으로 `tool_choice: 'none'`이 적용됩니다.
- **사용자 저장 templates.** 마음에 든 렌더가 있으면 `POST /api/templates`가 HTML + 메타데이터를 SQLite `templates` 테이블에 스냅샷으로 저장합니다. 다음 프로젝트의 picker에는 "내 템플릿" 행이 추가됩니다 — 기본 31개와 동일한 표면, 그러나 당신의 것.
- **탭 영속성.** 모든 프로젝트는 `tabs` 테이블에 자기가 연 파일들과 활성 탭을 기억합니다. 내일 다시 열어도 워크스페이스는 어제 떠난 그 모습 그대로.
- **Artifact lint API.** `POST /api/artifacts/lint`는 생성된 아티팩트에 대해 구조 검사(파괴된 `<artifact>` 프레임, 누락된 필수 사이드 파일, 오래된 팔레트 토큰)를 실행하고, 에이전트가 다음 턴에 다시 읽어들일 수 있는 findings를 반환합니다. 5차원 자기 검토는 이걸로 점수를 vibe가 아닌 실제 증거에 묶어둡니다.
- **Sidecar 프로토콜 + 데스크탑 자동화.** Daemon, web, desktop 프로세스 모두 타입화된 5필드 스탬프(`app · mode · namespace · ipc · source`)를 들고 다니며, JSON-RPC IPC 채널을 `/tmp/open-design/ipc/<namespace>/<app>.sock`에 노출합니다. `tools-dev inspect desktop status \| eval \| screenshot`이 그 채널 위에서 동작하므로, 헤드리스 E2E가 진짜 Electron 셸을 상대로 자체 하네스 없이 동작합니다([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)).
- **Windows 친화적 spawn.** 긴 합성 prompt에서 `CreateProcess`의 약 32 KB argv 한계를 넘을 만한 모든 어댑터(Codex, Gemini, OpenCode, Cursor Agent, Qwen, Qoder CLI, Pi)는 prompt를 stdin으로 보냅니다. Claude Code와 Copilot은 `-p`를 유지하고, 그것마저 넘치면 daemon은 임시 prompt 파일로 폴백합니다.
- **네임스페이스별 런타임 데이터.** `OD_DATA_DIR``--namespace`로 완전히 격리된 `.od/`-스타일 트리를 받습니다. Playwright, 베타 채널, 실제 작업 프로젝트가 SQLite 파일을 공유하는 일은 절대 없습니다.
## Anti-AI-slop 메커니즘
아래의 모든 메커니즘은 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 플레이북을 OD의 프롬프트 스택에 이식하고, 사이드 파일 pre-flight를 통해 skill별로 적용 가능하게 만든 것입니다. 실제 문구는 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)를 읽으세요:
- **질문 폼 우선.** Turn 1은 오직 `<question-form>` — 생각하기 없음, 도구 없음, 내레이션 없음. 사용자는 라디오 속도로 기본값을 선택합니다.
- **브랜드 스펙 추출.** 사용자가 스크린샷이나 URL을 첨부하면, 에이전트는 5단계 프로토콜(위치 파악 · 다운로드 · hex grep · `brand-spec.md` 코드화 · 발성)을 실행한 후 CSS를 작성합니다. **절대 기억에서 브랜드 색상을 추측하지 않습니다.**
- **5차원 검토.** `<artifact>`를 내보내기 전, 에이전트가 자신의 출력을 철학 / 계층 / 실행 / 구체성 / 절제 5가지 차원에서 15점으로 조용히 채점합니다. 3/5 미만은 퇴보 — 수정 후 재채점. 두 번의 패스는 정상입니다.
- **P0/P1/P2 체크리스트.** 모든 skill은 하드 P0 게이트가 있는 `references/checklist.md`를 제공합니다. 에이전트는 내보내기 전에 P0를 통과해야 합니다.
- **Slop 블랙리스트.** 공격적인 보라색 그라디언트, 일반 이모지 아이콘, 왼쪽 테두리 액센트가 있는 둥근 카드, 손으로 그린 SVG 인물, *디스플레이* 폰트로서의 Inter, 허구 지표 — 프롬프트에서 명시적으로 금지됩니다.
- **정직한 플레이스홀더 > 가짜 통계.** 실제 숫자가 없을 때 에이전트는 `—` 또는 레이블이 있는 회색 블록을 씁니다. "10배 빠릅니다"가 아닙니다.
## 비교
| 축 | [Claude Design][cd] (Anthropic) | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| 라이선스 | 클로즈드 | MIT | **Apache-2.0** |
| 폼 팩터 | 웹(claude.ai) | 데스크탑(Electron) | **웹앱 + 로컬 daemon** |
| Vercel 배포 가능 | ❌ | ❌ | **✅** |
| 에이전트 런타임 | 번들됨(Opus 4.7) | 번들됨([`pi-ai`][piai]) | **사용자 기존 CLI에 위임** |
| Skill | 독점 | 12개 커스텀 TS 모듈 + `SKILL.md` | **31개 파일 기반 [`SKILL.md`][skill] 번들, 드롭 가능** |
| 디자인 시스템 | 독점 | `DESIGN.md`(v0.2 로드맵) | **`DESIGN.md` × 72개 시스템 기본 제공** |
| 프로바이더 유연성 | Anthropic 전용 | [`pi-ai`][piai]를 통해 7+ | **16개 CLI 어댑터 + OpenAI 호환 BYOK 프록시** |
| 초기화 질문 폼 | ❌ | ❌ | **✅ 하드 규칙, turn 1** |
| 방향 선택기 | ❌ | ❌ | **✅ 5가지 결정론적 방향** |
| 실시간 할 일 진행 + 도구 스트림 | ❌ | ✅ | **✅** (open-codesign의 UX 패턴) |
| 샌드박스 iframe 미리보기 | ❌ | ✅ | **✅** (open-codesign의 패턴) |
| Claude Design ZIP 임포트 | n/a | ❌ | **`POST /api/import/claude-design` — Anthropic이 멈춘 곳에서 그대로 이어서** |
| 코멘트 모드 수술적 편집 | ❌ | ✅ | 🚧 로드맵(open-codesign에서 이식) |
| AI 제안 트윅 패널 | ❌ | ✅ | 🟡 부분 — [`tweaks` skill](skills/tweaks/) 출시, 채팅 통합 패널 UX는 로드맵 |
| 파일시스템급 워크스페이스 | ❌ | 부분(Electron 샌드박스) | **✅ 실제 cwd, 실제 도구, SQLite 영구 저장(projects · conversations · messages · tabs · templates)** |
| 5차원 자기 검토 | ❌ | ❌ | **✅ 내보내기 전 게이트** |
| Artifact lint | ❌ | ❌ | **`POST /api/artifacts/lint` — findings를 에이전트로 다시 피드** |
| Sidecar IPC + 헤드리스 데스크탑 | ❌ | ❌ | **✅ 스탬프된 프로세스 + `tools-dev inspect desktop status \| eval \| screenshot`** |
| 내보내기 형식 | 제한됨 | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTX(에이전트 주도) / ZIP / Markdown** |
| PPT skill 재사용 | N/A | 내장 | **[`guizang-ppt-skill`][guizang] 드롭인(덱 모드 기본)** |
| 최소 청구 | Pro / Max / Team | BYOK | **BYOK — 임의의 OpenAI 호환 `baseUrl` 붙여넣기** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## 지원하는 코딩 에이전트
daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. 스트리밍 디스패치 로직은 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts)의 `AGENT_DEFS`에 있고, CLI별 파서도 같은 디렉터리에 있습니다. 모델 목록은 `<bin> --list-models` / `<bin> models` / ACP 핸드셰이크로 탐지하거나, CLI가 목록을 노출하지 않을 때 큐레이션된 폴백을 사용합니다.
| 에이전트 | 바이너리 | 스트리밍 형식 | argv 형태(합성된 prompt 경로) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json`(타입 이벤트) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + `codex` 파서 | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--model …] [-c model_reasoning_effort=…]`(prompt는 stdin) |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + `gemini` 파서 | `GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo [--model …]`(prompt는 stdin) |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + `opencode` 파서 | `opencode run --format json --dangerously-skip-permissions [--model …] -`(prompt는 stdin) |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + `cursor-agent` 파서 | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -`(prompt는 stdin) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain`(원시 stdout 청크) | `qwen --yolo [--model …] -`(prompt는 stdin) |
| Qoder CLI | `qodercli` | `qoder-stream-json`(타입 이벤트) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]`(prompt는 stdin) |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json`(타입 이벤트) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc`(Agent Client Protocol) | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc`(stdio JSON-RPC) | `pi --mode rpc [--model …] [--thinking …]`(prompt는 RPC `prompt` 명령으로 전송) |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain`(원시 stdout 청크) | `deepseek exec --auto [--model …] <prompt>`(prompt는 위치 인수) |
| **멀티 프로바이더 BYOK** | n/a | SSE 정규화 | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → Anthropic / OpenAI 호환 / Azure OpenAI / Gemini; loopback / link-local / RFC1918에 대한 SSRF 차단 |
새 CLI 추가는 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts)에 항목 하나 추가하는 것입니다. 스트리밍 형식은 `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream`(CLI별 `eventParser`와 함께), `acp-json-rpc`, `pi-rpc`, `plain` 중 하나입니다.
## 참조 및 계보
이 저장소가 차용한 모든 외부 프로젝트. 각 링크는 출처로 이동하여 계보를 확인할 수 있습니다.
| 프로젝트 | 역할 |
|---|---|
| [`Claude Design`][cd] | 이 저장소가 오픈소스 대안을 제공하는 클로즈드 소스 제품. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | 디자인 철학 핵심. Junior-Designer 워크플로, 5단계 브랜드 에셋 프로토콜, anti-AI-slop 체크리스트, 5차원 자기 검토, 그리고 방향 선택기 뒤의 "5가지 학파 × 20가지 디자인 철학" 라이브러리 — 모두 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)와 [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)에 녹아들었습니다. |
| [**`op7418/guizang-ppt-skill`**][guizang] | [`skills/guizang-ppt/`](skills/guizang-ppt/) 아래에 원본 그대로 번들된 Magazine-web-PPT skill, 원 LICENSE 보존. 덱 모드 기본. P0/P1/P2 체크리스트 문화는 다른 모든 skill에도 차용됩니다. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + 어댑터 아키텍처. PATH 스캔 에이전트 감지, 단일 특권 프로세스로서의 로컬 daemon, 에이전트-동료 세계관. 모델을 채용했지만 코드는 vendor하지 않습니다. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | 최초의 오픈소스 Claude-Design 대안이자 가장 가까운 동류. 채택된 UX 패턴: 스트리밍 아티팩트 루프, 샌드박스 iframe 미리보기(React 18 + Babel 내장), 실시간 에이전트 패널(todos + tool calls + 중단 가능), 5가지 내보내기 형식(HTML/PDF/PPTX/ZIP/Markdown), 로컬 우선 designs 허브, `SKILL.md` 취향 주입. 로드맵의 UX 패턴: 코멘트 모드 수술적 편집, AI 제안 트윅 패널. **[`pi-ai`][piai]는 의도적으로 vendor하지 않습니다** — open-codesign은 이를 에이전트 런타임으로 번들링하지만 우리는 사용자가 이미 가진 CLI에 위임합니다. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9섹션 `DESIGN.md` 스키마의 출처이자 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts)를 통해 가져온 69개 제품 시스템. |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | 여러 에이전트 CLI에 걸친 심링크 기반 skill 배포의 영감. |
| [Claude Code skills][skill] | 원본 그대로 채택된 `SKILL.md` 규약 — 모든 Claude Code skill이 `skills/`에 드롭되면 daemon이 감지합니다. |
각각에서 무엇을 채용하고 의도적으로 채용하지 않았는지에 대한 장문의 계보 작성 → [`docs/references.md`](docs/references.md).
## 로드맵
- [x] Daemon + 에이전트 감지(16개 CLI 어댑터) + skill 레지스트리 + 디자인 시스템 카탈로그
- [x] 웹앱 + 채팅 + 질문 폼 + 5가지 방향 선택기 + 할 일 진행 + 샌드박스 미리보기
- [x] 31개 skill + 72개 디자인 시스템 + 5가지 시각적 방향 + 5개 기기 프레임
- [x] SQLite 기반 projects · conversations · messages · tabs · templates
- [x] OpenAI 호환 BYOK 프록시(`/api/proxy/stream`) + SSRF 차단
- [x] Claude Design ZIP 임포트(`/api/import/claude-design`)
- [x] Sidecar 프로토콜 + Electron 데스크탑 + IPC 자동화(STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN)
- [x] Artifact lint API + 5차원 자기 검토 내보내기 전 게이트
- [ ] 코멘트 모드 수술적 편집(요소 클릭 → 지시 → 패치) — [`open-codesign`][ocod]에서 가져온 패턴
- [ ] AI 제안 트윅 패널 UX — 빌딩 블록([`tweaks` skill](skills/tweaks/))은 출시, 채팅 통합 패널은 미완
- [ ] Vercel + 터널 배포 레시피(Topology B)
- [ ] `DESIGN.md`로 프로젝트를 스캐폴딩하는 원클릭 `npx od init`
- [ ] Skill 마켓플레이스(`od skills install <github-repo>`)와 `od skill add | list | remove | test` CLI 표면([`docs/skills-protocol.md`](docs/skills-protocol.md)에 초안 작성됨, 구현 미완)
- [x] `apps/packaged/`에서 패키지된 Electron 빌드 — macOS (Apple Silicon) 및 Windows (x64) 다운로드는 [open-design.ai](https://open-design.ai/)와 [GitHub 릴리스 페이지](https://github.com/nexu-io/open-design/releases)에서 제공
단계별 배포 → [`docs/roadmap.md`](docs/roadmap.md).
## 상태
이것은 초기 구현입니다 — 닫힌 루프(감지 → skill + 디자인 시스템 선택 → 채팅 → `<artifact>` 파싱 → 미리보기 → 저장)가 end-to-end로 실행됩니다. 프롬프트 스택과 skill 라이브러리가 대부분의 가치가 있으며, 안정적입니다. 컴포넌트 수준 UI는 매일 배포되고 있습니다.
## 스타 주세요
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="GitHub에서 Open Design에 스타 주기 — github.com/nexu-io/open-design" width="100%" /></a>
</p>
이것이 30분을 절약해줬다면 — ★를 주세요. 스타가 사용료를 대신 내지는 않지만, 다음 디자이너, 에이전트, 기여자에게 이 실험이 그들의 관심을 받을 가치가 있다는 것을 알려줍니다. 한 번의 클릭, 3초, 진짜 신호: [github.com/nexu-io/open-design](https://github.com/nexu-io/open-design).
## 기여
이슈, PR, 새로운 skill, 새로운 디자인 시스템 모두 환영합니다. 가장 레버리지가 높은 기여는 보통 폴더 하나, Markdown 파일 하나, 또는 PR 크기의 어댑터입니다:
- **skill 추가** — [`SKILL.md`][skill] 규약을 따르는 폴더를 [`skills/`](skills/)에 드롭하세요.
- **디자인 시스템 추가** — 9섹션 스키마를 사용하여 [`design-systems/<brand>/`](design-systems/)에 `DESIGN.md`를 드롭하세요.
- **새 코딩 에이전트 CLI 연결** — [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts)에 항목 하나 추가.
전체 설명, 병합 기준, 코드 스타일, 받지 않는 것 → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([Deutsch](CONTRIBUTING.de.md), [Français](CONTRIBUTING.fr.md), [简体中文](CONTRIBUTING.zh-CN.md)).
## 컨트리뷰터
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-06" 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) 레이블이 시작점입니다.
## 저장소 활동
<picture>
<img alt="Open Design — 저장소 지표" src="docs/assets/github-metrics.svg" />
</picture>
위의 SVG는 [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml)이 [`lowlighter/metrics`](https://github.com/lowlighter/metrics)를 사용해 매일 자동으로 다시 생성합니다. 즉시 새로 고치려면 **Actions** 탭에서 수동 트리거하세요; 더 풍부한 플러그인(traffic, follow-up time 등)을 켜려면 저장소 secrets에 fine-grained PAT를 `METRICS_TOKEN`이라는 이름으로 추가하세요.
## Star History
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
</picture>
</a>
곡선이 위로 휘면 — 그것이 우리가 찾는 신호입니다. ★를 눌러 위로 밀어주세요.
## 크레딧 / Credits
마스터 [`skills/html-ppt/`](skills/html-ppt/) skill과 [`skills/html-ppt-*/`](skills/) 아래의 15개 per-template wrapper(15개 full-deck 템플릿, 36개 테마, 31개 single-page 레이아웃, 27개 CSS 애니메이션 + 20개 canvas FX, 키보드 runtime, 자석식 카드 presenter mode 포함)는 오픈소스 프로젝트 [`lewislulu/html-ppt-skill`](https://github.com/lewislulu/html-ppt-skill)(MIT)에서 통합되었습니다. 원본 LICENSE는 [`skills/html-ppt/LICENSE`](skills/html-ppt/LICENSE)에 보존되어 있고 저작권 표시는 [@lewislulu](https://github.com/lewislulu)에게 있습니다. 각 per-template Examples 카드(`html-ppt-pitch-deck`, `html-ppt-tech-sharing`, `html-ppt-presenter-mode`, `html-ppt-xhs-post` …)는 authoring 가이드를 마스터 skill에 위임하므로, **Use this prompt** 클릭 시 업스트림과 동일한 prompt → 출력 동작이 그대로 보존됩니다.
[`skills/guizang-ppt/`](skills/guizang-ppt/) 매거진/가로 스와이프 deck flow는 [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill)(MIT)에서 통합되었으며, 저작권 표시는 [@op7418](https://github.com/op7418)에게 있습니다.
## 라이선스
Apache-2.0. 번들된 `skills/guizang-ppt/`는 원래 [LICENSE](skills/guizang-ppt/LICENSE)(MIT)와 [op7418](https://github.com/op7418)에 대한 저작권 표시를 유지합니다. 번들된 `skills/html-ppt/`는 원래 [LICENSE](skills/html-ppt/LICENSE)(MIT)와 [lewislulu](https://github.com/lewislulu)에 대한 저작권 표시를 유지합니다.

885
README.md Normal file
View File

@@ -0,0 +1,885 @@
# Open Design
> **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>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="Download" src="https://img.shields.io/badge/download-open--design.ai-ff6b35?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#supported-coding-agents"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><b>English</b> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a></p>
---
## Why this exists
Anthropic's [Claude Design][cd] (released 2026-04-17, Opus 4.7) showed what happens when an LLM stops writing prose and starts shipping design artifacts. It went viral — and stayed closed-source, paid-only, cloud-only, locked to Anthropic's model and Anthropic's skills. There is no checkout, no self-host, no Vercel deploy, no swap-in-your-own-agent.
**Open Design (OD) is the open-source alternative.** Same loop, same artifact-first mental model, none of the lock-in. We don't ship an agent — the strongest coding agents already live on your laptop. We wire them into a skill-driven design workflow that runs locally with `pnpm tools-dev`, can deploy the web layer to Vercel, and stays BYOK at every layer.
Type `make me a magazine-style pitch deck for our seed round`. The interactive question form pops up before the model improvises a single pixel. The agent picks one of five curated visual directions. A live `TodoWrite` plan streams into the UI. The daemon builds a real on-disk project folder with a seed template, layout library, and self-check checklist. The agent reads them — pre-flight enforced — runs a five-dimensional critique against its own output, and emits a single `<artifact>` that renders in a sandboxed iframe seconds later.
That's not "AI tries to design something". That's an AI that has been trained, by the prompt stack, to behave like a senior designer with a working filesystem, a deterministic palette library, and a checklist culture — exactly the bar Claude Design set, but open and yours.
OD stands on four open-source shoulders:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — the design-philosophy compass. Junior-Designer workflow, the 5-step brand-asset protocol, the anti-AI-slop checklist, the 5-dimensional self-critique, and the "5 schools × 20 design philosophies" idea behind our direction picker — all distilled into [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — the deck mode. Bundled verbatim under [`skills/guizang-ppt/`](skills/guizang-ppt/) with original LICENSE preserved; magazine-style layouts, WebGL hero, P0/P1/P2 checklists.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — the UX north star and our closest peer. The first open-source Claude-Design alternative. We borrow its streaming-artifact loop, its sandboxed-iframe preview pattern (vendored React 18 + Babel), its live agent panel (todos + tool calls + interruptible generation), and its five-format export list (HTML / PDF / PPTX / ZIP / Markdown). We deliberately diverge on form factor — they are a desktop Electron app bundling [`pi-ai`][piai]; we are a web app + local daemon that delegates to your existing CLI.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — the daemon-and-runtime architecture. PATH-scan agent detection, the local daemon as the only privileged process, the agent-as-teammate worldview.
## At a glance
| | What you get |
|---|---|
| **Coding-agent CLIs (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — auto-detected on `PATH`, swap with one click |
| **BYOK fallback** | Protocol-specific API proxy at `/api/proxy/{anthropic,openai,azure,google}/stream` — paste `baseUrl` + `apiKey` + `model`, choose Anthropic / OpenAI / Azure OpenAI / Google Gemini, and the daemon normalizes SSE back to the same chat stream. Internal-IP/SSRF blocked at the daemon edge. |
| **Design systems built-in** | **129** — 2 hand-authored starters + 70 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) from [`awesome-design-md`][acd2], plus 57 design skills from [`awesome-design-skills`][ads] added directly under `design-systems/` |
| **Skills built-in** | **31** — 27 in `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 in `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Grouped in the picker by `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Media generation** | Image · video · audio surfaces ship alongside the design loop. **gpt-image-2** (Azure / OpenAI) for posters, avatars, infographics, illustrated maps · **Seedance 2.0** (ByteDance) for cinematic 15s text-to-video and image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) for HTML→MP4 motion graphics (product reveals, kinetic typography, data charts, social overlays, logo outros). **93** ready-to-replicate prompts gallery — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — under [`prompt-templates/`](prompt-templates/), with preview thumbnails and source attribution. Same chat surface as code; outputs a real `.mp4` / `.png` chip into the project workspace. |
| **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — each ships a deterministic OKLch palette + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, shared across skills under [`assets/frames/`](assets/frames/) |
| **Agent runtime** | Local daemon spawns the CLI in your project folder — agent gets real `Read`, `Write`, `Bash`, `WebFetch` against a real on-disk environment, with Windows `ENAMETOOLONG` fallbacks (stdin / prompt-file) on every adapter |
| **Imports** | Drop a [Claude Design][cd] export ZIP onto the welcome dialog — `POST /api/import/claude-design` parses it into a real project so your agent can keep editing where Anthropic left off |
| **Persistence** | SQLite at `.od/app.sqlite`: projects · conversations · messages · tabs · saved templates. Reopen tomorrow, todo card and open files are exactly where you left them. |
| **Lifecycle** | One entry point: `pnpm tools-dev` (start / stop / run / status / logs / inspect / check) — boots daemon + web (+ desktop) under typed sidecar stamps |
| **Desktop** | Optional Electron shell with sandboxed renderer + sidecar IPC (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN) — drives `tools-dev inspect desktop screenshot` for E2E |
| **Deployable to** | Local (`pnpm tools-dev`) · Vercel web layer · packaged Electron desktop app for macOS (Apple Silicon) and Windows (x64) — download from [open-design.ai](https://open-design.ai/) or the [latest release](https://github.com/nexu-io/open-design/releases) |
| **License** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ads]: https://github.com/bergside/awesome-design-skills
## Demo
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · Entry view" /><br/>
<sub><b>Entry view</b> — pick a skill, pick a design system, type the brief. The same surface for prototypes, decks, mobile apps, dashboards, and editorial pages.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · Turn-1 discovery form" /><br/>
<sub><b>Turn-1 discovery form</b> — before the model writes a pixel, OD locks the brief: surface, audience, tone, brand context, scale. 30 seconds of radios beats 30 minutes of redirects.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · Direction picker" /><br/>
<sub><b>Direction picker</b> — when the user has no brand, the agent emits a second form with 5 curated directions (Monocle / Modern Minimal / Tech Utility / Brutalist / Soft Warm). One radio click → a deterministic palette + font stack, no model freestyle.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · Live todo progress" /><br/>
<sub><b>Live todo progress</b> — the agent's plan streams as a live card. <code>in_progress</code> → <code>completed</code> updates land in real time. The user can redirect cheaply, mid-flight.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · Sandboxed preview" /><br/>
<sub><b>Sandboxed preview</b> — every <code>&lt;artifact&gt;</code> renders in a clean srcdoc iframe. Editable in place via the file workspace; downloadable as HTML, PDF, ZIP.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · 72-system library" /><br/>
<sub><b>72-system library</b> — every product system shows its 4-color signature. Click for the full <code>DESIGN.md</code>, swatch grid, and live showcase.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · Magazine deck" /><br/>
<sub><b>Deck mode (guizang-ppt)</b> — the bundled <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> drops in unchanged. Magazine layouts, WebGL hero backgrounds, single-file HTML output, PDF export.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · Mobile prototype" /><br/>
<sub><b>Mobile prototype</b> — pixel-accurate iPhone 15 Pro chrome (Dynamic Island, status bar SVGs, home indicator). Multi-screen prototypes use the shared <code>/frames/</code> assets so the agent never re-draws a phone.</sub>
</td>
</tr>
</table>
## 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)).
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`.
### Showcase examples
The visually distinctive skills you'll most likely run first. Each ships a real `example.html` you can open straight from the repo to see exactly what the agent will produce — no auth, no setup.
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>Consumer dating / matchmaking dashboard — left rail nav, ticker bar, KPIs, 30-day mutual-matches chart, editorial typography.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>Two-spread digital e-guide — cover (title, author, TOC teaser) + lesson spread with pull-quote and step list. Creator / lifestyle tone.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>Brand product-launch HTML email — masthead, hero image, headline lockup, CTA, specs grid. Centered single-column, table-fallback safe.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>Three-frame gamified mobile-app prototype on a dark showcase stage — cover, today's quests with XP ribbons + level bar, quest detail.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>Three-frame mobile onboarding flow — splash, value-prop, sign-in. Status bar, swipe dots, primary CTA.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>Single-frame motion-design hero with looping CSS animations — rotating type ring, animated globe, ticking timer. Hand-off ready for HyperFrames.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>Three-card 1080×1080 social-media carousel — cinematic panels with display headlines that connect across the series, brand mark, loop affordance.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>Pixel / 8-bit animated explainer slide — full-bleed cream stage, animated pixel mascot, kinetic Japanese display type, looping CSS keyframes.</sub>
</td>
</tr>
</table>
### Design & marketing surfaces (prototype mode)
| Skill | Platform | Scenario | What it produces |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | desktop | design | Single-page HTML — landings, marketing, hero pages (default for prototype) |
| [`saas-landing`](skills/saas-landing/) | desktop | marketing | Hero / features / pricing / CTA marketing layout |
| [`dashboard`](skills/dashboard/) | desktop | operation | Admin / analytics with sidebar + dense data layout |
| [`pricing-page`](skills/pricing-page/) | desktop | sale | Standalone pricing + comparison tables |
| [`docs-page`](skills/docs-page/) | desktop | engineering | 3-column documentation layout |
| [`blog-post`](skills/blog-post/) | desktop | marketing | Editorial long-form |
| [`mobile-app`](skills/mobile-app/) | mobile | design | iPhone 15 Pro / Pixel framed app screen(s) |
| [`mobile-onboarding`](skills/mobile-onboarding/) | mobile | design | Multi-screen mobile onboarding flow (splash · value-prop · sign-in) |
| [`gamified-app`](skills/gamified-app/) | mobile | personal | Three-frame gamified mobile-app prototype |
| [`email-marketing`](skills/email-marketing/) | desktop | marketing | Brand product-launch HTML email (table-fallback safe) |
| [`social-carousel`](skills/social-carousel/) | desktop | marketing | 3-card 1080×1080 social carousel |
| [`magazine-poster`](skills/magazine-poster/) | desktop | marketing | Single-page magazine-style poster |
| [`motion-frames`](skills/motion-frames/) | desktop | marketing | Motion-design hero with looping CSS animations |
| [`sprite-animation`](skills/sprite-animation/) | desktop | marketing | Pixel / 8-bit animated explainer slide |
| [`dating-web`](skills/dating-web/) | desktop | personal | Consumer dating dashboard mockup |
| [`digital-eguide`](skills/digital-eguide/) | desktop | marketing | Two-spread digital e-guide (cover + lesson) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | desktop | design | Hand-drawn ideation sketch — for the "show something visible early" pass |
| [`critique`](skills/critique/) | desktop | design | Five-dimensional self-critique scoresheet (Philosophy · Hierarchy · Detail · Function · Innovation) |
| [`tweaks`](skills/tweaks/) | desktop | design | AI-emitted tweaks panel — the model surfaces the parameters worth nudging |
### Deck surfaces (deck mode)
| Skill | Default for | What it produces |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **default** for deck | Magazine-style web PPT — bundled verbatim from [op7418/guizang-ppt-skill][guizang], original LICENSE preserved |
| [`simple-deck`](skills/simple-deck/) | — | Minimal horizontal-swipe deck |
| [`replit-deck`](skills/replit-deck/) | — | Product-walkthrough deck (Replit-style) |
| [`weekly-update`](skills/weekly-update/) | — | Team weekly cadence as a swipe deck (progress · blockers · next) |
### Office & operations surfaces (prototype mode, document-flavored scenarios)
| Skill | Scenario | What it produces |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | PM specification doc with TOC + decision log |
| [`team-okrs`](skills/team-okrs/) | product | OKR scoresheet |
| [`meeting-notes`](skills/meeting-notes/) | operation | Meeting decision log |
| [`kanban-board`](skills/kanban-board/) | operation | Board snapshot |
| [`eng-runbook`](skills/eng-runbook/) | engineering | Incident runbook |
| [`finance-report`](skills/finance-report/) | finance | Exec finance summary |
| [`invoice`](skills/invoice/) | finance | Single-page invoice |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | Role onboarding plan |
Adding a skill takes one folder. Read [`docs/skills-protocol.md`](docs/skills-protocol.md) for the extended frontmatter, fork an existing skill, restart the daemon, it appears in the picker. The catalog endpoint is `GET /api/skills`; per-skill seed assembly (template + side-file references) lives at `GET /api/skills/:id/example`.
## Six load-bearing ideas
### 1 · We don't ship an agent. Yours is good enough.
The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/mariozechner/pi-ai), [`kiro-cli`](https://kiro.dev), `kilo`, [`vibe-acp`](https://github.com/mistralai/mistral-vibe), and `deepseek` on startup. Whichever ones it finds become candidate design engines — driven over stdio with one adapter per CLI, swappable from the model picker. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI installed? The API mode is the same pipeline minus the spawn — choose Anthropic, OpenAI-compatible, Azure OpenAI, or Google Gemini and the daemon forwards normalized SSE chunks back, with loopback / link-local / RFC1918 destinations rejected at the edge.
### 2 · Skills are files, not plugins.
Following Claude Code's [`SKILL.md` convention](https://docs.anthropic.com/en/docs/claude-code/skills), each skill is `SKILL.md` + `assets/` + `references/`. Drop a folder into [`skills/`](skills/), restart the daemon, it appears in the picker. The bundled `magazine-web-ppt` is [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) committed verbatim — original license preserved, attribution preserved.
### 3 · Design Systems are portable Markdown, not theme JSON.
The 9-section `DESIGN.md` schema from [`VoltAgent/awesome-design-md`][acd2] — color, typography, spacing, layout, components, motion, voice, brand, anti-patterns. Every artifact reads from the active system. Switch system → next render uses the new tokens. The dropdown ships with **Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Apple, Anthropic, Cursor, Supabase, Figma, Resend, Raycast, Lovable, Cohere, Mistral, ElevenLabs, X.AI, Spotify, Webflow, Sanity, PostHog, Sentry, MongoDB, ClickHouse, Cal, Replicate, Clay, Composio, Xiaohongshu…** — plus 57 design skills sourced from [`awesome-design-skills`][ads].
### 4 · The interactive question form prevents 80% of redirects.
OD's prompt stack hard-codes a `RULE 1`: every fresh design brief begins with a `<question-form id="discovery">` instead of code. Surface · audience · tone · brand context · scale · constraints. A long brief still leaves design decisions open — visual tone, color stance, scale — exactly the things the form locks down in 30 seconds. The cost of a wrong direction is one chat round, not one finished deck.
This is the **Junior-Designer mode** distilled from [`huashu-design`](https://github.com/alchaincyf/huashu-design): batch the questions up front, show something visible early (even a wireframe with grey blocks), let the user redirect cheaply. Combined with the brand-asset protocol (locate · download · `grep` hex · write `brand-spec.md` · vocalise), it's the single biggest reason output stops feeling like AI freestyle and starts feeling like a designer who paid attention before painting.
### 5 · The daemon makes the agent feel like it's on your laptop, because it is.
The daemon spawns the CLI with `cwd` set to the project's artifact folder under `.od/projects/<id>/`. The agent gets `Read`, `Write`, `Bash`, `WebFetch` — real tools against a real filesystem. It can `Read` the skill's `assets/template.html`, `grep` your CSS for hex values, write a `brand-spec.md`, drop generated images, and produce `.pptx` / `.zip` / `.pdf` files that show up in the file workspace as download chips when the turn ends. Sessions, conversations, messages, tabs persist in a local SQLite DB — pop the project open tomorrow and the agent's todo card is right where you left it.
### 6 · The prompt stack is the product.
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)
+ 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)
```
Every layer is composable. Every layer is a file you can edit. Read [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) and [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) to see the actual contract.
## Architecture
```
┌────────────────────── browser (Next.js 16) ──────────────────────┐
│ chat · file workspace · iframe preview · settings · imports │
└──────────────┬───────────────────────────────────┬───────────────┘
│ /api/* (rewritten in dev) │
▼ ▼
┌──────────────────────────────────┐ /api/proxy/{provider}/stream (SSE)
│ Local daemon (Express + SQLite) │ ─→ any OpenAI-compat
│ │ endpoint (BYOK)
│ /api/agents /api/skills│ w/ SSRF blocking
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/{provider}/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (static) /frames (static)
│ optional: sidecar IPC at /tmp/open-design/ipc/<ns>/<app>.sock
│ (STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN)
└─────────┬────────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · devin (ACP) · gemini · opencode · cursor-agent │
│ qwen · qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) · kiro (ACP) · kilo (ACP) · vibe (ACP) · deepseek │
│ reads SKILL.md + DESIGN.md, writes artifacts to disk │
└──────────────────────────────────────────────────────────────────┘
```
| Layer | Stack |
|---|---|
| Frontend | Next.js 16 App Router + React 18 + TypeScript, Vercel-deployable |
| Daemon | Node 24 · Express · SSE streaming · `better-sqlite3`; tables: `projects` · `conversations` · `messages` · `tabs` · `templates` |
| Agent transport | `child_process.spawn`; typed-event parsers for `claude-stream-json` (Claude Code), `qoder-stream-json` (Qoder CLI), `copilot-stream-json` (Copilot), `json-event-stream` per-CLI parsers (Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe via Agent Client Protocol), `pi-rpc` (Pi via stdio JSON-RPC), `plain` (Qwen Code / DeepSeek TUI) |
| BYOK proxy | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → provider-specific upstream APIs, normalized `delta/end/error` SSE; rejects loopback / link-local / RFC1918 hosts at the daemon edge |
| Storage | Plain files in `.od/projects/<id>/` + SQLite at `.od/app.sqlite` + credentials at `.od/media-config.json` (gitignored, auto-created). `OD_DATA_DIR=<dir>` relocates all daemon data (used for test isolation and read-only-install setups); `OD_MEDIA_CONFIG_DIR=<dir>` further narrows the override to just `media-config.json` for setups that want to keep API keys outside the data dir |
| Preview | Sandboxed iframe via `srcdoc` + per-skill `<artifact>` parser ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
| Export | HTML (inline assets) · PDF (browser print, deck-aware) · PPTX (agent-driven via skill) · ZIP (archiver) · Markdown |
| Lifecycle | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`; ports via `--daemon-port` / `--web-port`, namespaces via `--namespace` |
| Desktop (optional) | Electron shell — discovers the web URL through sidecar IPC, no port guessing; same `STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN` channel powers `tools-dev inspect desktop …` for E2E |
## Quickstart
### Download the desktop app (no build required)
The fastest way to try Open Design is the prebuilt desktop app — no Node, no pnpm, no clone:
- **[open-design.ai](https://open-design.ai/)** — official download page
- **[GitHub releases](https://github.com/nexu-io/open-design/releases)**
### Run from source
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # should print 10.33.2
pnpm install
pnpm tools-dev run web
# open the web URL printed by tools-dev
```
Windows launcher: build `OpenDesign.exe` yourself with the instructions in `tools/launcher/README.md`, or download it from GitHub Releases. Then place it in the repo root and double-click it to run `pnpm install` if needed and start Open Design with `pnpm tools-dev`.
Environment requirements: Node `~24` and pnpm `10.33.x`. `nvm`/`fnm` are optional helpers only; if you use one, run `nvm install 24 && nvm use 24` or `fnm install 24 && fnm use 24` before `pnpm install`.
For desktop/background startup, fixed-port restarts, and media generation dispatcher checks (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), see [`QUICKSTART.md`](QUICKSTART.md).
The first load:
1. Detects which agent CLIs you have on `PATH` and picks one automatically.
2. Loads 31 skills + 72 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.
Type a prompt, hit **Send**, watch the question form arrive, fill it, watch the todo card stream, watch the artifact render. Click **Save to disk** or download as a project ZIP.
### First-run state (`./.od/`)
The daemon owns one hidden folder at the repo root. Everything in it is gitignored and machine-local — never commit it.
```
.od/
├── app.sqlite ← projects · conversations · messages · open tabs
├── artifacts/ ← one-off "Save to disk" renders (timestamped)
└── projects/<id>/ ← per-project working dir, also the agent's cwd
```
| Want to… | Do this |
|---|---|
| Inspect what's in there | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| Reset to a clean slate | `pnpm tools-dev stop`, `rm -rf .od`, run `pnpm tools-dev run web` again |
| Move it elsewhere | `OD_DATA_DIR=<absolute-or-relative-path> pnpm tools-dev run web` — the daemon resolves `~/` and anchors relative paths to the repo root. `OD_MEDIA_CONFIG_DIR=<dir>` narrows the override to just `media-config.json` if you want credentials in a separate location. |
#### Migrating a pre-desktop-app `.od/` into the installed Desktop app
If you ran the repo first and only later installed the packaged Desktop app, the two writers point at different roots:
- Repo dev-server (`pnpm tools-dev start web`) writes to `<repo-root>/.od/`.
- Installed Desktop app writes under `<appData>/Open Design/namespaces/<channel>/data/`, where `<appData>` is Electron's per-OS app-data base (everything before the `Open Design` segment that `app.getPath("userData")` already includes). The channel suffix is **platform-specific** — the release workflows append `-win`/`-linux`:
| Platform | `<appData>` (Electron `appData` base) | Stable channel | Beta channel |
|---|---|---|---|
| macOS | `~/Library/Application Support` | `release-stable` | `release-beta` |
| Windows | `%APPDATA%` (= `%USERPROFILE%\AppData\Roaming`) | `release-stable-win` | `release-beta-win` |
| Linux | `$XDG_CONFIG_HOME` (default `~/.config`) | `release-stable-linux` | `release-beta-linux` |
Example resolved paths:
- macOS beta: `~/Library/Application Support/Open Design/namespaces/release-beta/data/`
- Windows beta: `%APPDATA%\Open Design\namespaces\release-beta-win\data\`
- Linux beta: `~/.config/Open Design/namespaces/release-beta-linux/data/`
If unsure, inspect the packaged daemon log right after the app boots; it logs the resolved `daemonDataRoot`.
> **⚠️ Do this in a clean state.** Migration replaces (not merges) the Desktop app's data dir with your repo `.od/`. Both writers must be fully stopped before copying — quit the Desktop app **and** stop the repo dev-server. SQLite-WAL needs to flush cleanly on both sides; if either daemon is still running it can write SQLite/WAL pages or project/artifact files mid-snapshot, leaving the staged copy inconsistent. If the Desktop app already has projects you care about, decide which side is authoritative before continuing — the steps below back up the Desktop's current `data/` to a sibling but do not merge.
To carry your existing projects, SQLite, artifacts, and `media-config.json` over to the Desktop app:
```bash
set -euo pipefail
# 1. Stop both writers so the source and target are quiescent.
# - Quit the Desktop app (Cmd+Q on macOS, File → Exit on Windows/Linux).
# - Stop the repo dev-server: `pnpm tools-dev stop` from the repo root.
# 2. Set REPO and APP_DATA to your actual paths; the example below is macOS + beta.
REPO="/path/to/open-design"
APP_DATA="$HOME/Library/Application Support/Open Design/namespaces/release-beta/data"
# 3. Preflight: see what (if anything) the Desktop app already has.
ls "$APP_DATA/projects" 2>/dev/null && echo "↑ Desktop already has projects — confirm this is a replace, not a merge."
# 4. Stage into a sibling first, then atomically swap into place. `set -e` plus
# the explicit rsync exit check guarantee a non-zero copy aborts before any
# `mv` runs, so the Desktop data dir cannot end up half-populated.
STAGE="${APP_DATA}.staged-$(date +%F-%H%M)"
mkdir -p "$STAGE"
rsync -a --exclude='backup-*' "$REPO/.od/" "$STAGE/" || { echo "rsync failed — aborting before swap"; exit 1; }
# 5. Backup the Desktop's current data, then promote the staged copy.
mv "$APP_DATA" "${APP_DATA}.fresh-baseline-$(date +%F-%H%M)"
mv "$STAGE" "$APP_DATA"
# 6. Relaunch the Desktop app. The daemon applies forward schema changes on boot.
```
If anything looks wrong after relaunch, restore the original Desktop data by deleting `$APP_DATA` and renaming the `.fresh-baseline-*` directory back into place.
> **⚠️ Schema migrations are forward-only.** The daemon applies `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE` changes on boot; there is no version guard. After migrating, **do not** open the same data dir with an older repo checkout — unsupported columns or behavior mismatches can leave the workspace inconsistent. Back up `app.sqlite*` before the first launch with the new app.
> **⚠️ Advanced: sharing one data dir between repo dev-server and Desktop app.** Pointing both at the same dir via `OD_DATA_DIR` is possible but **only safe one-at-a-time**. The daemon opens `app.sqlite` in WAL mode and writes uncoordinated files under `projects/` and `artifacts/`; running both writers concurrently can corrupt SQLite or clobber artifacts. Always stop the Desktop app before starting the dev-server, and stop the dev-server before opening the Desktop app:
>
> ```bash
> OD_DATA_DIR="$HOME/Library/Application Support/Open Design/namespaces/release-beta/data" \
> pnpm tools-dev start web
> ```
Full file map, scripts, and troubleshooting → [`QUICKSTART.md`](QUICKSTART.md).
## Running the Project
Open Design can run as a web app in your browser or as an Electron desktop application. Both modes share the same local daemon + web architecture.
### Web / Localhost (Default)
```bash
# Foreground mode — keeps the lifecycle command in the foreground (logs written to files)
pnpm tools-dev run web
# View recent logs:
pnpm tools-dev logs
# Background mode — daemon + web run as background processes
pnpm tools-dev start web
```
By default, `tools-dev` binds to available ephemeral ports and prints the actual URLs on startup. To use fixed ports from a stopped state:
```bash
pnpm tools-dev run web --daemon-port 17456 --web-port 17573
```
If daemon/web are already running, use `restart` to switch ports in the existing session:
```bash
pnpm tools-dev restart --daemon-port 17456 --web-port 17573
```
### Desktop / Electron
```bash
# Start daemon + web + desktop in the background
pnpm tools-dev
# Check desktop status
pnpm tools-dev inspect desktop status
# Take a screenshot of the desktop app
pnpm tools-dev inspect desktop screenshot --path /tmp/open-design.png
```
The desktop app discovers the web URL automatically via sidecar IPC — no port guessing required.
### Other Useful Commands
| Command | What it does |
|---|---|
| `pnpm tools-dev status` | Show running sidecar statuses |
| `pnpm tools-dev logs` | Show daemon/web/desktop log tails |
| `pnpm tools-dev stop` | Stop all running sidecars |
| `pnpm tools-dev restart` | Stop then restart all sidecars |
| `pnpm tools-dev check` | Status + recent logs + common diagnostics |
For fixed-port restarts, background startup, and full troubleshooting see [`QUICKSTART.md`](QUICKSTART.md).
## Use Open Design from your coding agent
Open Design ships a stdio MCP server. Wire it into Claude Code, Codex, Cursor, VS Code, Antigravity, Zed, Windsurf, or any MCP-compatible client and the agent in another repo can read files from your local Open Design projects directly. Replaces the export-then-attach loop. When the agent calls `search_files`, `get_file`, or `get_artifact` without a project argument, the MCP defaults to whatever project (and file) you have open in Open Design right now, so prompts like *"build this in my app"* or *"match these styles"* just work.
**Why MCP?** Exporting and re-attaching a zip every design iteration breaks flow. The MCP server exposes your design source directly -- tokens CSS, JSX components, entry HTML -- as a structured API the agent can query by name. The agent always sees the live file, not a stale copy from the last export.
Open **Settings → MCP server** in the Open Design app for a per-client install flow. The panel bakes the absolute path to your `node` binary and the daemon's built `cli.js` into every snippet, so it works on a fresh source clone where `od` is not on your PATH. Cursor gets a one-click deeplink; the rest get a copy-paste JSON snippet in the schema their config file expects (Claude Code includes a `claude mcp add-json` one-liner so you do not have to hand-edit `~/.claude.json`). Restart or reload your client after install for the server to show up.
The daemon must be running locally for MCP tool calls to succeed. If the agent was started before Open Design, restart the agent after Open Design is up so it can reach the live daemon. Tool calls made while the daemon is offline return a clear `"daemon not reachable"` error rather than a crash.
**Security model.** The MCP server is read-only; it exposes file reads, file metadata, and search -- nothing that writes to disk or calls an external service. It runs as a child process of the coding agent over stdio, so any MCP client you register inherits read access to your local Open Design projects. Treat it like installing a VS Code extension: only register clients you trust. The daemon binds to `127.0.0.1` by default; LAN-wide exposure requires an explicit `OD_BIND_HOST` opt-in.
## Repository structure
```
open-design/
├── README.md ← this file
├── README.de.md ← Deutsch
├── README.ru.md ← Русский
├── README.zh-CN.md ← 简体中文
├── QUICKSTART.md ← run / build / deploy guide
├── package.json ← pnpm workspace, single bin: od
├── apps/
│ ├── daemon/ ← Node + Express, the only server
│ │ ├── src/ ← TypeScript daemon source
│ │ │ ├── cli.ts ← `od` bin source, compiled to dist/cli.js
│ │ │ ├── server.ts ← /api/* routes (projects, chat, files, exports)
│ │ │ ├── agents.ts ← PATH scanner + per-CLI argv builders
│ │ │ ├── claude-stream.ts ← streaming JSON parser for Claude Code stdout
│ │ │ ├── skills.ts ← SKILL.md frontmatter loader
│ │ │ └── db.ts ← SQLite schema (projects/messages/templates/tabs)
│ │ ├── sidecar/ ← tools-dev daemon sidecar wrapper
│ │ └── tests/ ← daemon package tests
│ │
│ └── web/ ← Next.js 16 App Router + React client
│ ├── app/ ← App Router entrypoints
│ ├── next.config.ts ← dev rewrites + prod static export to out/
│ └── src/ ← React + TypeScript client modules
│ ├── App.tsx ← routing, bootstrap, settings
│ ├── components/ ← chat, composer, picker, preview, sketch, …
│ ├── prompts/
│ │ ├── system.ts ← composeSystemPrompt(base, skill, DS, metadata)
│ │ ├── discovery.ts ← turn-1 form + turn-2 branch + 5-dim critique
│ │ └── directions.ts ← 5 visual directions × OKLch palette + font stack
│ ├── artifacts/ ← streaming <artifact> parser + manifests
│ ├── runtime/ ← iframe srcdoc, markdown, export helpers
│ ├── providers/ ← daemon SSE + BYOK API transports
│ └── state/ ← config + projects (localStorage + daemon-backed)
├── e2e/ ← Playwright UI + external integration/Vitest harness
├── packages/
│ ├── contracts/ ← shared web/daemon app contracts
│ ├── sidecar-proto/ ← Open Design sidecar protocol contract
│ ├── sidecar/ ← generic sidecar runtime primitives
│ └── platform/ ← generic process/platform primitives
├── skills/ ← 31 SKILL.md skill bundles (27 prototype + 4 deck)
│ ├── web-prototype/ ← default for prototype mode
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← deck mode
│ └── guizang-ppt/ ← bundled magazine-web-ppt (default for deck)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 DESIGN.md systems
│ ├── default/ ← Neutral Modern (starter)
│ ├── warm-editorial/ ← Warm Editorial (starter)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md ← catalog overview
├── assets/
│ └── frames/ ← shared device frames (used cross-skill)
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ ├── deck-framework.html ← deck baseline (nav / counter / print)
│ └── kami-deck.html ← kami-flavored deck starter (parchment / ink-blue serif)
├── scripts/
│ └── sync-design-systems.ts ← re-import upstream awesome-design-md tarball
├── docs/
│ ├── spec.md ← product spec, scenarios, differentiation
│ ├── architecture.md ← topologies, data flow, components
│ ├── skills-protocol.md ← extended SKILL.md od: frontmatter
│ ├── agent-adapters.md ← per-CLI detection + dispatch
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← long-form provenance
│ ├── roadmap.md ← phased delivery
│ ├── schemas/ ← JSON schemas
│ └── examples/ ← canonical artifact examples
└── .od/ ← runtime data, gitignored, auto-created
├── app.sqlite ← projects / conversations / messages / tabs
├── projects/<id>/ ← per-project working folder (agent's cwd)
└── artifacts/ ← saved one-off renders
```
## Design Systems
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="The 72 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):
<details>
<summary><b>Full catalog</b> (click to expand)</summary>
**AI & LLM**`claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**Developer Tools**`cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**Productivity**`notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**Fintech**`stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**E-Commerce**`shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**Media**`spotify` · `playstation` · `wired` · `theverge` · `meta`
**Automotive**`tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**Other**`apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**Starters**`default` (Neutral Modern) · `warm-editorial`
</details>
The product-system library is imported via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) from [`VoltAgent/awesome-design-md`][acd2]. Re-run to refresh. The 57 design skills are sourced from [`bergside/awesome-design-skills`][ads] and added directly in `design-systems/`.
## Visual directions
When the user has no brand spec, the agent emits a second form with five curated directions — the OD adaptation of [`huashu-design`'s "5 schools × 20 design philosophies" fallback](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback). Each direction is a deterministic spec — palette in OKLch, font stack, layout posture cues, references — that the agent binds verbatim into the seed template's `:root`. One radio click → a fully specified visual system. No improvisation, no AI-slop.
| Direction | Mood | Refs |
|---|---|---|
| Editorial — Monocle / FT | Print magazine, ink + cream + warm rust | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | Cool, structured, minimal accent | Linear · Vercel · Stripe |
| Tech utility | Information density, monospace, terminal | Bloomberg · Bauhaus tools |
| Brutalist | Raw, oversized type, no shadows, harsh accents | Bloomberg Businessweek · Achtung |
| Soft warm | Generous, low contrast, peachy neutrals | Notion marketing · Apple Health |
Full spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
## Media generation
OD doesn't stop at code. The same chat surface that produces `<artifact>` HTML also drives **image**, **video**, and **audio** generation, with model adapters wired into the daemon's media pipeline ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Every render lands as a real file in the project workspace — `.png` for image, `.mp4` for video — and shows up as a download chip when the turn ends.
Three model families carry the load today:
| Surface | Model | Provider | What it's for |
|---|---|---|---|
| **Image** | `gpt-image-2` | Azure / OpenAI | Posters, profile avatars, illustrated maps, infographics, magazine-style social cards, photo restoration, exploded-view product art |
| **Video** | `seedance-2.0` | ByteDance Volcengine | 15s cinematic t2v + i2v with audio — narrative shorts, character close-ups, product films, MV-style choreography |
| **Video** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 motion graphics — product reveals, kinetic typography, data charts, social overlays, logo outros, TikTok-style verticals with karaoke captions |
A growing **prompt gallery** at [`prompt-templates/`](prompt-templates/) ships **93 ready-to-replicate prompts** — 43 image (`prompt-templates/image/*.json`), 39 Seedance (`prompt-templates/video/*.json` excluding `hyperframes-*`), 11 HyperFrames (`prompt-templates/video/hyperframes-*.json`). Each carries a preview thumbnail, the prompt body verbatim, the target model, the aspect ratio, and a `source` block for license + attribution. The daemon serves them at `GET /api/prompt-templates`, the web app surfaces them as a card grid in the **Image templates** and **Video templates** tabs of the entry view; one click drops a prompt into the composer with the right model preselected.
### gpt-image-2 — image gallery (sample of 43)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="3D Stone Staircase Evolution" /><br/><sub><b>3D Stone Staircase Evolution Infographic</b><br/>3-step infographic, hewn-stone aesthetic</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Illustrated City Food Map" /><br/><sub><b>Illustrated City Food Map</b><br/>Editorial hand-illustrated travel poster</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cinematic Elevator Scene" /><br/><sub><b>Cinematic Elevator Scene</b><br/>Single-frame editorial fashion still</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Cyberpunk Anime Portrait" /><br/><sub><b>Cyberpunk Anime Portrait</b><br/>Profile avatar — neon face text</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Glamorous Woman in Black" /><br/><sub><b>Glamorous Woman in Black Portrait</b><br/>Editorial studio portrait</sub></td>
</tr>
</table>
Full set → [`prompt-templates/image/`](prompt-templates/image/). Sources: most pull from [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts) (CC-BY-4.0) with author attribution preserved per template.
### Seedance 2.0 — video gallery (sample of 39)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Music Podcast Guitar" /></a><br/><sub><b>Music Podcast & Guitar Technique</b><br/>4K cinematic studio film</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Emotional Face" /></a><br/><sub><b>Emotional Face Close-up</b><br/>Cinematic micro-expression study</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Luxury Supercar" /></a><br/><sub><b>Luxury Supercar Cinematic</b><br/>Narrative product film</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Forbidden City Cat" /></a><br/><sub><b>Forbidden City Cat Satire</b><br/>Stylised satire short</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Japanese Romance" /></a><br/><sub><b>Japanese Romance Short Film</b><br/>15s Seedance 2.0 narrative</sub></td>
</tr>
</table>
Click any thumbnail to play the actual rendered MP4. Full set → [`prompt-templates/video/`](prompt-templates/video/) (the `*-seedance-*` and Cinematic-tagged entries). Sources: [`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts) (CC-BY-4.0) with original tweet links and author handles preserved.
### HyperFrames — HTML→MP4 motion graphics (11 ready-to-replicate templates)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) is HeyGen's open-source agent-native video framework — you (or the agent) write HTML + CSS + GSAP, HyperFrames renders it to a deterministic MP4 via headless Chrome + FFmpeg. Open Design ships HyperFrames as a first-class video model (`hyperframes-html`) wired into the daemon dispatch, plus the `skills/hyperframes/` skill that teaches the agent the timeline contract, scene-transition rules, audio-reactive patterns, captions/TTS, and the catalog blocks (`npx hyperframes add <slug>`).
Eleven hyperframes prompts ship under [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/), each one a concrete brief that produces a specific archetype:
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Product reveal" /></a><br/><sub><b>5s minimal product reveal</b> · 16:9 · push-in title card with shader transition</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="SaaS promo" /></a><br/><sub><b>30s SaaS product promo</b> · 16:9 · Linear/ClickUp-style with UI 3D reveals</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaoke" /></a><br/><sub><b>TikTok karaoke talking-head</b> · 9:16 · TTS + word-synced captions</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Brand sizzle" /></a><br/><sub><b>30s brand sizzle reel</b> · 16:9 · beat-synced kinetic typography, audio-reactive</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Data chart" /></a><br/><sub><b>Animated bar-chart race</b> · 16:9 · NYT-style data infographic</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Flight map" /></a><br/><sub><b>Flight map (origin → dest)</b> · 16:9 · Apple-style cinematic route reveal</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>4s cinematic logo outro</b> · 16:9 · piece-by-piece assembly + bloom</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Money counter" /></a><br/><sub><b>$0 → $10K money counter</b> · 9:16 · Apple-style hype with green flash + burst</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>3-phone app showcase</b> · 16:9 · floating phones with feature callouts</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Social overlay" /></a><br/><sub><b>Social overlay stack</b> · 9:16 · X · Reddit · Spotify · Instagram in sequence</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website to video" /></a><br/><sub><b>Website-to-video pipeline</b> · 16:9 · captures site at 3 viewports + transitions</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
Pattern is the same as the rest: pick a template, edit the brief, send. The agent reads the bundled `skills/hyperframes/SKILL.md` (which carries the OD-specific render workflow — composition source files into a `.hyperframes-cache/` so they don't clutter the file workspace, daemon dispatches `npx hyperframes render` to dodge the macOS sandbox-exec / Puppeteer hang, only the final `.mp4` lands as a project chip), authors the composition, and ships an MP4. Catalog block thumbnails © HeyGen, served from their CDN; the OSS framework itself is Apache-2.0.
> **Also wired but not surfaced as templates yet:** Kling 2.0 / 1.6 / 1.5, Veo 3 / Veo 2, Sora 2 / Sora 2-Pro (via Fal), MiniMax video-01 — all live in `VIDEO_MODELS` ([`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Suno v5 / v4.5, Udio v2, Lyria 2 (music) and gpt-4o-mini-tts, MiniMax TTS (speech) cover the audio surface. Templates for these are open contributions — drop a JSON into `prompt-templates/video/` or `prompt-templates/audio/` and it shows up in the picker.
## Beyond chat — what else ships
The chat / artifact loop gets the spotlight, but a handful of less-visible capabilities are already wired and worth knowing before you compare OD to anything else:
- **Claude Design ZIP import.** Drop an export from claude.ai onto the welcome dialog. `POST /api/import/claude-design` extracts it into a real `.od/projects/<id>/`, opens the entry file as a tab, and stages a continue-where-Anthropic-left-off prompt for your local agent. No re-prompting, no "ask the model to re-create what we just had". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
- **Multi-provider BYOK proxy.** `POST /api/proxy/{anthropic,openai,azure,google}/stream` takes `{ baseUrl, apiKey, model, messages }`, builds the provider-specific upstream request, normalizes SSE chunks into `delta/end/error`, and rejects loopback / link-local / RFC1918 destinations to head off SSRF. OpenAI-compatible covers OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter, and self-hosted vLLM; Azure OpenAI adds deployment URL + `api-version`; Google uses Gemini `:streamGenerateContent`.
- **User-saved templates.** Once you like a render, `POST /api/templates` snapshots the HTML + metadata into the SQLite `templates` table. The next project picks it from a "your templates" row in the picker — same surface as the shipped 31, but yours.
- **Tab persistence.** Every project remembers its open files and active tab in the `tabs` table. Reopen the project tomorrow and the workspace looks exactly the way you left it.
- **Artifact lint API.** `POST /api/artifacts/lint` runs structural checks on a generated artifact (broken `<artifact>` framing, missing required side files, stale palette tokens) and returns findings the agent can read back into its next turn. The five-dim self-critique uses this to ground its score in real evidence, not vibes.
- **Sidecar protocol + desktop automation.** Daemon, web, and desktop processes carry typed five-field stamps (`app · mode · namespace · ipc · source`) and expose a JSON-RPC IPC channel at `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status \| eval \| screenshot` drives that channel, so headless E2E works against a real Electron shell without bespoke harnesses ([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)).
- **Windows-friendly spawning.** Every adapter that would otherwise blow `CreateProcess`'s ~32 KB argv limit on long composed prompts (Codex, Gemini, OpenCode, Cursor Agent, Qwen, Qoder CLI, Pi) feeds the prompt over stdin instead. Claude Code and Copilot keep `-p`; the daemon falls back to a temp prompt-file when even that overflows.
- **Per-namespace runtime data.** `OD_DATA_DIR` and `--namespace` give you fully isolated `.od/`-style trees, so Playwright, beta channels, and your real projects never share a SQLite file.
## Anti-AI-slop machinery
The whole machinery below is the [`huashu-design`](https://github.com/alchaincyf/huashu-design) playbook, ported into OD's prompt-stack and made enforceable per-skill via the side-file pre-flight. Read [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) for the live wording:
- **Question form first.** Turn 1 is `<question-form>` only — no thinking, no tools, no narration. The user chooses defaults at radio speed.
- **Brand-spec extraction.** When the user attaches a screenshot or URL, the agent runs a five-step protocol (locate · download · grep hex · codify `brand-spec.md` · vocalise) before writing CSS. **Never guesses brand colors from memory.**
- **Five-dim critique.** Before emitting `<artifact>`, the agent silently scores its output 15 across philosophy / hierarchy / execution / specificity / restraint. Anything under 3/5 is a regression — fix and rescore. Two passes is normal.
- **P0/P1/P2 checklist.** Every skill ships a `references/checklist.md` with hard P0 gates. The agent must pass P0 before emitting.
- **Slop blacklist.** Aggressive purple gradients, generic emoji icons, rounded card with left-border accent, hand-drawn SVG humans, Inter as a *display* face, invented metrics — explicitly forbidden in the prompt.
- **Honest placeholders > fake stats.** When the agent doesn't have a real number, it writes `—` or a labelled grey block, not "10× faster".
## Comparison
| Axis | [Claude Design][cd] (Anthropic) | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| License | Closed | MIT | **Apache-2.0** |
| Form factor | Web (claude.ai) | Desktop (Electron) | **Web app + local daemon** |
| Deployable on Vercel | ❌ | ❌ | **✅** |
| Agent runtime | Bundled (Opus 4.7) | Bundled ([`pi-ai`][piai]) | **Delegated to user's existing CLI** |
| Skills | Proprietary | 12 custom TS modules + `SKILL.md` | **31 file-based [`SKILL.md`][skill] bundles, droppable** |
| Design system | Proprietary | `DESIGN.md` (v0.2 roadmap) | **`DESIGN.md` × 129 systems shipped** |
| Provider flexibility | Anthropic only | 7+ via [`pi-ai`][piai] | **16 CLI adapters + OpenAI-compatible BYOK proxy** |
| Init question form | ❌ | ❌ | **✅ Hard rule, turn 1** |
| Direction picker | ❌ | ❌ | **✅ 5 deterministic directions** |
| Live todo progress + tool stream | ❌ | ✅ | **✅** (UX pattern from open-codesign) |
| Sandboxed iframe preview | ❌ | ✅ | **✅** (pattern from open-codesign) |
| Claude Design ZIP import | n/a | ❌ | **`POST /api/import/claude-design` — keep editing where Anthropic left off** |
| Comment-mode surgical edits | ❌ | ✅ | 🟡 partial — preview element comments + chat attachments; surgical patch reliability still in progress |
| AI-emitted tweaks panel | ❌ | ✅ | 🚧 roadmap — dedicated chat-side panel UX is not implemented yet |
| Filesystem-grade workspace | ❌ | partial (Electron sandbox) | **✅ Real cwd, real tools, persisted SQLite (projects · conversations · messages · tabs · templates)** |
| 5-dim self-critique | ❌ | ❌ | **✅ Pre-emit gate** |
| Artifact lint | ❌ | ❌ | **`POST /api/artifacts/lint` — findings fed back to the agent** |
| Sidecar IPC + headless desktop | ❌ | ❌ | **✅ Stamped processes + `tools-dev inspect desktop status \| eval \| screenshot`** |
| Export formats | Limited | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTX (agent-driven) / ZIP / Markdown** |
| PPT skill reuse | N/A | Built-in | **[`guizang-ppt-skill`][guizang] drops in (default for deck mode)** |
| Minimum billing | Pro / Max / Team | BYOK | **BYOK — paste any OpenAI-compatible `baseUrl`** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## Supported coding agents
Auto-detected from `PATH` on daemon boot. No config required. Streaming dispatch lives in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) (`AGENT_DEFS`); per-CLI parsers live alongside it. Models are populated either by probing `<bin> --list-models` / `<bin> models` / ACP handshake, or from a curated fallback list when the CLI doesn't expose a list.
| Agent | Bin | Stream format | Argv shape (composed prompt path) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json` (typed events) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + `codex` parser | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--add-dir …] [--model …] [-c model_reasoning_effort=…]` (prompt on stdin) |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + `gemini` parser | `GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo [--model …]` (prompt on stdin) |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + `opencode` parser | `opencode run --format json --dangerously-skip-permissions [--model …] -` (prompt on stdin) |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + `cursor-agent` parser | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -` (prompt on stdin) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain` (raw stdout chunks) | `qwen --yolo [--model …] -` (prompt on stdin) |
| Qoder CLI | `qodercli` | `qoder-stream-json` (typed events) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]` (prompt on stdin) |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json` (typed events) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc` (Agent Client Protocol) | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain` (raw stdout chunks) | `deepseek exec --auto [--model …] <prompt>` (prompt as positional arg) |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc` (stdio JSON-RPC) | `pi --mode rpc [--model …] [--thinking …]` (prompt sent as RPC `prompt` command) |
| **Multi-provider BYOK** | n/a | SSE normalization | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI-compatible / Azure OpenAI / Gemini; SSRF-guarded against loopback / link-local / RFC1918 |
Adding a new CLI is one entry in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). Streaming format is one of `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream` (with a per-CLI `eventParser`), `acp-json-rpc`, `pi-rpc`, or `plain`.
## References & lineage
Every external project this repo borrows from. Each link goes to the source so you can verify the provenance.
| Project | Role here |
|---|---|
| [`Claude Design`][cd] | The closed-source product this repo is the open-source alternative to. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | The design-philosophy core. Junior-Designer workflow, the 5-step brand-asset protocol, anti-AI-slop checklist, 5-dimensional self-critique, and the "5 schools × 20 design philosophies" library behind our direction picker — all distilled into [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) and [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Magazine-web-PPT skill bundled verbatim under [`skills/guizang-ppt/`](skills/guizang-ppt/) with original LICENSE preserved. Default for deck mode. P0/P1/P2 checklist culture borrowed for every other skill. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | The daemon + adapter architecture. PATH-scan agent detection, local daemon as the only privileged process, agent-as-teammate worldview. We adopt the model; we do not vendor the code. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | The first open-source Claude-Design alternative and our closest peer. UX patterns adopted: streaming-artifact loop, sandboxed-iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible), five-format export list (HTML/PDF/PPTX/ZIP/Markdown), local-first storage hub, `SKILL.md` taste-injection, and the first pass of comment-mode preview annotations. UX patterns still on our roadmap: full surgical-edit reliability and AI-emitted tweaks panel. **We deliberately do not vendor [`pi-ai`][piai]** — open-codesign bundles it as the agent runtime; we delegate to whichever CLI the user already has. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Source of the 9-section `DESIGN.md` schema and 70 product systems imported via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). |
| [`bergside/awesome-design-skills`][ads] | Source of 57 design skills added directly as normalized `DESIGN.md` files under `design-systems/`. |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | Inspiration for symlink-based skill distribution across multiple agent CLIs. |
| [Claude Code skills][skill] | The `SKILL.md` convention adopted verbatim — any Claude Code skill drops into `skills/` and is picked up by the daemon. |
Long-form provenance write-up — what we take from each, what we deliberately don't — lives at [`docs/references.md`](docs/references.md).
## Roadmap
- [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] SQLite-backed projects · conversations · messages · tabs · templates
- [x] Multi-provider BYOK proxy (`/api/proxy/{anthropic,openai,azure,google}/stream`) with SSRF guard
- [x] Claude Design ZIP import (`/api/import/claude-design`)
- [x] Sidecar protocol + Electron desktop with IPC automation (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN)
- [x] Artifact lint API + 5-dim self-critique pre-emit gate
- [ ] Comment-mode surgical edits — partial shipped: preview element comments and chat attachments; reliable targeted patching remains in progress
- [ ] AI-emitted tweaks panel UX — not implemented yet
- [ ] Vercel + tunnel deployment recipe (Topology B)
- [ ] One-command `npx od init` to scaffold a project with `DESIGN.md`
- [ ] Skill marketplace (`od skills install <github-repo>`) and `od skill add | list | remove | test` CLI surface (drafted in [`docs/skills-protocol.md`](docs/skills-protocol.md), implementation pending)
- [x] Packaged Electron build out of `apps/packaged/` — macOS (Apple Silicon) and Windows (x64) downloads on [open-design.ai](https://open-design.ai/) and the [GitHub releases page](https://github.com/nexu-io/open-design/releases)
Phased delivery → [`docs/roadmap.md`](docs/roadmap.md).
## Status
This is an early implementation — the closed loop (detect → pick skill + design system → chat → parse `<artifact>` → preview → save) runs end-to-end. The prompt stack and skill library are where most of the value lives, and they're stable. The component-level UI is shipping daily.
## Star us
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="Star Open Design on GitHub — github.com/nexu-io/open-design" width="100%" /></a>
</p>
If this saved you thirty minutes — give it a ★. Stars don't pay rent, but they tell the next designer, agent, and contributor that this experiment is worth their attention. One click, three seconds, real signal: [github.com/nexu-io/open-design](https://github.com/nexu-io/open-design).
## Contributing
Issues, PRs, new skills, and new design systems are all welcome. The highest-leverage contributions are usually one folder, one Markdown file, or one PR-sized adapter:
- **Add a skill** — drop a folder into [`skills/`](skills/) following the [`SKILL.md`][skill] convention.
- **Add a design system** — drop a `DESIGN.md` into [`design-systems/<brand>/`](design-systems/) using the 9-section schema.
- **Wire up a new coding-agent CLI** — one entry in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts).
Full walkthrough, bar-for-merging, code style, and what we don't accept → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([Deutsch](CONTRIBUTING.de.md), [Français](CONTRIBUTING.fr.md), [简体中文](CONTRIBUTING.zh-CN.md)).
## Contributors
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-06" 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.
## Repository activity
<picture>
<img alt="Open Design — repository metrics" src="docs/assets/github-metrics.svg" />
</picture>
The SVG above is regenerated daily by [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) using [`lowlighter/metrics`](https://github.com/lowlighter/metrics). Trigger a manual refresh from the **Actions** tab if you want it sooner; for richer plugins (traffic, follow-up time), add a `METRICS_TOKEN` repository secret with a fine-grained PAT.
## Star History
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
</picture>
</a>
If the curve bends up, that's the signal we look for. ★ this repo to push it.
## Credits
The HTML PPT Studio family of skills — the master [`skills/html-ppt/`](skills/html-ppt/) and the per-template wrappers under [`skills/html-ppt-*/`](skills/) (15 full-deck templates, 36 themes, 31 single-page layouts, 27 CSS animations + 20 canvas FX, the keyboard runtime, and the magnetic-card presenter mode) — are integrated from the open-source project [`lewislulu/html-ppt-skill`](https://github.com/lewislulu/html-ppt-skill) (MIT). The upstream LICENSE ships in-tree at [`skills/html-ppt/LICENSE`](skills/html-ppt/LICENSE) and authorship credit goes to [@lewislulu](https://github.com/lewislulu). Each per-template Examples card (`html-ppt-pitch-deck`, `html-ppt-tech-sharing`, `html-ppt-presenter-mode`, `html-ppt-xhs-post`, …) delegates authoring guidance to the master skill so the upstream's prompt → output behavior is preserved end-to-end when you click **Use this prompt**.
The magazine / horizontal-swipe deck flow under [`skills/guizang-ppt/`](skills/guizang-ppt/) is integrated from [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) (MIT). Authorship credit goes to [@op7418](https://github.com/op7418).
## License
Apache-2.0. The bundled `skills/guizang-ppt/` retains its original [LICENSE](skills/guizang-ppt/LICENSE) (MIT) and authorship attribution to [op7418](https://github.com/op7418). The bundled `skills/html-ppt/` retains its original [LICENSE](skills/html-ppt/LICENSE) (MIT) and authorship attribution to [lewislulu](https://github.com/lewislulu).

757
README.pt-BR.md Normal file
View File

@@ -0,0 +1,757 @@
# Open Design
> **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>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="Baixar" src="https://img.shields.io/badge/baixar-open--design.ai-ff6b35?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#agentes-de-código-suportados"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-entrar-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.pt-BR.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <b>Português (Brasil)</b> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a></p>
---
## Por que isto existe
O [Claude Design][cd] da Anthropic (lançado em 2026-04-17, com Opus 4.7) mostrou o que acontece quando um LLM para de escrever prosa e começa a entregar artifacts de design. Bombou — e ficou closed-source, pago, só na nuvem, preso ao modelo da Anthropic e às skills da Anthropic. Não tem checkout, não tem self-host, não tem deploy na Vercel, não tem swap-do-seu-próprio-agente.
**O Open Design (OD) é a alternativa open-source.** Mesmo loop, mesmo modelo mental orientado a artifact, sem nenhum trava. A gente não despacha um agente — os agentes de código mais fortes já estão no seu laptop. A gente os pluga em um workflow de design orientado a skills que roda local com `pnpm tools-dev`, pode subir a camada web na Vercel e mantém BYOK em toda camada.
Digite `me faz um pitch deck estilo revista para nossa rodada seed`. O formulário de perguntas interativo aparece antes de o modelo improvisar um pixel. O agente escolhe uma de cinco direções visuais curadas. Um plano `TodoWrite` ao vivo flui para a UI. O daemon constrói uma pasta de projeto real em disco com template-semente, biblioteca de layouts e checklist de auto-checagem. O agente lê tudo — pre-flight forçado — roda uma crítica em cinco dimensões contra a própria saída e emite um único `<artifact>` que renderiza num iframe sandboxed em segundos.
Isso não é "IA tentando desenhar algo". É uma IA que foi treinada, pela pilha de prompt, para se comportar como uma designer sênior com filesystem funcional, biblioteca de paleta determinística e cultura de checklist — exatamente a barra que o Claude Design colocou, mas aberta e sua.
OD se apoia em quatro ombros open-source:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — a bússola da filosofia de design. Workflow Junior-Designer, protocolo de 5 passos para asset de marca, checklist anti-AI-slop, autocrítica em 5 dimensões e a ideia "5 escolas × 20 filosofias de design" por trás do nosso direction picker — tudo destilado em [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — o modo deck. Empacotado literalmente sob [`skills/guizang-ppt/`](skills/guizang-ppt/) com o LICENSE original preservado; layouts estilo revista, hero WebGL, checklists P0/P1/P2.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — a estrela-guia de UX e nosso peer mais próximo. A primeira alternativa open-source ao Claude Design. Pegamos o loop de streaming-artifact dele, o padrão de preview em iframe sandboxed (React 18 + Babel vendored), o painel de agente ao vivo (todos + tool calls + geração interruptível) e a lista de cinco formatos de export (HTML / PDF / PPTX / ZIP / Markdown). Divergimos de propósito no form factor — eles são um app desktop Electron com [`pi-ai`][piai] embutido; nós somos um web app + daemon local que delega ao seu CLI já existente.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — a arquitetura de daemon-and-runtime. Detecção de agente por scan de PATH, daemon local como único processo privilegiado, visão de mundo agente-como-time.
## Visão geral
| | O que você ganha |
|---|---|
| **CLIs de agente (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — detectados automaticamente no `PATH`, troca em um clique |
| **Fallback BYOK** | Proxy de API por protocolo em `/api/proxy/{anthropic,openai,azure,google}/stream` — cole `baseUrl` + `apiKey` + `model`, escolha Anthropic / OpenAI / Azure OpenAI / Google Gemini, e o daemon normaliza o SSE de volta para o mesmo stream de chat. IPs internos / SSRF bloqueados na borda do daemon. |
| **Design systems built-in** | **129** — 2 starters escritos à mão + 70 sistemas de produto (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) de [`awesome-design-md`][acd2], mais 57 design skills de [`awesome-design-skills`][ads] adicionados direto em `design-systems/` |
| **Skills built-in** | **31** — 27 em modo `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 em modo `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Agrupadas no picker por `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Geração de mídia** | Imagem · vídeo · áudio entregues lado a lado com o loop de design. **gpt-image-2** (Azure / OpenAI) para pôsteres, avatares, infográficos, mapas ilustrados · **Seedance 2.0** (ByteDance) para texto-para-vídeo cinematográfico de 15s e imagem-para-vídeo · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) para motion graphics HTML→MP4 (revelações de produto, kinetic typography, gráficos de dados, overlays sociais, logo outros). **93** prompts prontos para replicar — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — em [`prompt-templates/`](prompt-templates/), com thumbnails de preview e atribuição da fonte. Mesma superfície de chat do código; saída é um `.mp4` / `.png` real entrando no workspace do projeto. |
| **Direções visuais** | 5 escolas curadas (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — cada uma trazendo paleta OKLch determinística + font stack ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Frames de dispositivo** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, compartilhados entre skills sob [`assets/frames/`](assets/frames/) |
| **Runtime de agente** | Daemon local sobe o CLI dentro da pasta do seu projeto — agente recebe `Read`, `Write`, `Bash`, `WebFetch` reais contra um ambiente real em disco, com fallbacks de Windows `ENAMETOOLONG` (stdin / arquivo de prompt) em todos os adapters |
| **Imports** | Solte um ZIP exportado do [Claude Design][cd] no welcome dialog — `POST /api/import/claude-design` parseia para um projeto real, então seu agente continua editando de onde a Anthropic parou |
| **Persistência** | SQLite em `.od/app.sqlite`: projects · conversations · messages · tabs · saved templates. Reabra amanhã, o card de todo e os arquivos abertos estão exatamente onde você deixou. |
| **Ciclo de vida** | Um único entry point: `pnpm tools-dev` (start / stop / run / status / logs / inspect / check) — sobe daemon + web (+ desktop) sob stamps tipados de sidecar |
| **Desktop** | Shell Electron opcional com renderer sandboxed + IPC sidecar (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN) — alimenta `tools-dev inspect desktop screenshot` para E2E |
| **Deployável em** | Local (`pnpm tools-dev`) · camada web Vercel · aplicativo desktop Electron empacotado para macOS (Apple Silicon) e Windows (x64) — baixe em [open-design.ai](https://open-design.ai/) ou na [release mais recente](https://github.com/nexu-io/open-design/releases) |
| **Licença** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ads]: https://github.com/bergside/awesome-design-skills
## Demo
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · Tela de entrada" /><br/>
<sub><b>Tela de entrada</b> — escolha um skill, escolha um design system, digite o brief. Mesma superfície para protótipos, decks, mobile apps, dashboards e páginas editoriais.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · Formulário de descoberta no turn 1" /><br/>
<sub><b>Formulário de descoberta no turn 1</b> — antes do modelo escrever um pixel, o OD trava o brief: superfície, audiência, tom, contexto de marca, escala. 30 segundos de radios derrotam 30 minutos de redirecionamento.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · Direction picker" /><br/>
<sub><b>Direction picker</b> — quando o usuário não tem marca, o agente emite um segundo formulário com 5 direções curadas (Monocle / Modern Minimal / Tech Utility / Brutalist / Soft Warm). Um clique em um radio → paleta determinística + font stack, sem freestyle do modelo.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · Progresso de todos ao vivo" /><br/>
<sub><b>Progresso de todos ao vivo</b> — o plano do agente é streamado como um card vivo. Atualizações <code>in_progress</code> → <code>completed</code> caem em tempo real. O usuário pode redirecionar barato, em pleno voo.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · Preview sandboxed" /><br/>
<sub><b>Preview sandboxed</b> — todo <code>&lt;artifact&gt;</code> renderiza dentro de um iframe srcdoc limpo. Editável in place via o workspace de arquivos; baixável como HTML, PDF, ZIP.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · Biblioteca de 72 sistemas" /><br/>
<sub><b>Biblioteca de 72 sistemas</b> — todo sistema de produto mostra sua assinatura de 4 cores. Clique para ver o <code>DESIGN.md</code> completo, swatch grid e showcase ao vivo.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · Deck estilo revista" /><br/>
<sub><b>Modo deck (guizang-ppt)</b> — o <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> bundled cai inalterado. Layouts estilo revista, fundos hero WebGL, saída HTML em arquivo único, export PDF.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · Protótipo mobile" /><br/>
<sub><b>Protótipo mobile</b> — chrome iPhone 15 Pro pixel-accurate (Dynamic Island, SVGs da status bar, home indicator). Protótipos multi-tela usam os assets compartilhados de <code>/frames/</code> para o agente nunca redesenhar um celular.</sub>
</td>
</tr>
</table>
## Skills
**31 skills entregues na caixa.** Cada uma é uma pasta sob [`skills/`](skills/) seguindo a convenção [`SKILL.md`][skill] do Claude Code, estendida com um frontmatter `od:` que o daemon parseia literalmente — `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)).
Dois **modos** top-level carregam o catálogo: **`prototype`** (27 skills — qualquer coisa que renderize como artifact de página única, de uma landing estilo revista a uma tela de celular a um doc de spec de PM) e **`deck`** (4 skills — apresentações com swipe horizontal, com chrome de framework de deck). O campo **`scenario`** é o que o picker usa para agrupar: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
### Showcase de exemplos
As skills visualmente mais distintas, que você provavelmente vai rodar primeiro. Cada uma traz um `example.html` real para abrir direto do repo e ver exatamente o que o agente vai produzir — sem auth, sem setup.
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>Dashboard de namoro / matchmaking de consumo — nav lateral à esquerda, ticker bar, KPIs, gráfico de matches mútuos de 30 dias, tipografia editorial.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>E-guide digital de duas páginas — capa (título, autora, teaser de TOC) + spread de aula com pull-quote e lista de passos. Tom criador / lifestyle.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>E-mail HTML de lançamento de produto de marca — masthead, imagem hero, lockup de headline, CTA, grid de specs. Coluna única centralizada, table-fallback safe.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>Protótipo mobile gamificado em três frames sobre um palco escuro de showcase — cover, missões do dia com ribbons de XP + barra de level, detalhe de missão.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>Fluxo de onboarding mobile em três frames — splash, value-prop, sign-in. Status bar, dots de swipe, CTA primária.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>Hero de motion-design em frame único com animações CSS em loop — anel de tipografia em rotação, globo animado, timer girando. Pronto para hand-off para o HyperFrames.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>Carrossel 1080×1080 de mídia social com três cards — painéis cinematográficos com headlines de display que se conectam ao longo da série, marca, affordance de loop.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>Slide explicador animado em pixel / 8-bit — palco creme em full-bleed, mascote pixel animado, tipografia de display japonesa cinética, keyframes CSS em loop.</sub>
</td>
</tr>
</table>
### Superfícies de design & marketing (modo prototype)
| Skill | Plataforma | Cenário | O que produz |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | desktop | design | HTML de página única — landings, marketing, hero pages (default do prototype) |
| [`saas-landing`](skills/saas-landing/) | desktop | marketing | Layout de marketing hero / features / pricing / CTA |
| [`dashboard`](skills/dashboard/) | desktop | operation | Admin / analytics com sidebar + layout denso de dados |
| [`pricing-page`](skills/pricing-page/) | desktop | sale | Pricing standalone + tabelas comparativas |
| [`docs-page`](skills/docs-page/) | desktop | engineering | Layout de documentação em 3 colunas |
| [`blog-post`](skills/blog-post/) | desktop | marketing | Editorial de formato longo |
| [`mobile-app`](skills/mobile-app/) | mobile | design | Tela(s) de app emolduradas em iPhone 15 Pro / Pixel |
| [`mobile-onboarding`](skills/mobile-onboarding/) | mobile | design | Fluxo de onboarding mobile multi-tela (splash · value-prop · sign-in) |
| [`gamified-app`](skills/gamified-app/) | mobile | personal | Protótipo de app mobile gamificado em três frames |
| [`email-marketing`](skills/email-marketing/) | desktop | marketing | E-mail HTML de lançamento de produto de marca (table-fallback safe) |
| [`social-carousel`](skills/social-carousel/) | desktop | marketing | Carrossel social 1080×1080 com 3 cards |
| [`magazine-poster`](skills/magazine-poster/) | desktop | marketing | Pôster de página única estilo revista |
| [`motion-frames`](skills/motion-frames/) | desktop | marketing | Hero de motion-design com animações CSS em loop |
| [`sprite-animation`](skills/sprite-animation/) | desktop | marketing | Slide explicador animado em pixel / 8-bit |
| [`dating-web`](skills/dating-web/) | desktop | personal | Mockup de dashboard de namoro de consumo |
| [`digital-eguide`](skills/digital-eguide/) | desktop | marketing | E-guide digital de duas páginas (capa + aula) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | desktop | design | Sketch de ideação à mão — para a passada "mostre algo visível cedo" |
| [`critique`](skills/critique/) | desktop | design | Scoresheet de autocrítica em cinco dimensões (Filosofia · Hierarquia · Detalhe · Função · Inovação) |
| [`tweaks`](skills/tweaks/) | desktop | design | Painel de tweaks emitidos pela IA — o modelo expõe os parâmetros que valem ajuste |
### Superfícies de deck (modo deck)
| Skill | Default para | O que produz |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **default** do deck | PPT web estilo revista — bundled literalmente de [op7418/guizang-ppt-skill][guizang], LICENSE original preservado |
| [`simple-deck`](skills/simple-deck/) | — | Deck minimalista com swipe horizontal |
| [`replit-deck`](skills/replit-deck/) | — | Deck de walkthrough de produto (estilo Replit) |
| [`weekly-update`](skills/weekly-update/) | — | Cadência semanal do time como deck swipe (progresso · bloqueios · próximos) |
### Superfícies de office & operações (modo prototype, cenários com sabor de documento)
| Skill | Cenário | O que produz |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | Doc de spec de PM com TOC + log de decisão |
| [`team-okrs`](skills/team-okrs/) | product | Scoresheet de OKR |
| [`meeting-notes`](skills/meeting-notes/) | operation | Log de decisões de reunião |
| [`kanban-board`](skills/kanban-board/) | operation | Snapshot de board |
| [`eng-runbook`](skills/eng-runbook/) | engineering | Runbook de incidente |
| [`finance-report`](skills/finance-report/) | finance | Resumo executivo financeiro |
| [`invoice`](skills/invoice/) | finance | Fatura de página única |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | Plano de onboarding por cargo |
Adicionar uma skill leva uma pasta. Leia [`docs/skills-protocol.md`](docs/skills-protocol.md) para o frontmatter estendido, forke uma skill existente, reinicie o daemon, ela aparece no picker. O endpoint de catálogo é `GET /api/skills`; a montagem do seed por skill (template + referências auxiliares) vive em `GET /api/skills/:id/example`.
## Seis ideias que sustentam o projeto
### 1 · Não despachamos um agente. O seu já basta.
O daemon escaneia seu `PATH` por [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/mariozechner/pi-ai), [`kiro-cli`](https://kiro.dev) e [`vibe-acp`](https://github.com/mistralai/mistral-vibe) na inicialização. Os que ele encontrar viram engines de design candidatas — dirigidas via stdio com um adapter por CLI, trocáveis pelo picker de modelo. Inspirado em [`multica`](https://github.com/multica-ai/multica) e [`cc-switch`](https://github.com/farion1231/cc-switch). Sem CLI instalado? O modo API é o mesmo pipeline menos o spawn — escolha Anthropic, OpenAI-compatible, Azure OpenAI ou Google Gemini, e o daemon repassa chunks SSE normalizados, com destinos loopback / link-local / RFC1918 rejeitados na borda.
### 2 · Skills são arquivos, não plugins.
Seguindo a convenção [`SKILL.md`](https://docs.anthropic.com/en/docs/claude-code/skills) do Claude Code, cada skill é `SKILL.md` + `assets/` + `references/`. Coloque uma pasta em [`skills/`](skills/), reinicie o daemon, ela aparece no picker. O `magazine-web-ppt` bundled é o [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) commitado literalmente — licença original preservada, atribuição preservada.
### 3 · Design Systems são Markdown portátil, não JSON de tema.
O schema de 9 seções de `DESIGN.md` vindo de [`VoltAgent/awesome-design-md`][acd2] — color, typography, spacing, layout, components, motion, voice, brand, anti-patterns. Todo artifact lê do sistema ativo. Troque o sistema → o próximo render usa os novos tokens. O dropdown vem com **Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Apple, Anthropic, Cursor, Supabase, Figma, Resend, Raycast, Lovable, Cohere, Mistral, ElevenLabs, X.AI, Spotify, Webflow, Sanity, PostHog, Sentry, MongoDB, ClickHouse, Cal, Replicate, Clay, Composio, Xiaohongshu…** — mais 57 design skills vindas de [`awesome-design-skills`][ads].
### 4 · O formulário interativo de perguntas evita 80% dos redirecionamentos.
A pilha de prompt do OD hardcoda uma `RULE 1`: todo brief de design fresco começa com um `<question-form id="discovery">` em vez de código. Superfície · audiência · tom · contexto de marca · escala · restrições. Um brief comprido ainda deixa decisões de design abertas — tom visual, postura de cor, escala — exatamente as coisas que o formulário trava em 30 segundos. O custo de uma direção errada é uma rodada de chat, não um deck pronto.
Esse é o **modo Junior-Designer** destilado de [`huashu-design`](https://github.com/alchaincyf/huashu-design): batch das perguntas no início, mostre algo visível cedo (mesmo que seja um wireframe com blocos cinza), deixe o usuário redirecionar barato. Combinado com o protocolo de asset de marca (localizar · baixar · `grep` hex · escrever `brand-spec.md` · vocalizar), é a maior razão para a saída parar de soar como freestyle de IA e começar a soar como uma designer que prestou atenção antes de pintar.
### 5 · O daemon faz o agente parecer estar no seu laptop, porque está.
O daemon spawna o CLI com `cwd` no diretório de artifacts do projeto sob `.od/projects/<id>/`. O agente recebe `Read`, `Write`, `Bash`, `WebFetch` — tools reais contra um filesystem real. Ele consegue `Read` no `assets/template.html` da skill, `grep` o seu CSS atrás de hex values, escrever um `brand-spec.md`, soltar imagens geradas, e produzir `.pptx` / `.zip` / `.pdf` que aparecem no workspace de arquivos como chips de download quando a turn termina. Sessions, conversations, messages e tabs persistem num SQLite local — abra o projeto amanhã e o card de todo do agente está exatamente onde você deixou.
### 6 · A pilha de prompt é o produto.
O que você compõe no envio não é "system + user". É:
```
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)
+ 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)
```
Toda camada é compositável. Toda camada é um arquivo que dá pra editar. Leia [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) e [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para ver o contrato real.
## Arquitetura
```
┌────────────────────── browser (Next.js 16) ──────────────────────┐
│ chat · file workspace · iframe preview · settings · imports │
└──────────────┬───────────────────────────────────┬───────────────┘
│ /api/* (rewritten in dev) │
▼ ▼
┌──────────────────────────────────┐ /api/proxy/{provider}/stream (SSE)
│ Local daemon (Express + SQLite) │ ─→ any OpenAI-compat
│ │ endpoint (BYOK)
│ /api/agents /api/skills│ w/ SSRF blocking
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/{provider}/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (static) /frames (static)
│ optional: sidecar IPC at /tmp/open-design/ipc/<ns>/<app>.sock
│ (STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN)
└─────────┬────────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · devin (ACP) · gemini · opencode · cursor-agent │
│ qwen · qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) · kiro (ACP) · vibe (ACP) │
│ reads SKILL.md + DESIGN.md, writes artifacts to disk │
└──────────────────────────────────────────────────────────────────┘
```
| Camada | Stack |
|---|---|
| Frontend | Next.js 16 App Router + React 18 + TypeScript, deployável na Vercel |
| Daemon | Node 24 · Express · streaming SSE · `better-sqlite3`; tabelas: `projects` · `conversations` · `messages` · `tabs` · `templates` |
| Transporte do agente | `child_process.spawn`; parsers de eventos tipados para `claude-stream-json` (Claude Code), `qoder-stream-json` (Qoder CLI), `copilot-stream-json` (Copilot), parsers `json-event-stream` por CLI (Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe via Agent Client Protocol), `pi-rpc` (Pi via stdio JSON-RPC), `plain` (Qwen Code / DeepSeek TUI) |
| Proxy BYOK | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → APIs upstream específicas por provider, SSE normalizado em `delta/end/error`; rejeita hosts loopback / link-local / RFC1918 na borda do daemon |
| Storage | Arquivos planos em `.od/projects/<id>/` + SQLite em `.od/app.sqlite` + credenciais em `.od/media-config.json` (gitignored, autocriado). `OD_DATA_DIR=<dir>` realoca todos os dados do daemon (usado para isolamento de teste e setups com instalação read-only); `OD_MEDIA_CONFIG_DIR=<dir>` afunila o override apenas para `media-config.json`, em setups que querem manter chaves de API fora do diretório de dados |
| Preview | Iframe sandboxed via `srcdoc` + parser `<artifact>` por skill ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
| Export | HTML (assets inline) · PDF (browser print, deck-aware) · PPTX (orientado pelo agente via skill) · ZIP (archiver) · Markdown |
| Ciclo de vida | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`; portas via `--daemon-port` / `--web-port`, namespaces via `--namespace` |
| Desktop (opcional) | Shell Electron — descobre a URL do web via IPC sidecar, sem chute de porta; o mesmo canal `STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN` alimenta `tools-dev inspect desktop …` para E2E |
## Quickstart
### Baixe o aplicativo desktop (sem build necessário)
A maneira mais rápida de experimentar o Open Design é o aplicativo desktop pré-compilado — sem Node, sem pnpm, sem clone:
- **[open-design.ai](https://open-design.ai/)** — página oficial de downloads
- **[Releases do GitHub](https://github.com/nexu-io/open-design/releases)**
### Executar a partir do código-fonte
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # should print 10.33.2
pnpm install
pnpm tools-dev run web
# open the web URL printed by tools-dev
```
Inicializador do Windows: compile `OpenDesign.exe` com as instruções em `tools/launcher/README.md` ou baixe-o pelo GitHub Releases. Depois coloque-o na raiz do repo e dê dois cliques para executar `pnpm install` se necessário e iniciar o Open Design com `pnpm tools-dev`.
Requisitos de ambiente: Node `~24` e pnpm `10.33.x`. `nvm`/`fnm` são apenas helpers opcionais; se você usa um, rode `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` antes do `pnpm install`.
Para startup desktop/background, restart com porta fixa e checagens do dispatcher de geração de mídia (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), veja [`QUICKSTART.pt-BR.md`](QUICKSTART.pt-BR.md).
No primeiro carregamento:
1. Detecta quais CLIs de agente você tem no `PATH` e escolhe um automaticamente.
2. Carrega 31 skills + 72 design systems.
3. Abre o welcome dialog para você colar uma chave Anthropic (só necessária para o caminho de fallback BYOK).
4. **Cria automaticamente `./.od/`** — a pasta de runtime local para o SQLite de projetos, artifacts por projeto e renders salvos. Não há passo `od init`; o daemon `mkdir`a tudo no boot.
Digite um prompt, clique em **Send**, veja o formulário de perguntas chegar, preencha, veja o card de todo streamando, veja o artifact renderizar. Clique em **Save to disk** ou baixe como ZIP do projeto.
### Estado de primeira execução (`./.od/`)
O daemon dono de uma única pasta oculta na raiz do repo. Tudo nela é gitignored e local da máquina — nunca faça commit.
```
.od/
├── app.sqlite ← projects · conversations · messages · open tabs
├── artifacts/ ← one-off "Save to disk" renders (timestamped)
└── projects/<id>/ ← per-project working dir, also the agent's cwd
```
| Quando você quiser… | Faça isto |
|---|---|
| Inspecionar o que tem dentro | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| Resetar para um estado limpo | `pnpm tools-dev stop`, `rm -rf .od`, rode `pnpm tools-dev run web` de novo |
| Mover para outro lugar | ainda não suportado — o caminho é hard-coded relativo ao repo |
Mapa completo de arquivos, scripts e troubleshooting → [`QUICKSTART.pt-BR.md`](QUICKSTART.pt-BR.md).
## Estrutura do repositório
```
open-design/
├── README.md ← this file
├── README.pt-BR.md ← Português (Brasil)
├── README.de.md ← Deutsch
├── README.ru.md ← Русский
├── README.zh-CN.md ← 简体中文
├── QUICKSTART.md ← run / build / deploy guide
├── package.json ← pnpm workspace, single bin: od
├── apps/
│ ├── daemon/ ← Node + Express, the only server
│ │ ├── src/ ← TypeScript daemon source
│ │ │ ├── cli.ts ← `od` bin source, compiled to dist/cli.js
│ │ │ ├── server.ts ← /api/* routes (projects, chat, files, exports)
│ │ │ ├── agents.ts ← PATH scanner + per-CLI argv builders
│ │ │ ├── claude-stream.ts ← streaming JSON parser for Claude Code stdout
│ │ │ ├── skills.ts ← SKILL.md frontmatter loader
│ │ │ └── db.ts ← SQLite schema (projects/messages/templates/tabs)
│ │ ├── sidecar/ ← tools-dev daemon sidecar wrapper
│ │ └── tests/ ← daemon package tests
│ │
│ └── web/ ← Next.js 16 App Router + React client
│ ├── app/ ← App Router entrypoints
│ ├── next.config.ts ← dev rewrites + prod static export to out/
│ └── src/ ← React + TypeScript client modules
│ ├── App.tsx ← routing, bootstrap, settings
│ ├── components/ ← chat, composer, picker, preview, sketch, …
│ ├── prompts/
│ │ ├── system.ts ← composeSystemPrompt(base, skill, DS, metadata)
│ │ ├── discovery.ts ← turn-1 form + turn-2 branch + 5-dim critique
│ │ └── directions.ts ← 5 visual directions × OKLch palette + font stack
│ ├── artifacts/ ← streaming <artifact> parser + manifests
│ ├── runtime/ ← iframe srcdoc, markdown, export helpers
│ ├── providers/ ← daemon SSE + BYOK API transports
│ └── state/ ← config + projects (localStorage + daemon-backed)
├── e2e/ ← Playwright UI + external integration/Vitest harness
├── packages/
│ ├── contracts/ ← shared web/daemon app contracts
│ ├── sidecar-proto/ ← Open Design sidecar protocol contract
│ ├── sidecar/ ← generic sidecar runtime primitives
│ └── platform/ ← generic process/platform primitives
├── skills/ ← 31 SKILL.md skill bundles (27 prototype + 4 deck)
│ ├── web-prototype/ ← default for prototype mode
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← deck mode
│ └── guizang-ppt/ ← bundled magazine-web-ppt (default for deck)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 DESIGN.md systems
│ ├── default/ ← Neutral Modern (starter)
│ ├── warm-editorial/ ← Warm Editorial (starter)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md ← catalog overview
├── assets/
│ └── frames/ ← shared device frames (used cross-skill)
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ ├── deck-framework.html ← deck baseline (nav / counter / print)
│ └── kami-deck.html ← kami-flavored deck starter (parchment / ink-blue serif)
├── scripts/
│ └── sync-design-systems.ts ← re-import upstream awesome-design-md tarball
├── docs/
│ ├── spec.md ← product spec, scenarios, differentiation
│ ├── architecture.md ← topologies, data flow, components
│ ├── skills-protocol.md ← extended SKILL.md od: frontmatter
│ ├── agent-adapters.md ← per-CLI detection + dispatch
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← long-form provenance
│ ├── roadmap.md ← phased delivery
│ ├── schemas/ ← JSON schemas
│ └── examples/ ← canonical artifact examples
└── .od/ ← runtime data, gitignored, auto-created
├── app.sqlite ← projects / conversations / messages / tabs
├── projects/<id>/ ← per-project working folder (agent's cwd)
└── artifacts/ ← saved one-off renders
```
## Design Systems
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="A biblioteca de 72 design systems — spread de style guide" width="100%" />
</p>
72 sistemas na caixa, cada um como um único [`DESIGN.md`](design-systems/README.md):
<details>
<summary><b>Catálogo completo</b> (clique para expandir)</summary>
**AI & LLM**`claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**Developer Tools**`cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**Productivity**`notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**Fintech**`stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**E-Commerce**`shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**Media**`spotify` · `playstation` · `wired` · `theverge` · `meta`
**Automotive**`tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**Other**`apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**Starters**`default` (Neutral Modern) · `warm-editorial`
</details>
A biblioteca de sistemas de produto é importada via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) de [`VoltAgent/awesome-design-md`][acd2]. Re-rode para atualizar. As 57 design skills vêm de [`bergside/awesome-design-skills`][ads] e são adicionadas direto em `design-systems/`.
## Direções visuais
Quando o usuário não tem brand spec, o agente emite um segundo formulário com cinco direções curadas — a adaptação do OD do [fallback "5 escolas × 20 filosofias de design" do `huashu-design`](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback). Cada direção é uma spec determinística — paleta em OKLch, font stack, dicas de postura de layout, referências — que o agente coloca literalmente no `:root` do template-semente. Um clique de radio → um sistema visual totalmente especificado. Sem improviso, sem AI-slop.
| Direção | Mood | Refs |
|---|---|---|
| Editorial — Monocle / FT | Revista impressa, tinta + creme + ferrugem quente | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | Frio, estruturado, acento mínimo | Linear · Vercel · Stripe |
| Tech utility | Densidade de informação, monoespaçada, terminal | Bloomberg · ferramentas Bauhaus |
| Brutalist | Cru, tipografia gigante, sem sombra, acentos duros | Bloomberg Businessweek · Achtung |
| Soft warm | Generoso, baixo contraste, neutros pessegos | Marketing da Notion · Apple Health |
Spec completa → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
## Geração de mídia
O OD não para no código. A mesma superfície de chat que produz HTML `<artifact>` também dirige geração de **imagem**, **vídeo** e **áudio**, com adapters de modelo plugados no pipeline de mídia do daemon ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Todo render cai como arquivo real no workspace do projeto — `.png` para imagem, `.mp4` para vídeo — e aparece como chip de download quando a turn termina.
Três famílias de modelo carregam o peso hoje:
| Superfície | Modelo | Provider | Para quê |
|---|---|---|---|
| **Imagem** | `gpt-image-2` | Azure / OpenAI | Pôsteres, avatares de perfil, mapas ilustrados, infográficos, cards sociais estilo revista, restauração de foto, arte explodida de produto |
| **Vídeo** | `seedance-2.0` | ByteDance Volcengine | t2v + i2v cinematográfico de 15s com áudio — shorts narrativos, close-ups de personagem, filmes de produto, coreografia estilo MV |
| **Vídeo** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | Motion graphics HTML→MP4 — revelações de produto, kinetic typography, gráficos de dados, overlays sociais, logo outros, verticais estilo TikTok com legendas em karaokê |
Uma **galeria de prompts** crescente em [`prompt-templates/`](prompt-templates/) entrega **93 prompts prontos para replicar** — 43 de imagem (`prompt-templates/image/*.json`), 39 Seedance (`prompt-templates/video/*.json` excluindo `hyperframes-*`), 11 HyperFrames (`prompt-templates/video/hyperframes-*.json`). Cada um carrega thumbnail de preview, corpo do prompt literal, modelo alvo, aspect ratio e bloco `source` para licença + atribuição. O daemon serve em `GET /api/prompt-templates`, o app web os mostra como grid de cards nas tabs **Image templates** e **Video templates** da tela de entrada; um clique solta o prompt no composer com o modelo certo pré-selecionado.
### gpt-image-2 — galeria de imagens (amostra de 43)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="Evolução em escada de pedra 3D" /><br/><sub><b>Infográfico de Evolução em Escada de Pedra 3D</b><br/>Infográfico de 3 passos, estética de pedra esculpida</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Mapa Ilustrado de Comida" /><br/><sub><b>Mapa Ilustrado de Comida da Cidade</b><br/>Pôster de viagem editorial ilustrado à mão</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cena Cinematográfica de Elevador" /><br/><sub><b>Cena Cinematográfica de Elevador</b><br/>Still editorial de moda em frame único</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Retrato Anime Cyberpunk" /><br/><sub><b>Retrato Anime Cyberpunk</b><br/>Avatar de perfil — texto neon no rosto</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Mulher Glamurosa de Preto" /><br/><sub><b>Retrato de Mulher Glamurosa de Preto</b><br/>Retrato editorial de estúdio</sub></td>
</tr>
</table>
Set completo → [`prompt-templates/image/`](prompt-templates/image/). Fontes: a maioria vem de [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts) (CC-BY-4.0), com atribuição autoral preservada por template.
### Seedance 2.0 — galeria de vídeos (amostra de 39)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Podcast de Música e Violão" /></a><br/><sub><b>Podcast de Música & Técnica de Violão</b><br/>Filme cinematográfico de estúdio em 4K</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Rosto Emocional" /></a><br/><sub><b>Close-up de Rosto Emocional</b><br/>Estudo cinematográfico de microexpressão</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Supercarro de Luxo" /></a><br/><sub><b>Supercarro de Luxo Cinematográfico</b><br/>Filme narrativo de produto</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Gato da Cidade Proibida" /></a><br/><sub><b>Sátira do Gato da Cidade Proibida</b><br/>Sátira estilizada curta</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Romance Japonês" /></a><br/><sub><b>Curta de Romance Japonês</b><br/>Narrativa Seedance 2.0 de 15s</sub></td>
</tr>
</table>
Clique em qualquer thumbnail para tocar o MP4 renderizado de fato. Set completo → [`prompt-templates/video/`](prompt-templates/video/) (entradas `*-seedance-*` e marcadas como Cinematic). Fontes: [`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts) (CC-BY-4.0), com links originais de tweet e handles dos autores preservados.
### HyperFrames — motion graphics HTML→MP4 (11 templates prontos)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) é o framework open-source de vídeo agent-native da HeyGen — você (ou o agente) escreve HTML + CSS + GSAP, o HyperFrames renderiza em MP4 determinístico via headless Chrome + FFmpeg. O Open Design despacha o HyperFrames como modelo de vídeo de primeira classe (`hyperframes-html`) plugado ao dispatch do daemon, mais a skill `skills/hyperframes/` que ensina ao agente o contrato de timeline, regras de transição entre cenas, padrões audio-reativos, captions/TTS e os blocos do catálogo (`npx hyperframes add <slug>`).
Onze prompts hyperframes vivem em [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/), cada um sendo um brief concreto que produz um arquétipo específico:
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Reveal de produto" /></a><br/><sub><b>Reveal de produto minimalista de 5s</b> · 16:9 · push-in title card com transição shader</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="Promo SaaS" /></a><br/><sub><b>Promo de produto SaaS de 30s</b> · 16:9 · estilo Linear/ClickUp com reveals 3D de UI</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaokê" /></a><br/><sub><b>Talking-head karaokê TikTok</b> · 9:16 · TTS + legendas word-synced</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Sizzle reel" /></a><br/><sub><b>Sizzle reel de marca de 30s</b> · 16:9 · kinetic typography sincronizada na batida, audio-reativa</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Gráfico de dados" /></a><br/><sub><b>Bar-chart race animada</b> · 16:9 · infográfico de dados estilo NYT</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Mapa de voo" /></a><br/><sub><b>Mapa de voo (origem → destino)</b> · 16:9 · reveal cinematográfico de rota estilo Apple</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>Logo outro cinematográfico de 4s</b> · 16:9 · montagem peça-por-peça + bloom</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Contador de dinheiro" /></a><br/><sub><b>Contador $0 → $10K</b> · 9:16 · hype estilo Apple com flash verde + burst</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>Showcase de app em 3 celulares</b> · 16:9 · celulares flutuantes com callouts de feature</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Overlay social" /></a><br/><sub><b>Stack de overlays sociais</b> · 9:16 · X · Reddit · Spotify · Instagram em sequência</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Site para vídeo" /></a><br/><sub><b>Pipeline site-para-vídeo</b> · 16:9 · captura site em 3 viewports + transições</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
Padrão é o mesmo do resto: pegue um template, edite o brief, envie. O agente lê o `skills/hyperframes/SKILL.md` bundled (que carrega o workflow de render específico do OD — composição de arquivos-fonte em um `.hyperframes-cache/` para não poluir o workspace de arquivos, daemon despacha `npx hyperframes render` para fugir do hang sandbox-exec / Puppeteer do macOS, só o `.mp4` final cai como chip de projeto), autoriza a composição e entrega um MP4. Thumbnails dos blocos do catálogo © HeyGen, servidos do CDN deles; o framework OSS em si é Apache-2.0.
> **Também plugados, mas ainda sem templates de superfície:** Kling 2.0 / 1.6 / 1.5, Veo 3 / Veo 2, Sora 2 / Sora 2-Pro (via Fal), MiniMax video-01 — todos vivem em `VIDEO_MODELS` ([`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Suno v5 / v4.5, Udio v2, Lyria 2 (música) e gpt-4o-mini-tts, MiniMax TTS (fala) cobrem a superfície de áudio. Templates para esses são contribuições abertas — solte um JSON em `prompt-templates/video/` ou `prompt-templates/audio/` e ele aparece no picker.
## Além do chat — o que mais entregamos
O loop chat / artifact é o destaque, mas algumas capacidades menos visíveis já estão plugadas e valem conhecer antes de comparar o OD com qualquer outra coisa:
- **Importação de ZIP do Claude Design.** Solte um export do claude.ai no welcome dialog. `POST /api/import/claude-design` extrai para um `.od/projects/<id>/` real, abre o arquivo de entrada como tab e prepara um prompt continue-de-onde-a-Anthropic-parou para o seu agente local. Sem reprompt, sem "peça ao modelo para recriar o que acabamos de ter". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
- **Proxy BYOK multi-provider.** `POST /api/proxy/{anthropic,openai,azure,google}/stream` recebe `{ baseUrl, apiKey, model, messages }`, monta a requisição upstream específica do provider, normaliza chunks SSE em `delta/end/error` e rejeita destinos loopback / link-local / RFC1918 para evitar SSRF. OpenAI-compatível cobre OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter e vLLM self-hosted; Azure OpenAI adiciona URL de deployment + `api-version`; Google usa Gemini `:streamGenerateContent`.
- **Templates salvos pelo usuário.** Quando você gosta de um render, `POST /api/templates` faz snapshot do HTML + metadados na tabela `templates` do SQLite. O próximo projeto pega ele numa linha "your templates" no picker — mesma superfície dos 31 entregues, mas seu.
- **Persistência de tabs.** Todo projeto lembra os arquivos abertos e a tab ativa na tabela `tabs`. Reabra o projeto amanhã e o workspace está exatamente como você deixou.
- **API de lint de artifact.** `POST /api/artifacts/lint` roda checagens estruturais num artifact gerado (framing `<artifact>` quebrado, side files obrigatórios faltando, tokens de paleta velhos) e devolve findings que o agente pode reler na próxima turn. A autocrítica de 5-dim usa isso para ancorar a nota em evidência real, não em vibes.
- **Protocolo de sidecar + automação desktop.** Daemon, web e desktop carregam stamps tipados de cinco campos (`app · mode · namespace · ipc · source`) e expõem um canal IPC JSON-RPC em `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status \| eval \| screenshot` dirige esse canal, então E2E headless funciona contra um shell Electron real sem harnesses customizados ([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)).
- **Spawn amigável a Windows.** Todo adapter que estouraria o limite de ~32 KB de argv do `CreateProcess` em prompts compostos longos (Codex, Gemini, OpenCode, Cursor Agent, Qwen, Qoder CLI, Pi) entrega o prompt via stdin. Claude Code e Copilot ficam com `-p`; o daemon faz fallback para arquivo temporário de prompt quando até isso transborda.
- **Dados de runtime por namespace.** `OD_DATA_DIR` e `--namespace` te dão árvores estilo `.od/` totalmente isoladas, então Playwright, canais beta e seus projetos reais nunca compartilham um arquivo SQLite.
## Maquinário anti-AI-slop
Toda a maquinaria abaixo é o playbook do [`huashu-design`](https://github.com/alchaincyf/huashu-design), portado para a pilha de prompt do OD e exigível por skill via o pre-flight de side files. Leia [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) para o texto vivo:
- **Formulário de perguntas primeiro.** O turn 1 é só `<question-form>` — sem pensar, sem tools, sem narração. O usuário escolhe defaults na velocidade de um radio.
- **Extração de brand-spec.** Quando o usuário anexa um screenshot ou URL, o agente roda um protocolo de cinco passos (localizar · baixar · grep hex · codificar `brand-spec.md` · vocalizar) antes de escrever CSS. **Nunca chuta cores de marca de memória.**
- **Crítica em 5-dim.** Antes de emitir `<artifact>`, o agente silenciosamente nota o output de 1 a 5 em filosofia / hierarquia / execução / especificidade / contenção. Qualquer coisa abaixo de 3/5 é regressão — corrija e renote. Duas passadas é normal.
- **Checklist P0/P1/P2.** Toda skill traz um `references/checklist.md` com gates duros P0. O agente precisa passar P0 antes de emitir.
- **Blacklist de slop.** Gradiente roxo agressivo, ícones genéricos de emoji, card arredondado com borda lateral de destaque, humanos SVG desenhados à mão, Inter como fonte de *display*, métricas inventadas — explicitamente proibidos no prompt.
- **Placeholder honesto > stat falsa.** Quando o agente não tem um número real, ele escreve `—` ou um bloco cinza com label, não "10× mais rápido".
## Comparação
| Eixo | [Claude Design][cd] (Anthropic) | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| Licença | Closed | MIT | **Apache-2.0** |
| Form factor | Web (claude.ai) | Desktop (Electron) | **Web app + daemon local** |
| Deployável na Vercel | ❌ | ❌ | **✅** |
| Runtime de agente | Bundled (Opus 4.7) | Bundled ([`pi-ai`][piai]) | **Delegado ao CLI já existente do usuário** |
| Skills | Proprietárias | 12 módulos TS customizados + `SKILL.md` | **31 bundles [`SKILL.md`][skill] em arquivo, drop-in** |
| Design system | Proprietário | `DESIGN.md` (roadmap v0.2) | **`DESIGN.md` × 129 sistemas entregues** |
| Flexibilidade de provider | Só Anthropic | 7+ via [`pi-ai`][piai] | **14 adapters de CLI + proxy BYOK OpenAI-compatible** |
| Form de perguntas inicial | ❌ | ❌ | **✅ Regra dura, turn 1** |
| Direction picker | ❌ | ❌ | **✅ 5 direções determinísticas** |
| Progresso de todos ao vivo + stream de tools | ❌ | ✅ | **✅** (padrão UX vindo do open-codesign) |
| Preview em iframe sandboxed | ❌ | ✅ | **✅** (padrão vindo do open-codesign) |
| Importação de ZIP do Claude Design | n/a | ❌ | **`POST /api/import/claude-design` — continue de onde a Anthropic parou** |
| Edições cirúrgicas em modo comentário | ❌ | ✅ | 🟡 parcial — comentários por elemento de preview + anexos no chat; confiabilidade do patch cirúrgico ainda em andamento |
| Painel de tweaks emitido pela IA | ❌ | ✅ | 🚧 roadmap — UX dedicada de painel ao lado do chat ainda não está implementada |
| Workspace nível filesystem | ❌ | parcial (sandbox Electron) | **✅ cwd real, tools reais, SQLite persistido (projects · conversations · messages · tabs · templates)** |
| Autocrítica em 5-dim | ❌ | ❌ | **✅ Gate pré-emit** |
| Lint de artifact | ❌ | ❌ | **`POST /api/artifacts/lint` — findings devolvidos ao agente** |
| IPC de sidecar + desktop headless | ❌ | ❌ | **✅ Processos com stamps + `tools-dev inspect desktop status \| eval \| screenshot`** |
| Formatos de export | Limitado | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTX (orientado pelo agente) / ZIP / Markdown** |
| Reuso de skill PPT | N/A | Built-in | **[`guizang-ppt-skill`][guizang] cai inalterado (default do deck mode)** |
| Cobrança mínima | Pro / Max / Team | BYOK | **BYOK — cole qualquer `baseUrl` OpenAI-compatible** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## Agentes de código suportados
Detectados automaticamente do `PATH` no boot do daemon. Sem config necessária. O dispatch streaming vive em [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) (`AGENT_DEFS`); parsers por CLI vivem ao lado. Modelos são populados sondando `<bin> --list-models` / `<bin> models` / handshake ACP, ou via lista fallback curada quando o CLI não expõe lista.
| Agente | Bin | Formato de stream | Forma do argv (caminho de prompt composto) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json` (eventos tipados) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + parser `codex` | `codex exec --json --skip-git-repo-check --full-auto [-C cwd] [--model …] [-c model_reasoning_effort=…] -` (prompt no stdin) |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + parser `gemini` | `GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo [--model …]` (prompt no stdin) |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + parser `opencode` | `opencode run --format json --dangerously-skip-permissions [--model …] -` (prompt no stdin) |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + parser `cursor-agent` | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -` (prompt no stdin) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain` (chunks crus de stdout) | `qwen --yolo [--model …] -` (prompt no stdin) |
| Qoder CLI | `qodercli` | `qoder-stream-json` (eventos tipados) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]` (prompt no stdin) |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json` (eventos tipados) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc` (Agent Client Protocol) | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain` (raw stdout chunks) | `deepseek exec --auto [--model …] <prompt>` |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc` (stdio JSON-RPC) | `pi --mode rpc [--model …] [--thinking …]` (prompt enviado como comando RPC `prompt`) |
| **BYOK multi-provider** | n/a | Normalização SSE | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI-compatible / Azure OpenAI / Gemini; com guarda SSRF contra loopback / link-local / RFC1918 |
Adicionar um novo CLI é uma entrada em [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). O formato de stream é um de `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream` (com `eventParser` por CLI), `acp-json-rpc`, `pi-rpc` ou `plain`.
## Referências & linhagem
Todo projeto externo do qual este repo emprestou. Cada link aponta para a fonte para você verificar a procedência.
| Projeto | Papel aqui |
|---|---|
| [`Claude Design`][cd] | O produto closed-source ao qual este repo é alternativa open-source. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | O núcleo de filosofia de design. Workflow Junior-Designer, protocolo de 5 passos para asset de marca, checklist anti-AI-slop, autocrítica em 5 dimensões e a biblioteca "5 escolas × 20 filosofias de design" por trás do nosso direction picker — tudo destilado em [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) e [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Skill magazine-web-PPT bundled literalmente sob [`skills/guizang-ppt/`](skills/guizang-ppt/) com LICENSE original preservado. Default do deck mode. Cultura de checklist P0/P1/P2 emprestada para todas as outras skills. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | A arquitetura de daemon + adapter. Detecção de agente por scan de PATH, daemon local como único processo privilegiado, visão de mundo agente-como-time. Adotamos o modelo; não vendoramos o código. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | A primeira alternativa open-source ao Claude Design e nosso peer mais próximo. Padrões UX adotados: loop streaming-artifact, preview em iframe sandboxed (React 18 + Babel vendored), painel de agente ao vivo (todos + tool calls + interruptível), lista de cinco formatos de export (HTML/PDF/PPTX/ZIP/Markdown), hub de storage local-first, injeção de gosto via `SKILL.md` e a primeira passada de anotações de preview em modo comentário. Padrões UX ainda no nosso roadmap: confiabilidade plena de edição cirúrgica e painel de tweaks emitido pela IA. **Deliberadamente não vendoramos [`pi-ai`][piai]** — o open-codesign embute como runtime de agente; nós delegamos para o CLI que o usuário já tem. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Fonte do schema de 9 seções do `DESIGN.md` e dos 70 sistemas de produto importados via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). |
| [`bergside/awesome-design-skills`][ads] | Fonte das 57 design skills adicionadas direto como arquivos `DESIGN.md` normalizados sob `design-systems/`. |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | Inspiração para distribuição de skills via symlink entre múltiplos CLIs de agente. |
| [Claude Code skills][skill] | A convenção `SKILL.md` adotada literalmente — qualquer skill do Claude Code cai em `skills/` e é detectada pelo daemon. |
Procedência em formato longo — o que pegamos de cada um, o que deliberadamente não pegamos — vive em [`docs/references.md`](docs/references.md).
## Roadmap
- [x] Daemon + detecção de agente (14 adapters de CLI) + registry de skills + catálogo de design system
- [x] Web app + chat + formulário de perguntas + picker de 5 direções + progresso de todos + preview sandboxed
- [x] 31 skills + 72 design systems + 5 direções visuais + 5 frames de dispositivo
- [x] Projects · conversations · messages · tabs · templates lastreados em SQLite
- [x] Proxy BYOK multi-provider (`/api/proxy/{anthropic,openai,azure,google}/stream`) com guarda SSRF
- [x] Importação de ZIP do Claude Design (`/api/import/claude-design`)
- [x] Protocolo de sidecar + desktop Electron com automação IPC (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN)
- [x] API de lint de artifact + gate de autocrítica 5-dim pré-emit
- [ ] Edições cirúrgicas em modo comentário — parcial entregue: comentários por elemento de preview e anexos de chat; patch alvo confiável segue em andamento
- [ ] UX do painel de tweaks emitido pela IA — ainda não implementado
- [ ] Receita de deploy Vercel + tunnel (Topologia B)
- [ ] `npx od init` em um comando para fazer scaffold de um projeto com `DESIGN.md`
- [ ] Marketplace de skills (`od skills install <github-repo>`) e superfície CLI `od skill add | list | remove | test` (rascunhada em [`docs/skills-protocol.md`](docs/skills-protocol.md), implementação pendente)
- [x] Build Electron empacotado a partir de `apps/packaged/` — downloads para macOS (Apple Silicon) e Windows (x64) em [open-design.ai](https://open-design.ai/) e na [página de releases do GitHub](https://github.com/nexu-io/open-design/releases)
Entrega faseada → [`docs/roadmap.md`](docs/roadmap.md).
## Status
Esta é uma implementação inicial — o loop fechado (detectar → escolher skill + design system → chat → parsear `<artifact>` → preview → salvar) roda end-to-end. A pilha de prompt e a biblioteca de skills é onde mora a maior parte do valor, e estão estáveis. A UI no nível de componente está sendo entregue diariamente.
## Dê uma estrela
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="Dê estrela ao Open Design no GitHub — github.com/nexu-io/open-design" width="100%" /></a>
</p>
Se isso te poupou trinta minutos — dá um ★. Estrelas não pagam aluguel, mas dizem para a próxima designer, agente e contribuidora que esse experimento vale a atenção. Um clique, três segundos, sinal real: [github.com/nexu-io/open-design](https://github.com/nexu-io/open-design).
## Contribuindo
Issues, PRs, novas skills e novos design systems são todos bem-vindos. As contribuições com maior alavancagem geralmente são uma pasta, um arquivo Markdown ou um adapter do tamanho de um PR:
- **Adicione uma skill** — solte uma pasta em [`skills/`](skills/) seguindo a convenção [`SKILL.md`][skill].
- **Adicione um design system** — solte um `DESIGN.md` em [`design-systems/<marca>/`](design-systems/) usando o schema de 9 seções.
- **Plugue um novo CLI de agente de código** — uma entrada em [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts).
Walkthrough completo, barra para mergear, estilo de código e o que não aceitamos → [`CONTRIBUTING.pt-BR.md`](CONTRIBUTING.pt-BR.md) ([English](CONTRIBUTING.md), [Deutsch](CONTRIBUTING.de.md), [Français](CONTRIBUTING.fr.md), [简体中文](CONTRIBUTING.zh-CN.md)).
## Contribuidoras e contribuidores
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-06" 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.
## Atividade do repositório
<picture>
<img alt="Open Design — métricas do repositório" src="docs/assets/github-metrics.svg" />
</picture>
O SVG acima é regenerado diariamente por [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) usando [`lowlighter/metrics`](https://github.com/lowlighter/metrics). Dispare um refresh manual pela aba **Actions** se quiser antes; para plugins mais ricos (tráfego, follow-up time), adicione um secret de repositório `METRICS_TOKEN` com um PAT fine-grained.
## Star History
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<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-06" />
</picture>
</a>
Se a curva sobe, é o sinal que a gente procura. ★ esse repo para empurrar.
## Créditos
A família de skills HTML PPT Studio — a master [`skills/html-ppt/`](skills/html-ppt/) e os wrappers por template em [`skills/html-ppt-*/`](skills/) (15 templates de deck completo, 36 temas, 31 layouts de página única, 27 animações CSS + 20 canvas FX, runtime de teclado e modo apresentador com magnetic cards) — é integrada do projeto open-source [`lewislulu/html-ppt-skill`](https://github.com/lewislulu/html-ppt-skill) (MIT). O LICENSE upstream está in-tree em [`skills/html-ppt/LICENSE`](skills/html-ppt/LICENSE) e o crédito autoral vai para [@lewislulu](https://github.com/lewislulu). Cada card de Examples por template (`html-ppt-pitch-deck`, `html-ppt-tech-sharing`, `html-ppt-presenter-mode`, `html-ppt-xhs-post`, …) delega a orientação de autoria para a master skill, então o comportamento prompt → output do upstream é preservado end-to-end ao clicar em **Use this prompt**.
O fluxo magazine / horizontal-swipe deck em [`skills/guizang-ppt/`](skills/guizang-ppt/) é integrado de [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) (MIT). Crédito autoral para [@op7418](https://github.com/op7418).
## Licença
Apache-2.0. O bundled `skills/guizang-ppt/` mantém seu [LICENSE](skills/guizang-ppt/LICENSE) original (MIT) e atribuição de autoria a [op7418](https://github.com/op7418). O bundled `skills/html-ppt/` mantém seu [LICENSE](skills/html-ppt/LICENSE) original (MIT) e atribuição de autoria a [lewislulu](https://github.com/lewislulu).

756
README.ru.md Normal file
View File

@@ -0,0 +1,756 @@
# 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-прокси даёт тот же цикл без локального запуска агента.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — редакционная обложка: дизайн вместе с агентом на вашем ноутбуке" width="100%" />
</p>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="Скачать" src="https://img.shields.io/badge/%D1%81%D0%BA%D0%B0%D1%87%D0%B0%D1%82%D1%8C-open--design.ai-ff6b35?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#поддерживаемые-coding-agent-cli"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#системы-дизайна"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#навыки"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-присоединиться-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <b>Русский</b> · <a href="README.uk.md">Українська</a></p>
---
## Зачем это существует
Anthropic [Claude Design][cd] (выпущен 2026-04-17, на Opus 4.7) показал, что происходит, когда LLM перестаёт писать прозу и начинает выдавать готовые дизайн-артефакты. Продукт моментально стал вирусным — и остался закрытым, платным, облачным и жёстко привязанным к модели Anthropic и внутренним навыкам Anthropic. Никакого checkout, никакого self-host, никакого деплоя на Vercel, никакой замены агента на своего.
**Open Design (OD) — открытая альтернатива.** Тот же цикл, та же логика artifact-first, но без lock-in. Мы не поставляем собственного агента: самые сильные coding-агенты уже стоят у вас на ноутбуке. Мы связываем их с skill-driven workflow для дизайна, который запускается локально через `pnpm tools-dev`, умеет выкладывать web-слой на Vercel и остаётся BYOK на каждом уровне.
Введите `make me a magazine-style pitch deck for our seed round`. Ещё до того, как модель импровизирует хоть один пиксель, появляется интерактивная форма вопросов. Агент выбирает одно из пяти отобранных визуальных направлений. Живой план `TodoWrite` стримится в UI. Демон создаёт на диске реальную проектную папку с seed-шаблоном, библиотекой раскладок и checklistом самопроверки. Агент читает их — pre-flight обязателен — прогоняет пятимерную критику собственного результата и выдаёт единый `<artifact>`, который через несколько секунд рендерится в sandboxed iframe.
Это не «ИИ пытается что-то задизайнить». Это ИИ, который prompt stack приучил вести себя как senior-дизайнер с рабочей файловой системой, детерминированной библиотекой палитр и культурой checklistов — ровно та планка, которую задал Claude Design, только в открытом и вашем варианте.
OD стоит на плечах четырёх open-source проектов:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — философский компас дизайна. Junior-Designer workflow, 5-step protocol для brand assets, anti-AI-slop checklist, 5-dimensional self-critique и идея «5 schools × 20 design philosophies» для выбора направления — всё это distilled в [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — режим deck. Встроен без изменений в [`skills/guizang-ppt/`](skills/guizang-ppt/) с сохранением исходной LICENSE; журнальные раскладки, WebGL hero и P0/P1/P2 checklists.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX-северная звезда и наш ближайший peer. Мы заимствуем streaming-artifact loop, шаблон sandboxed iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible generation) и набор из пяти форматов экспорта (HTML / PDF / PPTX / ZIP / Markdown). Осознанное расхождение — в форм-факторе: они делают desktop Electron app с bundled [`pi-ai`][piai], мы — web app + local daemon, делегирующий работу вашему существующему CLI.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — архитектура демона и runtime. PATH-scan detection агентов, local daemon как единственный привилегированный процесс и мировоззрение agent-as-teammate.
## С первого взгляда
| | Что вы получаете |
|---|---|
| **Coding-agent CLI (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — автоматически обнаруживаются в `PATH`, переключаются одним кликом |
| **BYOK fallback** | OpenAI-совместимый прокси на `/api/proxy/stream` — вставьте `baseUrl` + `apiKey` + `model`, и любой вендор (Anthropic-via-OpenAI, DeepSeek, Groq, MiMo, OpenRouter, self-hosted vLLM или любой другой OpenAI-compatible provider) станет движком. На границе демона заблокированы internal IP / SSRF. |
| **Design systems built-in** | **129** — 2 вручную написанных стартера + 70 продуктовых систем (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) из [`awesome-design-md`][acd2], плюс 57 design skills из [`awesome-design-skills`][ads], добавленных напрямую в `design-systems/` |
| **Skills built-in** | **31** — 27 в режиме `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 в режиме `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). В picker группируются по `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Media generation** | Режимы image · video · audio идут рядом с дизайн-циклом. **gpt-image-2** (Azure / OpenAI) — для постеров, аватаров, инфографики и иллюстрированных карт; **Seedance 2.0** (ByteDance) — для кинематографичных 15s text-to-video и image-to-video; **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) — для HTML→MP4 motion graphics (product reveals, kinetic typography, charts, social overlays, logo outros). Галерея из **93 готовых к воспроизведению промптов** — 43 для gpt-image-2, 39 для Seedance и 11 для HyperFrames — лежит в [`prompt-templates/`](prompt-templates/), с preview thumbnail и указанием источника. Тот же чатовый surface, что и для кода; на выходе в проектном workspace появляется реальный `.mp4` / `.png`. |
| **Visual directions** | 5 отобранных школ (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — каждая с детерминированной OKLch-палитрой и стеком шрифтов ([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)) |
| **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-perfect, общие для навыков, хранятся в [`assets/frames/`](assets/frames/) |
| **Agent runtime** | Local daemon запускает CLI в папке проекта — агент получает реальные `Read`, `Write`, `Bash`, `WebFetch` поверх реальной on-disk среды, с Windows fallbackами для `ENAMETOOLONG` (stdin / prompt-file) на каждом адаптере |
| **Imports** | Перетащите ZIP-экспорт из [Claude Design][cd] в welcome dialog — `POST /api/import/claude-design` превратит его в реальный проект, чтобы ваш агент продолжил редактирование там, где остановился Anthropic |
| **Persistence** | SQLite в `.od/app.sqlite`: projects · conversations · messages · tabs · saved templates. Откройте завтра — и todo card с открытыми файлами будут ровно там, где вы их оставили. |
| **Lifecycle** | Единая точка входа: `pnpm tools-dev` (start / stop / run / status / logs / inspect / check) — поднимает daemon + web (+ desktop) под typed sidecar stamps |
| **Desktop** | Опциональная Electron-оболочка с sandboxed renderer + sidecar IPC (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN) — именно через неё работает `tools-dev inspect desktop screenshot` для E2E |
| **Deployable to** | Локально (`pnpm tools-dev`) · web-слой на Vercel · упакованное Electron desktop-приложение для macOS (Apple Silicon) и Windows (x64) — скачать на [open-design.ai](https://open-design.ai/) или на [странице последнего релиза](https://github.com/nexu-io/open-design/releases) |
| **License** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ads]: https://github.com/bergside/awesome-design-skills
## Демо
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · Экран входа" /><br/>
<sub><b>Экран входа</b> — выберите skill, design system и введите brief. Один и тот же surface для прототипов, deckов, мобильных приложений, dashboardов и editorial pages.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · Форма первичной диагностики" /><br/>
<sub><b>Форма первичной диагностики</b> — до того как модель нарисует хотя бы пиксель, OD фиксирует brief: surface, audience, tone, brand context, scale. 30 секунд с radio buttons лучше 30 минут редиректов.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · Выбор направления" /><br/>
<sub><b>Выбор направления</b> — если у пользователя нет бренда, агент выводит вторую форму с 5 curated directions (Monocle / Modern Minimal / Tech Utility / Brutalist / Soft Warm). Один radio click → детерминированная палитра и стек шрифтов, без model freestyle.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · Живой прогресс по задачам" /><br/>
<sub><b>Живой прогресс по задачам</b> — план агента стримится как live card. Обновления <code>in_progress</code> → <code>completed</code> прилетают в реальном времени. Пользователь может недорого скорректировать курс прямо на лету.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · Песочничный предпросмотр" /><br/>
<sub><b>Песочничный предпросмотр</b> — каждый <code>&lt;artifact&gt;</code> рендерится в чистом srcdoc iframe. Его можно редактировать на месте через файловый workspace и скачивать как HTML, PDF или ZIP.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · Библиотека из 72 систем" /><br/>
<sub><b>Библиотека из 72 систем</b> — каждая продуктовая система показывает свою 4-цветную сигнатуру. По клику открываются полные <code>DESIGN.md</code>, сетка swatchей и live showcase.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · Журнальный deck" /><br/>
<sub><b>Режим deck (guizang-ppt)</b> — встроенный <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> подключён без изменений. Журнальные раскладки, WebGL hero-фоны, single-file HTML output, экспорт в PDF.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · Мобильный прототип" /><br/>
<sub><b>Мобильный прототип</b> — pixel-perfect chrome iPhone 15 Pro (Dynamic Island, status bar SVG, home indicator). Многоэкранные прототипы используют общие ресурсы <code>/frames/</code>, поэтому агент никогда не перерисовывает телефон заново.</sub>
</td>
</tr>
</table>
## Навыки
**31 навык поставляется из коробки.** Каждый — это папка в [`skills/`](skills/), следующая Claude Code-конвенции [`SKILL.md`][skill] с расширенным `od:` frontmatter, который демон парсит как есть: `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)).
Два верхнеуровневых **режима** образуют каталог: **`prototype`** (27 навыков — всё, что рендерится как одностраничный артефакт: от журнальной landing page до экрана телефона или PM spec doc) и **`deck`** (4 навыка — горизонтально перелистываемые презентации с deck-framework chrome). Поле **`scenario`** используется pickerом для группировки: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
### Витринные примеры
Самые визуально характерные skills, которые вы, скорее всего, запустите первыми. Каждый поставляется с реальным `example.html`, который можно открыть прямо из репозитория и увидеть точный тип результата — без авторизации и без настройки.
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>Потребительский dashboard для знакомств / мэтчинга — левая навигационная колонка, тикер, KPI, график взаимных совпадений за 30 дней, editorial typography.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>Двухразворотный digital e-guide — обложка (заголовок, автор, teaser оглавления) + учебный разворот с pull quote и списком шагов. Тон — creator / lifestyle.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>Брендовое HTML-письмо для product launch — masthead, hero image, headline lockup, CTA и сетка характеристик. Центрированная одноколоночная структура, безопасная для table fallback.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>Трёхэкранный gamified mobile-app prototype на тёмной showcase-сцене — обложка, сегодняшние квесты с XP-ленточками и уровневой шкалой, детали квеста.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>Трёхэкранный mobile onboarding flow — splash, value proposition, sign-in. Status bar, точки свайпа, основной CTA.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>Однокадровый motion-design hero с циклическими CSS-анимациями — вращающееся типографическое кольцо, анимированный глобус, отсчитывающий таймер. Готово к hand-off в HyperFrames.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>Карусель для соцсетей из трёх карточек 1080×1080 — кинематографичные панели с display-заголовками, связывающими серию, brand mark и явным намёком на цикл просмотра.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>Пиксельный / 8-bit анимированный explainer slide — full-bleed кремовая сцена, анимированный pixel mascot, кинетическая японская display-типографика, зацикленные CSS keyframes.</sub>
</td>
</tr>
</table>
### Поверхности для дизайна и маркетинга (режим prototype)
| Skill | Платформа | Сценарий | Что создаёт |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | desktop | design | Одностраничный HTML — landing pages, marketing, hero pages (по умолчанию для prototype) |
| [`saas-landing`](skills/saas-landing/) | desktop | marketing | Маркетинговая раскладка: hero / features / pricing / CTA |
| [`dashboard`](skills/dashboard/) | desktop | operation | Admin / analytics с боковой панелью и плотной сеткой данных |
| [`pricing-page`](skills/pricing-page/) | desktop | sale | Самостоятельная pricing page и сравнительные таблицы |
| [`docs-page`](skills/docs-page/) | desktop | engineering | Трёхколоночная документационная раскладка |
| [`blog-post`](skills/blog-post/) | desktop | marketing | Длинный editorial material |
| [`mobile-app`](skills/mobile-app/) | mobile | design | Экран(ы) приложения во фреймах iPhone 15 Pro / Pixel |
| [`mobile-onboarding`](skills/mobile-onboarding/) | mobile | design | Многоэкранный mobile onboarding flow (splash · value-prop · sign-in) |
| [`gamified-app`](skills/gamified-app/) | mobile | personal | Трёхкадровый gamified mobile-app prototype |
| [`email-marketing`](skills/email-marketing/) | desktop | marketing | Брендовое HTML-письмо для product launch (безопасно для table fallback) |
| [`social-carousel`](skills/social-carousel/) | desktop | marketing | Карусель из 3 карточек 1080×1080 |
| [`magazine-poster`](skills/magazine-poster/) | desktop | marketing | Одностраничный постер в журнальном стиле |
| [`motion-frames`](skills/motion-frames/) | desktop | marketing | Motion-design hero с циклическими CSS-анимациями |
| [`sprite-animation`](skills/sprite-animation/) | desktop | marketing | Пиксельный / 8-bit анимированный explainer slide |
| [`dating-web`](skills/dating-web/) | desktop | personal | Макет пользовательского dashboard для знакомств |
| [`digital-eguide`](skills/digital-eguide/) | desktop | marketing | Двухразворотный digital e-guide (обложка + lesson spread) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | desktop | design | Ручной ideation sketch — для раннего «показать что-то видимое» |
| [`critique`](skills/critique/) | desktop | design | Пятимерный лист самокритики (Philosophy · Hierarchy · Detail · Function · Innovation) |
| [`tweaks`](skills/tweaks/) | desktop | design | Tweaks-панель, сгенерированная ИИ — модель выводит параметры, которые имеет смысл подкрутить |
### Поверхности для deckов (режим deck)
| Skill | Значение по умолчанию | Что создаёт |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **по умолчанию** для deck | Журнальный web PPT — встроен дословно из [op7418/guizang-ppt-skill][guizang], с сохранением исходной LICENSE |
| [`simple-deck`](skills/simple-deck/) | — | Минималистичный горизонтально пролистываемый deck |
| [`replit-deck`](skills/replit-deck/) | — | Product-walkthrough deck в стиле Replit |
| [`weekly-update`](skills/weekly-update/) | — | Еженедельный командный цикл в формате swipe deck (progress · blockers · next) |
### Поверхности для офиса и операций (режим prototype, документные сценарии)
| Skill | Сценарий | Что создаёт |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | Документ спецификации PM с оглавлением и decision log |
| [`team-okrs`](skills/team-okrs/) | product | Таблицу оценки OKR |
| [`meeting-notes`](skills/meeting-notes/) | operation | Журнал решений по встрече |
| [`kanban-board`](skills/kanban-board/) | operation | Снимок доски |
| [`eng-runbook`](skills/eng-runbook/) | engineering | Incident runbook |
| [`finance-report`](skills/finance-report/) | finance | Финансовое summary для руководства |
| [`invoice`](skills/invoice/) | finance | Одностраничный счёт |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | План онбординга по роли |
Добавление skill — это одна папка. Изучите [`docs/skills-protocol.md`](docs/skills-protocol.md), чтобы разобраться в расширенном frontmatter, форкните существующий skill, перезапустите демон — и он появится в picker. Каталог доступен по `GET /api/skills`; сборка seed-материалов для конкретного skill (template + side-file references) реализована в `GET /api/skills/:id/example`.
## Шесть несущих идей
### 1 · Мы не поставляем своего агента. Ваш уже достаточно хорош.
На старте демон сканирует `PATH` в поисках [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/mariozechner/pi-ai), [`kiro-cli`](https://kiro.dev) и [`vibe-acp`](https://github.com/mistralai/mistral-vibe). Всё найденное становится кандидатами на роль design engine — каждый работает через свой stdio-adapter и может переключаться из pickerа модели. CLI не установлен? `POST /api/proxy/stream` даёт тот же pipeline, только без spawn: вставьте любой OpenAI-compatible `baseUrl` + `apiKey`, и демон будет форвардить SSE chunks назад, при этом loopback / link-local / RFC1918 назначения отсекаются на границе.
### 2 · Skills — это файлы, а не плагины.
Следуя [`SKILL.md` convention](https://docs.anthropic.com/en/docs/claude-code/skills) из Claude Code, каждый skill — это `SKILL.md` + `assets/` + `references/`. Достаточно положить папку в [`skills/`](skills/), перезапустить демон — и skill появится в pickerе. Встроенный `magazine-web-ppt` — это [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill), добавленный дословно, с сохранением исходной лицензии и атрибуции.
### 3 · Design Systems — это переносимый Markdown, а не theme JSON.
Схема `DESIGN.md` из девяти разделов от [`VoltAgent/awesome-design-md`][acd2] — цвет, типографика, отступы, компоновка, компоненты, motion, voice, brand, anti-patterns. Каждый артефакт читает активную систему. Сменили систему — следующий рендер берёт новые токены. В выпадающем списке уже есть **Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Apple, Anthropic, Cursor, Supabase, Figma, Resend, Raycast, Lovable, Cohere, Mistral, ElevenLabs, X.AI, Spotify, Webflow, Sanity, PostHog, Sentry, MongoDB, ClickHouse, Cal, Replicate, Clay, Composio, Xiaohongshu…** — плюс 57 design skills из [`awesome-design-skills`][ads].
### 4 · Интерактивная форма вопросов убирает 80% редиректов.
В prompt stack OD жёстко зашито `RULE 1`: каждый новый дизайн-бриф начинается с `<question-form id="discovery">`, а не с кода. Surface · audience · tone · brand context · scale · constraints. Даже длинный бриф оставляет массу открытых решений — визуальный тон, цветовую позицию, масштаб — и как раз их форма фиксирует за 30 секунд.
Это и есть **режим Junior-Designer**, distilled из [`huashu-design`](https://github.com/alchaincyf/huashu-design): задаём вопросы upfront, быстро показываем что-то видимое (хотя бы wireframe с серыми блоками), даём пользователю дёшево скорректировать курс. В сочетании с brand-asset protocol (locate · download · `grep` hex · write `brand-spec.md` · vocalise) это, пожалуй, главный фактор, из-за которого output перестаёт быть AI freestyle и начинает ощущаться как работа внимательного дизайнера.
### 5 · Демон делает так, будто агент работает прямо на вашем ноутбуке, потому что так и есть.
Демон запускает CLI с `cwd`, указывающим на artifact-папку проекта внутри `.od/projects/<id>/`. Агент получает `Read`, `Write`, `Bash`, `WebFetch` — реальные инструменты поверх реальной файловой системы. Он может читать `assets/template.html` конкретного skill, делать `grep` по CSS ради hex-цветов, записывать `brand-spec.md`, складывать туда сгенерированные изображения и выпускать `.pptx` / `.zip` / `.pdf`, которые затем появляются в файловом workspace как chips для скачивания. Sessions, conversations, messages и tabs живут в local SQLite DB — откройте проект завтра, и todo card агента будет лежать там же, где вы её оставили.
### 6 · Prompt stack — это и есть продукт.
То, что композируется в момент отправки, — это не просто «system + user». Это:
```
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)
+ 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)
```
Каждый слой компонуем. Каждый слой — это файл, который можно редактировать. Откройте [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) и [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts), чтобы увидеть реальный контракт.
## Архитектура
```
┌────────────────────── browser (Next.js 16) ──────────────────────┐
│ chat · file workspace · iframe preview · settings · imports │
└──────────────┬───────────────────────────────────┬───────────────┘
│ /api/* (rewritten in dev) │
▼ ▼
┌──────────────────────────────────┐ /api/proxy/stream (SSE)
│ Local daemon (Express + SQLite) │ ─→ any OpenAI-compat
│ │ endpoint (BYOK)
│ /api/agents /api/skills│ w/ SSRF blocking
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (static) /frames (static)
│ optional: sidecar IPC at /tmp/open-design/ipc/<ns>/<app>.sock
│ (STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN)
└─────────┬────────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · devin (ACP) · gemini · opencode · cursor-agent │
│ qwen · qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) · kiro (ACP) · vibe (ACP) │
│ reads SKILL.md + DESIGN.md, writes artifacts to disk │
└──────────────────────────────────────────────────────────────────┘
```
| Слой | Стек |
|---|---|
| Frontend | Next.js 16 App Router + React 18 + TypeScript, готово к деплою на Vercel |
| Daemon | Node 24 · Express · SSE streaming · `better-sqlite3`; таблицы: `projects` · `conversations` · `messages` · `tabs` · `templates` |
| Agent transport | `child_process.spawn`; typed-event parsers для `claude-stream-json` (Claude Code), `qoder-stream-json` (Qoder CLI), `copilot-stream-json` (Copilot), `json-event-stream`-парсеры на каждый CLI (Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe через Agent Client Protocol), `pi-rpc` (Pi через stdio JSON-RPC), `plain` (Qwen Code / DeepSeek TUI) |
| BYOK proxy | `POST /api/proxy/stream` → OpenAI-compatible `/v1/chat/completions`, SSE pass-through; отвергает loopback / link-local / RFC1918 hosts на границе демона |
| Storage | Обычные файлы в `.od/projects/<id>/` + SQLite в `.od/app.sqlite``.gitignore`, создаётся автоматически). Для изоляции тестов можно переопределить корень через `OD_DATA_DIR` |
| Preview | Sandboxed iframe через `srcdoc` + parser `<artifact>` для каждого skill ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
| Export | HTML (с inline assets) · PDF (browser print, aware of deck mode) · PPTX (через skill и агента) · ZIP (archiver) · Markdown |
| Lifecycle | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`; порты задаются через `--daemon-port` / `--web-port`, namespaces — через `--namespace` |
| Desktop (optional) | Electron shell — узнаёт web URL через sidecar IPC, без угадывания портов; тот же канал `STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN` используется `tools-dev inspect desktop …` для E2E |
## Быстрый старт
### Скачать desktop-приложение (без сборки)
Самый быстрый способ попробовать Open Design — готовое desktop-приложение, без Node, pnpm и клонирования:
- **[open-design.ai](https://open-design.ai/)** — официальная страница загрузки
- **[GitHub-релизы](https://github.com/nexu-io/open-design/releases)**
### Запуск из исходников
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # должно вывести 10.33.2
pnpm install
pnpm tools-dev run web
# откройте web URL, который напечатает tools-dev
```
Лаунчер Windows: соберите `OpenDesign.exe` самостоятельно по инструкции в `tools/launcher/README.md` или скачайте его из GitHub Releases. Затем поместите файл в корень репозитория и дважды щёлкните его, чтобы при необходимости выполнить `pnpm install` и запустить Open Design через `pnpm tools-dev`.
Требования к окружению: Node `~24` и pnpm `10.33.x`. `nvm`/`fnm` — только вспомогательные инструменты; если вы ими пользуетесь, выполните `nvm install 24 && nvm use 24` или `fnm install 24 && fnm use 24` перед `pnpm install`.
Для desktop/background startup, перезапуска на фиксированных портах и проверки dispatcherа media generation (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`) смотрите [`QUICKSTART.md`](QUICKSTART.md).
При первой загрузке:
1. Определяется, какие agent CLI доступны в `PATH`, и один из них выбирается автоматически.
2. Загружаются 31 skill + 72 design systems.
3. Появляется welcome dialog, куда можно вставить ключ Anthropic (он нужен только для fallback-пути BYOK).
4. **Автоматически создаётся `./.od/`** — локальная runtime-папка для SQLite-базы проектов, артефактов по проектам и сохранённых рендеров. Шаг `od init` не нужен: демон сам делает `mkdir` всего необходимого при запуске.
Введите промпт, нажмите **Send**, дождитесь формы вопросов, заполните её, наблюдайте за стримом todo card и рендером артефакта. Нажмите **Save to disk** или скачайте проект как ZIP.
### Состояние первого запуска (`./.od/`)
Демон владеет одной скрытой папкой в корне репозитория. Всё внутри неё игнорируется gitом и привязано к вашей машине — коммитить это не нужно.
```
.od/
├── app.sqlite ← projects · conversations · messages · open tabs
├── artifacts/ ← одноразовые рендеры “Save to disk” (с метками времени)
└── projects/<id>/ ← рабочая директория проекта, она же cwd агента
```
| Хотите… | Сделайте так |
|---|---|
| Посмотреть содержимое | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| Сбросить всё к чистому состоянию | `pnpm tools-dev stop`, `rm -rf .od`, затем снова `pnpm tools-dev run web` |
| Перенести папку в другое место | пока не поддерживается — путь жёстко привязан к репозиторию |
Полная карта файлов, скрипты и troubleshooting → [`QUICKSTART.md`](QUICKSTART.md).
## Структура репозитория
```text
open-design/
├── README.md ← этот файл
├── README.de.md ← Deutsch
├── README.ru.md ← Русский
├── README.zh-CN.md ← 简体中文
├── QUICKSTART.md ← руководство по запуску / сборке / деплою
├── package.json ← pnpm workspace, единственный bin: od
├── apps/
│ ├── daemon/ ← Node + Express, единственный сервер
│ │ ├── src/ ← исходники демона на TypeScript
│ │ │ ├── cli.ts ← исходник bin `od`, компилируется в dist/cli.js
│ │ │ ├── server.ts ← маршруты /api/* (projects, chat, files, exports)
│ │ │ ├── agents.ts ← PATH scanner + builders argv для каждого CLI
│ │ │ ├── claude-stream.ts ← streaming JSON parser для stdout Claude Code
│ │ │ ├── skills.ts ← loader frontmatter из SKILL.md
│ │ │ └── db.ts ← схема SQLite (projects/messages/templates/tabs)
│ │ ├── sidecar/ ← sidecar wrapper демона для tools-dev
│ │ └── tests/ ← package tests демона
│ │
│ └── web/ ← Next.js 16 App Router + React client
│ ├── app/ ← entrypoints App Router
│ ├── next.config.ts ← dev rewrites + prod static export в out/
│ └── src/ ← client modules React + TypeScript
│ ├── App.tsx ← routing, bootstrap, settings
│ ├── components/ ← chat, composer, picker, preview, sketch, …
│ ├── prompts/
│ │ ├── system.ts ← composeSystemPrompt(base, skill, DS, metadata)
│ │ ├── discovery.ts ← форма первого хода + ветка второго + 5-мерная критика
│ │ └── directions.ts ← 5 visual directions × OKLch palette + font stack
│ ├── artifacts/ ← streaming parser для <artifact> + manifests
│ ├── runtime/ ← iframe srcdoc, markdown, export helpers
│ ├── providers/ ← daemon SSE + BYOK API transports
│ └── state/ ← config + projects (localStorage + daemon-backed)
├── e2e/ ← Playwright UI + external integration/Vitest harness
├── packages/
│ ├── contracts/ ← общие контракты web/daemon
│ ├── sidecar-proto/ ← протокол sidecar Open Design
│ ├── sidecar/ ← базовые runtime-примитивы sidecar
│ └── platform/ ← общие process/platform primitives
├── skills/ ← 31 bundle-навык SKILL.md (27 prototype + 4 deck)
│ ├── web-prototype/ ← значение по умолчанию для prototype mode
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← режим deck
│ └── guizang-ppt/ ← bundled magazine-web-ppt (по умолчанию для deck)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 системы DESIGN.md
│ ├── default/ ← Neutral Modern (starter)
│ ├── warm-editorial/ ← Warm Editorial (starter)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md ← обзор каталога
├── assets/
│ └── frames/ ← общие device frames (используются разными skills)
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ ├── deck-framework.html ← базовая основа deck (nav / counter / print)
│ └── kami-deck.html ← starter deck в духе kami (пергамент / ink-blue serif)
├── scripts/
│ └── sync-design-systems.ts ← повторный импорт upstream tarball из awesome-design-md
├── docs/
│ ├── spec.md ← спецификация продукта, сценарии, дифференциация
│ ├── architecture.md ← топологии, поток данных, компоненты
│ ├── skills-protocol.md ← расширенный od:-frontmatter для SKILL.md
│ ├── agent-adapters.md ← detection + dispatch по каждому CLI
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← развёрнутая provenance-документация
│ ├── roadmap.md ← поэтапная поставка
│ ├── schemas/ ← JSON schemas
│ └── examples/ ← канонические примеры артефактов
└── .od/ ← runtime-данные, в .gitignore, создаётся автоматически
├── app.sqlite ← projects / conversations / messages / tabs
├── projects/<id>/ ← рабочая папка проекта (cwd агента)
└── artifacts/ ← сохранённые одноразовые рендеры
```
## Системы дизайна
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="Библиотека из 72 design systems — разворот style guide" width="100%" />
</p>
72 системы из коробки, каждая — один файл [`DESIGN.md`](design-systems/README.md):
<details>
<summary><b>Полный каталог</b> (нажмите, чтобы развернуть)</summary>
**AI & LLM**`claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**Developer Tools**`cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**Productivity**`notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**Fintech**`stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**E-Commerce**`shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**Media**`spotify` · `playstation` · `wired` · `theverge` · `meta`
**Automotive**`tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**Other**`apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**Starters**`default` (Neutral Modern) · `warm-editorial`
</details>
Библиотека product systems импортируется через [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) из [`VoltAgent/awesome-design-md`][acd2]. Чтобы обновить её, достаточно заново запустить импорт. 57 design skills берутся из [`bergside/awesome-design-skills`][ads] и добавляются напрямую в `design-systems/`.
## Визуальные направления
Когда у пользователя нет brand spec, агент выводит вторую форму с пятью curated directions — это адаптация fallback-модели [`huashu-design` с «5 schools × 20 design philosophies»](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback). Каждое направление — детерминированная спецификация: палитра в OKLch, стек шрифтов, поведенческие подсказки по layout posture и референсы. Агент подставляет всё это дословно в `:root` seed-шаблона. Один radio click → полностью определённая визуальная система. Без импровизации, без AI-slop.
| Direction | Настроение | Референсы |
|---|---|---|
| Editorial — Monocle / FT | Печатный журнал, чернила + крем + тёплая ржавчина | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | Холодный, структурный, с минимальным акцентом | Linear · Vercel · Stripe |
| Tech utility | Информационная плотность, моноширинность, терминальность | Bloomberg · Bauhaus tools |
| Brutalist | Сырой, с крупной типографикой, без теней, с жёсткими акцентами | Bloomberg Businessweek · Achtung |
| Soft warm | Просторный, низкоконтрастный, в персиково-нейтральной гамме | Notion marketing · Apple Health |
Полная спецификация → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts).
## Генерация медиа
OD не заканчивается на коде. Тот же чатовый surface, который производит HTML-артефакты через `<artifact>`, умеет запускать и **image**, и **video**, и **audio** generation — через media pipeline демона ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Каждый результат сохраняется как реальный файл в project workspace — `.png` для image, `.mp4` для video — и в конце хода появляется как downloadable chip.
Сегодня основную нагрузку несут три семейства моделей:
| Surface | Model | Provider | Для чего используется |
|---|---|---|---|
| **Image** | `gpt-image-2` | Azure / OpenAI | Постеры, profile avatars, illustrated maps, infographics, magazine-style social cards, photo restoration, exploded-view product art |
| **Video** | `seedance-2.0` | ByteDance Volcengine | 15s cinematic t2v + i2v со звуком — narrative shorts, character close-ups, product films, MV-style choreography |
| **Video** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 motion graphics — product reveals, kinetic typography, charts, social overlays, logo outros, TikTok-style verticals с karaoke captions |
Растущая **галерея промптов** в [`prompt-templates/`](prompt-templates/) поставляется с **93 ready-to-replicate prompts** — 43 image (`prompt-templates/image/*.json`), 39 Seedance (`prompt-templates/video/*.json`, кроме `hyperframes-*`) и 11 HyperFrames (`prompt-templates/video/hyperframes-*.json`). Каждый объект включает preview thumbnail, полный текст prompt body, целевую модель, aspect ratio и блок `source` с лицензией и атрибуцией. Демон отдаёт всё это по `GET /api/prompt-templates`, а web app показывает их карточками во вкладках **Image templates** и **Video templates** на entry view; один клик переносит prompt в composer с уже выбранной нужной моделью.
### gpt-image-2 — галерея изображений (пример из 43)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="3D Stone Staircase Evolution" /><br/><sub><b>3D Stone Staircase Evolution Infographic</b><br/>трёхшаговая инфографика в эстетике высеченного камня</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Illustrated City Food Map" /><br/><sub><b>Illustrated City Food Map</b><br/>editorial-постер о путешествии с ручной иллюстрацией</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cinematic Elevator Scene" /><br/><sub><b>Cinematic Elevator Scene</b><br/>однокадровый editorial fashion still</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Cyberpunk Anime Portrait" /><br/><sub><b>Cyberpunk Anime Portrait</b><br/>profile avatar — неоновый текст по лицу</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Glamorous Woman in Black" /><br/><sub><b>Glamorous Woman in Black Portrait</b><br/>editorial studio portrait</sub></td>
</tr>
</table>
Полный набор → [`prompt-templates/image/`](prompt-templates/image/). Источник большинства примеров — [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts) (CC-BY-4.0), с сохранённой атрибуцией авторов для каждого template.
### Seedance 2.0 — галерея видео (пример из 39)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Music Podcast Guitar" /></a><br/><sub><b>Music Podcast & Guitar Technique</b><br/>кинематографичный студийный фильм в 4K</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Emotional Face" /></a><br/><sub><b>Emotional Face Close-up</b><br/>исследование микроэмоций в киноязыке</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Luxury Supercar" /></a><br/><sub><b>Luxury Supercar Cinematic</b><br/>нарративный product film</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Forbidden City Cat" /></a><br/><sub><b>Forbidden City Cat Satire</b><br/>стилизованный сатирический short</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Japanese Romance" /></a><br/><sub><b>Japanese Romance Short Film</b><br/>15-секундный narrative clip на Seedance 2.0</sub></td>
</tr>
</table>
Нажмите на любой thumbnail, чтобы воспроизвести реальный сгенерированный MP4. Полный набор → [`prompt-templates/video/`](prompt-templates/video/) (entries с `*-seedance-*` и меткой Cinematic). Источники: [`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts) (CC-BY-4.0), с сохранёнными оригинальными ссылками на твиты и author handles.
### HyperFrames — HTML→MP4 motion graphics (11 готовых шаблонов)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) — это open-source, agent-native video framework от HeyGen: вы (или агент) пишете HTML + CSS + GSAP, а HyperFrames рендерит всё в детерминированный MP4 через headless Chrome + FFmpeg. Open Design поставляет HyperFrames как first-class video model (`hyperframes-html`) в dispatch-пайплайне демона, плюс skill `skills/hyperframes/`, объясняющий агенту контракт таймлайна, правила переходов между сценами, audio-reactive patterns, captions/TTS и catalog blocks (`npx hyperframes add <slug>`).
Одиннадцать hyperframes-промптов лежат в [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/), и каждый из них описывает конкретный archetype:
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Product reveal" /></a><br/><sub><b>5s minimal product reveal</b> · 16:9 · push-in title card with shader transition</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="SaaS promo" /></a><br/><sub><b>30s SaaS product promo</b> · 16:9 · в стиле Linear/ClickUp с UI 3D reveal</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaoke" /></a><br/><sub><b>TikTok karaoke talking-head</b> · 9:16 · TTS + captions, синхронизированные по словам</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Brand sizzle" /></a><br/><sub><b>30s brand sizzle reel</b> · 16:9 · beat-synced kinetic typography, audio-reactive</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Data chart" /></a><br/><sub><b>Animated bar-chart race</b> · 16:9 · data-infographic в духе NYT</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Flight map" /></a><br/><sub><b>Flight map (origin → dest)</b> · 16:9 · кинематографичный route reveal в духе Apple</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>4s cinematic logo outro</b> · 16:9 · сборка по частям + bloom</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Money counter" /></a><br/><sub><b>$0 → $10K money counter</b> · 9:16 · Apple-style hype с зелёной вспышкой и burst</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>3-phone app showcase</b> · 16:9 · парящие телефоны с calloutами по функциям</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Social overlay" /></a><br/><sub><b>Social overlay stack</b> · 9:16 · X · Reddit · Spotify · Instagram по очереди</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website to video" /></a><br/><sub><b>Website-to-video pipeline</b> · 16:9 · съёмка сайта в 3 viewportах + transitions</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
Паттерн тот же, что и в остальных режимах: выберите template, отредактируйте brief, отправьте. Агент прочитает встроенный `skills/hyperframes/SKILL.md` (там описан OD-специфичный render workflow — composition source files складываются в `.hyperframes-cache/`, чтобы не засорять file workspace, демон запускает `npx hyperframes render`, обходя зависания macOS sandbox-exec / Puppeteer, и только финальный `.mp4` попадает в проект как chip), соберёт композицию и выпустит MP4. Thumbnails catalog blocks © HeyGen и отдаются с их CDN; сам OSS-framework лицензирован по Apache-2.0.
> **Уже подключено, но пока не вынесено в шаблоны:** Kling 2.0 / 1.6 / 1.5, Veo 3 / Veo 2, Sora 2 / Sora 2-Pro (через Fal), MiniMax video-01 — всё это уже живёт в `VIDEO_MODELS` ([`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). На стороне audio поддерживаются Suno v5 / v4.5, Udio v2, Lyria 2 (музыка) и gpt-4o-mini-tts, MiniMax TTS (речь). Шаблоны для них — открытая область для вкладов: добавьте JSON в `prompt-templates/video/` или `prompt-templates/audio/`, и он появится в pickerе.
## Не только чат — что ещё уже поставляется
Чатовый / артефактный цикл получает больше всего внимания, но в OD уже встроено ещё несколько менее заметных возможностей, о которых полезно знать до любых сравнений:
- **Импорт ZIP из Claude Design.** Перетащите экспорт из claude.ai в welcome dialog. `POST /api/import/claude-design` распакует его в реальный `.od/projects/<id>/`, откроет entry file как tab и подготовит prompt «продолжить с того места, где остановился Anthropic» для вашего локального агента. Никакого переформулирования, никакого «попросите модель восстановить то, что уже было». ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — маршрут `/api/import/claude-design`)
- **OpenAI-compatible BYOK proxy.** `POST /api/proxy/stream` принимает `{ baseUrl, apiKey, model, messages }`, нормализует путь до `…/v1/chat/completions`, форвардит SSE chunks обратно в браузер и отвергает loopback / link-local / RFC1918 адреса для защиты от SSRF. Подойдёт всё, что говорит на схеме OpenAI chat — Anthropic-via-OpenAI shim, DeepSeek, Groq, MiMo, OpenRouter, self-hosted vLLM. Для MiMo автоматически выставляется `tool_choice: 'none'`, потому что его схема tool-use плохо ведёт себя при free-form generation.
- **Шаблоны, сохранённые пользователем.** Когда вам нравится результат, `POST /api/templates` делает snapshot HTML и metadata в SQLite-таблицу `templates`. В следующем проекте он появляется в строке «your templates» внутри pickerа — в том же surface, что и встроенные 31 skill, только уже ваш.
- **Сохранение вкладок.** Каждый проект помнит открытые файлы и активную вкладку в таблице `tabs`. Откройте проект завтра — и workspace будет выглядеть ровно так, как вы его оставили.
- **Artifact lint API.** `POST /api/artifacts/lint` запускает структурные проверки над сгенерированным артефактом (сломанная рамка `<artifact>`, отсутствие обязательных side files, устаревшие palette tokens) и возвращает findings, которые агент может использовать в следующем ходе. Пятимерная самокритика использует этот API, чтобы опираться на реальные сигналы, а не на интуицию.
- **Sidecar protocol + desktop automation.** Процессы daemon, web и desktop получают типизированные пятикомпонентные stamps (`app · mode · namespace · ipc · source`) и открывают JSON-RPC IPC-канал по адресу `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status \| eval \| screenshot` управляет именно этим каналом, благодаря чему headless E2E работает поверх реальной Electron-shell, без особых harnessов ([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)).
- **Дружественный к Windows spawning.** Каждый адаптер, который иначе упёрся бы в лимит `CreateProcess` примерно в 32 KB по argv на длинных composition promptах (Codex, Gemini, OpenCode, Cursor Agent, Qwen, Qoder CLI, Pi), вместо этого отправляет prompt через stdin. Claude Code и Copilot сохраняют `-p`; если даже этого мало, демон переходит на временный prompt-file.
- **Runtime data по namespaceам.** `OD_DATA_DIR` и `--namespace` дают полностью изолированные деревья в духе `.od/`, так что Playwright, beta-каналы и ваши реальные проекты не делят одну SQLite-базу.
## Механика против AI-slop
Вся эта механика — прямое переложение методологии [`huashu-design`](https://github.com/alchaincyf/huashu-design) в prompt stack OD с enforceом через side-file pre-flight. Текущие формулировки можно посмотреть в [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts):
- **Сначала question form.** Ход 1 — только `<question-form>`, без размышлений, без tools, без narration. Пользователь выбирает дефолты со скоростью radio-click.
- **Извлечение brand spec.** Если пользователь прикладывает screenshot или URL, агент перед написанием CSS проходит пятишаговый протокол (locate · download · grep hex · codify `brand-spec.md` · vocalise). **Никогда не угадывает brand colors по памяти.**
- **Пятимерная критика.** Перед тем как выдать `<artifact>`, агент молча оценивает результат по шкале 15 по осям philosophy / hierarchy / execution / specificity / restraint. Всё, что ниже 3/5, считается регрессией — исправить и переоценить. Два прохода — норма.
- **Checklist P0/P1/P2.** Каждый skill поставляется с `references/checklist.md`, где есть жёсткие P0-гейты. До эмиссии артефакта агент обязан пройти P0.
- **Список запрещённого slopа.** Агрессивные фиолетовые градиенты, generic emoji icons, rounded card с left-border accent, hand-drawn SVG-люди, Inter как *display*-шрифт, вымышленные метрики — всё это прямо запрещено в promptе.
- **Честные placeholders лучше фальшивых цифр.** Если у агента нет реального числа, он пишет `—` или подписанный серый блок, а не «10× faster».
## Сравнение
| Axis | [Claude Design][cd] (Anthropic) | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| License | Closed | MIT | **Apache-2.0** |
| Form factor | Web (claude.ai) | Desktop (Electron) | **Web app + local daemon** |
| Deployable on Vercel | ❌ | ❌ | **✅** |
| Agent runtime | Bundled (Opus 4.7) | Bundled ([`pi-ai`][piai]) | **Delegated to user's existing CLI** |
| Skills | Proprietary | 12 custom TS modules + `SKILL.md` | **31 file-based [`SKILL.md`][skill] bundles, можно просто положить в папку** |
| Design system | Proprietary | `DESIGN.md` (v0.2 roadmap) | **`DESIGN.md` × 129 поставляемых систем** |
| Provider flexibility | Anthropic only | 7+ via [`pi-ai`][piai] | **16 CLI-адаптеров + OpenAI-compatible BYOK proxy** |
| Init question form | ❌ | ❌ | **✅ Жёсткое правило, ход 1** |
| Direction picker | ❌ | ❌ | **✅ 5 детерминированных направлений** |
| Live todo progress + tool stream | ❌ | ✅ | **✅** (UX-паттерн из open-codesign) |
| Sandboxed iframe preview | ❌ | ✅ | **✅** (паттерн из open-codesign) |
| Claude Design ZIP import | n/a | ❌ | **`POST /api/import/claude-design` — продолжайте там, где остановился Anthropic** |
| Comment-mode surgical edits | ❌ | ✅ | 🟡 частично — comments по preview-элементам и chat attachments уже есть, но надёжность точечных правок ещё в работе |
| AI-emitted tweaks panel | ❌ | ✅ | 🚧 в roadmap — отдельная chat-side panel UX пока не реализована |
| Filesystem-grade workspace | ❌ | partial (Electron sandbox) | **✅ Реальный cwd, реальные tools, persisted SQLite (projects · conversations · messages · tabs · templates)** |
| 5-dim self-critique | ❌ | ❌ | **✅ Pre-emit gate** |
| Artifact lint | ❌ | ❌ | **`POST /api/artifacts/lint` — findings возвращаются агенту** |
| Sidecar IPC + headless desktop | ❌ | ❌ | **✅ Stamped processes + `tools-dev inspect desktop status \| eval \| screenshot`** |
| Export formats | Limited | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTX (через агента) / ZIP / Markdown** |
| PPT skill reuse | N/A | Built-in | **[`guizang-ppt-skill`][guizang] подключается как есть (по умолчанию для deck mode)** |
| Minimum billing | Pro / Max / Team | BYOK | **BYOK — вставьте любой OpenAI-compatible `baseUrl`** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## Поддерживаемые coding-agent CLI
Автоматически обнаруживаются в `PATH` при запуске демона. Никакой настройки не нужно. Streaming dispatch живёт в [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) (`AGENT_DEFS`), а парсеры для каждого CLI — рядом. Список моделей заполняется либо probingом через `<bin> --list-models` / `<bin> models` / ACP handshake, либо curated fallback-списком, если CLI не умеет сам сообщать модели.
| Agent | Bin | Stream format | Форма argv (путь для composed prompt) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json` (typed events) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + parser `codex` | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--model …] [-c model_reasoning_effort=…]` (prompt через stdin) |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + parser `gemini` | `gemini --output-format stream-json --skip-trust --yolo [--model …] -` (prompt через stdin) |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + parser `opencode` | `opencode run --format json --dangerously-skip-permissions [--model …] -` (prompt через stdin) |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + parser `cursor-agent` | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -` (prompt через stdin) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain` (сырые stdout chunks) | `qwen --yolo [--model …] -` (prompt через stdin) |
| Qoder CLI | `qodercli` | `qoder-stream-json` (типизированные события) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]` (prompt через stdin) |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json` (typed events) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc` (Agent Client Protocol) | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain` (raw stdout chunks) | `deepseek exec --auto [--model …] <prompt>` |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc` (stdio JSON-RPC) | `pi --mode rpc [--model …] [--thinking …]` (prompt отправляется как RPC-команда `prompt`) |
| **OpenAI-compatible BYOK** | n/a | SSE pass-through | `POST /api/proxy/stream``<baseUrl>/v1/chat/completions`; SSRF-защита от loopback / link-local / RFC1918 |
Добавить новый CLI — это одна запись в [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). Формат стрима выбирается из `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream` (с отдельным `eventParser` на CLI), `acp-json-rpc`, `pi-rpc` или `plain`.
## Источники и происхождение
Здесь собраны все внешние проекты, из которых этот репозиторий что-то заимствует. Каждая ссылка ведёт к источнику, чтобы provenance можно было проверить самостоятельно.
| Project | Роль в проекте |
|---|---|
| [`Claude Design`][cd] | Закрытый продукт, для которого этот репозиторий служит open-source альтернативой. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Ядро дизайн-философии. Junior-Designer workflow, 5-step protocol для brand assets, anti-AI-slop checklist, пятимерная самокритика и библиотека «5 schools × 20 design philosophies» для direction pickerа — всё это distilled в [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) и [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Skill для magazine-web-PPT, встроенный без изменений в [`skills/guizang-ppt/`](skills/guizang-ppt/) с сохранением оригинальной LICENSE. Используется по умолчанию в режиме deck. Культура P0/P1/P2 checklistов позаимствована для всех остальных skills. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Архитектура демона и адаптеров. PATH-scan detection агентов, local daemon как единственный привилегированный процесс, worldview agent-as-teammate. Мы переняли модель, а не vendored code. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | Первая open-source альтернатива Claude Design и наш ближайший peer. Заимствованные UX-паттерны: streaming-artifact loop, sandboxed iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible), список из пяти экспортных форматов (HTML/PDF/PPTX/ZIP/Markdown), local-first storage hub, `SKILL.md`-внедрение вкуса и первая версия preview-аннотаций для comment mode. Всё ещё в roadmap: полная надёжность surgical edits и tweaks panel, генерируемая ИИ. **Мы намеренно не вендорим [`pi-ai`][piai]** — open-codesign включает его как runtime агента, а мы делегируем исполнение тому CLI, который уже установлен у пользователя. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Источник схемы `DESIGN.md` из 9 разделов и 70 продуктовых систем, импортируемых через [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). |
| [`bergside/awesome-design-skills`][ads] | Источник 57 design skills, нормализованных как файлы `DESIGN.md` в `design-systems/`. |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | Вдохновение для symlink-based distribution навыков между разными agent CLI. |
| [Claude Code skills][skill] | Конвенция `SKILL.md`, заимствованная без изменений — любой Claude Code skill можно положить в `skills/`, и демон его подхватит. |
Подробный provenance-разбор — что именно мы берём и что принципиально не берём — лежит в [`docs/references.md`](docs/references.md).
## Дорожная карта
- [x] Daemon + detection агентов (13 CLI adapters) + registry skills + catalog design systems
- [x] Web app + chat + question form + picker из 5 направлений + todo progress + sandboxed preview
- [x] 31 skill + 72 design systems + 5 visual directions + 5 device frames
- [x] SQLite-backed projects · conversations · messages · tabs · templates
- [x] OpenAI-compatible BYOK proxy (`/api/proxy/stream`) с SSRF-защитой
- [x] Claude Design ZIP import (`/api/import/claude-design`)
- [x] Sidecar protocol + Electron desktop с IPC automation (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN)
- [x] Artifact lint API + 5-dim self-critique pre-emit gate
- [ ] Comment-mode surgical edits — частично уже есть: comments по preview elements и chat attachments; надёжный targeted patching ещё в работе
- [ ] UX tweaks panel, которую создаёт ИИ — пока не реализована
- [ ] Рецепт деплоя на Vercel + через tunnel (Topology B)
- [ ] Однокомандный `npx od init`, создающий проект с `DESIGN.md`
- [ ] Marketplace навыков (`od skills install <github-repo>`) и CLI surface `od skill add | list | remove | test` (набросан в [`docs/skills-protocol.md`](docs/skills-protocol.md), реализация впереди)
- [x] Packaged Electron build на базе `apps/packaged/` — загрузки для macOS (Apple Silicon) и Windows (x64) на [open-design.ai](https://open-design.ai/) и [странице релизов GitHub](https://github.com/nexu-io/open-design/releases)
Поэтапная поставка → [`docs/roadmap.md`](docs/roadmap.md).
## Статус
Это ранняя реализация, но замкнутый цикл (detect → выбрать skill + design system → chat → parse `<artifact>` → preview → save) уже работает end-to-end. Основная ценность сосредоточена в prompt stack и библиотеке skills, и они уже достаточно стабильны. UI на уровне компонентов выкатывается практически ежедневно.
## Поставьте звезду
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="Поставьте звезду Open Design на GitHub — github.com/nexu-io/open-design" width="100%" /></a>
</p>
Если OD сэкономил вам хотя бы тридцать минут — подарите ему ★. Звёзды не платят аренду, но показывают следующему дизайнеру, агенту и контрибьютору, что этот эксперимент заслуживает внимания. Один клик, три секунды, реальный сигнал: [github.com/nexu-io/open-design](https://github.com/nexu-io/open-design).
## Как участвовать
Issues, PR, новые skills и новые design systems приветствуются. Самые ценные вклады чаще всего — это одна папка, один Markdown-файл или один adapter-PR:
- **Добавить skill** — положите папку в [`skills/`](skills/) по конвенции [`SKILL.md`][skill].
- **Добавить design system** — положите `DESIGN.md` в [`design-systems/<brand>/`](design-systems/), следуя схеме из 9 разделов.
- **Подключить новый coding-agent CLI** — одна запись в [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts).
Полный walkthrough, bar-for-merging, code style и список того, что мы не принимаем → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([Deutsch](CONTRIBUTING.de.md), [Français](CONTRIBUTING.fr.md), [简体中文](CONTRIBUTING.zh-CN.md)).
## Участники
Спасибо всем, кто помогает двигать 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-06" 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) — хорошая точка входа.
## Активность репозитория
<picture>
<img alt="Open Design — repository metrics" src="docs/assets/github-metrics.svg" />
</picture>
SVG выше ежедневно пересобирается workflow [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) с помощью [`lowlighter/metrics`](https://github.com/lowlighter/metrics). Если нужен refresh раньше, запустите workflow вручную во вкладке **Actions**; для более богатых плагинов (traffic, follow-up time) добавьте секрет репозитория `METRICS_TOKEN` с fine-grained PAT.
## История звёзд
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
</picture>
</a>
Если кривая идёт вверх — это и есть тот сигнал, на который мы смотрим. Поставьте ★ этому репозиторию, чтобы помочь ей расти.
## Благодарности
Семейство skills HTML PPT Studio — главный [`skills/html-ppt/`](skills/html-ppt/) и template-wrapperы в [`skills/html-ppt-*/`](skills/) (15 full-deck templates, 36 themes, 31 single-page layouts, 27 CSS animations + 20 canvas FX, keyboard runtime и magnetic-card presenter mode) — интегрировано из open-source проекта [`lewislulu/html-ppt-skill`](https://github.com/lewislulu/html-ppt-skill) (MIT). Upstream LICENSE лежит в репозитории по пути [`skills/html-ppt/LICENSE`](skills/html-ppt/LICENSE), а авторская атрибуция принадлежит [@lewislulu](https://github.com/lewislulu). Каждая Examples card конкретного template (`html-ppt-pitch-deck`, `html-ppt-tech-sharing`, `html-ppt-presenter-mode`, `html-ppt-xhs-post`, …) делегирует guidance по authoring master-skillу, чтобы поведение upstream «prompt → output» сохранялось end-to-end после клика **Use this prompt**.
Журнальный / горизонтально перелистываемый deck flow в [`skills/guizang-ppt/`](skills/guizang-ppt/) интегрирован из [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) (MIT). Авторская атрибуция принадлежит [@op7418](https://github.com/op7418).
## Лицензия
Apache-2.0. Встроенный `skills/guizang-ppt/` сохраняет свою исходную [LICENSE](skills/guizang-ppt/LICENSE) (MIT) и авторскую атрибуцию [op7418](https://github.com/op7418). Встроенный `skills/html-ppt/` сохраняет свою исходную [LICENSE](skills/html-ppt/LICENSE) (MIT) и авторскую атрибуцию [lewislulu](https://github.com/lewislulu).

756
README.uk.md Normal file
View File

@@ -0,0 +1,756 @@
# 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.
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design — editorial cover: design with the agent on your laptop" width="100%" />
</p>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="Завантажити" src="https://img.shields.io/badge/%D0%B7%D0%B0%D0%B2%D0%B0%D0%BD%D1%82%D0%B0%D0%B6%D0%B8%D1%82%D0%B8-open--design.ai-ff6b35?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#підтримувані-агенти-для-кодування"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#системи-дизайну"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#навички"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-приєднатись-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <b>Українська</b></p>
---
## Чому це існує
[Claude Design][cd] від Anthropic (випущено 17.04.2026, Opus 4.7) показав, що відбувається, коли LLM припиняє писати прозу й починає поставляти артефакти дизайну. Це стало вірусним — і залишилось закритим кодом, тільки платним, тільки хмарним, прив'язаним до моделі Anthropic та навичок Anthropic. Немає касси, немає self-hosting, немає Vercel deploy, немає зміни на свого власного агента.
**Open Design (OD) — це альтернатива з відкритим кодом.** Той же цикл, той же artifact-first менталітет, але без lock-in. Ми не поставляємо агента — найсильніші агенти для кодування вже живуть на вашому ноутбуці. Ми підключаємо їх до workflow дизайну, керованого навичками, що працює локально за допомогою `pnpm tools-dev`, може розгорнути веб-шар на Vercel, і залишається BYOK на кожному рівні.
Введіть `make me a magazine-style pitch deck for our seed round`. Інтерактивна форма запитань з'являється до того, як модель навіть імпровізує один піксель. Агент вибирає один із п'яти курованих візуальних напрямків. Живий план `TodoWrite` потокує в UI. Демон будує реальну папку проекту на диску з seed шаблоном, бібліотекою макетів і контрольним списком self-check. Агент читає їх — перевірка перед польотом обов'язкова — запускає п'яти-розмірну критику проти свого власного виходу й видає один `<artifact>`, який рендериться в пісочниці iframe через кілька секунд.
Це не "AI спробує щось спроектувати". Це AI, яка була навчена prompt stack, щоб поводитись як старший дизайнер з робочою файловою системою, детермінованою бібліотекою палітри та культурою контрольного списку — саме той стандарт, який встановив Claude Design, але відкритий і ваш.
OD стоїть на плечах чотирьох проектів з відкритим кодом:
- [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) — компас філософії дизайну. Workflow молодого дизайнера, протокол бренд-активів з 5 кроками, контрольний список anti-AI-slop, п'яти-розмірна self-critique та ідея "5 шкіл × 20 філософій дизайну" за нашим direction picker — все конденсоване в [`apps/daemon/src/prompts/discovery.ts`](apps/daemon/src/prompts/discovery.ts).
- [**`op7418/guizang-ppt-skill`**](https://github.com/op7418/guizang-ppt-skill) — режим presentations. Включена без змін під [`skills/guizang-ppt/`](skills/guizang-ppt/) із збереженою оригінальною ліцензією; макети в стилі журналу, WebGL герой, контрольні списки P0/P1/P2.
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) — UX північна зірка й наш найближчий партнер. Перша альтернатива Claude Design з відкритим кодом. Ми запозичили цикл streaming-artifact, шаблон preview sandboxed-iframe (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible generation) та п'ять форматів експорту (HTML / PDF / PPTX / ZIP / Markdown). Ми навмисно розходимось за формою — вони настільна Electron app з bundled [`pi-ai`][piai]; ми веб-app + локальний daemon, яка делегує вашому наявному CLI.
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) — архітектура daemon та runtime. Виявлення агента PATH-scan, локальний daemon як єдиний привілейований процес, світогляд agent-as-teammate.
## Одним поглядом
| | Що ви отримуєте |
|---|---|
| **CLI агентів для кодування (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — автоматично виявляються на `PATH`, одночисельний swap |
| **BYOK fallback** | Специфічний для протоколу API проксі за адресою `/api/proxy/{anthropic,openai,azure,google}/stream` — вставте `baseUrl` + `apiKey` + `model`, виберіть Anthropic / OpenAI / Azure OpenAI / Google Gemini, і демон нормалізує SSE назад у той самий потік чату. Внутрішні IP/SSRF заблоковані на краю демона. |
| **Системи дизайну вбудовані** | **129** — 2 hand-authored starter + 70 систем продукту (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) з [`awesome-design-md`][acd2], плюс 57 навичок дизайну з [`awesome-design-skills`][ads] додано безпосередньо під `design-systems/` |
| **Навички вбудовані** | **31** — 27 у режимі `prototype` (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 у режимі `deck` (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Згруповані у picker за `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
| **Медіа генерація** | Поверхні Image · video · audio поставляються разом з циклом дизайну. **gpt-image-2** (Azure / OpenAI) для плакатів, аватарів, інфографіки, ілюстрованих карт · **Seedance 2.0** (ByteDance) для кінематографічних 15-секундних text-to-video та image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) для HTML→MP4 motion graphics (product reveals, kinetic typography, data charts, social overlays, logo outros). **93** готових до репліки підказки — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — під [`prompt-templates/`](prompt-templates/), з preview thumbnails та атрибуцією джерела. Той же chat surface як код; виходить реальний `.mp4` / `.png` chip у робочий простір проекту. |
| **Візуальні напрями** | 5 курованих шкіл (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — кожна поставляється детермінованою палітрою OKLch + font stack ([`apps/daemon/src/prompts/directions.ts`](apps/daemon/src/prompts/directions.ts)) |
| **Кадри пристроїв** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — пікселем точні, спільні під [`assets/frames/`](assets/frames/) |
| **Agent runtime** | Локальний daemon запускає CLI у вашій папці проекту — агент отримує справжні `Read`, `Write`, `Bash`, `WebFetch` проти справжнього середовища на диску, з Windows `ENAMETOOLONG` fallbacks (stdin / prompt-file) у кожному адаптері |
| **Імпорти** | Перенесіть [Claude Design][cd] export ZIP на вікно приватних користувачів — `POST /api/import/claude-design` розбирає його на справжній проект, щоб ваш агент міг продовжувати там, де Anthropic закінчився |
| **Постійність** | SQLite за адресою `.od/app.sqlite`: projects · conversations · messages · tabs · saved templates. Пересніть завтра, todo card і відкриті файли саме там, де ви їх залишили. |
| **Життєвий цикл** | Одна точка входу: `pnpm tools-dev` (start / stop / run / status / logs / inspect / check) — завантажує daemon + web (+ desktop) під типізованими sidecar stamps |
| **Desktop** | Опціональна Electron shell із sandboxed renderer + sidecar IPC (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN) — керує `tools-dev inspect desktop screenshot` для E2E |
| **Розгортувати до** | Локально (`pnpm tools-dev`) · Vercel web layer · спакований Electron desktop-додаток для macOS (Apple Silicon) і Windows (x64) — завантаження з [open-design.ai](https://open-design.ai/) або зі [сторінки останнього релізу](https://github.com/nexu-io/open-design/releases) |
| **Ліцензія** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
[ads]: https://github.com/bergside/awesome-design-skills
## Демонстрація
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · Entry view" /><br/>
<sub><b>Вид входу</b> — виберіть навичку, виберіть систему дизайну, введіть brief. Та сама поверхня для прототипів, presentations, мобільних додатків, dashboards та редакційних сторінок.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · Turn-1 discovery form" /><br/>
<sub><b>Форма discovery Turn-1</b> — до того, як модель напише піксель, OD блокує brief: surface, audience, tone, brand context, scale. 30 секунд радіо краще, ніж 30 хвилин редиректів.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · Direction picker" /><br/>
<sub><b>Вибір напрямку</b> — коли користувач не має бренду, агент видає другу форму з 5 курованими напрямами (Monocle / Modern Minimal / Tech Utility / Brutalist / Soft Warm). Один click радіо → детермінована палітра + font stack, без model freestyle.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · Live todo progress" /><br/>
<sub><b>Живий прогрес todo</b> — план агента потокує як live card. `in_progress``completed` оновлення приходять в реальному часі. Користувач може дешево перенаправити в польоті.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · Sandboxed preview" /><br/>
<sub><b>Попередній перегляд в пісочниці</b> — кожен `<artifact>` рендериться в чистому srcdoc iframe. Редаговується на місці через файловий workspace; завантажується як HTML, PDF, ZIP.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · 72-system library" /><br/>
<sub><b>72-система бібліотека</b> — кожна система продукту показує своїм 4-колірна підпис. Натисніть для повного `DESIGN.md`, сітки зразків та live showcase.</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · Magazine deck" /><br/>
<sub><b>Режим Deck (guizang-ppt)</b> — bundled <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> падає без змін. Макети журналу, WebGL герой backgrounds, однофайловий HTML output, PDF export.</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · Mobile prototype" /><br/>
<sub><b>Мобільний прототип</b> — пікселем точна iPhone 15 Pro chrome (Dynamic Island, status bar SVGs, home indicator). Мультиекранні прототипи використовують спільні `/frames/` активи, тому агент ніколи не перерисовує телефон.</sub>
</td>
</tr>
</table>
## Навички
**31 навичка входить до комплекту.** Кожна — це папка під [`skills/`](skills/), яка слідує конвенції Claude Code [`SKILL.md`][skill] з розширеним `od:` frontmatter, який демон розбирає дослівно — `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)).
Два основні **режими** (modes) формують каталог: **`prototype`** (27 навичок — все, що рендериться як артефакт однієї сторінки, від журнального landing до екрана телефону чи специфікації PM) та **`deck`** (4 навички — horizontal-swipe presentations з deck-framework chrome). Поле **`scenario`** — це те, як вибір групує їх: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
### Показові приклади
Візуально характерні навички, які ви, ймовірно, захочете спробувати першими. Кожна з них містить реальний `example.html`, який ви можете відкрити прямо з репозиторію, щоб побачити, що саме створить агент — без реєстрації та налаштування.
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>Дашборд для знайомств — ліва навігація, стрічка новин, KPI, графік взаємних симпатій за 30 днів, редакційна типографіка.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>Цифровий посібник на два розвороти — обкладинка (назва, автор, зміст) + розворот уроку з цитатою та списком кроків.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>HTML-лист для запуску продукту — шапка, головне зображення, заголовок, CTA, сітка характеристик. Одна колонка, безпечно для таблиць.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>Три кадри ігрового мобільного додатка на темній сцені — обкладинка, сьогоднішні квести з XP та шкалою рівня, деталі квесту.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>Три кадри онбордингу мобільного додатка — заставка, цінність продукту, вхід. Статус-бар, точки прокрутки, основний CTA.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>Однокадровий герой моушн-дизайну з циклічною CSS-анімацією — кільце тексту, що обертається, анімований глобус, таймер. Готово для HyperFrames.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>Карусель для соцмереж з трьох карток 1080×1080 — кінематографічні панелі з заголовками, що з'єднуються в серію, логотип бренду.</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>Піксельний / 8-бітний анімований слайд-пояснення — кремова сцена на весь екран, анімований талісман, кінетичний японський шрифт, CSS-анімації.</sub>
</td>
</tr>
</table>
### Поверхні дизайну та маркетингу (режим prototype)
| Навичка | Платформа | Сценарій | Що створює |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | desktop | design | Односторінковий HTML — лендінги, маркетинг, головні сторінки (типово для прототипів) |
| [`saas-landing`](skills/saas-landing/) | desktop | marketing | Макет маркетингу: герой / переваги / ціни / CTA |
| [`dashboard`](skills/dashboard/) | desktop | operation | Адмінка / аналітика з бічною панеллю + щільний макет даних |
| [`pricing-page`](skills/pricing-page/) | desktop | sale | Окремі сторінки цін та таблиці порівняння |
| [`docs-page`](skills/docs-page/) | desktop | engineering | 3-колонковий макет документації |
| [`blog-post`](skills/blog-post/) | desktop | marketing | Редакційний лонгрід |
| [`mobile-app`](skills/mobile-app/) | mobile | design | Екран(и) додатка в рамці iPhone 15 Pro / Pixel |
| [`mobile-onboarding`](skills/mobile-onboarding/) | mobile | design | Багатоекранний онбординг (заставка · цінність · вхід) |
| [`gamified-app`](skills/gamified-app/) | mobile | personal | Трикадровий ігровий прототип мобільного додатка |
| [`email-marketing`](skills/email-marketing/) | desktop | marketing | Брендований HTML-лист для запуску продукту |
| [`social-carousel`](skills/social-carousel/) | desktop | marketing | Карусель для соцмереж з 3 карток 1080×1080 |
| [`magazine-poster`](skills/magazine-poster/) | desktop | marketing | Односторінковий плакат у журнальному стилі |
| [`motion-frames`](skills/motion-frames/) | desktop | marketing | Герой моушн-дизайну з циклічними CSS-анімаціями |
| [`sprite-animation`](skills/sprite-animation/) | desktop | marketing | Піксельний / 8-бітний анімований слайд-пояснення |
| [`dating-web`](skills/dating-web/) | desktop | personal | Макет дашборду для сервісу знайомств |
| [`digital-eguide`](skills/digital-eguide/) | desktop | marketing | Цифровий посібник на два розвороти (обкладинка + урок) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | desktop | design | Намальований від руки ескіз — для ранньої візуалізації ідей |
| [`critique`](skills/critique/) | desktop | design | 5-вимірна оцінка самокритики (Філософія · Ієрархія · Деталі · Функція · Інновація) |
| [`tweaks`](skills/tweaks/) | desktop | design | Панель налаштувань від AI — модель виводить параметри, які варто підкоригувати |
### Поверхні презентацій (режим deck)
| Навичка | Типово для | Що створює |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **типово** для deck | Веб-презентація у журнальному стилі — взято з [op7418/guizang-ppt-skill][guizang] |
| [`simple-deck`](skills/simple-deck/) | — | Мінімалістична презентація з горизонтальним гортанням |
| [`replit-deck`](skills/replit-deck/) | — | Презентація для огляду продукту (у стилі Replit) |
| [`weekly-update`](skills/weekly-update/) | — | Щотижневий звіт команди (прогрес · блокери · наступні кроки) |
### Поверхні для офісу та операцій (режим prototype, сценарії документів)
| Навичка | Сценарій | Що створює |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | Специфікація PM зі змістом + журналом рішень |
| [`team-okrs`](skills/team-okrs/) | product | Таблиця оцінки OKR |
| [`meeting-notes`](skills/meeting-notes/) | operation | Журнал рішень зустрічі |
| [`kanban-board`](skills/kanban-board/) | operation | Знімок канбан-дошки |
| [`eng-runbook`](skills/eng-runbook/) | engineering | Інструкція з реагування на інциденти |
| [`finance-report`](skills/finance-report/) | finance | Фінансовий звіт для керівництва |
| [`invoice`](skills/invoice/) | finance | Односторінковий рахунок-фактура |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | План онбордингу на посаду |
Додавання навички займає одну папку. Прочитайте [`docs/skills-protocol.md`](docs/skills-protocol.md) про розширений frontmatter, скопіюйте існуючу навичку, перезапустіть демон, і вона з'явиться у виборі. Ендпоінт каталогу — `GET /api/skills`; збірка seed для кожної навички (шаблон + side-file посилання) живе на `GET /api/skills/:id/example`.
## Шість ключових ідей
### 1 · Ми не постачаємо агента. Ваш — достатньо хороший.
При запуску демон сканує ваш `PATH` на наявність [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), `devin`, [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), [`qwen`](https://github.com/QwenLM/qwen-code), `qodercli`, [`copilot`](https://github.com/features/copilot/cli), `hermes`, `kimi`, [`pi`](https://github.com/mariozechner/pi-ai), [`kiro-cli`](https://kiro.dev) та [`vibe-acp`](https://github.com/mistralai/mistral-vibe) на старті. Ті, що знайдені, стають кандидатами на роль "двигуна" дизайну — вони керуються через stdio з одним адаптером на CLI, який можна змінити у виборі моделі. Натхненно [`multica`](https://github.com/multica-ai/multica) та [`cc-switch`](https://github.com/farion1231/cc-switch). Немає встановленого CLI? Режим API використовує той самий конвеєр — виберіть Anthropic, OpenAI-сумісний, Azure OpenAI або Google Gemini, і демон передаватиме нормалізовані фрагменти SSE, з блокуванням внутрішніх мереж на краю.
### 2 · Навички — це файли, а не плагіни.
Згідно з конвенцією Claude Code [`SKILL.md`][skill], кожна навичка — це `SKILL.md` + `assets/` + `references/`. Додайте папку в [`skills/`](skills/), перезапустіть демон, і вона з'явиться у виборі. Вбудована `magazine-web-ppt` — це [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill), додана без змін зі збереженням ліцензії та авторства.
### 3 · Системи дизайну — це портативний Markdown, а не JSON тем.
Схема `DESIGN.md` з 9 розділів від [`VoltAgent/awesome-design-md`][acd2] — колір, типографіка, відступи, макет, компоненти, рух, голос, бренд, антипатерни. Кожен артефакт базується на активній системі. Змініть систему → наступний рендер використовуватиме нові токени. Список включає **Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Apple, Anthropic, Cursor, Supabase, Figma, Resend, Raycast, Lovable, Cohere, Mistral, ElevenLabs, X.AI, Spotify, Webflow, Sanity, PostHog, Sentry, MongoDB, ClickHouse, Cal, Replicate, Clay, Composio, Xiaohongshu…** — плюс 57 навичок дизайну з [`awesome-design-skills`][ads].
### 4 · Інтерактивна форма запитань запобігає 80% помилок.
Стек промптів OD жорстко кодує `RULE 1`: кожен новий бриф дизайну починається з `<question-form id="discovery">` замість коду. Поверхня · аудиторія · тон · контекст бренду · масштаб · обмеження. Довгий бриф все одно залишає відкритими рішення щодо дизайну — візуальний тон, колірна позиція — саме те, що форма фіксує за 30 секунд. Вартість неправильного напрямку — один раунд чату, а не готовий проект.
Це **режим Junior-Designer**, взятий з [`huashu-design`](https://github.com/alchaincyf/huashu-design): зберіть питання заздалегідь, покажіть щось візуальне на ранній стадії (навіть вайрфрейм), дозвольте користувачеві дешево змінити напрямок. Поєднано з протоколом бренд-активів, це головна причина, чому результат виглядає як робота дизайнера, а не випадкова генерація AI.
### 5 · Демон дає агенту відчуття, що він на вашому ноутбуці, бо так і є.
Демон запускає CLI з робочим каталогом (`cwd`), встановленим у папку артефактів проекту під `.od/projects/<id>/`. Агент отримує справжні інструменти `Read`, `Write`, `Bash`, `WebFetch` проти реальної файлової системи. Він може читати `assets/template.html` навички, шукати HEX-значення у вашому CSS, писати `brand-spec.md`, додавати зображення і створювати файли `.pptx` / `.zip` / `.pdf`, які з'являються у робочому просторі. Сесії та повідомлення зберігаються у локальній БД SQLite.
### 6 · Стек промптів — це і є продукт.
Те, що ви компонуєте під час відправки, — це не просто "система + користувач". Це:
```
DISCOVERY directives (форма 1-го ходу, бранч бренду 2-го ходу, TodoWrite, 5-вимірна критика)
+ identity charter (OFFICIAL_DESIGNER_PROMPT, anti-AI-slop, junior-pass)
+ active DESIGN.md (72 системи доступні)
+ active SKILL.md (31 навичка доступна)
+ project metadata (тип, точність, нотатки доповідача, анімації, inspiration ids)
+ skill side files (автоматично введені: read assets/template.html + references/*.md)
+ (тип deck, без skill seed) DECK_FRAMEWORK_DIRECTIVE (навігація / лічильник / прокрутка / друк)
```
Кожен рівень можна комбінувати та редагувати. Прочитайте [`apps/daemon/src/prompts/system.ts`](apps/daemon/src/prompts/system.ts) та [`apps/daemon/src/prompts/discovery.ts`](apps/daemon/src/prompts/discovery.ts), щоб побачити актуальний контракт.
## Архітектура
```
┌────────────────────── браузер (Next.js 16) ──────────────────────┐
│ чат · робочий простір · прев'ю в iframe · налаштування · імпорт │
└──────────────┬───────────────────────────────────┬───────────────┘
│ /api/* (переписано в dev) │
▼ ▼
┌──────────────────────────────────┐ /api/proxy/{provider}/stream (SSE)
│ Локальний демон (Express + SQLite) │ ─→ будь-який OpenAI-сумісний
│ │ ендпоінт (BYOK)
│ /api/agents /api/skills│ з блокуванням SSRF
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/{provider}/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (static) /frames (static)
│ опціонально: sidecar IPC у /tmp/open-design/ipc/<ns>/<app>.sock
│ (STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN)
└─────────┬────────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · devin (ACP) · gemini · opencode · cursor-agent │
│ qwen · qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) · kiro (ACP) · vibe (ACP) │
│ читає SKILL.md + DESIGN.md, пише артефакти на диск │
└──────────────────────────────────────────────────────────────────┘
```
| Рівень | Стек |
|---|---|
| Frontend | Next.js 16 App Router + React 18 + TypeScript, розгортається на Vercel |
| Daemon | Node 24 · Express · SSE streaming · `better-sqlite3`; таблиці: `projects`, `conversations`, `messages`, `tabs`, `templates` |
| Транспорт агента | `child_process.spawn`; типізовані парсери для `claude-stream-json` (Claude Code), `qoder-stream-json` (Qoder CLI), `copilot-stream-json` (Copilot), `json-event-stream` (Codex / Gemini / OpenCode / Cursor Agent), `acp-json-rpc` (Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe), `pi-rpc` (Pi), `plain` (Qwen Code / DeepSeek TUI) |
| BYOK проксі | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → специфічні API провайдерів, нормалізований SSE `delta/end/error`; блокує loopback / RFC1918 на краю демона |
| Сховище | Звичайні файли в `.od/projects/<id>/` + SQLite у `.od/app.sqlite` (ігнорується git, автоматично створюється). Перевизначте корінь через `OD_DATA_DIR` для ізоляції тестів |
| Попередній перегляд | Ізольований iframe через `srcdoc` + парсер `<artifact>` для кожної навички ([`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts)) |
| Експорт | HTML (вбудовані активи) · PDF (друк браузера, deck-aware) · PPTX (через агента, через навичку) · ZIP (archiver) · Markdown |
| Життєвий цикл | `pnpm tools-dev start | stop | run | status | logs | inspect | check`; порти через `--daemon-port` / `--web-port`, простори імен через `--namespace` |
| Desktop (опц) | Electron shell — виявляє URL через sidecar IPC, без вгадування портів; той самий канал `STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN` керує `tools-dev inspect desktop …` для E2E |
## Швидкий старт
### Завантажити desktop-додаток (збірка не потрібна)
Найшвидший спосіб спробувати Open Design — готовий desktop-додаток, без Node, pnpm і клонування:
- **[open-design.ai](https://open-design.ai/)** — офіційна сторінка завантаження
- **[GitHub релізи](https://github.com/nexu-io/open-design/releases)**
### Запуск з вихідного коду
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # має вивести 10.33.2
pnpm install
pnpm tools-dev run web
# відкрийте URL у браузері, який виведе tools-dev
```
Лаунчер Windows: зберіть `OpenDesign.exe` самостійно за інструкцією в `tools/launcher/README.md` або завантажте його з GitHub Releases. Потім покладіть файл у корінь репозиторію й двічі клацніть його, щоб за потреби виконати `pnpm install` і запустити Open Design через `pnpm tools-dev`.
Вимоги до середовища: Node `~24` та pnpm `10.33.x`. `nvm`/`fnm` є лише додатковими помічниками; якщо ви використовуєте один з них, запустіть `nvm install 24 && nvm use 24` або `fnm install 24 && fnm use 24` перед `pnpm install`.
Для запуску desktop/background, перезапусків з фіксованими портами та перевірок диспетчера генерації медіа (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), див. [`QUICKSTART.md`](QUICKSTART.md).
Перше завантаження:
1. Виявляє, які CLI агенти ви маєте в `PATH`, і автоматично вибирає один.
2. Завантажує 31 навичку + 72 системи дизайну.
3. Виводить вітальне діалогове вікно, щоб ви могли вставити ключ Anthropic (потрібен лише для резервного шляху BYOK).
4. **Автоматично створює `./.od/`** — локальну папку для бази даних SQLite, артефактів для кожного проекту та збережених рендерів. Крок `od init` не потрібен; демон створює все, що йому потрібно при запуску.
Введіть промпт, натисніть **Send**, дочекайтеся появи форми запитань, заповніть її, дочекайтеся потоку картки завдання, дочекайтеся рендерингу артефакту. Натисніть **Save to disk** або завантажте як ZIP-архів проекту.
### Стан першого запуску (`./.od/`)
Демон володіє однією прихованою папкою в корені репозиторію. Все в ній ігнорується git і є локальним для машини — ніколи не комітьте її.
```
.od/
├── app.sqlite ← проекти · розмови · повідомлення · відкриті вкладки
├── artifacts/ ← одноразові рендери "Зберегти на диск" (з відміткою часу)
└── projects/<id>/ ← робочий каталог для кожного проекту, також cwd агента
```
| Хочете… | Зробіть це |
|---|---|
| Перевірити, що там є | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| Скинути до чистого стану | `pnpm tools-dev stop`, `rm -rf .od`, запустіть `pnpm tools-dev run web` знову |
| Перемістити в інше місце | поки не підтримується — шлях жорстко закодований відносно репозиторію |
Повна карта файлів, скрипти та усунення несправностей → [`QUICKSTART.md`](QUICKSTART.md).
## Структура репозиторію
```
open-design/
├── README.md ← цей файл
├── README.de.md ← Deutsch
├── README.ru.md ← Русский
├── README.zh-CN.md ← 简体中文
├── QUICKSTART.md ← посібник із запуску / збірки / розгортання
├── package.json ← pnpm workspace, бінарний файл: od
├── apps/
│ ├── daemon/ ← Node + Express, основний сервер
│ │ ├── src/ ← джерельний код демона на TypeScript
│ │ │ ├── cli.ts ← код `od` bin, компілюється у dist/cli.js
│ │ │ ├── server.ts ← маршрути /api/* (проекти, чат, файли, експорт)
│ │ │ ├── agents.ts ← сканер PATH + збирачі аргументів CLI
│ │ │ ├── claude-stream.ts ← потоковий JSON-парсер stdout Claude Code
│ │ │ ├── skills.ts ← завантажувач frontmatter SKILL.md
│ │ │ └── db.ts ← схема SQLite (проекти/повідомлення/шаблони/вкладки)
│ │ ├── sidecar/ ← обгортка sidecar демона tools-dev
│ │ └── tests/ ← тести пакету демона
│ │
│ └── web/ ← Next.js 16 App Router + React клієнт
│ ├── app/ ← точки входу App Router
│ ├── next.config.ts ← dev rewrites + prod static export у out/
│ └── src/ ← React + TypeScript клієнтські модулі
│ ├── App.tsx ← маршрутизація, bootstrap, налаштування
│ ├── components/ ← чат, композер, пікер, прев'ю, скетч, …
│ ├── prompts/
│ │ ├── system.ts ← composeSystemPrompt(base, skill, DS, metadata)
│ │ ├── discovery.ts ← форма 1-го ходу + бранч 2-го ходу + 5-вимірна критика
│ │ └── directions.ts ← 5 візуальних напрямків × OKLch палітра + font stack
│ ├── artifacts/ ← потоковий парсер <artifact> + маніфести
│ ├── runtime/ ← iframe srcdoc, markdown, помічники експорту
│ ├── providers/ ← транспорт SSE демона + BYOK API
│ └── state/ ← конфіг + проекти (localStorage + daemon-backed)
├── e2e/ ← Playwright UI + зовнішній інтеграційний/Vitest харнес
├── packages/
│ ├── contracts/ ← спільні контракти веб/daemon додатку
│ ├── sidecar-proto/ ← контракт протоколу sidecar Open Design
│ ├── sidecar/ ← загальні примітиви sidecar рантайму
│ └── platform/ ← загальні примітиви процесів/платформи
├── skills/ ← 31 комплект навичок SKILL.md (27 prototype + 4 deck)
│ ├── web-prototype/ ← типовий для режиму prototype
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← режим deck
│ └── guizang-ppt/ ← bundled magazine-web-ppt (типово для deck)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 системи DESIGN.md
│ ├── default/ ← Neutral Modern (стартер)
│ ├── warm-editorial/ ← Warm Editorial (стартер)
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md ← огляд каталогу
├── assets/
│ └── frames/ ← спільні кадри пристроїв (використовуються між навичками)
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ ├── deck-framework.html ← база deck (навігація / лічильник / друк)
│ └── kami-deck.html ← kami-стильований deck стартер (пергамент / ink-blue serif)
├── scripts/
│ └── sync-design-systems.ts ← реімпорт upstream awesome-design-md tarball
├── docs/
│ ├── spec.md ← специфікація продукту, сценарії, диференціація
│ ├── architecture.md ← топології, потік даних, компоненти
│ ├── skills-protocol.md ← розширений SKILL.md od: frontmatter
│ ├── agent-adapters.md ← виявлення + диспетчеризація для кожного CLI
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← довге походження
│ ├── roadmap.md ← поетапна поставка
│ ├── schemas/ ← JSON-схеми
│ └── examples/ ← канонічні приклади артефактів
└── .od/ ← дані під час виконання, ігноруються git, створюються автоматично
├── app.sqlite ← проекти / розмови / повідомлення / вкладки
├── projects/<id>/ ← робоча папка проекту (cwd агента)
└── artifacts/ ← збережені одноразові рендери
```
## Системи дизайну
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="Бібліотека 72 систем дизайну — стиль-гайд розворот" width="100%" />
</p>
72 системи з коробки, кожна як один [`DESIGN.md`](design-systems/README.md):
<details>
<summary><b>Повний каталог</b> (натисніть, щоб розгорнути)</summary>
**AI & LLM**`claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**Інструменти розробника**`cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**Продуктивність**`notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**Фінтех**`stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**E-Commerce**`shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**Медіа**`spotify` · `playstation` · `wired` · `theverge` · `meta`
**Автомобілі**`tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**Інше**`apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**Стартери**`default` (Neutral Modern) · `warm-editorial`
</details>
Бібліотека продуктових систем імпортується через [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) з [`VoltAgent/awesome-design-md`][acd2]. Перезапустіть для оновлення. 57 навичок дизайну беруться з [`bergside/awesome-design-skills`][ads] та додаються безпосередньо у `design-systems/`.
## Візуальні напрями
Коли у користувача немає специфікації бренду, агент видає другу форму з п'ятьма курованими напрямками — адаптація OD [fallback "5 шкіл × 20 філософій дизайну" з `huashu-design`](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback). Кожен напрямок — це детермінована специфікація (палітра в OKLch, font stack, підказки макетної позиції, референси), яку агент прив'язує дослівно у `:root` seed-шаблону. Один клік радіо → повністю специфікована візуальна система. Без імпровізації, без AI-slop.
| Напрямок | Настрій | Референси |
|---|---|---|
| Editorial — Monocle / FT | Друкований журнал, чорнило + крем + тепла іржа | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | Холодний, структурований, мінімальні акценти | Linear · Vercel · Stripe |
| Tech utility | Щільність інформації, моноширинний, термінал | Bloomberg · Bauhaus tools |
| Brutalist | Сирий, великий шрифт, без тіней, різкі акценти | Bloomberg Businessweek · Achtung |
| Soft warm | Щедрий, низький контраст, персикові нейтральні тони | Notion marketing · Apple Health |
Повна специфікація → [`apps/daemon/src/prompts/directions.ts`](apps/daemon/src/prompts/directions.ts).
## Медіа генерація
OD не зупиняється на коді. Та сама поверхня чату, яка створює `<artifact>` HTML, також керує генерацією **зображень**, **відео** та **аудіо**, з адаптерами моделей у медіа-конвеєрі демона ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Кожен рендер зберігається як реальний файл у робочому просторі проекту — `.png` для зображень, `.mp4` для відео — і з'являється як чіп для завантаження після завершення ходу.
Сьогодні підтримуються три сімейства моделей:
| Поверхня | Модель | Провайдер | Для чого |
|---|---|---|---|
| **Зображення** | `gpt-image-2` | Azure / OpenAI | Плакати, аватари профілів, карти, інфографіка, соціальні картки, розрізи продуктів |
| **Відео** | `seedance-2.0` | ByteDance Volcengine | 15с кінематографічного відео з аудіо за текстом або зображенням — короткометражки, великі плани, хореографія |
| **Відео** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 моушн-графіка — презентації продуктів, кінетична типографіка, діаграми, логотипи, караоке-субтитри |
Зростаюча **галерея промптів** у [`prompt-templates/`](prompt-templates/) поставляє **93 готові до репліки промпти** — 43 зображення (`prompt-templates/image/*.json`), 39 Seedance (`prompt-templates/video/*.json` без `hyperframes-*`), 11 HyperFrames (`prompt-templates/video/hyperframes-*.json`). Кожен містить мініатюру попереднього перегляду, тіло промпту дослівно, цільову модель, співвідношення сторін та блок `source` для ліцензії та атрибуції. Демон обслуговує їх на `GET /api/prompt-templates`, веб-додаток відображає їх як сітку карток у вкладках **Шаблони зображень** та **Шаблони відео** на виді входу; один клік опускає промпт у композер з попередньо вибраною моделлю.
### gpt-image-2 — галерея зображень (вибірка з 43)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="3D Stone Staircase Evolution" /><br/><sub><b>3D-інфографіка «Еволюція кам'яних сходів»</b><br/>3-крокова інфографіка, естетика тесаного каменю</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Illustrated City Food Map" /><br/><sub><b>Ілюстрована міська гастрокарта</b><br/>Редакційний ілюстрований від руки туристичний плакат</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cinematic Elevator Scene" /><br/><sub><b>Кінематографічна сцена в ліфті</b><br/>Однокадрова редакційна модна зйомка</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Cyberpunk Anime Portrait" /><br/><sub><b>Кіберпанк-аніме портрет</b><br/>Аватар профілю — неоновий текст на обличчі</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Glamorous Woman in Black" /><br/><sub><b>Гламурний портрет жінки в чорному</b><br/>Редакційний студійний портрет</sub></td>
</tr>
</table>
Повний набір → [`prompt-templates/image/`](prompt-templates/image/). Джерела: більшість запозичена з [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts) (CC-BY-4.0) зі збереженням атрибуції автора для кожного шаблону.
### Seedance 2.0 — відеогалерея (вибірка з 39)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Music Podcast Guitar" /></a><br/><sub><b>Музичний подкаст та гітарна техніка</b><br/>4K кінематографічна студійна зйомка</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Emotional Face" /></a><br/><sub><b>Емоційний крупний план обличчя</b><br/>Кінематографічне дослідження мікроемоцій</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Luxury Supercar" /></a><br/><sub><b>Кінематографічний люксовий суперкар</b><br/>Наративний продуктовий фільм</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Forbidden City Cat" /></a><br/><sub><b>Сатира «Кіт у Забороненому місті»</b><br/>Стилізований сатиричний короткометражний фільм</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Japanese Romance" /></a><br/><sub><b>Японська романтична короткометражка</b><br/>15-секундний наратив Seedance 2.0</sub></td>
</tr>
</table>
Натисніть будь-яку мініатюру, щоб відтворити реальний MP4. Повний набір → [`prompt-templates/video/`](prompt-templates/video/) (записи `*-seedance-*` та з тегом Cinematic). Джерела: [`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts) (CC-BY-4.0) зі збереженням оригінальних посилань на твіти та хендлів авторів.
### HyperFrames — HTML→MP4 моушн-графіка (11 готових до репліки шаблонів)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) — це фреймворк відео з відкритим кодом від HeyGen, нативний для агентів: ви (або агент) пишете HTML + CSS + GSAP, HyperFrames рендерить це у детермінований MP4 через headless Chrome + FFmpeg. Open Design поставляє HyperFrames як відеомодель першого класу (`hyperframes-html`), підключену до диспетчеризації демона, плюс навичку `skills/hyperframes/`, яка навчає агента контракту таймлайну, правил переходу між сценами, аудіо-реактивних патернів, субтитрів/TTS та каталог-блоків (`npx hyperframes add <slug>`).
Одинадцять промптів hyperframes поставляються у [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/), кожен — конкретний бриф, що створює певний архетип:
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Product reveal" /></a><br/><sub><b>5с мінімальний продукт-ревіл</b> · 16:9 · титульна картка з push-in та шейдерним переходом</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="SaaS promo" /></a><br/><sub><b>30с SaaS продукт-промо</b> · 16:9 · стиль Linear/ClickUp з 3D-ревілами UI</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaoke" /></a><br/><sub><b>TikTok караоке talking-head</b> · 9:16 · TTS + субтитри з синхронізацією по словах</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Brand sizzle" /></a><br/><sub><b>30с бренд sizzle-reel</b> · 16:9 · кінетична типографіка під біт, аудіо-реактивна</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Data chart" /></a><br/><sub><b>Анімована гонка стовпчикових діаграм</b> · 16:9 · NYT-стиль інфографіка даних</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Flight map" /></a><br/><sub><b>Карта польоту (місце → призначення)</b> · 16:9 · кінематографічний ревіл маршруту в стилі Apple</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>4с кінематографічний logo-outro</b> · 16:9 · збірка по частинах + bloom</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Money counter" /></a><br/><sub><b>Лічильник грошей $0 → $10K</b> · 9:16 · хайп в стилі Apple з зеленою спалаху + вибухом</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>3-телефонна вітрина додатка</b> · 16:9 · парящі телефони з підписами функцій</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Social overlay" /></a><br/><sub><b>Стек соціальних оверлеїв</b> · 9:16 · X · Reddit · Spotify · Instagram послідовно</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website to video" /></a><br/><sub><b>Пайплайн сайт→відео</b> · 16:9 · захват сайту у 3 в'юпортах + переходи</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
Патерн той самий, що й скрізь: виберіть шаблон, відредагуйте бриф, надішліть. Агент читає вбудований `skills/hyperframes/SKILL.md` (який містить OD-специфічний workflow рендерингу — композиція вихідних файлів у `.hyperframes-cache/`, щоб не засмічувати файловий робочий простір, демон диспетчеризує `npx hyperframes render`, щоб уникнути macOS sandbox-exec / Puppeteer зависання, лише фінальний `.mp4` з'являється як чіп проекту), створює композицію та поставляє MP4. Мініатюри каталог-блоків © HeyGen, подаються з їхнього CDN; сам OSS фреймворк — Apache-2.0.
> **Також підключені, але ще не представлені як шаблони:** Kling 2.0 / 1.6 / 1.5, Veo 3 / Veo 2, Sora 2 / Sora 2-Pro (через Fal), MiniMax video-01 — всі живі у `VIDEO_MODELS` ([`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Suno v5 / v4.5, Udio v2, Lyria 2 (музика) та gpt-4o-mini-tts, MiniMax TTS (мовлення) покривають аудіо-поверхню. Шаблони для них відкриті для внесків — додайте JSON у `prompt-templates/video/` або `prompt-templates/audio/`, і він з'явиться у пікері.
## Поза чатом — що ще поставляється
Цикл чат / артефакт отримує головну увагу, але низка менш помітних можливостей вже підключені й варто знати перед тим, як порівнювати OD з чимось іншим:
- **Імпорт Claude Design ZIP.** Перетягніть експорт з claude.ai на вітальне діалогове вікно. `POST /api/import/claude-design` розпакує його у реальну `.od/projects/<id>/`, відкриє вхідний файл як вкладку та підготує промпт «продовжити там, де Anthropic зупинився» для вашого локального агента. Без перепромпту, без «попросіть модель відтворити те, що ми щойно мали». ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
- **Багатопровайдерний BYOK проксі.** `POST /api/proxy/{anthropic,openai,azure,google}/stream` приймає `{ baseUrl, apiKey, model, messages }`, будує специфічний для провайдера запит, нормалізує SSE-фрагменти у `delta/end/error` та відхиляє loopback / link-local / RFC1918 адресати для захисту від SSRF. OpenAI-сумісний покриває OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter та self-hosted vLLM; Azure OpenAI додає deployment URL + `api-version`; Google використовує Gemini `:streamGenerateContent`.
- **Збережені користувачем шаблони.** Коли рендер вам подобається, `POST /api/templates` створює знімок HTML + метаданих у таблиці `templates` SQLite. Наступний проект вибере його з ряду «ваші шаблони» у пікері — та ж поверхня, що й 31 вбудована, але ваша.
- **Збереження вкладок.** Кожен проект запам'ятовує свої відкриті файли та активну вкладку у таблиці `tabs`. Відкрийте проект завтра, і робочий простір виглядатиме саме так, як ви його залишили.
- **API лінтингу артефактів.** `POST /api/artifacts/lint` запускає структурні перевірки згенерованого артефакту (пошкоджене `<artifact>` обрамлення, відсутні необхідні side-файли, застарілі токени палітри) та повертає знахідки, які агент може прочитати у свій наступний хід. П'ятивимірна self-critique використовує це для обґрунтування оцінки реальними доказами, а не враженнями.
- **Протокол sidecar + автоматизація desktop.** Процеси демона, вебу та desktop несуть типізовані п'ятипольні штампи (`app · mode · namespace · ipc · source`) та надають JSON-RPC IPC канал за адресою `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status | eval | screenshot` керує цим каналом, тому headless E2E працює проти реальної Electron shell без спеціальних харнесів ([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)).
- **Windows-дружнє породження.** Кожен адаптер, який інакше перевищив би ліміт argv ~32 КБ `CreateProcess` для довгих складених промптів (Codex, Gemini, OpenCode, Cursor Agent, Qwen, Qoder CLI, Pi), подає промпт через stdin. Claude Code та Copilot зберігають `-p`; демон відкатується до тимчасового файлу промпту, коли й це переповнюється.
- **Дані виконання для кожного простору імен.** `OD_DATA_DIR` та `--namespace` дають вам повністю ізольовані `.od/`-дерева, тому Playwright, бета-канали та ваші реальні проекти ніколи не ділять файл SQLite.
## Механізм Anti-AI-slop
Весь наведений нижче механізм — це плейбук [`huashu-design`](https://github.com/alchaincyf/huashu-design), портований у стек промптів OD та зроблений обов'язковим для кожної навички через pre-flight side-файлів. Прочитайте [`apps/daemon/src/prompts/discovery.ts`](apps/daemon/src/prompts/discovery.ts) для актуального формулювання:
- **Спочатку форма запитань.** Хід 1 — лише `<question-form>` — ніякого мислення, інструментів чи описів. Користувач обирає дефолти зі швидкістю радіо.
- **Екстракція бренд-специфікації.** Коли користувач додає скріншот або URL, агент виконує п'ятикроковий протокол (знайти · завантажити · grep hex · кодифікувати `brand-spec.md` · озвучити) перед написанням CSS. **Ніколи не вгадує кольори бренду з пам'яті.**
- **П'ятивимірна критика.** Перед видачею `<artifact>` агент мовчки оцінює свій вихід 15 за філософією / ієрархією / виконанням / специфічністю / стриманістю. Все нижче 3/5 — регресія — виправити та переоцінити. Два проходи — це нормально.
- **Чек-лист P0/P1/P2.** Кожна навичка поставляє `references/checklist.md` з жорсткими воротами P0. Агент повинен пройти P0 перед видачею.
- **Чорний список slop.** Агресивні фіолетові градієнти, універсальні іконки-емодзі, закруглені картки з акцентною лівою рамкою, намальовані від руки SVG-люди, Inter як *display* шрифт, вигадані метрики — явно заборонені в промпті.
- **Чесні плейсхолдери кращі за фейкові статистики.** Коли агент не має реального числа, він пише `—` або позначений сірий блок, а не «в 10 разів швидше».
## Порівняння
| Вісь | [Claude Design][cd] (Anthropic) | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| Ліцензія | Закрита | MIT | **Apache-2.0** |
| Форм-фактор | Веб (claude.ai) | Desktop (Electron) | **Веб-додаток + локальний демон** |
| Розгортання на Vercel | ❌ | ❌ | **✅** |
| Рантайм агента | Вбудований (Opus 4.7) | Вбудований ([`pi-ai`][piai]) | **Делеговано наявному CLI користувача** |
| Навички | Пропрієтарні | 12 кастомних TS-модулів + `SKILL.md` | **31 файлових [`SKILL.md`][skill] комплектів, що додаються перетягуванням** |
| Система дизайну | Пропрієтарна | `DESIGN.md` (дорожня карта v0.2) | **`DESIGN.md` × 129 систем поставлено** |
| Гнучкість провайдерів | Лише Anthropic | 7+ через [`pi-ai`][piai] | **16 CLI-адаптерів + OpenAI-сумісний BYOK проксі** |
| Початкова форма запитань | ❌ | ❌ | **✅ Жорстке правило, хід 1** |
| Вибір напрямку | ❌ | ❌ | **✅ 5 детермінованих напрямків** |
| Живий прогрес todo + потік інструментів | ❌ | ✅ | **✅** (UX-патерн з open-codesign) |
| Попередній перегляд у пісочниці iframe | ❌ | ✅ | **✅** (патерн з open-codesign) |
| Імпорт Claude Design ZIP | н/д | ❌ | **`POST /api/import/claude-design` — продовжуйте редагувати там, де Anthropic зупинився** |
| Хірургічні редагування в режимі коментарів | ❌ | ✅ | 🟡 частково — коментарі елементів прев'ю + вкладення чату; надійність хірургічних патчів ще в процесі |
| Панель AI-налаштувань | ❌ | ✅ | 🚧 дорожня карта — виділена UX-панель налаштувань з боку чату ще не реалізована |
| Файлова система як робочий простір | ❌ | частково (Electron sandbox) | **✅ Реальний cwd, реальні інструменти, збережений SQLite (проекти · розмови · повідомлення · вкладки · шаблони)** |
| П'ятивимірна self-critique | ❌ | ❌ | **✅ Ворота перед видачею** |
| Лінтинг артефактів | ❌ | ❌ | **`POST /api/artifacts/lint` — знахідки передаються назад агенту** |
| Sidecar IPC + headless desktop | ❌ | ❌ | **✅ Штамповані процеси + `tools-dev inspect desktop status | eval | screenshot`** |
| Формати експорту | Обмежені | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTX (через агента) / ZIP / Markdown** |
| Повторне використання PPT-навички | Н/Д | Вбудована | **[`guizang-ppt-skill`][guizang] додається (типово для режиму deck)** |
| Мінімальний білінг | Pro / Max / Team | BYOK | **BYOK — вставте будь-який OpenAI-сумісний `baseUrl`** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## Підтримувані агенти для кодування
Автоматично виявляються з `PATH` при старті демона. Налаштування не потрібні. Диспетчеризація потоків живе у [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) (`AGENT_DEFS`); парсери для кожного CLI — поруч. Моделі заповнюються або через зондування `<bin> --list-models` / `<bin> models` / ACP handshake, або з курованого резервного списку, коли CLI не надає список.
| Агент | Бінар | Формат потоку | Форма argv (шлях складеного промпту) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json` (типізовані події) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + парсер `codex` | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--model …] [-c model_reasoning_effort=…]` (промпт на stdin) |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + парсер `gemini` | `gemini --output-format stream-json --skip-trust --yolo [--model …] -` (промпт на stdin) |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + парсер `opencode` | `opencode run --format json --dangerously-skip-permissions [--model …] -` (промпт на stdin) |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + парсер `cursor-agent` | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -` (промпт на stdin) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain` (сирий stdout) | `qwen --yolo [--model …] -` (промпт на stdin) |
| Qoder CLI | `qodercli` | `qoder-stream-json` (типізовані події) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]` (промпт на stdin) |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json` (типізовані події) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc` (Agent Client Protocol) | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain` (raw stdout chunks) | `deepseek exec --auto [--model …] <prompt>` |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc` (stdio JSON-RPC) | `pi --mode rpc [--model …] [--thinking …]` (промпт надсилається як RPC-команда `prompt`) |
| **Багатопровайдерний BYOK** | н/д | Нормалізація SSE | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI-сумісний / Azure OpenAI / Gemini; захист від SSRF проти loopback / link-local / RFC1918 |
Додавання нового CLI — це один запис у [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). Формат потоку — один із `claude-stream-json`, `qoder-stream-json`, `copilot-stream-json`, `json-event-stream``eventParser` для кожного CLI), `acp-json-rpc`, `pi-rpc` або `plain`.
## Посилання та лінія спадкоємності
Кожен зовнішній проект, який це сховище запозичило. Кожне посилання веде до джерела, щоб ви могли перевірити походження.
| Проект | Роль тут |
|---|---|
| [`Claude Design`][cd] | Закритий продукт, альтернативою з відкритим кодом до якого є це сховище. |
| [**`alchaincyf/huashu-design`**](https://github.com/alchaincyf/huashu-design) | Ядро філософії дизайну. Workflow молодого дизайнера, 5-кроковий протокол бренд-активів, чек-лист anti-AI-slop, п'ятивимірна self-critique та бібліотека «5 шкіл × 20 філософій дизайну» за нашим вибором напрямку — все дистильовано у [`apps/daemon/src/prompts/discovery.ts`](apps/daemon/src/prompts/discovery.ts) та [`apps/daemon/src/prompts/directions.ts`](apps/daemon/src/prompts/directions.ts). |
| [**`op7418/guizang-ppt-skill`**][guizang] | Навичка magazine-web-PPT, вбудована дослівно під [`skills/guizang-ppt/`](skills/guizang-ppt/) зі збереженням оригінальної ЛІЦЕНЗІЇ. Типова для режиму deck. Культура чек-листів P0/P1/P2 запозичена для кожної іншої навички. |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Архітектура демона + адаптерів. Виявлення агента через PATH-scan, локальний демон як єдиний привілейований процес, світогляд agent-as-teammate. Ми приймаємо модель; ми не вендоримо код. |
| [**`OpenCoworkAI/open-codesign`**][ocod] | Перша альтернатива Claude Design з відкритим кодом та наш найближчий партнер. Прийняті UX-патерни: цикл streaming-artifact, прев'ю у пісочниці iframe (вендовані React 18 + Babel), жива панель агента (todos + tool calls + переривається), п'ятиформатний список експорту (HTML/PDF/PPTX/ZIP/Markdown), локальний хаб зберігання, ін'єкція смаку через `SKILL.md`, та перший прох коментарів режиму прев'ю. UX-патерни, що ще в нашій дорожній карті: повна надійність хірургічних редагувань та панель AI-налаштувань. **Ми навмисно не вендоримо [`pi-ai`][piai]** — open-codesign вбудовує його як рантайм агента; ми делегуємо тому CLI, який вже є у користувача. |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Джерело 9-секційної схеми `DESIGN.md` та 70 продуктових систем, імпортованих через [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). |
| [`bergside/awesome-design-skills`][ads] | Джерело 57 навичок дизайну, доданих безпосередньо як нормалізовані файли `DESIGN.md` під `design-systems/`. |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | Натхнення для розподілу навичок через symlink між кількома CLI агентів. |
| [Навички Claude Code][skill] | Конвенція `SKILL.md`, прийнята дослівно — будь-яка навичка Claude Code додається у `skills/` і підхоплюється демоном. |
Детальний опис походження — що ми беремо від кожного, що навмисно не беремо — живе у [`docs/references.md`](docs/references.md).
## Дорожня карта
- [x] Демон + виявлення агентів (16 CLI-адаптерів) + реєстр навичок + каталог систем дизайну
- [x] Веб-додаток + чат + форма запитань + вибір з 5 напрямків + прогрес todo + прев'ю в пісочниці
- [x] 31 навичка + 72 системи дизайну + 5 візуальних напрямків + 5 кадрів пристроїв
- [x] Проекти · розмови · повідомлення · вкладки · шаблони на SQLite
- [x] Багатопровайдерний BYOK проксі (`/api/proxy/{anthropic,openai,azure,google}/stream`) з захистом SSRF
- [x] Імпорт Claude Design ZIP (`/api/import/claude-design`)
- [x] Протокол sidecar + Electron desktop з IPC-автоматизацією (STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN)
- [x] API лінтингу артефактів + ворота п'ятивимірної self-critique перед видачею
- [ ] Хірургічні редагування в режимі коментарів — частково поставлено: коментарі елементів прев'ю та вкладення чату; надійне цілеспрямоване патчування ще в процесі
- [ ] UX панелі AI-налаштувань — ще не реалізовано
- [ ] Рецепт розгортання Vercel + тунель (Топологія B)
- [ ] Одна команда `npx od init` для скаффолдингу проекту з `DESIGN.md`
- [ ] Маркетплейс навичок (`od skills install <github-repo>`) та CLI-поверхня `od skill add | list | remove | test` (задрафтовано в [`docs/skills-protocol.md`](docs/skills-protocol.md), реалізація очікує)
- [x] Пакетна збірка Electron з `apps/packaged/` — завантаження для macOS (Apple Silicon) і Windows (x64) на [open-design.ai](https://open-design.ai/) та на [сторінці релізів GitHub](https://github.com/nexu-io/open-design/releases)
Поетапна поставка → [`docs/roadmap.md`](docs/roadmap.md).
## Статус
Це рання реалізація — замкнений цикл (виявити → вибрати навичку + систему дизайну → чат → розібрати `<artifact>` → прев'ю → зберегти) працює наскрізь. Стек промптів та бібліотека навичок — це те, де живе більшість цінності, і вони стабільні. UI на рівні компонентів постачається щодня.
## Поставте нам зірку
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="Поставте зірку Open Design на GitHub — github.com/nexu-io/open-design" width="100%" /></a>
</p>
Якщо це зекономило вам тридцять хвилин — поставте ★. Зірки не сплачують оренду, але вони кажуть наступному дизайнеру, агенту та контриб'ютору, що цей експеримент вартий їхньої уваги. Один клік, три секунди, реальний сигнал: [github.com/nexu-io/open-design](https://github.com/nexu-io/open-design).
## Внесок
Питання, PR, нові навички та нові системи дизайну — всі вітаються. Найбільш впливові внески зазвичай — це одна папка, один Markdown-файл або один PR-розмірний адаптер:
- **Додати навичку** — додайте папку у [`skills/`](skills/) за конвенцією [`SKILL.md`][skill].
- **Додати систему дизайну** — додайте `DESIGN.md` у [`design-systems/<brand>/`](design-systems/) за 9-секційною схемою.
- **Підключити новий CLI агент** — один запис у [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts).
Повний посібник, критерії злиття, стиль коду та що ми не приймаємо → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([Deutsch](CONTRIBUTING.de.md), [Français](CONTRIBUTING.fr.md), [简体中文](CONTRIBUTING.zh-CN.md)).
## Контриб'ютори
Дякуємо всім, хто допоміг просувати 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-06" 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) — це точка входу.
## Активність репозиторію
<picture>
<img alt="Open Design — метрики репозиторію" src="docs/assets/github-metrics.svg" />
</picture>
SVG вище перегенерується щодня [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) за допомогою [`lowlighter/metrics`](https://github.com/lowlighter/metrics). Зробіть ручне оновлення з вкладки **Actions**, якщо хочете швидше; для багатших плагінів (трафік, час відповіді) додайте секрет репозиторію `METRICS_TOKEN` з fine-grained PAT.
## Історія зірок
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
</picture>
</a>
Якщо крива вигинається вгору — це той сигнал, який ми шукаємо. ★ цей репо, щоб штовхнути її.
## Кредити
Сімейство навичок HTML PPT Studio — майстер-навичка [`skills/html-ppt/`](skills/html-ppt/) та обгортки для кожного шаблону під [`skills/html-ppt-*/`](skills/) (15 шаблонів повних колод, 36 тем, 31 односторінковий макет, 27 CSS-анімацій + 20 canvas FX, клавіатурний рантайм та режим презентації з магнітними картками) — інтегровані з проекту з відкритим кодом [`lewislulu/html-ppt-skill`](https://github.com/lewislulu/html-ppt-skill) (MIT). Вихідна ЛІЦЕНЗІЯ поставляється in-tree у [`skills/html-ppt/LICENSE`](skills/html-ppt/LICENSE), авторство належить [@lewislulu](https://github.com/lewislulu). Кожна картка прикладу для шаблону (`html-ppt-pitch-deck`, `html-ppt-tech-sharing`, `html-ppt-presenter-mode`, `html-ppt-xhs-post`, …) делегує авторські вказівки майстер-навичці, щоб поведінка промпт → вихід зберігалася наскрізь при натисканні **Використати цей промпт**.
Потік журнал / горизонтального гортання під [`skills/guizang-ppt/`](skills/guizang-ppt/) інтегрований з [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) (MIT). Авторство належить [@op7418](https://github.com/op7418).
## Ліцензія
Apache-2.0. Вбудована `skills/guizang-ppt/` зберігає свою оригінальну [ЛІЦЕНЗІЮ](skills/guizang-ppt/LICENSE) (MIT) та атрибуцію авторства [op7418](https://github.com/op7418). Вбудована `skills/html-ppt/` зберігає свою оригінальну [ЛІЦЕНЗІЮ](skills/html-ppt/LICENSE) (MIT) та атрибуцію авторства [lewislulu](https://github.com/lewislulu).

749
README.zh-CN.md Normal file
View File

@@ -0,0 +1,749 @@
# 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** 驱动。一个都没装?还有多 provider BYOK 代理 `/api/proxy/{anthropic,openai,azure,google}/stream` 兜底,同一条 loop少一次 spawn 而已。
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design 封面:与本地 AI 智能体共同设计" width="100%" />
</p>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="下载客户端" src="https://img.shields.io/badge/%E4%B8%8B%E8%BD%BD-%E5%AE%A2%E6%88%B7%E7%AB%AF-ff6b35?style=flat-square" /></a>
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#支持的-coding-agent"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#design-system"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#内置-skills"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-加入-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.zh-CN.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <b>简体中文</b> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a></p>
---
## 为什么要做这个
Anthropic 的 [Claude Design][cd]2026-04-17 发布,基于 Opus 4.7)让大家第一次看到:当一个 LLM 不再写废话、开始直接交付设计成品,会是什么样子。它瞬间出圈 —— 然后保持**闭源**、付费、只跑在云上、绑定 Anthropic 的模型和 Anthropic 的内部 skill。没有 checkout没有自托管没有 Vercel 部署,也换不了自己的 agent。
**Open DesignOD就是它的开源替代品。** 同一套 loop、同一种「artifact-first」心智模型但没有锁定。我们不做 agent —— 你笔记本上最强的 coding agent 已经装好了。我们要做的,是把它接进一个 skill 驱动的设计工作流:本地用 `pnpm tools-dev` 跑完整本地闭环,云端可单独部署 Web 层,每一层都 BYOK自带 Key
输入「帮我做一份杂志风的种子轮 pitch deck」。在模型挥洒第一个像素之前**初始化问题表单**已经先跳出来。Agent 从 5 套精挑的视觉方向里选一个。一张活的 `TodoWrite` 计划卡片实时流入 UI。Daemon 在磁盘上构建出一个真实的项目目录,里面有 seed 模板、布局库、自检 checklist。Agent **强制 pre-flight** 读取它们,对自己的输出跑一轮**五维评审**,几秒后吐出一个 `<artifact>`,渲染在沙盒 iframe 里。
这不是「AI 试图做点设计」。这是一个被提示词栈训练得像高级设计师一样工作的 AI —— 有可用的文件系统、有确定性的色板库、有 checklist 文化 —— 也就是 Claude Design 立下的那条线,只是这次它开源、归你。
OD 站在四个开源项目的肩膀上:
- [**`alchaincyf/huashu-design`**(花叔的画术)](https://github.com/alchaincyf/huashu-design) —— 设计哲学的指南针。Junior-Designer 工作流、5 步品牌资产协议、anti-AI-slop checklist、五维自评审、以及方向选择器背后的「5 流派 × 20 种设计哲学」思路 —— 全部蒸馏进 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)。
- [**`op7418/guizang-ppt-skill`**(歸藏的杂志风 PPT skill](https://github.com/op7418/guizang-ppt-skill) —— Deck 模式。原样捆绑在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留杂志版式、WebGL hero、P0/P1/P2 checklist。
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) —— UX 北极星,也是我们最接近的同类。第一个开源的 Claude-Design 替代品。我们借鉴了它的流式 artifact 循环、沙盒 iframe 预览模式(自带 React 18 + Babel、实时 agent 面板todos + tool calls + 可中断生成、5 种导出格式列表HTML / PDF / PPTX / ZIP / Markdown。我们刻意在形态上分流 —— 它是桌面 Electron 应用,把 [`pi-ai`][piai] 打包进去做 agent我们是 Web 应用 + 本地 daemon把 agent 运行时**委托**给你已经装好的 CLI。
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) —— Daemon 与运行时架构。PATH 扫描式 agent 检测,本地 daemon 作为唯一的特权进程agent-as-teammate 的世界观。
## 一眼概览
| | 你拿到的 |
|---|---|
| **Coding-agent CLI16 套)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI —— 在 `PATH` 上自动检测picker 一键切换 |
| **BYOK 兜底** | 协议分流代理 `/api/proxy/{anthropic,openai,azure,google}/stream` —— 填 `baseUrl` + `apiKey` + `model`,选择 Anthropic / OpenAI / Azure OpenAI / Google Geminidaemon 会把各家 SSE 统一成同一条 chat stream。daemon 边界拒绝 loopback / link-local / RFC1918 防 SSRF。 |
| **内置 design system** | **72 套** —— 2 套手写起手 + 70 套从 [`awesome-design-md`][acd2] 导入的产品系统Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma、小红书… |
| **内置 skill** | **31 个** —— 27 个 `prototype` 模式web-prototype、saas-landing、dashboard、mobile-app、gamified-app、social-carousel、magazine-poster、dating-web、sprite-animation、motion-frames、critique、tweaks、wireframe-sketch、pm-spec、eng-runbook、finance-report、hr-onboarding、invoice、kanban-board、team-okrs…+ 4 个 `deck` 模式(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`。Picker 按 `scenario` 分组design / marketing / operation / engineering / product / finance / hr / sale / personal。 |
| **媒体生成** | 图像 · 视频 · 音频三类 surface 与设计循环并行可用。**gpt-image-2**Azure / OpenAI做海报、头像、信息图、城市插画地图 · **Seedance 2.0**(字节跳动)做 15 秒电影感 t2v + i2v · **HyperFrames**[heygen-com/hyperframes](https://github.com/heygen-com/hyperframes))做 HTML→MP4 动态图形产品揭示、动力学排版、数据图表、社媒卡片、Logo 收尾)。**93 条**可一键复刻的 prompt gallery —— 43 条 gpt-image-2 + 39 条 Seedance + 11 条 HyperFrames统一放在 [`prompt-templates/`](prompt-templates/) 下附预览图与来源署名。Chat 入口和写代码同一处;输出真实的 `.mp4` / `.png` 落到项目工作区里。 |
| **视觉方向** | 5 套精选流派Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental每套自带 OKLch 色板 + 字体栈([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts) |
| **设备外壳** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome —— 像素级精确,跨 skill 共享,统一在 [`assets/frames/`](assets/frames/) |
| **Agent 运行时** | 本地 daemon 在你的项目目录里 spawn CLI —— agent 拥有真实的 `Read` / `Write` / `Bash` / `WebFetch`,作用在真实磁盘上;每个 adapter 都有 Windows `ENAMETOOLONG` 兜底stdin / 临时 prompt 文件) |
| **导入** | 把 [Claude Design][cd] 导出的 ZIP 直接拖到欢迎弹窗 —— `POST /api/import/claude-design` 解压成真实项目agent 接着 Anthropic 停下的地方继续编辑,不用再向模型重述上下文 |
| **持久化** | SQLite 在 `.od/app.sqlite`projects · conversations · messages · tabs · 用户 templates。明天再开todo 卡片和打开的文件都还在原位。 |
| **生命周期** | 唯一入口 `pnpm tools-dev`start / stop / run / status / logs / inspect / check—— 用类型化 sidecar stamp 启动 daemon + web+ desktop |
| **桌面端** | 可选 Electron 壳:渲染器 sandbox + sidecar IPCSTATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN—— 同一通道驱动 `tools-dev inspect desktop screenshot`,跑 E2E |
| **部署目标** | 本地 `pnpm tools-dev` · Vercel Web 层 · 打包好的 Electron 桌面端,支持 macOSApple Silicon和 Windowsx64—— 从 [open-design.ai](https://open-design.ai/) 或 [最新 release](https://github.com/nexu-io/open-design/releases) 直接下载 |
| **License** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
## 效果展示
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · 入口页" /><br/>
<sub><b>入口页</b> —— 选 skill、选 design system、写一行需求。同一个表面服务原型、deck、移动端、dashboard、editorial 页面所有 mode。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · 初始化问题表单" /><br/>
<sub><b>初始化问题表单</b> —— 模型动笔之前OD 先把需求锁住surface、受众、调性、品牌上下文、规模。30 秒勾选项秒杀 30 分钟来回返工。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · 方向选择器" /><br/>
<sub><b>方向选择器</b> —— 用户没有品牌上下文时agent 自动跳第二个表单5 套精选方向Monocle / Modern Minimal / Tech Utility / Brutalist / Soft Warm一个 radio 选完,色板 + 字体栈直接锁定,没有 freestyle 空间。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · 实时 todo 进度" /><br/>
<sub><b>实时 todo 进度</b> —— Agent 的计划以活卡片形式流入 UI。<code>in_progress</code> → <code>completed</code> 实时切换。用户能在中途以极低成本介入纠偏。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · 沙盒预览" /><br/>
<sub><b>沙盒预览</b> —— 每个 <code>&lt;artifact&gt;</code> 都在干净的 srcdoc iframe 里渲染。可在文件工作区里就地编辑;可下载为 HTML / PDF / ZIP。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · 72 套 design system 库" /><br/>
<sub><b>72 套 design system 库</b> —— 每套产品系统都展示 4 色色卡。点进去看完整的 <code>DESIGN.md</code>、色板网格、live showcase。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · 杂志风 deck" /><br/>
<sub><b>Deck 模式guizang-ppt</b> —— 内置的 <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> 原样接入。杂志版式、WebGL hero 背景、单文件 HTML 输出、可导 PDF。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · 移动端原型" /><br/>
<sub><b>移动端原型</b> —— 像素级精确的 iPhone 15 Pro chrome灵动岛、状态栏 SVG、Home Indicator。多屏原型直接复用 <code>/frames/</code> 共享资源agent 永远不需要重新画一遍手机。</sub>
</td>
</tr>
</table>
## 内置 Skills
**31 个 skill每个一个文件夹**,都遵循 Claude Code 的 [`SKILL.md`][skill] 规范,并叠加 OD 的 `od:` frontmatterdaemon 原样解析 —— `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))。
两种顶层 **mode** 撑起整个目录:**`prototype`**27 个 —— 任何能被渲染成单页 artifact 的产物,从杂志风 landing 到手机屏到 PM 规范文档都算)和 **`deck`**4 个 —— 横滑式演示,自带 deck framework 框架)。**`scenario`** 是 picker 用来分组的字段:`design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`
### 示例展示Showcase examples
视觉表现最强、最适合上手第一跑的几条 skill。每条都附带可直接打开的 `example.html` —— 不用登录、不用配置,先看产出再下单。
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>消费级约会 / 婚恋仪表盘 —— 左侧栏、社区动态 ticker、头部 KPI、30 天双向匹配柱状图editorial 字体,克制点缀色。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>两页数字 e-guide —— 封面标题、作者、TOC 预告)+ 内文跨页pull-quote + 步骤列表),创作者 / 生活方式风。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>品牌新品发布邮件 —— 顶部 wordmark、hero 图、标题锁排、主 CTA、规格网格。居中单列 + 表格降级,邮件客户端安全。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>三屏游戏化移动 app 原型,黑色舞台 —— 封面 / 今日任务XP 缎带 + 等级条)/ 任务详情。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>三屏移动端引导流 —— splash、价值主张、登录。状态栏、滑动点、主 CTA。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>单帧 motion 设计 heroCSS 循环动画 —— 旋转字环、地球、计时器。可直接交给 HyperFrames 等关键帧导出。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>1080×1080 三连社媒轮播图 —— 三张电影感面板标题前后呼应品牌标识、loop 标记。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>像素 / 8-bit 动画解释器单帧 —— 米白通屏、像素吉祥物、动感日文标题、循环 CSS keyframes可直接录屏成竖版视频。</sub>
</td>
</tr>
</table>
### 设计与营销类prototype 模式)
| Skill | 平台 | 场景 | 产出 |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | 桌面 | design | 单页 HTML —— landing、营销、heroprototype 默认) |
| [`saas-landing`](skills/saas-landing/) | 桌面 | marketing | hero / features / pricing / CTA 营销版式 |
| [`dashboard`](skills/dashboard/) | 桌面 | operation | 带侧栏 + 数据密集型的后台 |
| [`pricing-page`](skills/pricing-page/) | 桌面 | sale | 独立定价页 + 对比表 |
| [`docs-page`](skills/docs-page/) | 桌面 | engineering | 三栏文档版式 |
| [`blog-post`](skills/blog-post/) | 桌面 | marketing | 长文 editorial |
| [`mobile-app`](skills/mobile-app/) | 移动 | design | 带 iPhone 15 Pro / Pixel 外壳的 app 屏 |
| [`mobile-onboarding`](skills/mobile-onboarding/) | 移动 | design | 多屏移动端引导流splash · 价值主张 · 登录) |
| [`gamified-app`](skills/gamified-app/) | 移动 | personal | 三屏游戏化 app 原型 |
| [`email-marketing`](skills/email-marketing/) | 桌面 | marketing | 品牌新品发布邮件(表格降级邮件客户端安全) |
| [`social-carousel`](skills/social-carousel/) | 桌面 | marketing | 1080×1080 三连社媒轮播 |
| [`magazine-poster`](skills/magazine-poster/) | 桌面 | marketing | 单页杂志风海报 |
| [`motion-frames`](skills/motion-frames/) | 桌面 | marketing | CSS 循环动画的 motion hero |
| [`sprite-animation`](skills/sprite-animation/) | 桌面 | marketing | 像素 / 8-bit 动画解释器 |
| [`dating-web`](skills/dating-web/) | 桌面 | personal | 消费级约会 / 婚恋仪表盘 |
| [`digital-eguide`](skills/digital-eguide/) | 桌面 | marketing | 两页数字 e-guide封面 + 内文跨页) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | 桌面 | design | 手绘风线框稿 —— 服务于 "先把灰块拼出来给用户看" 的早期回合 |
| [`critique`](skills/critique/) | 桌面 | design | 五维自评分卡Philosophy · Hierarchy · Detail · Function · Innovation |
| [`tweaks`](skills/tweaks/) | 桌面 | design | AI 自吐 tweaks 面板 —— 模型自己抛出值得调的参数 |
### Deck 类deck 模式)
| Skill | 默认 | 产出 |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **deck 默认** | 杂志风网页 PPT —— 来自 [op7418/guizang-ppt-skill][guizang],原 LICENSE 保留 |
| [`simple-deck`](skills/simple-deck/) | — | 极简横滑 deck |
| [`replit-deck`](skills/replit-deck/) | — | 产品演示 deckReplit 风) |
| [`weekly-update`](skills/weekly-update/) | — | 团队周报横滑 deck进度 · 阻塞 · 下一步) |
### 文档与办公产物类prototype 模式 + 文档场景)
| Skill | Scenario | 产出 |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | PM 规范文档 + 目录 + 决策日志 |
| [`team-okrs`](skills/team-okrs/) | product | OKR 计分表 |
| [`meeting-notes`](skills/meeting-notes/) | operation | 会议决策纪要 |
| [`kanban-board`](skills/kanban-board/) | operation | 看板快照 |
| [`eng-runbook`](skills/eng-runbook/) | engineering | 故障 runbook |
| [`finance-report`](skills/finance-report/) | finance | 高管财务摘要 |
| [`invoice`](skills/invoice/) | finance | 单页发票 |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | 岗位入职计划 |
新增一个 skill 就是新增一个文件夹。读 [`docs/skills-protocol.md`](docs/skills-protocol.md) 了解扩展 frontmatterfork 一个现有 skill重启 daemon 即生效。目录拉取走 `GET /api/skills`;单个 skill 的种子拼装template + 边角文件 references`GET /api/skills/:id/example`
## 六个底层设计
### 1 · 我们不带 agent你的就够好
Daemon 启动时扫 `PATH`,找 [`claude`](https://docs.anthropic.com/en/docs/claude-code)、[`codex`](https://github.com/openai/codex)、[`cursor-agent`](https://www.cursor.com/cli)、[`gemini`](https://github.com/google-gemini/gemini-cli)、[`opencode`](https://opencode.ai/)、[`qwen`](https://github.com/QwenLM/qwen-code)、`qodercli`、[`copilot`](https://github.com/features/copilot/cli)、`hermes``kimi` 和 [`pi`](https://github.com/mariozechner/pi-ai)。能找到的都成为候选设计引擎 —— 走 stdio每个 CLI 一个 adaptermodel picker 一键切换。灵感来自 [`multica`](https://github.com/multica-ai/multica) 和 [`cc-switch`](https://github.com/farion1231/cc-switch)。一个 CLI 都没装API mode 就是同一条管线减去 spawn —— 选择 Anthropic、OpenAI 兼容、Azure OpenAI 或 Google Geminidaemon 把归一化后的 SSE 转发回浏览器loopback / link-local / RFC1918 在边界直接拒绝。
### 2 · Skill 是文件,不是插件
遵循 Claude Code [`SKILL.md` 规范](https://docs.anthropic.com/en/docs/claude-code/skills),每个 skill = `SKILL.md` + `assets/` + `references/`。把一个文件夹丢进 [`skills/`](skills/),重启 daemonpicker 里就能看到。内置的 `magazine-web-ppt` 就是 [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) **原样**捆绑 —— 原 LICENSE 保留、原作者归属保留。
### 3 · Design System 是可移植的 Markdown不是 theme JSON
[`VoltAgent/awesome-design-md`][acd2] 的 9 段式 `DESIGN.md` —— color、typography、spacing、layout、components、motion、voice、brand、anti-patterns。每个 artifact 都从激活的 system 里读 token。切换 system → 下一次渲染就用新的 token。下拉框里现成的有**Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Apple、Anthropic、Cursor、Supabase、Figma、Resend、Raycast、Lovable、Cohere、Mistral、ElevenLabs、X.AI、Spotify、Webflow、Sanity、PostHog、Sentry、MongoDB、ClickHouse、Cal、Replicate、Clay、Composio、小红书…** 共 72 套。
### 4 · 初始化问题表单干掉 80% 的来回返工
OD 的提示词栈把 `RULE 1` 写死了:每个新设计任务都从 `<question-form id="discovery">` 开始,**不是代码**。Surface · 受众 · 调性 · 品牌上下文 · 规模 · 约束。一段写得很长的需求里仍然有大量留白:视觉调性、色彩立场、规模 —— 而表单恰恰把这些用 30 秒勾选项锁死。错方向的代价是一轮对话,不是一份做完的 deck。
这就是从 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 蒸馏出来的 **Junior-Designer 模式**:开工前一次性批量问完,尽早 show 出一些可见的东西(哪怕只是灰色方块的 wireframe让用户用最低成本介入纠偏。再叠加品牌资产协议定位 · 下载 · `grep` hex · 写 `brand-spec.md` · 复述这是输出从「AI freestyle」跳到「先看资料再画图的设计师」最关键的一步。
### 5 · Daemon 让 agent 感觉自己就在你笔记本上 —— 因为它就是
Daemon `spawn` CLI 时,`cwd` 设到该项目在 `.od/projects/<id>/` 下的 artifact 文件夹。Agent 拿到的 `Read` / `Write` / `Bash` / `WebFetch` 都是真工具,作用在真文件系统上。它能 `Read` skill 的 `assets/template.html`,能 `grep` 你的 CSS 拿 hex能写一份 `brand-spec.md`,能落地生成的图片,能产出 `.pptx` / `.zip` / `.pdf` —— 这些文件在 turn 结束的时候作为下载 chip 出现在文件工作区里。Session、对话、消息、tab 都持久化在本地 SQLite 里 —— 明天再打开这个项目agent 的 todo 卡片还在你昨天停下的地方。
### 6 · 提示词栈本身就是产品
发送时拼装的不是「system + user」。它是
```
DISCOVERY 指令 turn-1 表单、turn-2 品牌分支、TodoWrite、五维评审
+ 身份与工作流宪章 OFFICIAL_DESIGNER_PROMPT、anti-AI-slop、Junior Designer 模式)
+ 激活的 DESIGN.md 72 套备选)
+ 激活的 SKILL.md 31 套备选)
+ 项目元数据 kind、fidelity、speakerNotes、animations、灵感 system id
+ Skill 副文件 (自动注入 pre-flight先读 assets/template.html + references/*.md
+ deck kind 且无 skill 种子时) DECK_FRAMEWORK_DIRECTIVE nav / counter / scroll / print
```
每一层都可组合。每一层都是一个你能改的文件。看 [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) 和 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 就知道真实契约长什么样。
## 技术架构
```
┌─────────────── 浏览器Next.js 16─────────────────────────────┐
│ chat · 文件工作区 · iframe 预览 · 设置 · 导入 │
└──────────────┬─────────────────────────────────┬───────────────┘
│ /api/*dev 走 rewrites
▼ ▼
┌─────────────────────────────────┐ /api/proxy/{provider}/stream (SSE)
│ 本地 daemonExpress + SQLite│ ─→ 任意 OpenAI 兼容
│ │ 端点BYOK
│ /api/agents /api/skills│ 含 SSRF 防御
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/{provider}/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (静态) /frames (静态)
│ 可选 sidecar IPC/tmp/open-design/ipc/<ns>/<app>.sock
STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN
└─────────┬───────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · gemini · opencode · cursor-agent · qwen │
│ qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) │
│ 读 SKILL.md + DESIGN.md把 artifact 写到磁盘 │
└──────────────────────────────────────────────────────────────────┘
```
| 层 | 技术栈 |
|---|---|
| 前端 | Next.js 16 App Router + React 18 + TypeScript可部署到 Vercel |
| Daemon | Node 24 · Express · SSE 流 · `better-sqlite3`;表:`projects` · `conversations` · `messages` · `tabs` · `templates` |
| Agent 传输层 | `child_process.spawn`Claude Code 走 `claude-stream-json`、Qoder CLI 走 `qoder-stream-json`、Copilot 走 `copilot-stream-json`、Codex / Gemini / OpenCode / Cursor Agent 走 `json-event-stream`(每个 CLI 一个 parser、Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe 走 `acp-json-rpc`Agent Client Protocol、Pi 走 `pi-rpc`stdio JSON-RPC、Qwen Code / DeepSeek TUI 走 `plain` |
| BYOK 代理 | `POST /api/proxy/{anthropic,openai,azure,google}/stream` → 各 provider 上游 API统一输出 `delta/end/error` SSEdaemon 边界拒绝 loopback / link-local / RFC1918 |
| 存储 | 纯文件 `.od/projects/<id>/` + SQLite `.od/app.sqlite`(已 gitignoredaemon 启动自建)。`OD_DATA_DIR` 可改根目录用于测试隔离 |
| 预览 | 沙盒 iframe`srcdoc`+ 每个 skill 的 `<artifact>` parser[`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts) |
| 导出 | HTML内联资源· PDF浏览器打印deck-aware· PPTXagent 驱动经由 skill· ZIParchiver· Markdown |
| 生命周期 | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`;端口走 `--daemon-port` / `--web-port`,命名空间走 `--namespace` |
| 桌面端(可选) | Electron 壳 —— 通过 sidecar IPC 拿 web URL不猜端口同一通道`STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN`)驱动 `tools-dev inspect desktop …` 跑 E2E |
## Quickstart
### 下载桌面端(无需构建)
试用 Open Design 最快的方式是直接下载预编译的桌面端 —— 不用装 Node、不用 pnpm、不用 clone
- **[open-design.ai](https://open-design.ai/)** —— 官方下载页
- **[GitHub releases](https://github.com/nexu-io/open-design/releases)**
### 从源码运行
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # 应输出 10.33.2
pnpm install
pnpm tools-dev run web
# 打开 tools-dev 输出的 web URL
```
Windows 启动器:请按照 `tools/launcher/README.md` 中的说明自行构建 `OpenDesign.exe`,或从 GitHub Releases 下载。然后将它放到仓库根目录并双击;它会在需要时运行 `pnpm install`,再用 `pnpm tools-dev` 启动 Open Design。
环境要求Node `~24`pnpm `10.33.x``nvm` / `fnm` 只是可选辅助工具,不是项目必需步骤;如果使用它们,先执行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`,再运行 `pnpm install`
桌面端/后台启动、固定端口重启,以及 media 生成派发器检查(`OD_BIN``OD_DAEMON_URL``apps/daemon/dist/cli.js`)见 [`QUICKSTART.zh-CN.md`](QUICKSTART.zh-CN.md)。
第一次加载会:
1. 检测你 `PATH` 上有哪些 agent CLI自动选一个。
2. 加载 31 个 skill + 72 套 design system。
3. 弹欢迎对话框,让你贴 Anthropic key仅 BYOK 兜底路径需要)。
4. **自动创建 `./.od/`** —— 本地运行时目录,存放 SQLite 项目库、各项目工作区、保存下来的 artifact。**没有** `od init` 这一步daemon 启动时会自己 `mkdir`
输入需求,回车,看 question form 跳出来,填,看 todo 卡片流动,看 artifact 渲染。点 **Save to disk** 或导出整个项目 ZIP。
### 第一次跑起来(`./.od/` 解释)
Daemon 在仓库根下维护一个隐藏目录,里面所有内容都已 gitignore纯本机数据**不要** commit。
```
.od/
├── app.sqlite ← 项目 · 对话 · 消息 · 打开的 tab
├── artifacts/ ← Save to disk 一次性渲染(带时间戳)
└── projects/<id>/ ← 每个项目的工作目录,也是 agent 的 cwd
```
| 想做什么 | 怎么做 |
|---|---|
| 看一眼里面有啥 | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| 完全清空,从零再来 | `pnpm tools-dev stop`,再 `rm -rf .od`,然后重新 `pnpm tools-dev run web` |
| 换到别的位置 | 暂不支持 —— 路径是相对仓库根写死的 |
完整文件地图、脚本、排错 → [`QUICKSTART.zh-CN.md`](QUICKSTART.zh-CN.md)。
## 仓库结构
```
open-design/
├── README.md ← 英文
├── README.de.md ← Deutsch
├── README.zh-CN.md ← 本文件
├── QUICKSTART.md ← 跑 / 构建 / 部署
├── package.json ← 单 bin: od
├── apps/
│ ├── daemon/ ← Node + Express唯一的服务端
│ │ ├── src/ ← TypeScript daemon 源码
│ │ │ ├── cli.ts ← `od` bin 源码,编译到 dist/cli.js
│ │ │ ├── server.ts ← /api/* 路由projects、chat、files、exports
│ │ │ ├── agents.ts ← PATH 扫描器 + 各 CLI 的 argv 拼装
│ │ │ ├── claude-stream.ts ← Claude Code stdout 流式 JSON 解析
│ │ │ ├── skills.ts ← SKILL.md frontmatter 加载器
│ │ │ └── db.ts ← SQLite schemaprojects/messages/templates/tabs
│ │ ├── sidecar/ ← tools-dev daemon sidecar wrapper
│ │ └── tests/ ← daemon 包测试
│ │
│ └── web/ ← Next.js 16 App Router + React 客户端
│ ├── app/ ← App Router 入口
│ ├── next.config.ts ← dev rewrites + 生产 out/ 静态导出
│ └── src/ ← React + TS 客户端模块
│ ├── App.tsx ← 路由、bootstrap、设置
│ ├── components/ ← chat、composer、picker、preview、sketch…
│ ├── prompts/ ← system、discovery、directions、deck framework
│ ├── artifacts/ ← streaming <artifact> parser + manifest
│ ├── runtime/ ← iframe srcdoc、markdown、导出辅助
│ ├── providers/ ← daemon SSE + BYOK API 传输
│ └── state/ ← localStorage + daemon-backed 项目状态
├── e2e/ ← Playwright UI + 外部集成/Vitest harness
├── packages/
│ ├── contracts/ ← web/daemon 共享 app contracts
│ ├── sidecar-proto/ ← Open Design sidecar protocol contract
│ ├── sidecar/ ← 通用 sidecar runtime primitives
│ └── platform/ ← 通用 process/platform primitives
├── skills/ ← 31 个 SKILL.md skill 包27 prototype + 4 deck
│ ├── web-prototype/ ← prototype 默认
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← deck 模式
│ └── guizang-ppt/ ← 内置 magazine-web-pptdeck 默认)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 套 DESIGN.md
│ ├── default/ ← Neutral Modern起手
│ ├── warm-editorial/ ← Warm Editorial起手
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md
├── assets/
│ └── frames/ ← 跨 skill 共享设备外壳
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ └── deck-framework.html ← deck 基线nav / counter / print
├── scripts/
│ └── sync-design-systems.ts ← 从上游 awesome-design-md tarball 重新导入
├── docs/
│ ├── spec.md ← 产品定义、场景、差异化
│ ├── architecture.md ← 拓扑、数据流、组件
│ ├── skills-protocol.md ← 扩展 SKILL.md 的 od: frontmatter
│ ├── agent-adapters.md ← 各 CLI 检测 + 派发
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← 详尽的引用与师承
│ ├── roadmap.md ← 分阶段交付
│ ├── schemas/ ← JSON schema
│ └── examples/ ← 标准 artifact 样例
└── .od/ ← 运行时数据,已 gitignoredaemon 启动自建
├── app.sqlite ← 项目 / 对话 / 消息 / tab
├── projects/<id>/ ← 每个项目的工作目录agent 的 cwd
└── artifacts/ ← 单次保存的 artifact
```
## Design System
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="72 套 Design Systems 库 — 编辑版式双页" width="100%" />
</p>
72 套开箱即用,每套一个 [`DESIGN.md`](design-systems/README.md)
<details>
<summary><b>完整目录</b>(点击展开)</summary>
**AI & LLM** —— `claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**开发者工具** —— `cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**生产力** —— `notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**金融科技** —— `stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**电商 / 出行** —— `shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**媒体** —— `spotify` · `playstation` · `wired` · `theverge` · `meta`
**汽车** —— `tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**其他** —— `apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**起手** —— `default`Neutral Modern· `warm-editorial`
</details>
整个库通过 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 从 [`VoltAgent/awesome-design-md`][acd2] 导入。重新执行即可刷新。
## 视觉方向
当用户没有品牌资产时agent 会跳第二个表单5 套精选方向 —— 这是 [`huashu-design` 的「设计方向顾问 · 5 流派 × 20 种设计哲学」 fallback](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback) 在 OD 里的落地。每一套都是确定性 spec —— OKLch 色板、字体栈、版式姿态、参考列表 —— agent 直接把它**原样**绑进 seed 模板的 `:root`。一个 radio 选完,整套视觉系统全部锁定。零 freestyle零 AI slop。
| 方向 | 调性 | 参考 |
|---|---|---|
| Editorial — Monocle / FT | 印刷杂志,墨水 + 米色纸 + 暖红强调 | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | 冷调、结构化、克制强调 | Linear · Vercel · Stripe |
| Tech utility | 信息密度、等宽、终端感 | Bloomberg · Bauhaus 工具 |
| Brutalist | 粗粝、巨字、无阴影、刺眼强调 | Bloomberg Businessweek · Achtung |
| Soft warm | 大方、低对比、桃色中性 | Notion 营销页 · Apple Health |
完整 spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。
## 媒体生成
OD 不止于代码。同一套生成 `<artifact>` HTML 的 chat 入口,也驱动**图像**、**视频**、**音频**生成 —— 模型 adapter 已经接进 daemon 的 media pipeline[`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts)、[`apps/web/src/media/models.ts`](apps/web/src/media/models.ts))。每一次渲染都是真实落盘的文件,`.png``.mp4` 在 turn 结束时直接以下载 chip 的形式出现在工作区里。
目前主力是三个模型族:
| Surface | 模型 | 提供方 | 用来做什么 |
|---|---|---|---|
| **图像** | `gpt-image-2` | Azure / OpenAI | 海报、头像、城市插画地图、信息图、杂志风社媒卡、老照片修复、产品爆炸图 |
| **视频** | `seedance-2.0` | 字节跳动 Volcengine | 15s 电影感 t2v + i2v + 音频 —— 叙事短片、人物特写、产品片、MV 编排 |
| **视频** | `hyperframes-html` | [HeyGen 开源](https://github.com/heygen-com/hyperframes) | HTML→MP4 动态图形 —— 产品揭示、动力学排版、数据图表、社媒覆盖层、Logo 收尾、TikTok 竖屏配卡拉 OK 字幕 |
不断生长的 **prompt gallery** 在 [`prompt-templates/`](prompt-templates/) —— 共 **93 条可一键复刻 prompt**43 条图像(`prompt-templates/image/*.json`、39 条 Seedance`prompt-templates/video/*.json`,不含 `hyperframes-*`、11 条 HyperFrames`prompt-templates/video/hyperframes-*.json`)。每一条都带预览缩略图、原文 prompt、目标模型、画幅比以及一个用来注明许可与作者的 `source` 区块。daemon 在 `GET /api/prompt-templates` 暴露它们Web 入口的 **Image templates** / **Video templates** 两个 tab 把它们渲染成卡片网格,一键就把 prompt 拍进 composer并自动选好对应模型。
### gpt-image-2 —— 图像样例(共 43 条,下面 5 张)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="3D Stone Staircase Evolution" /><br/><sub><b>3D Stone Staircase Evolution Infographic</b><br/>三段式石材风信息图</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Illustrated City Food Map" /><br/><sub><b>Illustrated City Food Map</b><br/>编辑级手绘旅行海报</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cinematic Elevator Scene" /><br/><sub><b>Cinematic Elevator Scene</b><br/>电梯场景的单帧时尚静帧</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Cyberpunk Anime Portrait" /><br/><sub><b>Cyberpunk Anime Portrait</b><br/>头像 —— 霓虹脸字</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Glamorous Woman in Black" /><br/><sub><b>Glamorous Woman in Black Portrait</b><br/>编辑级影棚肖像</sub></td>
</tr>
</table>
完整列表 → [`prompt-templates/image/`](prompt-templates/image/)。来源:多数取自 [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts)CC-BY-4.0),逐条保留作者署名。
### Seedance 2.0 —— 视频样例(共 39 条,下面 5 段)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Music Podcast Guitar" /></a><br/><sub><b>Music Podcast & Guitar Technique</b><br/>4K 电影感录音棚片段</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Emotional Face" /></a><br/><sub><b>Emotional Face Close-up</b><br/>电影感微表情研究</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Luxury Supercar" /></a><br/><sub><b>Luxury Supercar Cinematic</b><br/>叙事化产品片</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Forbidden City Cat" /></a><br/><sub><b>Forbidden City Cat Satire</b><br/>风格化讽刺短片</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Japanese Romance" /></a><br/><sub><b>Japanese Romance Short Film</b><br/>15s Seedance 2.0 叙事短片</sub></td>
</tr>
</table>
点任意缩略图即可播放真实渲染出的 MP4。完整列表 → [`prompt-templates/video/`](prompt-templates/video/)`*-seedance-*` 与带 Cinematic 标签的条目)。来源:[`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts)CC-BY-4.0),保留原推链接和作者 handle。
### HyperFrames —— HTML→MP4 动态图形11 条可一键复刻模板)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) 是 HeyGen 开源的 agent-native 视频框架 —— 你(或者 agent写 HTML + CSS + GSAPHyperFrames 通过 headless Chrome + FFmpeg 确定性地渲成 MP4。Open Design 把 HyperFrames 作为一等视频模型(`hyperframes-html`)接到 daemon dispatch同时打包了 `skills/hyperframes/` 这个 skill把 timeline 合约、scene transition 规则、audio-reactive 模式、字幕/TTS、目录块`npx hyperframes add <slug>`)一并教给 agent。
11 条 HyperFrames prompt 放在 [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/),每一条都是产生具体某个原型的明确 brief
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Product reveal" /></a><br/><sub><b>5s 极简产品揭示</b> · 16:9 · 推近标题卡 + shader 转场</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="SaaS promo" /></a><br/><sub><b>30s SaaS 产品片</b> · 16:9 · Linear/ClickUp 风带 UI 3D 揭示</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaoke" /></a><br/><sub><b>TikTok 卡拉 OK 口播</b> · 9:16 · TTS + 单词对齐字幕</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Brand sizzle" /></a><br/><sub><b>30s 品牌 sizzle</b> · 16:9 · 节拍同步动力学排版、audio-reactive</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Data chart" /></a><br/><sub><b>动画 bar-chart race</b> · 16:9 · NYT 风数据信息图</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Flight map" /></a><br/><sub><b>航线地图(起 → 终)</b> · 16:9 · Apple 风电影感路径揭示</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>4s 电影感 Logo 收尾</b> · 16:9 · 逐部件拼合 + 光晕</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Money counter" /></a><br/><sub><b>$0 → $10K 数字飙升</b> · 9:16 · Apple 风高燃绿光闪 + 钞票飞溅</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>3 手机 app 展示</b> · 16:9 · 悬浮三屏 + 功能旁注</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Social overlay" /></a><br/><sub><b>社媒卡叠加</b> · 9:16 · X · Reddit · Spotify · Instagram 依次入画</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website to video" /></a><br/><sub><b>网站到视频管线</b> · 16:9 · 抓取 3 种视口 + 转场串联</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
套路和其它一样:选模板、改 brief、发送。Agent 读取自带的 `skills/hyperframes/SKILL.md`(里面带 OD 专用的渲染流程 —— composition 源文件落到 `.hyperframes-cache/`避免污染文件工作区daemon 替你触发 `npx hyperframes render`,绕开 macOS sandbox-exec / Puppeteer 卡死;最终只有 `.mp4` 作为项目 chip 出现),写完 composition、产出 MP4。目录块缩略图版权归 HeyGen从他们的 CDN 回源OSS 框架本身是 Apache-2.0。
> **已经接好但还没出 prompt 模板的:** Kling 2.0 / 1.6 / 1.5、Veo 3 / Veo 2、Sora 2 / Sora 2-Provia Fal、MiniMax video-01 —— 都在 `VIDEO_MODELS`[`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)里。Suno v5 / v4.5、Udio v2、Lyria 2音乐和 gpt-4o-mini-tts、MiniMax TTS语音覆盖音频侧。补全这些模型的 prompt 模板属于开放贡献 —— 把 JSON 放进 `prompt-templates/video/` 或 `prompt-templates/audio/`picker 里就能直接看到。
## 聊天循环之外,还交付了什么
Chat / artifact 循环最显眼,但这套仓库里还有几个能力被埋得有点深,对照其它产品做选型之前值得先扫一遍:
- **Claude Design ZIP 导入。** 把 claude.ai 导出的 ZIP 拖到欢迎弹窗,`POST /api/import/claude-design` 把它解压成真实 `.od/projects/<id>/`,把入口文件作为 tab 打开,并预置一句「接着 Anthropic 停下的地方继续编辑」给本地 agent。不用再让模型重述上下文也不用「让模型重新画一遍」。([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
- **多 provider BYOK 代理。** `POST /api/proxy/{anthropic,openai,azure,google}/stream` 接收 `{ baseUrl, apiKey, model, messages }`,构造各 provider 的上游请求,把 SSE chunk 统一成 `delta/end/error`,同时拒绝 loopback / link-local / RFC1918 防 SSRF。OpenAI 兼容路径覆盖 OpenAI、Azure AI Foundry `/openai/v1`、DeepSeek、Groq、MiMo、OpenRouter、自托管 vLLMAzure OpenAI 路径补上 deployment URL + `api-version`Google 路径走 Gemini `:streamGenerateContent`
- **用户自存 templates。** 喜欢某次渲染?`POST /api/templates` 把 HTML + 元数据快照进 SQLite `templates` 表。下个项目的 picker 里多一行「你的模板」 —— 跟内置 31 套同一个挑选面,但是你的。
- **Tab 持久化。** 每个项目记得自己打开的文件和当前 tab存在 `tabs` 表里。明天再打开,工作区还是你昨天离开时的样子。
- **Artifact lint API。** `POST /api/artifacts/lint` 对生成的 artifact 跑结构性检查(`<artifact>` 框架是否破损、必需的副文件是否缺失、palette token 是否过期),返回 agent 下一回合可以读回去的 findings。五维自评审就是用它把分数落到证据上而不是 vibe。
- **Sidecar 协议 + 桌面端自动化。** Daemon、web、desktop 进程都带类型化的 5 字段 stamp`app · mode · namespace · ipc · source`),并把 JSON-RPC IPC 通道暴露在 `/tmp/open-design/ipc/<namespace>/<app>.sock``tools-dev inspect desktop status \| eval \| screenshot` 就跑在这条通道上,所以 headless E2E 直接打到真实 Electron 壳,不用造定制夹具([`packages/sidecar-proto/`](packages/sidecar-proto/)、[`apps/desktop/src/main/`](apps/desktop/src/main/))。
- **Windows 友好的 spawn。** 任何在长 prompt 上会撞 `CreateProcess` 32 KB argv 上限的 adapterCodex、Gemini、OpenCode、Cursor Agent、Qwen、Qoder CLI、Pi都改走 stdin。Claude Code 和 Copilot 保留 `-p`;连 stdin 都装不下时 daemon 退回临时 prompt 文件。
- **按 namespace 隔离的 runtime data。** `OD_DATA_DIR``--namespace` 给你完全隔离的 `.od/`-style 目录树Playwright、beta channel、你正经的项目永远不会共用同一个 SQLite 文件。
## 反 AI Slop 机制
下面整套机制都是 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 的 playbook被移植进 OD 的提示词栈,并通过 skill 副文件 pre-flight 让每个 skill 都能落地执行。看 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 是真实文案:
- **先表单。** Turn 1 必须是 `<question-form>`**不准** thinking、不准 tools、不准旁白。用户用 radio 速度选默认。
- **品牌资产协议。** 用户贴截图或 URL 时agent 走 5 步流程(定位 · 下载 · grep hex · 写 `brand-spec.md` · 复述)才能开始写 CSS。**绝不从记忆里猜品牌色**。
- **五维评审。** 在吐 `<artifact>` 之前agent 默默给自己 15 分打分,五个维度:哲学 / 层级 / 执行 / 具体度 / 克制。任一维 < 3/5 视为退步 —— 修完再评。两轮是常态。
- **P0/P1/P2 checklist。** 每个 skill 都自带 `references/checklist.md`,含硬性 P0。Agent 必须 P0 全过才能 emit。
- **Slop 黑名单。** 暴力紫渐变、通用 emoji 图标、左 border 圆角卡片、手绘 SVG 真人脸、Inter 当 *display* 字体、自编指标 —— 提示词里全部明令禁止。
- **诚实占位 > 假数据。** Agent 没真数字时写 `—` 或一个标注的灰块,绝不写「快 10 倍」。
## 横向对比
| 维度 | [Claude Design][cd]Anthropic | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| License | 闭源 | MIT | **Apache-2.0** |
| 形态 | Web (claude.ai) | 桌面 (Electron) | **Web 应用 + 本地 daemon** |
| 可部署 Vercel | ❌ | ❌ | **✅** |
| Agent 运行时 | 内置 (Opus 4.7) | 内置 ([`pi-ai`][piai]) | **委托给用户已装好的 CLI** |
| Skill | 私有 | 12 套自定义 TS 模块 + `SKILL.md` | **31 套基于文件的 [`SKILL.md`][skill],可丢入** |
| Design system | 私有 | `DESIGN.md`v0.2 路线图) | **`DESIGN.md` × 72 套,开箱即有** |
| Provider 灵活度 | 仅 Anthropic | 7+[`pi-ai`][piai] | **16 套 CLI adapter + OpenAI 兼容 BYOK 代理** |
| 初始化问题表单 | ❌ | ❌ | **✅ 硬规则 turn 1** |
| 方向选择器 | ❌ | ❌ | **✅ 5 套确定性方向** |
| 实时 todo 进度 + tool 流 | ❌ | ✅ | **✅**UX 模式来自 open-codesign |
| 沙盒 iframe 预览 | ❌ | ✅ | **✅**(模式来自 open-codesign |
| Claude Design ZIP 导入 | n/a | ❌ | **`POST /api/import/claude-design` —— 接着 Anthropic 停下的地方继续编辑** |
| 评论模式手术刀编辑 | ❌ | ✅ | 🟡 部分 —— 预览元素评论 + chat 附件已实现;可靠的局部 patch 仍在推进 |
| AI 自吐 tweaks 面板 | ❌ | ✅ | 🚧 路线图 —— 专属 chat-side 面板 UX 尚未实现 |
| 文件系统级工作区 | ❌ | 部分Electron 沙盒) | **✅ 真 cwd、真工具、SQLite 持久化projects · conversations · messages · tabs · templates** |
| 五维自评审 | ❌ | ❌ | **✅ Emit 前必跑** |
| Artifact lint | ❌ | ❌ | **`POST /api/artifacts/lint` —— 把 findings 喂回 agent** |
| Sidecar IPC + 无头桌面端 | ❌ | ❌ | **✅ stamped 进程 + `tools-dev inspect desktop status \| eval \| screenshot`** |
| 导出格式 | 受限 | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTXagent 驱动)/ ZIP / Markdown** |
| PPT skill 复用 | N/A | 内置 | **[`guizang-ppt-skill`][guizang] 直接接入deck 模式默认)** |
| 计费门槛 | Pro / Max / Team | BYOK | **BYOK —— 填任意 OpenAI 兼容 `baseUrl`** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## 支持的 Coding Agent
Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 的 `AGENT_DEFS` 里;每个 CLI 的 parser 也在同目录。模型列表的来源要么是探测 `<bin> --list-models` / `<bin> models` / ACP 握手,要么走精选 fallback。
| Agent | 二进制 | 流式格式 | argv 形态(拼装好的 prompt 路径) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json`(类型化事件) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + `codex` parser | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--model …] [-c model_reasoning_effort=…]`prompt 走 stdin |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + `gemini` parser | `GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo [--model …]`prompt 走 stdin |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + `opencode` parser | `opencode run --format json --dangerously-skip-permissions [--model …] -`prompt 走 stdin |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + `cursor-agent` parser | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -`prompt 走 stdin |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain`(原始 stdout chunk | `qwen --yolo [--model …] -`prompt 走 stdin |
| Qoder CLI | `qodercli` | `qoder-stream-json`(类型化事件) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]`prompt 走 stdin |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json`(类型化事件) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc`Agent Client Protocol | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc`stdio JSON-RPC | `pi --mode rpc [--model …] [--thinking …]`prompt 走 RPC `prompt` 命令) |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain`(原始 stdout chunk | `deepseek exec --auto [--model …] <prompt>` |
| **多 provider BYOK** | n/a | SSE 归一化 | `POST /api/proxy/{provider}/stream` → Anthropic / OpenAI 兼容 / Azure OpenAI / Gemini拒绝 loopback / link-local / RFC1918 |
加一个新 CLI = 在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一项。流式格式从 `claude-stream-json` / `qoder-stream-json` / `copilot-stream-json` / `json-event-stream`(搭配每 CLI 的 `eventParser`/ `acp-json-rpc` / `pi-rpc` / `plain` 中选一个。
## 引用与师承
每一个被借鉴的开源项目都列在这里。点链接可以验证师承。
| 项目 | 在这里的角色 |
|---|---|
| [`Claude Design`][cd] | 本仓库为之提供开源替代的闭源产品。 |
| [**`alchaincyf/huashu-design`**(花叔的画术)](https://github.com/alchaincyf/huashu-design) | 设计哲学的核心。Junior-Designer 工作流、5 步品牌资产协议、anti-AI-slop checklist、五维自评审、以及方向选择器背后的「5 流派 × 20 种设计哲学」库 —— 全部蒸馏进 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 与 [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。 |
| [**`op7418/guizang-ppt-skill`**(歸藏)][guizang] | Magazine-web-PPT skill 原样捆绑在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留。Deck 模式默认。P0/P1/P2 checklist 文化也被借给了所有其他 skill。 |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + adapter 架构。PATH 扫描式 agent 检测、本地 daemon 作为唯一特权进程、agent-as-teammate 世界观。我们采纳模型,不 vendor 代码。 |
| [**`OpenCoworkAI/open-codesign`**][ocod] | 第一个开源的 Claude-Design 替代品,也是我们最接近的同类。已采纳的 UX 模式:流式 artifact 循环、沙盒 iframe 预览(自带 React 18 + Babel、实时 agent 面板todos + tool calls + 可中断、5 种导出格式列表HTML/PDF/PPTX/ZIP/Markdown、本地优先的 designs hub、`SKILL.md` 品味注入,以及评论模式预览标注的第一版。路线图上的 UX 模式:可靠的局部 patch 和 AI 自吐 tweaks 面板。**我们刻意不 vendor [`pi-ai`][piai]** —— open-codesign 把它打包成 agent 运行时;我们则委托给用户已经装好的 CLI。 |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9 段式 `DESIGN.md` schema 的来源69 套产品系统通过 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 导入。 |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | 跨多个 agent CLI 的 symlink 式 skill 分发灵感来源。 |
| [Claude Code skills][skill] | `SKILL.md` 规范原样采纳 —— 任何 Claude Code skill 丢进 `skills/` 都能被 daemon 识别。 |
详尽的师承说明(每一项我们采纳了什么、刻意没采纳什么)在 [`docs/references.md`](docs/references.md)。
## Roadmap
- [x] Daemon + agent 检测16 套 CLI adapter+ skill registry + design-system 目录
- [x] Web 应用 + 对话 + question form + 5 套方向选择器 + todo progress + 沙盒预览
- [x] 31 个 skill + 72 套 design system + 5 套视觉方向 + 5 个设备外壳
- [x] SQLite 后端的 projects · conversations · messages · tabs · templates
- [x] 多 provider BYOK 代理(`/api/proxy/{anthropic,openai,azure,google}/stream`)含 SSRF 防御
- [x] Claude Design ZIP 导入(`/api/import/claude-design`
- [x] Sidecar 协议 + Electron 桌面端 + IPC 自动化STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN
- [x] Artifact lint API + 五维自评审 emit-前 gate
- [ ] 评论模式手术刀编辑 —— 已部分交付:预览元素评论和 chat 附件;可靠的定向 patch 仍在推进
- [ ] AI 自吐 tweaks 面板 UX —— 尚未实现
- [ ] Vercel + 隧道部署食谱Topology B
- [ ] 一行 `npx od init` 脚手架带 `DESIGN.md`
- [ ] Skill 市场(`od skills install <github-repo>`)和 `od skill add | list | remove | test` CLI 表面(在 [`docs/skills-protocol.md`](docs/skills-protocol.md) 里有草案daemon 实现尚未跟上)
- [x] `apps/packaged/` 出可分发 Electron 安装包 —— macOSApple Silicon和 Windowsx64下载已上线 [open-design.ai](https://open-design.ai/) 和 [GitHub releases 页面](https://github.com/nexu-io/open-design/releases)
分阶段交付计划在 [`docs/roadmap.md`](docs/roadmap.md)。
## 项目状态
这是一个早期实现 —— 闭环(检测 → 选 skill + design system → 对话 → 解析 `<artifact>` → 预览 → 保存)已经端到端跑通。提示词栈和 skill 库是价值最重的部分,目前已稳定。组件级 UI 仍在每天迭代。
## 给我们点个 Star
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="给 Open Design 点个 Star —— github.com/nexu-io/open-design" width="100%" /></a>
</p>
如果这套东西帮你省了半小时,给它一个 ★。Star 不付房租但它告诉下一个设计师、Agent 和贡献者:这个实验值得他们的注意力。一次点击、三秒钟、真实信号:[github.com/nexu-io/open-design](https://github.com/nexu-io/open-design)。
## 贡献
欢迎 issue、PR、新 skill、新 design system。收益最高的贡献往往就是一个文件夹、一份 Markdown或者一个 PR 大小的 adapter
- **加一个 skill** —— 往 [`skills/`](skills/) 丢一个文件夹,遵循 [`SKILL.md`][skill] 规范。
- **加一套 design system** —— 往 [`design-systems/<brand>/`](design-systems/) 丢一份 `DESIGN.md`,用 9 段式 schema。
- **接入一个新的 coding-agent CLI** —— 在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一项。
完整流程、合并硬线、代码风格、我们不接收的 PR 类型 → [`CONTRIBUTING.zh-CN.md`](CONTRIBUTING.zh-CN.md)[English](CONTRIBUTING.md)[Deutsch](CONTRIBUTING.de.md)[Français](CONTRIBUTING.fr.md))。
## 贡献者墙
感谢每一位让 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-06" 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) 标签起步。
## 仓库活跃度
<picture>
<img alt="Open Design 仓库指标" src="docs/assets/github-metrics.svg" />
</picture>
上面的 SVG 由 [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) 借助 [`lowlighter/metrics`](https://github.com/lowlighter/metrics) 每天自动重新生成。想要立刻刷新可以去 **Actions** 选项卡手动触发想开启更丰富的插件traffic、follow-up time 等)可在仓库 secrets 里加一个细粒度 PAT 命名为 `METRICS_TOKEN`
## Star History
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
</picture>
</a>
曲线往上走 —— 那就是我们想看到的信号。点 ★ 推它一把。
## 鸣谢 / Credits
[`skills/html-ppt/`](skills/html-ppt/) 主 skill 以及 [`skills/html-ppt-*/`](skills/) 下的 15 个 per-template 子 skill —— 含 15 套 full-deck、36 套主题、31 个单页 layout、27 个 CSS 动画 + 20 个 canvas FX、键盘 runtime 与磁吸卡片演讲者模式 —— 整合自开源项目 [`lewislulu/html-ppt-skill`](https://github.com/lewislulu/html-ppt-skill)MIT。原始 LICENSE 已保留于 [`skills/html-ppt/LICENSE`](skills/html-ppt/LICENSE),原作者归属 [@lewislulu](https://github.com/lewislulu)。每张 per-template 的 Examples 卡片(`html-ppt-pitch-deck``html-ppt-tech-sharing``html-ppt-presenter-mode``html-ppt-xhs-post` …)都把 authoring 指南委托给主 skill所以点 **Use this prompt** 后,沿用上游同样的 prompt → 产物路径。
[`skills/guizang-ppt/`](skills/guizang-ppt/) 杂志风横向翻页 deck 整合自 [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill)MIT原作者归属 [@op7418](https://github.com/op7418)。
## License
Apache-2.0。内置的 [`skills/guizang-ppt/`](skills/guizang-ppt/) 保留它原始的 [LICENSE](skills/guizang-ppt/LICENSE)MIT和原作者 [op7418](https://github.com/op7418) 的归属。内置的 [`skills/html-ppt/`](skills/html-ppt/) 保留它原始的 [LICENSE](skills/html-ppt/LICENSE)MIT和原作者 [lewislulu](https://github.com/lewislulu) 的归属。

816
README.zh-TW.md Normal file
View File

@@ -0,0 +1,816 @@
# 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 而已。
<p align="center">
<img src="docs/assets/banner.png" alt="Open Design 封面:與本地 AI 智慧體共同設計" width="100%" />
</p>
<p align="center">
<a href="https://github.com/nexu-io/open-design/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ffd700&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=2ecc71&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/issues"><img alt="Issues" src="https://img.shields.io/github/issues/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=ff6b6b&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/pulls"><img alt="Pull Requests" src="https://img.shields.io/github/issues-pr/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=9b59b6&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=3498db&logo=github&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Commit activity" src="https://img.shields.io/github/commit-activity/m/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=e67e22&logo=git&logoColor=white" /></a>
<a href="https://github.com/nexu-io/open-design/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/nexu-io/open-design?style=for-the-badge&labelColor=0d1117&color=8e44ad&logo=git&logoColor=white" /></a>
</p>
<p align="center">
<a href="https://open-design.ai/"><img alt="下載客戶端" src="https://img.shields.io/badge/%E4%B8%8B%E8%BC%89-%E5%AE%A2%E6%88%B6%E7%AB%AF-ff6b35?style=flat-square" /></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
<a href="#支援的-coding-agent"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
<a href="#design-system"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-72-orange?style=flat-square" /></a>
<a href="#內建-skills"><img alt="Skills" src="https://img.shields.io/badge/skills-31-teal?style=flat-square" /></a>
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-加入-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
</p>
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="README.ko.md">한국어</a> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a></p>
---
## 為什麼要做這個
Anthropic 的 [Claude Design][cd]2026-04-17 釋出,基於 Opus 4.7)讓大家第一次看到:當一個 LLM 不再寫廢話、開始直接交付設計成品,會是什麼樣子。它瞬間爆紅 —— 然後保持**閉源**、付費、只跑在雲上、綁定 Anthropic 的模型和 Anthropic 的內部 skill。沒有 checkout沒有自託管沒有 Vercel 部署,也換不了自己的 agent。
**Open DesignOD就是它的開源替代品。** 同一套 loop、同一種「artifact-first」心智模型但沒有鎖定。我們不做 agent —— 你筆記本上最強的 coding agent 已經裝好了。我們要做的,是把它接進一個 skill 驅動的設計工作流:本地用 `pnpm tools-dev` 跑完整本地閉環,雲端可單獨部署 Web 層,每一層都 BYOK自帶 Key
輸入「幫我做一份雜誌風的種子輪 pitch deck」。在模型揮灑第一個畫素之前**初始化問題表單**已經先跳出來。Agent 從 5 套精選的視覺方向裡選一個。一張動態的 `TodoWrite` 計畫卡片即時流入 UI。Daemon 在磁碟上構建出一個真實的專案目錄,裡面有 seed 模板、佈局庫、自檢 checklist。Agent **強制 pre-flight** 讀取它們,對自己的輸出跑一輪**五維評審**,幾秒後吐出一個 `<artifact>`,渲染在沙盒 iframe 裡。
這不是「AI 試圖做點設計」。這是一個被提示詞堆疊訓練得像高階設計師一樣工作的 AI —— 有可用的檔案系統、有確定性的色票庫、有 checklist 文化 —— 也就是 Claude Design 立下的那條線,只是這次它開源、歸你。
OD 站在四個開源專案的肩膀上:
- [**`alchaincyf/huashu-design`**(花叔的畫術)](https://github.com/alchaincyf/huashu-design) —— 設計哲學的指南針。Junior-Designer 工作流、5 步品牌資產協議、anti-AI-slop checklist、五維自評審、以及方向選擇器背後的「5 流派 × 20 種設計哲學」思路 —— 全部蒸餾進 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts)。
- [**`op7418/guizang-ppt-skill`**(歸藏的雜誌風 PPT skill](https://github.com/op7418/guizang-ppt-skill) —— Deck 模式。原樣納入在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留雜誌版式、WebGL hero、P0/P1/P2 checklist。
- [**`OpenCoworkAI/open-codesign`**](https://github.com/OpenCoworkAI/open-codesign) —— UX 北極星,也是我們最接近的同類。第一個開源的 Claude-Design 替代品。我們借鑑了它的流式 artifact 迴圈、沙盒 iframe 預覽模式(自帶 React 18 + Babel、即時 agent 面板todos + tool calls + 可中斷生成、5 種匯出格式列表HTML / PDF / PPTX / ZIP / Markdown。我們刻意在形態上做出差異化 —— 它是桌面 Electron 應用,把 [`pi-ai`][piai] 打包進去做 agent我們是 Web 應用 + 本地 daemon把 agent 執行時**委託**給你已經裝好的 CLI。
- [**`multica-ai/multica`**](https://github.com/multica-ai/multica) —— Daemon 與執行時架構。PATH 掃描式 agent 檢測,本地 daemon 作為唯一的特權程序agent-as-teammate 的世界觀。
## 一眼概覽
| | 你拿到的 |
|---|---|
| **Coding-agent CLI16 套)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI —— 在 `PATH` 上自動檢測picker 一鍵切換 |
| **BYOK 備援** | OpenAI 相容代理 `/api/proxy/stream` —— 填 `baseUrl` + `apiKey` + `model`,任意 vendorAnthropic-via-OpenAI、DeepSeek、Groq、MiMo、OpenRouter、自託管 vLLM或任何 OpenAI 相容的 provider都能直接當引擎用。daemon 邊界拒絕 loopback / link-local / RFC1918 防 SSRF。 |
| **內建 design system** | **72 套** —— 2 套手寫起手 + 70 套從 [`awesome-design-md`][acd2] 匯入的產品系統Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma、小紅書… |
| **內建 skill** | **31 個** —— 27 個 `prototype` 模式web-prototype、saas-landing、dashboard、mobile-app、gamified-app、social-carousel、magazine-poster、dating-web、sprite-animation、motion-frames、critique、tweaks、wireframe-sketch、pm-spec、eng-runbook、finance-report、hr-onboarding、invoice、kanban-board、team-okrs…+ 4 個 `deck` 模式(`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`。Picker 按 `scenario` 分組design / marketing / operation / engineering / product / finance / hr / sale / personal。 |
| **視覺方向** | 5 套精選流派Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental每套自帶 OKLch 色票 + 字型堆疊([`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts) |
| **裝置外殼** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome —— 畫素級精確,跨 skill 共享,統一在 [`assets/frames/`](assets/frames/) |
| **Agent 執行時** | 本地 daemon 在你的專案目錄裡 spawn CLI —— agent 擁有真實的 `Read` / `Write` / `Bash` / `WebFetch`,作用在真實磁碟上;每個 adapter 都有 Windows `ENAMETOOLONG` 備援stdin / 臨時 prompt 檔案) |
| **匯入** | 把 [Claude Design][cd] 匯出的 ZIP 直接拖到歡迎彈窗 —— `POST /api/import/claude-design` 解壓成真實專案agent 接著 Anthropic 停下的地方繼續編輯,不用再向模型重述上下文 |
| **持久化** | SQLite 在 `.od/app.sqlite`projects · conversations · messages · tabs · 使用者 templates。明天再開todo 卡片和開啟的檔案都還在原位。 |
| **生命週期** | 唯一入口 `pnpm tools-dev`start / stop / run / status / logs / inspect / check—— 用型別化 sidecar stamp 啟動 daemon + web+ desktop |
| **桌面版** | 可選 Electron 殼:渲染器 sandbox + sidecar IPCSTATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN—— 同一通道驅動 `tools-dev inspect desktop screenshot`,跑 E2E |
| **部署目標** | 本地 `pnpm tools-dev` · Vercel Web 層 · 打包好的 Electron 桌面端,支援 macOSApple Silicon和 Windowsx64—— 從 [open-design.ai](https://open-design.ai/) 或 [最新 release](https://github.com/nexu-io/open-design/releases) 直接下載 |
| **License** | Apache-2.0 |
[acd2]: https://github.com/VoltAgent/awesome-design-md
## 效果展示
<table>
<tr>
<td width="50%">
<img src="docs/screenshots/01-entry-view.png" alt="01 · 入口頁" /><br/>
<sub><b>入口頁</b> —— 選 skill、選 design system、寫一行需求。同一個表面服務原型、deck、行動版、dashboard、editorial 頁面所有 mode。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/02-question-form.png" alt="02 · 初始化問題表單" /><br/>
<sub><b>初始化問題表單</b> —— 模型動筆之前OD 先把需求鎖住surface、受眾、調性、品牌上下文、規模。30 秒勾選項秒殺 30 分鐘來回返工。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/03-direction-picker.png" alt="03 · 方向選擇器" /><br/>
<sub><b>方向選擇器</b> —— 使用者沒有品牌上下文時agent 自動跳第二個表單5 套精選方向Monocle / Modern Minimal / Tech Utility / Brutalist / Soft Warm一個 radio 選完,色票 + 字型堆疊直接鎖定,沒有 freestyle 空間。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/04-todo-progress.png" alt="04 · 即時 todo 進度" /><br/>
<sub><b>即時 todo 進度</b> —— Agent 的計畫以即時卡片形式流入 UI。<code>in_progress</code> → <code>completed</code> 即時切換。使用者能在中途以極低成本介入修正。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/05-preview-iframe.png" alt="05 · 沙盒預覽" /><br/>
<sub><b>沙盒預覽</b> —— 每個 <code>&lt;artifact&gt;</code> 都在乾淨的 srcdoc iframe 裡渲染。可在檔案工作區裡就地編輯;可下載為 HTML / PDF / ZIP。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · 72 套 design system 庫" /><br/>
<sub><b>72 套 design system 庫</b> —— 每套產品系統都展示 4 色色卡。點進去看完整的 <code>DESIGN.md</code>、色票網格、live showcase。</sub>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshots/07-magazine-deck.png" alt="07 · 雜誌風 deck" /><br/>
<sub><b>Deck 模式guizang-ppt</b> —— 內建的 <a href="https://github.com/op7418/guizang-ppt-skill"><code>guizang-ppt-skill</code></a> 原樣接入。雜誌版式、WebGL hero 背景、單檔案 HTML 輸出、可導 PDF。</sub>
</td>
<td width="50%">
<img src="docs/screenshots/08-mobile-app.png" alt="08 · 行動版原型" /><br/>
<sub><b>行動版原型</b> —— 畫素級精確的 iPhone 15 Pro chrome靈動島、狀態列 SVG、Home Indicator。多螢幕原型直接複用 <code>/frames/</code> 共享資源agent 永遠不需要重新畫一遍手機。</sub>
</td>
</tr>
</table>
## 內建 Skills
**31 個 skill每個一個資料夾**,都遵循 Claude Code 的 [`SKILL.md`][skill] 規範,併疊加 OD 的 `od:` frontmatterdaemon 原樣解析 —— `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))。
兩種頂層 **mode** 撐起整個目錄:**`prototype`**27 個 —— 任何能被渲染成單頁 artifact 的產物,從雜誌風 landing 到手機螢幕到 PM 規格文件都算)和 **`deck`**4 個 —— 橫滑式演示,自帶 deck framework 框架)。**`scenario`** 是 picker 用來分組的欄位:`design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`
### 示例展示Showcase examples
視覺表現最強、最適合入門第一跑的幾條 skill。每條都附帶可直接開啟的 `example.html` —— 不用登入、不用配置,先看產出再動手。
<table>
<tr>
<td width="50%" valign="top">
<a href="skills/dating-web/"><img src="docs/screenshots/skills/dating-web.png" alt="dating-web" /></a><br/>
<sub><b><a href="skills/dating-web/"><code>dating-web</code></a></b> · <i>prototype</i><br/>消費級約會 / 婚戀儀表盤 —— 左側欄、社群動態 ticker、頭部 KPI、30 天雙向匹配柱狀圖editorial 字型,剋制點綴色。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/digital-eguide/"><img src="docs/screenshots/skills/digital-eguide.png" alt="digital-eguide" /></a><br/>
<sub><b><a href="skills/digital-eguide/"><code>digital-eguide</code></a></b> · <i>template</i><br/>兩頁數字 e-guide —— 封面標題、作者、TOC 預告)+ 內文跨頁pull-quote + 步驟列表),創作者 / 生活方式風。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/email-marketing/"><img src="docs/screenshots/skills/email-marketing.png" alt="email-marketing" /></a><br/>
<sub><b><a href="skills/email-marketing/"><code>email-marketing</code></a></b> · <i>prototype</i><br/>品牌新品釋出郵件 —— 頂部 wordmark、hero 圖、標題鎖排、主 CTA、規格網格。居中單列 + 表格降級,郵件客戶端安全。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/gamified-app/"><img src="docs/screenshots/skills/gamified-app.png" alt="gamified-app" /></a><br/>
<sub><b><a href="skills/gamified-app/"><code>gamified-app</code></a></b> · <i>prototype</i><br/>三螢幕遊戲化移動 app 原型,黑色舞臺 —— 封面 / 今日任務XP 緞帶 + 等級條)/ 任務詳情。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/mobile-onboarding/"><img src="docs/screenshots/skills/mobile-onboarding.png" alt="mobile-onboarding" /></a><br/>
<sub><b><a href="skills/mobile-onboarding/"><code>mobile-onboarding</code></a></b> · <i>prototype</i><br/>三螢幕行動版引導流 —— splash、價值主張、登入。狀態列、滑動點、主 CTA。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/motion-frames/"><img src="docs/screenshots/skills/motion-frames.png" alt="motion-frames" /></a><br/>
<sub><b><a href="skills/motion-frames/"><code>motion-frames</code></a></b> · <i>prototype</i><br/>單幀 motion 設計 heroCSS 迴圈動畫 —— 旋轉字環、地球、計時器。可直接交給 HyperFrames 等關鍵幀匯出。</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="skills/social-carousel/"><img src="docs/screenshots/skills/social-carousel.png" alt="social-carousel" /></a><br/>
<sub><b><a href="skills/social-carousel/"><code>social-carousel</code></a></b> · <i>prototype</i><br/>1080×1080 三連社媒輪播圖 —— 三張電影感面板標題前後呼應品牌標識、loop 標記。</sub>
</td>
<td width="50%" valign="top">
<a href="skills/sprite-animation/"><img src="docs/screenshots/skills/sprite-animation.png" alt="sprite-animation" /></a><br/>
<sub><b><a href="skills/sprite-animation/"><code>sprite-animation</code></a></b> · <i>prototype</i><br/>畫素 / 8-bit 動畫直譯器單幀 —— 米白通螢幕、畫素吉祥物、動感日文標題、迴圈 CSS keyframes可直接錄螢幕成豎版影片。</sub>
</td>
</tr>
</table>
### 設計與營銷類prototype 模式)
| Skill | 平臺 | 場景 | 產出 |
|---|---|---|---|
| [`web-prototype`](skills/web-prototype/) | 桌面 | design | 單頁 HTML —— landing、營銷、heroprototype 預設) |
| [`saas-landing`](skills/saas-landing/) | 桌面 | marketing | hero / features / pricing / CTA 營銷版式 |
| [`dashboard`](skills/dashboard/) | 桌面 | operation | 帶側欄 + 資料密集型的後臺 |
| [`pricing-page`](skills/pricing-page/) | 桌面 | sale | 獨立定價頁 + 對比表 |
| [`docs-page`](skills/docs-page/) | 桌面 | engineering | 三欄文件版式 |
| [`blog-post`](skills/blog-post/) | 桌面 | marketing | 長文 editorial |
| [`mobile-app`](skills/mobile-app/) | 移動 | design | 帶 iPhone 15 Pro / Pixel 外殼的 app 螢幕 |
| [`mobile-onboarding`](skills/mobile-onboarding/) | 移動 | design | 多螢幕行動版引導流splash · 價值主張 · 登入) |
| [`gamified-app`](skills/gamified-app/) | 移動 | personal | 三螢幕遊戲化 app 原型 |
| [`email-marketing`](skills/email-marketing/) | 桌面 | marketing | 品牌新品釋出郵件(表格降級郵件客戶端安全) |
| [`social-carousel`](skills/social-carousel/) | 桌面 | marketing | 1080×1080 三連社媒輪播 |
| [`magazine-poster`](skills/magazine-poster/) | 桌面 | marketing | 單頁雜誌風海報 |
| [`motion-frames`](skills/motion-frames/) | 桌面 | marketing | CSS 迴圈動畫的 motion hero |
| [`sprite-animation`](skills/sprite-animation/) | 桌面 | marketing | 畫素 / 8-bit 動畫直譯器 |
| [`dating-web`](skills/dating-web/) | 桌面 | personal | 消費級約會 / 婚戀儀表盤 |
| [`digital-eguide`](skills/digital-eguide/) | 桌面 | marketing | 兩頁數字 e-guide封面 + 內文跨頁) |
| [`wireframe-sketch`](skills/wireframe-sketch/) | 桌面 | design | 手繪風線框稿 —— 服務於 "先把灰塊拼出來給使用者看" 的早期回合 |
| [`critique`](skills/critique/) | 桌面 | design | 五維自評分卡Philosophy · Hierarchy · Detail · Function · Innovation |
| [`tweaks`](skills/tweaks/) | 桌面 | design | AI 自吐 tweaks 面板 —— 模型自己丟擲值得調的引數 |
### Deck 類deck 模式)
| Skill | 預設 | 產出 |
|---|---|---|
| [`guizang-ppt`](skills/guizang-ppt/) | **deck 預設** | 雜誌風網頁 PPT —— 來自 [op7418/guizang-ppt-skill][guizang],原 LICENSE 保留 |
| [`simple-deck`](skills/simple-deck/) | — | 極簡橫滑 deck |
| [`replit-deck`](skills/replit-deck/) | — | 產品演示 deckReplit 風) |
| [`weekly-update`](skills/weekly-update/) | — | 團隊週報橫滑 deck進度 · 阻塞 · 下一步) |
### 文件與辦公產物類prototype 模式 + 文件場景)
| Skill | Scenario | 產出 |
|---|---|---|
| [`pm-spec`](skills/pm-spec/) | product | PM 規格文件 + 目錄 + 決策日誌 |
| [`team-okrs`](skills/team-okrs/) | product | OKR 計分表 |
| [`meeting-notes`](skills/meeting-notes/) | operation | 會議決策紀要 |
| [`kanban-board`](skills/kanban-board/) | operation | 看板快照 |
| [`eng-runbook`](skills/eng-runbook/) | engineering | 故障 runbook |
| [`finance-report`](skills/finance-report/) | finance | 高管財務摘要 |
| [`invoice`](skills/invoice/) | finance | 單頁發票 |
| [`hr-onboarding`](skills/hr-onboarding/) | hr | 崗位入職計畫 |
新增一個 skill 就是新增一個資料夾。讀 [`docs/skills-protocol.md`](docs/skills-protocol.md) 瞭解擴充套件 frontmatterfork 一個現有 skill重啟 daemon 即生效。目錄拉取走 `GET /api/skills`;單個 skill 的種子拼裝template + 邊角檔案 references`GET /api/skills/:id/example`
## 六個底層設計
### 1 · 我們不帶 agent你的就夠好
Daemon 啟動時掃 `PATH`,找 [`claude`](https://docs.anthropic.com/en/docs/claude-code)、[`codex`](https://github.com/openai/codex)、[`cursor-agent`](https://www.cursor.com/cli)、[`gemini`](https://github.com/google-gemini/gemini-cli)、[`opencode`](https://opencode.ai/)、[`qwen`](https://github.com/QwenLM/qwen-code)、`qodercli`、[`copilot`](https://github.com/features/copilot/cli)、`hermes``kimi` 和 [`pi`](https://github.com/mariozechner/pi-ai)。能找到的都成為候選設計引擎 —— 走 stdio每個 CLI 一個 adaptermodel picker 一鍵切換。靈感來自 [`multica`](https://github.com/multica-ai/multica) 和 [`cc-switch`](https://github.com/farion1231/cc-switch)。一個 CLI 都沒裝?`POST /api/proxy/stream` 就是同一條管線減去 spawn —— 填任意 OpenAI 相容 `baseUrl` + `apiKey`daemon 把 SSE 轉發回瀏覽器loopback / link-local / RFC1918 在邊界直接拒絕。
### 2 · Skill 是檔案,不是外掛
遵循 Claude Code [`SKILL.md` 規範](https://docs.anthropic.com/en/docs/claude-code/skills),每個 skill = `SKILL.md` + `assets/` + `references/`。把一個資料夾丟進 [`skills/`](skills/),重啟 daemonpicker 裡就能看到。內建的 `magazine-web-ppt` 就是 [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill) **原樣**納入 —— 原 LICENSE 保留、原作者歸屬保留。
### 3 · Design System 是可移植的 Markdown不是 theme JSON
[`VoltAgent/awesome-design-md`][acd2] 的 9 段式 `DESIGN.md` —— color、typography、spacing、layout、components、motion、voice、brand、anti-patterns。每個 artifact 都從啟用的 system 裡讀 token。切換 system → 下一次渲染就用新的 token。下拉框裡現成的有**Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Apple、Anthropic、Cursor、Supabase、Figma、Resend、Raycast、Lovable、Cohere、Mistral、ElevenLabs、X.AI、Spotify、Webflow、Sanity、PostHog、Sentry、MongoDB、ClickHouse、Cal、Replicate、Clay、Composio、小紅書…** 共 72 套。
### 4 · 初始化問題表單幹掉 80% 的來回返工
OD 的提示詞堆疊把 `RULE 1` 寫死了:每個新設計任務都從 `<question-form id="discovery">` 開始,**不是程式碼**。Surface · 受眾 · 調性 · 品牌上下文 · 規模 · 約束。一段寫得很長的需求裡仍然有大量留白:視覺調性、色彩立場、規模 —— 而表單恰恰把這些用 30 秒勾選項鎖死。錯方向的代價是一輪對話,不是一份做完的 deck。
這就是從 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 蒸餾出來的 **Junior-Designer 模式**:開工前一次性批次問完,儘早 show 出一些可見的東西(哪怕只是灰色方塊的 wireframe讓使用者用最低成本介入修正。再疊加品牌資產協議定位 · 下載 · `grep` hex · 寫 `brand-spec.md` · 複述這是輸出從「AI freestyle」跳到「先看資料再畫圖的設計師」最關鍵的一步。
### 5 · Daemon 讓 agent 感覺自己就在你筆記本上 —— 因為它就是
Daemon `spawn` CLI 時,`cwd` 設到該專案在 `.od/projects/<id>/` 下的 artifact 資料夾。Agent 拿到的 `Read` / `Write` / `Bash` / `WebFetch` 都是真工具,作用在真檔案系統上。它能 `Read` skill 的 `assets/template.html`,能 `grep` 你的 CSS 拿 hex能寫一份 `brand-spec.md`,能實作生成的圖片,能產出 `.pptx` / `.zip` / `.pdf` —— 這些檔案在 turn 結束的時候作為下載 chip 出現在檔案工作區裡。Session、對話、訊息、tab 都持久化在本地 SQLite 裡 —— 明天再開啟這個專案agent 的 todo 卡片還在你昨天停下的地方。
### 6 · 提示詞堆疊本身就是產品
傳送時拼裝的不是「system + user」。它是
```
DISCOVERY 指令 turn-1 表單、turn-2 品牌分支、TodoWrite、五維評審
+ 身份與工作流憲章 OFFICIAL_DESIGNER_PROMPT、anti-AI-slop、Junior Designer 模式)
+ 啟用的 DESIGN.md 72 套備選)
+ 啟用的 SKILL.md 31 套備選)
+ 專案後設資料 kind、fidelity、speakerNotes、animations、靈感 system id
+ Skill 副檔案 (自動注入 pre-flight先讀 assets/template.html + references/*.md
+ deck kind 且無 skill 種子時) DECK_FRAMEWORK_DIRECTIVE nav / counter / scroll / print
```
每一層都可組合。每一層都是一個你能改的檔案。看 [`apps/web/src/prompts/system.ts`](apps/web/src/prompts/system.ts) 和 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 就知道真實契約長什麼樣。
## 技術架構
```
┌─────────────── 瀏覽器Next.js 16─────────────────────────────┐
│ chat · 檔案工作區 · iframe 預覽 · 設定 · 匯入 │
└──────────────┬─────────────────────────────────┬───────────────┘
│ /api/*dev 走 rewrites
▼ ▼
┌─────────────────────────────────┐ /api/proxy/stream (SSE)
│ 本地 daemonExpress + SQLite│ ─→ 任意 OpenAI 相容
│ │ 端點BYOK
│ /api/agents /api/skills│ 含 SSRF 防禦
│ /api/design-systems /api/projects/…
│ /api/chat (SSE) /api/proxy/stream (SSE)
│ /api/templates /api/import/claude-design
│ /api/artifacts/save /api/artifacts/lint
│ /api/upload /api/projects/:id/files…
│ /artifacts (靜態) /frames (靜態)
│ 可選 sidecar IPC/tmp/open-design/ipc/<ns>/<app>.sock
STATUS · EVAL · SCREENSHOT · CONSOLE · CLICK · SHUTDOWN
└─────────┬───────────────────────┘
│ spawn(cli, [...], { cwd: .od/projects/<id> })
┌──────────────────────────────────────────────────────────────────┐
│ claude · codex · gemini · opencode · cursor-agent · qwen │
│ qoder · copilot · hermes (ACP) · kimi (ACP) · pi (RPC) │
│ 讀 SKILL.md + DESIGN.md把 artifact 寫到磁碟 │
└──────────────────────────────────────────────────────────────────┘
```
| 層 | 技術堆疊 |
|---|---|
| 前端 | Next.js 16 App Router + React 18 + TypeScript可部署到 Vercel |
| Daemon | Node 24 · Express · SSE 流 · `better-sqlite3`;表:`projects` · `conversations` · `messages` · `tabs` · `templates` |
| Agent 傳輸層 | `child_process.spawn`Claude Code 走 `claude-stream-json`、Qoder CLI 走 `qoder-stream-json`、Copilot 走 `copilot-stream-json`、Codex / Gemini / OpenCode / Cursor Agent 走 `json-event-stream`(每個 CLI 一個 parser、Devin / Hermes / Kimi / Kiro / Kilo / Mistral Vibe 走 `acp-json-rpc`Agent Client Protocol、Pi 走 `pi-rpc`stdio JSON-RPC、Qwen Code / DeepSeek TUI 走 `plain` |
| BYOK 代理 | `POST /api/proxy/stream` → OpenAI 相容 `/v1/chat/completions` 透傳 SSEdaemon 邊界拒絕 loopback / link-local / RFC1918 |
| 儲存 | 純檔案 `.od/projects/<id>/` + SQLite `.od/app.sqlite`(已 gitignoredaemon 啟動自建)。`OD_DATA_DIR` 可改根目錄用於測試隔離 |
| 預覽 | 沙盒 iframe`srcdoc`+ 每個 skill 的 `<artifact>` parser[`apps/web/src/artifacts/parser.ts`](apps/web/src/artifacts/parser.ts) |
| 匯出 | HTML內聯資源· PDF瀏覽器列印deck-aware· PPTXagent 驅動經由 skill· ZIParchiver· Markdown |
| 生命週期 | `pnpm tools-dev start \| stop \| run \| status \| logs \| inspect \| check`;埠走 `--daemon-port` / `--web-port`,名稱空間走 `--namespace` |
| 桌面版(可選) | Electron 殼 —— 透過 sidecar IPC 拿 web URL不猜埠同一通道`STATUS`/`EVAL`/`SCREENSHOT`/`CONSOLE`/`CLICK`/`SHUTDOWN`)驅動 `tools-dev inspect desktop …` 跑 E2E |
## Quickstart
### 下載桌面端(不需建置)
試用 Open Design 最快的方式是直接下載預編譯的桌面端 —— 不用裝 Node、不用 pnpm、不用 clone
- **[open-design.ai](https://open-design.ai/)** —— 官方下載頁
- **[GitHub releases](https://github.com/nexu-io/open-design/releases)**
### 從原始碼執行
```bash
git clone https://github.com/nexu-io/open-design.git
cd open-design
corepack enable
corepack pnpm --version # 應輸出 10.33.2
pnpm install
pnpm tools-dev run web
# 開啟 tools-dev 輸出的 web URL
```
Windows 啟動器:請依照 `tools/launcher/README.md` 的說明自行建置 `OpenDesign.exe`,或從 GitHub Releases 下載。接著將它放到 repo 根目錄並雙擊;它會在需要時執行 `pnpm install`,再用 `pnpm tools-dev` 啟動 Open Design。
環境要求Node `~24`pnpm `10.33.x``nvm` / `fnm` 只是可選輔助工具,不是專案必需步驟;如果使用它們,先執行 `nvm install 24 && nvm use 24``fnm install 24 && fnm use 24`,再執行 `pnpm install`
桌面版/後臺啟動、固定埠重啟,以及 media 生成派發器檢查(`OD_BIN``OD_DAEMON_URL``apps/daemon/dist/cli.js`)見 [`QUICKSTART.md`](QUICKSTART.md)。
第一次載入會:
1. 檢測你 `PATH` 上有哪些 agent CLI自動選一個。
2. 載入 31 個 skill + 72 套 design system。
3. 彈歡迎對話方塊,讓你貼 Anthropic key僅 BYOK 備援路徑需要)。
4. **自動建立 `./.od/`** —— 本地執行時目錄,存放 SQLite 專案庫、各專案工作區、儲存下來的 artifact。**沒有** `od init` 這一步daemon 啟動時會自己 `mkdir`
輸入需求,回車,看 question form 跳出來,填,看 todo 卡片流動,看 artifact 渲染。點 **Save to disk** 或匯出整個專案 ZIP。
### 第一次跑起來(`./.od/` 解釋)
Daemon 在倉庫根下維護一個隱藏目錄,裡面所有內容都已 gitignore純本機資料**不要** commit。
```
.od/
├── app.sqlite ← 專案 · 對話 · 訊息 · 開啟的 tab
├── artifacts/ ← Save to disk 一次性渲染(帶時間戳)
└── projects/<id>/ ← 每個專案的工作目錄,也是 agent 的 cwd
```
| 想做什麼 | 怎麼做 |
|---|---|
| 看一眼裡面有啥 | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` |
| 完全清空,從零再來 | `pnpm tools-dev stop`,再 `rm -rf .od`,然後重新 `pnpm tools-dev run web` |
| 換到別的位置 | 暫不支援 —— 路徑是相對倉庫根寫死的 |
完整檔案地圖、指令碼、排錯 → [`QUICKSTART.md`](QUICKSTART.md)。
## 跑專案
Open Design 可以跑成瀏覽器裡的 web app也可以跑成 Electron 桌面版。兩種模式共用同一套本機 daemon + web 架構。
### Web / Localhost預設
```bash
# 前景模式 —— 生命週期指令在前景跑log 寫進檔案)
pnpm tools-dev run web
# 看最近的 log
pnpm tools-dev logs
# 背景模式 —— daemon + web 跑成背景行程
pnpm tools-dev start web
```
預設 `tools-dev` 會綁到可用的暫時埠號,啟動時把實際 URL 印出來。要在停止狀態下用固定埠:
```bash
pnpm tools-dev run web --daemon-port 17456 --web-port 17573
```
如果 daemon / web 已經在跑,用 `restart` 在現有 session 裡換埠:
```bash
pnpm tools-dev restart --daemon-port 17456 --web-port 17573
```
### Desktop / Electron
```bash
# 在背景啟動 daemon + web + desktop
pnpm tools-dev
# 看桌面版狀態
pnpm tools-dev inspect desktop status
# 對桌面版截圖
pnpm tools-dev inspect desktop screenshot --path /tmp/open-design.png
```
桌面版透過 sidecar IPC 自動探得 web URL —— 不用猜埠。
### 其他常用指令
| 指令 | 用途 |
|---|---|
| `pnpm tools-dev status` | 顯示 sidecar 執行狀態 |
| `pnpm tools-dev logs` | 看 daemon / web / desktop 的 log 尾端 |
| `pnpm tools-dev stop` | 停掉所有 sidecar |
| `pnpm tools-dev restart` | 全部停掉再重啟 |
| `pnpm tools-dev check` | 狀態 + 最近 log + 常見診斷 |
固定埠重啟、背景啟動、完整排錯 → [`QUICKSTART.md`](QUICKSTART.md)。
## 從 coding agent 端使用 Open Design
Open Design 內建一個 stdio MCP server。把它接進 Claude Code、Codex、Cursor、VS Code、Antigravity、Zed、Windsurf或任何相容 MCP 的 client另一個 repo 裡的 agent 就能直接讀取你本機 Open Design 專案裡的檔案。整個 export-then-attach 迴圈被取代掉。當 agent 呼叫 `search_files``get_file``get_artifact` 沒帶 project 參數時MCP 預設指向你 Open Design 當下開著的那個專案(與檔案)—— 所以 *「在我的 app 裡蓋這個」*、*「對齊這套樣式」* 這類提示直接就能用。
**為什麼選 MCP** 每改一版設計就匯出再重附 zip會打斷節奏。MCP server 把你的設計原始碼直接暴露成結構化 API —— 設計 token CSS、JSX 元件、入口 HTML —— agent 可以照名字查詢。Agent 永遠看到的是當下這版檔案,不是上次匯出時的舊版。
在 Open Design app 裡打開 **Settings → MCP server** 就有逐 client 的安裝流程。面板會把 `node` 二進位的絕對路徑、daemon 編好的 `cli.js` 路徑,烘進每段 snippet —— 所以即使是剛 clone 下來、`od` 不在 PATH 上的環境也能用。Cursor 給一鍵 deeplink其它 client 給可貼上的 JSON snippetClaude Code 還附帶 `claude mcp add-json` 一行指令,不必手改 `~/.claude.json`)。裝完之後重啟或 reload 你的 clientserver 才會出現。
MCP 工具呼叫成功的前提是 daemon 在本機跑著。如果 agent 是在 Open Design 起來之前就啟動,等 OD 起來後請重啟 agent它才連得上活的 daemon。Daemon 不在線時的工具呼叫會回 `"daemon not reachable"` 的明確錯誤,不會 crash。
**安全性。** MCP server 是唯讀的 —— 它只暴露檔案讀取、檔案 metadata、搜尋沒有任何寫盤或呼叫外部服務的能力。它在 coding agent 下面以子行程身份透過 stdio 跑;任何你註冊上的 MCP client 都會繼承本機 Open Design 專案的讀取權限。把它當作裝 VS Code 擴充套件那樣對待 —— 只註冊你信得過的 client。Daemon 預設綁到 `127.0.0.1`;要讓區網內的機器也能連,得明確設 `OD_BIND_HOST`
## 倉庫結構
```
open-design/
├── README.md ← 英文
├── README.de.md ← Deutsch
├── README.zh-CN.md ← 简体中文
├── README.zh-TW.md ← 本檔案
├── QUICKSTART.md ← 跑 / 構建 / 部署
├── package.json ← 單 bin: od
├── apps/
│ ├── daemon/ ← Node + Express唯一的服務端
│ │ ├── src/ ← TypeScript daemon 原始碼
│ │ │ ├── cli.ts ← `od` bin 原始碼,編譯到 dist/cli.js
│ │ │ ├── server.ts ← /api/* 路由projects、chat、files、exports
│ │ │ ├── agents.ts ← PATH 掃描器 + 各 CLI 的 argv 拼裝
│ │ │ ├── claude-stream.ts ← Claude Code stdout 流式 JSON 解析
│ │ │ ├── skills.ts ← SKILL.md frontmatter 載入器
│ │ │ └── db.ts ← SQLite schemaprojects/messages/templates/tabs
│ │ ├── sidecar/ ← tools-dev daemon sidecar wrapper
│ │ └── tests/ ← daemon 包測試
│ │
│ └── web/ ← Next.js 16 App Router + React 客戶端
│ ├── app/ ← App Router 入口
│ ├── next.config.ts ← dev rewrites + 生產 out/ 靜態匯出
│ └── src/ ← React + TS 客戶端模組
│ ├── App.tsx ← 路由、bootstrap、設定
│ ├── components/ ← chat、composer、picker、preview、sketch…
│ ├── prompts/ ← system、discovery、directions、deck framework
│ ├── artifacts/ ← streaming <artifact> parser + manifest
│ ├── runtime/ ← iframe srcdoc、markdown、匯出輔助
│ ├── providers/ ← daemon SSE + BYOK API 傳輸
│ └── state/ ← localStorage + daemon-backed 專案狀態
├── e2e/ ← Playwright UI + 外部整合/Vitest harness
├── packages/
│ ├── contracts/ ← web/daemon 共享 app contracts
│ ├── sidecar-proto/ ← Open Design sidecar protocol contract
│ ├── sidecar/ ← 通用 sidecar runtime primitives
│ └── platform/ ← 通用 process/platform primitives
├── skills/ ← 31 個 SKILL.md skill 包27 prototype + 4 deck
│ ├── web-prototype/ ← prototype 預設
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
│ ├── email-marketing/ social-carousel/ magazine-poster/
│ ├── motion-frames/ sprite-animation/ digital-eguide/ dating-web/
│ ├── critique/ tweaks/ wireframe-sketch/
│ ├── pm-spec/ team-okrs/ meeting-notes/ kanban-board/
│ ├── eng-runbook/ finance-report/ invoice/ hr-onboarding/
│ ├── simple-deck/ replit-deck/ weekly-update/ ← deck 模式
│ └── guizang-ppt/ ← 內建 magazine-web-pptdeck 預設)
│ ├── SKILL.md
│ ├── assets/template.html ← seed
│ └── references/{themes,layouts,components,checklist}.md
├── design-systems/ ← 72 套 DESIGN.md
│ ├── default/ ← Neutral Modern起手
│ ├── warm-editorial/ ← Warm Editorial起手
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
│ └── README.md
├── assets/
│ └── frames/ ← 跨 skill 共享裝置外殼
│ ├── iphone-15-pro.html
│ ├── android-pixel.html
│ ├── ipad-pro.html
│ ├── macbook.html
│ └── browser-chrome.html
├── templates/
│ └── deck-framework.html ← deck 基線nav / counter / print
├── scripts/
│ └── sync-design-systems.ts ← 從上游 awesome-design-md tarball 重新匯入
├── docs/
│ ├── spec.md ← 產品定義、場景、差異化
│ ├── architecture.md ← 拓撲、資料流、元件
│ ├── skills-protocol.md ← 擴充套件 SKILL.md 的 od: frontmatter
│ ├── agent-adapters.md ← 各 CLI 檢測 + 派發
│ ├── modes.md ← prototype / deck / template / design-system
│ ├── references.md ← 詳盡的引用與師承
│ ├── roadmap.md ← 分階段交付
│ ├── schemas/ ← JSON schema
│ └── examples/ ← 標準 artifact 樣例
└── .od/ ← 執行時資料,已 gitignoredaemon 啟動自建
├── app.sqlite ← 專案 / 對話 / 訊息 / tab
├── projects/<id>/ ← 每個專案的工作目錄agent 的 cwd
└── artifacts/ ← 單次儲存的 artifact
```
## Design System
<p align="center">
<img src="docs/assets/design-systems-library.png" alt="72 套 Design Systems 庫 — 編輯版式雙頁" width="100%" />
</p>
72 套開箱即用,每套一個 [`DESIGN.md`](design-systems/README.md)
<details>
<summary><b>完整目錄</b>(點選展開)</summary>
**AI & LLM** —— `claude` · `cohere` · `mistral-ai` · `minimax` · `together-ai` · `replicate` · `runwayml` · `elevenlabs` · `ollama` · `x-ai`
**開發者工具** —— `cursor` · `vercel` · `linear-app` · `framer` · `expo` · `clickhouse` · `mongodb` · `supabase` · `hashicorp` · `posthog` · `sentry` · `warp` · `webflow` · `sanity` · `mintlify` · `lovable` · `composio` · `opencode-ai` · `voltagent`
**生產力** —— `notion` · `figma` · `miro` · `airtable` · `superhuman` · `intercom` · `zapier` · `cal` · `clay` · `raycast`
**金融科技** —— `stripe` · `coinbase` · `binance` · `kraken` · `mastercard` · `revolut` · `wise`
**電商 / 出行** —— `shopify` · `airbnb` · `uber` · `nike` · `starbucks` · `pinterest`
**媒體** —— `spotify` · `playstation` · `wired` · `theverge` · `meta`
**汽車** —— `tesla` · `bmw` · `ferrari` · `lamborghini` · `bugatti` · `renault`
**其他** —— `apple` · `ibm` · `nvidia` · `vodafone` · `sentry` · `resend` · `spacex`
**起手** —— `default`Neutral Modern· `warm-editorial`
</details>
整個庫透過 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 從 [`VoltAgent/awesome-design-md`][acd2] 匯入。重新執行即可重新整理。
## 視覺方向
當用戶沒有品牌資產時agent 會跳第二個表單5 套精選方向 —— 這是 [`huashu-design` 的「設計方向顧問 · 5 流派 × 20 種設計哲學」 fallback](https://github.com/alchaincyf/huashu-design#%E8%AE%BE%E8%AE%A1%E6%96%B9%E5%90%91%E9%A1%BE%E9%97%AE-fallback) 在 OD 裡的實作。每一套都是確定性 spec —— OKLch 色票、字型堆疊、版式姿態、參考列表 —— agent 直接把它**原樣**綁進 seed 模板的 `:root`。一個 radio 選完,整套視覺系統全部鎖定。零 freestyle零 AI slop。
| 方向 | 調性 | 參考 |
|---|---|---|
| Editorial — Monocle / FT | 印刷雜誌,墨水 + 米色紙 + 暖紅強調 | Monocle · FT Weekend · NYT Magazine |
| Modern minimal — Linear / Vercel | 冷調、結構化、剋制強調 | Linear · Vercel · Stripe |
| Tech utility | 資訊密度、等寬、終端感 | Bloomberg · Bauhaus 工具 |
| Brutalist | 粗糲、巨字、無陰影、刺眼強調 | Bloomberg Businessweek · Achtung |
| Soft warm | 大方、低對比、桃色中性 | Notion 營銷頁 · Apple Health |
完整 spec → [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。
## 媒體生成
OD 不只到程式碼為止。同一套產出 `<artifact>` HTML 的 chat 入口,也驅動**圖像**、**影片**、**音訊**生成 —— 模型 adapter 已經接進 daemon 的 media pipeline[`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts)、[`apps/web/src/media/models.ts`](apps/web/src/media/models.ts))。每一次渲染都是真的寫入專案工作區的檔案,`.png``.mp4` 在 turn 結束時直接以下載 chip 形式出現。
目前主力是三個模型族:
| Surface | 模型 | 提供方 | 用來做什麼 |
|---|---|---|---|
| **圖像** | `gpt-image-2` | Azure / OpenAI | 海報、頭像、城市插畫地圖、資訊圖、雜誌風社群卡、老照片修復、產品爆炸圖 |
| **影片** | `seedance-2.0` | 字節跳動 Volcengine | 15s 電影感 t2v + i2v + 音訊 —— 敘事短片、人物特寫、產品片、MV 編排 |
| **影片** | `hyperframes-html` | [HeyGen 開源](https://github.com/heygen-com/hyperframes) | HTML→MP4 動態圖形 —— 產品揭曉、動力學排版、資料圖表、社群覆蓋層、Logo 收尾、TikTok 直式配卡拉 OK 字幕 |
不斷成長的 **prompt gallery** 在 [`prompt-templates/`](prompt-templates/) —— 共 **93 條可一鍵複刻 prompt**43 條圖像(`prompt-templates/image/*.json`、39 條 Seedance`prompt-templates/video/*.json`,不含 `hyperframes-*`、11 條 HyperFrames`prompt-templates/video/hyperframes-*.json`)。每一條都帶預覽縮圖、原文 prompt、目標模型、畫面比例以及一個用來標註授權與作者的 `source` 區塊。daemon 在 `GET /api/prompt-templates` 暴露它們Web 入口的 **Image templates** / **Video templates** 兩個 tab 把它們渲染成卡片網格,一鍵就把 prompt 拍進 composer並自動選好對應模型。
### gpt-image-2 —— 圖像樣例(共 43 條,下面 5 張)
<table>
<tr>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776661968404_8a5flm_HGQc_KOaMAA2vt0.jpg" alt="3D Stone Staircase Evolution" /><br/><sub><b>3D Stone Staircase Evolution Infographic</b><br/>三段式石材風資訊圖</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg" alt="Illustrated City Food Map" /><br/><sub><b>Illustrated City Food Map</b><br/>編輯級手繪旅行海報</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453149026_gd2k50_HHCSvymboAAVscc.jpg" alt="Cinematic Elevator Scene" /><br/><sub><b>Cinematic Elevator Scene</b><br/>電梯場景的單格時尚靜畫</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453164993_mt5b69_HHDoWfeaUAEA6Vt.jpg" alt="Cyberpunk Anime Portrait" /><br/><sub><b>Cyberpunk Anime Portrait</b><br/>頭像 —— 霓虹臉字</sub></td>
<td width="20%" valign="top"><img src="https://cms-assets.youmind.com/media/1777453184257_vb9hvl_HG9tAkOa4AAuRrn.jpg" alt="Glamorous Woman in Black" /><br/><sub><b>Glamorous Woman in Black Portrait</b><br/>編輯級攝影棚肖像</sub></td>
</tr>
</table>
完整列表 → [`prompt-templates/image/`](prompt-templates/image/)。來源:多數取自 [`YouMind-OpenLab/awesome-gpt-image-prompts`](https://github.com/YouMind-OpenLab/awesome-gpt-image-prompts)CC-BY-4.0),逐條保留作者署名。
### Seedance 2.0 —— 影片樣例(共 39 條,下面 5 段)
<table>
<tr>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/c4515f4f328539e1ded2cc32f4ce63e7/thumbnails/thumbnail.jpg" alt="Music Podcast Guitar" /></a><br/><sub><b>Music Podcast & Guitar Technique</b><br/>4K 電影感錄音棚片段</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/4a47ba646e7cedd79363c861864b8714/thumbnails/thumbnail.jpg" alt="Emotional Face" /></a><br/><sub><b>Emotional Face Close-up</b><br/>電影感微表情研究</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7e8983364a95fe333f0f88bd1085a0e8/thumbnails/thumbnail.jpg" alt="Luxury Supercar" /></a><br/><sub><b>Luxury Supercar Cinematic</b><br/>敘事化產品片</sub></td>
<td width="20%" valign="top"><a href="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/downloads/default.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/0279a674ce138ab5a0a6f020a7273d89/thumbnails/thumbnail.jpg" alt="Forbidden City Cat" /></a><br/><sub><b>Forbidden City Cat Satire</b><br/>風格化諷刺短片</sub></td>
<td width="20%" valign="top"><a href="https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts/releases/download/videos/1402.mp4"><img src="https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/7f63ad253175a9ad1dac53de490efac8/thumbnails/thumbnail.jpg" alt="Japanese Romance" /></a><br/><sub><b>Japanese Romance Short Film</b><br/>15s Seedance 2.0 敘事短片</sub></td>
</tr>
</table>
點任意縮圖即可播放實際渲染出的 MP4。完整列表 → [`prompt-templates/video/`](prompt-templates/video/)`*-seedance-*` 與帶 Cinematic 標籤的條目)。來源:[`YouMind-OpenLab/awesome-seedance-2-prompts`](https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts)CC-BY-4.0),保留原推連結與作者 handle。
### HyperFrames —— HTML→MP4 動態圖形11 條可一鍵複刻樣板)
[**`heygen-com/hyperframes`**](https://github.com/heygen-com/hyperframes) 是 HeyGen 開源的 agent-native 影片框架 —— 你(或 agent寫 HTML + CSS + GSAPHyperFrames 透過 headless Chrome + FFmpeg 確定性地渲成 MP4。Open Design 把 HyperFrames 接成一等影片模型(`hyperframes-html`),掛進 daemon dispatch同時帶上 `skills/hyperframes/` 這個 skill把 timeline 合約、scene transition 規則、audio-reactive 模式、字幕 / TTS、目錄元件`npx hyperframes add <slug>`)一起教給 agent。
11 條 HyperFrames prompt 放在 [`prompt-templates/video/hyperframes-*.json`](prompt-templates/video/),每一條都是產生具體某個原型的明確 brief
<table>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-product-reveal-minimal.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Product reveal" /></a><br/><sub><b>5s 極簡產品揭曉</b> · 16:9 · 推近標題卡 + shader 轉場</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-saas-product-promo-30s.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="SaaS promo" /></a><br/><sub><b>30s SaaS 產品片</b> · 16:9 · Linear / ClickUp 風格帶 UI 3D 揭曉</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-tiktok-karaoke-talking-head.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/tiktok-follow.png" alt="TikTok karaoke" /></a><br/><sub><b>TikTok 卡拉 OK 口播</b> · 9:16 · TTS + 單字對齊字幕</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-brand-sizzle-reel.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Brand sizzle" /></a><br/><sub><b>30s 品牌 sizzle</b> · 16:9 · 節拍同步動力學排版、audio-reactive</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-data-bar-chart-race.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/data-chart.png" alt="Data chart" /></a><br/><sub><b>動畫 bar-chart race</b> · 16:9 · NYT 風資料資訊圖</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-flight-map-route.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/nyc-paris-flight.png" alt="Flight map" /></a><br/><sub><b>航線地圖(起 → 終)</b> · 16:9 · Apple 風電影感路徑揭曉</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-logo-outro-cinematic.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/logo-outro.png" alt="Logo outro" /></a><br/><sub><b>4s 電影感 Logo 收尾</b> · 16:9 · 逐部件拼合 + 光暈</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-money-counter-hype.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/apple-money-count.png" alt="Money counter" /></a><br/><sub><b>$0 → $10K 數字飆升</b> · 9:16 · Apple 風高燃綠光閃 + 鈔票四濺</sub></td>
</tr>
<tr>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-app-showcase-three-phones.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png" alt="App showcase" /></a><br/><sub><b>3 手機 app 展示</b> · 16:9 · 懸浮三屏 + 功能旁注</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-social-overlay-stack.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Social overlay" /></a><br/><sub><b>社群卡疊加</b> · 9:16 · X · Reddit · Spotify · Instagram 依序入畫</sub></td>
<td width="25%" valign="top"><a href="prompt-templates/video/hyperframes-website-to-video-promo.json"><img src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/instagram-follow.png" alt="Website to video" /></a><br/><sub><b>網站到影片管線</b> · 16:9 · 抓 3 種視口 + 轉場串聯</sub></td>
<td width="25%" valign="top">&nbsp;</td>
</tr>
</table>
流程跟其它一樣:挑樣板、改 brief、送出。Agent 讀取自帶的 `skills/hyperframes/SKILL.md`(裡面帶 OD 專用的渲染流程 —— composition 原始檔落到 `.hyperframes-cache/`避免汙染檔案工作區daemon 替你觸發 `npx hyperframes render`,繞開 macOS sandbox-exec / Puppeteer 卡死;最終只有 `.mp4` 作為專案 chip 出現),寫完 composition、產出 MP4。目錄元件縮圖版權歸 HeyGen由 HeyGen 的 CDN 提供OSS 框架本身是 Apache-2.0。
> **已經接好但還沒出 prompt 樣板的:** Kling 2.0 / 1.6 / 1.5、Veo 3 / Veo 2、Sora 2 / Sora 2-Provia Fal、MiniMax video-01 —— 都在 `VIDEO_MODELS`[`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)裡。Suno v5 / v4.5、Udio v2、Lyria 2音樂和 gpt-4o-mini-tts、MiniMax TTS語音覆蓋音訊側。補全這些模型的 prompt 樣板屬於開放貢獻 —— 把 JSON 放進 `prompt-templates/video/` 或 `prompt-templates/audio/`picker 裡就能直接看到。
## 聊天迴圈之外,還交付了什麼
Chat / artifact 迴圈最顯眼,但這套倉庫裡還有幾個能力被埋得有點深,對照其它產品做選型之前值得先掃一遍:
- **Claude Design ZIP 匯入。** 把 claude.ai 匯出的 ZIP 拖到歡迎彈窗,`POST /api/import/claude-design` 把它解壓成真實 `.od/projects/<id>/`,把入口檔案作為 tab 開啟,並預置一句「接著 Anthropic 停下的地方繼續編輯」給本地 agent。不用再讓模型重述上下文也不用「讓模型重新畫一遍」。([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
- **OpenAI 相容 BYOK 代理。** `POST /api/proxy/stream` 接收 `{ baseUrl, apiKey, model, messages }`,自動歸一化路徑(`…/v1/chat/completions`),把 SSE chunk 轉發回瀏覽器;同時拒絕 loopback / link-local / RFC1918 防 SSRF。任何說 OpenAI chat schema 的 vendor 都能直接用 —— Anthropic-via-OpenAI shim、DeepSeek、Groq、MiMo、OpenRouter、自託管 vLLM 都行。MiMo 會自動加 `tool_choice: 'none'`,因為它的 tool schema 和 free-form 生成不太合得來。
- **使用者自存 templates。** 喜歡某次渲染?`POST /api/templates` 把 HTML + 後設資料快照進 SQLite `templates` 表。下個專案的 picker 裡多一行「你的模板」 —— 跟內建 31 套同一個挑選面,但是你的。
- **Tab 持久化。** 每個專案記得自己開啟的檔案和當前 tab存在 `tabs` 表裡。明天再開啟,工作區還是你昨天離開時的樣子。
- **Artifact lint API。** `POST /api/artifacts/lint` 對生成的 artifact 跑結構性檢查(`<artifact>` 框架是否破損、必需的副檔案是否缺失、palette token 是否過期),返回 agent 下一回合可以讀回去的 findings。五維自評審就是用它把分數落到證據上而不是 vibe。
- **Sidecar 協議 + 桌面版自動化。** Daemon、web、desktop 程序都帶型別化的 5 欄位 stamp`app · mode · namespace · ipc · source`),並把 JSON-RPC IPC 通道暴露在 `/tmp/open-design/ipc/<namespace>/<app>.sock``tools-dev inspect desktop status \| eval \| screenshot` 就跑在這條通道上,所以 headless E2E 直接打到真實 Electron 殼,不用造定製夾具([`packages/sidecar-proto/`](packages/sidecar-proto/)、[`apps/desktop/src/main/`](apps/desktop/src/main/))。
- **Windows 友好的 spawn。** 任何在長 prompt 上會撞 `CreateProcess` 32 KB argv 上限的 adapterCodex、Gemini、OpenCode、Cursor Agent、Qwen、Qoder CLI、Pi都改走 stdin。Claude Code 和 Copilot 保留 `-p`;連 stdin 都裝不下時 daemon 退回臨時 prompt 檔案。
- **按 namespace 隔離的 runtime data。** `OD_DATA_DIR``--namespace` 給你完全隔離的 `.od/`-style 目錄樹Playwright、beta channel、你正經的專案永遠不會共用同一個 SQLite 檔案。
## 反 AI Slop 機制
下面整套機制都是 [`huashu-design`](https://github.com/alchaincyf/huashu-design) 的 playbook被移植進 OD 的提示詞堆疊,並透過 skill 副檔案 pre-flight 讓每個 skill 都能實作執行。看 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 是真實文案:
- **先表單。** Turn 1 必須是 `<question-form>`**不準** thinking、不準 tools、不準旁白。使用者用 radio 速度選預設。
- **品牌資產協議。** 使用者貼截圖或 URL 時agent 走 5 步流程(定位 · 下載 · grep hex · 寫 `brand-spec.md` · 複述)才能開始寫 CSS。**絕不從記憶裡猜品牌色**。
- **五維評審。** 在吐 `<artifact>` 之前agent 默默給自己 15 分打分,五個維度:哲學 / 層級 / 執行 / 具體度 / 剋制。任一維 < 3/5 視為退步 —— 修完再評。兩輪是常態。
- **P0/P1/P2 checklist。** 每個 skill 都自帶 `references/checklist.md`,含硬性 P0。Agent 必須 P0 全過才能 emit。
- **Slop 黑名單。** 暴力紫漸變、通用 emoji 圖示、左 border 圓角卡片、手繪 SVG 真人臉、Inter 當 *display* 字型、自編指標 —— 提示詞裡全部明令禁止。
- **誠實佔位 > 假資料。** Agent 沒真數字時寫 `—` 或一個標註的灰塊,絕不寫「快 10 倍」。
## 橫向對比
| 維度 | [Claude Design][cd]Anthropic | [Open CoDesign][ocod] | **Open Design** |
|---|---|---|---|
| License | 閉源 | MIT | **Apache-2.0** |
| 形態 | Web (claude.ai) | 桌面 (Electron) | **Web 應用 + 本地 daemon** |
| 可部署 Vercel | ❌ | ❌ | **✅** |
| Agent 執行時 | 內建 (Opus 4.7) | 內建 ([`pi-ai`][piai]) | **委託給使用者已裝好的 CLI** |
| Skill | 私有 | 12 套自定義 TS 模組 + `SKILL.md` | **31 套基於檔案的 [`SKILL.md`][skill],可丟入** |
| Design system | 私有 | `DESIGN.md`v0.2 路線圖) | **`DESIGN.md` × 72 套,開箱即有** |
| Provider 靈活度 | 僅 Anthropic | 7+[`pi-ai`][piai] | **16 套 CLI adapter + OpenAI 相容 BYOK 代理** |
| 初始化問題表單 | ❌ | ❌ | **✅ 硬規則 turn 1** |
| 方向選擇器 | ❌ | ❌ | **✅ 5 套確定性方向** |
| 即時 todo 進度 + tool 流 | ❌ | ✅ | **✅**UX 模式來自 open-codesign |
| 沙盒 iframe 預覽 | ❌ | ✅ | **✅**(模式來自 open-codesign |
| Claude Design ZIP 匯入 | n/a | ❌ | **`POST /api/import/claude-design` —— 接著 Anthropic 停下的地方繼續編輯** |
| 評論模式手術刀編輯 | ❌ | ✅ | 🚧 路線圖(移植自 open-codesign |
| AI 自吐 tweaks 面板 | ❌ | ✅ | 🟡 部分 —— [`tweaks` skill](skills/tweaks/) 已發,專屬 chat-side 面板 UX 仍在路線圖 |
| 檔案系統級工作區 | ❌ | 部分Electron 沙盒) | **✅ 真 cwd、真工具、SQLite 持久化projects · conversations · messages · tabs · templates** |
| 五維自評審 | ❌ | ❌ | **✅ Emit 前必跑** |
| Artifact lint | ❌ | ❌ | **`POST /api/artifacts/lint` —— 把 findings 喂回 agent** |
| Sidecar IPC + 無頭桌面版 | ❌ | ❌ | **✅ stamped 程序 + `tools-dev inspect desktop status \| eval \| screenshot`** |
| 匯出格式 | 受限 | HTML / PDF / PPTX / ZIP / Markdown | **HTML / PDF / PPTXagent 驅動)/ ZIP / Markdown** |
| PPT skill 複用 | N/A | 內建 | **[`guizang-ppt-skill`][guizang] 直接接入deck 模式預設)** |
| 計費門檻 | Pro / Max / Team | BYOK | **BYOK —— 填任意 OpenAI 相容 `baseUrl`** |
[cd]: https://x.com/claudeai/status/2045156267690213649
[ocod]: https://github.com/OpenCoworkAI/open-codesign
[piai]: https://github.com/mariozechner/pi-ai
[acd]: https://github.com/VoltAgent/awesome-claude-design
[guizang]: https://github.com/op7418/guizang-ppt-skill
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
## 支援的 Coding Agent
Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 的 `AGENT_DEFS` 裡;每個 CLI 的 parser 也在同目錄。模型列表的來源要麼是探測 `<bin> --list-models` / `<bin> models` / ACP 握手,要麼走精選 fallback。
| Agent | 二進位制 | 流式格式 | argv 形態(拼裝好的 prompt 路徑) |
|---|---|---|---|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | `claude-stream-json`(型別化事件) | `claude -p <prompt> --output-format stream-json --verbose [--include-partial-messages] [--add-dir …] --permission-mode bypassPermissions` |
| [Codex CLI](https://github.com/openai/codex) | `codex` | `json-event-stream` + `codex` parser | `codex exec --json --skip-git-repo-check --sandbox workspace-write -c sandbox_workspace_write.network_access=true [-C cwd] [--model …] [-c model_reasoning_effort=…]`prompt 走 stdin |
| Devin for Terminal | `devin` | `acp-json-rpc` | `devin --permission-mode dangerous --respect-workspace-trust false acp` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | `json-event-stream` + `gemini` parser | `GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo [--model …]`prompt 走 stdin |
| [OpenCode](https://opencode.ai/) | `opencode` | `json-event-stream` + `opencode` parser | `opencode run --format json --dangerously-skip-permissions [--model …] -`prompt 走 stdin |
| [Cursor Agent](https://www.cursor.com/cli) | `cursor-agent` | `json-event-stream` + `cursor-agent` parser | `cursor-agent --print --output-format stream-json --stream-partial-output --force --trust [--workspace cwd] [--model …] -`prompt 走 stdin |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | `plain`(原始 stdout chunk | `qwen --yolo [--model …] -`prompt 走 stdin |
| Qoder CLI | `qodercli` | `qoder-stream-json`(型別化事件) | `qodercli -p --output-format stream-json --permission-mode bypass_permissions [--cwd cwd] [--model …] [--add-dir …]`prompt 走 stdin |
| [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `copilot-stream-json`(型別化事件) | `copilot -p <prompt> --allow-all-tools --output-format json [--model …] [--add-dir …]` |
| [Hermes](https://github.com/eqlabs/hermes) | `hermes` | `acp-json-rpc`Agent Client Protocol | `hermes acp --accept-hooks` |
| Kimi CLI | `kimi` | `acp-json-rpc` | `kimi acp` |
| [Pi](https://github.com/mariozechner/pi-ai) | `pi` | `pi-rpc`stdio JSON-RPC | `pi --mode rpc [--model …] [--thinking …]`prompt 走 RPC `prompt` 命令) |
| [Kiro CLI](https://kiro.dev) | `kiro-cli` | `acp-json-rpc` | `kiro-cli acp` |
| Kilo | `kilo` | `acp-json-rpc` | `kilo acp` |
| [Mistral Vibe CLI](https://github.com/mistralai/mistral-vibe) | `vibe-acp` | `acp-json-rpc` | `vibe-acp` |
| DeepSeek TUI | `deepseek` | `plain`(原始 stdout chunk | `deepseek exec --auto [--model …] <prompt>` |
| **OpenAI 相容 BYOK** | n/a | SSE 透傳 | `POST /api/proxy/stream``<baseUrl>/v1/chat/completions`;拒絕 loopback / link-local / RFC1918 |
加一個新 CLI = 在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一項。流式格式從 `claude-stream-json` / `qoder-stream-json` / `copilot-stream-json` / `json-event-stream`(搭配每 CLI 的 `eventParser`/ `acp-json-rpc` / `pi-rpc` / `plain` 中選一個。
## 引用與師承
每一個被借鑑的開源專案都列在這裡。點連結可以驗證師承。
| 專案 | 在這裡的角色 |
|---|---|
| [`Claude Design`][cd] | 本倉庫為之提供開源替代的閉源產品。 |
| [**`alchaincyf/huashu-design`**(花叔的畫術)](https://github.com/alchaincyf/huashu-design) | 設計哲學的核心。Junior-Designer 工作流、5 步品牌資產協議、anti-AI-slop checklist、五維自評審、以及方向選擇器背後的「5 流派 × 20 種設計哲學」庫 —— 全部蒸餾進 [`apps/web/src/prompts/discovery.ts`](apps/web/src/prompts/discovery.ts) 與 [`apps/web/src/prompts/directions.ts`](apps/web/src/prompts/directions.ts)。 |
| [**`op7418/guizang-ppt-skill`**(歸藏)][guizang] | Magazine-web-PPT skill 原樣納入在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留。Deck 模式預設。P0/P1/P2 checklist 文化也被借給了所有其他 skill。 |
| [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + adapter 架構。PATH 掃描式 agent 檢測、本地 daemon 作為唯一特權程序、agent-as-teammate 世界觀。我們採納模型,不 vendor 程式碼。 |
| [**`OpenCoworkAI/open-codesign`**][ocod] | 第一個開源的 Claude-Design 替代品,也是我們最接近的同類。已採納的 UX 模式:流式 artifact 迴圈、沙盒 iframe 預覽(自帶 React 18 + Babel、即時 agent 面板todos + tool calls + 可中斷、5 種匯出格式列表HTML/PDF/PPTX/ZIP/Markdown、本地優先的 designs hub、`SKILL.md` 品味注入。路線圖上的 UX 模式評論模式手術刀編輯、AI 自吐 tweaks 面板。**我們刻意不 vendor [`pi-ai`][piai]** —— open-codesign 把它打包成 agent 執行時;我們則委託給使用者已經裝好的 CLI。 |
| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9 段式 `DESIGN.md` schema 的來源69 套產品系統透過 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 匯入。 |
| [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | 跨多個 agent CLI 的 symlink 式 skill 分發靈感來源。 |
| [Claude Code skills][skill] | `SKILL.md` 規範原樣採納 —— 任何 Claude Code skill 丟進 `skills/` 都能被 daemon 識別。 |
詳盡的師承說明(每一項我們採納了什麼、刻意沒採納什麼)在 [`docs/references.md`](docs/references.md)。
## Roadmap
- [x] Daemon + agent 檢測16 套 CLI adapter+ skill registry + design-system 目錄
- [x] Web 應用 + 對話 + question form + 5 套方向選擇器 + todo progress + 沙盒預覽
- [x] 31 個 skill + 72 套 design system + 5 套視覺方向 + 5 個裝置外殼
- [x] SQLite 後端的 projects · conversations · messages · tabs · templates
- [x] OpenAI 相容 BYOK 代理(`/api/proxy/stream`)含 SSRF 防禦
- [x] Claude Design ZIP 匯入(`/api/import/claude-design`
- [x] Sidecar 協議 + Electron 桌面版 + IPC 自動化STATUS / EVAL / SCREENSHOT / CONSOLE / CLICK / SHUTDOWN
- [x] Artifact lint API + 五維自評審 emit-前 gate
- [ ] 評論模式手術刀編輯(點元素 → 指令 → 區域性 patch—— 模式來自 [`open-codesign`][ocod]
- [ ] AI 自吐 tweaks 面板 UX —— 基礎積木([`tweaks` skill](skills/tweaks/))已發,整合到 chat 的面板尚未完成
- [ ] Vercel + 隧道部署食譜Topology B
- [ ] 一行 `npx od init` 腳手架帶 `DESIGN.md`
- [ ] Skill 市場(`od skills install <github-repo>`)和 `od skill add | list | remove | test` CLI 表面(在 [`docs/skills-protocol.md`](docs/skills-protocol.md) 裡有草案daemon 實現尚未跟上)
- [x] `apps/packaged/` 出可分發 Electron 安裝包 —— macOSApple Silicon和 Windowsx64下載已上線 [open-design.ai](https://open-design.ai/) 和 [GitHub releases 頁面](https://github.com/nexu-io/open-design/releases)
分階段交付計畫在 [`docs/roadmap.md`](docs/roadmap.md)。
## 專案狀態
這是一個早期實現 —— 閉環(檢測 → 選 skill + design system → 對話 → 解析 `<artifact>` → 預覽 → 儲存)已經端到端跑通。提示詞堆疊和 skill 庫是價值最重的部分,目前已穩定。元件級 UI 仍在每天迭代。
## 給我們點個 Star
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="給 Open Design 點個 Star —— github.com/nexu-io/open-design" width="100%" /></a>
</p>
如果這套東西幫你省了半小時,給它一個 ★。Star 不付房租但它告訴下一個設計師、Agent 和貢獻者:這個實驗值得他們的注意力。一次點選、三秒鐘、真實訊號:[github.com/nexu-io/open-design](https://github.com/nexu-io/open-design)。
## 貢獻
歡迎 issue、PR、新 skill、新 design system。收益最高的貢獻往往就是一個資料夾、一份 Markdown或者一個 PR 大小的 adapter
- **加一個 skill** —— 往 [`skills/`](skills/) 丟一個資料夾,遵循 [`SKILL.md`][skill] 規範。
- **加一套 design system** —— 往 [`design-systems/<brand>/`](design-systems/) 丟一份 `DESIGN.md`,用 9 段式 schema。
- **接入一個新的 coding-agent CLI** —— 在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一項。
完整流程、合併硬線、程式碼風格、我們不接收的 PR 型別 → [`CONTRIBUTING.zh-CN.md`](CONTRIBUTING.zh-CN.md)[English](CONTRIBUTING.md)[Deutsch](CONTRIBUTING.de.md)[Français](CONTRIBUTING.fr.md))。
## 貢獻者牆
感謝每一位讓 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-06" 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) 標籤起步。
## 倉庫活躍度
<picture>
<img alt="Open Design 倉庫指標" src="docs/assets/github-metrics.svg" />
</picture>
上面的 SVG 由 [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml) 藉助 [`lowlighter/metrics`](https://github.com/lowlighter/metrics) 每天自動重新生成。想要立刻重新整理可以去 **Actions** 選項卡手動觸發想開啟更豐富的外掛traffic、follow-up time 等)可在倉庫 secrets 里加一個細粒度 PAT 命名為 `METRICS_TOKEN`
## Star History
<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-06" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-06" />
</picture>
</a>
曲線往上走 —— 那就是我們想看到的訊號。點 ★ 推它一把。
## 致謝 / Credits
[`skills/html-ppt/`](skills/html-ppt/) 主 skill 以及 [`skills/html-ppt-*/`](skills/) 下的逐樣板子 skill —— 含 15 套 full-deck、36 套主題、31 個單頁 layout、27 個 CSS 動畫 + 20 個 canvas FX、鍵盤 runtime 與磁吸卡片演講者模式 —— 整合自開源專案 [`lewislulu/html-ppt-skill`](https://github.com/lewislulu/html-ppt-skill)MIT。原始 LICENSE 保留在 [`skills/html-ppt/LICENSE`](skills/html-ppt/LICENSE),原作者歸屬 [@lewislulu](https://github.com/lewislulu)。每張逐樣板的 Examples 卡片(`html-ppt-pitch-deck``html-ppt-tech-sharing``html-ppt-presenter-mode``html-ppt-xhs-post` …)都把 authoring 指南委派給主 skill —— 點 **Use this prompt** 之後,沿用上游同樣的 prompt → 輸出行為。
[`skills/guizang-ppt/`](skills/guizang-ppt/) 雜誌風橫向翻頁 deck 整合自 [`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill)MIT原作者歸屬 [@op7418](https://github.com/op7418)。
## License
Apache-2.0。內建的 [`skills/guizang-ppt/`](skills/guizang-ppt/) 保留它原始的 [LICENSE](skills/guizang-ppt/LICENSE)MIT和原作者 [op7418](https://github.com/op7418) 的歸屬。內建的 [`skills/html-ppt/`](skills/html-ppt/) 保留它原始的 [LICENSE](skills/html-ppt/LICENSE)MIT和原作者 [lewislulu](https://github.com/lewislulu) 的歸屬。

292
TRANSLATIONS.md Normal file
View File

@@ -0,0 +1,292 @@
# Translations
> **Status: living document.** Maintainers refine this as the project's i18n
> needs evolve. Contributions welcome.
For general contribution flow, see [CONTRIBUTING.md](CONTRIBUTING.md). The
"Localization maintenance" section there documents the boundary between
translated surfaces and agent-facing source material. This file covers
**how** to add and maintain a locale across the surfaces contributors
touch most often: UI chrome, root READMEs, core docs, and display metadata.
> **Why a separate file?** i18n contributors usually only need this surface
> — keeping locale workflow out of the main contribution guide isolates
> jargon (BCP-47, fallback chains, regional glossaries) from the broader
> code-workflow audience. CONTRIBUTING.md cross-links here for discovery.
## Maintained locales
UI dictionaries live in [`apps/web/src/i18n/locales/`](apps/web/src/i18n/locales/).
Root README translations live beside [`README.md`](README.md). Core doc
translations live beside [`QUICKSTART.md`](QUICKSTART.md) and
[`CONTRIBUTING.md`](CONTRIBUTING.md). Display metadata translations live in
`apps/web/src/i18n/content*.ts`.
The `LOCALES` array in [`apps/web/src/i18n/types.ts`](apps/web/src/i18n/types.ts)
is the authoritative list for the **UI dict**. Root README language
switchers cover every locale that has a root README; this set can differ
from `LOCALES`.
| Code | Language | UI dict | Root README | Core docs | Display metadata | Status |
| ------- | -------------------- | ---------------------- | ------------------- | --------- | ---------------- | ------ |
| `en` | English | `en.ts` (source) | `README.md` | source | `content.ts` | active |
| `ar` | العربية | `ar.ts` | `README.ar.md` | — | — | active |
| `de` | Deutsch | `de.ts` | `README.de.md` | yes | — | active |
| `es-ES` | Español (España) | `es-ES.ts` | `README.es.md` | — | — | active |
| `fa` | فارسی | `fa.ts` | — | — | — | active |
| `hu` | Magyar | `hu.ts` | — | — | — | active |
| `ja` | 日本語 | `ja.ts` | `README.ja-JP.md` | yes | — | active |
| `ko` | 한국어 | `ko.ts` | `README.ko.md` | — | — | active |
| `pl` | Polski | `pl.ts` | — | — | — | active |
| `pt-BR` | Português (Brasil) | `pt-BR.ts` | `README.pt-BR.md` | yes | — | active |
| `ru` | Русский | `ru.ts` | `README.ru.md` | — | `content.ru.ts` | active |
| `zh-CN` | 简体中文 | `zh-CN.ts` | `README.zh-CN.md` | yes | — | active |
| `zh-TW` | 繁體中文 | `zh-TW.ts` | `README.zh-TW.md` | — | — | active |
| `fr` | Français | `fr.ts` | `README.fr.md` | yes | `content.fr.ts` | active |
| `uk` | Українська | `uk.ts` | `README.uk.md` | — | — | active |
| `tr` | Türkçe | `tr.ts` | — | — | — | active |
> A locale may ship a UI dict, a root README, core docs, display metadata,
> or any subset of those surfaces. The English locale is the source of
> truth. Runtime lookup falls back to English for missing UI keys, while
> TypeScript requires registered dictionaries to satisfy the full `Dict`
> shape. Partial dictionaries can use `...en` plus translated overrides,
> and reviewers should treat remaining English strings as drift.
## Adding a new locale
1. **Pick a BCP-47 code.** Use the regional form (`pt-BR`, `es-ES`,
`zh-TW`) when the variant matters; the bare code (`fr`, `ru`) when it
doesn't. `pt-BR` and a hypothetical `pt-PT` would coexist as separate
locales — the same precedent applies to `en-US` / `en-GB` if a
contributor wants to maintain both.
2. **Update [`apps/web/src/i18n/types.ts`](apps/web/src/i18n/types.ts):**
- extend the `Locale` union
- append your code to `LOCALES`
- add a `LOCALE_LABEL[<code>]` entry — use the **native name** of the
language (`Deutsch`, `日本語`, not `de`, `ja`)
3. **Create the dictionary** at
`apps/web/src/i18n/locales/<code>.ts` — copy from `en.ts` and
translate the values. Keys must match `en.ts` exactly; missing keys
fall back to English.
4. **Register** your dictionary in
[`apps/web/src/i18n/index.tsx`](apps/web/src/i18n/index.tsx) — both
the import and the map entry:
```ts
import { fr } from './locales/fr';
// ...
const DICTS: Record<Locale, Dict> = {
// ...existing entries
fr,
};
```
5. **(Optional) Translate the root README** — copy `README.md` to
`README.<code>.md`. Repository precedent may use a documentation-region
code that differs from the UI dict code when that is the familiar docs
filename, such as `README.ja-JP.md` with UI locale `ja`, or
`README.es.md` with UI locale `es-ES`. Use OpenCC `s2twp.json` for
zh-CN ↔ zh-TW; use your judgment elsewhere.
6. **Update the language switcher in every root README**
(line ~30 of each root `README*.md`). Match the order used in the
English README and include the same set everywhere. The switcher set is
the set of root README translations, so it may differ from `LOCALES`.
7. **(Optional) Translate core docs** — copy `QUICKSTART.md` and/or
`CONTRIBUTING.md` to the matching docs filename, following existing
examples such as `QUICKSTART.fr.md`, `CONTRIBUTING.pt-BR.md`, and
`CONTRIBUTING.ja-JP.md`. Update links from the translated README to the
translated core docs that exist for that locale.
8. **(Optional) Translate display metadata** in
`apps/web/src/i18n/content*.ts`. Keep this to display-only metadata for
examples, gallery cards, and localized content chrome. Agent-executed
prompts, skill instructions, design systems, and prompt bodies stay in
their source language so prompt QA remains centralized.
9. **Run checks:** `pnpm typecheck` confirms the locale union and `DICTS`
map agree. `pnpm --filter @open-design/web test` covers locale/content
drift tests for the web package.
## Maintaining existing translations
When a PR changes English copy, check which surface changed and update the
matching translated surfaces deliberately:
- **UI chrome:** update `apps/web/src/i18n/locales/en.ts` first, then add
translated values to active locale dictionaries when the PR owns that
refresh. Partial dictionaries may inherit from English with `...en`.
- **Root README:** keep root README language switchers in sync across all
root `README*.md` files. Check badge counts, Quickstart links, supported
agent lists, and release/download links against `README.md` during a
refresh.
- **Core docs:** keep translated `QUICKSTART.*.md` and
`CONTRIBUTING.*.md` aligned with their English source when the locale owns
those docs.
- **Display metadata:** update `apps/web/src/i18n/content*.ts` alongside
`content.ts` when that locale maintains display metadata.
Automated P0 check:
- `pnpm i18n:check` enforces UI locale registration, root README switcher
consistency, and root README links to translated core docs. CI runs this
as a hard-fail check because these are structural issues.
Known current drift to clean up in focused PRs:
- Several translated READMEs lag behind current English badge counts,
supported agent lists, and Quickstart/download links.
## Backport policy
When the English README or UI dict gains new sections/keys, contributors
are **not required** to backport. The English fallback covers missing
keys at runtime. Locale maintainers (volunteers, often the original
author) are encouraged to refresh in a follow-up PR.
**Keep refresh PRs focused: one locale per PR, no mixed feature work.**
### Drift threshold
A locale is considered drifted when **either**:
- **≥20 untranslated UI keys** vs. `en.ts` (today this is checked
manually with a key-diff; a CI warning is tracked as a follow-up — see
[Deferred decisions](#deferred-decisions)), **or**
- **No refresh PR in 6+ months** while the English README or dict has
changed
These are tripwires for moving a locale to **stale** status (below);
they're not auto-rejection rules.
## Stale locales
We don't delete locales. When a locale crosses a drift tripwire above:
1. Add a `⚠️ Stale (last refreshed YYYY-MM)` cell to its row in the
maintained-locales table.
2. Drop a frontmatter comment at the top of the locale's `.ts` file:
```ts
// ⚠️ Stale: last refreshed 2025-09. See TRANSLATIONS.md.
export const fr: Dict = { ... };
```
3. The locale keeps compiling and rendering — readers still get
partially-translated UI, which is better than removing it.
A new contributor can pick it up by submitting a refresh PR; the
markers come off when the drift threshold is back under control.
## Regional terminology
Translations follow the conventions of the target region's tech writing
community. Maintainers trust contributors to make idiomatic choices and
will not gate-keep on style.
### zh-CN ↔ zh-TW glossary
When converting between Simplified and Traditional Chinese, prefer
Taiwan-specific phrasing in zh-TW rather than character-only conversion.
This list grew out of [PR #194](https://github.com/nexu-io/open-design/pull/194)
and is meant as a starting point, not a rulebook.
#### Core terms
Easy mappings — most appear in OpenCC's `s2twp.json` and require no
human judgment:
| English | zh-CN | zh-TW |
| ------------ | ------ | ------- |
| screen | 屏幕 | 螢幕 |
| stack | 栈 | 堆疊 |
| project | 项目 | 專案 |
| software | 软件 | 軟體 |
| video | 视频 | 影片 |
| file | 文件 | 檔案 |
| document | 文档 | 文件 |
| message | 信息 | 訊息 |
| network | 网络 | 網路 |
| database | 数据库 | 資料庫 |
| user | 用户 | 使用者 |
| default | 默认 | 預設 |
| real-time | 实时 | 即時 |
| install | 安装 | 安裝 |
| settings | 设置 | 設定 |
| menu | 菜单 | 選單 |
| compatible | 兼容 | 相容 |
| bind | 绑定 | 綁定 |
| desktop | 桌面端 | 桌面版 |
| mobile | 移动端 | 行動版 |
#### Idiomatic / domain-specific
Mappings that needed human judgment in #194 — OpenCC won't catch them
and they're the **most useful to record** because the next translator
will hit the same choices:
| English / context | zh-CN | zh-TW |
| ------------------------ | --------- | --------- |
| fallback / safety net | 兜底 | 備援 |
| bundle / package up | 捆绑 | 納入 |
| live, dynamic | 活的 | 動態的 |
| plan (noun) | 计划 | 計畫 |
| color palette | 色板 | 色票 |
| spec doc | 规范文件 | 規格文件 |
| course-correction | 介入纠偏 | 介入修正 |
| crash, screw up (slang) | 翻车 | 出包 |
| go viral (slang) | 出圈 | 爆紅 |
**Tooling:** [OpenCC](https://github.com/BYVoid/OpenCC) with `s2twp.json`
handles roughly the Core terms automatically. The Idiomatic table is
where the human review pays off — start there when adapting an existing
zh-CN translation.
Other CJK / RTL glossaries can extend this section as locales mature.
Don't pre-emptively fill empty tables — add a row when a contributor
hits a real terminology choice that future PRs will face.
## Native-speaker review
**Strongly preferred but not blocking.** Maintainers may merge a locale
PR with a `nit` label if no native speaker has reviewed within ~7 days
and CI passes. Subsequent fixes are welcome as separate PRs.
> The 7-day window is a starting point, not a hard policy. Adjust based
> on your locale's contributor availability and the size of the change.
## Deferred decisions
These items are **decided to defer** — the team has agreed not to act
on them now, with rough triggers for revisiting:
- **Translation memory tooling** (Crowdin / Weblate / Lingui). Re-evaluate
once the project hits ~12-15 active locales **or** when contributors
start visibly duplicating effort across PRs.
- **README template-driven generation** (e.g. [NRG](https://github.com/nanolaba/readme-generator),
custom `.src.md` build scripts, All Contributors-style tooling).
Re-evaluate once the project hits ≥15 locales **or** README structural
edits become more frequent than monthly. Discussion in
[#195](https://github.com/nexu-io/open-design/issues/195): template-driven
generation solves the "update line 27 in 10 README variants" brittleness,
but forces a shared structure that today's locale variants intentionally
diverge from (e.g. `README.zh-TW.md`'s "上手體驗" section, the pt-BR /
pt-PT precedent for content-level — not just translation-level —
differences). Worth revisiting once locale voice is more settled or
the manual-update cost grows.
## Open questions
Genuinely undecided — flagged so contributors know they're live design
discussions:
- **Source-of-truth drift CI.** A `pnpm i18n:diff` script that compares
each locale's keys to `en.ts` and warns (not fails) when a locale
exceeds the 20-key drift threshold. Tracked as a follow-up after this
doc lands.
- **README freshness signal.** A small badge or front-matter timestamp
on each `README.<code>.md` could help readers gauge how current a
translation is.
- **Native-speaker review window.** Whether `~7 days` is too short for
smaller language communities — adjust if real data shows otherwise.
If you have an opinion on any of the above, open an issue or comment on
[#195](https://github.com/nexu-io/open-design/issues/195).

51
apps/AGENTS.md Normal file
View File

@@ -0,0 +1,51 @@
# apps/AGENTS.md
Follow the root `AGENTS.md` first. This file only records module-level boundaries for `apps/`.
## Active apps
- `apps/web`: Next.js 16 App Router + React 18 web runtime. Entrypoints live in `apps/web/app/`; the main client shell is `apps/web/src/App.tsx`. During local `tools-dev` web runs, `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to `OD_PORT`.
- `apps/daemon`: Express + SQLite local daemon and `od` bin. It owns REST/SSE APIs, agent CLI spawning, skills, design systems, artifact persistence, static serving, and local data under `.od/`.
- `apps/desktop`: Electron shell. Desktop does not guess the web port; it reads runtime status through sidecar IPC and opens the reported web URL.
- `apps/packaged`: Thin packaged Electron runtime entry. It starts packaged daemon/web sidecars, registers the `od://` entry protocol, and delegates desktop host behavior to `apps/desktop`.
## Daemon layout
- `apps/daemon/src/` contains only daemon app source.
- `apps/daemon/tests/` contains daemon tests.
- `apps/daemon/sidecar/` contains the daemon sidecar entry.
- CLI/agent argument changes or stdout parser changes belong in `apps/daemon/src/agents.ts` and the matching parser tests.
## Test layout
- App tests live in each app's `tests/` directory, sibling to `src/`; preserve source-relative subpaths inside `tests/` when useful.
- Keep app `src/` directories source-only; do not add new `*.test.ts` or `*.test.tsx` files under `src/`.
- `apps/web/tests/` contains web-owned Vitest tests and uses `*.test.ts` / `*.test.tsx`.
- Playwright UI automation belongs in `e2e/ui/`; do not add Playwright suites or UI automation helper scripts under `apps/web`.
## Sidecar awareness
- App business layers must not import sidecar packages or branch on `runtime.mode`, `namespace`, `ipc`, or `source`.
- Keep sidecar awareness in `apps/<app>/sidecar` or the desktop sidecar entry wrapper.
## Packaged runtime
- `apps/nextjs` has been removed; do not restore it.
- Packaged web uses Next.js SSR through the web sidecar; do not put Next output under daemon `OD_RESOURCE_ROOT`.
- Packaged `OD_RESOURCE_ROOT` is only for daemon non-Next read-only resources: `skills/`, `design-systems/`, and `frames/`.
- Packaged data/log/runtime/cache paths must be namespace-scoped and must not depend on daemon or web ports.
- Daemon↔web packaged traffic still uses an HTTP origin/port because Next.js dev server and SSR proxy paths assume HTTP origins; switching to Unix sockets would require patching Next internals. The invariant is that data/log/runtime/cache paths never embed ports.
## Common app commands
```bash
pnpm --filter @open-design/web typecheck
pnpm --filter @open-design/web test
pnpm --filter @open-design/daemon typecheck
pnpm --filter @open-design/daemon test
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop typecheck
pnpm --filter @open-design/desktop build
pnpm --filter @open-design/packaged typecheck
pnpm --filter @open-design/packaged build
```

57
apps/daemon/package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "@open-design/daemon",
"version": "0.4.1",
"private": true,
"type": "module",
"main": "./dist/cli.js",
"types": "./dist/cli.d.ts",
"bin": {
"od": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/cli.d.ts",
"default": "./dist/cli.js"
},
"./package.json": "./package.json",
"./sidecar": {
"types": "./dist/sidecar/index.d.ts",
"default": "./dist/sidecar/index.js"
}
},
"files": [
"dist",
"package.json"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"daemon": "pnpm run build && node dist/cli.js --no-open",
"dev": "pnpm run build && node dist/cli.js --no-open",
"start": "pnpm run build && node dist/cli.js",
"test": "vitest run -c vitest.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"@open-design/contracts": "workspace:*",
"@open-design/platform": "workspace:*",
"@open-design/sidecar": "workspace:*",
"@open-design/sidecar-proto": "workspace:*",
"better-sqlite3": "^12.9.0",
"chokidar": "^5.0.0",
"express": "^4.19.2",
"jszip": "^3.10.1",
"multer": "^1.4.5-lts.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.10",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
},
"engines": {
"node": "~24"
}
}

490
apps/daemon/src/acp.ts Normal file
View File

@@ -0,0 +1,490 @@
// @ts-nocheck
import { spawn } from 'node:child_process';
import path from 'node:path';
const ACP_PROTOCOL_VERSION = 1;
const DEFAULT_TIMEOUT_MS = 15_000;
const DEFAULT_STAGE_TIMEOUT_MS = 180_000;
export function buildAcpSessionNewParams(cwd, { mcpServers } = {}) {
const servers = Array.isArray(mcpServers) ? mcpServers : [];
return {
cwd: path.resolve(cwd),
// MCP is an optional compatibility layer. Default to no MCP servers so ACP
// agents can run through the skill + CLI path without MCP support. Do not
// auto-install or mutate user/global MCP config; callers must pass an
// explicit per-session MCP descriptor when a compatible agent supports it.
// Normalize to the ACP stdio server shape expected by Kimi/Hermes.
mcpServers: servers.map((s) => ({
type: typeof s?.type === 'string' ? s.type : 'stdio',
name: typeof s?.name === 'string' ? s.name : '',
command: typeof s?.command === 'string' ? s.command : '',
args: Array.isArray(s?.args) ? s.args : [],
env: Array.isArray(s?.env) ? s.env : [],
})),
};
}
function sendRpc(writable, id, method, params) {
writable.write(
`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`,
);
}
function sendRpcResult(writable, id, result) {
writable.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
}
function isJsonRpcId(value) {
return typeof value === 'number' || typeof value === 'string';
}
function rpcErrorMessage(raw) {
if (!raw || typeof raw !== 'object' || !raw.error || typeof raw.error !== 'object') {
return '';
}
const message =
typeof raw.error.message === 'string'
? raw.error.message
: typeof raw.error.code === 'number'
? String(raw.error.code)
: 'json-rpc error';
return typeof raw.id === 'number'
? `json-rpc id ${raw.id}: ${message}`
: message;
}
function formatUsage(usage) {
if (!usage || typeof usage !== 'object') return null;
const out = {};
if (typeof usage.inputTokens === 'number') out.input_tokens = usage.inputTokens;
if (typeof usage.outputTokens === 'number') out.output_tokens = usage.outputTokens;
if (typeof usage.cachedReadTokens === 'number') {
out.cached_read_tokens = usage.cachedReadTokens;
}
if (typeof usage.thoughtTokens === 'number') out.thought_tokens = usage.thoughtTokens;
if (typeof usage.totalTokens === 'number') out.total_tokens = usage.totalTokens;
return Object.keys(out).length > 0 ? out : null;
}
function choosePermissionOutcome(options) {
const list = Array.isArray(options) ? options : [];
const approveForSession = list.find((option) => option?.optionId === 'approve_for_session');
if (approveForSession) return 'approve_for_session';
const allowAlways = list.find((option) => option?.kind === 'allow_always');
if (allowAlways?.optionId) return allowAlways.optionId;
const allowOnce = list.find((option) => option?.kind === 'allow_once');
if (allowOnce?.optionId) return allowOnce.optionId;
return null;
}
function normalizeModels(models, defaultModelOption) {
const available = Array.isArray(models?.availableModels) ? models.availableModels : [];
const currentModelId =
typeof models?.currentModelId === 'string' ? models.currentModelId : null;
const seen = new Set([defaultModelOption.id]);
const out = [defaultModelOption];
for (const model of available) {
const id = typeof model?.modelId === 'string' ? model.modelId.trim() : '';
if (!id || seen.has(id)) continue;
seen.add(id);
const name = typeof model?.name === 'string' ? model.name.trim() : '';
const isCurrent = id === currentModelId;
const labelBase = name && name !== id ? `${name} (${id})` : id;
out.push({ id, label: isCurrent ? `${labelBase} • current` : labelBase });
}
return out;
}
export function createJsonLineStream(onMessage) {
let buffer = '';
return {
feed(chunk) {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
onMessage(JSON.parse(trimmed), trimmed);
} catch {
// Ignore non-JSON log lines on stdout.
}
}
},
flush() {
const trimmed = buffer.trim();
buffer = '';
if (!trimmed) return;
try {
onMessage(JSON.parse(trimmed), trimmed);
} catch {
// Ignore trailing non-JSON log lines on stdout.
}
},
};
}
export async function detectAcpModels({
bin,
args,
cwd = process.cwd(),
env = process.env,
timeoutMs = DEFAULT_TIMEOUT_MS,
clientName = 'open-design-detect',
clientVersion = 'runtime-adapter',
defaultModelOption = { id: 'default', label: 'Default (CLI config)' },
}) {
return await new Promise((resolve, reject) => {
const child = spawn(bin, args, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...env },
});
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
let settled = false;
let stderrBuf = '';
let expectedId = 1;
let nextId = 2;
const finish = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
child.stdin.end();
} catch {}
fn(value);
};
const fail = (message) => {
finish(reject, new Error(message));
if (!child.killed) child.kill('SIGTERM');
};
const writeRpc = (id, method, params) => {
try {
sendRpc(child.stdin, id, method, params);
} catch (err) {
fail(`stdin write failed: ${err.message}`);
}
};
const sendSessionNew = () => {
expectedId = nextId;
writeRpc(nextId, 'session/new', buildAcpSessionNewParams(cwd));
nextId += 1;
};
const parser = createJsonLineStream((raw) => {
const rpcErr = rpcErrorMessage(raw);
if (rpcErr) {
// JSON-RPC -32603 "Internal error" during model detection:
// If this is for the current expected-id (initialize/session/new),
// it's a real probe failure — reject immediately.
// Otherwise it's cleanup noise — suppress it.
if (raw.error?.code === -32603 && raw.id !== expectedId) return;
fail(rpcErr);
return;
}
if (raw.id !== expectedId || !raw.result || typeof raw.result !== 'object') return;
if (expectedId === 1) {
sendSessionNew();
return;
}
if (expectedId === 2) {
const models = normalizeModels(raw.result.models, defaultModelOption);
finish(resolve, models);
if (!child.killed) child.kill('SIGTERM');
}
});
child.stdout.on('data', (chunk) => parser.feed(chunk));
child.stdout.on('close', () => parser.flush());
child.stdin.on('error', (err) => fail(`stdin error: ${err.message}`));
child.stderr.on('data', (chunk) => {
stderrBuf = `${stderrBuf}${chunk}`.slice(-16_000);
});
child.on('error', (err) => fail(`spawn failed: ${err.message}`));
child.on('close', (code, signal) => {
parser.flush();
if (!settled) {
const errTail = stderrBuf.trim();
const suffix = errTail ? ` stderr=${errTail}` : '';
fail(`ACP model detection exited code=${code} signal=${signal ?? 'none'}${suffix}`);
}
});
const timer = setTimeout(() => {
fail(`ACP model detection timed out after ${timeoutMs}ms`);
}, timeoutMs);
writeRpc(1, 'initialize', {
protocolVersion: ACP_PROTOCOL_VERSION,
clientCapabilities: { terminal: false },
clientInfo: { name: clientName, version: clientVersion },
});
});
}
export function attachAcpSession({
child,
prompt,
cwd,
model,
mcpServers,
send,
clientName = 'open-design',
clientVersion = 'runtime-adapter',
stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
}) {
const runStartedAt = Date.now();
const effectiveCwd = path.resolve(cwd || process.cwd());
let expectedId = 1;
let nextId = 2;
let promptRequestId = null;
let setModelRequestId = null;
let sessionId = null;
let activeModel = null;
let emittedThinkingStart = false;
let emittedFirstTokenStatus = false;
let finished = false;
let fatal = false;
let stageTimer = null;
const resetStageTimer = (label) => {
clearTimeout(stageTimer);
stageTimer = setTimeout(() => {
fail(`ACP ${label} timed out after ${stageTimeoutMs}ms`);
}, stageTimeoutMs);
};
const clearStageTimer = () => {
clearTimeout(stageTimer);
stageTimer = null;
};
const fail = (message) => {
if (finished) return;
finished = true;
fatal = true;
clearStageTimer();
send('error', { message });
if (!child.killed) child.kill('SIGTERM');
};
const writeRpc = (id, method, params, timeoutLabel) => {
resetStageTimer(timeoutLabel);
try {
sendRpc(child.stdin, id, method, params);
} catch (err) {
fail(`stdin write failed: ${err.message}`);
}
};
const sendPrompt = () => {
promptRequestId = nextId;
expectedId = promptRequestId;
writeRpc(
promptRequestId,
'session/prompt',
{
sessionId,
prompt: [{ type: 'text', text: prompt }],
},
'session/prompt',
);
nextId += 1;
};
const replyPermission = (raw) => {
const optionId = choosePermissionOutcome(raw.params?.options);
if (!optionId || !isJsonRpcId(raw.id)) {
fail(`unhandled ACP permission request: ${JSON.stringify(raw)}`);
return;
}
resetStageTimer('session/request_permission');
try {
sendRpcResult(child.stdin, raw.id, {
outcome: { outcome: 'selected', optionId },
});
} catch (err) {
fail(`stdin write failed: ${err.message}`);
}
};
const parser = createJsonLineStream((raw, rawLine) => {
resetStageTimer('response');
const rpcErr = rpcErrorMessage(raw);
if (rpcErr) {
// After response completion, any late-arriving errors from the agent
// (pipe-broken, cleanup race conditions, etc.) are safe to ignore.
if (finished) return;
// JSON-RPC error handling:
// -32603 "Internal error": unexpected-id errors are cleanup noise — suppress.
// Expected-id errors for session/set_model fall through to the recovery
// block. All others (initialize, session/new, session/prompt) are real
// failures — call fail().
// -32602 "Invalid params": these are real validation failures. Only
// suppress when they match setModelRequestId so the recovery block handles
// them. Any other -32602 (unexpected-id or non-set_model expected-id) is
// a genuine protocol error — call fail().
if (raw.error?.code === -32603 && raw.id !== expectedId) {
return;
}
if (raw.error?.code === -32602 && raw.id !== setModelRequestId) {
fail(rpcErr);
return;
}
if (raw.error?.code === -32603 && raw.id === expectedId) {
if (raw.id === setModelRequestId) {
// Fall through — the recovery block will handle this
} else {
fail(rpcErr);
return;
}
}
if (raw.error?.code === -32602 && raw.id === setModelRequestId) {
// Fall through — the recovery block will handle this
}
}
if (raw.method === 'session/request_permission') {
replyPermission(raw);
return;
}
if (raw.method === 'session/update' && raw.params?.update) {
const update = raw.params.update;
if (update.sessionUpdate === 'agent_thought_chunk') {
const text = update.content?.text;
if (typeof text === 'string' && text.length > 0) {
if (!emittedThinkingStart) {
emittedThinkingStart = true;
send('agent', { type: 'thinking_start' });
}
send('agent', { type: 'thinking_delta', delta: text });
}
return;
}
if (update.sessionUpdate === 'agent_message_chunk') {
const text = update.content?.text;
if (typeof text === 'string' && text.length > 0) {
if (!emittedFirstTokenStatus) {
emittedFirstTokenStatus = true;
send('agent', {
type: 'status',
label: 'streaming',
ttftMs: Date.now() - runStartedAt,
});
}
send('agent', { type: 'text_delta', delta: text });
}
return;
}
return;
}
// Recovery: if session/set_model failed with -32603 or -32602, fall back to
// sending the prompt with the default (already-active) model.
// -32603: agent doesn't support set_model at all (internal error).
// -32602: agent rejects the model ID or set_model params (invalid params).
// This is scoped to the exact set_model request id to avoid
// triggering on prompt or other request failures.
if (
(raw.error?.code === -32603 || raw.error?.code === -32602) &&
raw.id === setModelRequestId &&
promptRequestId === null
) {
setModelRequestId = null;
activeModel = activeModel || 'default';
send('agent', { type: 'status', label: 'model', model: activeModel });
sendPrompt();
return;
}
if (raw.id !== expectedId || !raw.result || typeof raw.result !== 'object') {
return;
}
if (expectedId === 1) {
expectedId = nextId;
writeRpc(
nextId,
'session/new',
buildAcpSessionNewParams(effectiveCwd, { mcpServers }),
'session/new',
);
nextId += 1;
return;
}
if (expectedId === 2) {
sessionId = typeof raw.result.sessionId === 'string' ? raw.result.sessionId : null;
activeModel =
typeof raw.result.models?.currentModelId === 'string'
? raw.result.models.currentModelId
: null;
if (sessionId && activeModel) {
send('agent', { type: 'status', label: 'model', model: activeModel });
}
if (sessionId && model && model !== 'default') {
setModelRequestId = nextId;
expectedId = nextId;
writeRpc(
nextId,
'session/set_model',
{
sessionId,
modelId: model,
},
'session/set_model',
);
nextId += 1;
return;
}
if (!sessionId) {
fail(`invalid session/new response: ${rawLine}`);
return;
}
sendPrompt();
return;
}
if (promptRequestId !== null && raw.id === promptRequestId) {
const usage = formatUsage(raw.result.usage);
if (usage) {
send('agent', {
type: 'usage',
usage,
durationMs: Date.now() - runStartedAt,
});
}
finished = true;
clearStageTimer();
child.stdin.end();
return;
}
if (sessionId && model && model !== 'default' && raw.id === expectedId) {
activeModel = model;
send('agent', { type: 'status', label: 'model', model: activeModel });
sendPrompt();
}
});
child.stdout.on('data', (chunk) => parser.feed(chunk));
child.on('close', () => {
clearStageTimer();
parser.flush();
});
child.on('error', (err) => fail(err.message));
child.stdin.on('error', (err) => fail(`stdin error: ${err.message}`));
writeRpc(1, 'initialize', {
protocolVersion: ACP_PROTOCOL_VERSION,
clientCapabilities: { terminal: false },
clientInfo: { name: clientName, version: clientVersion },
}, 'initialize');
return {
hasFatalError() {
return fatal;
},
};
}

1324
apps/daemon/src/agents.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
// Daemon-backed app preferences (onboarding state, agent/skill/DS selection).
//
// The web frontend pushes non-sensitive preferences here via PUT
// /api/app-config; the daemon persists them to <dataDir>/app-config.json
// (where dataDir defaults to <projectRoot>/.od but follows OD_DATA_DIR when
// set, keeping test and multi-namespace runs isolated).
// This survives browser storage resets and origin changes so onboarding
// and agent selection don't reappear unexpectedly.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
export interface AgentModelPrefs {
model?: string;
reasoning?: string;
}
export type AgentCliEnvPrefs = Record<string, Record<string, string>>;
export interface AppConfigPrefs {
onboardingCompleted?: boolean;
agentId?: string | null;
agentModels?: Record<string, AgentModelPrefs>;
agentCliEnv?: AgentCliEnvPrefs;
skillId?: string | null;
designSystemId?: string | null;
disabledSkills?: string[];
disabledDesignSystems?: string[];
}
const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
'onboardingCompleted',
'agentId',
'agentModels',
'agentCliEnv',
'skillId',
'designSystemId',
'disabledSkills',
'disabledDesignSystems',
] as const);
function configFile(dataDir: string): string {
return path.join(dataDir, 'app-config.json');
}
const AGENT_MODEL_KEYS: ReadonlySet<string> = new Set(['model', 'reasoning']);
const AGENT_CLI_ENV_KEYS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
['claude', new Set(['CLAUDE_CONFIG_DIR'])],
['codex', new Set(['CODEX_HOME'])],
]);
function isValidAgentModelEntry(v: unknown): v is AgentModelPrefs {
if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
const obj = v as Record<string, unknown>;
for (const k of Object.keys(obj)) {
if (!AGENT_MODEL_KEYS.has(k)) return false;
if (obj[k] !== undefined && typeof obj[k] !== 'string') return false;
}
return true;
}
function validateAgentModels(
raw: unknown,
): Record<string, AgentModelPrefs> | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const result: Record<string, AgentModelPrefs> = Object.create(null);
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
if (k === '__proto__' || k === 'constructor') continue;
if (isValidAgentModelEntry(v)) {
result[k] = v;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
function validateAgentCliEnv(raw: unknown): AgentCliEnvPrefs | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const result: AgentCliEnvPrefs = Object.create(null);
for (const [agentId, value] of Object.entries(raw as Record<string, unknown>)) {
if (agentId === '__proto__' || agentId === 'constructor') continue;
const allowed = AGENT_CLI_ENV_KEYS.get(agentId);
if (!allowed || typeof value !== 'object' || value === null || Array.isArray(value)) {
continue;
}
const env: Record<string, string> = Object.create(null);
for (const [envKey, envValue] of Object.entries(value as Record<string, unknown>)) {
if (!allowed.has(envKey)) continue;
if (typeof envValue !== 'string') continue;
const trimmed = envValue.trim();
if (!trimmed) continue;
env[envKey] = trimmed;
}
if (Object.keys(env).length > 0) result[agentId] = env;
}
return Object.keys(result).length > 0 ? result : undefined;
}
export function agentCliEnvForAgent(
prefs: AgentCliEnvPrefs | undefined,
agentId: string,
): Record<string, string> {
if (!prefs || typeof agentId !== 'string') return {};
const env = prefs[agentId];
if (!env || typeof env !== 'object' || Array.isArray(env)) return {};
return { ...env };
}
function applyConfigValue(
target: Record<string, unknown>,
key: keyof AppConfigPrefs,
value: unknown,
): void {
if (key === 'onboardingCompleted') {
if (typeof value === 'boolean') target[key] = value;
return;
}
if (key === 'agentId' || key === 'skillId' || key === 'designSystemId') {
if (typeof value === 'string' || value === null) target[key] = value;
return;
}
if (key === 'agentModels') {
const validated = validateAgentModels(value);
if (validated !== undefined) {
target[key] = validated;
} else {
delete target[key];
}
}
if (key === 'agentCliEnv') {
const validated = validateAgentCliEnv(value);
if (validated !== undefined) {
target[key] = validated;
} else {
delete target[key];
}
}
if (key === 'disabledSkills' || key === 'disabledDesignSystems') {
if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
target[key] = value;
} else {
delete target[key];
}
}
}
function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {
const result: Record<string, unknown> = Object.create(null);
for (const key of Object.keys(obj)) {
if (ALLOWED_KEYS.has(key as keyof AppConfigPrefs)) {
applyConfigValue(result, key as keyof AppConfigPrefs, obj[key]);
}
}
return result as AppConfigPrefs;
}
export async function readAppConfig(dataDir: string): Promise<AppConfigPrefs> {
try {
const raw = await readFile(configFile(dataDir), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return filterAllowedKeys(parsed as Record<string, unknown>);
}
console.warn('[app-config] Invalid shape in config file, returning empty');
return {};
} catch (err: unknown) {
const e = err as { code?: string; name?: string; message?: string };
if (e.code === 'ENOENT') return {};
if (e.name === 'SyntaxError') {
console.error('[app-config] Corrupted JSON, returning empty:', e.message);
return {};
}
throw err;
}
}
// Serialize concurrent writes to the same dataDir so the read-modify-write
// cycle doesn't lose updates when two PUT requests overlap.
const writeLocks = new Map<string, Promise<unknown>>();
export async function writeAppConfig(
dataDir: string,
partial: Record<string, unknown>,
): Promise<AppConfigPrefs> {
const prev = writeLocks.get(dataDir) ?? Promise.resolve();
const task = prev.catch(() => {}).then(() => doWrite(dataDir, partial));
writeLocks.set(dataDir, task);
try {
return await task;
} finally {
if (writeLocks.get(dataDir) === task) writeLocks.delete(dataDir);
}
}
async function doWrite(
dataDir: string,
partial: Record<string, unknown>,
): Promise<AppConfigPrefs> {
const existing = await readAppConfig(dataDir);
const next: Record<string, unknown> = { ...existing };
for (const key of Object.keys(partial)) {
if (!ALLOWED_KEYS.has(key as keyof AppConfigPrefs)) continue;
applyConfigValue(next, key as keyof AppConfigPrefs, partial[key]);
}
const file = configFile(dataDir);
await mkdir(path.dirname(file), { recursive: true });
const tmp = file + '.' + randomBytes(4).toString('hex') + '.tmp';
await writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
await rename(tmp, file);
return next as AppConfigPrefs;
}

View File

@@ -0,0 +1,144 @@
import { readFile, stat } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join, parse as parsePath } from 'node:path';
export const APP_VERSION_FALLBACK = '0.0.0';
// Keep this structurally aligned with `@open-design/contracts` AppVersionInfo.
// Daemon cannot import the package root type directly yet because its NodeNext
// test typecheck follows the contracts source re-exports and requires explicit
// `.js` extensions across that package.
export interface AppVersionInfo {
version: string;
channel: string;
packaged: boolean;
platform: string;
arch: string;
}
interface PackageMetadata {
version?: unknown;
}
export interface ResolveAppVersionInfoOptions {
env?: NodeJS.ProcessEnv | undefined;
packageMetadata?: PackageMetadata | null;
resourcesPath?: string | undefined;
execPath?: string | undefined;
platform?: NodeJS.Platform | undefined;
arch?: NodeJS.Architecture | undefined;
}
export interface ReadAppVersionInfoOptions extends ResolveAppVersionInfoOptions {
packageJsonUrl?: URL | undefined;
}
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
// The compiled daemon ships in two layouts depending on which tsconfig produced
// it: `dist/app-version.js` (rootDir=src, used by the `od` CLI) and
// `dist/src/app-version.js` (rootDir=., used by the packaged sidecar entry).
// A fixed relative path like `../package.json` only points at the daemon
// `package.json` in the first layout — in the sidecar layout it resolves to
// `dist/package.json`, which does not exist, so the version silently falls
// back to `APP_VERSION_FALLBACK`. Walk up from `import.meta.url` until we find
// a real `package.json` so both build outputs (and the TypeScript source
// during `tools-dev`) read the daemon's actual version. Callers that already
// inject the version via `OD_APP_VERSION` (packaged runtime) keep working
// because that env still wins inside `resolveAppVersionInfo`.
async function findNearestPackageJsonUrl(startUrl: URL): Promise<URL | null> {
let currentDir: string;
try {
currentDir = dirname(fileURLToPath(startUrl));
} catch {
return null;
}
const root = parsePath(currentDir).root;
while (true) {
const candidate = join(currentDir, 'package.json');
try {
const stats = await stat(candidate);
if (stats.isFile()) return pathToFileURL(candidate);
} catch {
// try the parent directory
}
if (currentDir === root) return null;
const parent = dirname(currentDir);
if (parent === currentDir) return null;
currentDir = parent;
}
}
function cleanString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
export function isPackagedRuntime({
resourcesPath = processWithResources.resourcesPath,
execPath = process.execPath,
platform = process.platform,
}: Pick<ResolveAppVersionInfoOptions, 'resourcesPath' | 'execPath' | 'platform'> = {}): boolean {
if (cleanString(resourcesPath)) return true;
const normalizedExecPath = cleanString(execPath)?.replace(/\\/g, '/').toLowerCase();
if (!normalizedExecPath) return false;
switch (platform) {
case 'darwin':
return normalizedExecPath.includes('/contents/resources/');
case 'win32':
return normalizedExecPath.includes('/resources/') || normalizedExecPath.includes('/app.asar');
case 'linux':
return normalizedExecPath.includes('/usr/share/')
|| normalizedExecPath.includes('/opt/')
|| normalizedExecPath.includes('/resources/');
default:
return normalizedExecPath.includes('/resources/') || normalizedExecPath.includes('/app.asar');
}
}
export function resolveAppVersionInfo({
env = process.env,
packageMetadata,
resourcesPath,
execPath,
platform = process.platform,
arch = process.arch,
}: ResolveAppVersionInfoOptions = {}): AppVersionInfo {
const packaged = isPackagedRuntime({ resourcesPath, execPath, platform });
const version = cleanString(env.OD_APP_VERSION)
?? cleanString(packageMetadata?.version)
?? APP_VERSION_FALLBACK;
const prereleaseChannel = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/)?.[1]?.split('.')[0] ?? null;
const channel = cleanString(env.OD_RELEASE_CHANNEL)
?? cleanString(env.OD_APP_CHANNEL)
?? prereleaseChannel
?? (packaged ? 'stable' : 'development');
return { version, channel, packaged, platform, arch };
}
async function readPackageMetadata(packageJsonUrl: URL): Promise<PackageMetadata | null> {
try {
const raw = await readFile(packageJsonUrl, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
export async function readCurrentAppVersionInfo({
packageJsonUrl,
packageMetadata,
env,
resourcesPath,
execPath,
platform,
arch,
}: ReadAppVersionInfoOptions = {}): Promise<AppVersionInfo> {
const resolvedUrl = packageJsonUrl ?? await findNearestPackageJsonUrl(new URL(import.meta.url));
const metadata = packageMetadata
?? (resolvedUrl ? await readPackageMetadata(resolvedUrl) : null);
return resolveAppVersionInfo({ env, packageMetadata: metadata, resourcesPath, execPath, platform, arch });
}

View File

@@ -0,0 +1,259 @@
// @ts-nocheck
import path from 'node:path';
const MANIFEST_VERSION = 1;
const MAX_TITLE_LENGTH = 200;
const MAX_ENTRY_LENGTH = 260;
const MAX_SOURCE_SKILL_ID_LENGTH = 128;
const MAX_DESIGN_SYSTEM_ID_LENGTH = 128;
const MAX_SUPPORTING_FILE_LENGTH = 260;
const MAX_SUPPORTING_FILES = 128;
const MAX_METADATA_BYTES = 16 * 1024;
const ALLOWED_KINDS = new Set([
'html',
'deck',
'react-component',
'markdown-document',
'svg',
'diagram',
'code-snippet',
'mini-app',
'design-system',
]);
const ALLOWED_RENDERERS = new Set([
'html',
'deck-html',
'react-component',
'markdown',
'svg',
'diagram',
'code',
'mini-app',
'design-system',
]);
const ALLOWED_EXPORTS = new Set(['html', 'pdf', 'zip', 'pptx', 'jsx', 'md', 'svg', 'txt']);
const ALLOWED_STATUS = new Set(['streaming', 'complete', 'error']);
function isPlainObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function validateBoundedString(value, field, maxLen, { allowEmpty = false } = {}) {
if (typeof value !== 'string') return `${field} must be a string`;
if (!allowEmpty && value.length === 0) return `${field} is required`;
if (value.length > maxLen) return `${field} exceeds max length (${maxLen})`;
return null;
}
function validateSupportingPath(value) {
if (typeof value !== 'string') return 'supportingFiles entries must be strings';
if (value.length === 0) return 'supportingFiles entries cannot be empty';
if (value.length > MAX_SUPPORTING_FILE_LENGTH) {
return `supportingFiles entries exceed max length (${MAX_SUPPORTING_FILE_LENGTH})`;
}
if (/^[A-Za-z]:/.test(value) || value.startsWith('/')) {
return 'supportingFiles cannot contain absolute paths';
}
if (value.includes('\u0000')) return 'supportingFiles cannot contain null bytes';
const normalized = value.replace(/\\/g, '/');
if (normalized.includes('..')) return 'supportingFiles cannot contain traversal segments';
const parts = normalized.split('/').filter(Boolean);
if (parts.length === 0 || parts.some((p) => p === '.' || p === '..')) {
return 'supportingFiles cannot contain traversal segments';
}
return null;
}
export function validateArtifactManifestInput(manifest, entry) {
if (manifest == null) return { ok: true, value: null };
if (!isPlainObject(manifest)) {
return { ok: false, error: 'artifactManifest must be an object' };
}
const kindErr = validateBoundedString(manifest.kind, 'artifactManifest.kind', 64);
if (kindErr) return { ok: false, error: kindErr };
if (!ALLOWED_KINDS.has(manifest.kind)) {
return { ok: false, error: 'artifactManifest.kind is not allowed' };
}
const rendererErr = validateBoundedString(manifest.renderer, 'artifactManifest.renderer', 64);
if (rendererErr) return { ok: false, error: rendererErr };
if (!ALLOWED_RENDERERS.has(manifest.renderer)) {
return { ok: false, error: 'artifactManifest.renderer is not allowed' };
}
if (!Array.isArray(manifest.exports) || manifest.exports.length === 0) {
return { ok: false, error: 'artifactManifest.exports must be a non-empty array' };
}
for (const exp of manifest.exports) {
if (typeof exp !== 'string') {
return { ok: false, error: 'artifactManifest.exports must contain strings' };
}
if (!ALLOWED_EXPORTS.has(exp)) {
return { ok: false, error: `artifactManifest.exports contains unsupported value: ${exp}` };
}
}
if (manifest.status !== undefined) {
if (typeof manifest.status !== 'string') {
return { ok: false, error: 'artifactManifest.status must be a string' };
}
if (!ALLOWED_STATUS.has(manifest.status)) {
return { ok: false, error: 'artifactManifest.status is not allowed' };
}
}
if (manifest.supportingFiles !== undefined) {
if (!Array.isArray(manifest.supportingFiles)) {
return { ok: false, error: 'artifactManifest.supportingFiles must be an array' };
}
if (manifest.supportingFiles.length > MAX_SUPPORTING_FILES) {
return {
ok: false,
error: `artifactManifest.supportingFiles exceeds max items (${MAX_SUPPORTING_FILES})`,
};
}
for (const rel of manifest.supportingFiles) {
const relErr = validateSupportingPath(rel);
if (relErr) return { ok: false, error: relErr };
}
}
if (manifest.title !== undefined) {
const titleErr = validateBoundedString(
manifest.title,
'artifactManifest.title',
MAX_TITLE_LENGTH,
{ allowEmpty: false },
);
if (titleErr) return { ok: false, error: titleErr };
}
if (manifest.sourceSkillId !== undefined) {
const skillErr = validateBoundedString(
manifest.sourceSkillId,
'artifactManifest.sourceSkillId',
MAX_SOURCE_SKILL_ID_LENGTH,
{ allowEmpty: true },
);
if (skillErr) return { ok: false, error: skillErr };
}
if (manifest.designSystemId !== undefined && manifest.designSystemId !== null) {
const dsErr = validateBoundedString(
manifest.designSystemId,
'artifactManifest.designSystemId',
MAX_DESIGN_SYSTEM_ID_LENGTH,
{ allowEmpty: true },
);
if (dsErr) return { ok: false, error: dsErr };
}
if (manifest.metadata !== undefined) {
if (!isPlainObject(manifest.metadata)) {
return { ok: false, error: 'artifactManifest.metadata must be a plain object' };
}
const serialized = JSON.stringify(manifest.metadata);
if (typeof serialized !== 'string') {
return { ok: false, error: 'artifactManifest.metadata must be JSON-serializable' };
}
if (Buffer.byteLength(serialized, 'utf8') > MAX_METADATA_BYTES) {
return {
ok: false,
error: `artifactManifest.metadata exceeds max size (${MAX_METADATA_BYTES} bytes)`,
};
}
}
const safeEntry = typeof entry === 'string' ? entry : '';
if (!safeEntry || safeEntry.length > MAX_ENTRY_LENGTH) {
return { ok: false, error: `artifact entry exceeds max length (${MAX_ENTRY_LENGTH})` };
}
return { ok: true, value: sanitizeManifest(manifest, safeEntry) };
}
export function sanitizeManifest(manifest, entry) {
const now = new Date().toISOString();
return {
version: MANIFEST_VERSION,
kind: manifest.kind,
title: manifest.title || entry,
entry,
renderer: manifest.renderer,
status: ALLOWED_STATUS.has(manifest.status) ? manifest.status : 'complete',
exports: manifest.exports,
supportingFiles: Array.isArray(manifest.supportingFiles)
? manifest.supportingFiles.map((x) => x.replace(/\\/g, '/'))
: undefined,
createdAt: typeof manifest.createdAt === 'string' ? manifest.createdAt : now,
updatedAt: now,
sourceSkillId: manifest.sourceSkillId,
designSystemId: manifest.designSystemId ?? undefined,
metadata: manifest.metadata,
};
}
export function parsePersistedManifest(raw, fallbackEntry) {
try {
const parsed = JSON.parse(raw);
if (!parsed || parsed.version !== MANIFEST_VERSION) return null;
const entry = typeof parsed.entry === 'string' && parsed.entry ? parsed.entry : fallbackEntry;
const result = validateArtifactManifestInput(parsed, entry);
return result.ok ? result.value : null;
} catch {
return null;
}
}
export function inferLegacyManifest(entry) {
const lower = entry.toLowerCase();
const ext = path.extname(lower);
// NOTE: This duplicate heuristic must stay in sync with
// src/artifacts/manifest.ts::inferLegacyManifest() until frontend+daemon
// inference is moved to a shared runtime-safe module.
const isDeck = ext === '.html' && (lower.includes('deck') || lower.includes('slides') || lower.includes('pitch'));
if (ext === '.html' || ext === '.htm') {
return {
version: MANIFEST_VERSION,
kind: isDeck ? 'deck' : 'html',
title: entry,
entry,
renderer: isDeck ? 'deck-html' : 'html',
status: 'complete',
exports: isDeck ? ['html', 'pdf', 'pptx', 'zip'] : ['html', 'pdf', 'zip'],
metadata: { inferred: true },
};
}
if (ext === '.md') {
return {
version: MANIFEST_VERSION,
kind: 'markdown-document',
title: entry,
entry,
renderer: 'markdown',
status: 'complete',
exports: ['md', 'html', 'pdf', 'zip'],
metadata: { inferred: true },
};
}
if (ext === '.svg') {
return {
version: MANIFEST_VERSION,
kind: 'svg',
title: entry,
entry,
renderer: 'svg',
status: 'complete',
exports: ['svg', 'zip'],
metadata: { inferred: true },
};
}
return null;
}

View File

@@ -0,0 +1,145 @@
// @ts-nocheck
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { inflateRawSync } from 'node:zlib';
import { validateProjectPath } from './projects.js';
const EOCD_SIG = 0x06054b50;
const CENTRAL_SIG = 0x02014b50;
const LOCAL_SIG = 0x04034b50;
const MAX_FILES = 500;
const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
const MAX_FILE_BYTES = 25 * 1024 * 1024;
export async function importClaudeDesignZip(zipPath, projectDir) {
const zip = await readFile(zipPath);
const entries = readCentralDirectory(zip);
const files = [];
let totalBytes = 0;
for (const entry of entries) {
if (entry.isDirectory) continue;
if (files.length >= MAX_FILES) throw new Error('zip contains too many files');
const relPath = sanitizeZipPath(entry.name);
if (entry.uncompressedSize > MAX_FILE_BYTES) {
throw new Error(`zip file too large: ${relPath}`);
}
totalBytes += entry.uncompressedSize;
if (totalBytes > MAX_TOTAL_BYTES) throw new Error('zip is too large');
const body = readEntryBody(zip, entry);
if (body.length !== entry.uncompressedSize) {
throw new Error(`zip entry size mismatch: ${relPath}`);
}
files.push({ path: relPath, body });
}
if (files.length === 0) throw new Error('zip contains no files');
const entryFile = chooseEntryFile(files.map((f) => f.path));
if (!entryFile) throw new Error('zip does not contain an HTML file');
await mkdir(projectDir, { recursive: true });
for (const f of files) {
const target = safeJoin(projectDir, f.path);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, f.body);
}
return {
entryFile,
files: files.map((f) => f.path),
};
}
function readCentralDirectory(zip) {
const eocdOffset = findEndOfCentralDirectory(zip);
const entryCount = zip.readUInt16LE(eocdOffset + 10);
const centralSize = zip.readUInt32LE(eocdOffset + 12);
const centralOffset = zip.readUInt32LE(eocdOffset + 16);
if (centralOffset + centralSize > zip.length) {
throw new Error('invalid zip central directory');
}
const entries = [];
let offset = centralOffset;
for (let i = 0; i < entryCount; i += 1) {
if (zip.readUInt32LE(offset) !== CENTRAL_SIG) {
throw new Error('invalid zip central directory entry');
}
const flags = zip.readUInt16LE(offset + 8);
const method = zip.readUInt16LE(offset + 10);
const compressedSize = zip.readUInt32LE(offset + 20);
const uncompressedSize = zip.readUInt32LE(offset + 24);
const nameLen = zip.readUInt16LE(offset + 28);
const extraLen = zip.readUInt16LE(offset + 30);
const commentLen = zip.readUInt16LE(offset + 32);
const localOffset = zip.readUInt32LE(offset + 42);
const name = zip.slice(offset + 46, offset + 46 + nameLen).toString('utf8');
if ((flags & 1) !== 0) throw new Error('encrypted zip entries are not supported');
if (method !== 0 && method !== 8) {
throw new Error(`unsupported zip compression method: ${method}`);
}
entries.push({
name,
method,
compressedSize,
uncompressedSize,
localOffset,
isDirectory: name.endsWith('/'),
});
offset += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
function findEndOfCentralDirectory(zip) {
const min = Math.max(0, zip.length - 0xffff - 22);
for (let i = zip.length - 22; i >= min; i -= 1) {
if (zip.readUInt32LE(i) === EOCD_SIG) return i;
}
throw new Error('invalid zip: missing central directory');
}
function readEntryBody(zip, entry) {
const offset = entry.localOffset;
if (zip.readUInt32LE(offset) !== LOCAL_SIG) {
throw new Error(`invalid zip local header: ${entry.name}`);
}
const nameLen = zip.readUInt16LE(offset + 26);
const extraLen = zip.readUInt16LE(offset + 28);
const bodyStart = offset + 30 + nameLen + extraLen;
const bodyEnd = bodyStart + entry.compressedSize;
if (bodyEnd > zip.length) throw new Error(`zip entry exceeds archive: ${entry.name}`);
const compressed = zip.slice(bodyStart, bodyEnd);
if (entry.method === 0) return Buffer.from(compressed);
return inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize });
}
function sanitizeZipPath(name) {
if (name.includes('\0')) throw new Error('invalid zip file name');
if (/^[A-Za-z]:/.test(name) || name.startsWith('/')) {
throw new Error('absolute zip paths are not allowed');
}
return validateProjectPath(name);
}
function chooseEntryFile(paths) {
const html = paths.filter((p) => /\.html?$/i.test(p));
if (html.length === 0) return null;
const lower = new Map(html.map((p) => [p.toLowerCase(), p]));
return (
lower.get('index.html') ??
html.find((p) => !p.includes('/')) ??
html[0] ??
null
);
}
function safeJoin(root, relPath) {
const target = path.resolve(root, relPath);
if (!target.startsWith(root + path.sep) && target !== root) {
throw new Error('path escapes project dir');
}
return target;
}

View File

@@ -0,0 +1,218 @@
// @ts-nocheck
/**
* Parses Claude Code's `--output-format stream-json --verbose` JSONL stream
* (with or without `--include-partial-messages`) into a small set of
* UI-friendly events. With partial messages on, text arrives as
* `stream_event` deltas; without it (older builds <1.0.86, or any build
* where the flag isn't passed) text arrives only in the final `assistant`
* wrapper. We handle both. The UI only needs to know five things:
*
* - status : high-level lifecycle ("initializing", "requesting",
* "thinking")
* - text_delta : assistant text chunk (gets fed to the artifact parser)
* - thinking_delta: extended-thinking chunk (shown in a collapsed block)
* - tool_use : { id, name, input } (fires when input is complete)
* - tool_result : { tool_use_id, content, is_error }
* - usage : aggregated input/output/cache tokens + cost
*
* Callers give us `onEvent({ type, ...payload })`. We track per-content-block
* state to accumulate partial tool_use input JSON and emit a single
* `tool_use` event when that block stops.
*/
export function createClaudeStreamHandler(onEvent) {
let buffer = '';
// Per-content-block scratch, keyed by `${messageId}:${blockIndex}`.
const blocks = new Map();
// Most recent assistant message id so content_block_* events without an id
// can be attributed correctly.
let currentMessageId = null;
// Message ids that already streamed text via `stream_event` deltas.
// When `--include-partial-messages` is OFF (older Claude Code, e.g. 1.0.84
// pre-flag), no deltas arrive — only the final `assistant` wrapper carries
// text. The fallback below emits that text once, but we must skip it for
// newer builds that already streamed deltas, otherwise the message would
// duplicate.
const textStreamed = new Set();
function blockKey(index) {
return `${currentMessageId ?? 'anon'}:${index}`;
}
function feed(chunk) {
buffer += chunk;
let nl;
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
let obj;
try {
obj = JSON.parse(line);
} catch {
onEvent({ type: 'raw', line });
continue;
}
handleObject(obj);
}
}
function flush() {
const rem = buffer.trim();
buffer = '';
if (!rem) return;
try {
handleObject(JSON.parse(rem));
} catch {
onEvent({ type: 'raw', line: rem });
}
}
function handleObject(obj) {
if (!obj || typeof obj !== 'object') return;
if (obj.type === 'system' && obj.subtype === 'init') {
onEvent({
type: 'status',
label: 'initializing',
model: obj.model ?? null,
sessionId: obj.session_id ?? null,
});
return;
}
if (obj.type === 'system' && obj.subtype === 'status') {
onEvent({ type: 'status', label: obj.status ?? 'working' });
return;
}
if (obj.type === 'stream_event' && obj.event) {
handleStreamEvent(obj.event);
return;
}
// `assistant` messages are the "block finished" signal for the current
// content block. For tool_use blocks whose input finished assembling,
// emit tool_use now with the final parsed input. For text blocks, emit
// the text as a single delta — but only if no streaming deltas already
// covered it (older Claude Code without --include-partial-messages
// delivers text only here; newer builds stream it and would duplicate).
if (obj.type === 'assistant' && obj.message?.content) {
currentMessageId = obj.message.id ?? currentMessageId;
const msgId = obj.message.id ?? null;
const alreadyStreamed = msgId ? textStreamed.has(msgId) : false;
for (const block of obj.message.content) {
if (block.type === 'tool_use') {
onEvent({
type: 'tool_use',
id: block.id,
name: block.name,
input: block.input ?? null,
});
} else if (
!alreadyStreamed &&
block.type === 'text' &&
typeof block.text === 'string' &&
block.text.length > 0
) {
onEvent({ type: 'text_delta', delta: block.text });
} else if (
!alreadyStreamed &&
block.type === 'thinking' &&
typeof block.thinking === 'string' &&
block.thinking.length > 0
) {
onEvent({ type: 'thinking_delta', delta: block.thinking });
}
}
return;
}
// `user` messages in a stream-json transcript are usually tool_result
// wrappers from prior turns.
if (obj.type === 'user' && obj.message?.content) {
for (const block of obj.message.content) {
if (block.type === 'tool_result') {
onEvent({
type: 'tool_result',
toolUseId: block.tool_use_id,
content: stringifyToolResult(block.content),
isError: Boolean(block.is_error),
});
}
}
return;
}
if (obj.type === 'result') {
onEvent({
type: 'usage',
usage: obj.usage ?? null,
costUsd: obj.total_cost_usd ?? null,
durationMs: obj.duration_ms ?? null,
stopReason: obj.stop_reason ?? null,
});
return;
}
}
function handleStreamEvent(ev) {
if (ev.type === 'message_start') {
currentMessageId = ev.message?.id ?? null;
if (typeof ev.ttft_ms === 'number') {
onEvent({ type: 'status', label: 'streaming', ttftMs: ev.ttft_ms });
}
return;
}
if (ev.type === 'content_block_start' && ev.content_block) {
const key = blockKey(ev.index);
const block = ev.content_block;
blocks.set(key, { type: block.type, name: block.name, id: block.id, input: '' });
if (block.type === 'thinking') {
onEvent({ type: 'thinking_start' });
}
return;
}
if (ev.type === 'content_block_delta' && ev.delta) {
const state = blocks.get(blockKey(ev.index));
const delta = ev.delta;
if (delta.type === 'text_delta' && typeof delta.text === 'string') {
if (currentMessageId) textStreamed.add(currentMessageId);
onEvent({ type: 'text_delta', delta: delta.text });
return;
}
if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') {
if (currentMessageId) textStreamed.add(currentMessageId);
onEvent({ type: 'thinking_delta', delta: delta.thinking });
return;
}
if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
if (state && state.type === 'tool_use') {
state.input += delta.partial_json;
}
return;
}
}
if (ev.type === 'content_block_stop') {
blocks.delete(blockKey(ev.index));
return;
}
}
return { feed, flush };
}
function stringifyToolResult(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((c) => (c?.type === 'text' ? c.text : JSON.stringify(c)))
.join('\n');
}
return JSON.stringify(content);
}

557
apps/daemon/src/cli.ts Normal file
View File

@@ -0,0 +1,557 @@
#!/usr/bin/env node
// @ts-nocheck
import { startServer } from './server.js';
import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js';
import { runConnectorsToolCli } from './tools-connectors-cli.js';
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
const argv = process.argv.slice(2);
// ---- Subcommand router ----------------------------------------------------
//
// `od` is two CLIs glued together:
// - default mode: starts the daemon + opens the web UI.
// - `od media …`: a thin client that POSTs to the running daemon. This
// is what the code agent invokes from inside a chat to actually
// produce image / video / audio bytes (the unifying contract).
//
// We dispatch on the first positional argument so flags like --port keep
// working unchanged. Subcommand routing is keyword-based; flags are
// parsed inside each handler.
// Flags accepted by `od media generate`. Whitelisted so a hallucinated
// `--length 5` from the LLM fails fast instead of silently no-op'ing
// while we route a bogus body to the daemon.
//
// Hoisted to the top of the module *before* the subcommand dispatch
// below: top-level `await SUBCOMMAND_MAP[first](rest)` runs runMedia
// synchronously during module evaluation, and runMedia references these
// `const` Sets — leaving them at the bottom of the file would hit the
// TDZ ("Cannot access 'MEDIA_GENERATE_STRING_FLAGS' before
// initialization") and crash every `od media …` invocation.
const MEDIA_GENERATE_STRING_FLAGS = new Set([
'project',
'surface',
'model',
'prompt',
'output',
'aspect',
'length',
'duration',
'voice',
'audio-kind',
'composition-dir',
'image',
'daemon-url',
]);
const MEDIA_GENERATE_BOOLEAN_FLAGS = new Set([
'help',
'h',
]);
const MCP_STRING_FLAGS = new Set([
'daemon-url',
]);
const MCP_BOOLEAN_FLAGS = new Set([
'help',
'h',
]);
const SUBCOMMAND_MAP = {
media: runMedia,
mcp: runMcp,
};
if (argv[0] === 'mcp' && argv[1] === 'live-artifacts') {
try {
const { exitCode } = await runLiveArtifactsMcpServer();
process.exit(exitCode);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`);
process.exit(1);
}
}
const first = argv.find((a) => !a.startsWith('-'));
if (first && SUBCOMMAND_MAP[first]) {
const idx = argv.indexOf(first);
const rest = [...argv.slice(0, idx), ...argv.slice(idx + 1)];
await SUBCOMMAND_MAP[first](rest);
process.exit(0);
}
if (argv[0] === 'tools' && argv[1] === 'live-artifacts') {
runLiveArtifactsToolCli(argv.slice(2))
.then(({ exitCode }) => {
process.exitCode = exitCode;
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`);
process.exitCode = 1;
});
} else if (argv[0] === 'tools' && argv[1] === 'connectors') {
runConnectorsToolCli(argv.slice(2))
.then(({ exitCode }) => {
process.exitCode = exitCode;
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${JSON.stringify({ ok: false, error: { message } })}\n`);
process.exitCode = 1;
});
} else {
// Default: daemon mode.
let port = Number(process.env.OD_PORT) || 7456;
let host = process.env.OD_BIND_HOST || '127.0.0.1';
let open = true;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '-p' || a === '--port') {
port = Number(argv[++i]);
} else if (a === '--host') {
host = argv[++i];
} else if (a === '--no-open') {
open = false;
} else if (a === '-h' || a === '--help') {
printRootHelp();
process.exit(0);
}
}
startServer({ port, host }).then(url => {
console.log(`[od] listening on ${url}`);
if (open) {
const opener = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start'
: 'xdg-open';
import('node:child_process').then(({ spawn }) => {
spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref();
});
}
});
}
function printRootHelp() {
console.log(`Usage:
od [--port <n>] [--host <addr>] [--no-open]
Start the local daemon and open the web UI.
od tools live-artifacts <create|list|update|refresh> [options]
Manage live artifacts through daemon wrapper commands.
od tools connectors <list|execute> [options]
Discover and execute configured connectors.
od mcp live-artifacts
Start the MCP server exposing live-artifact and connector tools.
"$OD_NODE_BIN" "$OD_BIN" tools ...
Recommended agent-runtime form; avoids relying on user PATH for od or node.
od media generate --surface <image|video|audio> --model <id> [opts]
Generate a media artifact and write it into the active project.
Designed to be invoked by a code agent - picks up OD_DAEMON_URL
and OD_PROJECT_ID from the env that the daemon injected on spawn.
od mcp [--daemon-url <url>]
Run a stdio MCP server that proxies read-only tool calls to a
running Open Design daemon. Wire it into a coding agent
(Claude Code, Cursor, VS Code, Zed, Windsurf) in another repo
to pull files from a local Open Design project without
exporting a zip.
Options:
--port <n> Port to listen on (default: 7456, env: OD_PORT).
--host <addr> Interface address to bind to (default: 127.0.0.1, env: OD_BIND_HOST).
Set to a specific IP (e.g. a Tailscale address) to restrict access
to that interface only.
--no-open Do not open the browser after start.
What the daemon does:
* scans PATH for installed code-agent CLIs (claude, codex, devin, gemini, opencode, cursor-agent, ...)
* serves the chat UI at http://<host>:<port>
* proxies messages (text + images) to the selected agent via child-process spawn
* exposes /api/projects/:id/media/generate — the unified image/video/audio
dispatcher that the agent calls via \`od media generate\`.`);
}
// ---------------------------------------------------------------------------
// Subcommand: od media …
// ---------------------------------------------------------------------------
async function runMedia(args) {
const sub = args.find((a) => !a.startsWith('-')) || '';
if (sub === 'help' || sub === '-h' || sub === '--help' || sub === '') {
printMediaHelp();
return;
}
if (sub !== 'generate' && sub !== 'wait') {
console.error(`unknown subcommand: od media ${sub}`);
printMediaHelp();
process.exit(1);
}
const idx = args.indexOf(sub);
const subArgs = [...args.slice(0, idx), ...args.slice(idx + 1)];
if (sub === 'wait') return runMediaWait(subArgs);
return runMediaGenerate(subArgs);
}
async function runMediaGenerate(rawArgs) {
let flags;
try {
flags = parseFlags(rawArgs, {
string: MEDIA_GENERATE_STRING_FLAGS,
boolean: MEDIA_GENERATE_BOOLEAN_FLAGS,
});
} catch (err) {
console.error(err.message);
printMediaHelp();
process.exit(2);
}
const daemonUrl = flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
const projectId = flags.project || process.env.OD_PROJECT_ID;
if (!projectId) {
console.error(
'project id required. Pass --project <id> or set OD_PROJECT_ID. The daemon injects this when it spawns the code agent.',
);
process.exit(2);
}
const surface = flags.surface;
if (!surface || !['image', 'video', 'audio'].includes(surface)) {
console.error('--surface must be one of: image | video | audio');
process.exit(2);
}
if (!flags.model) {
console.error('--model required (see http://<daemon>/api/media/models)');
process.exit(2);
}
const body = {
surface,
model: flags.model,
prompt: flags.prompt,
output: flags.output,
aspect: flags.aspect,
voice: flags.voice,
audioKind: flags['audio-kind'],
compositionDir: flags['composition-dir'],
image: flags.image,
};
if (flags.length != null) body.length = Number(flags.length);
if (flags.duration != null) body.duration = Number(flags.duration);
const url = `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/media/generate`;
let resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
} catch (err) {
surfaceFetchError(err, daemonUrl);
process.exit(3);
}
if (!resp.ok) {
const text = await resp.text();
console.error(`daemon ${resp.status}: ${text}`);
process.exit(4);
}
const accepted = await resp.json();
const { taskId } = accepted;
if (!taskId) {
console.error('daemon did not return a taskId');
process.exit(4);
}
console.error(`task ${taskId} queued (${accepted.status || 'queued'})`);
await pollUntilDoneOrBudget(daemonUrl, taskId, 0);
}
async function runMediaWait(rawArgs) {
const taskId = rawArgs.find((a) => a && !a.startsWith('--'));
if (!taskId) {
console.error('usage: od media wait <taskId> [--since <n>] [--daemon-url <url>]');
process.exit(2);
}
const flagsOnly = rawArgs.filter((a) => a !== taskId);
let flags;
try {
flags = parseFlags(flagsOnly, {
string: new Set(['since', 'daemon-url']),
boolean: new Set(['help', 'h']),
});
} catch (err) {
console.error(err.message);
printMediaHelp();
process.exit(2);
}
const daemonUrl =
flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
const since = Number.isFinite(Number(flags.since))
? Number(flags.since)
: 0;
await pollUntilDoneOrBudget(daemonUrl, taskId, since);
}
async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart) {
const totalBudgetMs = 25_000;
const perCallTimeoutMs = 4_000;
const startedAt = Date.now();
const url = `${daemonUrl.replace(/\/$/, '')}/api/media/tasks/${encodeURIComponent(taskId)}/wait`;
let since = Number.isFinite(sinceStart) ? sinceStart : 0;
let lastSnapshot = null;
while (Date.now() - startedAt < totalBudgetMs) {
const remaining = totalBudgetMs - (Date.now() - startedAt);
const callTimeout = Math.max(500, Math.min(perCallTimeoutMs, remaining));
let resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ since, timeoutMs: callTimeout }),
});
} catch (err) {
surfaceFetchError(err, daemonUrl);
process.exit(3);
}
if (resp.status === 404) {
console.error(`task ${taskId} not found (expired or never queued)`);
process.exit(4);
}
if (!resp.ok) {
const text = await resp.text();
console.error(`daemon ${resp.status}: ${text}`);
process.exit(4);
}
let snap;
try {
snap = await resp.json();
} catch {
console.error('daemon returned non-JSON for /wait');
process.exit(4);
}
lastSnapshot = snap;
if (Array.isArray(snap.progress)) {
for (const line of snap.progress) {
process.stderr.write(line + '\n');
process.stdout.write(`# ${line}\n`);
}
}
if (typeof snap.nextSince === 'number') since = snap.nextSince;
if (snap.status === 'done') {
const file = snap.file || {};
const warnings = Array.isArray(file.warnings) ? file.warnings : [];
for (const w of warnings) {
if (typeof w === 'string' && w) console.error(`WARN: ${w}`);
}
if (file.providerError) {
const provider = file.providerId || 'provider';
console.error(
`WARN: ${provider} call failed — wrote stub fallback (${file.size} bytes) to ${file.name}`,
);
console.error(`WARN: reason: ${file.providerError}`);
console.error(
'WARN: surface this verbatim to the user. Do NOT claim the stub is the final result.',
);
}
process.stdout.write(JSON.stringify({ file }) + '\n');
process.exit(file.providerError ? 5 : 0);
}
if (snap.status === 'failed') {
const msg = snap.error?.message || 'task failed';
console.error(`task failed: ${msg}`);
process.stdout.write(
JSON.stringify({ taskId, status: 'failed', error: snap.error || {} }) + '\n',
);
process.exit(snap.error?.status || 5);
}
}
const handoff = {
taskId,
status: lastSnapshot?.status || 'running',
nextSince: since,
elapsed: Math.round((Date.now() - startedAt) / 1000),
};
process.stdout.write(JSON.stringify(handoff) + '\n');
process.stderr.write(
`task ${taskId} still running after ${handoff.elapsed}s. ` +
`Run \`"$OD_NODE_BIN" "$OD_BIN" media wait ${taskId} --since ${since}\` to continue in an agent runtime ` +
`(exit code 2 = still running).\n`,
);
process.exit(2);
}
function surfaceFetchError(err, daemonUrl) {
const cause = err && typeof err === 'object' ? err.cause : null;
const code =
cause && typeof cause === 'object' && typeof cause.code === 'string'
? cause.code
: null;
const causeMsg =
cause && typeof cause === 'object' && typeof cause.message === 'string'
? cause.message
: '';
let detail = err && err.message ? err.message : String(err);
if (code) detail = `${code}${causeMsg ? `${causeMsg}` : ''}`;
else if (causeMsg) detail = causeMsg;
console.error(`failed to reach daemon at ${daemonUrl}: ${detail}`);
if (code === 'EPERM' || code === 'ENETUNREACH') {
console.error(
'hint: outbound connect was denied by a sandbox. If you launched ' +
'this command from a code agent, check the agent\'s sandbox / ' +
'network policy. The Open Design daemon itself is unaffected - it can be ' +
'reached from a regular shell.',
);
}
}
function parseFlags(argv, opts = {}) {
const stringFlags = opts.string instanceof Set ? opts.string : new Set();
const booleanFlags = opts.boolean instanceof Set ? opts.boolean : new Set();
const knownFlags = new Set([...stringFlags, ...booleanFlags]);
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a || !a.startsWith('--')) {
throw new Error(`unexpected positional argument: ${a}`);
}
const eq = a.indexOf('=');
const key = eq >= 0 ? a.slice(2, eq) : a.slice(2);
if (knownFlags.size > 0 && !knownFlags.has(key)) {
throw new Error(
`unknown flag: --${key}. Run with --help for the list of accepted flags.`,
);
}
if (eq >= 0) {
out[key] = a.slice(eq + 1);
continue;
}
if (booleanFlags.has(key)) {
out[key] = true;
continue;
}
if (stringFlags.has(key)) {
const next = argv[i + 1];
if (next == null) {
throw new Error(`flag --${key} requires a value`);
}
out[key] = next;
i++;
continue;
}
const next = argv[i + 1];
if (next != null && !next.startsWith('--')) {
out[key] = next;
i++;
} else {
out[key] = true;
}
}
return out;
}
function printMediaHelp() {
console.log(`Usage: od media generate --surface <image|video|audio> --model <id> [opts]
"$OD_NODE_BIN" "$OD_BIN" media generate --surface <image|video|audio> --model <id> [opts]
Required:
--surface image | video | audio
--model Model id from /api/media/models (e.g. gpt-image-2, seedance-2, suno-v5).
--project Project id. Auto-resolved from OD_PROJECT_ID when invoked by the daemon.
Common options:
--prompt "<text>" Generation prompt.
--output <filename> File to write under the project. Auto-named if omitted.
--aspect 1:1|16:9|9:16|4:3|3:4
--length <seconds> Video length.
--duration <seconds> Audio duration.
--voice <voice-id> Speech / TTS voice.
--audio-kind music|speech|sfx
--composition-dir <path> hyperframes-html only — project-relative path
to the dir containing hyperframes.json /
meta.json / index.html. The daemon runs
\`npx hyperframes render\` against it.
--image <path> Project-relative path to a reference image
(image-to-video for Seedance i2v models, or
future image-edit endpoints). Daemon reads
the file from the project, base64-encodes
it, and forwards it to the upstream API.
--daemon-url http://127.0.0.1:7456
Output: a single line of JSON: {"file": { name, size, kind, mime, ... }}.
Skills should call this and then reference the returned filename in their
artifact / message body. The daemon writes the bytes into the project's
files folder so the FileViewer can preview them immediately.`);
}
// ---------------------------------------------------------------------------
// Subcommand: od mcp
// ---------------------------------------------------------------------------
async function runMcp(args) {
let flags;
try {
flags = parseFlags(args, {
string: MCP_STRING_FLAGS,
boolean: MCP_BOOLEAN_FLAGS,
});
} catch (err) {
console.error(err.message);
printMcpHelp();
process.exit(2);
}
if (flags.help || flags.h) {
printMcpHelp();
return;
}
const daemonUrl =
flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
const { runMcpStdio } = await import('./mcp.js');
await runMcpStdio({ daemonUrl });
}
function printMcpHelp() {
console.log(`Usage: od mcp [--daemon-url <url>]
Run a stdio MCP (Model Context Protocol) server that proxies read-only
tool calls to a running Open Design daemon. Wire it into a coding agent
in another repo so the agent can pull files from a local Open Design
project without exporting a zip every iteration.
Options:
--daemon-url <url> Open Design daemon HTTP base URL (default: env
OD_DAEMON_URL, falling back to http://127.0.0.1:7456).
Tools exposed:
list_projects list every Open Design project
get_active_context what project/file the user has open right now
get_artifact([project, entry]) bundle: entry file + every referenced sibling
get_project([project]) single project metadata
get_file([project, path]) file contents (textual mimes only for now)
search_files(query[, project]) literal substring search across textual files
list_files([project]) project files + artifactManifest sidecars
When project is omitted, get_artifact / get_project / get_file /
search_files / list_files default to the project the user has open in
Open Design; get_artifact and get_file additionally default to the
active file. The response stamps usedActiveContext so callers can see
which project/file got resolved.
For the copy-paste, per-client snippet (with absolute paths resolved
for your machine, plus a one-click deeplink for Cursor), open Settings
→ MCP server in the Open Design app. Read-only by design; the daemon
must be running locally for tool calls to succeed.`);
}

View File

@@ -0,0 +1,278 @@
// Codex hatch-pet registry. Lists pets that the upstream `hatch-pet`
// skill packages under `${CODEX_HOME:-$HOME/.codex}/pets/<id>/` and the
// curated set bundled with this repo under `assets/community-pets/<id>/`.
//
// On-disk shape (per the hatch-pet `references/codex-pet-contract.md`):
//
// <root>/<id>/
// pet.json # { id, displayName, description, spritesheetPath }
// spritesheet.webp # 1536x1872 8x9 atlas (or .png / .gif fallback)
//
// We scan both folders lazily on every list request — there are only a
// handful of pets in either location, and watching the filesystem would
// add a daemon-side dependency that doesn't pay off here. When the same
// pet id exists in both, the user's local copy wins so re-baking a
// bundled pet locally is a supported workflow.
import { readdir, readFile, stat } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// Pre-scanned set of ids that live under the bundled `assets/community-pets/`
// root. We resolve the `bundled` flag against this set rather than against
// "which folder did we end up reading from", so a pet that exists in BOTH
// the bundled root and the user's `~/.codex/pets/` still surfaces as
// bundled (the sprite content can still come from the user's local copy
// — only the flag is determined by the curated set membership).
type BundledIdSet = Set<string>;
async function readBundledIds(root: string): Promise<BundledIdSet> {
const ids: BundledIdSet = new Set();
let entries: Dirent[] = [];
try {
entries = await readdir(root, { withFileTypes: true, encoding: 'utf8' });
} catch {
return ids;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const safeFolderId = sanitizeId(entry.name);
if (!safeFolderId) continue;
ids.add(safeFolderId);
}
return ids;
}
export interface CodexPetSummaryRecord {
id: string;
displayName: string;
description: string;
spritesheetUrl: string;
spritesheetExt: string;
hatchedAt: number;
// True when the pet was found in the bundled `assets/community-pets/`
// folder rather than the user's `~/.codex/pets/`. Surfaced so the UI
// can render a "Bundled" pill and skip prompting the user to sync
// pets that already ship with the app.
bundled?: boolean;
}
export interface CodexPetListResult {
pets: CodexPetSummaryRecord[];
rootDir: string;
}
interface PetManifest {
id?: unknown;
displayName?: unknown;
description?: unknown;
spritesheetPath?: unknown;
}
interface SpritesheetPick {
absPath: string;
ext: string;
}
export function resolveCodexPetsRoot(): string {
const home = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
return path.join(home, 'pets');
}
const SPRITESHEET_NAMES = [
'spritesheet.webp',
'spritesheet.png',
'spritesheet.gif',
] as const;
// Scan a single root and append summaries to `out`. Pets already in
// `seenIds` are skipped — the user-root scan can therefore preempt a
// bundled pet of the same id without the bundled scan re-emitting a
// duplicate entry with a conflicting `bundled` flag.
//
// `bundledIds` lets us tag a pet as part of the curated set even when
// the sprite content was read from the user's local `~/.codex/pets/`
// copy. Without this, a user who synced every community pet via
// `pnpm sync:community-pets` would always preempt the bundled scan
// and the "Built-in" tab would render empty.
async function scanRoot(
root: string,
baseUrl: string,
bundledFallback: boolean,
bundledIds: BundledIdSet,
out: CodexPetSummaryRecord[],
seenIds: Set<string>,
): Promise<void> {
let entries: Dirent[] = [];
try {
entries = await readdir(root, { withFileTypes: true, encoding: 'utf8' });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// The folder name is the on-disk identity for the pet — the
// `/api/codex-pets/:id/spritesheet` route resolves directly against
// it, so we use the sanitised folder name as the public id even
// when the manifest declares a different `id`. Mirroring the two
// would let a manifest typo (or a pet whose sanitised id differs
// from the folder name) silently 404 the download route.
const safeFolderId = sanitizeId(entry.name);
if (!safeFolderId) continue;
if (seenIds.has(safeFolderId)) continue;
const dir = path.join(root, entry.name);
const manifestPath = path.join(dir, 'pet.json');
let manifest: PetManifest = {};
try {
const raw = await readFile(manifestPath, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
manifest = parsed as PetManifest;
}
} catch {
// Manifest is optional — fall back to folder name for the
// display name so manually-dropped pets still appear.
}
const sheet = await pickSpritesheet(dir, manifest);
if (!sheet) continue;
let mtimeMs = 0;
try {
const st = await stat(sheet.absPath);
mtimeMs = st.mtimeMs;
} catch {
// ignore — listing should not fail on a transient stat error.
}
seenIds.add(safeFolderId);
const displayName = pickString(manifest.displayName) ?? prettyName(entry.name);
const description = pickString(manifest.description) ?? '';
const spritesheetUrl = `${baseUrl}/api/codex-pets/${encodeURIComponent(safeFolderId)}/spritesheet`;
// Curated-set membership wins over the source-folder default — a
// pet read from the user's `~/.codex/pets/` is still bundled if its
// id is part of `assets/community-pets/`.
const bundled = bundledIds.has(safeFolderId) ? true : bundledFallback;
out.push({
id: safeFolderId,
displayName,
description,
spritesheetUrl,
spritesheetExt: sheet.ext,
hatchedAt: Math.floor(mtimeMs),
bundled,
});
}
}
export async function listCodexPets(
options: { baseUrl?: string; bundledRoot?: string } = {},
): Promise<CodexPetListResult> {
const baseUrl = options.baseUrl ?? '';
const userRoot = resolveCodexPetsRoot();
const out: CodexPetSummaryRecord[] = [];
const seen = new Set<string>();
// Resolve the curated set membership up front so the user-root scan
// can stamp `bundled: true` on any local re-bake, and so the
// bundled-root scan only adds the curated pets the user has not
// already shadowed.
const bundledIds = options.bundledRoot
? await readBundledIds(options.bundledRoot)
: new Set<string>();
// User pets first so a locally re-baked copy preempts the bundled
// one (same id ⇒ user wins for sprite content).
await scanRoot(userRoot, baseUrl, false, bundledIds, out, seen);
if (options.bundledRoot) {
await scanRoot(options.bundledRoot, baseUrl, true, bundledIds, out, seen);
}
// Newest-first across both origins. Sorting by mtime keeps the
// "recently hatched" framing in the UI honest — a bundled pet from
// 2024 still sinks below a fresh user-hatched pet from this morning.
out.sort((a, b) => b.hatchedAt - a.hatchedAt);
return { pets: out, rootDir: userRoot };
}
// Returns { absPath, ext } for the resolved spritesheet of a given pet
// id, or null if the pet folder / sheet is missing. Used by the
// `/api/codex-pets/:id/spritesheet` route to safely serve the file —
// the id is sanitised on both sides so users cannot path-escape into
// arbitrary folders under their home directory or the bundled assets.
export async function readCodexPetSpritesheet(
id: string,
options: { bundledRoot?: string } = {},
): Promise<SpritesheetPick | null> {
const safeId = sanitizeId(id);
if (!safeId) return null;
const roots: string[] = [resolveCodexPetsRoot()];
if (options.bundledRoot) roots.push(options.bundledRoot);
for (const root of roots) {
const dir = path.join(root, safeId);
// Re-resolve the manifest so a manifest-declared spritesheetPath wins
// when it differs from our default name (matches the hatch-pet
// contract).
let manifest: PetManifest = {};
try {
const raw = await readFile(path.join(dir, 'pet.json'), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
manifest = parsed as PetManifest;
}
} catch {
// ignore; pickSpritesheet falls back to the canonical names.
}
const sheet = await pickSpritesheet(dir, manifest);
if (sheet) return sheet;
}
return null;
}
async function pickSpritesheet(dir: string, manifest: PetManifest): Promise<SpritesheetPick | null> {
const candidates: string[] = [];
const declaredPath = pickString(manifest.spritesheetPath);
if (declaredPath) {
// Resolve manifest path relative to the pet folder, then ensure it
// does not escape that folder.
const abs = path.resolve(dir, declaredPath);
if (abs.startsWith(dir + path.sep) || abs === dir) {
candidates.push(abs);
}
}
for (const name of SPRITESHEET_NAMES) {
candidates.push(path.join(dir, name));
}
for (const abs of candidates) {
try {
const st = await stat(abs);
if (!st.isFile()) continue;
return { absPath: abs, ext: path.extname(abs).slice(1).toLowerCase() || 'png' };
} catch {
continue;
}
}
return null;
}
// Strip anything that might let a request path-escape, then collapse
// runs of dots and reject any that still contain `..` after trimming —
// the daemon serves these ids straight into a `path.join`, and a value
// like `foo..bar` would otherwise be interpreted as `foo/../bar`.
// Mirrors the pet folder names produced by the upstream skill
// (lowercase + hyphens), but also accepts alphanumerics + a small set
// of safe punctuation to handle pets that users authored manually.
function sanitizeId(value: unknown): string {
const collapsed = String(value ?? '')
.replace(/[^a-zA-Z0-9._-]/g, '')
.replace(/\.+/g, '.')
.replace(/^[._-]+|[._-]+$/g, '')
.slice(0, 80);
if (collapsed.includes('..')) return '';
return collapsed;
}
function pickString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function prettyName(folder: string): string {
return folder.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -0,0 +1,311 @@
// Daemon-side port of `scripts/sync-community-pets.ts`. Downloads pets
// from the public Codex Pet Share + j20 Hatchery catalogs into the
// `${CODEX_HOME:-$HOME/.codex}/pets/` registry that `codex-pets.ts`
// scans. Surfaced via `POST /api/codex-pets/sync` so the web Pet
// settings can offer a one-click refresh of the community catalog.
//
// Kept identical in spirit to the CLI script; tweaks here should be
// mirrored there (and vice versa) until both grow a shared package.
import { mkdir, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { resolveCodexPetsRoot } from './codex-pets.js';
const PETSHARE_BASE = 'https://ihzwckyzfcuktrljwpha.supabase.co/functions/v1/petshare';
const HATCHERY_LIST = 'https://j20.nz/hatchery/api/pets.json';
export interface SyncOptions {
// 'petshare' | 'hatchery' | 'all' — controls which catalogs we hit.
source?: 'petshare' | 'hatchery' | 'all';
// Re-download pets that already have a folder on disk.
force?: boolean;
// Cap the number of pets per source (handy for smoke tests).
limit?: number | null;
// Parallel downloads (defaults to 6).
concurrency?: number;
}
export interface SyncResult {
// How many pets were freshly written to disk.
wrote: number;
// Pets that already had a complete folder and were left alone.
skipped: number;
// Pets that errored during list / download / write.
failed: number;
// Total pets considered after de-duplication across catalogs.
total: number;
// Absolute path of the on-disk pet root we wrote into.
rootDir: string;
// Up to a handful of human-readable error messages — surfaced in the
// UI so users get actionable feedback when a transient catalog hiccup
// breaks an otherwise-good run.
errors: string[];
}
interface PetTask {
source: 'petshare' | 'hatchery';
folder: string;
manifest: Record<string, unknown>;
spritesheetUrl: string;
spritesheetExt: 'webp' | 'png' | 'gif';
}
interface PetShareItem {
id?: string;
displayName?: string;
description?: string;
spritesheetPath?: string;
spritesheetUrl?: string;
ownerName?: string;
tags?: string[];
}
interface PetShareListResponse {
pets?: PetShareItem[];
totalPages?: number;
}
interface HatcheryItem {
id?: string;
petManifestId?: string;
displayName?: string;
description?: string;
spritesheetUrl?: string;
authorLabel?: string;
authorXUrl?: string;
galleryUrl?: string;
}
interface HatcheryListResponse {
pets?: HatcheryItem[];
}
function sanitizeFolder(value: unknown): string {
return String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[._-]+|[._-]+$/g, '')
.slice(0, 80);
}
function extOf(url: string | undefined): 'webp' | 'png' | 'gif' {
const clean = (url || '').split('?')[0] ?? '';
const ext = clean.split('.').pop()?.toLowerCase() ?? 'webp';
if (ext === 'webp' || ext === 'png' || ext === 'gif') return ext;
return 'webp';
}
async function pathExists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}
async function listPetSharePets(limit: number | null): Promise<PetTask[]> {
const tasks: PetTask[] = [];
let page = 1;
const pageSize = 24;
for (;;) {
const url = `${PETSHARE_BASE}/api/pets?page=${page}&pageSize=${pageSize}`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`petshare list page ${page} failed: ${resp.status} ${resp.statusText}`);
}
const data = (await resp.json()) as PetShareListResponse;
for (const pet of data.pets ?? []) {
const folder = sanitizeFolder(pet.id);
if (!folder) continue;
const spritesheetUrl = pet.spritesheetUrl?.startsWith('http')
? pet.spritesheetUrl
: `${PETSHARE_BASE}${pet.spritesheetUrl ?? ''}`;
const ext = extOf(pet.spritesheetPath ?? spritesheetUrl);
tasks.push({
source: 'petshare',
folder,
manifest: {
id: pet.id,
displayName: pet.displayName,
description: pet.description ?? '',
spritesheetPath: `spritesheet.${ext}`,
author: pet.ownerName,
tags: pet.tags ?? [],
source: 'codex-pet-share',
sourceUrl: `https://codex-pet-share.pages.dev/#/pets/${encodeURIComponent(pet.id ?? '')}`,
},
spritesheetUrl,
spritesheetExt: ext,
});
if (limit && tasks.length >= limit) return tasks;
}
if (page >= (data.totalPages ?? 1)) break;
page++;
}
return tasks;
}
async function listHatcheryPets(limit: number | null): Promise<PetTask[]> {
const resp = await fetch(HATCHERY_LIST);
if (!resp.ok) {
throw new Error(`hatchery list failed: ${resp.status} ${resp.statusText}`);
}
const data = (await resp.json()) as HatcheryListResponse;
const tasks: PetTask[] = [];
for (const pet of data.pets ?? []) {
const folder = sanitizeFolder(pet.petManifestId || pet.id);
if (!folder) continue;
if (!pet.spritesheetUrl) continue;
tasks.push({
source: 'hatchery',
folder,
manifest: {
id: pet.petManifestId || pet.id,
displayName: pet.displayName,
description: pet.description ?? '',
spritesheetPath: 'spritesheet.webp',
author: pet.authorLabel,
authorXUrl: pet.authorXUrl,
source: 'j20-hatchery',
sourceUrl: pet.galleryUrl,
},
spritesheetUrl: pet.spritesheetUrl,
spritesheetExt: extOf(pet.spritesheetUrl),
});
if (limit && tasks.length >= limit) break;
}
return tasks;
}
async function downloadBinary(url: string): Promise<Buffer> {
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`download ${url} failed: ${resp.status} ${resp.statusText}`);
}
const ab = await resp.arrayBuffer();
return Buffer.from(ab);
}
async function writePet(
task: PetTask,
outRoot: string,
force: boolean,
): Promise<'wrote' | 'skipped'> {
const dir = path.join(outRoot, task.folder);
const sheetPath = path.join(dir, `spritesheet.${task.spritesheetExt}`);
const manifestPath = path.join(dir, 'pet.json');
if (!force && (await pathExists(sheetPath)) && (await pathExists(manifestPath))) {
return 'skipped';
}
await mkdir(dir, { recursive: true });
const bytes = await downloadBinary(task.spritesheetUrl);
if (bytes.length < 16) {
throw new Error(`${task.folder}: spritesheet too small (${bytes.length} bytes)`);
}
// Reject HTML error pages dressed as `.webp` so the UI doesn't end up
// adopting a pet whose sprite is `<!doctype html>`.
const head = bytes.subarray(0, 12);
const isWebp = head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP';
const isPng = head.toString('hex', 0, 8) === '89504e470d0a1a0a';
const isGif = head.toString('ascii', 0, 6) === 'GIF87a' || head.toString('ascii', 0, 6) === 'GIF89a';
if (!isWebp && !isPng && !isGif) {
throw new Error(`${task.folder}: spritesheet is not webp/png/gif`);
}
await writeFile(sheetPath, bytes);
await writeFile(manifestPath, JSON.stringify(task.manifest, null, 2) + '\n', 'utf8');
return 'wrote';
}
async function runPool<T, R>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let cursor = 0;
const workers = Array.from(
{ length: Math.min(concurrency, items.length) },
async () => {
for (;;) {
const idx = cursor++;
if (idx >= items.length) return;
results[idx] = await worker(items[idx]!, idx);
}
},
);
await Promise.all(workers);
return results;
}
export async function syncCommunityPets(options: SyncOptions = {}): Promise<SyncResult> {
const sourceArg = options.source ?? 'all';
const sources = new Set<'petshare' | 'hatchery'>();
if (sourceArg === 'all' || sourceArg === 'petshare') sources.add('petshare');
if (sourceArg === 'all' || sourceArg === 'hatchery') sources.add('hatchery');
const force = Boolean(options.force);
const limit =
options.limit && Number.isFinite(options.limit) ? Math.max(1, options.limit) : null;
const concurrency =
options.concurrency && Number.isFinite(options.concurrency)
? Math.max(1, options.concurrency)
: 6;
const rootDir = resolveCodexPetsRoot();
await mkdir(rootDir, { recursive: true });
const errors: string[] = [];
const tasks: PetTask[] = [];
if (sources.has('petshare')) {
try {
tasks.push(...(await listPetSharePets(limit)));
} catch (err) {
errors.push((err as Error).message ?? String(err));
}
}
if (sources.has('hatchery')) {
try {
tasks.push(...(await listHatcheryPets(limit)));
} catch (err) {
errors.push((err as Error).message ?? String(err));
}
}
// Earlier sources win when two catalogs publish the same folder name
// — matches the CLI script's de-duplication so a sync from the UI
// produces the same on-disk layout as `pnpm sync:community-pets`.
const dedup = new Map<string, PetTask>();
for (const task of tasks) {
if (!dedup.has(task.folder)) dedup.set(task.folder, task);
}
const unique = Array.from(dedup.values());
let wrote = 0;
let skipped = 0;
let failed = 0;
await runPool(unique, concurrency, async (task) => {
try {
const result = await writePet(task, rootDir, force);
if (result === 'wrote') wrote++;
else skipped++;
} catch (err) {
failed++;
const message = (err as Error).message ?? String(err);
// Cap the surfaced errors so a fully-broken catalog doesn't ship
// a 200KB JSON response; the daemon log keeps the rest.
if (errors.length < 10) errors.push(`${task.folder}: ${message}`);
}
});
return {
wrote,
skipped,
failed,
total: unique.length,
rootDir,
errors,
};
}

View File

@@ -0,0 +1,173 @@
import type { BoundedJsonObject, BoundedJsonValue } from '../live-artifacts/schema.js';
export type ConnectorStatus = 'available' | 'connected' | 'error' | 'disabled';
export type ConnectorToolSideEffect = 'read' | 'write' | 'destructive' | 'unknown';
export type ConnectorToolApproval = 'auto' | 'confirm' | 'disabled';
export interface ConnectorToolSafety {
sideEffect: ConnectorToolSideEffect;
approval: ConnectorToolApproval;
reason: string;
}
export interface ConnectorToolDetail {
name: string;
title: string;
description?: string;
inputSchemaJson?: BoundedJsonObject;
outputSchemaJson?: BoundedJsonObject;
safety: ConnectorToolSafety;
refreshEligible: boolean;
}
export interface ConnectorCatalogToolDefinition extends ConnectorToolDetail {
/** Provider scopes required for this tool. Empty for local/read-only providers. */
requiredScopes: string[];
/** Provider-native tool identifier, when different from the Open Design tool name. */
providerToolId?: string;
}
export interface ConnectorDetail {
id: string;
name: string;
provider: string;
category: string;
description?: string;
status: ConnectorStatus;
accountLabel?: string;
tools: ConnectorToolDetail[];
featuredToolNames?: string[];
minimumApproval?: ConnectorToolApproval;
lastError?: string;
auth?: ConnectorAuthDetail;
}
export interface ConnectorAuthDetail {
provider: 'local' | 'none' | 'oauth' | 'composio';
configured: boolean;
}
export interface ConnectorCatalogDefinition {
id: string;
name: string;
provider: string;
category: string;
description?: string;
tools: ConnectorCatalogToolDefinition[];
/** The complete allowlist of callable tool names for this connector. */
allowedToolNames: string[];
/** How the connector is made available. `none` and `local` connectors require no user OAuth state. */
authentication?: 'local' | 'none' | 'oauth' | 'composio';
/** Provider toolkit slug used by external connector providers such as Composio. */
providerConnectorId?: string;
featuredToolNames?: string[];
minimumApproval?: ConnectorToolApproval;
disabled?: boolean;
}
export interface ConnectorToolSafetyClassificationInput {
name: string;
title?: string;
description?: string;
requiredScopes?: readonly string[];
}
const destructiveHintPattern = /(?:^|[._:\-/\s])(?:destructive|destroy|drop|truncate|purge|erase|wipe|remove-all|remove_all|revoke|reset)(?:$|[._:\-/\s])/i;
const writeHintPattern = /(?:^|[._:\-/\s])(?:write|create|update|delete|admin|send|post|manage)(?:$|[._:\-/\s])/i;
const readOnlyHintPattern = /(?:^|[._:\-/\s])(?:read|readonly|read-only|read_only|get|list|search|fetch|view|query|inspect|summary|status)(?:$|[._:\-/\s])/i;
function connectorToolSafetyHaystack(input: ConnectorToolSafetyClassificationInput): string {
return [input.name, input.title, input.description, ...(input.requiredScopes ?? [])]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.join(' ');
}
export function classifyConnectorToolSafety(input: ConnectorToolSafetyClassificationInput): ConnectorToolSafety {
const haystack = connectorToolSafetyHaystack(input);
if (destructiveHintPattern.test(haystack)) {
return {
sideEffect: 'destructive',
approval: 'disabled',
reason: 'Tool name, scope, or description contains destructive hints; destructive tools are not refreshable.',
};
}
if (writeHintPattern.test(haystack)) {
return {
sideEffect: 'write',
approval: 'confirm',
reason: 'Tool name or required scope indicates write-capable behavior; explicit confirmation is required.',
};
}
if (readOnlyHintPattern.test(haystack)) {
return {
sideEffect: 'read',
approval: 'auto',
reason: 'Tool name, scope, or description indicates explicit read-only behavior.',
};
}
return {
sideEffect: 'write',
approval: 'confirm',
reason: 'Tool safety could not be proven read-only; defaulting to confirmation-required write policy.',
};
}
export function isRefreshEligibleConnectorToolSafety(safety: ConnectorToolSafety): boolean {
return safety.sideEffect === 'read' && safety.approval === 'auto';
}
export function defineConnectorTool(
tool: Omit<ConnectorCatalogToolDefinition, 'safety' | 'refreshEligible'> & {
safety?: ConnectorToolSafety;
refreshEligible?: boolean;
},
): ConnectorCatalogToolDefinition {
const safety = tool.safety ?? classifyConnectorToolSafety(tool);
return {
...tool,
safety,
refreshEligible: tool.refreshEligible ?? isRefreshEligibleConnectorToolSafety(safety),
};
}
function cloneBoundedJsonValue(value: BoundedJsonValue): BoundedJsonValue {
if (Array.isArray(value)) return value.map((item) => cloneBoundedJsonValue(item));
if (value !== null && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneBoundedJsonValue(entry)]));
}
return value;
}
function cloneBoundedJsonObject(value: BoundedJsonObject): BoundedJsonObject {
return cloneBoundedJsonValue(value) as BoundedJsonObject;
}
function toolDefinitionToDetail(tool: ConnectorCatalogToolDefinition): ConnectorToolDetail {
return {
name: tool.name,
title: tool.title,
...(tool.description === undefined ? {} : { description: tool.description }),
...(tool.inputSchemaJson === undefined ? {} : { inputSchemaJson: cloneBoundedJsonObject(tool.inputSchemaJson) }),
...(tool.outputSchemaJson === undefined ? {} : { outputSchemaJson: cloneBoundedJsonObject(tool.outputSchemaJson) }),
safety: { ...tool.safety },
refreshEligible: tool.refreshEligible,
};
}
export function connectorDefinitionToDetail(definition: ConnectorCatalogDefinition): ConnectorDetail {
return {
id: definition.id,
name: definition.name,
provider: definition.provider,
category: definition.category,
...(definition.description === undefined ? {} : { description: definition.description }),
status: definition.disabled ? 'disabled' : 'available',
tools: definition.tools.map((tool) => toolDefinitionToDetail(tool)),
...(definition.featuredToolNames === undefined ? {} : { featuredToolNames: [...definition.featuredToolNames] }),
...(definition.minimumApproval === undefined ? {} : { minimumApproval: definition.minimumApproval }),
auth: {
provider: definition.authentication ?? (definition.provider === 'open-design' ? 'local' : 'oauth'),
configured: definition.authentication === 'local' || definition.authentication === 'none',
},
};
}

View File

@@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
export interface ComposioConfig {
apiKey: string;
}
export interface PublicComposioConfig {
configured: boolean;
apiKeyTail: string;
}
let configFilePath = path.join(process.cwd(), '.od', 'connectors', 'composio-config.json');
export function configureComposioConfigStore(dataDir: string): void {
configFilePath = path.join(dataDir, 'connectors', 'composio-config.json');
}
export function readComposioConfig(): ComposioConfig {
const raw = readRawConfig();
return normalizeComposioConfig(raw);
}
export function readPublicComposioConfig(): PublicComposioConfig {
const config = readComposioConfig();
return {
configured: Boolean(config.apiKey),
apiKeyTail: config.apiKey ? config.apiKey.slice(-4) : '',
};
}
export function writeComposioConfig(input: unknown): PublicComposioConfig {
const prior = readComposioConfig();
const record = input && typeof input === 'object' && !Array.isArray(input)
? input as Record<string, unknown>
: {};
const hasApiKey = Object.prototype.hasOwnProperty.call(record, 'apiKey');
const apiKeyInput = normalizeOptionalString(record.apiKey) ?? '';
const next = normalizeComposioConfig({
apiKey: hasApiKey ? apiKeyInput : prior.apiKey,
});
writeRawConfig(next);
return readPublicComposioConfig();
}
function readRawConfig(): unknown {
try {
return JSON.parse(fs.readFileSync(configFilePath, 'utf8')) as unknown;
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return {};
throw error;
}
}
function writeRawConfig(config: ComposioConfig): void {
fs.mkdirSync(path.dirname(configFilePath), { recursive: true, mode: 0o700 });
const tempPath = `${configFilePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tempPath, configFilePath);
fs.chmodSync(configFilePath, 0o600);
}
function normalizeComposioConfig(value: unknown): ComposioConfig {
const raw = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
return {
apiKey: normalizeOptionalString(raw.apiKey) ?? '',
};
}
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}

View File

@@ -0,0 +1,795 @@
// Curated metadata overrides for Composio toolkits.
//
// The Composio public toolkit list is long and the default description we
// used to ship (`Connect to <name> through Composio.`) is uninformative.
// This module hosts hand-written overrides for the most common toolkits so
// each connector card surfaces an accurate, category-specific description
// and a better category tag than the generic "Composio" bucket.
//
// Keep keys in sync with the slugs in DOCUMENTED_COMPOSIO_TOOLKITS. If a
// toolkit is missing from this map, composio.ts falls back to a neutral
// description generated from the display name.
export interface ComposioToolkitMetadata {
/** Human-authored description tailored to the SaaS/tool. */
description: string;
/** Preferred category tag for the connector card. */
category: string;
}
export const COMPOSIO_TOOLKIT_METADATA: Record<string, ComposioToolkitMetadata> = {
// Developer tooling
GITHUB: {
description:
'Browse repositories, read issues and pull requests, inspect commits, and search code across GitHub.',
category: 'Developer',
},
GITLAB: {
description:
'Inspect GitLab projects, issues, merge requests, and pipelines for engineering workflows.',
category: 'Developer',
},
BITBUCKET: {
description:
'Read Bitbucket repositories, pull requests, and pipelines to feed code-aware artifacts.',
category: 'Developer',
},
LINEAR: {
description:
'Query Linear issues, projects, cycles, and teams to ground planning artifacts in live product data.',
category: 'Project management',
},
JIRA: {
description:
'Search Jira issues, sprints, epics, and boards to build status reports and roadmap artifacts.',
category: 'Project management',
},
CONFLUENCE: {
description:
'Search and read Confluence spaces and pages for internal documentation context.',
category: 'Documentation',
},
SENTRY: {
description:
'Inspect Sentry issues, events, and release health to surface production incidents.',
category: 'Observability',
},
DATADOG: {
description:
'Query Datadog monitors, dashboards, and metrics for live reliability dashboards.',
category: 'Observability',
},
PAGERDUTY: {
description:
'Read PagerDuty incidents, services, and schedules to power on-call runbooks.',
category: 'Observability',
},
DATABRICKS: {
description:
'Access Databricks workspaces, clusters, and SQL warehouses for data-driven artifacts.',
category: 'Data platform',
},
SNOWFLAKE: {
description:
'Run read-only queries against Snowflake warehouses to pull analytics into live artifacts.',
category: 'Data platform',
},
SUPABASE: {
description:
'Inspect Supabase projects, tables, and storage buckets for prototypes grounded in real data.',
category: 'Data platform',
},
CONVEX: {
description: 'Query Convex tables and functions for realtime-backed live artifacts.',
category: 'Data platform',
},
PRISMA: {
description: 'Inspect Prisma schema and data models for database-driven prototypes.',
category: 'Developer',
},
PINECONE: {
description: 'Query Pinecone indexes and namespaces for retrieval-augmented artifacts.',
category: 'AI infrastructure',
},
DIGITAL_OCEAN: {
description: 'Inspect DigitalOcean droplets, databases, and spaces for infra dashboards.',
category: 'Developer',
},
FLY: {
description: 'Read Fly.io apps, machines, and volumes to power infra status artifacts.',
category: 'Developer',
},
APIFY_MCP: {
description: 'Run Apify actors to scrape, crawl, and enrich data for live artifacts.',
category: 'Automation',
},
TAVILY_MCP: {
description: 'Run Tavily web search and extraction for research-grounded artifacts.',
category: 'Research',
},
GRANOLA_MCP: {
description: 'Pull Granola meeting notes and summaries into briefing artifacts.',
category: 'Productivity',
},
TINYFISH_MCP: {
description: 'Run TinyFish browsing agents to capture structured web data into artifacts.',
category: 'Automation',
},
// Productivity / docs
NOTION: {
description:
'Search Notion pages and databases, read page content, and pull structured records into artifacts.',
category: 'Productivity',
},
GOOGLEDOCS: {
description: 'Read Google Docs content and comments to source text for live artifacts.',
category: 'Productivity',
},
GOOGLESHEETS: {
description:
'Read and search Google Sheets spreadsheets to power tables, charts, and dashboards.',
category: 'Spreadsheets',
},
EXCEL: {
description:
'Read Excel workbooks, worksheets, and ranges to pull numbers into live artifacts.',
category: 'Spreadsheets',
},
GOOGLESLIDES: {
description: 'Read Google Slides presentations for reference in new decks.',
category: 'Presentations',
},
GOOGLEDRIVE: {
description: 'Search and read files and folders stored in Google Drive.',
category: 'Storage',
},
DROPBOX: {
description: 'Search and read files stored in Dropbox for document-grounded artifacts.',
category: 'Storage',
},
BOX: {
description: 'Browse and read Box files and folders for enterprise document workflows.',
category: 'Storage',
},
ONE_DRIVE: {
description: 'Search and read files in OneDrive for Microsoft 365 document workflows.',
category: 'Storage',
},
SHARE_POINT: {
description: 'Browse SharePoint sites and lists to pull structured enterprise content.',
category: 'Storage',
},
EGNYTE: {
description: 'Read Egnyte folders and files for regulated document workflows.',
category: 'Storage',
},
GOOGLECALENDAR: {
description: 'Read calendar events and availability from Google Calendar.',
category: 'Calendar',
},
OUTLOOK: {
description: 'Read Outlook mailboxes, calendars, and contacts for Microsoft 365 workflows.',
category: 'Email',
},
GMAIL: {
description: 'Search and read Gmail threads to surface inbox context in artifacts.',
category: 'Email',
},
GOOGLE_CHAT: {
description: 'Read Google Chat spaces and messages for team-comms grounded artifacts.',
category: 'Communication',
},
SLACK: {
description: 'Search Slack channels, read messages, and list users and channels.',
category: 'Communication',
},
SLACKBOT: {
description: 'Use a Slack bot identity to read channels and messages in a workspace.',
category: 'Communication',
},
DISCORD: {
description: 'Read Discord servers, channels, and messages for community analytics.',
category: 'Communication',
},
DISCORDBOT: {
description: 'Use a Discord bot identity to read servers, channels, and messages.',
category: 'Communication',
},
MICROSOFT_TEAMS: {
description: 'Read Microsoft Teams channels, chats, and meetings for workplace context.',
category: 'Communication',
},
WEBEX: {
description: 'Read Webex rooms, messages, and meeting metadata.',
category: 'Communication',
},
ZOOM: {
description: 'Read Zoom meetings, recordings, and participant metadata.',
category: 'Meetings',
},
GOOGLEMEET: {
description: 'Read Google Meet meeting and participant metadata.',
category: 'Meetings',
},
WHATSAPP: {
description: 'Read WhatsApp Business conversations and message metadata.',
category: 'Communication',
},
// Project mgmt / tasks / collaboration
ASANA: {
description: 'Query Asana projects, tasks, and teams for delivery artifacts.',
category: 'Project management',
},
MONDAY: {
description: 'Read monday.com boards, items, and updates.',
category: 'Project management',
},
MONDAY_MCP: {
description: 'Run monday.com actions through the MCP integration.',
category: 'Project management',
},
CLICKUP: {
description: 'Query ClickUp spaces, lists, and tasks for planning artifacts.',
category: 'Project management',
},
TRELLO: {
description: 'Read Trello boards, lists, and cards for kanban-style artifacts.',
category: 'Project management',
},
BASECAMP: {
description: 'Read Basecamp projects, todos, and messages.',
category: 'Project management',
},
WRIKE: {
description: 'Query Wrike folders, tasks, and custom fields.',
category: 'Project management',
},
TODOIST: {
description: 'Read Todoist projects and tasks for personal productivity artifacts.',
category: 'Tasks',
},
TICKTICK: {
description: 'Read TickTick lists and tasks for personal productivity artifacts.',
category: 'Tasks',
},
DART: {
description: 'Query Dart workspaces, tasks, and docs for engineering planning.',
category: 'Project management',
},
PRODUCTBOARD: {
description: 'Read Productboard features, notes, and roadmaps.',
category: 'Product',
},
GOOGLETASKS: {
description: 'Read Google Tasks lists and tasks.',
category: 'Tasks',
},
ROAM: {
description: 'Read Roam Research graphs and pages for networked-note artifacts.',
category: 'Documentation',
},
// Design / whiteboards
FIGMA: {
description:
'Read Figma files, pages, frames, and components to reference real design context.',
category: 'Design',
},
MIRO: {
description: 'Read Miro boards and sticky notes for whiteboard-based artifacts.',
category: 'Whiteboard',
},
MURAL: {
description: 'Read Mural boards and widgets for workshop-grounded artifacts.',
category: 'Whiteboard',
},
CANVA: {
description: 'Read Canva designs and brand assets.',
category: 'Design',
},
MATTERPORT: {
description: 'Read Matterport spaces and captures for 3D-grounded artifacts.',
category: 'Design',
},
// CRM / sales
HUBSPOT: {
description: 'Query HubSpot contacts, companies, deals, and tickets.',
category: 'CRM',
},
SALESFORCE: {
description: 'Query Salesforce objects, reports, and dashboards.',
category: 'CRM',
},
SALESFORCE_SERVICE_CLOUD: {
description: 'Query Salesforce Service Cloud cases, accounts, and knowledge articles.',
category: 'Support',
},
PIPEDRIVE: {
description: 'Read Pipedrive deals, contacts, and activities.',
category: 'CRM',
},
ATTIO: {
description: 'Query Attio lists, records, and attributes for modern CRM workflows.',
category: 'CRM',
},
CAPSULE_CRM: {
description: 'Read Capsule CRM contacts, opportunities, and tasks.',
category: 'CRM',
},
KOMMO: {
description: 'Read Kommo leads, contacts, and pipelines.',
category: 'CRM',
},
ZOHO: {
description: 'Query Zoho CRM modules, records, and reports.',
category: 'CRM',
},
ZOHO_BIGIN: {
description: 'Read Zoho Bigin pipelines, deals, and contacts.',
category: 'CRM',
},
ZOHO_BOOKS: {
description: 'Read Zoho Books invoices, customers, and ledgers.',
category: 'Finance',
},
ZOHO_DESK: {
description: 'Query Zoho Desk tickets, agents, and departments.',
category: 'Support',
},
ZOHO_INVENTORY: {
description: 'Read Zoho Inventory items, orders, and warehouses.',
category: 'Commerce',
},
ZOHO_INVOICE: {
description: 'Read Zoho Invoice invoices, estimates, and customers.',
category: 'Finance',
},
ZOHO_MAIL: {
description: 'Search Zoho Mail folders and messages.',
category: 'Email',
},
FOLLOW_UP_BOSS: {
description: 'Read Follow Up Boss contacts, deals, and activities for real estate CRM.',
category: 'CRM',
},
HIGHLEVEL: {
description: 'Query HighLevel contacts, pipelines, and campaigns.',
category: 'CRM',
},
PARMA: {
description: 'Read Parma personal CRM contacts and interactions.',
category: 'CRM',
},
INSIGHTO_AI: {
description: 'Read Insighto.ai voice agent conversations and analytics.',
category: 'AI agents',
},
LEVER: {
description: 'Query Lever opportunities, candidates, and postings.',
category: 'Recruiting',
},
RECRUITEE: {
description: 'Read Recruitee candidates, jobs, and pipelines.',
category: 'Recruiting',
},
GONG: {
description: 'Read Gong call recordings, transcripts, and sales insights.',
category: 'Sales intelligence',
},
// Support / helpdesk
INTERCOM: {
description: 'Query Intercom conversations, users, and articles.',
category: 'Support',
},
ZENDESK: {
description: 'Read Zendesk tickets, users, and help center articles.',
category: 'Support',
},
GORGIAS: {
description: 'Read Gorgias tickets, customers, and macros for ecommerce support.',
category: 'Support',
},
HELP_SCOUT: {
description: 'Query Help Scout mailboxes, conversations, and customers.',
category: 'Support',
},
SERVICENOW: {
description: 'Read ServiceNow incidents, change requests, and CMDB records.',
category: 'ITSM',
},
FRESHBOOKS: {
description: 'Read FreshBooks invoices, clients, and expenses.',
category: 'Finance',
},
// Finance / accounting / payments
STRIPE: {
description: 'Read Stripe customers, charges, subscriptions, and payouts.',
category: 'Payments',
},
QUICKBOOKS: {
description: 'Query QuickBooks customers, invoices, and accounts.',
category: 'Accounting',
},
XERO: {
description: 'Read Xero invoices, contacts, and ledgers.',
category: 'Accounting',
},
NETSUITE: {
description: 'Query NetSuite records, saved searches, and reports.',
category: 'ERP',
},
RAMP: {
description: 'Read Ramp transactions, cards, and vendors.',
category: 'Finance',
},
BREX: {
description: 'Read Brex transactions, cards, and budgets.',
category: 'Finance',
},
RAZORPAY: {
description: 'Read Razorpay payments, orders, and settlements.',
category: 'Payments',
},
MONEYBIRD: {
description: 'Read Moneybird invoices, contacts, and administrations.',
category: 'Accounting',
},
FREEAGENT: {
description: 'Read FreeAgent invoices, expenses, and timeslips.',
category: 'Accounting',
},
COUPA: {
description: 'Read Coupa suppliers, invoices, and requisitions.',
category: 'Procurement',
},
SPLITWISE: {
description: 'Read Splitwise groups, expenses, and balances.',
category: 'Finance',
},
YNAB: {
description: 'Read YNAB budgets, accounts, and transactions.',
category: 'Finance',
},
BEEMINDER: {
description: 'Read Beeminder goals and datapoints.',
category: 'Personal',
},
// Marketing / ads / email
MAILCHIMP: {
description: 'Read Mailchimp audiences, campaigns, and reports.',
category: 'Marketing',
},
BREVO: {
description: 'Read Brevo contacts, campaigns, and SMS metrics.',
category: 'Marketing',
},
KLAVIYO: {
description: 'Read Klaviyo lists, segments, flows, and campaign metrics.',
category: 'Marketing',
},
OMNISEND: {
description: 'Read Omnisend campaigns, automations, and audiences.',
category: 'Marketing',
},
SENDLOOP: {
description: 'Read Sendloop lists and campaigns.',
category: 'Marketing',
},
KIT: {
description: 'Read Kit (ConvertKit) subscribers, sequences, and broadcasts.',
category: 'Marketing',
},
GOOGLEADS: {
description: 'Read Google Ads campaigns, ad groups, and performance reports.',
category: 'Advertising',
},
METAADS: {
description: 'Read Meta (Facebook/Instagram) Ads campaigns and insights.',
category: 'Advertising',
},
REDDIT_ADS: {
description: 'Read Reddit Ads campaigns and performance.',
category: 'Advertising',
},
LINKEDIN_ADS: {
description: 'Read LinkedIn Ads campaigns, creatives, and analytics.',
category: 'Advertising',
},
GOOGLE_ANALYTICS: {
description: 'Query Google Analytics 4 reports, metrics, and audiences.',
category: 'Analytics',
},
GOOGLE_SEARCH_CONSOLE: {
description: 'Query Google Search Console pages, queries, and performance metrics.',
category: 'Analytics',
},
GOOGLEBIGQUERY: {
description: 'Run read-only BigQuery SQL for analytics-grounded artifacts.',
category: 'Analytics',
},
// Social
LINKEDIN: {
description: 'Read LinkedIn profiles, posts, and company pages.',
category: 'Social',
},
TWITTER: {
description: 'Read Twitter/X timelines, tweets, users, and searches.',
category: 'Social',
},
FACEBOOK: {
description: 'Read Facebook pages, posts, and insights.',
category: 'Social',
},
INSTAGRAM: {
description: 'Read Instagram media, profiles, and insights.',
category: 'Social',
},
REDDIT: {
description: 'Read Reddit subreddits, posts, and comments.',
category: 'Social',
},
TIKTOK: {
description: 'Read TikTok videos, profiles, and analytics.',
category: 'Social',
},
SNAPCHAT: {
description: 'Read Snapchat Ads Manager campaigns and audience insights.',
category: 'Advertising',
},
YOUTUBE: {
description: 'Read YouTube channels, videos, comments, and analytics.',
category: 'Video',
},
SPOTIFY: {
description: 'Read Spotify playlists, tracks, and listener metadata.',
category: 'Media',
},
STRAVA: {
description: 'Read Strava activities, athletes, and segments.',
category: 'Fitness',
},
GUMROAD: {
description: 'Read Gumroad products, sales, and customers.',
category: 'Commerce',
},
DUB: {
description: 'Read Dub links, domains, and analytics.',
category: 'Marketing',
},
EVENTBRITE: {
description: 'Read Eventbrite events, attendees, and orders.',
category: 'Events',
},
TICKETMASTER: {
description: 'Read Ticketmaster events, venues, and attractions.',
category: 'Events',
},
EPIC_GAMES: {
description: 'Read Epic Games store and developer portal data.',
category: 'Gaming',
},
// HR / people
BAMBOOHR: {
description: 'Read BambooHR employees, time off, and directories.',
category: 'HR',
},
GUSTO: {
description: 'Read Gusto employees, payroll runs, and benefits.',
category: 'HR',
},
// Scheduling / signing
CAL: {
description: 'Read Cal.com event types, bookings, and availability.',
category: 'Scheduling',
},
CALENDLY: {
description: 'Read Calendly event types, bookings, and users.',
category: 'Scheduling',
},
SCHEDULEONCE: {
description: 'Read ScheduleOnce bookings, calendars, and event types.',
category: 'Scheduling',
},
CLOCKIFY: {
description: 'Read Clockify time entries, projects, and reports.',
category: 'Time tracking',
},
HARVEST: {
description: 'Read Harvest time entries, projects, and invoices.',
category: 'Time tracking',
},
TIMELY: {
description: 'Read Timely time entries and memories.',
category: 'Time tracking',
},
WAKATIME: {
description: 'Read WakaTime coding time, languages, and projects.',
category: 'Time tracking',
},
FATHOM: {
description: 'Read Fathom call recordings and summaries.',
category: 'Meetings',
},
DIALPAD: {
description: 'Read Dialpad calls, contacts, and rooms.',
category: 'Communication',
},
DOCUSIGN: {
description: 'Read DocuSign envelopes, signers, and templates.',
category: 'Signing',
},
DROPBOX_SIGN: {
description: 'Read Dropbox Sign (HelloSign) signature requests and templates.',
category: 'Signing',
},
BOLDSIGN: {
description: 'Read BoldSign envelopes, templates, and signers.',
category: 'Signing',
},
// Forms / surveys / feedback
TYPEFORM: {
description: 'Read Typeform forms, responses, and analytics.',
category: 'Forms',
},
TALLY: {
description: 'Read Tally forms and submissions.',
category: 'Forms',
},
GOOGLEFORMS: {
description: 'Read Google Forms forms and responses.',
category: 'Forms',
},
SURVEY_MONKEY: {
description: 'Read SurveyMonkey surveys and responses.',
category: 'Surveys',
},
// Content / CMS / data-stores
AIRTABLE: {
description: 'Query Airtable bases, tables, and records for structured data artifacts.',
category: 'Database',
},
CONTENTFUL: {
description: 'Read Contentful content types, entries, and assets.',
category: 'CMS',
},
STORYBLOK: {
description: 'Read Storyblok stories, spaces, and components.',
category: 'CMS',
},
WEBFLOW: {
description: 'Read Webflow sites, collections, and items.',
category: 'CMS',
},
SHOPIFY: {
description: 'Read Shopify products, orders, and customers.',
category: 'Commerce',
},
SQUARE: {
description: 'Read Square payments, catalog, and locations.',
category: 'Payments',
},
SHIPPO: {
description: 'Read Shippo shipments, tracking, and labels.',
category: 'Logistics',
},
LODGIFY: {
description: 'Read Lodgify properties, bookings, and rates.',
category: 'Hospitality',
},
SERVICEM8: {
description: 'Read ServiceM8 jobs, staff, and clients.',
category: 'Field service',
},
// Education / LMS / knowledge
CANVAS: {
description: 'Read Canvas LMS courses, assignments, and submissions.',
category: 'Education',
},
D2LBRIGHTSPACE: {
description: 'Read D2L Brightspace courses, enrollments, and gradebooks.',
category: 'Education',
},
GOOGLE_CLASSROOM: {
description: 'Read Google Classroom courses, coursework, and rosters.',
category: 'Education',
},
BLACKBOARD: {
description: 'Read Blackboard courses, assignments, and users.',
category: 'Education',
},
BLACKBAUD: {
description: 'Read Blackbaud constituents, gifts, and campaigns.',
category: 'Nonprofit',
},
CROWDIN: {
description: 'Read Crowdin projects, strings, and translations.',
category: 'Localization',
},
HUGGING_FACE: {
description: 'Read Hugging Face models, datasets, and spaces metadata.',
category: 'AI infrastructure',
},
YANDEX: {
description: 'Query Yandex services such as search and translate.',
category: 'Search',
},
GOOGLE_MAPS: {
description: 'Query Google Maps places, routes, and geocoding.',
category: 'Maps',
},
GOOGLEPHOTOS: {
description: 'Read Google Photos albums and media metadata.',
category: 'Media',
},
GOOGLECONTACTS: {
description: 'Read Google Contacts people and groups.',
category: 'Contacts',
},
GOOGLE_ADMIN: {
description: 'Read Google Workspace admin directory, users, and groups.',
category: 'Admin',
},
GOOGLESUPER: {
description: 'Unified Google Workspace access across Gmail, Drive, Calendar, and Docs.',
category: 'Productivity',
},
// Security / misc
BITWARDEN: {
description: 'Read Bitwarden organization vaults and metadata (no secret values).',
category: 'Security',
},
BORNEO: {
description: 'Read Borneo data discovery findings and policies.',
category: 'Security',
},
APALEO: {
description: 'Read Apaleo property, reservation, and folio data for hospitality workflows.',
category: 'Hospitality',
},
EXIST: {
description: 'Read Exist personal analytics and correlations.',
category: 'Personal',
},
PUSHBULLET: {
description: 'Read Pushbullet pushes and devices.',
category: 'Personal',
},
STACK_EXCHANGE: {
description: 'Search Stack Exchange questions, answers, and tags across sites.',
category: 'Research',
},
LINKHUT: {
description: 'Read Linkhut bookmarks and tags.',
category: 'Personal',
},
ZOOMINFO: {
description: 'Query ZoomInfo companies, contacts, and intent signals.',
category: 'Sales intelligence',
},
TONEDEN: {
description: 'Read ToneDen campaigns and audiences for music marketing.',
category: 'Marketing',
},
};
/**
* Resolve curated metadata for a toolkit slug. Returns undefined when the
* toolkit has not been manually described yet — callers should fall back
* to a generic description in that case.
*/
export function getComposioToolkitMetadata(slug: string): ComposioToolkitMetadata | undefined {
return COMPOSIO_TOOLKIT_METADATA[slug];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,490 @@
import net from 'node:net';
import type { Express, Request, RequestHandler, Response } from 'express';
import type { ToolTokenGrant } from '../tool-tokens.js';
import { validateBoundedJsonObject } from '../live-artifacts/schema.js';
import { executeConnectorTool, listConnectorTools } from '../tools/connectors.js';
import { connectorService, ConnectorService, ConnectorServiceError } from './service.js';
type ConnectorApiErrorCode =
| 'BAD_REQUEST'
| 'FORBIDDEN'
| 'VALIDATION_FAILED'
| 'CONNECTOR_NOT_FOUND'
| 'CONNECTOR_NOT_CONNECTED'
| 'CONNECTOR_DISABLED'
| 'CONNECTOR_TOOL_NOT_FOUND'
| 'CONNECTOR_SAFETY_DENIED'
| 'CONNECTOR_INPUT_SCHEMA_MISMATCH'
| 'CONNECTOR_RATE_LIMITED'
| 'CONNECTOR_OUTPUT_TOO_LARGE'
| 'CONNECTOR_EXECUTION_FAILED';
export type ConnectorApiErrorSender = (
res: Response,
status: number,
code: ConnectorApiErrorCode,
message: string,
init?: { details?: unknown; retryable?: boolean; requestId?: string; taskId?: string },
) => Response;
export interface RegisterConnectorRoutesOptions {
service?: ConnectorService;
sendApiError: ConnectorApiErrorSender;
projectsRoot?: string;
authorizeToolRequest?: (req: Request, res: Response, operation: string) => ToolTokenGrant | null;
requireLocalDaemonRequest?: RequestHandler;
}
function sendConnectorRouteError(res: Response, err: unknown, sendApiError: ConnectorApiErrorSender): Response {
if (err instanceof ConnectorServiceError) {
return sendApiError(res, err.status, err.code, err.message, err.details === undefined ? {} : { details: err.details });
}
return sendApiError(res, 500, 'CONNECTOR_EXECUTION_FAILED', err instanceof Error ? err.message : String(err));
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
if (normalized === 'localhost') return true;
if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true;
if (normalized.startsWith('::ffff:')) return isLoopbackHostname(normalized.slice('::ffff:'.length));
return net.isIP(normalized) === 4 && (normalized === '127.0.0.1' || normalized.startsWith('127.'));
}
function connectorCallbackUrl(req: Request): string {
const host = req.get('host') ?? 'localhost';
let hostname = 'localhost';
try {
hostname = new URL(`http://${host}`).hostname;
} catch {
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'connector OAuth callback host is invalid', 400, { host });
}
if (!isLoopbackHostname(hostname)) {
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'connector OAuth callback host must be loopback', 400, { host });
}
return `${req.protocol}://${host}/api/connectors/oauth/callback`;
}
function escapeHtml(value: string): string {
return value.replace(/[&<>'"]/g, (char) => {
switch (char) {
case '&':
return '&amp;';
case '<':
return '&lt;';
case '>':
return '&gt;';
case "'":
return '&#39;';
case '"':
return '&quot;';
default:
return char;
}
});
}
function renderConnectorConnectedHtml(connectorId: string): string {
const knownConnectorLabels: Record<string, string> = {
github: 'GitHub',
google_drive: 'Google Drive',
notion: 'Notion',
};
const connectorLabel = connectorId
? knownConnectorLabels[connectorId] ?? connectorId
.split(/[-_\s]+/g)
.filter(Boolean)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(' ')
: 'Connector';
const connectorLabelHtml = escapeHtml(connectorLabel);
const connectorIdJson = JSON.stringify(connectorId);
const connectorLabelJson = JSON.stringify(connectorLabel);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${connectorLabelHtml} connected · Open Design</title>
<style>
:root {
--bg: #faf9f7;
--bg-panel: #ffffff;
--bg-subtle: #f4f2ed;
--border: #ebe8e1;
--border-strong: #d8d4cb;
--text: #1a1916;
--text-strong: #0d0c0a;
--text-muted: #74716b;
--text-soft: #989590;
--accent: #c96442;
--accent-hover: #b45a3b;
--accent-tint: #fbeee5;
--green: #1f7a3a;
--green-bg: #e8f7ee;
--green-border: #c6ead2;
--shadow-xs: 0 1px 0 rgba(28, 27, 26, 0.04);
--shadow-lg: 0 24px 60px rgba(28, 27, 26, 0.16), 0 8px 16px rgba(28, 27, 26, 0.07);
--radius: 10px;
--radius-lg: 14px;
--radius-pill: 999px;
--serif: 'Source Serif Pro', 'Source Serif 4', 'Iowan Old Style', 'Apple Garamond', Georgia, 'Times New Roman', serif;
--sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 32px;
color: var(--text);
background:
radial-gradient(circle at 50% 0%, rgba(201, 100, 66, 0.11), transparent 34rem),
linear-gradient(180deg, #ffffff 0%, var(--bg) 42%, var(--bg) 100%);
font: 13.5px/1.5 var(--sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
main {
width: min(440px, 100%);
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--bg-panel) 96%, transparent);
box-shadow: var(--shadow-lg);
}
.chrome {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.brand-mark {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
color: var(--accent);
background: linear-gradient(135deg, #fbeee5 0%, #f5d8cb 100%);
font-family: var(--serif);
font-size: 11px;
font-weight: 700;
letter-spacing: -0.04em;
}
.brand-title {
font-family: var(--serif);
font-size: 16px;
font-weight: 600;
letter-spacing: -0.015em;
color: var(--text-strong);
}
.content {
display: grid;
gap: 18px;
padding: 34px 30px 30px;
text-align: center;
}
.status-icon {
display: inline-grid;
place-items: center;
justify-self: center;
width: 54px;
height: 54px;
border: 1px solid var(--green-border);
border-radius: 50%;
color: var(--green);
background: var(--green-bg);
box-shadow: var(--shadow-xs);
}
h1 {
margin: 0;
color: var(--text-strong);
font-family: var(--serif);
font-size: clamp(26px, 7vw, 34px);
line-height: 1.05;
letter-spacing: -0.03em;
}
p { margin: 0; color: var(--text-muted); }
.summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-subtle);
text-align: left;
}
.summary-label { display: grid; gap: 2px; min-width: 0; }
.summary-label strong { color: var(--text); font-size: 13px; }
.summary-label span { color: var(--text-soft); font-size: 12px; }
.pill {
flex: 0 0 auto;
padding: 3px 8px;
border: 1px solid color-mix(in srgb, var(--green) 24%, transparent);
border-radius: var(--radius-pill);
color: var(--green);
background: var(--green-bg);
font-size: 11px;
font-weight: 600;
}
button {
justify-self: center;
min-width: 132px;
border: 1px solid var(--accent);
border-radius: 6px;
padding: 8px 14px;
color: white;
background: var(--accent);
box-shadow: 0 1px 0 rgba(180, 90, 59, 0.18) inset, var(--shadow-xs);
font: 500 13px/1.4 var(--sans);
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
}
button:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
button:active { transform: translateY(1px); }
.hint { color: var(--text-soft); font-size: 12px; }
@media (max-width: 480px) {
body { padding: 18px; }
.content { padding: 28px 22px 24px; }
.summary { align-items: flex-start; flex-direction: column; }
}
</style>
</head>
<body>
<main aria-labelledby="callback-title">
<div class="chrome" aria-label="Open Design">
<span class="brand-mark" aria-hidden="true">OD</span>
<span class="brand-title">Open Design</span>
</div>
<section class="content">
<div class="status-icon" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 6.5L9.5 17L4 11.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div>
<h1 id="callback-title">${connectorLabelHtml} connected</h1>
<p>Your connector is ready to use in Open Design.</p>
</div>
<div class="summary" role="status">
<span class="summary-label">
<strong>${connectorLabelHtml}</strong>
<span>Connection synced with the main window</span>
</span>
<span class="pill">Connected</span>
</div>
<button type="button" id="close-window">Close window</button>
<p class="hint" id="auto-close-hint">This popup will close automatically if your browser allows it.</p>
</section>
</main>
<script>
(() => {
const connectorId = ${connectorIdJson};
const connectorLabel = ${connectorLabelJson};
const message = { type: 'open-design:connector-connected', connectorId, connectorLabel };
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, '*');
window.setTimeout(() => window.close(), 900);
} else {
document.getElementById('auto-close-hint').textContent = 'You can close this tab and return to Open Design.';
}
} catch {
document.getElementById('auto-close-hint').textContent = 'You can close this tab and return to Open Design.';
}
document.getElementById('close-window').addEventListener('click', () => window.close());
})();
</script>
</body>
</html>`;
}
export function registerConnectorRoutes(app: Express, options: RegisterConnectorRoutesOptions): void {
const service = options.service ?? connectorService;
const requireLocalDaemonRequest: RequestHandler = options.requireLocalDaemonRequest ?? ((_req, _res, next) => next());
app.get('/api/connectors', async (_req: Request, res: Response) => {
try {
res.json({ connectors: await service.listConnectors() });
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.get('/api/connectors/status', async (_req: Request, res: Response) => {
try {
res.json({ statuses: service.listConnectorStatuses() });
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.get('/api/connectors/discovery', async (req: Request, res: Response) => {
try {
const refresh = typeof req.query.refresh === 'string'
? ['1', 'true', 'yes'].includes(req.query.refresh.toLowerCase())
: false;
res.json(await service.listConnectorDiscovery({ refresh }));
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.get('/api/connectors/:connectorId', async (req: Request, res: Response) => {
try {
const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
res.json({ connector: await service.getConnector(connectorId) });
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.post('/api/connectors/:connectorId/connect', requireLocalDaemonRequest, async (req: Request, res: Response) => {
try {
const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
const body = isPlainObject(req.body) ? req.body : {};
const accountLabel = typeof body.accountLabel === 'string' ? body.accountLabel : undefined;
const credentials = body.credentials === undefined ? undefined : body.credentials;
if (credentials !== undefined && !isPlainObject(credentials)) {
options.sendApiError(res, 400, 'VALIDATION_FAILED', 'credentials must be an object');
return;
}
const definition = await service.getDefinition(connectorId);
if (definition?.authentication === 'composio' && credentials !== undefined) {
options.sendApiError(res, 400, 'VALIDATION_FAILED', 'Composio connector credentials can only be stored through OAuth callback completion');
return;
}
res.json({
...(await service.connect(connectorId, {
...(accountLabel === undefined ? {} : { accountLabel }),
...(credentials === undefined ? {} : { credentials }),
callbackUrl: `${connectorCallbackUrl(req)}/${encodeURIComponent(connectorId)}`,
})),
});
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.get('/api/connectors/oauth/callback/:connectorId', async (req: Request, res: Response) => {
try {
const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
const state = typeof req.query.state === 'string' ? req.query.state : undefined;
if (!state) return options.sendApiError(res, 400, 'BAD_REQUEST', 'state is required');
const providerConnectionId = typeof req.query.connected_account_id === 'string'
? req.query.connected_account_id
: typeof req.query.connection_id === 'string'
? req.query.connection_id
: typeof req.query.account_id === 'string'
? req.query.account_id
: undefined;
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
await service.completeComposioConnection({ connectorId, state, ...(providerConnectionId === undefined ? {} : { providerConnectionId }), ...(status === undefined ? {} : { status }) });
res.type('html').send(renderConnectorConnectedHtml(connectorId));
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.delete('/api/connectors/:connectorId/connection', requireLocalDaemonRequest, async (req: Request, res: Response) => {
try {
const connectorId = req.params.connectorId;
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
res.json({ connector: await service.disconnect(connectorId) });
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.get('/api/tools/connectors/list', async (req: Request, res: Response) => {
try {
if (!options.authorizeToolRequest) {
options.sendApiError(res, 500, 'CONNECTOR_EXECUTION_FAILED', 'connector tool routes are not configured');
return;
}
const grant = options.authorizeToolRequest?.(req, res, 'connectors:list');
if (!grant) return;
const projectId = typeof req.query.projectId === 'string' ? req.query.projectId : undefined;
if (projectId && projectId !== grant.projectId) {
options.sendApiError(res, 403, 'FORBIDDEN', 'projectId is derived from the tool token', {
details: { suppliedProjectId: projectId },
});
return;
}
if (!options.projectsRoot) {
options.sendApiError(res, 500, 'CONNECTOR_EXECUTION_FAILED', 'connector tool routes are not configured');
return;
}
res.json({ connectors: await listConnectorTools({ grant, projectsRoot: options.projectsRoot, service }) });
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
app.post('/api/tools/connectors/execute', async (req: Request, res: Response) => {
try {
if (!options.authorizeToolRequest) {
options.sendApiError(res, 500, 'CONNECTOR_EXECUTION_FAILED', 'connector tool routes are not configured');
return;
}
const grant = options.authorizeToolRequest?.(req, res, 'connectors:execute');
if (!grant) return;
if (!options.projectsRoot) {
options.sendApiError(res, 500, 'CONNECTOR_EXECUTION_FAILED', 'connector tool routes are not configured');
return;
}
const { projectId, connectorId, toolName, input, purpose } = req.body || {};
if (projectId && projectId !== grant.projectId) {
options.sendApiError(res, 403, 'FORBIDDEN', 'projectId is derived from the tool token', {
details: { suppliedProjectId: projectId },
});
return;
}
if (purpose !== undefined && purpose !== 'agent_preview') {
options.sendApiError(res, 403, 'FORBIDDEN', 'connector tool purpose is derived from the tool token', {
details: { suppliedPurpose: purpose },
});
return;
}
if (typeof connectorId !== 'string' || connectorId.length === 0) {
options.sendApiError(res, 400, 'BAD_REQUEST', 'connectorId is required');
return;
}
if (typeof toolName !== 'string' || toolName.length === 0) {
options.sendApiError(res, 400, 'BAD_REQUEST', 'toolName is required');
return;
}
const inputValidation = validateBoundedJsonObject(input ?? {}, 'input');
if (!inputValidation.ok) {
options.sendApiError(res, 400, 'VALIDATION_FAILED', inputValidation.error, {
details: { kind: 'validation', issues: inputValidation.issues },
});
return;
}
const result = await executeConnectorTool(
{ connectorId, toolName, input: inputValidation.value },
{ grant, projectsRoot: options.projectsRoot, service },
);
res.json(result);
} catch (err) {
sendConnectorRouteError(res, err, options.sendApiError);
}
});
}

View File

@@ -0,0 +1,792 @@
import fs from 'node:fs';
import path from 'node:path';
import type { BoundedJsonObject, BoundedJsonValue } from '../live-artifacts/schema.js';
import {
classifyConnectorToolSafety,
connectorDefinitionToDetail,
type ConnectorDetail,
type ConnectorCatalogDefinition,
type ConnectorCatalogToolDefinition,
type ConnectorToolSafety,
type ConnectorStatus,
} from './catalog.js';
import { composioConnectorProvider, getStaticComposioCatalogDefinitions, type ComposioConnectionStart } from './composio.js';
export interface ConnectorExecuteRequest {
connectorId: string;
toolName: string;
input: BoundedJsonObject;
expectedAccountLabel?: string;
}
export interface ConnectorExecuteResponse {
ok: true;
connectorId: string;
accountLabel?: string;
toolName: string;
safety: ConnectorCatalogDefinition['tools'][number]['safety'];
output: BoundedJsonValue;
outputSummary?: string;
metadata?: BoundedJsonObject;
}
export interface ConnectorConnectResult {
connector: ConnectorDetail;
auth?: Pick<ComposioConnectionStart, 'kind' | 'redirectUrl' | 'providerConnectionId' | 'expiresAt'>;
}
type PublicComposioConnectionStart = Pick<ComposioConnectionStart, 'kind' | 'redirectUrl' | 'providerConnectionId' | 'expiresAt'>;
function publicComposioAuthStart(auth: ComposioConnectionStart): PublicComposioConnectionStart {
return {
kind: auth.kind,
...(auth.redirectUrl === undefined ? {} : { redirectUrl: auth.redirectUrl }),
...(auth.providerConnectionId === undefined ? {} : { providerConnectionId: auth.providerConnectionId }),
...(auth.expiresAt === undefined ? {} : { expiresAt: auth.expiresAt }),
};
}
export type ConnectorServiceErrorCode =
| 'CONNECTOR_NOT_FOUND'
| 'CONNECTOR_NOT_CONNECTED'
| 'CONNECTOR_DISABLED'
| 'CONNECTOR_TOOL_NOT_FOUND'
| 'CONNECTOR_SAFETY_DENIED'
| 'CONNECTOR_INPUT_SCHEMA_MISMATCH'
| 'CONNECTOR_RATE_LIMITED'
| 'CONNECTOR_OUTPUT_TOO_LARGE'
| 'CONNECTOR_EXECUTION_FAILED';
export class ConnectorServiceError extends Error {
constructor(
readonly code: ConnectorServiceErrorCode,
message: string,
readonly status: number,
readonly details?: BoundedJsonObject,
) {
super(message);
this.name = 'ConnectorServiceError';
}
}
export interface ConnectorConnectionStatus {
status: ConnectorStatus;
accountLabel?: string;
lastError?: string;
}
export interface ConnectorConnectionRecord extends ConnectorConnectionStatus {
updatedAt: string;
}
export interface ConnectorDiscoveryResult {
connectors: ConnectorDetail[];
meta?: {
provider: 'composio';
refreshRequested?: boolean;
};
}
export type ConnectorCredentialMaterial = Record<string, unknown>;
export interface ConnectorCredentialRecord {
schemaVersion: 1;
connectorId: string;
accountLabel: string;
credentials: ConnectorCredentialMaterial;
updatedAt: string;
}
export interface ConnectorCredentialStore {
get(connectorId: string): ConnectorCredentialRecord | undefined;
set(record: ConnectorCredentialRecord): void;
delete(connectorId: string): void;
deleteByProvider(provider: string): void;
}
export interface ConnectorStatusServiceOptions {
initialStatuses?: Record<string, ConnectorConnectionStatus>;
credentialStore?: ConnectorCredentialStore;
}
const LOCAL_CONNECTOR_ACCOUNT_LABELS: Record<string, string> = {};
function nowIso(): string {
return new Date().toISOString();
}
function cloneCredentialMaterial(credentials: ConnectorCredentialMaterial): ConnectorCredentialMaterial {
return JSON.parse(JSON.stringify(credentials)) as ConnectorCredentialMaterial;
}
export class InMemoryConnectorCredentialStore implements ConnectorCredentialStore {
private readonly records = new Map<string, ConnectorCredentialRecord>();
get(connectorId: string): ConnectorCredentialRecord | undefined {
const record = this.records.get(connectorId);
return record === undefined ? undefined : { ...record, credentials: cloneCredentialMaterial(record.credentials) };
}
set(record: ConnectorCredentialRecord): void {
this.records.set(record.connectorId, { ...record, credentials: cloneCredentialMaterial(record.credentials) });
}
delete(connectorId: string): void {
this.records.delete(connectorId);
}
deleteByProvider(provider: string): void {
for (const [connectorId, record] of this.records.entries()) {
if (record.credentials.provider === provider) this.records.delete(connectorId);
}
}
}
export class FileConnectorCredentialStore implements ConnectorCredentialStore {
private readonly filePath: string;
constructor(dataDir: string) {
this.filePath = path.join(dataDir, 'connectors', 'credentials.json');
}
get(connectorId: string): ConnectorCredentialRecord | undefined {
return this.readRecords()[connectorId];
}
set(record: ConnectorCredentialRecord): void {
const records = this.readRecords();
records[record.connectorId] = { ...record, credentials: cloneCredentialMaterial(record.credentials) };
this.writeRecords(records);
}
delete(connectorId: string): void {
const records = this.readRecords();
if (records[connectorId] === undefined) return;
delete records[connectorId];
this.writeRecords(records);
}
deleteByProvider(provider: string): void {
const records = this.readRecords();
let changed = false;
for (const [connectorId, record] of Object.entries(records)) {
if (record.credentials.provider === provider) {
delete records[connectorId];
changed = true;
}
}
if (changed) this.writeRecords(records);
}
private readRecords(): Record<string, ConnectorCredentialRecord> {
try {
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const records: Record<string, ConnectorCredentialRecord> = {};
for (const [connectorId, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
const raw = value as Record<string, unknown>;
if (raw.schemaVersion !== 1 || raw.connectorId !== connectorId || typeof raw.accountLabel !== 'string' || typeof raw.updatedAt !== 'string') continue;
if (!raw.credentials || typeof raw.credentials !== 'object' || Array.isArray(raw.credentials)) continue;
records[connectorId] = {
schemaVersion: 1,
connectorId,
accountLabel: raw.accountLabel,
credentials: cloneCredentialMaterial(raw.credentials as ConnectorCredentialMaterial),
updatedAt: raw.updatedAt,
};
}
return records;
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return {};
throw error;
}
}
private writeRecords(records: Record<string, ConnectorCredentialRecord>): void {
const dir = path.dirname(this.filePath);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(records, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tempPath, this.filePath);
fs.chmodSync(this.filePath, 0o600);
}
}
function cloneStatus(status: ConnectorConnectionStatus): ConnectorConnectionStatus {
return {
status: status.status,
...(status.accountLabel === undefined ? {} : { accountLabel: status.accountLabel }),
...(status.lastError === undefined ? {} : { lastError: status.lastError }),
};
}
function isAutoConnectedConnector(definition: ConnectorCatalogDefinition): boolean {
const authentication = definition.authentication ?? (definition.provider === 'open-design' ? 'local' : 'oauth');
return (authentication === 'local' || authentication === 'none') && definition.tools.every((tool) => tool.requiredScopes.length === 0);
}
function approvalRank(approval: ConnectorCatalogDefinition['minimumApproval']): number {
switch (approval) {
case 'auto':
return 0;
case 'confirm':
return 1;
case 'disabled':
return 2;
default:
return 2;
}
}
function stricterApproval(
left: ConnectorCatalogDefinition['minimumApproval'] | undefined,
right: ConnectorCatalogDefinition['minimumApproval'] | undefined,
): ConnectorCatalogDefinition['minimumApproval'] | undefined {
if (left === undefined) return right;
if (right === undefined) return left;
return approvalRank(left) >= approvalRank(right) ? left : right;
}
function runtimeSafetyForTool(tool: ConnectorCatalogToolDefinition): ConnectorToolSafety {
const classified = classifyConnectorToolSafety(tool);
if (classified.sideEffect !== 'read' || classified.approval !== 'auto') return classified;
return tool.safety;
}
function assertJsonSchemaMatches(value: BoundedJsonValue, schema: BoundedJsonObject | undefined, path = 'input'): void {
if (schema === undefined) return;
const type = schema.type;
if (typeof type === 'string') {
const actualType = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
if (type === 'number') {
if (typeof value !== 'number') throw new Error(`${path} must be a number`);
} else if (type === 'integer') {
if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`${path} must be an integer`);
} else if (type !== actualType) {
throw new Error(`${path} must be a ${type}`);
}
}
if (type === 'object') {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${path} must be an object`);
const objectValue = value as BoundedJsonObject;
const required = Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === 'string') : [];
for (const key of required) {
if (objectValue[key] === undefined) throw new Error(`${path}.${key} is required by connector input schema`);
}
const properties = schema.properties;
const propertySchemas = properties !== null && typeof properties === 'object' && !Array.isArray(properties)
? properties as Record<string, BoundedJsonObject>
: {};
if (schema.additionalProperties === false) {
for (const key of Object.keys(objectValue)) {
if (propertySchemas[key] === undefined) throw new Error(`${path}.${key} is not allowed by connector input schema`);
}
}
for (const [key, childSchema] of Object.entries(propertySchemas)) {
if (objectValue[key] !== undefined && childSchema !== null && typeof childSchema === 'object' && !Array.isArray(childSchema)) {
assertJsonSchemaMatches(objectValue[key]!, childSchema, `${path}.${key}`);
}
}
}
if (type === 'string' && typeof value === 'string') {
if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) throw new Error(`${path} exceeds connector input schema maxLength`);
}
if ((type === 'number' || type === 'integer') && typeof value === 'number') {
if (typeof schema.minimum === 'number' && value < schema.minimum) throw new Error(`${path} is below connector input schema minimum`);
if (typeof schema.maximum === 'number' && value > schema.maximum) throw new Error(`${path} exceeds connector input schema maximum`);
}
}
function defaultConnectedAccountLabel(definition: ConnectorCatalogDefinition): string {
return LOCAL_CONNECTOR_ACCOUNT_LABELS[definition.id] ?? definition.name;
}
export class ConnectorStatusService {
private readonly statuses = new Map<string, ConnectorConnectionRecord>();
private credentialStore: ConnectorCredentialStore | undefined;
constructor(options: ConnectorStatusServiceOptions = {}) {
this.credentialStore = options.credentialStore;
for (const [connectorId, status] of Object.entries(options.initialStatuses ?? {})) {
this.statuses.set(connectorId, { ...cloneStatus(status), updatedAt: nowIso() });
}
}
setCredentialStore(credentialStore: ConnectorCredentialStore): void {
this.credentialStore = credentialStore;
}
deleteCredentialsByProvider(provider: string): void {
for (const [connectorId, status] of this.statuses.entries()) {
if (status.status !== 'connected') continue;
const credential = this.getCredential(connectorId);
if (credential?.credentials.provider === provider) this.statuses.delete(connectorId);
}
this.credentialStore?.deleteByProvider(provider);
}
getStatus(definition: ConnectorCatalogDefinition): ConnectorConnectionStatus {
if (definition.disabled) return { status: 'disabled' };
const stored = this.statuses.get(definition.id);
if (stored) return cloneStatus(stored);
const credentialRecord = this.getCredential(definition.id);
if (credentialRecord !== undefined) {
return { status: 'connected', accountLabel: credentialRecord.accountLabel };
}
if (isAutoConnectedConnector(definition)) {
return { status: 'connected', accountLabel: defaultConnectedAccountLabel(definition) };
}
return { status: 'available' };
}
listStatuses(): Record<string, ConnectorConnectionStatus> {
return Object.fromEntries(
Array.from(this.statuses.entries()).map(([connectorId, status]) => [connectorId, cloneStatus(status)]),
);
}
connect(definition: ConnectorCatalogDefinition, accountLabel?: string, credentials?: ConnectorCredentialMaterial): ConnectorConnectionStatus {
if (definition.disabled) return { status: 'disabled' };
if (credentials !== undefined) {
this.credentialStore?.set({
schemaVersion: 1,
connectorId: definition.id,
accountLabel: accountLabel ?? defaultConnectedAccountLabel(definition),
credentials,
updatedAt: nowIso(),
});
}
const next: ConnectorConnectionRecord = {
status: 'connected',
accountLabel: accountLabel ?? defaultConnectedAccountLabel(definition),
updatedAt: nowIso(),
};
this.statuses.set(definition.id, next);
return cloneStatus(next);
}
getCredential(connectorId: string): ConnectorCredentialRecord | undefined {
return this.credentialStore?.get(connectorId);
}
disconnect(definition: ConnectorCatalogDefinition): ConnectorConnectionStatus {
if (definition.disabled) return { status: 'disabled' };
this.credentialStore?.delete(definition.id);
if (isAutoConnectedConnector(definition)) {
this.statuses.delete(definition.id);
return this.getStatus(definition);
}
const next: ConnectorConnectionRecord = { status: 'available', updatedAt: nowIso() };
this.statuses.set(definition.id, next);
return cloneStatus(next);
}
setError(definition: ConnectorCatalogDefinition, lastError: string, accountLabel?: string): ConnectorConnectionStatus {
if (definition.disabled) return { status: 'disabled' };
const next: ConnectorConnectionRecord = {
status: 'error',
...(accountLabel === undefined ? {} : { accountLabel }),
lastError,
updatedAt: nowIso(),
};
this.statuses.set(definition.id, next);
return cloneStatus(next);
}
clear(connectorId: string): void {
this.statuses.delete(connectorId);
}
}
export interface ConnectorExecutionContext {
projectsRoot: string;
projectId: string;
runId?: string;
purpose?: 'agent_preview' | 'artifact_refresh';
signal?: AbortSignal;
}
export const CONNECTOR_MAX_OUTPUT_BYTES = 256 * 1024;
export const CONNECTOR_RUN_RATE_LIMIT_CALLS = 10;
export const CONNECTOR_RUN_RATE_LIMIT_WINDOW_MS = 60_000;
export const CONNECTOR_RUN_LIMIT_TTL_MS = 15 * 60_000;
export const CONNECTOR_RUN_TOTAL_CALL_LIMIT = 60;
const CONNECTOR_REDACTED_VALUE = '[redacted]';
const CONNECTOR_FORBIDDEN_OUTPUT_KEYS = new Set([
'raw',
'rawresponse',
'payload',
'body',
'headers',
'cookie',
'authorization',
'token',
'secret',
'credential',
'password',
]);
interface ConnectorRunLimitState {
windowStartedAt: number;
lastSeenAt: number;
windowCalls: number;
totalCalls: number;
}
export interface ConnectorOutputProtectionResult {
output: BoundedJsonValue;
redacted: boolean;
serializedBytes: number;
}
function connectorRunLimitKey(context: ConnectorExecutionContext): string {
return `${context.projectId}\0${context.runId ?? `${context.purpose ?? 'agent_preview'}:no-run-id`}`;
}
function jsonSerializedBytes(value: BoundedJsonValue): number {
return Buffer.byteLength(JSON.stringify(value), 'utf8');
}
function isForbiddenConnectorOutputKey(key: string): boolean {
const normalized = key.toLowerCase();
return CONNECTOR_FORBIDDEN_OUTPUT_KEYS.has(normalized) || /(?:token|secret|credential|password|authorization|cookie)/i.test(key);
}
function redactConnectorOutputValue(value: BoundedJsonValue): { value: BoundedJsonValue; redacted: boolean } {
if (Array.isArray(value)) {
let redacted = false;
const next = value.map((item) => {
const child = redactConnectorOutputValue(item);
redacted = child.redacted || redacted;
return child.value;
});
return { value: next, redacted };
}
if (value !== null && typeof value === 'object') {
let redacted = false;
const next: BoundedJsonObject = {};
for (const [key, child] of Object.entries(value)) {
if (isForbiddenConnectorOutputKey(key)) {
next[key] = CONNECTOR_REDACTED_VALUE;
redacted = true;
continue;
}
const redactedChild = redactConnectorOutputValue(child);
next[key] = redactedChild.value;
redacted = redactedChild.redacted || redacted;
}
return { value: next, redacted };
}
return { value, redacted: false };
}
export function protectConnectorOutput(output: BoundedJsonValue): ConnectorOutputProtectionResult {
const redacted = redactConnectorOutputValue(output);
const serializedBytes = jsonSerializedBytes(redacted.value);
if (serializedBytes > CONNECTOR_MAX_OUTPUT_BYTES) {
throw new ConnectorServiceError('CONNECTOR_OUTPUT_TOO_LARGE', 'connector output exceeds max serialized size', 502, {
maxSerializedBytes: CONNECTOR_MAX_OUTPUT_BYTES,
serializedBytes,
});
}
return { output: redacted.value, redacted: redacted.redacted, serializedBytes };
}
export class ConnectorService {
private readonly runLimits = new Map<string, ConnectorRunLimitState>();
constructor(private readonly statusService = new ConnectorStatusService()) {}
setCredentialStore(credentialStore: ConnectorCredentialStore): void {
this.statusService.setCredentialStore(credentialStore);
}
deleteCredentialsByProvider(provider: string): void {
this.statusService.deleteCredentialsByProvider(provider);
}
async listDefinitions(signal?: AbortSignal): Promise<ConnectorCatalogDefinition[]> {
return composioConnectorProvider.listDefinitions(signal);
}
listFastDefinitions(): ConnectorCatalogDefinition[] {
return getStaticComposioCatalogDefinitions();
}
async getDefinition(connectorId: string, signal?: AbortSignal): Promise<ConnectorCatalogDefinition | undefined> {
return composioConnectorProvider.getDefinition(connectorId, signal);
}
getStatus(definition: ConnectorCatalogDefinition): ConnectorConnectionStatus {
return this.statusService.getStatus(definition);
}
getCredential(connectorId: string): ConnectorCredentialRecord | undefined {
return this.statusService.getCredential(connectorId);
}
async listConnectors(signal?: AbortSignal): Promise<ConnectorDetail[]> {
return this.listFastDefinitions().map((definition) => this.toDetail(definition));
}
listConnectorStatuses(): Record<string, ConnectorConnectionStatus> {
return {
...this.statusService.listStatuses(),
...Object.fromEntries(this.listFastDefinitions().map((definition) => [definition.id, this.getStatus(definition)])),
};
}
async listConnectorDiscovery(options: { refresh?: boolean; signal?: AbortSignal } = {}): Promise<ConnectorDiscoveryResult> {
if (options.refresh) composioConnectorProvider.clearDiscoveryCache();
return {
connectors: (await this.listDefinitions(options.signal)).map((definition) => this.toDetail(definition)),
meta: {
provider: 'composio',
...(options.refresh ? { refreshRequested: true } : {}),
},
};
}
async getConnector(connectorId: string, signal?: AbortSignal): Promise<ConnectorDetail> {
const definition = await this.getDefinition(connectorId, signal);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
return this.toDetail(definition);
}
async connect(connectorId: string, options: { accountLabel?: string; credentials?: ConnectorCredentialMaterial; callbackUrl?: string; signal?: AbortSignal } = {}): Promise<ConnectorConnectResult> {
const definition = await this.getDefinition(connectorId, options.signal);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
let auth: ComposioConnectionStart | undefined;
let detailDefinition = definition;
if (definition.authentication === 'composio' && options.credentials === undefined) {
if (!options.callbackUrl) {
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'callbackUrl is required for Composio connectors', 400, { connectorId });
}
auth = await composioConnectorProvider.connect(definition, options.callbackUrl, options.signal);
detailDefinition = await this.getDefinition(connectorId, options.signal) ?? definition;
if (auth.kind === 'redirect_required' || auth.kind === 'pending') {
return { connector: this.toDetail(detailDefinition), auth: publicComposioAuthStart(auth) };
}
if (auth.credentials !== undefined) {
options = { ...options, ...(auth.accountLabel === undefined ? {} : { accountLabel: auth.accountLabel }), credentials: auth.credentials };
}
}
const status = this.statusService.connect(detailDefinition, options.accountLabel, options.credentials);
if (status.status === 'disabled') {
throw new ConnectorServiceError('CONNECTOR_DISABLED', 'connector is disabled', 403);
}
return { connector: this.toDetail(detailDefinition), ...(auth === undefined ? {} : { auth: publicComposioAuthStart(auth) }) };
}
async disconnect(connectorId: string): Promise<ConnectorDetail> {
const definition = await this.getDefinition(connectorId);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
if (definition.authentication === 'composio') {
await composioConnectorProvider.disconnect(this.getCredential(connectorId)?.credentials);
}
this.statusService.disconnect(definition);
return this.toDetail(definition);
}
async completeComposioConnection(input: { connectorId: string; state: string; providerConnectionId?: string; status?: string; signal?: AbortSignal }): Promise<ConnectorDetail> {
const definition = await this.getDefinition(input.connectorId, input.signal);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
if (definition.authentication !== 'composio') {
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'connector is not backed by Composio', 400, { connectorId: input.connectorId });
}
const completed = await composioConnectorProvider.completeConnection({ definition, state: input.state, ...(input.providerConnectionId === undefined ? {} : { providerConnectionId: input.providerConnectionId }), ...(input.status === undefined ? {} : { status: input.status }), ...(input.signal === undefined ? {} : { signal: input.signal }) });
this.statusService.connect(definition, completed.accountLabel, completed.credentials);
return this.toDetail(definition);
}
async execute(request: ConnectorExecuteRequest, context: ConnectorExecutionContext): Promise<ConnectorExecuteResponse> {
const definition = await this.getDefinition(request.connectorId, context.signal);
if (!definition) {
throw new ConnectorServiceError('CONNECTOR_NOT_FOUND', 'connector not found', 404);
}
const connector = this.toDetail(definition);
if (connector.status === 'disabled') {
throw new ConnectorServiceError('CONNECTOR_DISABLED', 'connector is disabled', 403);
}
if (connector.status !== 'connected') {
throw new ConnectorServiceError('CONNECTOR_NOT_CONNECTED', 'connector is not connected', 403, {
connectorId: request.connectorId,
status: connector.status,
});
}
if (request.expectedAccountLabel !== undefined && connector.accountLabel !== request.expectedAccountLabel) {
throw new ConnectorServiceError('CONNECTOR_NOT_CONNECTED', 'connector account changed since refresh approval', 409, {
connectorId: request.connectorId,
expectedAccountLabel: request.expectedAccountLabel,
currentAccountLabel: connector.accountLabel ?? null,
});
}
if (!definition.allowedToolNames.includes(request.toolName)) {
throw new ConnectorServiceError('CONNECTOR_TOOL_NOT_FOUND', 'connector tool is not allowed', 404, {
connectorId: request.connectorId,
toolName: request.toolName,
});
}
const tool = definition.tools.find((candidate) => candidate.name === request.toolName);
if (!tool) {
throw new ConnectorServiceError('CONNECTOR_TOOL_NOT_FOUND', 'connector tool not found', 404);
}
const runtimeSafety = runtimeSafetyForTool(tool);
const effectiveApproval = stricterApproval(stricterApproval(definition.minimumApproval, tool.safety.approval), runtimeSafety.approval);
if (effectiveApproval !== 'auto' || runtimeSafety.sideEffect !== 'read') {
throw new ConnectorServiceError('CONNECTOR_SAFETY_DENIED', 'connector tool is not auto-approved read-only by current safety policy', 403, {
connectorId: request.connectorId,
toolName: request.toolName,
approvalPolicy: effectiveApproval ?? null,
safety: { ...runtimeSafety },
});
}
try {
assertJsonSchemaMatches(request.input, tool.inputSchemaJson);
} catch (error) {
throw new ConnectorServiceError('CONNECTOR_INPUT_SCHEMA_MISMATCH', error instanceof Error ? error.message : String(error), 400, {
connectorId: request.connectorId,
toolName: request.toolName,
});
}
this.enforceRunLimits(context);
const providerOutput = await this.executeConnectorProviderTool(request, context);
const protectedOutput = protectConnectorOutput(providerOutput);
const output = protectedOutput.output;
const outputSummary = summarizeConnectorOutput(output);
return {
ok: true,
connectorId: request.connectorId,
...(connector.accountLabel === undefined ? {} : { accountLabel: connector.accountLabel }),
toolName: request.toolName,
safety: { ...runtimeSafety },
output,
...(outputSummary === undefined ? {} : { outputSummary }),
metadata: {
connectorId: request.connectorId,
toolName: request.toolName,
purpose: context.purpose ?? 'agent_preview',
outputSerializedBytes: protectedOutput.serializedBytes,
...(protectedOutput.redacted ? { redacted: true } : {}),
...(context.runId === undefined ? {} : { runId: context.runId }),
},
};
}
protected async executeConnectorProviderTool(request: ConnectorExecuteRequest, context: ConnectorExecutionContext): Promise<BoundedJsonObject> {
const definition = await this.getDefinition(request.connectorId, context.signal);
const tool = definition?.tools.find((candidate) => candidate.name === request.toolName);
if (definition?.authentication === 'composio' && tool) {
return composioConnectorProvider.execute(definition, tool, request.input, this.getCredential(request.connectorId)?.credentials, context.signal);
}
throw new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'connector provider is not implemented', 501, {
connectorId: request.connectorId,
toolName: request.toolName,
});
}
private enforceRunLimits(context: ConnectorExecutionContext): void {
if (context.runId === undefined) return;
const now = Date.now();
this.pruneRunLimits(now);
const key = connectorRunLimitKey(context);
const current = this.runLimits.get(key);
const state: ConnectorRunLimitState = current === undefined || now - current.windowStartedAt >= CONNECTOR_RUN_RATE_LIMIT_WINDOW_MS
? { windowStartedAt: now, lastSeenAt: now, windowCalls: 0, totalCalls: current?.totalCalls ?? 0 }
: current;
if (state.totalCalls >= CONNECTOR_RUN_TOTAL_CALL_LIMIT) {
throw new ConnectorServiceError('CONNECTOR_RATE_LIMITED', 'connector tool run call limit exceeded', 429, {
runId: context.runId ?? null,
totalCallLimit: CONNECTOR_RUN_TOTAL_CALL_LIMIT,
});
}
if (state.windowCalls >= CONNECTOR_RUN_RATE_LIMIT_CALLS) {
throw new ConnectorServiceError('CONNECTOR_RATE_LIMITED', 'connector tool rate limit exceeded', 429, {
runId: context.runId ?? null,
rateLimit: CONNECTOR_RUN_RATE_LIMIT_CALLS,
windowMs: CONNECTOR_RUN_RATE_LIMIT_WINDOW_MS,
});
}
state.windowCalls += 1;
state.totalCalls += 1;
state.lastSeenAt = now;
this.runLimits.set(key, state);
}
private pruneRunLimits(now = Date.now()): void {
for (const [key, state] of this.runLimits.entries()) {
if (now - state.lastSeenAt >= CONNECTOR_RUN_LIMIT_TTL_MS) this.runLimits.delete(key);
}
}
private toDetail(definition: ConnectorCatalogDefinition): ConnectorDetail {
const detail = connectorDefinitionToDetail(definition);
const status = this.getStatus(definition);
return {
...detail,
status: status.status,
...(status.accountLabel === undefined ? {} : { accountLabel: status.accountLabel }),
...(status.lastError === undefined ? {} : { lastError: status.lastError }),
...(detail.auth === undefined ? {} : {
auth: {
...detail.auth,
configured: detail.auth.configured || (definition.authentication === 'composio' && composioConnectorProvider.isConfigured(definition)),
},
}),
};
}
}
export const connectorService = new ConnectorService();
export function configureConnectorCredentialStore(credentialStore: ConnectorCredentialStore): void {
connectorService.setCredentialStore(credentialStore);
}
export function deleteConnectorCredentialsByProvider(provider: string): void {
connectorService.deleteCredentialsByProvider(provider);
}
function summarizeConnectorOutput(output: BoundedJsonValue): string | undefined {
if (output === null || typeof output !== 'object' || Array.isArray(output)) return undefined;
const maybeToolName = output.toolName;
if (typeof maybeToolName === 'string') {
if (typeof output.count === 'number') return `${maybeToolName}: ${output.count} result${output.count === 1 ? '' : 's'}`;
if (typeof output.path === 'string') return `${maybeToolName}: ${output.path}`;
if (typeof output.isRepository === 'boolean') return `${maybeToolName}: ${output.isRepository ? 'repository found' : 'not a repository'}`;
return maybeToolName;
}
return undefined;
}

View File

@@ -0,0 +1,130 @@
// @ts-nocheck
/**
* Parses GitHub Copilot CLI's `--output-format json` JSONL stream into the
* same UI-friendly events that claude-stream.js emits, so the chat panel
* can render Copilot's thinking / tool calls / text the same way it does
* Claude Code's.
*
* Copilot's schema uses dotted top-level types (`assistant.*`, `tool.*`,
* `session.*`, `user.*`, `result`) with the payload under `data`. The
* `ephemeral: true` events (session.mcp_*, reasoning_delta, etc.) are still
* useful — they carry the streaming deltas — but events we don't have a UI
* lane for (mcp_server_status, skills_loaded, full reasoning recap, turn
* boundaries) are dropped on the floor.
*
* Mapping:
* session.tools_updated -> status (initializing, with model name)
* assistant.turn_start -> status (streaming)
* assistant.reasoning_delta -> thinking_delta
* assistant.message_delta -> text_delta
* tool.execution_start -> tool_use
* tool.execution_complete -> tool_result
* result -> usage
*/
export function createCopilotStreamHandler(onEvent) {
let buffer = '';
function feed(chunk) {
buffer += chunk;
let nl;
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
let obj;
try {
obj = JSON.parse(line);
} catch {
onEvent({ type: 'raw', line });
continue;
}
handleObject(obj);
}
}
function flush() {
const rem = buffer.trim();
buffer = '';
if (!rem) return;
try {
handleObject(JSON.parse(rem));
} catch {
onEvent({ type: 'raw', line: rem });
}
}
function handleObject(obj) {
if (!obj || typeof obj !== 'object' || typeof obj.type !== 'string') return;
const data = obj.data || {};
switch (obj.type) {
case 'session.tools_updated':
if (data.model) {
onEvent({ type: 'status', label: 'initializing', model: data.model });
}
return;
case 'assistant.turn_start':
onEvent({ type: 'status', label: 'streaming' });
return;
case 'assistant.reasoning_delta':
if (typeof data.deltaContent === 'string') {
onEvent({ type: 'thinking_delta', delta: data.deltaContent });
}
return;
case 'assistant.message_delta':
if (typeof data.deltaContent === 'string') {
onEvent({ type: 'text_delta', delta: data.deltaContent });
}
return;
case 'tool.execution_start':
onEvent({
type: 'tool_use',
id: data.toolCallId ?? null,
name: data.toolName ?? null,
input: data.arguments ?? null,
});
return;
case 'tool.execution_complete':
onEvent({
type: 'tool_result',
toolUseId: data.toolCallId ?? null,
content: stringifyResult(data.result),
isError: data.success === false,
});
return;
case 'result':
// `result` puts usage / exitCode at the top level, not under `data`.
// Treat a missing exitCode as success when `success: true` is set —
// strict `=== 0` would otherwise mis-flag turns where Copilot emits
// usage without a numeric exit code as `error`.
onEvent({
type: 'usage',
usage: obj.usage ?? null,
stopReason:
obj.success === true || obj.exitCode === 0 ? 'completed' : 'error',
durationMs: obj.usage?.sessionDurationMs ?? null,
});
return;
default:
return;
}
}
return { feed, flush };
}
function stringifyResult(r) {
if (r == null) return '';
if (typeof r === 'string') return r;
if (typeof r.content === 'string') return r.content;
if (typeof r.detailedContent === 'string') return r.detailedContent;
return JSON.stringify(r);
}

46
apps/daemon/src/craft.ts Normal file
View File

@@ -0,0 +1,46 @@
// @ts-nocheck
// Craft references loader. The active skill declares which sections it
// needs via `od.craft.requires`; this module reads the matching files
// from <projectRoot>/craft/<slug>.md and returns a single concatenated
// body ready to splice into the system prompt. Missing files are
// dropped silently — a skill that lists `motion` before we ship a
// motion.md should still work, just without the motion section.
import { readFile } from "node:fs/promises";
import path from "node:path";
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
/**
* @param {string} craftDir absolute path to the craft/ directory
* @param {string[]} requested slugs from `od.craft.requires`
* @returns {Promise<{ body: string, sections: string[] }>}
* body is the concatenated markdown (each file preceded by a level-3
* section header). sections lists which slugs actually resolved.
*/
export async function loadCraftSections(craftDir, requested) {
if (!craftDir || !Array.isArray(requested) || requested.length === 0) {
return { body: "", sections: [] };
}
const seen = new Set();
const parts = [];
const sections = [];
for (const raw of requested) {
if (typeof raw !== "string") continue;
const slug = raw.trim().toLowerCase();
if (!SLUG_RE.test(slug) || seen.has(slug)) continue;
seen.add(slug);
try {
const filePath = path.join(craftDir, `${slug}.md`);
const text = await readFile(filePath, "utf8");
const trimmed = text.trim();
if (!trimmed) continue;
parts.push(`### ${slug}\n\n${trimmed}`);
sections.push(slug);
} catch {
// File doesn't exist or unreadable — skip silently. Skills can
// forward-reference future craft sections without breaking.
}
}
return { body: parts.join("\n\n---\n\n"), sections };
}

View File

@@ -0,0 +1,187 @@
<CRITIQUE_RUN version="1" maxRounds="3" threshold="8.0" scale="10">
<ROUND n="1">
<PANELIST role="designer">
<NOTES>Round 1 intent: establish a bold magazine-poster grid for an investor-deck hero, with oversized title, a single accent CTA, and the brand wordmark anchored top-left.</NOTES>
<ARTIFACT mime="text/html"><![CDATA[
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Investor Deck Cover v1</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0a0a0a;color:#f5f5f5;min-height:100vh;display:flex;align-items:center;justify-content:center}
.poster{width:960px;padding:48px 40px 40px;position:relative}
.wordmark{font-size:14px;letter-spacing:.2em;text-transform:uppercase;color:#888}
h1{font-size:72px;font-weight:800;line-height:1;margin:24px 0 12px;color:#fff}
.sub{font-size:18px;color:#aaa;margin-bottom:40px}
.cta{display:inline-block;padding:14px 32px;background:#e63;color:#fff;font-weight:700;font-size:16px;border-radius:4px}
</style>
</head>
<body>
<div class="poster">
<div class="wordmark">Acme Ventures</div>
<h1>The Future of<br>Infrastructure</h1>
<p class="sub">Series B deck / Q2 2025</p>
<a class="cta" href="#">Request Access</a>
</div>
</body>
</html>
]]></ARTIFACT>
</PANELIST>
<PANELIST role="critic" score="6.4" must_fix="3">
<DIM name="hierarchy" score="6">CTA competes with wordmark at top-left; eye path is ambiguous.</DIM>
<DIM name="type" score="7">H1 at 72px reads as poster, not landing page; descends too fast into body copy.</DIM>
<DIM name="contrast" score="4">CTA background #e63 on #0a0a0a body gives approx 3.9:1; fails WCAG AA for normal text.</DIM>
<DIM name="rhythm" score="6">Vertical gaps 24/12/40 are ad-hoc; no 8px grid system visible.</DIM>
<DIM name="space" score="7">Left/right padding 40px is uniform but feels tight against the 960px column.</DIM>
<MUST_FIX>Darken CTA background to at least 4.5:1 contrast ratio against body.</MUST_FIX>
<MUST_FIX>Establish explicit 8px vertical rhythm (margins multiples of 8).</MUST_FIX>
<MUST_FIX>Shift wordmark to top-right so hierarchy flows top-left title to bottom CTA.</MUST_FIX>
</PANELIST>
<PANELIST role="brand" score="7.5" must_fix="2">
<DIM name="voice" score="8">Tagline "The Future of Infrastructure" is punchy and on-brand for Series B.</DIM>
<DIM name="color" score="7">Dark background suits premium investor aesthetic; accent orange feels startup-generic.</DIM>
<DIM name="lockup" score="7">Wordmark legibility is fine but placement top-left conflicts with primary read path.</DIM>
<MUST_FIX>Replace generic orange with brand blue (#1a6cf5) to reinforce system identity.</MUST_FIX>
<MUST_FIX>Add thin rule below wordmark to separate identity zone from content zone.</MUST_FIX>
</PANELIST>
<PANELIST role="a11y" score="5.0" must_fix="2">
<DIM name="color-contrast" score="4">CTA text contrast 3.9:1 fails AA; subtitle #aaa on #0a0a0a is 5.5:1, borderline.</DIM>
<DIM name="focus" score="5">No visible focus ring on CTA anchor; keyboard users cannot navigate.</DIM>
<DIM name="semantics" score="6">H1 present; landmark regions missing (no main, no header).</DIM>
<MUST_FIX>Add :focus-visible outline to .cta with 3px offset.</MUST_FIX>
<MUST_FIX>Wrap content in semantic main element and add header landmark for wordmark.</MUST_FIX>
</PANELIST>
<PANELIST role="copy" score="6.0" must_fix="1">
<DIM name="headline" score="7">Strong active framing; "Infrastructure" is broad but works for deck cover.</DIM>
<DIM name="sub-copy" score="5">Date string "Q2 2025" is unnecessary on a timeless hero; reads as dated artifact.</DIM>
<DIM name="cta-label" score="6">"Request Access" is generic; "See the Deck" is more specific and action-confirming.</DIM>
<MUST_FIX>Change CTA label from "Request Access" to "See the Deck" for specificity.</MUST_FIX>
</PANELIST>
<ROUND_END n="1" composite="6.26" must_fix="8" decision="continue">
<REASON>Composite 6.26 is below threshold 8.0; 8 must-fix items open across critic, brand, a11y, and copy. Continue to round 2.</REASON>
</ROUND_END>
</ROUND>
<ROUND n="2">
<PANELIST role="designer">
<NOTES>Round 2 refinement: moved wordmark to top-right, adopted brand blue #1a6cf5 for CTA, normalized vertical spacing to 8px grid, added focus ring, wrapped in semantic landmarks, removed date from subtitle, updated CTA label.</NOTES>
</PANELIST>
<PANELIST role="critic" score="7.8" must_fix="2">
<DIM name="hierarchy" score="8">Wordmark top-right clears the primary read path; hierarchy now title to sub to CTA.</DIM>
<DIM name="type" score="8">8px rhythm applied consistently; heading still large but balanced by tighter sub spacing.</DIM>
<DIM name="contrast" score="7">Brand blue CTA passes AA at ~5.2:1; subtitle gray still at 5.5:1, acceptable.</DIM>
<DIM name="rhythm" score="8">Margins now multiples of 8; much more systematic.</DIM>
<DIM name="space" score="7">Horizontal padding increased to 56px; feels airy but right column reads empty.</DIM>
<MUST_FIX>Add a secondary visual element (rule or column) to balance right-side whitespace.</MUST_FIX>
<MUST_FIX>Tighten H1 line-height to 0.95 for denser poster feel.</MUST_FIX>
</PANELIST>
<PANELIST role="brand" score="8.2" must_fix="1">
<DIM name="voice" score="9">Headline unchanged; brand blue CTA unifies identity system across deck.</DIM>
<DIM name="color" score="8">Blue accent is immediately recognizable as the brand system color.</DIM>
<DIM name="lockup" score="8">Identity zone separated by rule; clean and professional.</DIM>
<MUST_FIX>Increase wordmark letter-spacing to 0.25em for premium print feel.</MUST_FIX>
</PANELIST>
<PANELIST role="a11y" score="7.5" must_fix="1">
<DIM name="color-contrast" score="8">CTA now passes AA; subtitle is acceptable.</DIM>
<DIM name="focus" score="7">Focus ring present but offset is 2px; raise to 3px per WCAG 2.2 guideline.</DIM>
<DIM name="semantics" score="7">main and header landmarks added; no skip-nav link yet.</DIM>
<MUST_FIX>Add a visually-hidden skip-navigation link before the main landmark.</MUST_FIX>
</PANELIST>
<PANELIST role="copy" score="8.0" must_fix="0">
<DIM name="headline" score="8">Remains strong; no changes needed.</DIM>
<DIM name="sub-copy" score="8">Date removed; subtitle now reads "Series B overview" which is clean and evergreen.</DIM>
<DIM name="cta-label" score="8">"See the Deck" is direct and confirms the action.</DIM>
</PANELIST>
<ROUND_END n="2" composite="7.86" must_fix="4" decision="continue">
<REASON>Composite 7.86 is below threshold 8.0; 4 must-fix items remain across critic, brand, and a11y. Continue to round 3.</REASON>
</ROUND_END>
</ROUND>
<ROUND n="3">
<PANELIST role="designer">
<NOTES>Round 3 polish: added decorative vertical rule at right to anchor whitespace, tightened H1 line-height to 0.95, raised wordmark letter-spacing to 0.25em, increased focus-ring offset to 3px, added visually-hidden skip-nav link.</NOTES>
</PANELIST>
<PANELIST role="critic" score="8.6" must_fix="0">
<DIM name="hierarchy" score="9">Clear top-right wordmark, dominant title, subdued subtitle, prominent CTA. Excellent path.</DIM>
<DIM name="type" score="9">H1 at 0.95 line-height gives tight poster texture; body type proportions now balanced.</DIM>
<DIM name="contrast" score="8">All elements pass AA; CTA 5.2:1, subtitle 5.5:1, body copy 14.5:1.</DIM>
<DIM name="rhythm" score="9">Consistent 8px multiples throughout; vertical rule reinforces grid axis.</DIM>
<DIM name="space" score="8">Right column balanced by rule; generous but not wasteful.</DIM>
</PANELIST>
<PANELIST role="brand" score="9.0" must_fix="0">
<DIM name="voice" score="9">Headline tone is authoritative; brand identity is coherent from wordmark to CTA.</DIM>
<DIM name="color" score="9">Brand blue fully integrated; palette is consistent and premium.</DIM>
<DIM name="lockup" score="9">Identity zone with rule separator and 0.25em letter-spacing reads as editorial quality.</DIM>
</PANELIST>
<PANELIST role="a11y" score="8.4" must_fix="0">
<DIM name="color-contrast" score="9">All text elements pass WCAG AA; CTA passes AA large.</DIM>
<DIM name="focus" score="8">Focus ring at 3px offset is clearly visible and meets 2.2 criterion 2.4.11.</DIM>
<DIM name="semantics" score="8">Landmarks correct; skip-nav present; heading hierarchy is single H1 with no skips.</DIM>
</PANELIST>
<PANELIST role="copy" score="8.4" must_fix="0">
<DIM name="headline" score="9">Punchy, memorable, and stakes-appropriate for Series B investor deck.</DIM>
<DIM name="sub-copy" score="8">Evergreen subtitle anchors context without expiry.</DIM>
<DIM name="cta-label" score="8">"See the Deck" is action-confirming and specific.</DIM>
</PANELIST>
<ROUND_END n="3" composite="8.60" must_fix="0" decision="ship">
<REASON>Composite 8.60 exceeds threshold 8.0; zero must-fix items remain. Ship.</REASON>
</ROUND_END>
</ROUND>
<SHIP round="3" composite="8.60" status="shipped">
<ARTIFACT mime="text/html"><![CDATA[
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Investor Deck Cover</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0a0a0a;color:#f5f5f5;min-height:100vh;display:flex;align-items:center;justify-content:center}
.skip-nav{position:absolute;left:-9999px}.skip-nav:focus{left:16px;top:16px;z-index:100;background:#1a6cf5;color:#fff;padding:8px 16px;border-radius:4px}
.poster{width:960px;padding:56px 56px 56px;position:relative;border-right:1px solid #222}
header{display:flex;justify-content:flex-end;margin-bottom:64px}
.wordmark{font-size:13px;letter-spacing:.25em;text-transform:uppercase;color:#666}
h1{font-size:72px;font-weight:800;line-height:.95;margin-bottom:24px;color:#fff}
.sub{font-size:18px;color:#aaa;margin-bottom:48px}
.cta{display:inline-block;padding:14px 32px;background:#1a6cf5;color:#fff;font-weight:700;font-size:16px;border-radius:4px;text-decoration:none}
.cta:focus-visible{outline:3px solid #fff;outline-offset:3px}
</style>
</head>
<body>
<a class="skip-nav" href="#main">Skip to content</a>
<div class="poster">
<header><span class="wordmark">Acme Ventures</span></header>
<main id="main">
<h1>The Future of<br>Infrastructure</h1>
<p class="sub">Series B overview</p>
<a class="cta" href="#">See the Deck</a>
</main>
</div>
</body>
</html>
]]></ARTIFACT>
<SUMMARY>Across three rounds the panel converged from a rough poster sketch (composite 6.26) to a polished investor-deck hero (composite 8.60). The key changes were: moving the wordmark to the top-right to establish a clear top-to-bottom read path; replacing the generic orange CTA with brand blue #1a6cf5 for system coherence; normalizing all vertical spacing to an 8px grid; adding a decorative vertical rule to balance right-column whitespace; tightening H1 line-height to 0.95 for a denser poster texture; fixing WCAG AA contrast on the CTA; adding proper semantic landmarks, a visible focus ring, and a skip-navigation link; and sharpening the CTA label from "Request Access" to "See the Deck".</SUMMARY>
</SHIP>
<SHIP round="3" composite="8.60" status="shipped"><ARTIFACT mime="text/html"><![CDATA[ <p>second</p> ]]></ARTIFACT><SUMMARY>duplicate</SUMMARY></SHIP>
</CRITIQUE_RUN>

View File

@@ -0,0 +1,185 @@
<CRITIQUE_RUN version="1" maxRounds="3" threshold="8.0" scale="10">
<ROUND n="1">
<PANELIST role="designer">
<NOTES>Round 1 intent: establish a bold magazine-poster grid for an investor-deck hero, with oversized title, a single accent CTA, and the brand wordmark anchored top-left.</NOTES>
<ARTIFACT mime="text/html"><![CDATA[
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Investor Deck Cover v1</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0a0a0a;color:#f5f5f5;min-height:100vh;display:flex;align-items:center;justify-content:center}
.poster{width:960px;padding:48px 40px 40px;position:relative}
.wordmark{font-size:14px;letter-spacing:.2em;text-transform:uppercase;color:#888}
h1{font-size:72px;font-weight:800;line-height:1;margin:24px 0 12px;color:#fff}
.sub{font-size:18px;color:#aaa;margin-bottom:40px}
.cta{display:inline-block;padding:14px 32px;background:#e63;color:#fff;font-weight:700;font-size:16px;border-radius:4px}
</style>
</head>
<body>
<div class="poster">
<div class="wordmark">Acme Ventures</div>
<h1>The Future of<br>Infrastructure</h1>
<p class="sub">Series B deck / Q2 2025</p>
<a class="cta" href="#">Request Access</a>
</div>
</body>
</html>
]]></ARTIFACT>
</PANELIST>
<PANELIST role="critic" score="6.4" must_fix="3">
<DIM name="hierarchy" score="6">CTA competes with wordmark at top-left; eye path is ambiguous.</DIM>
<DIM name="type" score="7">H1 at 72px reads as poster, not landing page; descends too fast into body copy.</DIM>
<DIM name="contrast" score="4">CTA background #e63 on #0a0a0a body gives approx 3.9:1; fails WCAG AA for normal text.</DIM>
<DIM name="rhythm" score="6">Vertical gaps 24/12/40 are ad-hoc; no 8px grid system visible.</DIM>
<DIM name="space" score="7">Left/right padding 40px is uniform but feels tight against the 960px column.</DIM>
<MUST_FIX>Darken CTA background to at least 4.5:1 contrast ratio against body.</MUST_FIX>
<MUST_FIX>Establish explicit 8px vertical rhythm (margins multiples of 8).</MUST_FIX>
<MUST_FIX>Shift wordmark to top-right so hierarchy flows top-left title to bottom CTA.</MUST_FIX>
</PANELIST>
<PANELIST role="brand" score="7.5" must_fix="2">
<DIM name="voice" score="8">Tagline "The Future of Infrastructure" is punchy and on-brand for Series B.</DIM>
<DIM name="color" score="7">Dark background suits premium investor aesthetic; accent orange feels startup-generic.</DIM>
<DIM name="lockup" score="7">Wordmark legibility is fine but placement top-left conflicts with primary read path.</DIM>
<MUST_FIX>Replace generic orange with brand blue (#1a6cf5) to reinforce system identity.</MUST_FIX>
<MUST_FIX>Add thin rule below wordmark to separate identity zone from content zone.</MUST_FIX>
</PANELIST>
<PANELIST role="a11y" score="5.0" must_fix="2">
<DIM name="color-contrast" score="4">CTA text contrast 3.9:1 fails AA; subtitle #aaa on #0a0a0a is 5.5:1, borderline.</DIM>
<DIM name="focus" score="5">No visible focus ring on CTA anchor; keyboard users cannot navigate.</DIM>
<DIM name="semantics" score="6">H1 present; landmark regions missing (no main, no header).</DIM>
<MUST_FIX>Add :focus-visible outline to .cta with 3px offset.</MUST_FIX>
<MUST_FIX>Wrap content in semantic main element and add header landmark for wordmark.</MUST_FIX>
</PANELIST>
<PANELIST role="copy" score="6.0" must_fix="1">
<DIM name="headline" score="7">Strong active framing; "Infrastructure" is broad but works for deck cover.</DIM>
<DIM name="sub-copy" score="5">Date string "Q2 2025" is unnecessary on a timeless hero; reads as dated artifact.</DIM>
<DIM name="cta-label" score="6">"Request Access" is generic; "See the Deck" is more specific and action-confirming.</DIM>
<MUST_FIX>Change CTA label from "Request Access" to "See the Deck" for specificity.</MUST_FIX>
</PANELIST>
<ROUND_END n="1" composite="6.26" must_fix="8" decision="continue">
<REASON>Composite 6.26 is below threshold 8.0; 8 must-fix items open across critic, brand, a11y, and copy. Continue to round 2.</REASON>
</ROUND_END>
</ROUND>
<ROUND n="2">
<PANELIST role="designer">
<NOTES>Round 2 refinement: moved wordmark to top-right, adopted brand blue #1a6cf5 for CTA, normalized vertical spacing to 8px grid, added focus ring, wrapped in semantic landmarks, removed date from subtitle, updated CTA label.</NOTES>
</PANELIST>
<PANELIST role="critic" score="7.8" must_fix="2">
<DIM name="hierarchy" score="8">Wordmark top-right clears the primary read path; hierarchy now title to sub to CTA.</DIM>
<DIM name="type" score="8">8px rhythm applied consistently; heading still large but balanced by tighter sub spacing.</DIM>
<DIM name="contrast" score="7">Brand blue CTA passes AA at ~5.2:1; subtitle gray still at 5.5:1, acceptable.</DIM>
<DIM name="rhythm" score="8">Margins now multiples of 8; much more systematic.</DIM>
<DIM name="space" score="7">Horizontal padding increased to 56px; feels airy but right column reads empty.</DIM>
<MUST_FIX>Add a secondary visual element (rule or column) to balance right-side whitespace.</MUST_FIX>
<MUST_FIX>Tighten H1 line-height to 0.95 for denser poster feel.</MUST_FIX>
</PANELIST>
<PANELIST role="brand" score="8.2" must_fix="1">
<DIM name="voice" score="9">Headline unchanged; brand blue CTA unifies identity system across deck.</DIM>
<DIM name="color" score="8">Blue accent is immediately recognizable as the brand system color.</DIM>
<DIM name="lockup" score="8">Identity zone separated by rule; clean and professional.</DIM>
<MUST_FIX>Increase wordmark letter-spacing to 0.25em for premium print feel.</MUST_FIX>
</PANELIST>
<PANELIST role="a11y" score="7.5" must_fix="1">
<DIM name="color-contrast" score="8">CTA now passes AA; subtitle is acceptable.</DIM>
<DIM name="focus" score="7">Focus ring present but offset is 2px; raise to 3px per WCAG 2.2 guideline.</DIM>
<DIM name="semantics" score="7">main and header landmarks added; no skip-nav link yet.</DIM>
<MUST_FIX>Add a visually-hidden skip-navigation link before the main landmark.</MUST_FIX>
</PANELIST>
<PANELIST role="copy" score="8.0" must_fix="0">
<DIM name="headline" score="8">Remains strong; no changes needed.</DIM>
<DIM name="sub-copy" score="8">Date removed; subtitle now reads "Series B overview" which is clean and evergreen.</DIM>
<DIM name="cta-label" score="8">"See the Deck" is direct and confirms the action.</DIM>
</PANELIST>
<ROUND_END n="2" composite="7.86" must_fix="4" decision="continue">
<REASON>Composite 7.86 is below threshold 8.0; 4 must-fix items remain across critic, brand, and a11y. Continue to round 3.</REASON>
</ROUND_END>
</ROUND>
<ROUND n="3">
<PANELIST role="designer">
<NOTES>Round 3 polish: added decorative vertical rule at right to anchor whitespace, tightened H1 line-height to 0.95, raised wordmark letter-spacing to 0.25em, increased focus-ring offset to 3px, added visually-hidden skip-nav link.</NOTES>
</PANELIST>
<PANELIST role="critic" score="8.6" must_fix="0">
<DIM name="hierarchy" score="9">Clear top-right wordmark, dominant title, subdued subtitle, prominent CTA. Excellent path.</DIM>
<DIM name="type" score="9">H1 at 0.95 line-height gives tight poster texture; body type proportions now balanced.</DIM>
<DIM name="contrast" score="8">All elements pass AA; CTA 5.2:1, subtitle 5.5:1, body copy 14.5:1.</DIM>
<DIM name="rhythm" score="9">Consistent 8px multiples throughout; vertical rule reinforces grid axis.</DIM>
<DIM name="space" score="8">Right column balanced by rule; generous but not wasteful.</DIM>
</PANELIST>
<PANELIST role="brand" score="9.0" must_fix="0">
<DIM name="voice" score="9">Headline tone is authoritative; brand identity is coherent from wordmark to CTA.</DIM>
<DIM name="color" score="9">Brand blue fully integrated; palette is consistent and premium.</DIM>
<DIM name="lockup" score="9">Identity zone with rule separator and 0.25em letter-spacing reads as editorial quality.</DIM>
</PANELIST>
<PANELIST role="a11y" score="8.4" must_fix="0">
<DIM name="color-contrast" score="9">All text elements pass WCAG AA; CTA passes AA large.</DIM>
<DIM name="focus" score="8">Focus ring at 3px offset is clearly visible and meets 2.2 criterion 2.4.11.</DIM>
<DIM name="semantics" score="8">Landmarks correct; skip-nav present; heading hierarchy is single H1 with no skips.</DIM>
</PANELIST>
<PANELIST role="copy" score="8.4" must_fix="0">
<DIM name="headline" score="9">Punchy, memorable, and stakes-appropriate for Series B investor deck.</DIM>
<DIM name="sub-copy" score="8">Evergreen subtitle anchors context without expiry.</DIM>
<DIM name="cta-label" score="8">"See the Deck" is action-confirming and specific.</DIM>
</PANELIST>
<ROUND_END n="3" composite="8.60" must_fix="0" decision="ship">
<REASON>Composite 8.60 exceeds threshold 8.0; zero must-fix items remain. Ship.</REASON>
</ROUND_END>
</ROUND>
<SHIP round="3" composite="8.60" status="shipped">
<ARTIFACT mime="text/html"><![CDATA[
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Investor Deck Cover</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0a0a0a;color:#f5f5f5;min-height:100vh;display:flex;align-items:center;justify-content:center}
.skip-nav{position:absolute;left:-9999px}.skip-nav:focus{left:16px;top:16px;z-index:100;background:#1a6cf5;color:#fff;padding:8px 16px;border-radius:4px}
.poster{width:960px;padding:56px 56px 56px;position:relative;border-right:1px solid #222}
header{display:flex;justify-content:flex-end;margin-bottom:64px}
.wordmark{font-size:13px;letter-spacing:.25em;text-transform:uppercase;color:#666}
h1{font-size:72px;font-weight:800;line-height:.95;margin-bottom:24px;color:#fff}
.sub{font-size:18px;color:#aaa;margin-bottom:48px}
.cta{display:inline-block;padding:14px 32px;background:#1a6cf5;color:#fff;font-weight:700;font-size:16px;border-radius:4px;text-decoration:none}
.cta:focus-visible{outline:3px solid #fff;outline-offset:3px}
</style>
</head>
<body>
<a class="skip-nav" href="#main">Skip to content</a>
<div class="poster">
<header><span class="wordmark">Acme Ventures</span></header>
<main id="main">
<h1>The Future of<br>Infrastructure</h1>
<p class="sub">Series B overview</p>
<a class="cta" href="#">See the Deck</a>
</main>
</div>
</body>
</html>
]]></ARTIFACT>
<SUMMARY>Across three rounds the panel converged from a rough poster sketch (composite 6.26) to a polished investor-deck hero (composite 8.60). The key changes were: moving the wordmark to the top-right to establish a clear top-to-bottom read path; replacing the generic orange CTA with brand blue #1a6cf5 for system coherence; normalizing all vertical spacing to an 8px grid; adding a decorative vertical rule to balance right-column whitespace; tightening H1 line-height to 0.95 for a denser poster texture; fixing WCAG AA contrast on the CTA; adding proper semantic landmarks, a visible focus ring, and a skip-navigation link; and sharpening the CTA label from "Request Access" to "See the Deck".</SUMMARY>
</SHIP>
</CRITIQUE_RUN>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,185 @@
<CRITIQUE_RUN version="1" maxRounds="3" threshold="8.0" scale="10">
<ROUND n="1">
<PANELIST role="designer">
<NOTES>Round 1 intent: establish a bold magazine-poster grid for an investor-deck hero, with oversized title, a single accent CTA, and the brand wordmark anchored top-left.</NOTES>
<ARTIFACT mime="text/html"><![CDATA[
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Investor Deck Cover v1</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0a0a0a;color:#f5f5f5;min-height:100vh;display:flex;align-items:center;justify-content:center}
.poster{width:960px;padding:48px 40px 40px;position:relative}
.wordmark{font-size:14px;letter-spacing:.2em;text-transform:uppercase;color:#888}
h1{font-size:72px;font-weight:800;line-height:1;margin:24px 0 12px;color:#fff}
.sub{font-size:18px;color:#aaa;margin-bottom:40px}
.cta{display:inline-block;padding:14px 32px;background:#e63;color:#fff;font-weight:700;font-size:16px;border-radius:4px}
</style>
</head>
<body>
<div class="poster">
<div class="wordmark">Acme Ventures</div>
<h1>The Future of<br>Infrastructure</h1>
<p class="sub">Series B deck / Q2 2025</p>
<a class="cta" href="#">Request Access</a>
</div>
</body>
</html>
]]></ARTIFACT>
</PANELIST>
<PANELIST role="critic" score="6.4" must_fix="3">
<DIM name="hierarchy" score="6">CTA competes with wordmark at top-left; eye path is ambiguous.</DIM>
<DIM name="type" score="7">H1 at 72px reads as poster, not landing page; descends too fast into body copy.</DIM>
<DIM name="contrast" score="4">CTA background #e63 on #0a0a0a body gives approx 3.9:1; fails WCAG AA for normal text.</DIM>
<DIM name="rhythm" score="6">Vertical gaps 24/12/40 are ad-hoc; no 8px grid system visible.</DIM>
<DIM name="space" score="7">Left/right padding 40px is uniform but feels tight against the 960px column.</DIM>
<MUST_FIX>Darken CTA background to at least 4.5:1 contrast ratio against body.</MUST_FIX>
<MUST_FIX>Establish explicit 8px vertical rhythm (margins multiples of 8).</MUST_FIX>
<MUST_FIX>Shift wordmark to top-right so hierarchy flows top-left title to bottom CTA.</MUST_FIX>
</PANELIST>
<PANELIST role="brand" score="7.5" must_fix="2">
<DIM name="voice" score="8">Tagline "The Future of Infrastructure" is punchy and on-brand for Series B.</DIM>
<DIM name="color" score="7">Dark background suits premium investor aesthetic; accent orange feels startup-generic.</DIM>
<DIM name="lockup" score="7">Wordmark legibility is fine but placement top-left conflicts with primary read path.</DIM>
<MUST_FIX>Replace generic orange with brand blue (#1a6cf5) to reinforce system identity.</MUST_FIX>
<MUST_FIX>Add thin rule below wordmark to separate identity zone from content zone.</MUST_FIX>
</PANELIST>
<PANELIST role="a11y" score="5.0" must_fix="2">
<DIM name="color-contrast" score="4">CTA text contrast 3.9:1 fails AA; subtitle #aaa on #0a0a0a is 5.5:1, borderline.</DIM>
<DIM name="focus" score="5">No visible focus ring on CTA anchor; keyboard users cannot navigate.</DIM>
<DIM name="semantics" score="6">H1 present; landmark regions missing (no main, no header).</DIM>
<MUST_FIX>Add :focus-visible outline to .cta with 3px offset.</MUST_FIX>
<MUST_FIX>Wrap content in semantic main element and add header landmark for wordmark.</MUST_FIX>
</PANELIST>
<PANELIST role="copy" score="6.0" must_fix="1">
<DIM name="headline" score="7">Strong active framing; "Infrastructure" is broad but works for deck cover.</DIM>
<DIM name="sub-copy" score="5">Date string "Q2 2025" is unnecessary on a timeless hero; reads as dated artifact.</DIM>
<DIM name="cta-label" score="6">"Request Access" is generic; "See the Deck" is more specific and action-confirming.</DIM>
<MUST_FIX>Change CTA label from "Request Access" to "See the Deck" for specificity.</MUST_FIX>
</PANELIST>
<ROUND_END n="1" composite="6.26" must_fix="8" decision="continue">
<REASON>Composite 6.26 is below threshold 8.0; 8 must-fix items open across critic, brand, a11y, and copy. Continue to round 2.</REASON>
</ROUND_END>
</ROUND>
<ROUND n="2">
<PANELIST role="designer">
<NOTES>Round 2 refinement: moved wordmark to top-right, adopted brand blue #1a6cf5 for CTA, normalized vertical spacing to 8px grid, added focus ring, wrapped in semantic landmarks, removed date from subtitle, updated CTA label.</NOTES>
</PANELIST>
<PANELIST role="critic" score="7.8" must_fix="2">
<DIM name="hierarchy" score="8">Wordmark top-right clears the primary read path; hierarchy now title to sub to CTA.</DIM>
<DIM name="type" score="8">8px rhythm applied consistently; heading still large but balanced by tighter sub spacing.</DIM>
<DIM name="contrast" score="7">Brand blue CTA passes AA at ~5.2:1; subtitle gray still at 5.5:1, acceptable.</DIM>
<DIM name="rhythm" score="8">Margins now multiples of 8; much more systematic.</DIM>
<DIM name="space" score="7">Horizontal padding increased to 56px; feels airy but right column reads empty.</DIM>
<MUST_FIX>Add a secondary visual element (rule or column) to balance right-side whitespace.</MUST_FIX>
<MUST_FIX>Tighten H1 line-height to 0.95 for denser poster feel.</MUST_FIX>
<PANELIST role="brand" score="8.2" must_fix="1">
<DIM name="voice" score="9">Headline unchanged; brand blue CTA unifies identity system across deck.</DIM>
<DIM name="color" score="8">Blue accent is immediately recognizable as the brand system color.</DIM>
<DIM name="lockup" score="8">Identity zone separated by rule; clean and professional.</DIM>
<MUST_FIX>Increase wordmark letter-spacing to 0.25em for premium print feel.</MUST_FIX>
</PANELIST>
<PANELIST role="a11y" score="7.5" must_fix="1">
<DIM name="color-contrast" score="8">CTA now passes AA; subtitle is acceptable.</DIM>
<DIM name="focus" score="7">Focus ring present but offset is 2px; raise to 3px per WCAG 2.2 guideline.</DIM>
<DIM name="semantics" score="7">main and header landmarks added; no skip-nav link yet.</DIM>
<MUST_FIX>Add a visually-hidden skip-navigation link before the main landmark.</MUST_FIX>
</PANELIST>
<PANELIST role="copy" score="8.0" must_fix="0">
<DIM name="headline" score="8">Remains strong; no changes needed.</DIM>
<DIM name="sub-copy" score="8">Date removed; subtitle now reads "Series B overview" which is clean and evergreen.</DIM>
<DIM name="cta-label" score="8">"See the Deck" is direct and confirms the action.</DIM>
</PANELIST>
<ROUND_END n="2" composite="7.86" must_fix="4" decision="continue">
<REASON>Composite 7.86 is below threshold 8.0; 4 must-fix items remain across critic, brand, and a11y. Continue to round 3.</REASON>
</ROUND_END>
</ROUND>
<ROUND n="3">
<PANELIST role="designer">
<NOTES>Round 3 polish: added decorative vertical rule at right to anchor whitespace, tightened H1 line-height to 0.95, raised wordmark letter-spacing to 0.25em, increased focus-ring offset to 3px, added visually-hidden skip-nav link.</NOTES>
</PANELIST>
<PANELIST role="critic" score="8.6" must_fix="0">
<DIM name="hierarchy" score="9">Clear top-right wordmark, dominant title, subdued subtitle, prominent CTA. Excellent path.</DIM>
<DIM name="type" score="9">H1 at 0.95 line-height gives tight poster texture; body type proportions now balanced.</DIM>
<DIM name="contrast" score="8">All elements pass AA; CTA 5.2:1, subtitle 5.5:1, body copy 14.5:1.</DIM>
<DIM name="rhythm" score="9">Consistent 8px multiples throughout; vertical rule reinforces grid axis.</DIM>
<DIM name="space" score="8">Right column balanced by rule; generous but not wasteful.</DIM>
</PANELIST>
<PANELIST role="brand" score="9.0" must_fix="0">
<DIM name="voice" score="9">Headline tone is authoritative; brand identity is coherent from wordmark to CTA.</DIM>
<DIM name="color" score="9">Brand blue fully integrated; palette is consistent and premium.</DIM>
<DIM name="lockup" score="9">Identity zone with rule separator and 0.25em letter-spacing reads as editorial quality.</DIM>
</PANELIST>
<PANELIST role="a11y" score="8.4" must_fix="0">
<DIM name="color-contrast" score="9">All text elements pass WCAG AA; CTA passes AA large.</DIM>
<DIM name="focus" score="8">Focus ring at 3px offset is clearly visible and meets 2.2 criterion 2.4.11.</DIM>
<DIM name="semantics" score="8">Landmarks correct; skip-nav present; heading hierarchy is single H1 with no skips.</DIM>
</PANELIST>
<PANELIST role="copy" score="8.4" must_fix="0">
<DIM name="headline" score="9">Punchy, memorable, and stakes-appropriate for Series B investor deck.</DIM>
<DIM name="sub-copy" score="8">Evergreen subtitle anchors context without expiry.</DIM>
<DIM name="cta-label" score="8">"See the Deck" is action-confirming and specific.</DIM>
</PANELIST>
<ROUND_END n="3" composite="8.60" must_fix="0" decision="ship">
<REASON>Composite 8.60 exceeds threshold 8.0; zero must-fix items remain. Ship.</REASON>
</ROUND_END>
</ROUND>
<SHIP round="3" composite="8.60" status="shipped">
<ARTIFACT mime="text/html"><![CDATA[
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Investor Deck Cover</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0a0a0a;color:#f5f5f5;min-height:100vh;display:flex;align-items:center;justify-content:center}
.skip-nav{position:absolute;left:-9999px}.skip-nav:focus{left:16px;top:16px;z-index:100;background:#1a6cf5;color:#fff;padding:8px 16px;border-radius:4px}
.poster{width:960px;padding:56px 56px 56px;position:relative;border-right:1px solid #222}
header{display:flex;justify-content:flex-end;margin-bottom:64px}
.wordmark{font-size:13px;letter-spacing:.25em;text-transform:uppercase;color:#666}
h1{font-size:72px;font-weight:800;line-height:.95;margin-bottom:24px;color:#fff}
.sub{font-size:18px;color:#aaa;margin-bottom:48px}
.cta{display:inline-block;padding:14px 32px;background:#1a6cf5;color:#fff;font-weight:700;font-size:16px;border-radius:4px;text-decoration:none}
.cta:focus-visible{outline:3px solid #fff;outline-offset:3px}
</style>
</head>
<body>
<a class="skip-nav" href="#main">Skip to content</a>
<div class="poster">
<header><span class="wordmark">Acme Ventures</span></header>
<main id="main">
<h1>The Future of<br>Infrastructure</h1>
<p class="sub">Series B overview</p>
<a class="cta" href="#">See the Deck</a>
</main>
</div>
</body>
</html>
]]></ARTIFACT>
<SUMMARY>Across three rounds the panel converged from a rough poster sketch (composite 6.26) to a polished investor-deck hero (composite 8.60). The key changes were: moving the wordmark to the top-right to establish a clear top-to-bottom read path; replacing the generic orange CTA with brand blue #1a6cf5 for system coherence; normalizing all vertical spacing to an 8px grid; adding a decorative vertical rule to balance right-column whitespace; tightening H1 line-height to 0.95 for a denser poster texture; fixing WCAG AA contrast on the CTA; adding proper semantic landmarks, a visible focus ring, and a skip-navigation link; and sharpening the CTA label from "Request Access" to "See the Deck".</SUMMARY>
</SHIP>
</CRITIQUE_RUN>

View File

@@ -0,0 +1,160 @@
<CRITIQUE_RUN version="1" maxRounds="3" threshold="8.0" scale="10">
<ROUND n="1">
<PANELIST role="designer">
<NOTES>Round 1 intent: establish a bold magazine-poster grid for an investor-deck hero, with oversized title, a single accent CTA, and the brand wordmark anchored top-left.</NOTES>
</PANELIST>
<PANELIST role="critic" score="6.4" must_fix="3">
<DIM name="hierarchy" score="6">CTA competes with wordmark at top-left; eye path is ambiguous.</DIM>
<DIM name="type" score="7">H1 at 72px reads as poster, not landing page; descends too fast into body copy.</DIM>
<DIM name="contrast" score="4">CTA background #e63 on #0a0a0a body gives approx 3.9:1; fails WCAG AA for normal text.</DIM>
<DIM name="rhythm" score="6">Vertical gaps 24/12/40 are ad-hoc; no 8px grid system visible.</DIM>
<DIM name="space" score="7">Left/right padding 40px is uniform but feels tight against the 960px column.</DIM>
<MUST_FIX>Darken CTA background to at least 4.5:1 contrast ratio against body.</MUST_FIX>
<MUST_FIX>Establish explicit 8px vertical rhythm (margins multiples of 8).</MUST_FIX>
<MUST_FIX>Shift wordmark to top-right so hierarchy flows top-left title to bottom CTA.</MUST_FIX>
</PANELIST>
<PANELIST role="brand" score="7.5" must_fix="2">
<DIM name="voice" score="8">Tagline "The Future of Infrastructure" is punchy and on-brand for Series B.</DIM>
<DIM name="color" score="7">Dark background suits premium investor aesthetic; accent orange feels startup-generic.</DIM>
<DIM name="lockup" score="7">Wordmark legibility is fine but placement top-left conflicts with primary read path.</DIM>
<MUST_FIX>Replace generic orange with brand blue (#1a6cf5) to reinforce system identity.</MUST_FIX>
<MUST_FIX>Add thin rule below wordmark to separate identity zone from content zone.</MUST_FIX>
</PANELIST>
<PANELIST role="a11y" score="5.0" must_fix="2">
<DIM name="color-contrast" score="4">CTA text contrast 3.9:1 fails AA; subtitle #aaa on #0a0a0a is 5.5:1, borderline.</DIM>
<DIM name="focus" score="5">No visible focus ring on CTA anchor; keyboard users cannot navigate.</DIM>
<DIM name="semantics" score="6">H1 present; landmark regions missing (no main, no header).</DIM>
<MUST_FIX>Add :focus-visible outline to .cta with 3px offset.</MUST_FIX>
<MUST_FIX>Wrap content in semantic main element and add header landmark for wordmark.</MUST_FIX>
</PANELIST>
<PANELIST role="copy" score="6.0" must_fix="1">
<DIM name="headline" score="7">Strong active framing; "Infrastructure" is broad but works for deck cover.</DIM>
<DIM name="sub-copy" score="5">Date string "Q2 2025" is unnecessary on a timeless hero; reads as dated artifact.</DIM>
<DIM name="cta-label" score="6">"Request Access" is generic; "See the Deck" is more specific and action-confirming.</DIM>
<MUST_FIX>Change CTA label from "Request Access" to "See the Deck" for specificity.</MUST_FIX>
</PANELIST>
<ROUND_END n="1" composite="6.26" must_fix="8" decision="continue">
<REASON>Composite 6.26 is below threshold 8.0; 8 must-fix items open across critic, brand, a11y, and copy. Continue to round 2.</REASON>
</ROUND_END>
</ROUND>
<ROUND n="2">
<PANELIST role="designer">
<NOTES>Round 2 refinement: moved wordmark to top-right, adopted brand blue #1a6cf5 for CTA, normalized vertical spacing to 8px grid, added focus ring, wrapped in semantic landmarks, removed date from subtitle, updated CTA label.</NOTES>
</PANELIST>
<PANELIST role="critic" score="7.8" must_fix="2">
<DIM name="hierarchy" score="8">Wordmark top-right clears the primary read path; hierarchy now title to sub to CTA.</DIM>
<DIM name="type" score="8">8px rhythm applied consistently; heading still large but balanced by tighter sub spacing.</DIM>
<DIM name="contrast" score="7">Brand blue CTA passes AA at ~5.2:1; subtitle gray still at 5.5:1, acceptable.</DIM>
<DIM name="rhythm" score="8">Margins now multiples of 8; much more systematic.</DIM>
<DIM name="space" score="7">Horizontal padding increased to 56px; feels airy but right column reads empty.</DIM>
<MUST_FIX>Add a secondary visual element (rule or column) to balance right-side whitespace.</MUST_FIX>
<MUST_FIX>Tighten H1 line-height to 0.95 for denser poster feel.</MUST_FIX>
</PANELIST>
<PANELIST role="brand" score="8.2" must_fix="1">
<DIM name="voice" score="9">Headline unchanged; brand blue CTA unifies identity system across deck.</DIM>
<DIM name="color" score="8">Blue accent is immediately recognizable as the brand system color.</DIM>
<DIM name="lockup" score="8">Identity zone separated by rule; clean and professional.</DIM>
<MUST_FIX>Increase wordmark letter-spacing to 0.25em for premium print feel.</MUST_FIX>
</PANELIST>
<PANELIST role="a11y" score="7.5" must_fix="1">
<DIM name="color-contrast" score="8">CTA now passes AA; subtitle is acceptable.</DIM>
<DIM name="focus" score="7">Focus ring present but offset is 2px; raise to 3px per WCAG 2.2 guideline.</DIM>
<DIM name="semantics" score="7">main and header landmarks added; no skip-nav link yet.</DIM>
<MUST_FIX>Add a visually-hidden skip-navigation link before the main landmark.</MUST_FIX>
</PANELIST>
<PANELIST role="copy" score="8.0" must_fix="0">
<DIM name="headline" score="8">Remains strong; no changes needed.</DIM>
<DIM name="sub-copy" score="8">Date removed; subtitle now reads "Series B overview" which is clean and evergreen.</DIM>
<DIM name="cta-label" score="8">"See the Deck" is direct and confirms the action.</DIM>
</PANELIST>
<ROUND_END n="2" composite="7.86" must_fix="4" decision="continue">
<REASON>Composite 7.86 is below threshold 8.0; 4 must-fix items remain across critic, brand, and a11y. Continue to round 3.</REASON>
</ROUND_END>
</ROUND>
<ROUND n="3">
<PANELIST role="designer">
<NOTES>Round 3 polish: added decorative vertical rule at right to anchor whitespace, tightened H1 line-height to 0.95, raised wordmark letter-spacing to 0.25em, increased focus-ring offset to 3px, added visually-hidden skip-nav link.</NOTES>
</PANELIST>
<PANELIST role="critic" score="8.6" must_fix="0">
<DIM name="hierarchy" score="9">Clear top-right wordmark, dominant title, subdued subtitle, prominent CTA. Excellent path.</DIM>
<DIM name="type" score="9">H1 at 0.95 line-height gives tight poster texture; body type proportions now balanced.</DIM>
<DIM name="contrast" score="8">All elements pass AA; CTA 5.2:1, subtitle 5.5:1, body copy 14.5:1.</DIM>
<DIM name="rhythm" score="9">Consistent 8px multiples throughout; vertical rule reinforces grid axis.</DIM>
<DIM name="space" score="8">Right column balanced by rule; generous but not wasteful.</DIM>
</PANELIST>
<PANELIST role="brand" score="9.0" must_fix="0">
<DIM name="voice" score="9">Headline tone is authoritative; brand identity is coherent from wordmark to CTA.</DIM>
<DIM name="color" score="9">Brand blue fully integrated; palette is consistent and premium.</DIM>
<DIM name="lockup" score="9">Identity zone with rule separator and 0.25em letter-spacing reads as editorial quality.</DIM>
</PANELIST>
<PANELIST role="a11y" score="8.4" must_fix="0">
<DIM name="color-contrast" score="9">All text elements pass WCAG AA; CTA passes AA large.</DIM>
<DIM name="focus" score="8">Focus ring at 3px offset is clearly visible and meets 2.2 criterion 2.4.11.</DIM>
<DIM name="semantics" score="8">Landmarks correct; skip-nav present; heading hierarchy is single H1 with no skips.</DIM>
</PANELIST>
<PANELIST role="copy" score="8.4" must_fix="0">
<DIM name="headline" score="9">Punchy, memorable, and stakes-appropriate for Series B investor deck.</DIM>
<DIM name="sub-copy" score="8">Evergreen subtitle anchors context without expiry.</DIM>
<DIM name="cta-label" score="8">"See the Deck" is action-confirming and specific.</DIM>
</PANELIST>
<ROUND_END n="3" composite="8.60" must_fix="0" decision="ship">
<REASON>Composite 8.60 exceeds threshold 8.0; zero must-fix items remain. Ship.</REASON>
</ROUND_END>
</ROUND>
<SHIP round="3" composite="8.60" status="shipped">
<ARTIFACT mime="text/html"><![CDATA[
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Investor Deck Cover</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0a0a0a;color:#f5f5f5;min-height:100vh;display:flex;align-items:center;justify-content:center}
.skip-nav{position:absolute;left:-9999px}.skip-nav:focus{left:16px;top:16px;z-index:100;background:#1a6cf5;color:#fff;padding:8px 16px;border-radius:4px}
.poster{width:960px;padding:56px 56px 56px;position:relative;border-right:1px solid #222}
header{display:flex;justify-content:flex-end;margin-bottom:64px}
.wordmark{font-size:13px;letter-spacing:.25em;text-transform:uppercase;color:#666}
h1{font-size:72px;font-weight:800;line-height:.95;margin-bottom:24px;color:#fff}
.sub{font-size:18px;color:#aaa;margin-bottom:48px}
.cta{display:inline-block;padding:14px 32px;background:#1a6cf5;color:#fff;font-weight:700;font-size:16px;border-radius:4px;text-decoration:none}
.cta:focus-visible{outline:3px solid #fff;outline-offset:3px}
</style>
</head>
<body>
<a class="skip-nav" href="#main">Skip to content</a>
<div class="poster">
<header><span class="wordmark">Acme Ventures</span></header>
<main id="main">
<h1>The Future of<br>Infrastructure</h1>
<p class="sub">Series B overview</p>
<a class="cta" href="#">See the Deck</a>
</main>
</div>
</body>
</html>
]]></ARTIFACT>
<SUMMARY>Across three rounds the panel converged from a rough poster sketch (composite 6.26) to a polished investor-deck hero (composite 8.60). The key changes were: moving the wordmark to the top-right to establish a clear top-to-bottom read path; replacing the generic orange CTA with brand blue #1a6cf5 for system coherence; normalizing all vertical spacing to an 8px grid; adding a decorative vertical rule to balance right-column whitespace; tightening H1 line-height to 0.95 for a denser poster texture; fixing WCAG AA contrast on the CTA; adding proper semantic landmarks, a visible focus ring, and a skip-navigation link; and sharpening the CTA label from "Request Access" to "See the Deck".</SUMMARY>
</SHIP>
</CRITIQUE_RUN>

View File

@@ -0,0 +1,88 @@
import { defaultCritiqueConfig, FALLBACK_POLICIES } from '@open-design/contracts/critique';
import type { CritiqueConfig } from '@open-design/contracts/critique';
/**
* Load CritiqueConfig from process.env. Keys map 1:1 to OD_CRITIQUE_*.
* Missing values fall back to defaultCritiqueConfig(). Invalid values
* (non-numeric, negative, out-of-range) throw RangeError so misconfig
* surfaces at boot, never silently.
*
* @see specs/current/critique-theater.md § Configuration (env vars)
*/
export function loadCritiqueConfigFromEnv(env: NodeJS.ProcessEnv = process.env): CritiqueConfig {
const defaults = defaultCritiqueConfig();
const enabled = parseEnabled(env['OD_CRITIQUE_ENABLED'], defaults.enabled);
const maxRounds = parsePositiveInt('OD_CRITIQUE_MAX_ROUNDS', env['OD_CRITIQUE_MAX_ROUNDS'], defaults.maxRounds);
const scoreThreshold = parseNonNegativeFloat('OD_CRITIQUE_SCORE_THRESHOLD', env['OD_CRITIQUE_SCORE_THRESHOLD'], defaults.scoreThreshold);
const scoreScale = parsePositiveInt('OD_CRITIQUE_SCORE_SCALE', env['OD_CRITIQUE_SCORE_SCALE'], defaults.scoreScale);
const perRoundTimeoutMs = parsePositiveInt('OD_CRITIQUE_PER_ROUND_TIMEOUT_MS', env['OD_CRITIQUE_PER_ROUND_TIMEOUT_MS'], defaults.perRoundTimeoutMs);
const totalTimeoutMs = parsePositiveInt('OD_CRITIQUE_TOTAL_TIMEOUT_MS', env['OD_CRITIQUE_TOTAL_TIMEOUT_MS'], defaults.totalTimeoutMs);
const parserMaxBlockBytes = parsePositiveInt('OD_CRITIQUE_PARSER_MAX_BLOCK_BYTES', env['OD_CRITIQUE_PARSER_MAX_BLOCK_BYTES'], defaults.parserMaxBlockBytes);
const fallbackPolicy = parseFallbackPolicy(env['OD_CRITIQUE_FALLBACK_POLICY'], defaults.fallbackPolicy);
// Cross-field validation: threshold cannot exceed scale.
if (scoreThreshold > scoreScale + 1e-9) {
throw new RangeError(
`OD_CRITIQUE_SCORE_THRESHOLD (${scoreThreshold}) must be <= OD_CRITIQUE_SCORE_SCALE (${scoreScale})`,
);
}
return {
...defaults,
enabled,
maxRounds,
scoreThreshold,
scoreScale,
perRoundTimeoutMs,
totalTimeoutMs,
parserMaxBlockBytes,
fallbackPolicy,
};
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
function parseEnabled(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined) return fallback;
const v = raw.trim().toLowerCase();
return v === 'true' || v === '1' || v === 'yes';
}
function parsePositiveInt(key: string, raw: string | undefined, fallback: number): number {
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
throw new RangeError(
`${key} must be a positive integer, got "${raw}"`,
);
}
return n;
}
function parseNonNegativeFloat(key: string, raw: string | undefined, fallback: number): number {
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) {
throw new RangeError(
`${key} must be a non-negative finite number, got "${raw}"`,
);
}
return n;
}
function parseFallbackPolicy(
raw: string | undefined,
fallback: CritiqueConfig['fallbackPolicy'],
): CritiqueConfig['fallbackPolicy'] {
if (raw === undefined) return fallback;
const trimmed = raw.trim();
if (FALLBACK_POLICIES.includes(trimmed as CritiqueConfig['fallbackPolicy'])) {
return trimmed as CritiqueConfig['fallbackPolicy'];
}
throw new RangeError(
`OD_CRITIQUE_FALLBACK_POLICY must be one of ${FALLBACK_POLICIES.join(', ')}, got "${raw}"`,
);
}

View File

@@ -0,0 +1,20 @@
export class MalformedBlockError extends Error {
constructor(message: string, public readonly position: number) {
super(message);
this.name = 'MalformedBlockError';
}
}
export class OversizeBlockError extends Error {
constructor(message: string, public readonly position: number) {
super(message);
this.name = 'OversizeBlockError';
}
}
export class MissingArtifactError extends Error {
constructor(message: string) {
super(message);
this.name = 'MissingArtifactError';
}
}

View File

@@ -0,0 +1,710 @@
import type { ChildProcess } from 'node:child_process';
import type Database from 'better-sqlite3';
import type { CritiqueConfig, PanelEvent } from '@open-design/contracts/critique';
import { panelEventToSse } from '@open-design/contracts/critique';
import type { CritiqueSseEvent } from '@open-design/contracts/critique';
import { parseCritiqueStream } from './parser.js';
import {
computeComposite,
decideRound,
selectFallbackRound,
type RoundState,
} from './scoreboard.js';
import {
insertCritiqueRun,
updateCritiqueRun,
type CritiqueRunRow,
} from './persistence.js';
import { writeTranscript } from './transcript.js';
import {
MalformedBlockError,
OversizeBlockError,
MissingArtifactError,
} from './errors.js';
/**
* Tolerance used when comparing the agent-supplied composite attribute on
* <ROUND_END> / <SHIP> against the daemon's computed composite. Composites
* are weighted floats so a tiny FP delta is normal; anything larger than this
* is reported as a composite_mismatch parser warning.
*/
const COMPOSITE_TOLERANCE = 0.01;
/**
* SSE bus contract: the orchestrator emits CritiqueSseEvent variants here so
* the existing /api/projects/:id/events stream can fan them out unchanged.
* Implementations should be non-blocking; backpressure is the caller's job.
*/
export interface CritiqueSseBus {
emit(event: CritiqueSseEvent): void;
}
export interface OrchestratorParams {
runId: string;
projectId: string;
conversationId: string | null;
artifactId: string;
artifactDir: string;
adapter: string;
cfg: CritiqueConfig;
db: Database.Database;
bus: CritiqueSseBus;
/**
* Source of CLI stdout. The orchestrator is transport-agnostic: a real
* spawn wrapper passes the child process stdout, tests pass a synthetic
* iterable.
*/
stdout: AsyncIterable<string>;
/**
* Optional abort signal. Aborting causes the orchestrator to flush
* best-so-far state and emit critique.interrupted before returning.
*/
signal?: AbortSignal;
/**
* Optional handle to the spawned child process. When provided the
* orchestrator calls child.kill('SIGTERM') on every non-clean termination
* path (timeout, abort, parser error, child non-zero exit).
*/
child?: Pick<ChildProcess, 'kill'>;
/**
* Resolves when the child process exits. Used to race parser completion
* against an early child exit so a non-zero exit code is classified as
* 'failed' rather than waiting for the parser to time out.
*/
childExitPromise?: Promise<{ code: number | null; signal: string | null }>;
}
export interface OrchestratorResult {
status: CritiqueRunRow['status'];
composite: number | null;
rounds: CritiqueRunRow['rounds'];
transcriptPath: string | null;
artifactPath: string | null;
}
/**
* Drives one Critique Theater run end-to-end:
* parse stdout -> collect events -> score per round -> persist -> emit SSE.
*
* @see specs/current/critique-theater.md § Wire protocol parser invariants
* and § Failure modes (recovery)
*/
export async function runOrchestrator(
params: OrchestratorParams,
): Promise<OrchestratorResult> {
const { runId, projectId, conversationId, artifactDir, adapter, cfg, db, bus, stdout } = params;
const signal = params.signal;
const child = params.child;
const childExitPromise = params.childExitPromise;
// Defensive entry: validate every CritiqueConfig numeric field before any side effect.
if (!Number.isFinite(cfg.maxRounds) || cfg.maxRounds < 1) {
throw new RangeError(`runOrchestrator: cfg.maxRounds must be a positive integer, got ${cfg.maxRounds}`);
}
if (!Number.isFinite(cfg.scoreScale) || cfg.scoreScale < 1) {
throw new RangeError(`runOrchestrator: cfg.scoreScale must be a positive integer, got ${cfg.scoreScale}`);
}
if (!Number.isFinite(cfg.scoreThreshold) || cfg.scoreThreshold < 0) {
throw new RangeError(`runOrchestrator: cfg.scoreThreshold must be >= 0, got ${cfg.scoreThreshold}`);
}
if (!Number.isFinite(cfg.perRoundTimeoutMs) || cfg.perRoundTimeoutMs < 1) {
throw new RangeError(`runOrchestrator: cfg.perRoundTimeoutMs must be positive, got ${cfg.perRoundTimeoutMs}`);
}
if (!Number.isFinite(cfg.totalTimeoutMs) || cfg.totalTimeoutMs < 1) {
throw new RangeError(`runOrchestrator: cfg.totalTimeoutMs must be positive, got ${cfg.totalTimeoutMs}`);
}
if (!Number.isFinite(cfg.parserMaxBlockBytes) || cfg.parserMaxBlockBytes < 1) {
throw new RangeError(`runOrchestrator: cfg.parserMaxBlockBytes must be positive, got ${cfg.parserMaxBlockBytes}`);
}
// 1. Insert a 'running' row.
insertCritiqueRun(db, {
id: runId,
projectId,
conversationId,
status: 'running',
protocolVersion: cfg.protocolVersion,
});
const collectedEvents: PanelEvent[] = [];
const roundStates = new Map<number, RoundState>();
const completedRounds: RoundState[] = [];
let artifactPath: string | null = null;
let shipEvent: Extract<PanelEvent, { type: 'ship' }> | null = null;
let finalStatus: CritiqueRunRow['status'] = 'failed';
let finalComposite: number | null = null;
let transcriptPath: string | null = null;
// Total deadline.
const totalDeadline = Date.now() + cfg.totalTimeoutMs;
// Helper: SIGTERM the child on non-clean termination paths.
const killChild = () => { child?.kill('SIGTERM'); };
// Build a rejection promise for early child exit with non-zero code or
// signal-terminated exit. Resolves (not rejects) only for a clean code 0
// exit with no signal so the parser loop can finish naturally. A non-null
// signal means the child was killed (by us, by the user via /cancel, by
// the OS, etc.) and is treated as terminal so the orchestrator can persist
// 'interrupted' instead of falling through to the no-SHIP fallback path
// and reporting below_threshold for a user-cancelled run.
const childExitRace: Promise<never> | null = childExitPromise
? childExitPromise.then(({ code, signal: exitSignal }) => {
if (exitSignal !== null) {
return Promise.reject(new ChildSignaledError(exitSignal));
}
if (code !== 0 && code !== null) {
return Promise.reject(new ChildExitError(code));
}
// Clean exit with no signal: let the parser finish naturally.
return new Promise<never>(() => { /* intentionally pending */ });
})
: null;
try {
// Per-round timeout tracking.
let roundDeadline: number | null = null;
let currentRoundN: number | null = null;
// Wrap parser with abort + total-timeout awareness.
const timedSource = applyTimeouts(stdout, {
signal,
totalDeadline,
getPerRoundDeadline: () => roundDeadline,
childExitRace,
});
const parserOpts = {
runId,
adapter,
parserMaxBlockBytes: cfg.parserMaxBlockBytes,
projectId,
artifactId: params.artifactId,
};
for await (const event of parseCritiqueStream(timedSource, parserOpts)) {
// Ship events are buffered, not emitted raw. The normalized ship event
// (with daemon-authoritative status/composite from decideRound(...))
// is emitted after the loop so SSE clients and the transcript only
// ever see daemon-scored ship payloads, not the agent's raw claim.
if (event.type !== 'ship') {
collectedEvents.push(event);
bus.emit(panelEventToSse(event));
}
switch (event.type) {
case 'run_started': {
break;
}
case 'panelist_open': {
if (!roundStates.has(event.round)) {
roundStates.set(event.round, {
n: event.round,
scores: {},
mustFix: 0,
composite: 0,
});
}
if (event.round !== currentRoundN) {
currentRoundN = event.round;
roundDeadline = Date.now() + cfg.perRoundTimeoutMs;
}
break;
}
case 'panelist_close': {
const rs = roundStates.get(event.round);
if (rs !== undefined) {
rs.scores[event.role] = event.score;
rs.composite = computeComposite(rs.scores, cfg.weights);
}
break;
}
case 'panelist_must_fix': {
const rs = roundStates.get(event.round);
if (rs !== undefined) {
rs.mustFix += 1;
}
break;
}
case 'round_end': {
const rs = roundStates.get(event.round);
if (rs !== undefined) {
// Daemon-side composite (computed via configured weights from
// panelist_close events) is the source of truth. The agent's
// <ROUND_END composite="..."> attribute is advisory: if it
// diverges beyond COMPOSITE_TOLERANCE we emit a composite_mismatch
// parser_warning, but the daemon value is what scores and persists.
// Same policy for mustFix, which is tallied from panelist_must_fix
// events.
if (Math.abs(event.composite - rs.composite) > COMPOSITE_TOLERANCE
|| event.mustFix !== rs.mustFix) {
const warning: Extract<PanelEvent, { type: 'parser_warning' }> = {
type: 'parser_warning',
runId,
kind: 'composite_mismatch',
position: 0,
};
collectedEvents.push(warning);
bus.emit(panelEventToSse(warning));
}
completedRounds.push({ ...rs });
}
roundDeadline = null;
break;
}
case 'ship': {
shipEvent = event;
break;
}
case 'panelist_dim': {
// Extract designer round-1 ARTIFACT reference from dimNote is not
// our job here; artifact path comes from the ship event's artifactRef
// or from a panelist block. We store the artifactId from the ship event below.
break;
}
default:
break;
}
}
// 3. Determine final status and composite.
//
// The agent's raw <SHIP> was buffered (not emitted) by the parser loop
// above. We resolve it here against the daemon scoreboard, then emit a
// single normalized ship event so the transcript and SSE bus reflect the
// daemon-authoritative status/composite, not the agent's claim.
let resolvedShip = shipEvent;
if (resolvedShip !== null) {
const shippedRound = completedRounds.find((r) => r.n === resolvedShip!.round);
if (shippedRound === undefined) {
// The agent claimed a SHIP for a round that was never closed by the
// daemon. Trusting it would re-open the scoring-integrity hole this
// patch is meant to close, so we drop the agent ship, emit a
// parser_warning, and fall through to the no-SHIP fallback policy.
const warning: Extract<PanelEvent, { type: 'parser_warning' }> = {
type: 'parser_warning',
runId,
kind: 'duplicate_ship',
position: 0,
};
collectedEvents.push(warning);
bus.emit(panelEventToSse(warning));
resolvedShip = null;
}
}
if (resolvedShip !== null) {
// Daemon-authoritative scoring: derive status from decideRound(...)
// using the daemon's computed composite/mustFix rather than the
// agent's <SHIP composite=... status=...> attributes. A composite
// divergence larger than COMPOSITE_TOLERANCE emits composite_mismatch.
const ship = resolvedShip;
const shippedRound = completedRounds.find((r) => r.n === ship.round)!;
if (Math.abs(ship.composite - shippedRound.composite) > COMPOSITE_TOLERANCE) {
const warning: Extract<PanelEvent, { type: 'parser_warning' }> = {
type: 'parser_warning',
runId,
kind: 'composite_mismatch',
position: 0,
};
collectedEvents.push(warning);
bus.emit(panelEventToSse(warning));
}
const decision = decideRound(shippedRound.composite, shippedRound.mustFix, cfg);
finalStatus = decision === 'ship' ? 'shipped' : 'below_threshold';
finalComposite = shippedRound.composite;
// Emit the daemon-authoritative ship event. SSE clients and the
// transcript see this single normalized payload, never the raw agent
// claim from the buffered shipEvent.
const normalizedShip: Extract<PanelEvent, { type: 'ship' }> = {
type: 'ship',
runId,
round: shippedRound.n,
composite: shippedRound.composite,
status: finalStatus,
artifactRef: { projectId, artifactId: params.artifactId },
summary: ship.summary,
};
collectedEvents.push(normalizedShip);
bus.emit(panelEventToSse(normalizedShip));
// artifactPath stays null until a future phase actually extracts the
// <SHIP><ARTIFACT> body and writes it to disk. Persisting a synthesized
// path that no file occupies would let UI/replay/export code dereference
// a missing file. The transcript still carries the ship event with the
// artifact reference so consumers can find the run.
artifactPath = null;
} else {
// No SHIP arrived (or the agent SHIP was rejected as malformed above).
// Apply fallback policy over the daemon's closed rounds.
killChild();
const fallback = selectFallbackRound(completedRounds, cfg.fallbackPolicy);
if (fallback !== null) {
finalStatus = 'below_threshold';
finalComposite = fallback.composite;
// Emit a synthetic ship event.
const syntheticShip: Extract<PanelEvent, { type: 'ship' }> = {
type: 'ship',
runId,
round: fallback.n,
composite: fallback.composite,
status: 'below_threshold',
artifactRef: { projectId, artifactId: params.artifactId },
summary: `Fallback: best round ${fallback.n} composite ${fallback.composite.toFixed(2)}`,
};
collectedEvents.push(syntheticShip);
bus.emit(panelEventToSse(syntheticShip));
} else {
finalStatus = 'failed';
finalComposite = null;
const failedEvent: Extract<PanelEvent, { type: 'failed' }> = {
type: 'failed',
runId,
cause: 'orchestrator_internal',
};
collectedEvents.push(failedEvent);
bus.emit(panelEventToSse(failedEvent));
}
}
} catch (err) {
// All non-clean termination paths: SIGTERM the child.
killChild();
// Classify the error.
if (err instanceof AbortError) {
finalStatus = 'interrupted';
// Defect 7: ship best-so-far when at least one round completed.
const fallback = completedRounds.length > 0
? selectFallbackRound(completedRounds, cfg.fallbackPolicy)
: null;
if (fallback !== null) {
finalComposite = fallback.composite;
const syntheticShip: Extract<PanelEvent, { type: 'ship' }> = {
type: 'ship',
runId,
round: fallback.n,
composite: fallback.composite,
status: 'interrupted',
artifactRef: { projectId, artifactId: params.artifactId },
summary: `Interrupted after round ${fallback.n}, best composite ${fallback.composite.toFixed(2)}`,
};
collectedEvents.push(syntheticShip);
bus.emit(panelEventToSse(syntheticShip));
}
const interruptedEvent: Extract<PanelEvent, { type: 'interrupted' }> = {
type: 'interrupted',
runId,
bestRound: completedRounds.length > 0 ? (completedRounds[completedRounds.length - 1]?.n ?? 0) : 0,
composite: finalComposite ?? 0,
};
collectedEvents.push(interruptedEvent);
bus.emit(panelEventToSse(interruptedEvent));
} else if (err instanceof TimeoutError) {
finalStatus = 'timed_out';
// Defect 7: ship best-so-far when at least one round completed.
const fallback = completedRounds.length > 0
? selectFallbackRound(completedRounds, cfg.fallbackPolicy)
: null;
if (fallback !== null) {
finalComposite = fallback.composite;
const syntheticShip: Extract<PanelEvent, { type: 'ship' }> = {
type: 'ship',
runId,
round: fallback.n,
composite: fallback.composite,
status: 'timed_out',
artifactRef: { projectId, artifactId: params.artifactId },
summary: `Timed out after round ${fallback.n}, best composite ${fallback.composite.toFixed(2)}`,
};
collectedEvents.push(syntheticShip);
bus.emit(panelEventToSse(syntheticShip));
}
const failedEvent: Extract<PanelEvent, { type: 'failed' }> = {
type: 'failed',
runId,
cause: err.cause,
};
collectedEvents.push(failedEvent);
bus.emit(panelEventToSse(failedEvent));
} else if (err instanceof ChildExitError) {
finalStatus = 'failed';
const failedEvent: Extract<PanelEvent, { type: 'failed' }> = {
type: 'failed',
runId,
cause: 'cli_exit_nonzero',
};
collectedEvents.push(failedEvent);
bus.emit(panelEventToSse(failedEvent));
} else if (err instanceof ChildSignaledError) {
// Signal-terminated child (e.g. SIGTERM from /api/runs/:id/cancel)
// is classified as 'interrupted' so the persisted critique row
// reflects the actual cause (user/operator interruption) rather
// than getting flushed through the no-SHIP fallback as
// 'below_threshold'. If at least one round closed cleanly, ship
// the best-so-far via selectFallbackRound, mirroring the abort path.
finalStatus = 'interrupted';
const fallback = completedRounds.length > 0
? selectFallbackRound(completedRounds, cfg.fallbackPolicy)
: null;
if (fallback !== null) {
finalComposite = fallback.composite;
const syntheticShip: Extract<PanelEvent, { type: 'ship' }> = {
type: 'ship',
runId,
round: fallback.n,
composite: fallback.composite,
status: 'interrupted',
artifactRef: { projectId, artifactId: params.artifactId },
summary: `Child terminated by signal ${err.signal} after round ${fallback.n}, best composite ${fallback.composite.toFixed(2)}`,
};
collectedEvents.push(syntheticShip);
bus.emit(panelEventToSse(syntheticShip));
}
const interruptedEvent: Extract<PanelEvent, { type: 'interrupted' }> = {
type: 'interrupted',
runId,
bestRound: completedRounds.length > 0
? (completedRounds[completedRounds.length - 1]?.n ?? 0)
: 0,
composite: finalComposite ?? 0,
};
collectedEvents.push(interruptedEvent);
bus.emit(panelEventToSse(interruptedEvent));
} else if (
err instanceof MalformedBlockError ||
err instanceof OversizeBlockError ||
err instanceof MissingArtifactError
) {
finalStatus = 'degraded';
const reason =
err instanceof MalformedBlockError ? 'malformed_block' :
err instanceof OversizeBlockError ? 'oversize_block' :
'missing_artifact';
const degradedEvent: Extract<PanelEvent, { type: 'degraded' }> = {
type: 'degraded',
runId,
reason,
adapter,
};
collectedEvents.push(degradedEvent);
bus.emit(panelEventToSse(degradedEvent));
} else {
finalStatus = 'failed';
const failedEvent: Extract<PanelEvent, { type: 'failed' }> = {
type: 'failed',
runId,
cause: 'orchestrator_internal',
};
collectedEvents.push(failedEvent);
bus.emit(panelEventToSse(failedEvent));
}
}
// Write transcript for all non-trivially-failed runs.
if (finalStatus !== 'failed' || collectedEvents.length > 0) {
try {
const result = await writeTranscript(artifactDir, collectedEvents);
transcriptPath = result.path;
} catch {
// Transcript write failure must not mask the primary outcome.
transcriptPath = null;
}
}
// Build rounds summary for persistence.
const roundsSummary = completedRounds.map((r) => ({
n: r.n,
composite: r.composite,
mustFix: r.mustFix,
decision: decideRound(r.composite, r.mustFix, cfg) as 'continue' | 'ship',
}));
// Persist final state.
updateCritiqueRun(db, runId, {
status: finalStatus,
score: finalComposite,
rounds: roundsSummary,
transcriptPath,
artifactPath,
});
return {
status: finalStatus,
composite: finalComposite,
rounds: roundsSummary,
transcriptPath,
artifactPath,
};
}
// ---------------------------------------------------------------------------
// Internal timeout / abort utilities
// ---------------------------------------------------------------------------
class AbortError extends Error {
constructor() {
super('run aborted');
this.name = 'AbortError';
}
}
class TimeoutError extends Error {
constructor(
message: string,
public readonly cause: 'per_round_timeout' | 'total_timeout',
) {
super(message);
this.name = 'TimeoutError';
}
}
/** Thrown when the child process exits with a non-zero code before the parser finishes. */
class ChildExitError extends Error {
constructor(public readonly code: number) {
super(`child exited with code ${code}`);
this.name = 'ChildExitError';
}
}
/**
* Thrown when the child process is signal-terminated (SIGTERM, SIGINT, etc.)
* before the parser finishes. From the orchestrator's perspective this is
* always treated as 'interrupted': the daemon kills the child via
* /api/runs/:id/cancel, the user kills it manually, or the OS terminates it.
* Either way the run was cut short externally and shouldn't fall through to
* the no-SHIP fallback path that would persist below_threshold.
*/
class ChildSignaledError extends Error {
constructor(public readonly signal: string) {
super(`child terminated by signal ${signal}`);
this.name = 'ChildSignaledError';
}
}
interface TimeoutOptions {
signal: AbortSignal | undefined;
totalDeadline: number;
getPerRoundDeadline: () => number | null;
/** When provided, races each iteration against a child-exit rejection. */
childExitRace: Promise<never> | null;
}
/**
* Builds a Promise that rejects with TimeoutError after delayMs, or resolves
* immediately when delayMs <= 0. Returns a cancel function to clear the timer.
*/
function makeTimeoutRace(
delayMs: number,
cause: 'per_round_timeout' | 'total_timeout',
): { promise: Promise<never>; cancel: () => void } {
let timerId: ReturnType<typeof setTimeout> | undefined;
let rejectFn!: (e: TimeoutError) => void;
const promise = new Promise<never>((_, reject) => {
rejectFn = reject;
if (delayMs <= 0) {
reject(new TimeoutError(`${cause} exceeded`, cause));
} else {
timerId = setTimeout(() => reject(new TimeoutError(`${cause} exceeded`, cause)), delayMs);
}
});
const cancel = () => {
if (timerId !== undefined) clearTimeout(timerId);
// Prevent unhandled rejection after cancel.
promise.catch(() => { /* intentionally swallowed */ });
};
void rejectFn; // suppress unused-variable warning
return { promise, cancel };
}
/**
* Wraps a source AsyncIterable<string> with abort and real-timer timeout
* enforcement. Each call to iterator.next() is raced against the total-
* deadline timer and the current per-round deadline timer so stalling
* sources (no chunks arriving) are caught even when the source never yields.
*/
async function* applyTimeouts(
source: AsyncIterable<string>,
opts: TimeoutOptions,
): AsyncIterable<string> {
const iter = source[Symbol.asyncIterator]();
// Keep a single total timer running for the full lifetime of the source.
const totalDelayMs = opts.totalDeadline - Date.now();
const totalTimer = makeTimeoutRace(totalDelayMs, 'total_timeout');
try {
while (true) {
// Check abort eagerly before each iteration.
if (opts.signal?.aborted) {
throw new AbortError();
}
// Build per-round timer for this iteration.
const roundDeadline = opts.getPerRoundDeadline();
const roundDelayMs = roundDeadline !== null ? roundDeadline - Date.now() : null;
let roundTimer: { promise: Promise<never>; cancel: () => void } | null = null;
if (roundDelayMs !== null) {
roundTimer = makeTimeoutRace(roundDelayMs, 'per_round_timeout');
}
let iterResult: IteratorResult<string>;
try {
const races: Promise<unknown>[] = [iter.next(), totalTimer.promise];
if (roundTimer !== null) races.push(roundTimer.promise);
// AbortSignal race: if signal fires, reject immediately.
if (opts.signal) {
const abortPromise = new Promise<never>((_, reject) => {
if (opts.signal!.aborted) {
reject(new AbortError());
} else {
opts.signal!.addEventListener('abort', () => reject(new AbortError()), { once: true });
}
});
races.push(abortPromise);
}
// Child-exit race: if the child exits non-zero before the parser
// finishes, surface ChildExitError so the run is classified as
// 'failed' with cause 'cli_exit_nonzero' rather than waiting for
// the total timeout.
if (opts.childExitRace !== null) {
races.push(opts.childExitRace);
}
iterResult = await Promise.race(races) as IteratorResult<string>;
} finally {
roundTimer?.cancel();
}
if (iterResult.done) {
break;
}
yield iterResult.value;
}
} finally {
totalTimer.cancel();
// Give the underlying iterator a chance to clean up. Use a 200ms timeout
// so a stalling generator (e.g. one stuck in await new Promise(() => {}))
// never blocks the orchestrator teardown path indefinitely.
if (typeof iter.return === 'function') {
await Promise.race([
iter.return().catch(() => { /* ignore cleanup errors */ }),
new Promise<void>((resolve) => setTimeout(resolve, 200)),
]);
}
}
// Final abort check after source exhausted.
if (opts.signal?.aborted) {
throw new AbortError();
}
}

View File

@@ -0,0 +1,21 @@
import type { PanelEvent } from '@open-design/contracts/critique';
import { parseV1 } from './parsers/v1.js';
export interface ParserOptions {
runId: string;
adapter: string;
parserMaxBlockBytes: number;
/** Project identity threaded into ship event artifactRef. */
projectId?: string;
/** Artifact identity threaded into ship event artifactRef. */
artifactId?: string;
}
export async function* parseCritiqueStream(
source: AsyncIterable<string>,
opts: ParserOptions,
): AsyncIterable<PanelEvent> {
// For v1, the version is detected from <CRITIQUE_RUN version="1"> in the first chunk.
// Only v1 exists currently so we always dispatch to parsers/v1.
yield* parseV1(source, opts);
}

View File

@@ -0,0 +1,508 @@
import type { PanelEvent, PanelistRole } from '@open-design/contracts/critique';
import { MalformedBlockError, MissingArtifactError, OversizeBlockError } from '../errors.js';
const KNOWN_ROLES: ReadonlySet<string> = new Set(['designer', 'critic', 'brand', 'a11y', 'copy']);
// Hoisted regexes reused across emitInner invocations. Reset lastIndex before each loop.
const DIM_RE = /<DIM\s+name="([^"]+)"\s+score="([^"]+)">([\s\S]*?)<\/DIM>/g;
const MUST_FIX_RE = /<MUST_FIX>([\s\S]*?)<\/MUST_FIX>/g;
const DEFAULT_SCORE_SCALE = 10;
interface State {
buf: string;
consumed: number;
runId: string;
adapter: string;
protocolVersion: number;
// Captured from <CRITIQUE_RUN scale="..."> so score bounds match the run's declared scale,
// not a hardcoded 100. Defaults to DEFAULT_SCORE_SCALE before run_started is parsed.
scoreScale: number;
// Hard cap on bytes between matched open/close tags. Enforced inside drain on
// every buffered block (PANELIST, ROUND_END, SHIP) so an oversized block that
// arrives intact in one chunk is rejected before its body is sliced and emitted.
// The post-drain check on state.buf only catches *unclosed* runaway blocks.
parserMaxBlockBytes: number;
// Threaded from parser options into ship event artifactRef so downstream
// consumers see the real run identity instead of empty placeholders.
projectId: string;
artifactId: string;
inRun: boolean;
currentRound: number | null;
// Count of <ROUND_END> events fired since the last <CRITIQUE_RUN> opener.
// Used by the SHIP envelope guard: a SHIP that arrives before any round
// completes is malformed and must be rejected.
roundsClosed: number;
shipSeen: boolean;
designerArtifactInRound1: boolean;
lastAdvance: number;
}
export async function* parseV1(
source: AsyncIterable<string>,
opts: {
runId: string;
adapter: string;
parserMaxBlockBytes: number;
projectId?: string;
artifactId?: string;
},
): AsyncIterable<PanelEvent> {
const state: State = {
buf: '',
consumed: 0,
runId: opts.runId,
adapter: opts.adapter,
protocolVersion: 1,
scoreScale: DEFAULT_SCORE_SCALE,
parserMaxBlockBytes: opts.parserMaxBlockBytes,
projectId: opts.projectId ?? '',
artifactId: opts.artifactId ?? '',
inRun: false,
currentRound: null,
roundsClosed: 0,
shipSeen: false,
designerArtifactInRound1: false,
lastAdvance: 0,
};
for await (const chunk of source) {
state.buf += chunk;
yield* drain(state);
// After drain, anything still in the buffer is a partial tag waiting on more input.
// If that pending block is bigger than the cap, the producer is stuck inside one
// unclosed block and we have to fail rather than buffer indefinitely. Compare in
// UTF-8 bytes (mrcfps review #2) so a buffer full of CJK or emoji cannot exceed
// the configured byte cap while staying under the JS string length cap.
const bufBytes = Buffer.byteLength(state.buf, 'utf8');
if (bufBytes > opts.parserMaxBlockBytes) {
throw new OversizeBlockError(
`block exceeded ${opts.parserMaxBlockBytes} bytes at position ${state.consumed}`,
state.consumed,
);
}
}
yield* drain(state);
// End-of-stream invariants.
if (state.inRun && !state.shipSeen) {
throw new MalformedBlockError(
`CRITIQUE_RUN never closed (no </CRITIQUE_RUN> and no <SHIP>) at position ${state.consumed}`,
state.consumed,
);
}
}
function* drain(state: State): Generator<PanelEvent> {
let cursor = 0;
while (cursor < state.buf.length) {
const slice = state.buf.slice(cursor);
// <CRITIQUE_RUN ...>
if (slice.startsWith('<CRITIQUE_RUN ')) {
const close = slice.indexOf('>');
if (close < 0) break;
const attrs = parseAttrs(slice.slice('<CRITIQUE_RUN'.length, close));
state.protocolVersion = Number(attrs['version'] ?? '1');
const declaredScale = Number(attrs['scale'] ?? String(DEFAULT_SCORE_SCALE));
state.scoreScale = isFinite(declaredScale) && declaredScale > 0 ? declaredScale : DEFAULT_SCORE_SCALE;
state.inRun = true;
yield {
type: 'run_started',
runId: state.runId,
protocolVersion: state.protocolVersion,
cast: ['designer', 'critic', 'brand', 'a11y', 'copy'],
maxRounds: Number(attrs['maxRounds'] ?? '3'),
threshold: Number(attrs['threshold'] ?? '8.0'),
scale: state.scoreScale,
};
cursor += close + 1;
state.lastAdvance = state.consumed + cursor;
continue;
}
// <ROUND n="N">
const roundMatch = slice.match(/^<ROUND\s+([^>]*)>/);
if (roundMatch) {
// Envelope guard (mrcfps review #2): no run-level event may appear before
// <CRITIQUE_RUN ...> opens the envelope, otherwise downstream consumers
// see contract-shaped events without the required run_started handshake.
if (!state.inRun) {
throw new MalformedBlockError(
`<ROUND> at position ${state.consumed + cursor} appeared before <CRITIQUE_RUN>`,
state.consumed + cursor,
);
}
const a = parseAttrs(roundMatch[1] ?? '');
state.currentRound = Number(a['n']);
cursor += roundMatch[0].length;
state.lastAdvance = state.consumed + cursor;
continue;
}
// <PANELIST ...>...</PANELIST>
if (
slice.startsWith('<PANELIST ') ||
slice.startsWith('<PANELIST\t') ||
slice.startsWith('<PANELIST\n')
) {
if (!state.inRun) {
throw new MalformedBlockError(
`<PANELIST> at position ${state.consumed + cursor} appeared before <CRITIQUE_RUN>`,
state.consumed + cursor,
);
}
const closeIdx = slice.indexOf('</PANELIST>');
if (closeIdx < 0) break;
// Per-block size enforcement (mrcfps review): a complete oversized block
// that arrives in one large chunk would otherwise slip past the post-drain
// buf-size check because its body would be sliced and emitted before the
// check ran. Catch it here, before any work happens. Use UTF-8 byte length
// so multibyte content (CJK, emoji) cannot bypass the byte-defined cap.
const blockText = slice.slice(0, closeIdx + '</PANELIST>'.length);
const blockBytes = Buffer.byteLength(blockText, 'utf8');
if (blockBytes > state.parserMaxBlockBytes) {
throw new OversizeBlockError(
`PANELIST block of ${blockBytes} bytes exceeded ${state.parserMaxBlockBytes} at position ${state.consumed + cursor}`,
state.consumed + cursor,
);
}
const headEnd = slice.indexOf('>');
// headEnd must be the opener's closing >, which has to come BEFORE the
// matched </PANELIST>. Without this guard a malformed opener like
// <PANELIST role="critic" score="8"</PANELIST> (no opening >) would
// pick up the closing tag's > and emit panelist events for an invalid block.
if (headEnd < 0) break;
if (headEnd >= closeIdx) {
throw new MalformedBlockError(
`<PANELIST> opening tag at position ${state.consumed + cursor} has no closing > before </PANELIST>`,
state.consumed + cursor,
);
}
const head = slice.slice('<PANELIST'.length, headEnd);
const body = slice.slice(headEnd + 1, closeIdx);
// Nesting guard: if another <PANELIST opening appears inside what we believe
// is this PANELIST body, the current block was never closed and we are about
// to mis-attribute the next sibling's content. Treat as malformed.
if (/<PANELIST[\s>]/.test(body)) {
throw new MalformedBlockError(
`PANELIST block at position ${state.consumed + cursor} never closed before the next <PANELIST opening`,
state.consumed + cursor,
);
}
const attrs = parseAttrs(head);
const roleStr = attrs['role'];
if (!roleStr || !KNOWN_ROLES.has(roleStr)) {
yield {
type: 'parser_warning',
runId: state.runId,
kind: 'unknown_role',
position: state.consumed + cursor,
};
cursor += closeIdx + '</PANELIST>'.length;
state.lastAdvance = state.consumed + cursor;
continue;
}
const role = roleStr as PanelistRole;
// A PANELIST block must appear inside a <ROUND n="..."> envelope. If no round
// has been opened (or the n attribute parsed to NaN), the stream is malformed
// and emitting events with an invalid round would corrupt every downstream
// consumer (reducer, scoreboard, persistence).
if (state.currentRound == null || !Number.isFinite(state.currentRound)) {
throw new MalformedBlockError(
`PANELIST at position ${state.consumed + cursor} appeared before a valid <ROUND n="..."> opening`,
state.consumed + cursor,
);
}
const round = state.currentRound;
yield { type: 'panelist_open', runId: state.runId, round, role };
yield* emitInner(state, role, body);
const rawScore = Number(attrs['score'] ?? '0');
const score = clampScore(rawScore, state.scoreScale);
if (isOutOfRange(rawScore, state.scoreScale)) {
yield {
type: 'parser_warning',
runId: state.runId,
kind: 'score_clamped',
position: state.consumed + cursor,
};
}
yield { type: 'panelist_close', runId: state.runId, round, role, score };
cursor += closeIdx + '</PANELIST>'.length;
state.lastAdvance = state.consumed + cursor;
continue;
}
// <ROUND_END n="N" ...>...</ROUND_END>
if (slice.startsWith('<ROUND_END ')) {
if (!state.inRun) {
throw new MalformedBlockError(
`<ROUND_END> at position ${state.consumed + cursor} appeared before <CRITIQUE_RUN>`,
state.consumed + cursor,
);
}
const closeIdx = slice.indexOf('</ROUND_END>');
if (closeIdx < 0) break;
const blockText = slice.slice(0, closeIdx + '</ROUND_END>'.length);
const blockBytes = Buffer.byteLength(blockText, 'utf8');
if (blockBytes > state.parserMaxBlockBytes) {
throw new OversizeBlockError(
`ROUND_END block of ${blockBytes} bytes exceeded ${state.parserMaxBlockBytes} at position ${state.consumed + cursor}`,
state.consumed + cursor,
);
}
const headEnd = slice.indexOf('>');
if (headEnd < 0) break;
if (headEnd >= closeIdx) {
throw new MalformedBlockError(
`<ROUND_END> opening tag at position ${state.consumed + cursor} has no closing > before </ROUND_END>`,
state.consumed + cursor,
);
}
const attrs = parseAttrs(slice.slice('<ROUND_END'.length, headEnd));
const inner = slice.slice(headEnd + 1, closeIdx);
const reason = (inner.match(/<REASON>([\s\S]*?)<\/REASON>/)?.[1] ?? '').trim();
// The wire protocol (spec § Wire protocol parser invariants) requires the
// designer to emit exactly one <ARTIFACT> in round 1. Subsequent rounds may
// omit ARTIFACT and ship NOTES-only (the designer is iterating in place).
// If protocol v2 ever relaxes this to "at any point before SHIP", widen the
// check to use a `designerArtifactSeen` flag instead.
if (state.currentRound === 1 && !state.designerArtifactInRound1) {
throw new MissingArtifactError(
`round 1 closed at position ${state.consumed + cursor} without designer ARTIFACT`,
);
}
yield {
type: 'round_end',
runId: state.runId,
round: Number(attrs['n']),
composite: Number(attrs['composite'] ?? '0'),
mustFix: Number(attrs['must_fix'] ?? '0'),
decision: attrs['decision'] === 'ship' ? 'ship' : 'continue',
reason,
};
state.currentRound = null;
state.roundsClosed += 1;
cursor += closeIdx + '</ROUND_END>'.length;
state.lastAdvance = state.consumed + cursor;
continue;
}
// </ROUND>
if (slice.startsWith('</ROUND>')) {
cursor += '</ROUND>'.length;
state.lastAdvance = state.consumed + cursor;
continue;
}
// <SHIP ...>...</SHIP>
if (slice.startsWith('<SHIP ')) {
if (!state.inRun) {
throw new MalformedBlockError(
`<SHIP> at position ${state.consumed + cursor} appeared before <CRITIQUE_RUN>`,
state.consumed + cursor,
);
}
// Envelope guard: SHIP must not arrive before at least one round has
// completed. A stream that skips directly from <CRITIQUE_RUN> to <SHIP>
// bypasses the round-1 designer-artifact invariant.
if (state.roundsClosed === 0) {
throw new MalformedBlockError(
`<SHIP> at position ${state.consumed + cursor} appeared before any <ROUND_END>`,
state.consumed + cursor,
);
}
const closeIdx = slice.indexOf('</SHIP>');
if (closeIdx < 0) break;
const blockText = slice.slice(0, closeIdx + '</SHIP>'.length);
const blockBytes = Buffer.byteLength(blockText, 'utf8');
if (blockBytes > state.parserMaxBlockBytes) {
throw new OversizeBlockError(
`SHIP block of ${blockBytes} bytes exceeded ${state.parserMaxBlockBytes} at position ${state.consumed + cursor}`,
state.consumed + cursor,
);
}
if (state.shipSeen) {
yield {
type: 'parser_warning',
runId: state.runId,
kind: 'duplicate_ship',
position: state.consumed + cursor,
};
cursor += closeIdx + '</SHIP>'.length;
state.lastAdvance = state.consumed + cursor;
continue;
}
state.shipSeen = true;
const headEnd = slice.indexOf('>');
if (headEnd < 0) break;
if (headEnd >= closeIdx) {
throw new MalformedBlockError(
`<SHIP> opening tag at position ${state.consumed + cursor} has no closing > before </SHIP>`,
state.consumed + cursor,
);
}
const attrs = parseAttrs(slice.slice('<SHIP'.length, headEnd));
const inner = slice.slice(headEnd + 1, closeIdx);
// Validate that a non-empty <ARTIFACT> block is present inside <SHIP>.
const artifactMatch = inner.match(/<ARTIFACT\b[^>]*>([\s\S]*?)<\/ARTIFACT>/);
if (!artifactMatch || artifactMatch[1] === undefined || artifactMatch[1].trim().length === 0) {
throw new MissingArtifactError(
`<SHIP> at position ${state.consumed + cursor} contains no <ARTIFACT> block or the block is empty`,
);
}
const summary = (inner.match(/<SUMMARY>([\s\S]*?)<\/SUMMARY>/)?.[1] ?? '').trim();
const rawStatus = attrs['status'] ?? '';
const validStatuses = ['shipped', 'below_threshold', 'timed_out', 'interrupted'] as const;
const status = (
validStatuses.includes(rawStatus as (typeof validStatuses)[number])
? rawStatus
: 'shipped'
) as 'shipped' | 'below_threshold' | 'timed_out' | 'interrupted';
yield {
type: 'ship',
runId: state.runId,
round: Number(attrs['round'] ?? '0'),
composite: Number(attrs['composite'] ?? '0'),
status,
artifactRef: { projectId: state.projectId, artifactId: state.artifactId },
summary,
};
cursor += closeIdx + '</SHIP>'.length;
state.lastAdvance = state.consumed + cursor;
continue;
}
// </CRITIQUE_RUN>
if (slice.startsWith('</CRITIQUE_RUN>')) {
state.inRun = false;
cursor += '</CRITIQUE_RUN>'.length;
state.lastAdvance = state.consumed + cursor;
continue;
}
// Whitespace: skip
const ch = slice.charAt(0);
if (ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t') {
cursor += 1;
continue;
}
// Unknown '<': wait for more bytes (partial tag across chunk boundary)
if (ch === '<') {
break;
}
// Non-whitespace, non-tag character inside CRITIQUE_RUN: malformed
if (state.inRun) {
throw new MalformedBlockError(
`unexpected character "${ch}" at position ${state.consumed + cursor}`,
state.consumed + cursor,
);
}
cursor += 1;
}
state.consumed += cursor;
state.buf = state.buf.slice(cursor);
}
function* emitInner(
state: State,
role: PanelistRole,
inner: string,
): Generator<PanelEvent> {
// emitInner is on the parser hot path. Reuse the module-level regex objects
// and reset lastIndex so successive runs don't see stale match state.
const round = state.currentRound;
if (round == null || !Number.isFinite(round)) {
// Defensive: callers should already have rejected this, but emitting a
// panelist_dim with an invalid round value would corrupt downstream state.
return;
}
DIM_RE.lastIndex = 0;
let dm: RegExpExecArray | null;
while ((dm = DIM_RE.exec(inner)) !== null) {
const raw = Number(dm[2]);
const dimScore = clampScore(raw, state.scoreScale);
if (isOutOfRange(raw, state.scoreScale)) {
yield {
type: 'parser_warning',
runId: state.runId,
kind: 'score_clamped',
position: state.consumed,
};
}
yield {
type: 'panelist_dim',
runId: state.runId,
round,
role,
dimName: dm[1] ?? '',
dimScore,
dimNote: (dm[3] ?? '').trim(),
};
}
MUST_FIX_RE.lastIndex = 0;
let mf: RegExpExecArray | null;
while ((mf = MUST_FIX_RE.exec(inner)) !== null) {
yield {
type: 'panelist_must_fix',
runId: state.runId,
round,
role,
text: (mf[1] ?? '').trim(),
};
}
// The round-1 designer artifact invariant is checked at ROUND_END close. We
// only flip the flag here so that ROUND_END knows the artifact arrived.
if (role === 'designer' && round === 1 && /<ARTIFACT\b/.test(inner)) {
state.designerArtifactInRound1 = true;
}
}
function parseAttrs(s: string): Record<string, string> {
const out: Record<string, string> = {};
const re = /([a-zA-Z_]+)\s*=\s*"([^"]*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(s)) !== null) {
const key = m[1];
if (key != null) out[key] = m[2] ?? '';
}
return out;
}
// Score range and clamp now respect the run's declared scale (captured from
// <CRITIQUE_RUN scale="..."> into State.scoreScale). Without this a value of
// 42 in a scale=10 run would sneak through and warp composite math.
function isOutOfRange(n: number, scale: number): boolean {
if (!isFinite(n)) return true;
return n < 0 || n > scale;
}
function clampScore(n: number, scale: number): number {
if (!isFinite(n)) return 0;
if (n < 0) return 0;
if (n > scale) return scale;
return n;
}

View File

@@ -0,0 +1,354 @@
import type Database from 'better-sqlite3';
import type { ShipStatus } from '@open-design/contracts/critique';
/**
* Final critique status persisted with each run. Mirrors the spec's CHECK
* constraint on critique_status. 'failed' covers orchestrator-level errors,
* 'legacy' marks rows produced before the feature shipped (reserved for the
* artifacts-on-disk backfill in Phase 15).
*/
export type CritiqueRunStatus =
| ShipStatus
| 'degraded'
| 'failed'
| 'legacy';
export const CRITIQUE_RUN_STATUSES: readonly CritiqueRunStatus[] = [
'shipped',
'below_threshold',
'timed_out',
'interrupted',
'degraded',
'failed',
'legacy',
];
// All values accepted by the DB CHECK constraint, including the in-flight value
// that the public type union deliberately omits.
const ALL_VALID_STATUSES: ReadonlySet<string> = new Set([
...CRITIQUE_RUN_STATUSES,
'running',
]);
export interface CritiqueRoundSummary {
n: number;
composite: number;
mustFix: number;
decision: 'continue' | 'ship';
}
export interface CritiqueRunRow {
id: string;
projectId: string;
conversationId: string | null;
artifactPath: string | null;
status: CritiqueRunStatus;
score: number | null;
rounds: CritiqueRoundSummary[];
transcriptPath: string | null;
protocolVersion: number;
createdAt: number;
updatedAt: number;
}
export interface CritiqueRunInsert {
id: string;
projectId: string;
conversationId?: string | null;
artifactPath?: string | null;
/** Accepts 'running' in addition to the terminal statuses so callers can
* create in-flight rows without a type cast. */
status: CritiqueRunStatus | 'running';
score?: number | null;
rounds?: CritiqueRoundSummary[];
transcriptPath?: string | null;
protocolVersion: number;
createdAt?: number;
updatedAt?: number;
}
export interface CritiqueRunPatch {
status?: CritiqueRunStatus;
score?: number | null;
rounds?: CritiqueRoundSummary[];
transcriptPath?: string | null;
artifactPath?: string | null;
updatedAt?: number;
}
// Internal envelope stored in the rounds_json column. The rounds array is the
// primary payload; recoveryReason is written by reconcileStaleRuns.
interface RoundsPayload {
rounds: CritiqueRoundSummary[];
recoveryReason?: string;
}
function serializeRoundsPayload(
rounds: CritiqueRoundSummary[],
recoveryReason?: string,
): string {
if (recoveryReason === undefined) {
// Store a plain array when no envelope fields are needed, so reads
// handle both formats gracefully.
return JSON.stringify(rounds);
}
const payload: RoundsPayload = { rounds, recoveryReason };
return JSON.stringify(payload);
}
function parseRoundsPayload(json: string): { rounds: CritiqueRoundSummary[]; recoveryReason?: string } {
try {
const parsed: unknown = JSON.parse(json);
if (Array.isArray(parsed)) {
return { rounds: parsed as CritiqueRoundSummary[] };
}
if (parsed !== null && typeof parsed === 'object') {
const obj = parsed as Record<string, unknown>;
const rounds = Array.isArray(obj['rounds'])
? (obj['rounds'] as CritiqueRoundSummary[])
: [];
if (typeof obj['recoveryReason'] === 'string') {
return { rounds, recoveryReason: obj['recoveryReason'] };
}
return { rounds };
}
return { rounds: [] };
} catch {
return { rounds: [] };
}
}
// Raw row shape as returned by better-sqlite3 (snake_case column aliases).
interface RawCritiqueRunRow {
id: string;
projectId: string;
conversationId: string | null;
artifactPath: string | null;
status: string;
score: number | null;
roundsJson: string;
transcriptPath: string | null;
protocolVersion: number;
createdAt: number;
updatedAt: number;
}
function normalizeRow(raw: RawCritiqueRunRow): CritiqueRunRow {
const { rounds } = parseRoundsPayload(raw.roundsJson);
return {
id: raw.id,
projectId: raw.projectId,
conversationId: raw.conversationId,
artifactPath: raw.artifactPath,
status: raw.status as CritiqueRunStatus,
score: raw.score,
rounds,
transcriptPath: raw.transcriptPath,
protocolVersion: Number(raw.protocolVersion),
createdAt: Number(raw.createdAt),
updatedAt: Number(raw.updatedAt),
};
}
const COLS = `
id,
project_id AS projectId,
conversation_id AS conversationId,
artifact_path AS artifactPath,
status,
score,
rounds_json AS roundsJson,
transcript_path AS transcriptPath,
protocol_version AS protocolVersion,
created_at AS createdAt,
updated_at AS updatedAt
`;
/**
* Idempotent. Creates the critique_runs table and the supporting indexes if
* they don't exist. Safe to call from the existing migrate(db) flow on every
* daemon boot.
*/
export function migrateCritique(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS critique_runs (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
conversation_id TEXT,
artifact_path TEXT,
status TEXT NOT NULL CHECK (status IN
('shipped','below_threshold','timed_out','interrupted','degraded','failed','legacy','running')),
score REAL,
rounds_json TEXT NOT NULL DEFAULT '[]',
transcript_path TEXT,
protocol_version INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_critique_runs_project
ON critique_runs(project_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_critique_runs_status
ON critique_runs(status);
`);
}
export function insertCritiqueRun(
db: Database.Database,
input: CritiqueRunInsert,
): CritiqueRunRow {
if (!ALL_VALID_STATUSES.has(input.status)) {
throw new RangeError(
`Invalid critique run status: "${input.status}". Must be one of: ${[...ALL_VALID_STATUSES].join(', ')}`,
);
}
const now = Date.now();
const rounds = input.rounds ?? [];
db.prepare(
`INSERT INTO critique_runs
(id, project_id, conversation_id, artifact_path, status, score,
rounds_json, transcript_path, protocol_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
input.id,
input.projectId,
input.conversationId ?? null,
input.artifactPath ?? null,
input.status,
input.score ?? null,
serializeRoundsPayload(rounds),
input.transcriptPath ?? null,
input.protocolVersion,
input.createdAt ?? now,
input.updatedAt ?? now,
);
const row = getCritiqueRun(db, input.id);
if (row === null) {
throw new Error(`Failed to fetch critique run after insert: ${input.id}`);
}
return row;
}
export function getCritiqueRun(
db: Database.Database,
id: string,
): CritiqueRunRow | null {
const raw = db
.prepare(`SELECT ${COLS} FROM critique_runs WHERE id = ?`)
.get(id) as RawCritiqueRunRow | undefined;
return raw !== undefined ? normalizeRow(raw) : null;
}
/**
* Updates the patch fields on an existing run. Returns the new row, or null
* when the id does not exist. Always updates updated_at.
*/
export function updateCritiqueRun(
db: Database.Database,
id: string,
patch: CritiqueRunPatch,
): CritiqueRunRow | null {
const existing = getCritiqueRun(db, id);
if (existing === null) return null;
const now = Date.now();
const updatedAt = patch.updatedAt ?? now;
const status = patch.status ?? existing.status;
const score = 'score' in patch ? patch.score ?? null : existing.score;
const rounds = patch.rounds ?? existing.rounds;
const transcriptPath =
'transcriptPath' in patch
? patch.transcriptPath ?? null
: existing.transcriptPath;
const artifactPath =
'artifactPath' in patch
? patch.artifactPath ?? null
: existing.artifactPath;
db.prepare(
`UPDATE critique_runs
SET status = ?,
score = ?,
rounds_json = ?,
transcript_path = ?,
artifact_path = ?,
updated_at = ?
WHERE id = ?`,
).run(
status,
score,
serializeRoundsPayload(rounds),
transcriptPath,
artifactPath,
updatedAt,
id,
);
return getCritiqueRun(db, id);
}
export function listCritiqueRunsByProject(
db: Database.Database,
projectId: string,
): CritiqueRunRow[] {
const rows = db
.prepare(
`SELECT ${COLS}
FROM critique_runs
WHERE project_id = ?
ORDER BY updated_at DESC`,
)
.all(projectId) as RawCritiqueRunRow[];
return rows.map(normalizeRow);
}
export function deleteCritiqueRun(db: Database.Database, id: string): void {
db.prepare(`DELETE FROM critique_runs WHERE id = ?`).run(id);
}
/**
* Recovery scan called on daemon boot: any run still in a non-terminal status
* older than staleAfterMs is marked 'interrupted' with rounds_json.recoveryReason
* = 'daemon_restart'. Returns the count of rows mutated.
*/
export function reconcileStaleRuns(
db: Database.Database,
options: { staleAfterMs: number; now?: number },
): number {
const now = options.now ?? Date.now();
const cutoff = now - options.staleAfterMs;
const reconcile = db.transaction(() => {
const staleRows = db
.prepare(
`SELECT ${COLS}
FROM critique_runs
WHERE status = 'running'
AND updated_at < ?`,
)
.all(cutoff) as RawCritiqueRunRow[];
if (staleRows.length === 0) return 0;
const update = db.prepare(
`UPDATE critique_runs
SET status = 'interrupted',
rounds_json = ?,
updated_at = ?
WHERE id = ?`,
);
for (const raw of staleRows) {
const { rounds } = parseRoundsPayload(raw.roundsJson);
const newPayload = serializeRoundsPayload(rounds, 'daemon_restart');
update.run(newPayload, now, raw.id);
}
return staleRows.length;
});
return reconcile() as number;
}

View File

@@ -0,0 +1,91 @@
import type { CritiqueConfig, PanelEvent, PanelistRole, RoundDecision } from '@open-design/contracts/critique';
/**
* Per-round scores indexed by panelist role. Absent roles are undefined.
* @see specs/current/critique-theater.md § Composite score formula
*/
export type RoleScores = Partial<Record<PanelistRole, number>>;
/**
* Accumulated state for a single round's scoring pass.
* @see specs/current/critique-theater.md § Composite score formula
*/
export interface RoundState {
n: number;
scores: RoleScores;
mustFix: number;
composite: number;
}
/**
* Computes the weighted composite score for a set of panelist scores.
* Absent roles are excluded; weights redistribute proportionally over
* present roles only. Returns 0 when no role has a score.
*
* @see specs/current/critique-theater.md § Composite score formula
*/
export function computeComposite(
scores: RoleScores,
weights: CritiqueConfig['weights'],
): number {
const roles = Object.keys(scores) as PanelistRole[];
const present = roles.filter((r) => scores[r] !== undefined);
if (present.length === 0) return 0;
const totalWeight = present.reduce((s, r) => s + weights[r], 0);
if (totalWeight < 1e-9) return 0;
return present.reduce((s, r) => {
const score = scores[r];
if (score === undefined) return s;
return s + (weights[r] / totalWeight) * score;
}, 0);
}
/**
* Applies the convergence rule: returns 'ship' when composite >= threshold
* (with float epsilon 1e-9) AND mustFix === 0; otherwise 'continue'.
*
* @see specs/current/critique-theater.md § Convergence rule
*/
export function decideRound(
composite: number,
mustFix: number,
cfg: CritiqueConfig,
): RoundDecision {
if (composite >= cfg.scoreThreshold - 1e-9 && mustFix === 0) {
return 'ship';
}
return 'continue';
}
/**
* Selects the best round according to fallbackPolicy when no <SHIP> arrived.
* Returns the elected RoundState or null when the list is empty or policy
* is 'fail'.
*
* @see specs/current/critique-theater.md § Failure modes (recovery)
*/
export function selectFallbackRound(
rounds: RoundState[],
policy: CritiqueConfig['fallbackPolicy'],
): RoundState | null {
if (rounds.length === 0) return null;
if (policy === 'fail') return null;
if (policy === 'ship_last') {
const last = rounds[rounds.length - 1];
return last ?? null;
}
// ship_best: highest composite; tie-break by highest round number
let best: RoundState | null = null;
for (const r of rounds) {
if (
best === null ||
r.composite > best.composite + 1e-9 ||
(Math.abs(r.composite - best.composite) < 1e-9 && r.n > best.n)
) {
best = r;
}
}
return best;
}

View File

@@ -0,0 +1,178 @@
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, rename, rm, open } from 'node:fs/promises';
import { createGzip, createGunzip } from 'node:zlib';
import { createInterface } from 'node:readline';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import type { PanelEvent } from '@open-design/contracts/critique';
/**
* Default gzip threshold (256 KiB). Files whose cumulative UTF-8 byte size
* exceeds this value are written as .ndjson.gz; smaller files stay plain.
* @see specs/current/critique-theater.md § Persistence (transcript files)
*/
const DEFAULT_GZIP_THRESHOLD_BYTES = 256 * 1024;
/**
* Write a sequence of PanelEvents as newline-delimited JSON to a transcript
* file under the artifact directory. Files larger than gzipThresholdBytes
* are gzipped to .ndjson.gz; smaller files stay as plain .ndjson. The
* threshold is applied to the cumulative UTF-8 byte size of the serialized
* payload, not the array length, so multibyte transcripts size correctly.
*
* Backpressure-aware: events are streamed via Node streams, so the writer
* never holds the full transcript in memory.
*
* Returns the path written (relative to artifactDir). Caller persists the
* relative path on the critique_runs row.
*
* @see specs/current/critique-theater.md § Persistence (transcript files)
*/
export async function writeTranscript(
artifactDir: string,
events: AsyncIterable<PanelEvent> | Iterable<PanelEvent>,
opts?: { gzipThresholdBytes?: number },
): Promise<{ path: string; bytes: number; gzipped: boolean }> {
if (typeof artifactDir !== 'string' || artifactDir.length === 0) {
throw new RangeError('writeTranscript: artifactDir must be a non-empty string');
}
if (
events === null ||
events === undefined ||
(typeof events !== 'object' && typeof events !== 'function')
) {
throw new RangeError('writeTranscript: events must be iterable');
}
// Validate that the value is actually iterable / async-iterable.
const hasAsyncIter = Symbol.asyncIterator in (events as object);
const hasSyncIter = Symbol.iterator in (events as object);
if (!hasAsyncIter && !hasSyncIter) {
throw new RangeError('writeTranscript: events must be iterable');
}
const threshold = opts?.gzipThresholdBytes ?? DEFAULT_GZIP_THRESHOLD_BYTES;
await mkdir(artifactDir, { recursive: true });
const tempPath = join(artifactDir, `transcript.tmp.${process.pid}.${Date.now()}.ndjson`);
const finalNdjson = join(artifactDir, 'transcript.ndjson');
const finalGz = join(artifactDir, 'transcript.ndjson.gz');
let totalBytes = 0;
// Stream events to temp file, accumulating byte count.
const ws = createWriteStream(tempPath, { encoding: 'utf8' });
try {
await new Promise<void>((resolve, reject) => {
ws.on('error', reject);
ws.on('finish', resolve);
(async () => {
try {
for await (const event of events as AsyncIterable<PanelEvent>) {
const line = JSON.stringify(event) + '\n';
const lineBytes = Buffer.byteLength(line, 'utf8');
totalBytes += lineBytes;
const ok = ws.write(line);
if (!ok) {
// Backpressure: wait for drain before continuing.
await new Promise<void>((res, rej) => {
ws.once('drain', res);
ws.once('error', rej);
});
}
}
ws.end();
} catch (err) {
ws.destroy(err instanceof Error ? err : new Error(String(err)));
reject(err);
}
})();
});
const gzipped = totalBytes > threshold;
if (gzipped) {
// Write gzip output to a temp file first, fsync, then atomic-rename.
// A crash mid-write leaves the .gz.tmp but never the final .gz, so
// partial files can't be mistaken for valid data on the next read.
const gzTempPath = join(artifactDir, `transcript.tmp.${process.pid}.${Date.now()}.ndjson.gz.tmp`);
try {
await pipeline(
createReadStream(tempPath),
createGzip(),
createWriteStream(gzTempPath),
);
// fsync: flush OS write buffers before rename so crash after rename
// cannot leave a zero-length .gz.
const fh = await open(gzTempPath, 'r+');
try {
await fh.sync();
} finally {
await fh.close();
}
await rename(gzTempPath, finalGz);
} catch (gzErr) {
// Unlink the .gz.tmp so no partial file lingers.
await rm(gzTempPath, { force: true });
throw gzErr;
}
await rm(tempPath, { force: true });
return { path: 'transcript.ndjson.gz', bytes: totalBytes, gzipped: true };
} else {
await rename(tempPath, finalNdjson);
return { path: 'transcript.ndjson', bytes: totalBytes, gzipped: false };
}
} catch (err) {
// Ensure the write stream has fully closed before unlinking. If the
// iterable fails before the lazy open completes, unlinking immediately can
// race with createWriteStream and leave a late-created temp file behind.
ws.destroy();
if (!ws.closed) {
await new Promise<void>((resolve) => {
ws.once('close', resolve);
});
}
// Ensure temp file is cleaned up on any failure.
await rm(tempPath, { force: true });
throw err;
}
}
/**
* Inverse of writeTranscript. Streams a transcript file (.ndjson or .ndjson.gz)
* back out as PanelEvents. Used by replay paths and by Phase 11 e2e.
*
* @see specs/current/critique-theater.md § Persistence (transcript files)
*/
export async function* readTranscript(
artifactDir: string,
fileName: string,
): AsyncIterable<PanelEvent> {
if (!fileName.endsWith('.ndjson') && !fileName.endsWith('.ndjson.gz')) {
throw new RangeError(
`readTranscript: unknown extension on "${fileName}", expected .ndjson or .ndjson.gz`,
);
}
const filePath = join(artifactDir, fileName);
const isGz = fileName.endsWith('.ndjson.gz');
const fileStream = createReadStream(filePath);
const source: NodeJS.ReadableStream = isGz
? fileStream.pipe(createGunzip())
: fileStream;
const rl = createInterface({
input: source as unknown as NodeJS.ReadableStream,
crlfDelay: Infinity,
});
for await (const line of rl) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const event = JSON.parse(trimmed) as PanelEvent;
yield event;
}
}

View File

@@ -0,0 +1,150 @@
// Stage the active skill into the agent's project cwd so its side files
// (assets/, references/) are reachable through a cwd-relative path
// (`.od-skills/<folder>/...`). The chat handler invokes
// `stageActiveSkill()` once per turn before spawning the agent; the
// skill preamble emitted by `withSkillRootPreamble()` advertises both
// the cwd-relative alias path (primary) and the absolute repo path
// (fallback) so agents work whether or not staging succeeds.
//
// Why a per-project copy and not a symlink/junction
// -------------------------------------------------
// An earlier draft of this fix (PR #435 round 1) created a directory
// link pointing at the repository's live `skills/` tree. Reviewers
// flagged that as a write-amplification vulnerability: agents have
// write access to their cwd, and a `Write`/`Edit`/`Bash` call against
// `.od-skills/<id>/SKILL.md` resolves through the symlink and mutates
// the shipped resource itself. Per-project copies eliminate that
// channel — every byte under `.od-skills/` is a private working copy,
// and corrupting it has no effect on other projects or on the source.
//
// Cost. We only stage the *active* skill, not the entire SKILLS_DIR;
// individual skills are typically 13 MB. On APFS / btrfs / ReFS
// `fs.cp` uses copy-on-write where available, so the steady-state cost
// is a few syscalls.
//
// Source symlinks. We `dereference: true` so the staged copy is fully
// self-contained — nothing inside it can write back to a real file
// outside the project. We also call `stat()` (not `lstat()`) on the
// source root so an environment that puts `skills/` itself behind a
// symlink (e.g. a content-addressable mount) is followed correctly.
import { cp, lstat, rm, stat } from 'node:fs/promises';
import path from 'node:path';
export const SKILLS_CWD_ALIAS = '.od-skills';
export type SkillStagingLogger = (message: string) => void;
export interface SkillStagingResult {
/** True when a usable copy of the source is sitting at `stagedPath`. */
staged: boolean;
/** Absolute path of the staged directory if staging succeeded. */
stagedPath?: string;
/** Populated when staging was skipped or failed; never thrown. */
reason?: string;
}
/**
* Copy `<sourceDir>` to `<cwd>/.od-skills/<folderName>/` so an agent can
* reach skill side files via a cwd-relative path. Idempotent and
* non-throwing — failures are logged and surfaced via the result so the
* caller falls back to absolute-path delivery (`--add-dir` for
* Claude/Copilot, embedded absolute path in the preamble for others).
*
* The previous-turn copy is replaced wholesale on every call, which is
* the simplest correct way to handle skill-source updates (e.g. the
* user just edited a `references/*.md` mid-session).
*/
export async function stageActiveSkill(
cwd: string | null | undefined,
folderName: string,
sourceDir: string,
log: SkillStagingLogger = () => {},
): Promise<SkillStagingResult> {
if (!cwd) {
return { staged: false, reason: 'no project cwd' };
}
if (!isSafeAliasSegment(folderName)) {
return { staged: false, reason: `unsafe folder name "${folderName}"` };
}
// `stat()` follows symlinks so a symlinked SKILLS_DIR or a symlinked
// skill folder is treated as the directory it points at, not skipped.
let sourceStat;
try {
sourceStat = await stat(sourceDir);
} catch (err) {
return {
staged: false,
reason: `source missing: ${(err as Error).message}`,
};
}
if (!sourceStat.isDirectory()) {
return { staged: false, reason: 'source is not a directory' };
}
const aliasRoot = path.join(cwd, SKILLS_CWD_ALIAS);
const stagedPath = path.join(aliasRoot, folderName);
// The alias root is OD-reserved. If the user (or some unrelated tool)
// has put a real file under that name, refuse to clobber it. A
// legacy symlink left by an earlier daemon version is replaced with
// a real directory so we own the writable namespace.
try {
const aliasStat = await lstat(aliasRoot);
if (aliasStat.isSymbolicLink()) {
log(
`[od] skill-stage: replacing legacy symlink at ${aliasRoot} with a real directory`,
);
await rm(aliasRoot, { recursive: true, force: true });
} else if (!aliasStat.isDirectory()) {
log(
`[od] skill-stage: ${aliasRoot} exists and is not a directory; refusing to stage`,
);
return {
staged: false,
reason: 'alias root taken by a non-directory entry',
};
}
} catch {
// does not exist — created by `cp` below
}
try {
// Wipe a stale per-skill copy first so a removed source file is
// reflected and a partially-failed previous run cannot leave junk
// behind.
await rm(stagedPath, { recursive: true, force: true });
await cp(sourceDir, stagedPath, {
recursive: true,
// Resolve every symlink we find inside the skill so the staged
// copy is a fully self-contained set of regular files. This is
// what makes the copy a true write barrier — no entry under
// `.od-skills/...` can resolve back to a real file outside the
// project cwd.
dereference: true,
preserveTimestamps: true,
});
return { staged: true, stagedPath };
} catch (err) {
log(`[od] skill-stage failed: ${(err as Error).message}`);
return { staged: false, reason: (err as Error).message };
}
}
const UNSAFE_ALIAS_RE = /[\\/]|\0/;
/**
* Returns true if `name` is safe to use as a single path segment under
* the alias root. Rejects empty strings, dot-segments (`.`/`..`), path
* separators (`/`, `\`), null bytes, and absolute paths so a malformed
* caller cannot escape the alias root.
*/
function isSafeAliasSegment(name: unknown): name is string {
if (typeof name !== 'string') return false;
if (name.length === 0) return false;
if (name === '.' || name === '..') return false;
if (UNSAFE_ALIAS_RE.test(name)) return false;
if (path.isAbsolute(name)) return false;
return true;
}

1010
apps/daemon/src/db.ts Normal file

File diff suppressed because it is too large Load Diff

908
apps/daemon/src/deploy.ts Normal file
View File

@@ -0,0 +1,908 @@
// @ts-nocheck
import fs from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { readProjectFile, validateProjectPath } from './projects.js';
export const VERCEL_PROVIDER_ID = 'vercel-self';
export const SAVED_TOKEN_MASK = 'saved-vercel-token';
const VERCEL_API = 'https://api.vercel.com';
const VERCEL_PROTECTED_MESSAGE =
'Deployment is protected by Vercel. Disable Deployment Protection or use a custom domain to make this link public.';
export class DeployError extends Error {
constructor(message, status = 400, details = undefined) {
super(message);
this.name = 'DeployError';
this.status = status;
this.details = details;
}
}
export function deployConfigPath() {
const base = process.env.OD_USER_STATE_DIR || path.join(os.homedir(), '.open-design');
return path.join(base, 'vercel.json');
}
export async function readVercelConfig() {
try {
const raw = await readFile(deployConfigPath(), 'utf8');
const parsed = JSON.parse(raw);
return {
token: typeof parsed.token === 'string' ? parsed.token : '',
teamId: typeof parsed.teamId === 'string' ? parsed.teamId : '',
teamSlug: typeof parsed.teamSlug === 'string' ? parsed.teamSlug : '',
};
} catch (err) {
if (err && err.code === 'ENOENT') return { token: '', teamId: '', teamSlug: '' };
throw err;
}
}
export async function writeVercelConfig(input) {
const current = await readVercelConfig();
const tokenInput = typeof input?.token === 'string' ? input.token.trim() : '';
const next = {
token:
tokenInput && tokenInput !== SAVED_TOKEN_MASK
? tokenInput
: current.token,
teamId: typeof input?.teamId === 'string' ? input.teamId.trim() : current.teamId,
teamSlug:
typeof input?.teamSlug === 'string' ? input.teamSlug.trim() : current.teamSlug,
};
const file = deployConfigPath();
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
try {
fs.chmodSync(file, 0o600);
} catch {
// Best effort on filesystems that do not support chmod.
}
return publicDeployConfig(next);
}
export function publicDeployConfig(config) {
return {
providerId: VERCEL_PROVIDER_ID,
configured: Boolean(config?.token),
tokenMask: config?.token ? SAVED_TOKEN_MASK : '',
teamId: config?.teamId || '',
teamSlug: config?.teamSlug || '',
target: 'preview',
};
}
// Walk the entry HTML and any referenced CSS, producing the full set of
// files that would be uploaded for a deploy along with the lists of
// missing and invalid references. Does not throw on a partial result so
// callers can distinguish between "ready to ship" and "ready except for
// these specific issues" without parsing an error string.
export async function buildDeployFilePlan(projectsRoot, projectId, entryName, options = {}) {
const entryPath = validateProjectPath(entryName);
if (!/\.html?$/i.test(entryPath)) {
throw new DeployError('Only HTML files can be deployed.', 400);
}
const entry = await readProjectFile(projectsRoot, projectId, entryPath);
const html = entry.buffer.toString('utf8');
const entryBase = path.posix.dirname(entryPath);
const deployHtml = injectDeployHookScript(
rewriteEntryHtmlReferences(html, entryBase),
options.hookScriptUrl ?? process.env.OD_DEPLOY_HOOK_SCRIPT_URL,
);
const files = new Map();
files.set('index.html', {
file: 'index.html',
data: Buffer.from(deployHtml, 'utf8'),
contentType: entry.mime,
sourcePath: entryPath,
});
const visited = new Set([entryPath]);
const missing = [];
const invalid = [];
const pending = extractHtmlReferences(html).map((ref) => ({
ref,
base: entryBase,
}));
// Inline `<style>` blocks and `style="..."` attributes can reference
// background images, custom fonts, and stylesheets via @import. They
// are resolved relative to the entry HTML, same as src/href.
for (const ref of extractInlineCssReferences(html)) {
pending.push({ ref, base: entryBase });
}
for (const manifestRef of entry.artifactManifest?.supportingFiles ?? []) {
pending.push({ ref: manifestRef, base: entryBase });
}
while (pending.length > 0) {
const item = pending.shift();
const resolved = resolveReferencedPath(item.ref, item.base);
if (!resolved) continue;
let safePath;
try {
safePath = validateProjectPath(resolved);
} catch {
invalid.push(item.ref);
continue;
}
if (safePath === entryPath || visited.has(safePath)) continue;
visited.add(safePath);
let projectFile;
try {
projectFile = await readProjectFile(projectsRoot, projectId, safePath);
} catch (err) {
if (err && err.code === 'ENOENT') {
missing.push(safePath);
continue;
}
invalid.push(safePath);
continue;
}
files.set(safePath, {
file: safePath,
data: projectFile.buffer,
contentType: projectFile.mime,
sourcePath: safePath,
});
if (/\.css$/i.test(safePath)) {
const cssBase = path.posix.dirname(safePath);
for (const ref of extractCssReferences(projectFile.buffer.toString('utf8'))) {
pending.push({ ref, base: cssBase });
}
}
}
return {
entryPath,
html,
files: Array.from(files.values()),
missing,
invalid,
};
}
export async function buildDeployFileSet(projectsRoot, projectId, entryName, options = {}) {
const plan = await buildDeployFilePlan(projectsRoot, projectId, entryName, options);
if (plan.missing.length || plan.invalid.length) {
const parts = [];
if (plan.missing.length) parts.push(`missing: ${plan.missing.join(', ')}`);
if (plan.invalid.length) parts.push(`invalid: ${plan.invalid.join(', ')}`);
throw new DeployError(`Could not deploy referenced files (${parts.join('; ')}).`, 400, {
missing: plan.missing,
invalid: plan.invalid,
});
}
return plan.files;
}
export async function deployToVercel({ config, files, projectId }) {
if (!config?.token) {
throw new DeployError('Vercel token is required.', 400);
}
const createResp = await fetch(`${VERCEL_API}/v13/deployments${vercelTeamQuery(config)}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${config.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: safeVercelProjectName(`od-${projectId}`),
files: files.map((f) => ({
file: f.file,
data: Buffer.from(f.data).toString('base64'),
encoding: 'base64',
})),
projectSettings: { framework: null },
}),
});
const created = await readVercelJson(createResp);
if (!createResp.ok) throw vercelError(created, createResp.status);
const deploymentId = created.id || created.uid;
const initialUrl = deploymentUrl(created);
const ready = deploymentId
? await pollVercelDeployment(config, deploymentId)
: created;
if (ready?.readyState === 'ERROR') {
throw new DeployError(ready?.error?.message || 'Vercel deployment failed.', 502, ready);
}
const candidates = deploymentUrlCandidates(ready, created);
const link = await waitForReachableDeploymentUrl(candidates.length ? candidates : [initialUrl]);
return {
providerId: VERCEL_PROVIDER_ID,
url: link.url || deploymentUrl(ready) || initialUrl,
deploymentId,
target: 'preview',
status: link.status,
statusMessage: link.statusMessage,
reachableAt: link.reachableAt,
};
}
export function extractHtmlReferences(html) {
const refs = [];
for (const tag of parseHtmlTags(html)) {
const attrs = parseHtmlAttributes(tag.attrs);
for (const name of ['src', 'poster']) {
const value = attrs.get(name);
if (value) refs.push(value);
}
const href = attrs.get('href');
if (href && shouldCollectHref(tag.name, attrs)) refs.push(href);
const srcset = attrs.get('srcset');
if (srcset) {
for (const part of srcset.split(',')) {
const url = part.trim().split(/\s+/)[0];
if (url) refs.push(url);
}
}
}
return refs;
}
// Character classes scope the lazy match so unclosed url(((( or
// `@import "foo` cannot trigger O(n^2) regex backtracking on
// attacker-controlled CSS. The tradeoff is that quoted urls
// containing literal `)` characters must be percent-encoded; CSS
// authors are already expected to do this in practice.
const CSS_URL_REGEX = /url\(\s*(['"]?)([^)]*?)\1\s*\)/gi;
const CSS_IMPORT_REGEX = /@import\s+(?:url\(\s*)?(['"])([^'"]*?)\1/gi;
export function extractCssReferences(css) {
const refs = [];
const urlRe = new RegExp(CSS_URL_REGEX.source, CSS_URL_REGEX.flags);
let match;
while ((match = urlRe.exec(css))) refs.push(match[2]);
const importRe = new RegExp(CSS_IMPORT_REGEX.source, CSS_IMPORT_REGEX.flags);
while ((match = importRe.exec(css))) refs.push(match[2]);
return refs;
}
// Collect url() / @import references from inline `<style>` blocks and
// `style="..."` attributes. These bypass the external-stylesheet path
// (link rel=stylesheet -> .css file -> extractCssReferences) but still
// pull in real assets, e.g. background images and @font-face sources.
//
// Style-like text that lives inside `<script>` string literals or HTML
// comments is intentionally skipped, mirroring how extractHtmlReferences
// treats those raw-text regions.
export function extractInlineCssReferences(html) {
const source = String(html);
const refs = [];
const skipRanges = htmlRawTextRanges(source);
const styleBlockRe = /<style\b[^<>]*>([\s\S]*?)<\/style\s*>/gi;
let block;
while ((block = styleBlockRe.exec(source))) {
if (isOffsetInRanges(block.index, skipRanges)) continue;
refs.push(...extractCssReferences(block[1]));
}
for (const tag of parseHtmlTags(source)) {
const attrs = parseHtmlAttributes(tag.attrs);
const style = attrs.get('style');
if (style) refs.push(...extractCssReferences(style));
}
return refs;
}
// Rewrite url() / @import references inside a CSS string so that paths
// resolved relative to `baseDir` survive the entry-HTML being moved to
// the deploy root. Mirrors `rewriteHtmlReference` for HTML attributes.
// Uses the same hardened character classes as `extractCssReferences` so
// extract and rewrite see the same set of references.
export function rewriteCssReferences(css, baseDir) {
return String(css)
.replace(CSS_URL_REGEX, (match, quote, value) => {
if (!value) return match;
const rewritten = rewriteHtmlReference(value, baseDir);
return `url(${quote}${rewritten}${quote})`;
})
.replace(/(@import\s+)(['"])([^'"]*?)\2/gi, (_full, prefix, quote, value) => {
const rewritten = rewriteHtmlReference(value, baseDir);
return `${prefix}${quote}${rewritten}${quote}`;
});
}
export function resolveReferencedPath(raw, baseDir) {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith('#')) return null;
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed)) return null;
if (trimmed.startsWith('//')) return null;
const withoutHash = trimmed.split('#')[0];
const withoutQuery = withoutHash.split('?')[0];
if (!withoutQuery) return null;
if (withoutQuery.startsWith('/')) return withoutQuery.slice(1);
return path.posix.normalize(path.posix.join(baseDir || '.', withoutQuery));
}
export function rewriteEntryHtmlReferences(html, baseDir) {
const source = String(html);
// Compute raw-text ranges against the input first so the style-block
// pre-pass can skip `<style>...</style>` text that lives inside a
// `<script>` string literal or an HTML comment. Without this gate, a
// template like `const tpl = '<style>...url("foo")...</style>'` would
// get mutated, changing runtime JS behavior.
const inputRawTextRanges = htmlRawTextRanges(source);
const styleRewritten = source.replace(
/(<style\b[^<>]*>)([\s\S]*?)(<\/style\s*>)/gi,
(full, openTag, content, closeTag, offset) => {
if (isOffsetInRanges(offset, inputRawTextRanges)) return full;
return `${openTag}${rewriteCssReferences(content, baseDir)}${closeTag}`;
},
);
// Re-derive raw-text ranges against the post-style HTML: rewriting can
// shift offsets, and the tag-attribute pass below skips raw-text
// regions by absolute offset. Two scans are intentional, deploy is
// not a hot path and the cost is linear in document size.
const rawTextRanges = htmlRawTextRanges(styleRewritten);
return styleRewritten.replace(/<([A-Za-z][A-Za-z0-9:-]*)([^<>]*?)>/g, (tag, rawName, rawAttrs, offset) => {
if (isOffsetInRanges(offset, rawTextRanges)) return tag;
const tagName = String(rawName).toLowerCase();
const attrs = parseHtmlAttributes(rawAttrs);
return `<${rawName}${rewriteHtmlAttributes(rawAttrs, tagName, attrs, baseDir)}>`;
});
}
// Soft thresholds chosen against Vercel's v13 deployment shape and
// typical first-paint budgets. Per-asset is a usability hint, not a
// hard cap; bundle is a margin against Vercel's 100MB request body
// (each file is base64-encoded which adds ~33%, so 75MiB pre-encoded
// is the safer ceiling).
export const DEPLOY_PREFLIGHT_LARGE_ASSET_BYTES = 4 * 1024 * 1024;
export const DEPLOY_PREFLIGHT_LARGE_BUNDLE_BYTES = 75 * 1024 * 1024;
export const DEPLOY_PREFLIGHT_LARGE_HTML_BYTES = 1 * 1024 * 1024;
function isExternalUrl(value) {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (!trimmed) return false;
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed)) return true;
if (trimmed.startsWith('//')) return true;
return false;
}
function pushUnique(list, warning) {
const key = `${warning.code}:${warning.path ?? ''}:${warning.url ?? ''}`;
if (list.seen.has(key)) return;
list.seen.add(key);
list.warnings.push(warning);
}
// Walk the entry HTML once to gather signals that affect deployment
// quality without touching the network. Returns a structured warning
// list the UI can render verbatim.
//
// `entryPath` is used as the warning `path` for HTML-level findings so
// the UI can deep-link from a warning into the source file the author
// is actually editing. `files` carries deploy-relative paths (the entry
// HTML is always renamed to `index.html`) so per-asset warnings live in
// the deploy namespace.
/**
* @param {{
* entryPath: string,
* html: string,
* files: any[],
* missing?: any[],
* invalid?: any[]
* }} input
* @returns {{ warnings: any[], totalBytes: number, totalFiles: number }}
*/
export function analyzeDeployPlan(input: {
entryPath: string;
html: string;
files: any[];
missing?: any[];
invalid?: any[];
}): { warnings: any[]; totalBytes: number; totalFiles: number } {
const { entryPath, html, files } = input;
const missing = input.missing ?? [];
const invalid = input.invalid ?? [];
const acc: { warnings: any[]; seen: Set<string> } = { warnings: [], seen: new Set() };
for (const ref of missing) {
pushUnique(acc, {
code: 'broken-reference',
path: ref,
message: `Referenced file is missing on disk: ${ref}`,
});
}
for (const ref of invalid) {
pushUnique(acc, {
code: 'invalid-reference',
path: ref,
message: `Reference is not a valid project path: ${ref}`,
});
}
let totalBytes = 0;
let entrySize = 0;
for (const f of files || []) {
const size = f.data?.length ?? 0;
totalBytes += size;
if (f.file === 'index.html') entrySize = size;
if (size > DEPLOY_PREFLIGHT_LARGE_ASSET_BYTES && f.file !== 'index.html') {
pushUnique(acc, {
code: 'large-asset',
path: f.file,
size,
message: `Asset is ${formatMib(size)}, larger than ${formatMib(DEPLOY_PREFLIGHT_LARGE_ASSET_BYTES)}; consider compressing or hosting on a CDN.`,
});
}
}
if (entrySize > DEPLOY_PREFLIGHT_LARGE_HTML_BYTES) {
pushUnique(acc, {
// Report against the source entry path so the UI can deep-link
// back to the file the author edits, not the deploy-renamed
// `index.html` which does not exist in the project tree.
code: 'large-html',
path: entryPath,
size: entrySize,
message: `Entry HTML is ${formatMib(entrySize)}; large HTML inflates time-to-first-paint.`,
});
}
if (totalBytes > DEPLOY_PREFLIGHT_LARGE_BUNDLE_BYTES) {
pushUnique(acc, {
code: 'large-bundle',
size: totalBytes,
message: `Bundle is ${formatMib(totalBytes)}; Vercel rejects deploy bodies above ~100MB after base64 encoding.`,
});
}
const source = String(html ?? '');
// Anchor to the document prolog so a `<!doctype html>` substring that
// happens to live inside a `<script>` template literal or a comment
// is not treated as a real declaration. Per HTML5, the prolog may
// begin with an optional BOM, then any number of HTML comments and
// whitespace, then the doctype. Built via `new RegExp` so the BOM
// appears as an explicit U+FEFF escape rather than a literal
// zero-width character in the regex source.
if (!new RegExp('^\\uFEFF?\\s*(?:<!--[\\s\\S]*?-->\\s*)*<!doctype\\s+html', 'i').test(source)) {
pushUnique(acc, {
code: 'no-doctype',
path: entryPath,
message: 'Entry HTML is missing `<!DOCTYPE html>`; browsers may render in quirks mode.',
});
}
let hasViewport = false;
for (const tag of parseHtmlTags(source)) {
const attrs = parseHtmlAttributes(tag.attrs);
if (
tag.name === 'meta' &&
String(attrs.get('name') || '').toLowerCase() === 'viewport'
) {
hasViewport = true;
}
if (tag.name === 'script') {
const src = attrs.get('src');
if (isExternalUrl(src)) {
pushUnique(acc, {
code: 'external-script',
path: entryPath,
url: src,
message: `External script will not be vendored into the deploy: ${src}`,
});
}
}
if (tag.name === 'link') {
const rel = String(attrs.get('rel') || '').toLowerCase();
const href = attrs.get('href');
if (rel.split(/\s+/).includes('stylesheet') && isExternalUrl(href)) {
pushUnique(acc, {
code: 'external-stylesheet',
path: entryPath,
url: href,
message: `External stylesheet will not be vendored into the deploy: ${href}`,
});
}
}
}
if (!hasViewport) {
pushUnique(acc, {
code: 'no-viewport',
path: entryPath,
message: 'Entry HTML is missing `<meta name="viewport">`; mobile rendering will be off.',
});
}
return { warnings: acc.warnings, totalBytes, totalFiles: (files || []).length };
}
function formatMib(bytes) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
}
// One-shot orchestrator: build the file plan, run the analyzer, and
// return the typed preflight payload exposed by the daemon.
export async function prepareDeployPreflight(projectsRoot, projectId, entryName, options = {}) {
const plan = await buildDeployFilePlan(projectsRoot, projectId, entryName, options);
const { warnings, totalBytes, totalFiles } = analyzeDeployPlan(plan);
return {
providerId: VERCEL_PROVIDER_ID,
entry: plan.entryPath,
files: plan.files.map((f) => ({
path: f.file,
size: f.data?.length ?? 0,
mime: f.contentType || 'application/octet-stream',
sourcePath: f.sourcePath,
})),
totalFiles,
totalBytes,
warnings,
};
}
export function injectDeployHookScript(html, scriptUrl) {
const normalized = normalizeDeployHookScriptUrl(scriptUrl);
if (!normalized) return html;
const tag =
`<script src="${escapeHtmlAttribute(normalized)}" defer ` +
'data-open-design-deploy-hook="true" data-closeable="true"></script>';
if (/<\/body\s*>/i.test(html)) {
return html.replace(/<\/body\s*>/i, `${tag}</body>`);
}
return `${html}${tag}`;
}
export function normalizeDeployHookScriptUrl(raw) {
if (typeof raw !== 'string') return '';
const trimmed = raw.trim();
if (!trimmed) return '';
try {
const url = new URL(trimmed);
if (url.protocol !== 'https:' && url.protocol !== 'http:') return '';
return url.toString();
} catch {
return '';
}
}
function escapeHtmlAttribute(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function rewriteSrcset(raw, baseDir) {
return String(raw)
.split(',')
.map((part) => {
const trimmed = part.trim();
if (!trimmed) return part;
const pieces = trimmed.split(/\s+/);
const nextUrl = rewriteHtmlReference(pieces[0], baseDir);
return [nextUrl, ...pieces.slice(1)].join(' ');
})
.join(', ');
}
function parseHtmlTags(html) {
const tags = [];
const rawTextRanges = htmlRawTextRanges(html);
const tagRe = /<([A-Za-z][A-Za-z0-9:-]*)([^<>]*?)>/g;
let match;
while ((match = tagRe.exec(String(html)))) {
if (isOffsetInRanges(match.index, rawTextRanges)) continue;
tags.push({
name: String(match[1]).toLowerCase(),
attrs: match[2] || '',
});
}
return tags;
}
function htmlRawTextRanges(html) {
const source = String(html);
const ranges = [];
const commentRe = /<!--[\s\S]*?-->/g;
let match;
while ((match = commentRe.exec(source))) {
ranges.push([match.index, match.index + match[0].length]);
}
const rawTagRe = /<(script|style)\b[^<>]*>/gi;
while ((match = rawTagRe.exec(source))) {
const tagName = String(match[1]).toLowerCase();
const contentStart = match.index + match[0].length;
const closeRe = new RegExp(`</${tagName}\\s*>`, 'gi');
closeRe.lastIndex = contentStart;
const close = closeRe.exec(source);
const contentEnd = close ? close.index : source.length;
if (contentEnd > contentStart) ranges.push([contentStart, contentEnd]);
rawTagRe.lastIndex = close ? close.index + close[0].length : source.length;
}
return ranges;
}
function isOffsetInRanges(offset, ranges) {
return ranges.some(([start, end]) => offset >= start && offset < end);
}
function parseHtmlAttributes(rawAttrs) {
const attrs = new Map();
const attrRe = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
let match;
while ((match = attrRe.exec(String(rawAttrs)))) {
attrs.set(String(match[1]).toLowerCase(), match[2] ?? match[3] ?? match[4] ?? '');
}
return attrs;
}
function rewriteHtmlAttributes(rawAttrs, tagName, attrs, baseDir) {
const shouldRewriteHref = shouldCollectHref(tagName, attrs);
return String(rawAttrs).replace(
/([^\s"'<>/=]+)(\s*=\s*)("([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g,
(full, rawName, equals, rawValue, doubleQuoted, singleQuoted, unquoted) => {
const name = String(rawName).toLowerCase();
if (
name !== 'src' &&
name !== 'poster' &&
name !== 'srcset' &&
name !== 'href' &&
name !== 'style'
) {
return full;
}
if (name === 'href' && !shouldRewriteHref) return full;
const value = doubleQuoted ?? singleQuoted ?? unquoted ?? '';
let nextValue;
if (name === 'srcset') nextValue = rewriteSrcset(value, baseDir);
else if (name === 'style') nextValue = rewriteCssReferences(value, baseDir);
else nextValue = rewriteHtmlReference(value, baseDir);
if (doubleQuoted !== undefined) return `${rawName}${equals}"${nextValue}"`;
if (singleQuoted !== undefined) return `${rawName}${equals}'${nextValue}'`;
return `${rawName}${equals}${nextValue}`;
},
);
}
function shouldCollectHref(tagName, attrs) {
if (tagName !== 'link') return false;
const rel = String(attrs.get('rel') || '').toLowerCase();
if (!rel) return false;
return rel.split(/\s+/).some((item) => (
item === 'stylesheet' ||
item === 'icon' ||
item === 'apple-touch-icon' ||
item === 'manifest' ||
item === 'preload' ||
item === 'modulepreload' ||
item === 'prefetch'
));
}
function rewriteHtmlReference(raw, baseDir) {
if (typeof raw !== 'string') return raw;
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith('/') || trimmed.startsWith('#')) return raw;
const resolved = resolveReferencedPath(raw, baseDir);
if (!resolved) return raw;
const suffix = referenceSuffix(trimmed);
return `${resolved}${suffix}`;
}
function referenceSuffix(raw) {
const queryIdx = raw.indexOf('?');
const hashIdx = raw.indexOf('#');
const suffixIdx =
queryIdx === -1 ? hashIdx : hashIdx === -1 ? queryIdx : Math.min(queryIdx, hashIdx);
return suffixIdx === -1 ? '' : raw.slice(suffixIdx);
}
async function pollVercelDeployment(config, id) {
let last = null;
for (let i = 0; i < 30; i += 1) {
await new Promise((resolve) => setTimeout(resolve, i < 5 ? 1000 : 2000));
const resp = await fetch(
`${VERCEL_API}/v13/deployments/${encodeURIComponent(id)}${vercelTeamQuery(config)}`,
{ headers: { Authorization: `Bearer ${config.token}` } },
);
const json = await readVercelJson(resp);
if (!resp.ok) throw vercelError(json, resp.status);
last = json;
if (json.readyState === 'READY' || json.readyState === 'ERROR') return json;
}
return last;
}
export async function waitForReachableDeploymentUrl(
urls,
{ timeoutMs = 60_000, intervalMs = 2_000 } = {},
) {
const candidates = [...new Set((urls || []).map(normalizeDeploymentUrl).filter(Boolean))];
const fallbackUrl = candidates[0] || '';
if (!fallbackUrl) {
return {
status: 'link-delayed',
url: '',
statusMessage: 'Vercel did not return a public deployment URL.',
};
}
const startedAt = Date.now();
let lastMessage = '';
while (Date.now() - startedAt <= timeoutMs) {
for (const url of candidates) {
const result = await checkDeploymentUrl(url);
if (result.reachable) {
return {
status: 'ready',
url,
statusMessage: 'Public link is ready.',
reachableAt: Date.now(),
};
}
if (result.status === 'protected') {
return {
status: 'protected',
url,
statusMessage: result.statusMessage || VERCEL_PROTECTED_MESSAGE,
};
}
lastMessage = result.statusMessage || lastMessage;
}
if (Date.now() - startedAt >= timeoutMs) break;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return {
status: 'link-delayed',
url: fallbackUrl,
statusMessage:
lastMessage || 'Vercel returned a deployment URL, but it is not reachable yet.',
};
}
export async function checkDeploymentUrl(url, { timeoutMs = 8_000 } = {}) {
const normalized = normalizeDeploymentUrl(url);
if (!normalized) {
return { reachable: false, statusMessage: 'Deployment URL is empty.' };
}
const head = await requestDeploymentUrl(normalized, 'HEAD', timeoutMs);
if (head.reachable) return head;
if (head.status === 'protected') return head;
if (head.statusCode && (head.statusCode === 405 || head.statusCode === 403 || head.statusCode >= 400)) {
const get = await requestDeploymentUrl(normalized, 'GET', timeoutMs);
if (get.reachable) return get;
if (get.status === 'protected') return get;
return get.statusMessage ? get : head;
}
const get = await requestDeploymentUrl(normalized, 'GET', timeoutMs);
return get.reachable ? get : (get.statusMessage ? get : head);
}
async function requestDeploymentUrl(url, method, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetch(url, {
method,
redirect: 'manual',
signal: controller.signal,
});
if (resp.status >= 200 && resp.status < 400) {
return { reachable: true, statusCode: resp.status };
}
const body = method === 'GET' || resp.status === 401
? await resp.text().catch(() => '')
: '';
if (resp.status === 401 && isVercelProtectedResponse(resp, body)) {
return {
reachable: false,
status: 'protected',
statusCode: resp.status,
statusMessage: VERCEL_PROTECTED_MESSAGE,
};
}
return {
reachable: false,
statusCode: resp.status,
statusMessage: `Public link returned HTTP ${resp.status}.`,
};
} catch (err) {
return {
reachable: false,
statusMessage: `Public link is not reachable yet: ${err?.message || String(err)}`,
};
} finally {
clearTimeout(timer);
}
}
export function isVercelProtectedResponse(resp, body = '') {
const server = resp.headers?.get?.('server') || '';
const setCookie = resp.headers?.get?.('set-cookie') || '';
const text = String(body || '');
return (
/vercel/i.test(server) ||
/_vercel_sso_nonce/i.test(setCookie) ||
/Authentication Required/i.test(text) ||
/Vercel Authentication/i.test(text) ||
/vercel\.com\/sso-api/i.test(text)
);
}
export function deploymentUrlCandidates(...responses) {
const urls = [];
for (const json of responses) {
if (!json) continue;
if (json.url) urls.push(json.url);
for (const alias of json.alias ?? []) urls.push(alias);
for (const alias of json.aliases ?? []) {
if (typeof alias === 'string') urls.push(alias);
else if (alias?.domain) urls.push(alias.domain);
else if (alias?.url) urls.push(alias.url);
}
}
return [...new Set(urls.map(normalizeDeploymentUrl).filter(Boolean))];
}
export function normalizeDeploymentUrl(url) {
if (typeof url !== 'string') return '';
const trimmed = url.trim();
if (!trimmed) return '';
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
}
function vercelTeamQuery(config) {
const params = new URLSearchParams();
if (config.teamId) params.set('teamId', config.teamId);
else if (config.teamSlug) params.set('slug', config.teamSlug);
const s = params.toString();
return s ? `?${s}` : '';
}
async function readVercelJson(resp) {
try {
return await resp.json();
} catch {
return {};
}
}
function vercelError(json, status) {
const code = json?.error?.code;
const message = json?.error?.message || json?.message || `Vercel request failed (${status}).`;
if (code === 'forbidden' || /permission/i.test(message)) {
return new DeployError("You don't have permission to create a project.", status, json);
}
return new DeployError(message, status, json);
}
function deploymentUrl(json) {
const url = json?.url || json?.alias?.[0] || '';
if (!url) return '';
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
}
function safeVercelProjectName(raw) {
return String(raw)
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || `od-${randomUUID().slice(0, 8)}`;
}

View File

@@ -0,0 +1,620 @@
// @ts-nocheck
/**
* Build a showcase HTML page from a DESIGN.md so the user can see what each
* design system looks like *before* generating anything. We don't try to
* render a unique product mockup — we extract the palette, typography, and
* a couple of component conventions, then drop them into one fixed
* template. The full DESIGN.md is rendered below as prose for reference.
*
* Parsing is deliberately permissive: imported systems vary in section
* naming and bullet style, so we use loose regexes and fall back to sane
* defaults when a token isn't found.
*/
export function renderDesignSystemPreview(id, raw) {
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const title = cleanTitle(titleMatch?.[1] ?? id);
const subtitle = extractSubtitle(raw);
const colors = extractColors(raw);
const fonts = extractFonts(raw);
const bg =
pickColor(colors, ['page background', 'background', 'canvas', 'paper', 'bg ', 'page bg'])
?? pickColor(colors, ['white'])
?? '#ffffff';
const fg =
pickColor(colors, ['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite'])
?? '#111111';
// Accent: brand/primary names first, then fall back to the first color
// that doesn't look like a neutral white/black/grey so we always show
// something punchy in the showcase header.
const accent =
pickColor(colors, ['primary brand', 'brand primary', 'primary', 'brand', 'accent'])
?? firstNonNeutral(colors)
?? '#2f6feb';
const muted = pickColor(colors, ['muted', 'secondary', 'neutral', 'subtle', 'caption']) ?? '#777777';
const border = pickColor(colors, ['border', 'divider', 'rule', 'stroke']) ?? '#e5e5e5';
const surface =
pickColor(colors, ['surface', 'card', 'background-secondary', 'panel', 'elevated'])
?? '#ffffff';
const display = fonts.display
?? fonts.heading
?? "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
const body = fonts.body ?? display;
const mono = fonts.mono ?? "ui-monospace, 'JetBrains Mono', monospace";
const renderedMarkdown = renderMarkdownLite(raw);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)} — design system preview</title>
<style>
:root {
--bg: ${bg};
--fg: ${fg};
--accent: ${accent};
--muted: ${muted};
--border: ${border};
--surface: ${surface};
--display: ${display};
--body: ${body};
--mono: ${mono};
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--body);
line-height: 1.55;
font-size: 16px;
}
.wrap { max-width: 960px; margin: 0 auto; padding: 56px 32px 96px; }
.badge {
display: inline-block;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 4px 10px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
margin-bottom: 24px;
}
h1 {
font-family: var(--display);
font-size: clamp(40px, 6vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
margin: 0 0 16px;
}
.lede {
max-width: 60ch;
font-size: 18px;
color: var(--muted);
margin: 0 0 56px;
}
section { margin-bottom: 72px; }
.section-title {
font-family: var(--display);
font-size: 22px;
font-weight: 600;
margin: 0 0 16px;
letter-spacing: -0.01em;
}
.palette {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
}
.swatch {
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
background: var(--surface);
}
.swatch .chip {
height: 96px;
}
.swatch .meta {
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.swatch .name { font-size: 13px; font-weight: 500; }
.swatch .hex { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.typo-row {
display: grid;
grid-template-columns: 88px 1fr;
gap: 24px;
padding: 18px 0;
border-top: 1px solid var(--border);
}
.typo-row:first-child { border-top: none; padding-top: 0; }
.typo-row .label {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
padding-top: 4px;
}
.typo-display { font-family: var(--display); font-size: 40px; line-height: 1.1; letter-spacing: -0.02em; }
.typo-body { font-family: var(--body); font-size: 16px; }
.typo-mono { font-family: var(--mono); font-size: 14px; color: var(--muted); }
.components {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 640px) { .components { grid-template-columns: 1fr; } }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
}
.card .eyebrow {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
margin-bottom: 8px;
}
.card h3 {
font-family: var(--display);
font-size: 20px;
margin: 0 0 8px;
letter-spacing: -0.01em;
}
.card p { margin: 0; color: var(--muted); }
.btn-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
button {
font: inherit;
cursor: pointer;
border-radius: 8px;
padding: 10px 18px;
}
.btn-primary {
background: var(--accent);
color: ${pickReadableForeground(accent)};
border: 1px solid var(--accent);
}
.btn-secondary {
background: transparent;
color: var(--fg);
border: 1px solid var(--border);
}
.btn-link {
background: transparent;
border: none;
color: var(--accent);
padding: 10px 0;
font-weight: 500;
}
.prose {
border-top: 1px solid var(--border);
padding-top: 32px;
color: var(--fg);
}
.prose h1, .prose h2, .prose h3 { font-family: var(--display); letter-spacing: -0.01em; }
.prose h1 { font-size: 28px; margin-top: 0; }
.prose h2 { font-size: 20px; margin-top: 32px; }
.prose h3 { font-size: 16px; margin-top: 24px; }
.prose p, .prose ul, .prose ol { margin: 12px 0; }
.prose code { font-family: var(--mono); background: var(--surface); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; }
.prose blockquote { margin: 16px 0; padding: 8px 16px; border-left: 3px solid var(--accent); color: var(--muted); }
.prose ul, .prose ol { padding-left: 22px; }
.prose pre { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.55; }
.prose pre code { background: transparent; border: none; padding: 0; font-size: inherit; }
.prose hr { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
.prose a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; }
.prose a:hover { border-bottom-color: var(--accent); }
.prose img { max-width: 100%; height: auto; border-radius: 6px; }
.prose .table-wrap { overflow-x: auto; margin: 18px 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
.prose table { width: 100%; border-collapse: collapse; font-size: 13.5px; line-height: 1.5; }
.prose th, .prose td { padding: 9px 14px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }
.prose th { background: var(--bg); font-weight: 600; font-size: 12px; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); }
.prose tr:last-child td { border-bottom: none; }
.prose td code, .prose th code { white-space: nowrap; }
.prose td[align="right"], .prose th[align="right"] { text-align: right; }
.prose td[align="center"], .prose th[align="center"] { text-align: center; }
</style>
</head>
<body>
<main class="wrap">
<span class="badge">Design system preview · ${escapeHtml(id)}</span>
<h1>${escapeHtml(title)}</h1>
${subtitle ? `<p class="lede">${escapeHtml(subtitle)}</p>` : ''}
<section>
<h2 class="section-title">Palette</h2>
<div class="palette">
${colors
.slice(0, 12)
.map(
(c) => `<div class="swatch">
<div class="chip" style="background:${c.value};"></div>
<div class="meta">
<span class="name">${escapeHtml(c.name)}</span>
<span class="hex">${escapeHtml(c.value)}</span>
</div>
</div>`,
)
.join('')}
</div>
</section>
<section>
<h2 class="section-title">Typography</h2>
<div class="typo-row">
<span class="label">Display</span>
<div class="typo-display">The grid carries weight; the line carries pace.</div>
</div>
<div class="typo-row">
<span class="label">Body</span>
<div class="typo-body">Body copy reads at sixteen pixels with a 1.55 leading. Restraint and rhythm matter more than novelty — pick a stack that earns the page.</div>
</div>
<div class="typo-row">
<span class="label">Mono</span>
<div class="typo-mono">/* monospace · ${escapeHtml(mono.split(',')[0]?.replace(/['"]/g, '').trim() ?? 'mono')} */</div>
</div>
</section>
<section>
<h2 class="section-title">Components</h2>
<div class="components">
<div class="card">
<div class="eyebrow">Card</div>
<h3>Production-quality artifact</h3>
<p>Sample card showing how surfaces, borders, and accent text behave in this system.</p>
</div>
<div class="card">
<div class="eyebrow">Buttons</div>
<h3>Three weights, one accent</h3>
<div class="btn-row" style="margin-top: 12px;">
<button class="btn-primary">Primary</button>
<button class="btn-secondary">Secondary</button>
<button class="btn-link">Link →</button>
</div>
</div>
</div>
</section>
<section class="prose">
${renderedMarkdown}
</section>
</main>
</body>
</html>`;
}
function extractSubtitle(raw) {
const lines = raw.split(/\r?\n/);
const h1 = lines.findIndex((l) => /^#\s+/.test(l));
if (h1 === -1) return '';
const after = lines.slice(h1 + 1);
const nextHeading = after.findIndex((l) => /^#{1,6}\s+/.test(l));
const window = (nextHeading === -1 ? after : after.slice(0, nextHeading))
.join('\n')
.replace(/^>\s*Category:.*$/gim, '')
.replace(/^>\s*/gm, '')
.trim();
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
function extractColors(raw) {
const colors = [];
const seen = new Set();
function push(name, value) {
const cleanName = name.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim();
if (!cleanName || cleanName.length > 60) return;
const v = normalizeHex(value);
const key = `${cleanName.toLowerCase()}|${v}`;
if (seen.has(key)) return;
seen.add(key);
colors.push({ name: cleanName, value: v });
}
// Form A: "- **Background:** `#FAFAFA`" / "- Background: #FAFAFA"
const reA = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\s*\**\s*[:]\s*`?(#[0-9a-fA-F]{3,8})/gm;
let m;
while ((m = reA.exec(raw)) !== null) push(m[1], m[2]);
// Form B: "**Stripe Purple** (`#533afd`)" — common in awesome-design-md.
// Token name is whatever's bolded; the hex follows in parens/backticks.
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
while ((m = reB.exec(raw)) !== null) push(m[1], m[2]);
return colors;
}
function extractFonts(raw) {
const out = {};
// "- **Display / headings:** `'GT Sectra', ...`"
// We want the backticked stack OR the rest of the line.
const re = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z /]{1,30}?)\s*\**\s*[:]\s*`?([^`\n]+?)`?$/gm;
let m;
while ((m = re.exec(raw)) !== null) {
const label = m[1].toLowerCase();
const value = m[2].trim().replace(/[*_`]+$/g, '').trim();
if (!/[a-zA-Z]/.test(value)) continue;
if (value.startsWith('#')) continue;
if (/display|heading|h1|title/.test(label) && !out.display) out.display = value;
else if (/body|text|paragraph|copy/.test(label) && !out.body) out.body = value;
else if (/mono|code/.test(label) && !out.mono) out.mono = value;
}
return out;
}
function pickColor(colors, hints) {
for (const hint of hints) {
const needle = hint.toLowerCase();
const found = colors.find((c) => c.name.toLowerCase().includes(needle));
if (found) return found.value;
}
return null;
}
function firstNonNeutral(colors) {
for (const c of colors) {
const v = c.value.replace('#', '').toLowerCase();
if (v.length !== 6) continue;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const sat = max === 0 ? 0 : (max - min) / max;
if (sat > 0.25) return c.value;
}
return null;
}
function pickReadableForeground(hex) {
const n = normalizeHex(hex);
if (n.length !== 7) return '#ffffff';
const r = parseInt(n.slice(1, 3), 16);
const g = parseInt(n.slice(3, 5), 16);
const b = parseInt(n.slice(5, 7), 16);
// Standard luminance check.
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.6 ? '#0a0a0a' : '#ffffff';
}
function normalizeHex(hex) {
let h = hex.toLowerCase();
if (h.length === 4) {
h = '#' + h.slice(1).split('').map((c) => c + c).join('');
}
return h;
}
function cleanTitle(raw) {
return String(raw).replace(/^Design System (Inspired by|for)\s+/i, '').trim();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;',
);
}
// Tiny markdown renderer — enough for our DESIGN.md prose: H1H4, paragraphs,
// bullet/ordered lists, blockquotes, fenced code, GFM pipe tables, horizontal
// rules, inline `code` / **bold** / *italic* / [link](url). Not a full markdown
// implementation but covers everything the DESIGN.md files actually use.
function renderMarkdownLite(src) {
const lines = src.split(/\r?\n/);
const out = [];
let inList = null;
let inBlockquote = false;
let inCode = false;
let i = 0;
function closeList() {
if (inList) {
out.push(`</${inList}>`);
inList = null;
}
}
function closeBlockquote() {
if (inBlockquote) {
out.push('</blockquote>');
inBlockquote = false;
}
}
while (i < lines.length) {
const raw = lines[i] ?? '';
const line = raw.trimEnd();
if (line.startsWith('```')) {
closeList();
closeBlockquote();
if (!inCode) {
out.push('<pre><code>');
inCode = true;
} else {
out.push('</code></pre>');
inCode = false;
}
i++;
continue;
}
if (inCode) {
out.push(escapeHtml(raw));
i++;
continue;
}
if (!line.trim()) {
closeList();
closeBlockquote();
i++;
continue;
}
// GFM pipe table — at least a header row, a separator row of dashes,
// and one body row. Look ahead from `i` so we can consume the whole
// block in one step.
if (looksLikeTableHeader(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1] ?? '')) {
closeList();
closeBlockquote();
const headerCells = splitTableRow(line);
const aligns = parseAlignments(lines[i + 1] ?? '', headerCells.length);
const bodyRows = [];
let j = i + 2;
while (j < lines.length) {
const next = (lines[j] ?? '').trimEnd();
if (!next.trim() || !next.includes('|')) break;
bodyRows.push(splitTableRow(next));
j++;
}
out.push(renderTable(headerCells, bodyRows, aligns));
i = j;
continue;
}
// ATX headings #..####
const h = /^(#{1,4})\s+(.+)$/.exec(line);
if (h) {
closeList();
closeBlockquote();
const level = h[1].length;
out.push(`<h${level}>${inline(h[2])}</h${level}>`);
i++;
continue;
}
// Horizontal rule.
if (/^([-*_])\1{2,}\s*$/.test(line)) {
closeList();
closeBlockquote();
out.push('<hr />');
i++;
continue;
}
const bq = /^>\s?(.*)$/.exec(line);
if (bq) {
closeList();
if (!inBlockquote) {
out.push('<blockquote>');
inBlockquote = true;
}
out.push(`<p>${inline(bq[1] || '')}</p>`);
i++;
continue;
}
closeBlockquote();
const li = /^([-*])\s+(.+)$/.exec(line);
if (li) {
if (inList !== 'ul') {
closeList();
out.push('<ul>');
inList = 'ul';
}
out.push(`<li>${inline(li[2])}</li>`);
i++;
continue;
}
const oli = /^\d+\.\s+(.+)$/.exec(line);
if (oli) {
if (inList !== 'ol') {
closeList();
out.push('<ol>');
inList = 'ol';
}
out.push(`<li>${inline(oli[1])}</li>`);
i++;
continue;
}
closeList();
out.push(`<p>${inline(line)}</p>`);
i++;
}
closeList();
closeBlockquote();
if (inCode) out.push('</code></pre>');
return out.join('\n');
}
function looksLikeTableHeader(line) {
const trimmed = line.trim();
if (!trimmed.includes('|')) return false;
// At least one pipe between non-pipe content.
return /\|/.test(trimmed.replace(/^\||\|$/g, ''));
}
function isTableSeparator(line) {
const trimmed = line.trim();
if (!trimmed.includes('|')) return false;
// Each cell must be only dashes / colons / whitespace.
return splitTableRow(trimmed).every((cell) => /^:?-{1,}:?$/.test(cell.trim()));
}
function splitTableRow(line) {
let s = line.trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|')) s = s.slice(0, -1);
return s.split('|').map((c) => c.trim());
}
function parseAlignments(separatorLine, count) {
const cells = splitTableRow(separatorLine);
const aligns = [];
for (let k = 0; k < count; k++) {
const cell = (cells[k] ?? '').trim();
const left = cell.startsWith(':');
const right = cell.endsWith(':');
if (left && right) aligns.push('center');
else if (right) aligns.push('right');
else aligns.push(null);
}
return aligns;
}
function renderTable(header, rows, aligns) {
const th = header
.map((cell, k) => {
const align = aligns[k];
const attr = align ? ` align="${align}"` : '';
return `<th${attr}>${inline(cell)}</th>`;
})
.join('');
const body = rows
.map((row) => {
const tds = row
.map((cell, k) => {
const align = aligns[k];
const attr = align ? ` align="${align}"` : '';
return `<td${attr}>${inline(cell)}</td>`;
})
.join('');
return `<tr>${tds}</tr>`;
})
.join('');
return `<div class="table-wrap"><table><thead><tr>${th}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function inline(s) {
// Process inline tokens. Order matters: code spans first so their content
// isn't further parsed; then bold/italic; then links; finally bare URLs.
const escaped = escapeHtml(s);
return escaped
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
.replace(/(^|[\s(])_([^_\n]+)_(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>');
}

View File

@@ -0,0 +1,874 @@
// @ts-nocheck
/**
* Build a fully-formed product webpage that demonstrates a design system in
* action — not just a list of tokens, but a real-feeling marketing /
* product page (nav, hero, social proof, feature grid, dashboard preview,
* pricing, testimonials, FAQ, CTA, footer) styled entirely from the
* tokens we extract from the system's DESIGN.md.
*
* Same parsing utilities as design-system-preview.js — kept inline rather
* than imported so the two views can evolve independently.
*/
export function renderDesignSystemShowcase(id, raw) {
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const rawTitle = titleMatch?.[1] ?? id;
const title = cleanTitle(rawTitle);
const subtitle = extractSubtitle(raw) || 'A design system rendered as a real product surface.';
const colors = extractColors(raw);
const fonts = extractFonts(raw);
// Hints are matched against each color's role description (the prose that
// follows the name in DESIGN.md, e.g. "Primary background.") first, then
// against the color name. We use word-boundary matching so descriptive
// names like "Cardinal Red" don't accidentally satisfy a "card" hint and
// "Gem Pink" doesn't satisfy "ink".
// Hint ordering matters: more specific phrases come first so a system
// with both "Primary background" and "Page background in light mode" (e.g.
// Linear's marketing black + light-mode escape hatch) lands on the
// dominant role rather than the light-mode subtitle. We drop 'page
// background' from the bg hints entirely because in practice it almost
// always belongs to a secondary, light-mode-only entry.
const bg =
pickColor(colors, ['primary background', 'background', 'canvas', 'paper'])
?? firstLightish(colors)
?? '#ffffff';
// Exclude `bg` so a token whose hex matches the page background (for
// example Warp's "Warm Parchment" doubling as primary text *and* the
// firstLightish bg fallback) doesn't make body copy invisible.
const fg =
pickColor(
colors,
[
'primary text',
'body text',
'foreground',
'ink primary',
'heading',
'ink',
'graphite',
'navy',
],
[bg],
)
?? pickReadableForeground(bg)
?? '#0a0a0a';
const accent =
pickColor(colors, [
'brand primary',
'primary brand',
'primary cta',
'gradient origin',
'brand mark',
'brand color',
])
?? firstNonNeutral(colors, [bg, fg])
?? '#2f6feb';
const accent2 =
pickColor(colors, [
'brand secondary',
'secondary brand',
'gradient terminus',
'tertiary brand',
'tertiary',
'highlight',
])
?? secondNonNeutral(colors, [accent, bg, fg])
?? accent;
const muted =
pickColor(colors, ['secondary text', 'caption', 'metadata', 'placeholder', 'muted', 'subtle'])
?? '#666666';
const border =
pickColor(colors, ['border', 'divider', 'hairline', 'rule', 'stroke'])
?? '#e6e6e6';
const surface =
pickColor(colors, [
'secondary surface',
'section break',
'sidebar',
'surface subtle',
'surface',
'panel',
'elevated',
'card surface',
])
?? mixSurface(bg);
const display = fonts.display ?? fonts.heading ?? "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
const body = fonts.body ?? display;
const mono = fonts.mono ?? "ui-monospace, 'JetBrains Mono', monospace";
const accentFg = pickReadableForeground(accent);
const accent2Fg = pickReadableForeground(accent2);
const productName = title;
const tagline = oneLine(subtitle).slice(0, 120);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(productName)} — showcase</title>
<style>
:root {
--bg: ${bg};
--fg: ${fg};
--accent: ${accent};
--accent-fg: ${accentFg};
--accent-2: ${accent2};
--accent-2-fg: ${accent2Fg};
--muted: ${muted};
--border: ${border};
--surface: ${surface};
--display: ${display};
--body: ${body};
--mono: ${mono};
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--body);
line-height: 1.6;
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
.container { max-width: 1180px; margin: 0 auto; padding: 0 28px; }
/* Nav */
.nav {
position: sticky; top: 0; z-index: 30;
background: rgba(255,255,255,0.7);
backdrop-filter: saturate(180%) blur(14px);
border-bottom: 1px solid var(--border);
}
.nav-row {
display: flex; align-items: center; gap: 32px;
height: 64px;
}
.brand { display: flex; align-items: center; gap: 10px; font-family: var(--display); font-weight: 700; font-size: 17px; letter-spacing: -0.01em; }
.brand-mark {
width: 26px; height: 26px; border-radius: 7px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
.nav-links { display: flex; gap: 22px; font-size: 14px; color: var(--muted); }
.nav-links a:hover { color: var(--fg); }
.nav-spacer { flex: 1; }
.nav-cta {
display: inline-flex; align-items: center; gap: 6px;
background: var(--fg); color: var(--bg);
padding: 8px 14px; border-radius: 8px; font-size: 13px; font-weight: 500;
}
.nav-link-cta { color: var(--fg); font-weight: 500; font-size: 14px; }
/* Hero */
.hero { padding: 96px 0 72px; }
.hero-eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--mono); font-size: 12px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.08em;
padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px;
background: var(--surface);
margin-bottom: 24px;
}
.hero-eyebrow .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.hero h1 {
font-family: var(--display);
font-size: clamp(44px, 6.6vw, 84px);
line-height: 1.02;
letter-spacing: -0.025em;
margin: 0 0 22px;
max-width: 18ch;
font-weight: 700;
}
.hero h1 em { font-style: normal; background: linear-gradient(120deg, var(--accent), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
.hero p.lede {
font-size: 19px; color: var(--muted);
max-width: 56ch; margin: 0 0 36px;
}
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.btn {
font: inherit; cursor: pointer; border-radius: 10px;
padding: 13px 22px; font-size: 14.5px; font-weight: 500;
border: 1px solid transparent; display: inline-flex; align-items: center; gap: 8px;
}
.btn-primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { background: transparent; color: var(--fg); border-color: var(--border); }
.btn-ghost:hover { background: var(--surface); }
.hero-meta { display: flex; gap: 24px; margin-top: 44px; color: var(--muted); font-size: 13px; }
.hero-meta span strong { color: var(--fg); font-weight: 600; }
/* Logo strip */
.logos { padding: 36px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.logos-label { font-size: 12px; color: var(--muted); text-align: center; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 18px; }
.logos-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 44px; align-items: center; opacity: 0.85; }
.logo-pill { font-family: var(--display); font-weight: 700; font-size: 17px; letter-spacing: -0.01em; color: var(--muted); }
/* Features grid */
.section { padding: 96px 0; }
.section-eyebrow { font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.1em; font-size: 12px; color: var(--accent); margin-bottom: 12px; }
.section-title { font-family: var(--display); font-size: clamp(32px, 4.2vw, 48px); letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 18px; max-width: 22ch; font-weight: 700; }
.section-lede { color: var(--muted); font-size: 17px; max-width: 56ch; margin: 0 0 48px; }
.features {
display: grid; gap: 18px;
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 920px) { .features { grid-template-columns: 1fr 1fr; } }
@media (max-width: 600px) { .features { grid-template-columns: 1fr; } }
.feature {
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
padding: 26px; display: flex; flex-direction: column; gap: 12px;
}
.feature-icon {
width: 36px; height: 36px; border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: var(--accent-fg);
display: inline-flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 700;
}
.feature h3 { font-family: var(--display); font-size: 18px; margin: 0; letter-spacing: -0.01em; }
.feature p { color: var(--muted); margin: 0; font-size: 14.5px; line-height: 1.55; }
/* Product preview / dashboard mock */
.preview-wrap { padding-top: 24px; padding-bottom: 96px; }
.preview-frame {
background: var(--surface); border: 1px solid var(--border); border-radius: 18px;
padding: 14px;
box-shadow: 0 30px 80px rgba(0,0,0,0.06), 0 12px 30px rgba(0,0,0,0.04);
}
.preview-titlebar { display: flex; gap: 6px; padding: 4px 8px 12px; }
.preview-titlebar span { width: 10px; height: 10px; border-radius: 50%; background: var(--border); }
.preview-app {
background: var(--bg); border: 1px solid var(--border); border-radius: 12px;
display: grid; grid-template-columns: 220px 1fr; min-height: 440px; overflow: hidden;
}
.preview-side { background: var(--surface); border-right: 1px solid var(--border); padding: 18px 14px; display: flex; flex-direction: column; gap: 4px; }
.side-link { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; font-size: 13.5px; color: var(--muted); }
.side-link.active { background: var(--bg); color: var(--fg); font-weight: 500; box-shadow: inset 0 0 0 1px var(--border); }
.side-link .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.side-section { font-family: var(--mono); text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--muted); padding: 14px 10px 6px; }
.preview-main { padding: 22px 24px; display: flex; flex-direction: column; gap: 22px; }
.preview-head { display: flex; align-items: center; justify-content: space-between; }
.preview-head h4 { font-family: var(--display); font-size: 22px; margin: 0; letter-spacing: -0.01em; }
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
.kpi .label { font-size: 11.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
.kpi .value { font-family: var(--display); font-size: 24px; font-weight: 700; margin-top: 4px; letter-spacing: -0.01em; }
.kpi .delta { font-family: var(--mono); font-size: 11.5px; margin-top: 2px; color: var(--accent); }
.chart-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px; }
.chart-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
.chart-head .title { font-weight: 600; font-size: 14px; }
.chart-head .meta { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.chart svg { width: 100%; height: 160px; display: block; }
.preview-row-2 { display: grid; grid-template-columns: 1.6fr 1fr; gap: 14px; }
.list-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
.list-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--border); align-items: center; }
.list-row:first-of-type { border-top: none; }
.list-row .name { font-weight: 500; font-size: 13.5px; }
.list-row .meta { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 500; background: var(--bg); border: 1px solid var(--border); color: var(--muted); }
.badge.up { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
.list-card .head { display: flex; justify-content: space-between; align-items: baseline; padding: 14px 16px; border-bottom: 1px solid var(--border); }
.list-card .head h5 { margin: 0; font-size: 14px; }
/* Pricing */
.pricing { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
@media (max-width: 920px) { .pricing { grid-template-columns: 1fr; } }
.price-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
padding: 28px; display: flex; flex-direction: column; gap: 18px;
}
.price-card.featured {
background: var(--fg); color: var(--bg); border-color: var(--fg);
}
.price-card.featured .muted, .price-card.featured h3, .price-card.featured .price { color: var(--bg); }
.price-card .tier-name { font-family: var(--display); font-size: 14px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
.price-card .price { font-family: var(--display); font-size: 44px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
.price-card .price small { font-size: 14px; color: var(--muted); font-weight: 400; }
.price-card ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; font-size: 14.5px; }
.price-card li::before { content: "✓"; color: var(--accent); margin-right: 8px; font-weight: 700; }
.price-card.featured li::before { color: var(--accent-2); }
/* Testimonials */
.quotes { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
@media (max-width: 760px) { .quotes { grid-template-columns: 1fr; } }
.quote { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 26px; display: flex; flex-direction: column; gap: 18px; }
.quote p { font-size: 17px; line-height: 1.55; margin: 0; font-family: var(--display); letter-spacing: -0.01em; }
.quote-author { display: flex; align-items: center; gap: 12px; }
.quote-author .avatar { width: 36px; height: 36px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-2)); }
.quote-author .name { font-weight: 600; font-size: 13.5px; }
.quote-author .role { font-size: 12.5px; color: var(--muted); }
/* FAQ */
.faq { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 32px; }
@media (max-width: 760px) { .faq { grid-template-columns: 1fr; } }
.faq-item { padding: 18px 0; border-top: 1px solid var(--border); }
.faq-item h4 { margin: 0 0 6px; font-family: var(--display); font-size: 17px; letter-spacing: -0.01em; }
.faq-item p { margin: 0; color: var(--muted); font-size: 14.5px; }
/* CTA */
.cta {
margin: 48px 0 96px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: var(--accent-fg);
border-radius: 24px;
padding: 64px 56px;
display: grid;
grid-template-columns: 1.4fr auto;
gap: 32px;
align-items: center;
}
@media (max-width: 760px) { .cta { grid-template-columns: 1fr; padding: 36px; } }
.cta h2 { font-family: var(--display); font-size: clamp(28px, 4vw, 40px); letter-spacing: -0.02em; margin: 0 0 10px; line-height: 1.1; max-width: 22ch; }
.cta p { margin: 0; opacity: 0.92; font-size: 16px; max-width: 50ch; }
.cta .btn { background: var(--accent-fg); color: var(--accent); border: none; }
.cta .btn-secondary { background: transparent; color: var(--accent-fg); border: 1px solid color-mix(in srgb, var(--accent-fg) 35%, transparent); }
/* Footer */
footer { border-top: 1px solid var(--border); padding: 36px 0 56px; color: var(--muted); font-size: 13.5px; }
.footer-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; margin-bottom: 32px; }
@media (max-width: 760px) { .footer-row { grid-template-columns: 1fr 1fr; } }
.footer-col h6 { color: var(--fg); font-family: var(--display); font-size: 13.5px; margin: 0 0 12px; font-weight: 600; }
.footer-col a { display: block; padding: 4px 0; }
.footer-col a:hover { color: var(--fg); }
.footer-bottom { display: flex; justify-content: space-between; padding-top: 24px; border-top: 1px solid var(--border); }
</style>
</head>
<body>
<header class="nav">
<div class="container nav-row">
<a class="brand" href="#"><span class="brand-mark"></span>${escapeHtml(productName)}</a>
<nav class="nav-links">
<a href="#features">Product</a>
<a href="#preview">Workspace</a>
<a href="#pricing">Pricing</a>
<a href="#faq">Docs</a>
<a href="#faq">Customers</a>
</nav>
<div class="nav-spacer"></div>
<a class="nav-link-cta" href="#">Sign in</a>
<a class="nav-cta" href="#">Get started →</a>
</div>
</header>
<main>
<section class="hero">
<div class="container">
<div class="hero-eyebrow"><span class="dot"></span>${escapeHtml(productName)} · live preview</div>
<h1>The system that makes <em>${escapeHtml(productName)}</em> feel like ${escapeHtml(productName)}.</h1>
<p class="lede">${escapeHtml(tagline)}</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#">Start a free trial →</a>
<a class="btn btn-ghost" href="#preview">See it in action</a>
</div>
<div class="hero-meta">
<span><strong>4.9</strong> · App Store rating</span>
<span><strong>SOC 2</strong> · Type II compliant</span>
<span><strong>120k+</strong> active teams</span>
</div>
</div>
</section>
<section class="logos">
<div class="container">
<div class="logos-label">Trusted by teams shipping serious work</div>
<div class="logos-row">
<span class="logo-pill">Northwind</span>
<span class="logo-pill">Pioneer</span>
<span class="logo-pill">Lattice</span>
<span class="logo-pill">Atlas Co.</span>
<span class="logo-pill">Voltage</span>
<span class="logo-pill">Foundry</span>
</div>
</div>
</section>
<section class="section" id="features">
<div class="container">
<div class="section-eyebrow">What it does</div>
<h2 class="section-title">Every primitive a fast team needs.</h2>
<p class="section-lede">A system styled entirely from the tokens of ${escapeHtml(productName)} — palette, typography, surfaces, and motion. Drop it into any product and it stays in character.</p>
<div class="features">
${featureCard('★', 'Tokens that compose', 'Color, type, spacing, and elevation defined once and reused across every surface — from a marketing hero to a row in a table.')}
${featureCard('◐', 'Light & dark in lockstep', 'Every component ships with both modes. The accent reads as confident in either context, and contrast meets WCAG AA out of the box.')}
${featureCard('⌘', 'Desktop-first, but mobile-honest', 'Layouts collapse from a 12-column desktop grid to a focused single column without losing density or rhythm.')}
${featureCard('▣', 'Production-grade primitives', '40+ components — from the obvious (button, input) to the load-bearing (data table, command bar, empty states).')}
${featureCard('↗', 'Designed for handoff', 'Every spec carries a Figma frame, a code snippet, and a "do/dont" pair so engineers dont have to guess.')}
${featureCard('∞', 'Built to evolve', 'Tokens version semver-style. A palette refresh ships through one file — no component code touches.')}
</div>
</div>
</section>
<section class="preview-wrap" id="preview">
<div class="container">
<div class="section-eyebrow">In production</div>
<h2 class="section-title">A workspace, fully styled.</h2>
<p class="section-lede">This is the same component library you'd use in your app — rendered with ${escapeHtml(productName)} tokens.</p>
<div class="preview-frame">
<div class="preview-titlebar"><span></span><span></span><span></span></div>
<div class="preview-app">
<aside class="preview-side">
<div class="brand" style="margin-bottom: 14px;"><span class="brand-mark"></span>${escapeHtml(productName)}</div>
<a class="side-link active"><span class="dot"></span>Overview</a>
<a class="side-link">Customers</a>
<a class="side-link">Pipeline</a>
<a class="side-link">Reports</a>
<a class="side-link">Automations</a>
<div class="side-section">Workspaces</div>
<a class="side-link">Growth</a>
<a class="side-link">Lifecycle</a>
<a class="side-link">Finance</a>
</aside>
<div class="preview-main">
<div class="preview-head">
<h4>Overview</h4>
<span class="badge up">↑ 12.4% this week</span>
</div>
<div class="kpi-row">
${kpi('MRR', '$184,210', '+8.2%')}
${kpi('Active orgs', '2,914', '+121')}
${kpi('Conversion', '4.6%', '+0.4 pp')}
${kpi('Net retention', '113%', '+2 pp')}
</div>
<div class="chart-card">
<div class="chart-head">
<span class="title">Revenue · last 12 weeks</span>
<span class="meta">USD · weekly</span>
</div>
<div class="chart">
${inlineLineChart()}
</div>
</div>
<div class="preview-row-2">
<div class="list-card">
<div class="head">
<h5>Top accounts</h5>
<span class="badge">View all</span>
</div>
${listRow('Northwind Trading', 'Annual · NA', '$48,200', 'up')}
${listRow('Pioneer Robotics', 'Quarterly · EMEA', '$31,890', 'up')}
${listRow('Atlas Cooperative', 'Annual · APAC', '$22,400', '')}
${listRow('Foundry Group', 'Monthly · NA', '$14,750', 'up')}
</div>
<div class="list-card">
<div class="head">
<h5>Activity</h5>
<span class="badge">Live</span>
</div>
${activityRow('Renewal closed', 'Lattice · 11m ago')}
${activityRow('Trial started', 'Voltage · 22m ago')}
${activityRow('Plan upgraded', 'Pioneer · 1h ago')}
${activityRow('Invoice paid', 'Atlas · 2h ago')}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="section" id="pricing" style="padding-top: 24px;">
<div class="container">
<div class="section-eyebrow">Pricing</div>
<h2 class="section-title">Built for teams of one to one thousand.</h2>
<p class="section-lede">Pick the plan that matches the way your team ships. Every tier ships the full token system.</p>
<div class="pricing">
${priceCard('Starter', '$0', 'Free forever', ['Single user', 'All core tokens', 'Up to 3 projects', 'Community support'])}
${priceCard('Team', '$24', 'per seat / month', ['Unlimited projects', 'Real-time co-edit', 'Brand themes', 'Priority email support'], true)}
${priceCard('Enterprise', 'Custom', 'volume pricing', ['SSO + SCIM', 'Audit logs', 'Custom token schemas', 'Dedicated success manager'])}
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="section-eyebrow">Customers</div>
<h2 class="section-title">Loved by teams who care about craft.</h2>
<div class="quotes">
${quote('"Our marketing site, our app, and our internal dashboards finally feel like the same product. The token system is doing all the work."', 'Mira Okafor', 'Head of Design · Pioneer')}
${quote('"We swapped our entire design language in an afternoon. Nothing broke. Thats the line, and we crossed it."', 'Caleb Renner', 'Engineering Lead · Northwind')}
</div>
</div>
</section>
<section class="section" id="faq" style="padding-top: 24px;">
<div class="container">
<div class="section-eyebrow">FAQ</div>
<h2 class="section-title">Questions, answered.</h2>
<div class="faq">
${faq('Is this a Figma library, a code library, or both?', 'Both. Tokens flow from one source of truth into Figma styles and into the codegen pipeline at the same time.')}
${faq('Can we ship our own brand theme?', 'Yes — fork the token file, change the palette and type stack, and every component reskins automatically.')}
${faq('What about accessibility?', 'Color contrast meets WCAG AA on every surface. Components ship with focus rings, ARIA roles, and keyboard handling.')}
${faq('How do you handle dark mode?', 'Every token has a paired dark value. The system flips at the document level — no per-component overrides needed.')}
</div>
</div>
</section>
<section>
<div class="container">
<div class="cta">
<div>
<h2>Ship a product that finally feels finished.</h2>
<p>Drop the system into your app today. The first project is on us.</p>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<a class="btn btn-primary" href="#">Start free trial</a>
<a class="btn btn-secondary" href="#">Talk to sales</a>
</div>
</div>
</div>
</section>
</main>
<footer>
<div class="container">
<div class="footer-row">
<div class="footer-col">
<div class="brand" style="margin-bottom: 12px;"><span class="brand-mark"></span>${escapeHtml(productName)}</div>
<p style="margin: 0; max-width: 38ch;">${escapeHtml(tagline)}</p>
</div>
<div class="footer-col"><h6>Product</h6><a href="#">Features</a><a href="#">Pricing</a><a href="#">Changelog</a><a href="#">Roadmap</a></div>
<div class="footer-col"><h6>Company</h6><a href="#">About</a><a href="#">Customers</a><a href="#">Careers</a><a href="#">Press</a></div>
<div class="footer-col"><h6>Resources</h6><a href="#">Docs</a><a href="#">Status</a><a href="#">Brand</a><a href="#">Contact</a></div>
</div>
<div class="footer-bottom">
<span>© ${new Date().getFullYear()} ${escapeHtml(productName)}. All rights reserved.</span>
<span>Showcase rendered from <code style="font-family: var(--mono);">design-systems/${escapeHtml(id)}/DESIGN.md</code></span>
</div>
</div>
</footer>
</body>
</html>`;
}
function featureCard(icon, title, body) {
return `<div class="feature">
<div class="feature-icon">${escapeHtml(icon)}</div>
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(body)}</p>
</div>`;
}
function kpi(label, value, delta) {
return `<div class="kpi">
<div class="label">${escapeHtml(label)}</div>
<div class="value">${escapeHtml(value)}</div>
<div class="delta">${escapeHtml(delta)}</div>
</div>`;
}
function listRow(name, meta, value, status) {
const badge = status === 'up' ? '<span class="badge up">↑</span>' : '<span class="badge">·</span>';
return `<div class="list-row">
<div>
<div class="name">${escapeHtml(name)}</div>
<div class="meta">${escapeHtml(meta)}</div>
</div>
<div class="meta">${escapeHtml(value)}</div>
${badge}
</div>`;
}
function activityRow(name, meta) {
return `<div class="list-row">
<div>
<div class="name">${escapeHtml(name)}</div>
<div class="meta">${escapeHtml(meta)}</div>
</div>
<div></div>
<span class="badge">●</span>
</div>`;
}
function priceCard(name, price, sub, features, featured) {
return `<div class="price-card${featured ? ' featured' : ''}">
<div class="tier-name">${escapeHtml(name)}</div>
<div class="price">${escapeHtml(price)} <small>${escapeHtml(sub)}</small></div>
<ul>${features.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
<a class="btn ${featured ? 'btn-primary' : 'btn-ghost'}" href="#" style="${featured ? 'background: var(--accent); color: var(--accent-fg); border-color: var(--accent);' : ''}">Choose ${escapeHtml(name)}</a>
</div>`;
}
function quote(text, name, role) {
return `<div class="quote">
<p>${escapeHtml(text)}</p>
<div class="quote-author">
<div class="avatar"></div>
<div>
<div class="name">${escapeHtml(name)}</div>
<div class="role">${escapeHtml(role)}</div>
</div>
</div>
</div>`;
}
function faq(q, a) {
return `<div class="faq-item">
<h4>${escapeHtml(q)}</h4>
<p>${escapeHtml(a)}</p>
</div>`;
}
function inlineLineChart() {
// Deterministic numbers so the chart looks specific (12 weekly data points).
const data = [38, 44, 41, 52, 49, 61, 58, 67, 71, 76, 82, 88];
const max = Math.max(...data);
const min = Math.min(...data);
const w = 720;
const h = 160;
const padX = 8;
const padY = 14;
const stepX = (w - padX * 2) / (data.length - 1);
const norm = (v) => padY + (h - padY * 2) * (1 - (v - min) / (max - min));
const points = data.map((v, i) => `${padX + i * stepX},${norm(v).toFixed(1)}`).join(' ');
const area = `${padX},${h} ${points} ${w - padX},${h}`;
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
<defs>
<linearGradient id="lg" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="var(--accent)" stop-opacity="0.32"/>
<stop offset="100%" stop-color="var(--accent)" stop-opacity="0"/>
</linearGradient>
</defs>
<polygon points="${area}" fill="url(#lg)"/>
<polyline points="${points}" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
${data.map((v, i) => `<circle cx="${padX + i * stepX}" cy="${norm(v).toFixed(1)}" r="${i === data.length - 1 ? 4 : 0}" fill="var(--accent)"/>`).join('')}
</svg>`;
}
function extractSubtitle(raw) {
const lines = raw.split(/\r?\n/);
const h1 = lines.findIndex((l) => /^#\s+/.test(l));
if (h1 === -1) return '';
const after = lines.slice(h1 + 1);
const nextHeading = after.findIndex((l) => /^#{1,6}\s+/.test(l));
const window = (nextHeading === -1 ? after : after.slice(0, nextHeading))
.join('\n')
.replace(/^>\s*Category:.*$/gim, '')
.replace(/^>\s*/gm, '')
.trim();
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
export function extractColors(raw) {
const colors = [];
const seen = new Set();
function push(name, value, role) {
const cleanName = String(name).replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim();
if (!cleanName || cleanName.length > 60) return;
const v = normalizeHex(value);
const key = `${cleanName.toLowerCase()}|${v}`;
const cleanRole = String(role || '')
.replace(/[`*_]+/g, '')
.replace(/\s+/g, ' ')
.trim()
.replace(/[.;]+$/, '');
if (seen.has(key)) {
// Already recorded — but if this occurrence carries a richer role
// description, upgrade the stored entry so role-based lookups don't
// fall back to the bare name.
if (cleanRole) {
const existing = colors.find(
(c) => c.name.toLowerCase() === cleanName.toLowerCase() && c.value === v,
);
if (existing && (!existing.role || cleanRole.length > existing.role.length)) {
existing.role = cleanRole;
}
}
return;
}
seen.add(key);
colors.push({ name: cleanName, value: v, role: cleanRole });
}
// Process the file line-by-line so multi-hex entries like Linear's
// `**Marketing Black** (\`#010102\` / \`#08090a\`): role` don't confuse a
// single global regex. We extract three pieces from each candidate line:
// - the bold (or list-prefixed) name
// - the FIRST hex on the line
// - everything after the first `:` that follows the hex (the role)
for (const rawLine of raw.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
// Pattern A: **Name** … #hex … : role description
const bold = /\*\*([A-Za-z][A-Za-z0-9 /&()+_'-]{1,40}?)\*\*([^\n]+)/.exec(line);
if (bold) {
const rest = bold[2] ?? '';
const hex = /#[0-9a-fA-F]{3,8}\b/.exec(rest);
if (hex) {
const after = rest.slice((hex.index ?? 0) + hex[0].length);
const colonIdx = after.search(/[:]/);
const role = colonIdx >= 0 ? after.slice(colonIdx + 1).trim() : '';
push(bold[1], hex[0], role);
continue;
}
}
// Pattern B: list-prefixed spec lines like
// "- Background: `#7d2ae8`" inside a ### Buttons block.
// Also handles the `- **Name:** \`#hex\`` shape (colon inside the bold
// wrapper) used by agentic/warm-editorial: the optional `\*{0,2}` slots
// before the name and after the colon let us absorb the surrounding
// `**` markers without needing a third pattern.
// Use the name itself as the role so lookups can still see "Background"
// and "Text" labels.
const spec = /^[\s>*-]*\*{0,2}([A-Za-z][^:*\n]{1,40}?)\*{0,2}\s*[:]\s*\*{0,2}\s*`?(#[0-9a-fA-F]{3,8})/.exec(line);
if (spec) {
push(spec[1], spec[2], spec[1]);
}
}
return colors;
}
function extractFonts(raw) {
const out = {};
const re = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z /]{1,30}?)\s*\**\s*[:]\s*`?([^`\n]+?)`?$/gm;
let m;
while ((m = re.exec(raw)) !== null) {
const label = m[1].toLowerCase();
const value = m[2].trim().replace(/[*_`]+$/g, '').trim();
if (!/[a-zA-Z]/.test(value)) continue;
if (value.startsWith('#')) continue;
if (/display|heading|h1|title/.test(label) && !out.display) out.display = value;
else if (/body|text|paragraph|copy/.test(label) && !out.body) out.body = value;
else if (/mono|code/.test(label) && !out.mono) out.mono = value;
}
return out;
}
function escapeRegex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Match a hint as a whole word inside `text` (case-insensitive). We use word
// boundaries so descriptive color names like "Cardinal Red" don't satisfy a
// "card" hint, and "Gem Pink" doesn't satisfy "ink" — both real bugs the
// substring-based version produced for the Duolingo and Canva showcases.
function matchesHint(text, hint) {
if (!text) return false;
const needle = hint.toLowerCase().trim();
if (!needle) return false;
const re = new RegExp(`\\b${escapeRegex(needle)}\\b`, 'i');
return re.test(text);
}
function pickColor(colors, hints, exclude = []) {
// Two-pass lookup: each hint is first checked against every color's role
// description (the prose authors use to explain how the color is used)
// and only then against the bare name. This ensures a `**Snow** … Primary
// background.` line is recognised as the page background even though the
// name "Snow" doesn't contain the word "background".
// `exclude` skips colors whose hex equals an already-chosen role (e.g.
// pass `[bg]` when picking `fg`) so two roles can't collapse to the same
// hex and erase contrast.
const blocked = new Set(
exclude
.map((v) => (v == null ? '' : String(v).toLowerCase()))
.filter((v) => v.length > 0),
);
const isAllowed = (c) => !blocked.has(c.value.toLowerCase());
for (const hint of hints) {
const byRole = colors.find((c) => isAllowed(c) && matchesHint(c.role, hint));
if (byRole) return byRole.value;
const byName = colors.find((c) => isAllowed(c) && matchesHint(c.name, hint));
if (byName) return byName.value;
}
return null;
}
function colorSaturation(hex) {
const v = String(hex).replace('#', '').toLowerCase();
if (v.length !== 6) return 0;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
return max === 0 ? 0 : (max - min) / max;
}
function colorLuminance(hex) {
const v = String(hex).replace('#', '').toLowerCase();
if (v.length !== 6) return 0.5;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}
function firstLightish(colors) {
for (const c of colors) {
if (colorSaturation(c.value) > 0.15) continue;
if (colorLuminance(c.value) >= 0.92) return c.value;
}
return null;
}
function firstNonNeutral(colors, exclude = []) {
const set = new Set(exclude.map((v) => String(v || '').toLowerCase()));
for (const c of colors) {
if (set.has(c.value.toLowerCase())) continue;
if (colorSaturation(c.value) > 0.25) return c.value;
}
return null;
}
function secondNonNeutral(colors, exclude = []) {
const set = new Set(exclude.map((v) => String(v || '').toLowerCase()));
for (const c of colors) {
if (set.has(c.value.toLowerCase())) continue;
if (colorSaturation(c.value) > 0.25) return c.value;
}
return null;
}
function pickReadableForeground(hex) {
const n = normalizeHex(hex);
if (n.length !== 7) return '#ffffff';
const r = parseInt(n.slice(1, 3), 16);
const g = parseInt(n.slice(3, 5), 16);
const b = parseInt(n.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.6 ? '#0a0a0a' : '#ffffff';
}
function mixSurface(bg) {
const n = normalizeHex(bg);
if (n.length !== 7) return '#fafafa';
const r = parseInt(n.slice(1, 3), 16);
const g = parseInt(n.slice(3, 5), 16);
const b = parseInt(n.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Lift dark backgrounds; tint light backgrounds slightly cooler.
const adjust = lum < 0.4 ? 16 : -8;
const fix = (v) => Math.max(0, Math.min(255, v + adjust)).toString(16).padStart(2, '0');
return `#${fix(r)}${fix(g)}${fix(b)}`;
}
function normalizeHex(hex) {
let h = hex.toLowerCase();
if (h.length === 4) {
h = '#' + h.slice(1).split('').map((c) => c + c).join('');
}
return h;
}
function cleanTitle(raw) {
return String(raw).replace(/^Design System (Inspired by|for)\s+/i, '').trim();
}
function oneLine(s) {
return String(s).replace(/\s+/g, ' ').trim();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;',
);
}

View File

@@ -0,0 +1,170 @@
// @ts-nocheck
// Design-system registry. Scans <projectRoot>/design-systems/* for DESIGN.md
// files. Title comes from the first H1. Category comes from a
// `> Category: <name>` blockquote line beneath the H1. Summary is the first
// paragraph between the H1 and the next heading (Category line stripped).
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
export async function listDesignSystems(root) {
const out = [];
let entries = [];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const designPath = path.join(root, entry.name, 'DESIGN.md');
try {
const stats = await stat(designPath);
if (!stats.isFile()) continue;
const raw = await readFile(designPath, 'utf8');
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const title = cleanTitle(titleMatch?.[1] ?? entry.name);
out.push({
id: entry.name,
title,
category: extractCategory(raw) ?? 'Uncategorized',
summary: summarize(raw),
swatches: extractSwatches(raw),
surface: extractSurface(raw),
body: raw,
});
} catch {
// Skip.
}
}
return out;
}
export async function readDesignSystem(root, id) {
const file = path.join(root, id, 'DESIGN.md');
try {
return await readFile(file, 'utf8');
} catch {
return null;
}
}
function summarize(raw) {
const lines = raw.split(/\r?\n/);
const firstH1 = lines.findIndex((l) => /^#\s+/.test(l));
if (firstH1 === -1) return '';
const afterH1 = lines.slice(firstH1 + 1);
const nextHeading = afterH1.findIndex((l) => /^#{1,6}\s+/.test(l));
const window = (nextHeading === -1 ? afterH1 : afterH1.slice(0, nextHeading))
.join('\n')
// Drop the Category metadata line — it's surfaced separately.
.replace(/^>\s*Category:.*$/gim, '')
.replace(/^>\s*/gm, '')
.trim();
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
function extractCategory(raw) {
const m = /^>\s*Category:\s*(.+?)\s*$/im.exec(raw);
return m?.[1];
}
const KNOWN_SURFACES = new Set(['web', 'image', 'video', 'audio']);
function extractSurface(raw) {
const m = /^>\s*Surface:\s*(.+?)\s*$/im.exec(raw);
if (!m) return 'web';
const v = m[1].trim().toLowerCase();
return KNOWN_SURFACES.has(v) ? v : 'web';
}
// Strip boilerplate like "Design System Inspired by Cohere" → "Cohere" so
// the picker dropdown reads cleanly. Hand-authored titles that don't match
// the pattern (e.g. "Neutral Modern") pass through unchanged.
function cleanTitle(raw) {
return raw
.replace(/^Design System (Inspired by|for)\s+/i, '')
.trim();
}
/**
* Pull 4 representative colors from a DESIGN.md so the picker can render
* a tiny swatch row next to each system. Order: [bg, support, fg, accent].
*
* The shape is deliberately compact — one accent + one background + one
* fg + one supporting tone — so the row reads like a brand mark even at
* thumbnail scale. Picked greedily by token-name hints (matches the
* heuristics in design-system-preview.js so the strip and the showcase
* agree on which colors the system "is").
*
* @param {string} raw Markdown body of DESIGN.md
* @returns {string[]} Up to 4 hex strings; [] if extraction fails.
*/
function extractSwatches(raw) {
const colors = [];
const seen = new Set();
function push(name, value) {
const cleanName = name.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
const v = normalizeHex(value);
if (!v || cleanName.length > 60) return;
const key = `${cleanName}|${v}`;
if (seen.has(key)) return;
seen.add(key);
colors.push({ name: cleanName, value: v });
}
// Form A: "- **Background:** `#FAFAFA`" — the colon may sit inside the
// bold markers (`**Name:**`) or outside them (`**Name**:`). Both variants
// are common in hand-authored DESIGN.md files, so we allow the colon in
// either position around the closing `**`.
const reA = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\s*[:]?\s*\**\s*[:]?\s*`?(#[0-9a-fA-F]{3,8})/gm;
let m;
while ((m = reA.exec(raw)) !== null) push(m[1], m[2]);
// Form B: "**Stripe Purple** (`#533afd`)"
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
while ((m = reB.exec(raw)) !== null) push(m[1], m[2]);
if (colors.length === 0) return [];
function pick(hints) {
for (const h of hints) {
const found = colors.find((c) => c.name.includes(h));
if (found) return found.value;
}
return null;
}
function isNeutral(hex) {
if (!/^#[0-9a-f]{6}$/.test(hex)) return false;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return Math.max(r, g, b) - Math.min(r, g, b) < 10;
}
const bg =
pick(['page background', 'background', 'canvas', 'paper', 'surface'])
?? '#ffffff';
const fg =
pick(['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite'])
?? '#111111';
const accent =
pick(['primary brand', 'brand primary', 'accent', 'brand', 'primary'])
?? colors.find((c) => !isNeutral(c.value))?.value
?? colors[0]?.value
?? '#888888';
const support =
pick(['border', 'divider', 'rule', 'muted', 'secondary', 'subtle'])
?? colors.find(
(c) => isNeutral(c.value) && c.value !== bg && c.value !== fg,
)?.value
?? '#cccccc';
return [bg, support, fg, accent];
}
function normalizeHex(raw) {
if (typeof raw !== 'string') return null;
const m = /^#([0-9a-fA-F]{3,8})$/.exec(raw.trim());
if (!m) return null;
let hex = m[1];
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
if (hex.length === 4) hex = hex.split('').map((c) => c + c).join('').slice(0, 8);
return '#' + hex.toLowerCase();
}

View File

@@ -0,0 +1,294 @@
// @ts-nocheck
import { execFile } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import JSZip from 'jszip';
import { kindFor } from './projects.js';
const execFileP = promisify(execFile);
const MAX_COMPRESSED_PREVIEW_BYTES = 10 * 1024 * 1024;
const MAX_UNCOMPRESSED_PREVIEW_BYTES = 50 * 1024 * 1024;
const MAX_XML_ENTRY_BYTES = 5 * 1024 * 1024;
const MAX_PDF_PREVIEW_CONCURRENCY = 2;
const pdfPreviewQueue = createLimiter(MAX_PDF_PREVIEW_CONCURRENCY);
export async function buildDocumentPreview(file) {
const kind = kindFor(file.name);
if (!['pdf', 'document', 'presentation', 'spreadsheet'].includes(kind)) {
const err = new Error('unsupported preview type');
err.statusCode = 415;
throw err;
}
if (kind === 'pdf') {
return {
kind,
title: path.basename(file.name),
sections: await pdfPreviewQueue(() => previewPdf(file.buffer)),
};
}
assertPreviewInputSize(file.buffer.length);
const zip = await JSZip.loadAsync(file.buffer);
assertZipPreviewSize(zip);
if (kind === 'document') {
return {
kind,
title: path.basename(file.name),
sections: await previewDocx(zip),
};
}
if (kind === 'presentation') {
return {
kind,
title: path.basename(file.name),
sections: await previewPptx(zip),
};
}
return {
kind,
title: path.basename(file.name),
sections: await previewXlsx(zip),
};
}
async function previewPdf(buffer) {
assertPreviewInputSize(buffer.length);
const tmpDir = await mkdtemp(path.join(tmpdir(), 'od-preview-'));
const tmpFile = path.join(tmpDir, 'input.pdf');
await writeFile(tmpFile, buffer, { flag: 'wx' });
try {
const { stdout } = await execFileP('pdftotext', ['-layout', tmpFile, '-'], {
timeout: 5000,
maxBuffer: 2 * 1024 * 1024,
});
const lines = stdout
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0);
return [
{
title: 'PDF',
lines: lines.length > 0 ? lines : ['No readable text found.'],
},
];
} catch {
return [
{
title: 'PDF',
lines: ['Text preview is unavailable. Use Open or Download to inspect the PDF.'],
},
];
} finally {
rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
async function previewDocx(zip) {
const xml = await readZipText(zip, 'word/document.xml');
const paragraphs = extractParagraphs(xml, /<w:p\b[\s\S]*?<\/w:p>/g);
return [
{
title: 'Document',
lines: paragraphs.length > 0 ? paragraphs : ['No readable text found.'],
},
];
}
async function previewPptx(zip) {
const slideNames = Object.keys(zip.files)
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name))
.sort(numericPathSort);
const sections = [];
for (let i = 0; i < slideNames.length; i += 1) {
const xml = await readZipText(zip, slideNames[i]);
const lines = extractTextRuns(xml);
sections.push({
title: `Slide ${i + 1}`,
lines: lines.length > 0 ? lines : ['No readable text found.'],
});
}
return sections.length > 0
? sections
: [{ title: 'Presentation', lines: ['No readable slides found.'] }];
}
async function previewXlsx(zip) {
const sharedStrings = await readSharedStrings(zip);
const workbook = await readWorkbook(zip);
const sections = [];
for (const sheet of workbook) {
const xml = await readZipText(zip, sheet.path).catch(() => '');
const lines = extractWorksheetRows(xml, sharedStrings);
sections.push({
title: sheet.name,
lines: lines.length > 0 ? lines : ['No readable cell values found.'],
});
}
return sections.length > 0
? sections
: [{ title: 'Spreadsheet', lines: ['No readable sheets found.'] }];
}
async function readSharedStrings(zip) {
const xml = await readZipText(zip, 'xl/sharedStrings.xml').catch(() => '');
if (!xml) return [];
return Array.from(xml.matchAll(/<si\b[\s\S]*?<\/si>/g)).map((m) =>
extractTextRuns(m[0]).join(''),
);
}
async function readWorkbook(zip) {
const workbookXml = await readZipText(zip, 'xl/workbook.xml').catch(() => '');
const relsXml = await readZipText(zip, 'xl/_rels/workbook.xml.rels').catch(() => '');
const rels = new Map();
for (const rel of relsXml.matchAll(/<Relationship\b([^>]*)\/?>/g)) {
const attrs = parseAttrs(rel[1]);
if (attrs.Id && attrs.Target) rels.set(attrs.Id, attrs.Target);
}
const sheets = [];
for (const sheet of workbookXml.matchAll(/<sheet\b([^>]*)\/?>/g)) {
const attrs = parseAttrs(sheet[1]);
const relId = attrs['r:id'];
const target = relId ? rels.get(relId) : null;
if (!target) continue;
sheets.push({
name: attrs.name || `Sheet ${sheets.length + 1}`,
path: `xl/${target.replace(/^\/?xl\//, '')}`,
});
}
if (sheets.length > 0) return sheets;
return Object.keys(zip.files)
.filter((name) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(name))
.sort(numericPathSort)
.map((name, i) => ({ name: `Sheet ${i + 1}`, path: name }));
}
function extractWorksheetRows(xml, sharedStrings) {
const rows = [];
for (const row of xml.matchAll(/<row\b[\s\S]*?<\/row>/g)) {
const values = [];
for (const cell of row[0].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const attrs = parseAttrs(cell[1]);
const body = cell[2];
let value = '';
if (attrs.t === 's') {
const idx = Number(extractFirst(body, /<v>([\s\S]*?)<\/v>/));
value = Number.isInteger(idx) ? sharedStrings[idx] ?? '' : '';
} else if (attrs.t === 'inlineStr') {
value = extractTextRuns(body).join('');
} else {
value = decodeXml(extractFirst(body, /<v>([\s\S]*?)<\/v>/));
}
if (value.trim()) values.push(value.trim());
}
if (values.length > 0) rows.push(values.join(' | '));
}
return rows;
}
function extractParagraphs(xml, paragraphPattern) {
return Array.from(xml.matchAll(paragraphPattern))
.map((m) => extractTextRuns(m[0]).join(' ').replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
function extractTextRuns(xml) {
return Array.from(xml.matchAll(/<a:t[^>]*>([\s\S]*?)<\/a:t>|<w:t[^>]*>([\s\S]*?)<\/w:t>|<t[^>]*>([\s\S]*?)<\/t>/g))
.map((m) => decodeXml(m[1] ?? m[2] ?? m[3] ?? '').trim())
.filter(Boolean);
}
async function readZipText(zip, name) {
const entry = zip.file(name);
if (!entry) throw new Error(`missing ${name}`);
const size = entry._data?.uncompressedSize ?? 0;
if (size > MAX_XML_ENTRY_BYTES) {
const err = new Error('document section too large to preview');
err.statusCode = 413;
throw err;
}
const xml = await entry.async('text');
assertSafeXml(xml);
return xml;
}
function parseAttrs(raw) {
const attrs = {};
for (const m of raw.matchAll(/([\w:-]+)="([^"]*)"/g)) {
attrs[m[1]] = decodeXml(m[2]);
}
return attrs;
}
function extractFirst(raw, pattern) {
const m = raw.match(pattern);
return m ? m[1] ?? '' : '';
}
function decodeXml(raw) {
return String(raw)
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
}
function assertPreviewInputSize(size) {
if (size > MAX_COMPRESSED_PREVIEW_BYTES) {
const err = new Error('document too large to preview');
err.statusCode = 413;
throw err;
}
}
function assertZipPreviewSize(zip) {
let total = 0;
for (const entry of Object.values(zip.files)) {
total += entry._data?.uncompressedSize ?? 0;
if (total > MAX_UNCOMPRESSED_PREVIEW_BYTES) {
const err = new Error('document too large to preview');
err.statusCode = 413;
throw err;
}
}
}
function assertSafeXml(xml) {
if (/<!DOCTYPE\b|<!ENTITY\b/i.test(xml)) {
const err = new Error('unsupported XML entities');
err.statusCode = 415;
throw err;
}
}
function createLimiter(limit) {
let active = 0;
const pending = [];
const runNext = () => {
if (active >= limit || pending.length === 0) return;
active += 1;
const { task, resolve, reject } = pending.shift();
Promise.resolve()
.then(task)
.then(resolve, reject)
.finally(() => {
active -= 1;
runNext();
});
};
return (task) =>
new Promise((resolve, reject) => {
pending.push({ task, resolve, reject });
runNext();
});
}
function numericPathSort(a, b) {
const an = Number(a.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0);
const bn = Number(b.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0);
return an - bn || a.localeCompare(b);
}

View File

@@ -0,0 +1,137 @@
// @ts-nocheck
// Minimal YAML front-matter parser. Handles the subset used by SKILL.md in
// our examples: scalar strings/numbers/booleans, block-literal (|) strings,
// and flat arrays ("- foo"). Keeps the daemon dep-free. If you need real
// YAML (nested objects, flow-style, anchors), swap for `yaml` or `js-yaml`.
export function parseFrontmatter(src) {
const text = src.replace(/^/, '');
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
if (!match) return { data: {}, body: text };
const [, yaml, body] = match;
return { data: parseYamlSubset(yaml), body };
}
function parseYamlSubset(src) {
const lines = src.split(/\r?\n/);
const root = {};
const stack = [{ indent: -1, container: root, key: null }];
let i = 0;
while (i < lines.length) {
const raw = lines[i];
if (/^\s*(#.*)?$/.test(raw)) {
i++;
continue;
}
const indent = raw.match(/^\s*/)[0].length;
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
stack.pop();
}
const top = stack[stack.length - 1];
const line = raw.slice(indent);
// Array item
if (line.startsWith('- ')) {
const value = line.slice(2).trim();
let container = top.container;
if (!Array.isArray(container)) {
// Convert the pending key's value to an array on first `-`.
const parent = stack[stack.length - 2];
if (parent && top.key) {
parent.container[top.key] = [];
container = parent.container[top.key];
top.container = container;
} else {
i++;
continue;
}
}
if (value.includes(':')) {
const obj = {};
const colonIdx = value.indexOf(':');
const key = value.slice(0, colonIdx).trim();
const valRaw = value.slice(colonIdx + 1).trim();
if (valRaw) obj[key] = coerce(valRaw);
container.push(obj);
stack.push({ indent, container: obj, key: null });
} else {
container.push(coerce(value));
}
i++;
continue;
}
// key: value or key: |
const kv = /^([^:]+):\s*(.*)$/.exec(line);
if (!kv) {
i++;
continue;
}
const key = kv[1].trim();
const val = kv[2];
if (val === '' || val === undefined) {
top.container[key] = {};
stack.push({ indent, container: top.container[key], key });
i++;
continue;
}
if (val === '|' || val === '|-' || val === '>' || val === '>-') {
const collected = [];
const childIndent = indent + 2;
i++;
while (i < lines.length) {
const next = lines[i];
if (/^\s*$/.test(next)) {
collected.push('');
i++;
continue;
}
const nIndent = next.match(/^\s*/)[0].length;
if (nIndent < childIndent) break;
collected.push(next.slice(childIndent));
i++;
}
top.container[key] = collected.join('\n').trimEnd();
continue;
}
if (val === '[]') {
top.container[key] = [];
i++;
continue;
}
if (val.startsWith('[') && val.endsWith(']')) {
top.container[key] = val
.slice(1, -1)
.split(',')
.map((s) => coerce(s.trim()))
.filter((v) => v !== '');
i++;
continue;
}
top.container[key] = coerce(val);
i++;
}
return root;
}
function coerce(raw) {
if (raw === undefined) return '';
let v = raw.trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
return v.slice(1, -1);
}
if (v === 'true') return true;
if (v === 'false') return false;
if (v === 'null' || v === '~') return null;
if (/^-?\d+$/.test(v)) return Number(v);
if (/^-?\d*\.\d+$/.test(v)) return Number(v);
return v;
}

View File

@@ -0,0 +1,331 @@
// @ts-nocheck
function safeParseJson(value) {
if (value == null) return null;
if (typeof value === 'object') return value;
if (typeof value !== 'string') return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
function stringifyContent(value) {
if (typeof value === 'string') return value;
if (value == null) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function formatOpenCodeUsage(tokens) {
if (!tokens || typeof tokens !== 'object') return null;
const usage = {};
if (typeof tokens.input === 'number') usage.input_tokens = tokens.input;
if (typeof tokens.output === 'number') usage.output_tokens = tokens.output;
if (typeof tokens.reasoning === 'number') usage.thought_tokens = tokens.reasoning;
if (tokens.cache && typeof tokens.cache === 'object') {
if (typeof tokens.cache.read === 'number') usage.cached_read_tokens = tokens.cache.read;
if (typeof tokens.cache.write === 'number') usage.cached_write_tokens = tokens.cache.write;
}
return Object.keys(usage).length > 0 ? usage : null;
}
function handleOpenCodeEvent(obj, onEvent, state) {
if (!obj || typeof obj !== 'object') return false;
const part = obj.part && typeof obj.part === 'object' ? obj.part : {};
if (obj.type === 'step_start') {
onEvent({ type: 'status', label: 'running' });
return true;
}
if (obj.type === 'text' && typeof part.text === 'string' && part.text.length > 0) {
onEvent({ type: 'text_delta', delta: part.text });
return true;
}
if (obj.type === 'tool_use' && typeof part.tool === 'string' && typeof part.callID === 'string') {
const statePart = part.state && typeof part.state === 'object' ? part.state : null;
const key = `${obj.sessionID || 'session'}:${part.callID}`;
if (!state.openCodeToolUses.has(key)) {
state.openCodeToolUses.add(key);
onEvent({
type: 'tool_use',
id: part.callID,
name: part.tool,
input: safeParseJson(statePart?.input) ?? statePart?.input ?? null,
});
}
if (statePart?.status === 'completed') {
onEvent({
type: 'tool_result',
toolUseId: part.callID,
content: stringifyContent(statePart.output),
isError: false,
});
}
return true;
}
if (obj.type === 'step_finish') {
const usage = formatOpenCodeUsage(part.tokens);
if (usage) {
onEvent({
type: 'usage',
usage,
costUsd: typeof part.cost === 'number' ? part.cost : undefined,
});
}
return true;
}
if (obj.type === 'error') {
const message =
(obj.error && typeof obj.error === 'object' && obj.error.data?.message) ||
(obj.error && typeof obj.error === 'object' && obj.error.name) ||
'OpenCode error';
onEvent({ type: 'raw', line: stringifyContent({ type: 'error', message }) });
return true;
}
return false;
}
function handleGeminiEvent(obj, onEvent) {
if (!obj || typeof obj !== 'object') return false;
if (obj.type === 'init') {
onEvent({
type: 'status',
label: 'initializing',
model: typeof obj.model === 'string' ? obj.model : undefined,
});
return true;
}
if (
obj.type === 'message' &&
obj.role === 'assistant' &&
typeof obj.content === 'string' &&
obj.content.length > 0
) {
onEvent({ type: 'text_delta', delta: obj.content });
return true;
}
if (obj.type === 'result' && obj.stats && typeof obj.stats === 'object') {
const usage = {};
if (typeof obj.stats.input_tokens === 'number') usage.input_tokens = obj.stats.input_tokens;
if (typeof obj.stats.output_tokens === 'number') usage.output_tokens = obj.stats.output_tokens;
if (typeof obj.stats.cached === 'number') usage.cached_read_tokens = obj.stats.cached;
onEvent({
type: 'usage',
usage,
durationMs: typeof obj.stats.duration_ms === 'number' ? obj.stats.duration_ms : undefined,
});
return true;
}
return false;
}
function extractCursorText(message) {
const blocks = Array.isArray(message?.content) ? message.content : [];
return blocks
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.join('');
}
function emitCursorTextDelta(text, onEvent, state) {
if (!state.cursorTextSoFar) {
state.cursorTextSoFar = text;
onEvent({ type: 'text_delta', delta: text });
return;
}
if (text === state.cursorTextSoFar) {
return;
}
if (text.startsWith(state.cursorTextSoFar)) {
const delta = text.slice(state.cursorTextSoFar.length);
if (delta) onEvent({ type: 'text_delta', delta });
state.cursorTextSoFar = text;
return;
}
state.cursorTextSoFar += text;
onEvent({ type: 'text_delta', delta: text });
}
function handleCursorEvent(obj, onEvent, state) {
if (!obj || typeof obj !== 'object') return false;
if (obj.type === 'system' && obj.subtype === 'init') {
onEvent({
type: 'status',
label: 'initializing',
model: typeof obj.model === 'string' ? obj.model : undefined,
});
return true;
}
if (obj.type === 'assistant' && obj.message) {
const text = extractCursorText(obj.message);
if (!text) return false;
if (typeof obj.timestamp_ms === 'number') {
emitCursorTextDelta(text, onEvent, state);
return true;
}
emitCursorTextDelta(text, onEvent, state);
return true;
}
if (obj.type === 'result' && obj.usage && typeof obj.usage === 'object') {
const usage = {};
if (typeof obj.usage.inputTokens === 'number') usage.input_tokens = obj.usage.inputTokens;
if (typeof obj.usage.outputTokens === 'number') usage.output_tokens = obj.usage.outputTokens;
if (typeof obj.usage.cacheReadTokens === 'number') {
usage.cached_read_tokens = obj.usage.cacheReadTokens;
}
if (typeof obj.usage.cacheWriteTokens === 'number') {
usage.cached_write_tokens = obj.usage.cacheWriteTokens;
}
onEvent({
type: 'usage',
usage,
durationMs: typeof obj.duration_ms === 'number' ? obj.duration_ms : undefined,
});
return true;
}
return false;
}
function handleCodexEvent(obj, onEvent, state) {
if (!obj || typeof obj !== 'object') return false;
if (obj.type === 'thread.started') {
onEvent({ type: 'status', label: 'initializing' });
return true;
}
if (obj.type === 'turn.started') {
onEvent({ type: 'status', label: 'running' });
return true;
}
if (obj.type === 'item.started' && obj.item && typeof obj.item === 'object') {
const item = obj.item;
if (item.type === 'command_execution' && typeof item.id === 'string') {
if (!state.codexToolUses.has(item.id)) {
state.codexToolUses.add(item.id);
onEvent({
type: 'tool_use',
id: item.id,
name: 'Bash',
input: {
command: typeof item.command === 'string' ? item.command : '',
},
});
}
return true;
}
}
if (obj.type === 'item.completed' && obj.item && typeof obj.item === 'object') {
const item = obj.item;
if (item.type === 'command_execution' && typeof item.id === 'string') {
if (!state.codexToolUses.has(item.id)) {
state.codexToolUses.add(item.id);
onEvent({
type: 'tool_use',
id: item.id,
name: 'Bash',
input: {
command: typeof item.command === 'string' ? item.command : '',
},
});
}
onEvent({
type: 'tool_result',
toolUseId: item.id,
content: stringifyContent(item.aggregated_output ?? ''),
isError: typeof item.exit_code === 'number' ? item.exit_code !== 0 : item.status === 'failed',
});
return true;
}
}
if (
obj.type === 'item.completed' &&
obj.item &&
typeof obj.item === 'object' &&
obj.item.type === 'agent_message' &&
typeof obj.item.text === 'string' &&
obj.item.text.length > 0
) {
onEvent({ type: 'text_delta', delta: obj.item.text });
return true;
}
if (obj.type === 'turn.completed' && obj.usage && typeof obj.usage === 'object') {
const usage = {};
if (typeof obj.usage.input_tokens === 'number') usage.input_tokens = obj.usage.input_tokens;
if (typeof obj.usage.output_tokens === 'number') usage.output_tokens = obj.usage.output_tokens;
if (typeof obj.usage.cached_input_tokens === 'number') {
usage.cached_read_tokens = obj.usage.cached_input_tokens;
}
onEvent({ type: 'usage', usage });
return true;
}
return false;
}
export function createJsonEventStreamHandler(kind, onEvent) {
let buffer = '';
const state = {
cursorTextSoFar: '',
openCodeToolUses: new Set(),
codexToolUses: new Set(),
};
function handleLine(line) {
let obj;
try {
obj = JSON.parse(line);
} catch {
onEvent({ type: 'raw', line });
return;
}
if (kind === 'opencode' && handleOpenCodeEvent(obj, onEvent, state)) return;
if (kind === 'gemini' && handleGeminiEvent(obj, onEvent)) return;
if (kind === 'cursor-agent' && handleCursorEvent(obj, onEvent, state)) return;
if (kind === 'codex' && handleCodexEvent(obj, onEvent, state)) return;
onEvent({ type: 'raw', line });
}
function feed(chunk) {
buffer += chunk;
let nl;
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
handleLine(line);
}
}
function flush() {
const rem = buffer.trim();
buffer = '';
if (!rem) return;
handleLine(rem);
}
return { feed, flush };
}

View File

@@ -0,0 +1,63 @@
import path from 'node:path';
import fs from 'node:fs';
const BLOCKED_CANONICAL = (() => {
const raw =
process.platform === 'win32'
? ['C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)']
: ['/etc', '/proc', '/sys', '/dev', '/boot'];
const set = new Set<string>(raw);
for (const p of raw) {
try { set.add(fs.realpathSync.native(p)); } catch { /* not resolvable, keep as-is */ }
}
return [...set];
})();
const WIN_ROOT_RE = /^[A-Za-z]:\\?$/;
function isFilesystemRoot(p: string): boolean {
if (process.platform === 'win32') return WIN_ROOT_RE.test(p);
return p === '/';
}
function isBlocked(realPath: string): boolean {
if (isFilesystemRoot(realPath)) return true;
return BLOCKED_CANONICAL.some(
(p: string) =>
realPath === p ||
realPath.startsWith(p + path.sep) ||
p.startsWith(realPath + path.sep),
);
}
export function validateLinkedDirs(
dirs: unknown,
): { dirs: string[]; error?: undefined } | { error: string; dirs?: undefined } {
if (!Array.isArray(dirs)) return { error: 'linkedDirs must be an array' };
const validated: string[] = [];
for (const d of dirs) {
if (typeof d !== 'string' || !d.trim()) {
return { error: 'each linked dir must be a non-empty string' };
}
if (!path.isAbsolute(d)) {
return { error: `linked dir must be an absolute path: ${d}` };
}
let realPath: string;
try {
realPath = fs.realpathSync.native(path.resolve(d));
} catch {
return { error: `directory does not exist or is not accessible: ${d}` };
}
try {
const stat = fs.statSync(realPath);
if (!stat.isDirectory()) return { error: `not a directory: ${d}` };
} catch {
return { error: `directory does not exist or is not accessible: ${d}` };
}
if (isBlocked(realPath)) {
return { error: `system directory not allowed: ${d}` };
}
validated.push(realPath);
}
return { dirs: [...new Set(validated)] };
}

View File

@@ -0,0 +1,980 @@
// @ts-nocheck
/**
* Anti-slop linter for generated HTML artifacts.
*
* Runs grep-style checks against an artifact body and returns a list of
* structured findings. P0 findings indicate the artifact is regressing
* to AI-slop tropes (purple gradients, emoji feature icons, sans-serif
* display, invented metrics, lorem-style filler) and are surfaced back
* to the agent as a system message so it can self-correct on the next
* turn. P1/P2 findings are advisories.
*
* The linter is deliberately greppy: cheap, deterministic, and trivial
* to extend. It does NOT parse HTML — false positives are tolerable
* because each finding includes a snippet so the agent can verify.
*
* Wired into the artifact save flow (POST /api/artifacts/save) and
* exposed standalone at POST /api/artifacts/lint for the chat UI to
* surface badges next to each saved artifact.
*/
/**
* @typedef {Object} LintFinding
* @property {'P0'|'P1'|'P2'} severity
* @property {string} id short stable id (e.g. 'purple-gradient')
* @property {string} message one-line explanation
* @property {string} fix one-line corrective suggestion (for the agent)
* @property {string} [snippet] matched text (≤ 200 chars), if any
*/
const PURPLE_HEXES = [
// Tailwind violet / purple — the original AI-slop palette.
'#a855f7', '#9333ea', '#7c3aed', '#6d28d9', '#581c87',
'#8b5cf6', '#a78bfa', '#c4b5fd', '#ddd6fe', '#ede9fe',
// Tailwind indigo — Refero's #1 reported AI tell. Common solid uses
// (button fill, accent badge), not just gradients, are flagged
// separately by `ai-default-indigo` below.
'#6366f1', '#4f46e5', '#4338ca', '#3730a3', '#312e81',
'#818cf8', '#a5b4fc', '#c7d2fe', '#e0e7ff', '#eef2ff',
];
// Blue / cyan stops used in the documented "blue→cyan two-stop trust
// gradient" cardinal sin. The purple-gradient rule above only catches
// gradients that contain a violet/indigo hex or the literal
// `purple`/`violet` keyword, so an artifact emitting
// `linear-gradient(90deg, #3b82f6, #06b6d4)` (or the keyword form
// `linear-gradient(90deg, blue, cyan)`) slipped past P0 even though
// `craft/anti-ai-slop.md` explicitly flags it. The `trust-gradient`
// rule below pairs these against each other to close the gap.
const TRUST_GRADIENT_BLUE_HEXES = [
// Tailwind blue 500900 + 400/300/200.
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
'#60a5fa', '#93c5fd', '#bfdbfe',
// Tailwind sky 400700 — the same blue→cyan ramp under a different name.
'#0ea5e9', '#0284c7', '#0369a1', '#38bdf8', '#7dd3fc',
];
const TRUST_GRADIENT_CYAN_HEXES = [
// Tailwind cyan 500900 + 400/300/200.
'#06b6d4', '#0891b2', '#0e7490', '#155e75', '#164e63',
'#22d3ee', '#67e8f9', '#a5f3fc',
];
// Subset of PURPLE_HEXES that constitute the canonical "default LLM
// accent" — even a single solid use is a tell. The DESIGN.md provides
// `var(--accent)`; if a brief truly needs indigo, the design system
// should encode it explicitly so we know it's intentional.
//
// Keep this in sync with the explicit list in `craft/anti-ai-slop.md`'s
// "Default Tailwind indigo as accent" cardinal-sin entry — the prompt
// contract documents the exact set the lint enforces.
const AI_DEFAULT_INDIGO = [
'#6366f1', '#4f46e5', '#4338ca', '#3730a3',
'#8b5cf6', '#7c3aed', '#a855f7',
];
const SLOP_EMOJI = [
'✨', '🚀', '🎯', '⚡', '🔥', '💡', '📈', '🎨', '🛡️', '🌟',
'💪', '🎉', '👋', '🙌', '✅', '⭐', '🏆',
];
// Simple sentinel words for invented-metric copy. Catching every claim is
// hopeless; we look for the canonical AI-startup phrasings.
const INVENTED_METRIC_PATTERNS = [
/\b10×\s+(faster|better|easier)\b/i,
/\b100×\s+(faster|better)\b/i,
/\b99\.\d+%\s+uptime\b/i,
/\bzero[- ]downtime\b/i,
/\b3×\s+more\s+(productive|efficient)\b/i,
];
const FILLER_PATTERNS = [
/\bfeature\s+(one|two|three|1|2|3)\b/i,
/\blorem\s+ipsum\b/i,
/\bdolor\s+sit\s+amet\b/i,
/\bplaceholder\s+text\b/i,
/\bsample\s+content\b/i,
];
// Display-face check: an h1 / h2 / h3 element whose `font-family` lands on
// Inter / Roboto / Arial / -apple-system without an actual serif before it.
// We check the `<style>` block specifically; inline styles are checked too.
const DISPLAY_SANS_RE =
/(?:h1|h2|h3|\.h-?(?:hero|xl|lg|md))[^{}]*\{[^}]*font-family\s*:\s*["']?(?:Inter|Roboto|Arial|-apple-system|system-ui|SF\s+Pro)/i;
/**
* Run all checks against an HTML artifact body. Returns an array of
* findings. The checks are intentionally independent so adding a new
* one only means appending to this function.
*
* @param {string} html
* @returns {LintFinding[]}
*/
export function lintArtifact(rawHtml) {
/** @type {LintFinding[]} */
const out = [];
if (typeof rawHtml !== 'string' || rawHtml.length === 0) return out;
// Strip HTML comments before any pattern matching — comments often contain
// pedagogical examples ("paste a `<section class="slide">` here") that
// would otherwise fire false positives for the section / slide checks.
const html = rawHtml.replace(/<!--[\s\S]*?-->/g, '');
const lower = html.toLowerCase();
// ── P0-1: purple gradient backgrounds ─────────────────────────────
for (const hex of PURPLE_HEXES) {
const re = new RegExp(
`linear-gradient\\([^)]*${escapeRe(hex)}[^)]*\\)`,
'i',
);
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'purple-gradient',
message: `Found a violet/purple gradient using ${hex} — anti-slop list says no.`,
fix: 'Replace the gradient with a flat surface (var(--bg) or var(--surface)) or use the active accent at a single intensity, not in a gradient.',
snippet: clip(m[0]),
});
break;
}
}
// Also catch the literal "purple"/"violet" keyword in a linear-gradient.
if (out.find((f) => f.id === 'purple-gradient') === undefined) {
const m = /linear-gradient\([^)]*\b(purple|violet)\b[^)]*\)/i.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'purple-gradient',
message: `Found a "${m[1]}" keyword inside a gradient — anti-slop.`,
fix: 'Remove the gradient or swap to a single solid color from the active design tokens.',
snippet: clip(m[0]),
});
}
}
// ── P0-1c: blue→cyan "trust" two-stop gradient ─────────────────────
// craft/anti-ai-slop.md documents three flavours of the two-stop
// "trust" gradient — purple→blue, blue→cyan, indigo→pink. The first
// and third are caught by `purple-gradient` above because the
// relevant indigo/violet hex appears in PURPLE_HEXES, but a pure
// blue→cyan gradient has no overlap with that list and slipped
// past unflagged. Detect a `linear-gradient(...)` whose stop list
// contains both a blue token (hex or keyword) and a cyan token
// (hex or keyword). Skip if the purple-gradient rule already fired
// so we emit a single corrective signal per artifact.
if (out.find((f) => f.id === 'purple-gradient') === undefined) {
const tg = detectBlueCyanTrustGradient(html);
if (tg) {
out.push({
severity: 'P0',
id: 'trust-gradient',
message: `Found a blue→cyan two-stop "trust" gradient — anti-slop list says no.`,
fix: 'Replace the gradient with a flat surface (var(--bg) or var(--surface)) or use a single design-token color. Two-stop blue→cyan trust gradients are a SaaS hero cliché.',
snippet: clip(tg),
});
}
}
// ── P0-1b: solid AI-default indigo as accent ──────────────────────
// Even outside a gradient, a single use of #6366f1 et al. is the
// textbook LLM tell. We only fire if the existing purple-gradient
// check didn't already, since they overlap in spirit. Strip
// token-definition blocks first: a brief whose accent is
// intentionally indigo declares it as `--accent: #6366f1` inside
// a selector list containing `:root` (or another known global
// theme scope like `html` / bare `[data-theme="..."]`) and uses
// var(--accent) downstream. That is the design system speaking,
// not the model defaulting, and must not fire. Component-local
// variables (e.g. `.cta { --cta-bg: #6366f1; }`) stay in scope so
// the lint still catches indigo laundered through a local var.
if (out.find((f) => f.id === 'purple-gradient') === undefined) {
const htmlForIndigo = stripTokenBlocks(html);
for (const hex of AI_DEFAULT_INDIGO) {
const re = new RegExp(escapeRe(hex), 'i');
const m = re.exec(htmlForIndigo);
if (m) {
out.push({
severity: 'P0',
id: 'ai-default-indigo',
message: `Found a default LLM accent color (${hex}) — this is the most-reported AI design tell.`,
fix: 'Replace with var(--accent) from the active DESIGN.md. If the brief truly requires indigo, encode it as the design system\'s accent so it reads as intentional, not default.',
snippet: clip(m[0]),
});
break;
}
}
}
// ── P0-2: emoji used as feature/UI icons ──────────────────────────
for (const e of SLOP_EMOJI) {
if (html.includes(e)) {
// Only flag if it appears in a structural context — heading,
// button, list item — not in body prose.
const re = new RegExp(
`<(?:h[1-6]|button|li|span class="[^"]*icon[^"]*")[^>]*>[^<]*${escapeRe(e)}`,
'i',
);
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'emoji-icon',
message: `Emoji "${e}" used as a UI icon — anti-slop list says SVG monoline only.`,
fix: 'Replace with a small inline SVG icon (1.61.8px stroke, currentColor) or remove the icon entirely.',
snippet: clip(m[0]),
});
break;
}
}
}
// ── P0-3: rounded card with left-border accent ────────────────────
const leftAccentRe =
/\.[a-z-]+\s*\{[^}]*border-left\s*:\s*\d+px\s+solid\s+[^;]+;[^}]*border-radius\s*:\s*[1-9]/i;
const lam = leftAccentRe.exec(html);
if (lam) {
out.push({
severity: 'P0',
id: 'left-accent-card',
message: 'Rounded card with a coloured left border — the canonical AI-slop card pattern.',
fix: 'Drop either the border-radius (set 0px) or the border-left. Cards in the OD seed use hairline borders all-round, no left accent.',
snippet: clip(lam[0]),
});
}
// ── P0-4: sans-serif display face ─────────────────────────────────
// Skill seeds bind --font-display to a serif. Catch the case where a
// generated artifact reverts this on h1/h2/h3 to system-sans.
const dm = DISPLAY_SANS_RE.exec(html);
if (dm) {
out.push({
severity: 'P0',
id: 'sans-display',
message: 'A heading rule uses Inter / Roboto / system-sans as the display face — not the serif the seed binds.',
fix: 'Use `font-family: var(--font-display)` on h1/h2/h3 and let the active design system pick the serif. Override only if the active direction is "tech / utility" or "modern minimal".',
snippet: clip(dm[0]),
});
}
// ── P0-5: invented metric phrasing ────────────────────────────────
for (const re of INVENTED_METRIC_PATTERNS) {
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'invented-metric',
message: `Suspected invented metric: "${m[0]}". Anti-slop list says: no numbers without a real source.`,
fix: 'Either remove the claim or replace with a placeholder (— or a labelled stub) until the user supplies a real number.',
snippet: clip(m[0]),
});
break;
}
}
// ── P0-6: filler / lorem text ─────────────────────────────────────
for (const re of FILLER_PATTERNS) {
const m = re.exec(html);
if (m) {
out.push({
severity: 'P0',
id: 'filler-copy',
message: `Filler copy detected: "${m[0]}". Pages should ship with real, brief-derived copy.`,
fix: 'Replace with copy specific to the brief or delete the section entirely. An empty section is a design problem to solve with composition, not by inventing words.',
snippet: clip(m[0]),
});
break;
}
}
// ── P0-7: scrollIntoView (breaks iframe preview) ──────────────────
if (/\.scrollIntoView\s*\(/.test(html)) {
out.push({
severity: 'P0',
id: 'scroll-into-view',
message: 'Element.scrollIntoView() detected — yanks the host page when an iframe boundary is crossed.',
fix: 'Use `scrollTo({ left, top, behavior: "smooth" })` on the actual scroller (see simple-deck seed for the proven pattern).',
});
}
// ── P1-0: ALL-CAPS without letter-spacing ─────────────────────────
// Refero's typography rules: any `text-transform: uppercase` rule
// must pair with `letter-spacing: >= 0.06em` (or an absolute px
// equivalent). Iterate every <style> block (artifacts often emit
// a reset block followed by a tokens/components block) and scan
// each CSS body for an uppercase declaration whose selector body
// is missing letter-spacing or sets it visibly too low.
// Token-aware tracking: collect per-scope `--name: value` declarations
// from global theme scopes once, then pass them to the tracking helper
// so a rule like `letter-spacing: var(--caps-tracking)` is judged by
// the token's literal value in every applicable theme instead of being
// treated as missing.
const tokenScopes = extractCssTokens(html);
outer: for (const styleBlock of html.matchAll(
/<style[^>]*>([\s\S]*?)<\/style>/gi,
)) {
// Strip CSS comments before structural matching: a `<style>` body
// such as `/* .eyebrow { text-transform: uppercase; } */` is
// commented-out by the browser but the rule-shaped regex below
// would otherwise match it and emit a P1 finding for CSS that has
// no rendered effect.
const css = (styleBlock[1] ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
// Match a CSS rule body containing text-transform: uppercase.
// Capture the selector + body so we can inspect tracking. The body
// alternation is `[^{}]*` (not `[^}]*`) so the regex matches only
// innermost `selector { body }` rules. With `[^}]*`, an outer
// `@media (...) { .display { font-size: 48px; text-transform:
// uppercase; … } }` matches as a single rule whose selector is the
// `@media (...)` wrapper and whose body begins with `.display {
// font-size: …` — so `parseDeclarations()` sees the first property
// as `.display { font-size`, not `font-size`, the same-rule
// font-size is lost, and `hasAdequateUppercaseTracking()` falls
// back to the lenient inherited-size path that accepts 1px
// tracking on a 48px heading. Restricting the body to `[^{}]*`
// makes the regex skip the wrapper and match the inner rule
// directly.
const upperRe = /([^{}]*)\{([^{}]*text-transform\s*:\s*uppercase[^{}]*)\}/gi;
let m;
while ((m = upperRe.exec(css)) !== null) {
const selector = (m[1] ?? '').trim();
const body = m[2] ?? '';
if (!hasAdequateUppercaseTracking(body, tokenScopes)) {
out.push({
severity: 'P1',
id: 'all-caps-no-tracking',
message: `Selector \`${selector.slice(0, 60)}\` sets text-transform: uppercase without sufficient letter-spacing (≥0.06em).`,
fix: 'Add `letter-spacing: 0.08em` (typical) to the same rule. ALL CAPS without tracking looks cramped — Refero\'s typography rules call this out as a top-tier amateur tell.',
snippet: clip(`${selector} { ${body.trim()} }`),
});
break outer;
}
}
}
// ── P1-0b: ALL-CAPS in inline style attributes ────────────────────
// The <style>-block scan above misses inline declarations such as
// `<span style="text-transform: uppercase">NEW</span>`, which the
// browser still renders ALL CAPS. craft/typography.md treats the
// tracking floor as having no exceptions, so the inline form runs
// through the same `hasAdequateUppercaseTracking` check used by the
// <style>-block branch — no separate threshold. Only fire if the
// <style>-block scan above didn't already produce this id, so the
// agent gets a single corrective signal per artifact.
if (out.find((f) => f.id === 'all-caps-no-tracking') === undefined) {
const inlineStyleRe = /(?:^|\s)style\s*=\s*(["'])([\s\S]*?)\1/gi;
let im;
while ((im = inlineStyleRe.exec(html)) !== null) {
const decl = im[2] ?? '';
if (!/text-transform\s*:\s*uppercase/i.test(decl)) continue;
if (!hasAdequateUppercaseTracking(decl, tokenScopes)) {
out.push({
severity: 'P1',
id: 'all-caps-no-tracking',
message:
'Inline style sets text-transform: uppercase without sufficient letter-spacing (≥0.06em).',
fix: 'Add `letter-spacing: 0.08em` (typical) to the same inline style. ALL CAPS without tracking looks cramped — Refero\'s typography rules call this out as a top-tier amateur tell.',
snippet: clip(decl.trim()),
});
break;
}
}
}
// ── P1-1: external image URLs (CDN / unsplash / placehold.co) ─────
// Allow data: urls and same-origin paths.
const extImg =
/<img[^>]+src=["']https?:\/\/(?:images\.unsplash\.com|placehold\.co|placekitten\.com|via\.placeholder\.com|picsum\.photos|loremflickr\.com)/i.exec(
html,
);
if (extImg) {
out.push({
severity: 'P1',
id: 'external-image',
message: 'External placeholder image CDN detected — fragile, looks fake when it 404s.',
fix: 'Use the .ph-img placeholder class shipped in the seed templates instead.',
snippet: clip(extImg[0]),
});
}
// ── P1-2: raw hex outside :root ───────────────────────────────────
// Heuristic: count `#xxxxxx` occurrences inside the first <style> block,
// outside the `:root{...}` declaration. Many is suspicious.
const styleRe = /<style[^>]*>([\s\S]*?)<\/style>/i;
const styleMatch = styleRe.exec(html);
if (styleMatch) {
const css = styleMatch[1] ?? '';
const rootRe = /:root\s*\{[^}]*\}/g;
const cssWithoutRoot = css.replace(rootRe, '');
const hexes = cssWithoutRoot.match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
// Allow up to ~12 raw hex values outside :root. Device chrome
// (mobile-app frame: bezel gradient, side rails, status icons) has
// legitimate hardware-specific values in the 810 range; raise the
// threshold so seed templates pass without ceremony. More than ~12
// signals tokens weren't honoured by the agent's generation.
if (hexes.length > 12) {
out.push({
severity: 'P1',
id: 'raw-hex',
message: `${hexes.length} raw hex values found outside :root — design tokens probably not honoured.`,
fix: 'Move every color into the :root token block (--bg / --surface / --fg / --muted / --border / --accent) and reference via var(). Use color-mix() for derived tones.',
snippet: hexes.slice(0, 6).join(' '),
});
}
}
// ── P1-3: too many accent uses in the rendered body ───────────────
// Approximation: count `var(--accent)` references that appear OUTSIDE
// the <style> block — i.e. inline styles in the rendered DOM, not the
// class system definitions. The seed's <style> block defines the
// accent on many class selectors that won't all render on one page;
// the body is what the user actually sees.
const styleStripped = html.replace(/<style[\s\S]*?<\/style>/gi, '');
const accentUsesInBody = (styleStripped.match(/var\(--accent\)/g) ?? []).length;
if (accentUsesInBody > 6) {
out.push({
severity: 'P1',
id: 'accent-overuse',
message: `var(--accent) used ${accentUsesInBody} times inline in the body — likely overused per screen.`,
fix: 'Cap accent usage at 2 visible uses per screen (one eyebrow + one CTA, OR one accent card + one tab). Demote the rest to var(--fg) or var(--muted).',
});
}
// ── P2-1: missing comment-mode anchor on <section> ────────────────
// Either `data-od-id` (web/mobile prototypes) or `data-screen-label`
// (decks) counts. Whichever the artifact uses, every <section> should
// carry one so the chat layer can target it.
const sections = html.match(/<section\b[^>]*>/gi) ?? [];
const tagged = sections.filter(
(s) => /data-od-id\s*=/.test(s) || /data-screen-label\s*=/.test(s),
).length;
if (sections.length > 0 && tagged < sections.length) {
out.push({
severity: 'P2',
id: 'missing-section-anchor',
message: `${sections.length - tagged} of ${sections.length} <section>s lack data-od-id (or data-screen-label).`,
fix: 'Add data-od-id="kebab-slug" (or data-screen-label="01 Cover" for slides) to every top-level <section> so comment mode can target it.',
});
}
// ── P2-2: missing slide theme classes (deck specifically) ──────────
// Triggered only if the artifact looks deck-shaped (has .slide).
if (/class\s*=\s*["'][^"']*\bslide\b/.test(html)) {
const slideMatches = html.match(/<section\s+class\s*=\s*["'][^"']*\bslide\b[^"']*["']/gi) ?? [];
const themed = slideMatches.filter((s) =>
/\b(light|dark|hero\s+light|hero\s+dark)\b/.test(s),
).length;
if (slideMatches.length > 0 && themed < slideMatches.length) {
out.push({
severity: 'P0',
id: 'slide-theme-missing',
message: `${slideMatches.length - themed} of ${slideMatches.length} slides lack a theme class (light / dark / hero light / hero dark).`,
fix: 'Every <section class="slide"> must include exactly one theme class. Audit your slide list and add light/dark/hero modifiers.',
});
}
// Theme rhythm: no 3+ same-theme in a row.
const themeSeq = slideMatches
.map((s) => {
if (/hero\s+dark/.test(s)) return 'HD';
if (/hero\s+light/.test(s)) return 'HL';
if (/\bdark\b/.test(s)) return 'D';
if (/\blight\b/.test(s)) return 'L';
return '?';
})
.filter((t) => t !== '?');
for (let i = 0; i < themeSeq.length - 2; i++) {
const a = themeSeq[i];
const isLight = (t) => t === 'L' || t === 'HL';
const isDark = (t) => t === 'D' || t === 'HD';
if (
(isLight(a) && isLight(themeSeq[i + 1]) && isLight(themeSeq[i + 2])) ||
(isDark(a) && isDark(themeSeq[i + 1]) && isDark(themeSeq[i + 2]))
) {
out.push({
severity: 'P1',
id: 'slide-rhythm',
message: `Three same-theme slides in a row at position ${i + 1}${i + 3} — visual fatigue.`,
fix: 'Swap the middle slide to the opposite theme (light → dark, or dark → light). For 8+ slides, mix in at least one hero light AND one hero dark.',
});
break;
}
}
}
return out;
}
/**
* Format findings as a Markdown block ready to splice into a system
* reminder back to the agent. P0 findings appear first.
*
* @param {LintFinding[]} findings
* @returns {string}
*/
export function renderFindingsForAgent(findings) {
if (findings.length === 0) return '';
const sorted = [...findings].sort((a, b) => severity(a) - severity(b));
const lines = [
'<artifact-lint>',
'The artifact you just produced has the following anti-slop / design-token issues.',
`${findings.filter((f) => f.severity === 'P0').length} P0 (must fix), ${findings.filter((f) => f.severity === 'P1').length} P1 (should fix), ${findings.filter((f) => f.severity === 'P2').length} P2 (nice to have).`,
'Re-emit a corrected `<artifact>` in your next turn — do not write a separate explanation; the user has the previous version already.',
'',
];
for (const f of sorted) {
lines.push(`**[${f.severity}] ${f.id}** — ${f.message}`);
lines.push(` Fix: ${f.fix}`);
if (f.snippet) lines.push(` Snippet: \`${f.snippet}\``);
lines.push('');
}
lines.push('</artifact-lint>');
return lines.join('\n');
}
function severity(f) {
return f.severity === 'P0' ? 0 : f.severity === 'P1' ? 1 : 2;
}
function clip(s) {
if (!s) return '';
const trimmed = s.replace(/\s+/g, ' ').trim();
return trimmed.length > 200 ? trimmed.slice(0, 197) + '…' : trimmed;
}
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Scan every `linear-gradient(...)` body for a blue→cyan two-stop
// trust gradient. Returns the first matching gradient text or `null`.
// The check accepts either Tailwind blue/sky/cyan hex stops or the
// literal `blue`/`cyan` keywords, so both
// `linear-gradient(90deg, #3b82f6, #06b6d4)` and
// `linear-gradient(90deg, blue, cyan)` fire P0.
function detectBlueCyanTrustGradient(html) {
const re = /linear-gradient\([^)]*\)/gi;
let m;
while ((m = re.exec(html)) !== null) {
const grad = m[0].toLowerCase();
const hasBlue =
TRUST_GRADIENT_BLUE_HEXES.some((h) => grad.includes(h.toLowerCase())) ||
/\bblue\b/.test(grad);
const hasCyan =
TRUST_GRADIENT_CYAN_HEXES.some((h) => grad.includes(h.toLowerCase())) ||
/\bcyan\b/.test(grad);
if (hasBlue && hasCyan) return m[0];
}
return null;
}
// True when the declaration body has letter-spacing satisfying the
// craft rule: `letter-spacing >= 0.06em` of the element's own font.
//
// `em` maps directly to the 0.06 floor — it is relative to the
// element's own font-size, which is what the rule measures against.
//
// `rem` and `px` are absolute relative to the element: `rem` resolves
// against the root font-size (assumed 16px — the browser default and
// the value all OD seed templates use), so `0.06rem` on a 48px heading
// is `0.96px`, only `0.02em` of the element. Treating `rem` like `em`
// (the previous behaviour) accepts that as compliant when the rule
// it enforces is the per-element em floor; convert `rem` to absolute
// px and reuse the same px-vs-element-font-size resolution.
//
// px (and the converted-rem path) resolve in three steps:
// 1. If the same rule body declares `font-size` in `px` or `rem`
// (after `var()` resolution), convert it to absolute px and
// compare px tracking against `fs * 0.06` — exact translation
// of the em rule. `rem` font sizes resolve via the same root
// assumption used for tracking, so a `font-size: 3rem` heading
// enforces a 2.88px floor instead of the lenient body fallback.
// 2. If the rule explicitly declares a `font-size` in a unit we
// can't resolve (`em`, `%`, `calc(...)`, an unresolved var,
// etc.), refuse the lenient fallback: the heading might be
// arbitrarily large, in which case 1px tracking is well below
// 0.06em. Treat as missing tracking — the agent can either
// switch to `em` letter-spacing or declare an explicit px/rem
// font-size we can verify.
// 3. Otherwise (no font-size declared at all, font-size inherited),
// use a conservative `>= 1px` absolute fallback. That stays
// correct for the typical body-text default of 16px (1px / 16px
// ≈ 0.0625em, just over the floor) and for any smaller label
// (1px / 14px ≈ 0.071em, 1px / 12px ≈ 0.083em).
//
// `scopes` (optional) is the array of per-scope token records
// harvested from global theme scopes elsewhere in the artifact (see
// `extractCssTokens`). Each record carries the scope's per-scope
// last-write-wins token map plus enough metadata to identify which
// themes the scope applies to. Per-theme effective maps are built
// here via `buildResolvedThemes` so simple `var(--name)` (and
// `var(--name, fallback)`) references in the body resolve to the
// value the browser would render in that theme — keeping values
// declared in the same scope paired together. References without a
// matching token but with an inline fallback (`var(--x, 0.08em)`)
// resolve to the fallback; unresolved references with no fallback
// stay in place so the existing "no numeric value" path returns
// false.
//
// When a token resolves to different values in different themes
// (e.g. `:root { --caps-tracking: 0.02em }` overridden by
// `[data-theme="dark"] { --caps-tracking: 0.08em }`), the helper is
// conservative: it walks every per-theme map produced by
// `buildResolvedThemes` and returns true only if EVERY theme satisfies
// the 0.06em floor. A theme-scoped override that lifts the value
// above the floor must not silently rescue a default value that
// renders below it. Crucially, theme maps preserve the scope-internal
// relationship between tokens, so a paired declaration such as
// `:root { --display-size: 16px; --caps-tracking: 1px }` is judged
// against (16px, 1px) — never against the impossible cross-theme
// pairing (48px, 1px) that an independent per-token cartesian would
// emit.
const ROOT_FONT_PX = 16;
function hasAdequateUppercaseTracking(body, scopes) {
const themes = buildResolvedThemes(scopes ?? []);
for (const themeMap of themes) {
const resolved = resolveCssVars(body, themeMap);
if (!isResolvedTrackingAdequate(resolved)) return false;
}
return true;
}
// Single-resolution tracking check. Parses the declaration list with
// exact property names (so token-name declarations such as
// `--letter-spacing: 0.08em` cannot satisfy the rule) and selects the
// LAST matching `letter-spacing` and `font-size` declarations to model
// CSS source-order cascade — `.eyebrow { letter-spacing: 0.08em;
// letter-spacing: 0.02em }` renders the noncompliant `0.02em` value,
// so the lint must judge against the last declaration, not the first.
function isResolvedTrackingAdequate(body) {
const decls = parseDeclarations(body);
const ls = findLastDecl(decls, 'letter-spacing');
if (!ls) return false;
const lsMatch = /^(-?\d*\.?\d+)\s*(em|px|rem)\b/i.exec(ls.value);
if (!lsMatch) return false;
const v = parseFloat(lsMatch[1]);
const unit = lsMatch[2].toLowerCase();
if (unit === 'em') return v >= 0.06;
const trackingPx = unit === 'rem' ? v * ROOT_FONT_PX : v;
const fsPx = resolveFontSizePx(decls);
if (fsPx != null) {
return fsPx > 0 && trackingPx >= fsPx * 0.06;
}
if (decls.some((d) => d.prop === 'font-size')) return false;
return trackingPx >= 1;
}
// Build per-theme effective token maps from the per-scope records
// produced by `extractCssTokens`. A "theme" is the default rendering
// (no theme attribute set) plus one entry per distinct theme-attribute
// selector seen across scopes. Default-applying scopes (whose selector
// list contains a bare `:root` / `html` / `body`) apply to every theme
// as a baseline; variant scopes apply only to the themes their
// selector targets. Within a single theme, scopes are applied in
// source order so the final value reflects the cascade the browser
// would render.
//
// Returned as an array — one map per theme. The lint passes only when
// every theme map satisfies the rule, so a default-theme value below
// the floor flags even if a variant overrides it above the floor (and
// vice versa). Building per-theme maps preserves the scope-internal
// relationship between tokens, so values declared together in the
// same scope (e.g. `--display-size` and `--caps-tracking` both on
// `:root`) stay paired during evaluation. The previous design merged
// values by token name across scopes and then took an independent
// per-token cartesian product, which generated impossible cross-theme
// pairings such as `(default-size, dark-track)` and emitted false
// positives on legitimate light/dark theme variants.
function buildResolvedThemes(scopes) {
const themeKeys = new Set(['default']);
for (const scope of scopes) {
for (const k of scope.themeKeys) themeKeys.add(k);
}
const themes = new Map();
for (const k of themeKeys) themes.set(k, new Map());
for (const scope of scopes) {
if (scope.isDefault) {
for (const map of themes.values()) {
for (const [k, v] of scope.tokens) map.set(k, v);
}
} else {
for (const themeKey of scope.themeKeys) {
const map = themes.get(themeKey);
if (map) {
for (const [k, v] of scope.tokens) map.set(k, v);
}
}
}
}
return Array.from(themes.values());
}
function isBareGlobalSelector(s) {
return /^(?::root|html|body)$/.test(s);
}
function findLastDecl(decls, prop) {
for (let i = decls.length - 1; i >= 0; i--) {
if (decls[i].prop === prop) return decls[i];
}
return undefined;
}
// Split a CSS declaration body into `{ prop, value }` entries, lowercasing
// the property name and skipping custom properties (`--name`). Used by
// the uppercase-tracking lint so substring matches on `letter-spacing`
// or `font-size` cannot collide with token-name declarations.
function parseDeclarations(body) {
const out = [];
for (const raw of body.split(';')) {
const idx = raw.indexOf(':');
if (idx < 0) continue;
const prop = raw.slice(0, idx).trim().toLowerCase();
if (!prop || prop.startsWith('--')) continue;
const value = raw.slice(idx + 1).trim();
if (!value) continue;
out.push({ prop, value });
}
return out;
}
// Resolve a same-rule `font-size` declaration to absolute px. Returns
// the px value when font-size is declared in `px` or `rem` (rem maps
// via the root font-size assumption shared with tracking); returns
// `null` when font-size is absent OR present in an unresolvable unit
// (`em`, `%`, `calc(...)`, an unresolved `var(--...)`). The caller
// distinguishes those two `null` cases by re-checking the parsed
// declarations for an exact `font-size` property.
//
// Selects the LAST `font-size` declaration in source order so that a
// rule like `.display { font-size: 48px; font-size: 1em }` is judged
// against the noncompliant `1em` the browser actually renders, not the
// stale earlier `48px`. CSS cascade is last-write-wins on conflicting
// declarations within a single rule body.
function resolveFontSizePx(decls) {
const fs = findLastDecl(decls, 'font-size');
if (!fs) return null;
const m = /^(-?\d*\.?\d+)\s*(px|rem)\b/i.exec(fs.value);
if (!m) return null;
const v = parseFloat(m[1]);
const unit = m[2].toLowerCase();
return unit === 'rem' ? v * ROOT_FONT_PX : v;
}
// Collect CSS custom properties (`--name: value`) declared in global
// theme scopes (`:root`, `html`, theme-attribute selectors) from every
// `<style>` block in the artifact. Tokens declared on component
// selectors are intentionally ignored: the lint must still catch
// indigo / under-tracking laundered through a local var, and the
// tracking helper resolves only the global-scope tokens artifacts use
// to express design intent.
//
// Returns an array of per-scope records:
// `{ selectors, tokens, isDefault, themeKeys }`
// where `tokens` is the per-scope last-write-wins map of CSS custom
// properties, `selectors` lists the parsed selectors from the rule,
// `isDefault` is true if any selector is a bare global
// (`:root` / `html` / `body` without an attribute suffix), and
// `themeKeys` is the set of theme-attribute selector strings the rule
// targets. Per-theme effective maps are derived downstream from these
// records by `buildResolvedThemes`, which preserves the scope-internal
// relationship between values so a paired declaration like
// `:root { --display-size: 16px; --caps-tracking: 1px }` is judged
// as `(16px, 1px)` together, not against the impossible cross-theme
// pairing `(48px, 1px)` that an independent per-token cartesian over
// distinct values would emit.
//
// Within a single rule body, CSS cascade is last-write-wins: a block
// like `:root { --caps-tracking: 0.02em; --caps-tracking: 0.08em; }`
// renders the second value, and the first never reaches any element.
// Per-scope, we keep only the LAST value declared for each token
// name; cross-scope merging happens later in `buildResolvedThemes`,
// where the same source-order cascade is applied between scopes that
// target the same theme.
function extractCssTokens(html) {
const scopes = [];
for (const styleBlock of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
const css = (styleBlock[1] ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
const ruleRe = /([^{}]*)\{([^{}]*)\}/g;
let m;
while ((m = ruleRe.exec(css)) !== null) {
const sel = (m[1] ?? '').trim();
if (!selectorListIsGlobalThemeScope(sel)) continue;
const selectors = sel.split(',').map((s) => s.trim()).filter(Boolean);
const isDefault = selectors.some(isBareGlobalSelector);
const themeKeys = new Set(
selectors.filter((s) => !isBareGlobalSelector(s)),
);
const body = m[2] ?? '';
const tokens = new Map();
for (const decl of body.split(';').map((d) => d.trim()).filter(Boolean)) {
const dm = /^(--[\w-]+)\s*:\s*(.+)$/.exec(decl);
if (dm) {
tokens.set(dm[1], dm[2].trim());
}
}
if (tokens.size === 0) continue;
scopes.push({ selectors, tokens, isDefault, themeKeys });
}
}
return scopes;
}
// Replace simple `var(--name)` (and `var(--name, fallback)`) references
// in a CSS declaration body with the literal token value. Iterates a
// few times so a token whose value is itself another `var(--...)`
// resolves through one or two hops; bounded depth so a cyclic
// definition (`--a: var(--b); --b: var(--a)`) terminates instead of
// looping forever. Only one-level fallbacks are recognised — enough
// for the typography pattern this lint cares about, and keeps the
// regex linear-time on artifact-sized inputs.
const VAR_RESOLVE_MAX_DEPTH = 4;
function resolveCssVars(body, tokens) {
let out = body;
for (let i = 0; i < VAR_RESOLVE_MAX_DEPTH; i++) {
const next = out.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/g,
(full, name, fallback) => {
const v = tokens.get(name);
if (v != null) return v;
if (fallback != null) return fallback.trim();
return full;
},
);
if (next === out) break;
out = next;
}
return out;
}
// Remove CSS rule blocks that look like design-token definitions.
// Operates only on CSS extracted from <style> blocks — running the
// rule-shaped regex against the full HTML string makes the first
// selector capture include leading text like `<style>`, which then
// fails the `:root` selector test.
//
// A rule is treated as a token block only when ALL THREE conditions hold:
// 1. every selector in the list is a global theme-scope selector
// (`:root`, `:root[data-theme="..."]`, `html`, `body`, or a bare
// attribute selector for a known global-theme switch —
// `data-theme`, `data-color-scheme`, `data-mode`). Selector lists
// that mix in a component selector — e.g.
// `:root, .cta { --cta-bg: #6366f1 }` — or that target an
// arbitrary component/state attribute like `[data-variant="primary"]`
// or `[aria-current="page"]` fail this test, so indigo laundered
// through a local var or rule still trips the lint.
// 2. its body is token-shaped: only CSS custom properties
// (`--name: value`), with a small allowlist for global-theme
// metadata such as `color-scheme` that legitimately accompanies
// tokens in `:root` and cannot smuggle a visible color.
// A non-token declaration on `:root` (e.g.
// `:root { background: #6366f1 }`) keeps the rule in scope so
// the indigo check fires.
// 3. no token in the body launders an indigo hex through a
// non-`--accent` name. The craft contract's escape hatch is to
// encode indigo as the active design system's `--accent` token;
// anything else (`:root { --primary: #6366f1 }`,
// `:root { --button-bg: #4f46e5 }`) is still the LLM-default
// color hidden behind an arbitrary token name and must stay in
// scope of the indigo scan.
function stripTokenBlocks(input) {
return input.replace(
/(<style[^>]*>)([\s\S]*?)(<\/style>)/gi,
(_m, open, css, close) => `${open}${stripTokenBlocksFromCss(css)}${close}`,
);
}
function stripTokenBlocksFromCss(css) {
// Strip CSS comments before any structural matching: a block like
// `:root { /* brand accent */ --accent: #6366f1; }` would otherwise
// produce a declaration fragment that begins with the comment,
// fail `isTokenShapedDeclaration`, and leave a legitimate token
// definition in scope of the indigo scan.
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '');
// The body alternation is `[^{}]*` (not `[^}]*`) so the regex matches
// only innermost `selector { body }` rules. That lets us recognize
// global token blocks nested inside at-rule wrappers — e.g.
// `@media (prefers-color-scheme: dark) { :root { --accent: #6366f1 } }`
// — by matching the inner `:root { ... }` directly. The outer
// `@media` wrapper is preserved with the inner token block stripped,
// so the indigo scan no longer fires on legitimate responsive theme
// declarations.
return cleaned.replace(/([^{}]*)\{([^{}]*)\}/g, (full, selector, body) => {
const sel = (selector || '').trim();
if (!selectorListIsGlobalThemeScope(sel)) return full;
const decls = (body || '')
.split(';')
.map((d) => d.trim())
.filter(Boolean);
if (decls.length === 0) return full;
const tokenShaped = decls.every(isTokenShapedDeclaration);
if (!tokenShaped) return full;
// The `--accent` escape hatch is for `--accent` only. Any other
// global token whose value carries an AI-default indigo hex is
// still laundering the LLM-default color through an arbitrary
// name (`--primary: #6366f1`, `--button-bg: #4f46e5`, …). Keep
// the rule in scope so the indigo lint fires on the literal hex.
if (decls.some(declarationLaundersIndigo)) return full;
return '';
});
}
function declarationLaundersIndigo(decl) {
const m = /^(--[\w-]+)\s*:\s*(.+)$/.exec(decl);
if (!m) return false;
if (m[1].toLowerCase() === '--accent') return false;
const value = m[2].toLowerCase();
for (const hex of AI_DEFAULT_INDIGO) {
if (value.includes(hex.toLowerCase())) return true;
}
return false;
}
function isTokenShapedDeclaration(decl) {
// CSS custom property — the canonical token shape.
if (/^--[\w-]+\s*:/.test(decl)) return true;
// Global-theme metadata that legitimately accompanies tokens in
// `:root` / `html` / `[data-theme="..."]` and whose values are
// keywords, so they cannot smuggle a hardcoded color.
if (/^color-scheme\s*:/i.test(decl)) return true;
return false;
}
function selectorListIsGlobalThemeScope(selector) {
const parts = selector.split(',').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return false;
return parts.every(isGlobalThemeScopeSelector);
}
// Attribute selectors — bare or attached to `:root`/`html`/`body` —
// are exempted only when the attribute is one of the known
// global-theme switches. A broader exemption would also strip
// arbitrary component/state attribute rules
// (e.g. `[data-variant="primary"] { --button-bg: #6366f1; }`,
// `:root[data-variant="primary"] { --button-bg: #6366f1; }`, or
// `html[aria-current="page"] { --nav-accent: #6366f1; }`), which
// is the exact component-local indigo laundering this lint is
// meant to catch.
const GLOBAL_THEME_ATTRIBUTES = new Set([
'data-theme',
'data-color-scheme',
'data-mode',
]);
function isGlobalThemeScopeSelector(s) {
// :root / html / body, optionally suffixed with a single attribute
// selector. The bare form (no attribute) is always a global theme
// scope; the prefixed form is only a theme scope when the attribute
// names one of GLOBAL_THEME_ATTRIBUTES. A component/state attribute
// suffix (`:root[data-variant="primary"]`, `html[aria-current="page"]`)
// must keep the rule in scope of the indigo lint.
const tagAttr = /^(?::root|html|body)(?:\[([a-zA-Z-]+)(?:[*^$|~]?=[^\]]*)?\])?$/.exec(s);
if (tagAttr) {
const attrName = tagAttr[1];
if (!attrName) return true;
return GLOBAL_THEME_ATTRIBUTES.has(attrName.toLowerCase());
}
// Bare attribute selector restricted to known global-theme switches.
const bareAttr = /^\[([a-zA-Z-]+)(?:[*^$|~]?=[^\]]*)?\]$/.exec(s);
if (bareAttr && GLOBAL_THEME_ATTRIBUTES.has(bareAttr[1].toLowerCase())) {
return true;
}
return false;
}

View File

@@ -0,0 +1,248 @@
import {
appendLiveArtifactRefreshLogEntry,
commitLiveArtifactRefreshCandidate,
getLiveArtifact,
markLiveArtifactRefreshRunning,
markLiveArtifactRefreshFailed,
type LiveArtifactStoreRecord,
withLiveArtifactRefreshLock,
} from './store.js';
import {
buildLiveArtifactRefreshCandidate,
executeLocalDaemonRefreshSource,
liveArtifactRefreshRunRegistry,
normalizeLiveArtifactRefreshTimeouts,
withLiveArtifactRefreshRun,
withLiveArtifactRefreshSourceTimeout,
} from './refresh.js';
import { connectorService } from '../connectors/service.js';
import type { BoundedJsonObject, LiveArtifactRefreshErrorRecord, LiveArtifactRefreshSourceMetadata, LiveArtifactSource } from './schema.js';
export interface RefreshLiveArtifactOptions {
projectsRoot: string;
projectId: string;
artifactId: string;
now?: Date;
onStarted?: (event: { refreshId: string; artifact: LiveArtifactStoreRecord['artifact'] }) => void | Promise<void>;
}
export interface RefreshLiveArtifactResult {
artifact: LiveArtifactStoreRecord['artifact'];
refresh: {
id: string;
status: 'succeeded';
refreshedSourceCount: number;
};
}
export class LiveArtifactRefreshUnavailableError extends Error {
constructor(message = 'No refresh source is available yet.') {
super(message);
this.name = 'LiveArtifactRefreshUnavailableError';
}
}
function nowDate(): Date {
return new Date();
}
function durationMs(startedAt: Date, finishedAt: Date): number {
return Math.max(0, finishedAt.getTime() - startedAt.getTime());
}
function toRefreshErrorRecord(error: unknown): LiveArtifactRefreshErrorRecord {
if (error instanceof Error) {
return error.name === 'Error'
? { message: error.message }
: { code: error.name, message: error.message };
}
return { message: String(error) };
}
function documentSourceMetadata(source: LiveArtifactSource): LiveArtifactRefreshSourceMetadata {
const metadata: LiveArtifactRefreshSourceMetadata = { sourceType: 'document' };
if (source.toolName !== undefined) metadata.toolName = source.toolName;
if (source.connector !== undefined) metadata.connector = source.connector;
return metadata;
}
function isSupportedSource(source: LiveArtifactSource | undefined): source is LiveArtifactSource {
if (source === undefined) return false;
return source.type === 'local_file' || source.type === 'daemon_tool' || source.type === 'connector_tool';
}
function hasRefreshPermission(source: LiveArtifactSource): boolean {
return source.refreshPermission === 'manual_refresh_granted_for_read_only';
}
async function executeRefreshSource(options: {
projectsRoot: string;
projectId: string;
source: LiveArtifactSource;
signal: AbortSignal;
}): Promise<BoundedJsonObject> {
const { projectsRoot, projectId, source, signal } = options;
if (source.type === 'connector_tool') {
const connector = source.connector;
if (connector === undefined) throw new Error('connector refresh source requires connector metadata');
const result = await connectorService.execute(
{
connectorId: connector.connectorId,
toolName: connector.toolName,
input: source.input,
...(connector.accountLabel === undefined ? {} : { expectedAccountLabel: connector.accountLabel }),
},
{ projectsRoot, projectId, purpose: 'artifact_refresh', signal },
);
if (result.output === null || typeof result.output !== 'object' || Array.isArray(result.output)) {
throw new Error('connector refresh output must be a JSON object');
}
return result.output;
}
if (source.type !== 'daemon_tool' && source.type !== 'local_file') {
throw new Error(`refresh source ${source.type} is not supported yet`);
}
return executeLocalDaemonRefreshSource({ projectsRoot, projectId, source, signal });
}
export async function refreshLiveArtifact(options: RefreshLiveArtifactOptions): Promise<RefreshLiveArtifactResult> {
return withLiveArtifactRefreshLock(options, async (lock) => {
const refreshId = lock.metadata.refreshId;
let sequence = 0;
const appendLog = async (entry: {
step: string;
status: 'running' | 'succeeded' | 'failed' | 'cancelled' | 'skipped';
startedAt: Date;
finishedAt?: Date;
source?: LiveArtifactRefreshSourceMetadata;
error?: unknown;
metadata?: BoundedJsonObject;
}): Promise<void> => {
await appendLiveArtifactRefreshLogEntry({
projectsRoot: options.projectsRoot,
projectId: options.projectId,
artifactId: options.artifactId,
refreshId,
sequence: sequence++,
step: entry.step,
status: entry.status,
startedAt: entry.startedAt,
...(entry.finishedAt === undefined ? {} : { finishedAt: entry.finishedAt, durationMs: durationMs(entry.startedAt, entry.finishedAt) }),
...(entry.source === undefined ? {} : { source: entry.source }),
...(entry.error === undefined ? {} : { error: toRefreshErrorRecord(entry.error) }),
...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
});
};
const refreshStartedAt = options.now ?? nowDate();
await appendLog({ step: 'refresh:start', status: 'running', startedAt: refreshStartedAt });
const running = await markLiveArtifactRefreshRunning({
projectsRoot: options.projectsRoot,
projectId: options.projectId,
artifactId: options.artifactId,
refreshId,
now: refreshStartedAt,
});
await options.onStarted?.({ refreshId, artifact: running.artifact });
try {
const record = await getLiveArtifact(options);
const artifact = record.artifact;
const currentDataJson = artifact.document?.dataJson ?? {};
const documentSource = artifact.document?.sourceJson;
const hasDocumentSource = isSupportedSource(documentSource);
const timeouts = normalizeLiveArtifactRefreshTimeouts();
if (!hasDocumentSource) {
throw new LiveArtifactRefreshUnavailableError();
}
if (!hasRefreshPermission(documentSource)) {
throw new LiveArtifactRefreshUnavailableError('Refresh is disabled for this artifact source.');
}
const candidate = await withLiveArtifactRefreshRun(
liveArtifactRefreshRunRegistry,
{
projectId: options.projectId,
artifactId: options.artifactId,
refreshId,
totalTimeoutMs: timeouts.totalTimeoutMs,
now: refreshStartedAt,
},
async (run) => {
let documentOutput: { output: BoundedJsonObject } | undefined;
if (hasDocumentSource) {
const step = 'document';
const sourceMetadata = documentSourceMetadata(documentSource);
const documentStartedAt = nowDate();
await appendLog({ step, status: 'running', startedAt: documentStartedAt, source: sourceMetadata });
try {
const output = await withLiveArtifactRefreshSourceTimeout(
run,
{ step, source: sourceMetadata, sourceTimeoutMs: timeouts.sourceTimeoutMs },
async (signal) => executeRefreshSource({
projectsRoot: options.projectsRoot,
projectId: options.projectId,
source: documentSource,
signal,
}),
);
const documentFinishedAt = nowDate();
await appendLog({ step, status: 'succeeded', startedAt: documentStartedAt, finishedAt: documentFinishedAt, source: sourceMetadata });
documentOutput = { output };
} catch (error) {
const documentFinishedAt = nowDate();
await appendLog({ step, status: 'failed', startedAt: documentStartedAt, finishedAt: documentFinishedAt, source: sourceMetadata, error });
throw error;
}
}
return buildLiveArtifactRefreshCandidate({
artifact,
currentDataJson,
...(documentOutput === undefined ? {} : { documentOutput }),
now: nowDate(),
});
},
);
const refreshedSourceCount = hasDocumentSource ? 1 : 0;
const committed = await commitLiveArtifactRefreshCandidate({
projectsRoot: options.projectsRoot,
projectId: options.projectId,
artifactId: options.artifactId,
refreshId,
dataJson: candidate.dataJson,
now: nowDate(),
});
const refreshFinishedAt = nowDate();
await appendLog({
step: 'refresh:commit',
status: 'succeeded',
startedAt: refreshStartedAt,
finishedAt: refreshFinishedAt,
metadata: { refreshedSourceCount },
});
return {
artifact: committed.artifact,
refresh: { id: refreshId, status: 'succeeded', refreshedSourceCount },
};
} catch (error) {
const refreshFinishedAt = nowDate();
await appendLog({ step: 'refresh:failed', status: 'failed', startedAt: refreshStartedAt, finishedAt: refreshFinishedAt, error }).catch(() => {});
await markLiveArtifactRefreshFailed({
projectsRoot: options.projectsRoot,
projectId: options.projectId,
artifactId: options.artifactId,
refreshId,
now: refreshFinishedAt,
}).catch(() => {});
throw error;
}
});
}

View File

@@ -0,0 +1,739 @@
import { execFile } from 'node:child_process';
import { lstat, readFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { listFiles, projectDir, readProjectFile, validateProjectPath } from '../projects.js';
import type { BoundedJsonObject, BoundedJsonValue, LiveArtifact, LiveArtifactRefreshSourceMetadata, LiveArtifactSource } from './schema.js';
import { validateBoundedJsonObject } from './schema.js';
const execFileAsync = promisify(execFile);
export const DEFAULT_LIVE_ARTIFACT_SOURCE_TIMEOUT_MS = 30_000;
export const DEFAULT_LIVE_ARTIFACT_TOTAL_TIMEOUT_MS = 120_000;
export type LiveArtifactRefreshAbortKind = 'cancelled' | 'source_timeout' | 'total_timeout';
export interface LiveArtifactRefreshTimeouts {
sourceTimeoutMs: number;
totalTimeoutMs: number;
}
export interface LiveArtifactRefreshRunScope {
projectId: string;
artifactId: string;
refreshId: string;
}
export interface LiveArtifactRefreshRun extends LiveArtifactRefreshRunScope {
readonly signal: AbortSignal;
readonly startedAt: Date;
}
export interface LiveArtifactRefreshRunOptions extends LiveArtifactRefreshRunScope {
totalTimeoutMs?: number;
now?: Date;
}
export interface LiveArtifactRefreshSourceExecutionOptions {
step: string;
source?: LiveArtifactRefreshSourceMetadata;
sourceTimeoutMs?: number;
}
export type LocalDaemonRefreshToolName =
| 'project_files.search'
| 'project_files.read_json'
| 'git.summary'
| 'public_github_repository_metric';
export interface ExecuteLocalDaemonRefreshSourceOptions {
projectsRoot: string;
projectId: string;
source: LiveArtifactSource;
signal?: AbortSignal;
}
export interface ApplyLiveArtifactOutputMappingOptions {
source: LiveArtifactSource;
output: BoundedJsonObject;
}
export interface LiveArtifactRefreshDocumentOutput {
output: BoundedJsonObject;
}
export interface BuildLiveArtifactRefreshCandidateOptions {
artifact: LiveArtifact;
currentDataJson: BoundedJsonObject;
documentOutput?: LiveArtifactRefreshDocumentOutput;
now?: Date;
}
export interface LiveArtifactRefreshCandidate {
dataJson: BoundedJsonObject;
}
export interface ProjectFilesSearchInput extends BoundedJsonObject {
query?: string;
maxResults?: number;
}
export interface ProjectFilesReadJsonInput extends BoundedJsonObject {
path?: string;
file?: string;
name?: string;
}
export interface GitSummaryInput extends BoundedJsonObject {
maxCommits?: number;
}
export interface PublicGithubRepositoryMetricInput extends BoundedJsonObject {
url?: string;
fields?: string[];
}
export class LiveArtifactRefreshAbortError extends Error {
readonly kind: LiveArtifactRefreshAbortKind;
readonly projectId: string;
readonly artifactId: string;
readonly refreshId: string;
readonly timeoutMs?: number;
readonly step?: string;
constructor(message: string, options: LiveArtifactRefreshRunScope & { kind: LiveArtifactRefreshAbortKind; timeoutMs?: number; step?: string }) {
super(message);
this.name = 'LiveArtifactRefreshAbortError';
this.kind = options.kind;
this.projectId = options.projectId;
this.artifactId = options.artifactId;
this.refreshId = options.refreshId;
if (options.timeoutMs !== undefined) this.timeoutMs = options.timeoutMs;
if (options.step !== undefined) this.step = options.step;
}
}
interface ActiveRefreshRun extends LiveArtifactRefreshRun {
readonly controller: AbortController;
readonly totalTimeout: ReturnType<typeof setTimeout>;
}
function validateTimeoutMs(value: number, path: string): number {
if (!Number.isSafeInteger(value) || value < 1) {
throw new RangeError(`${path} must be a positive safe integer`);
}
return value;
}
export function normalizeLiveArtifactRefreshTimeouts(options?: Partial<LiveArtifactRefreshTimeouts>): LiveArtifactRefreshTimeouts {
return {
sourceTimeoutMs: validateTimeoutMs(options?.sourceTimeoutMs ?? DEFAULT_LIVE_ARTIFACT_SOURCE_TIMEOUT_MS, 'sourceTimeoutMs'),
totalTimeoutMs: validateTimeoutMs(options?.totalTimeoutMs ?? DEFAULT_LIVE_ARTIFACT_TOTAL_TIMEOUT_MS, 'totalTimeoutMs'),
};
}
function refreshRunKey(scope: LiveArtifactRefreshRunScope): string {
return `${scope.projectId}\0${scope.artifactId}\0${scope.refreshId}`;
}
function abortPromise(signal: AbortSignal): Promise<never> {
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
}
function toRefreshAbortError(reason: unknown, fallback: LiveArtifactRefreshRunScope): LiveArtifactRefreshAbortError {
if (reason instanceof LiveArtifactRefreshAbortError) return reason;
if (reason instanceof Error) {
return new LiveArtifactRefreshAbortError(reason.message, { ...fallback, kind: 'cancelled' });
}
return new LiveArtifactRefreshAbortError(String(reason || 'live artifact refresh cancelled'), { ...fallback, kind: 'cancelled' });
}
export class LiveArtifactRefreshRunRegistry {
private readonly runs = new Map<string, ActiveRefreshRun>();
startRun(options: LiveArtifactRefreshRunOptions): LiveArtifactRefreshRun {
const totalTimeoutMs = validateTimeoutMs(options.totalTimeoutMs ?? DEFAULT_LIVE_ARTIFACT_TOTAL_TIMEOUT_MS, 'totalTimeoutMs');
const key = refreshRunKey(options);
if (this.runs.has(key)) {
throw new Error('live artifact refresh run already registered');
}
const controller = new AbortController();
const totalTimeout = setTimeout(() => {
controller.abort(new LiveArtifactRefreshAbortError('live artifact refresh timed out', {
...options,
kind: 'total_timeout',
timeoutMs: totalTimeoutMs,
}));
}, totalTimeoutMs);
totalTimeout.unref?.();
const run: ActiveRefreshRun = {
projectId: options.projectId,
artifactId: options.artifactId,
refreshId: options.refreshId,
startedAt: options.now ?? new Date(),
signal: controller.signal,
controller,
totalTimeout,
};
this.runs.set(key, run);
return run;
}
finishRun(run: LiveArtifactRefreshRunScope): void {
const active = this.runs.get(refreshRunKey(run));
if (active === undefined) return;
clearTimeout(active.totalTimeout);
this.runs.delete(refreshRunKey(run));
}
cancelRun(scope: LiveArtifactRefreshRunScope, reason = 'live artifact refresh cancelled by user'): boolean {
const active = this.runs.get(refreshRunKey(scope));
if (active === undefined) return false;
active.controller.abort(new LiveArtifactRefreshAbortError(reason, { ...scope, kind: 'cancelled' }));
return true;
}
hasRun(scope: LiveArtifactRefreshRunScope): boolean {
return this.runs.has(refreshRunKey(scope));
}
}
export const liveArtifactRefreshRunRegistry = new LiveArtifactRefreshRunRegistry();
export async function withLiveArtifactRefreshRun<T>(
registry: LiveArtifactRefreshRunRegistry,
options: LiveArtifactRefreshRunOptions,
callback: (run: LiveArtifactRefreshRun) => Promise<T>,
): Promise<T> {
const run = registry.startRun(options);
try {
return await Promise.race([callback(run), abortPromise(run.signal)]);
} catch (error) {
if (!run.signal.aborted) throw error;
throw toRefreshAbortError(error, run);
} finally {
registry.finishRun(run);
}
}
export async function withLiveArtifactRefreshSourceTimeout<T>(
run: LiveArtifactRefreshRun,
options: LiveArtifactRefreshSourceExecutionOptions,
callback: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const sourceTimeoutMs = validateTimeoutMs(options.sourceTimeoutMs ?? DEFAULT_LIVE_ARTIFACT_SOURCE_TIMEOUT_MS, 'sourceTimeoutMs');
const sourceController = new AbortController();
const onRunAbort = (): void => sourceController.abort(run.signal.reason);
if (run.signal.aborted) onRunAbort();
else run.signal.addEventListener('abort', onRunAbort, { once: true });
const sourceTimeout = setTimeout(() => {
sourceController.abort(new LiveArtifactRefreshAbortError('live artifact refresh source timed out', {
projectId: run.projectId,
artifactId: run.artifactId,
refreshId: run.refreshId,
kind: 'source_timeout',
timeoutMs: sourceTimeoutMs,
step: options.step,
}));
}, sourceTimeoutMs);
sourceTimeout.unref?.();
try {
return await Promise.race([callback(sourceController.signal), abortPromise(sourceController.signal)]);
} catch (error) {
if (!sourceController.signal.aborted) throw error;
throw toRefreshAbortError(error, run);
} finally {
clearTimeout(sourceTimeout);
run.signal.removeEventListener('abort', onRunAbort);
}
}
function isLocalDaemonRefreshToolName(value: string | undefined): value is LocalDaemonRefreshToolName {
return value === 'project_files.search'
|| value === 'project_files.read_json'
|| value === 'git.summary'
|| value === 'public_github_repository_metric';
}
function asBoundedRefreshOutput(value: BoundedJsonObject): BoundedJsonObject {
const result = validateBoundedJsonObject(value, 'localRefreshOutput');
if (!result.ok) {
const firstIssue = result.issues[0];
throw new Error(firstIssue === undefined ? result.error : `${firstIssue.path}: ${firstIssue.message}`);
}
return result.value;
}
const SAFE_MAPPING_SEGMENT = /^[A-Za-z_][A-Za-z0-9_-]*$|^(?:0|[1-9][0-9]*)$/;
const UNSAFE_MAPPING_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
function parseMappingPath(path: string, field: string): string[] {
const normalized = path.startsWith('$.') ? path.slice(2) : path;
if (normalized.length === 0 || normalized.startsWith('.') || normalized.endsWith('.') || normalized.includes('..')) {
throw new Error(`${field} must be a dot-separated JSON path`);
}
const segments = normalized.split('.');
for (const segment of segments) {
if (!SAFE_MAPPING_SEGMENT.test(segment) || UNSAFE_MAPPING_SEGMENTS.has(segment)) {
throw new Error(`${field} contains unsupported JSON path segment: ${segment}`);
}
}
return segments;
}
function isJsonObject(value: BoundedJsonValue | undefined): value is BoundedJsonObject {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function readMappedValue(root: BoundedJsonObject, path: string): BoundedJsonValue | undefined {
let current: BoundedJsonValue | undefined = root;
for (const segment of parseMappingPath(path, 'outputMapping.dataPaths.from')) {
if (Array.isArray(current)) {
const index = Number(segment);
if (!Number.isSafeInteger(index) || index < 0) throw new Error(`outputMapping.dataPaths.from array segment is invalid: ${segment}`);
current = current[index];
} else if (isJsonObject(current)) {
current = current[segment];
} else {
return undefined;
}
if (current === undefined) return undefined;
}
return current;
}
function makeContainer(nextSegment: string): BoundedJsonObject | BoundedJsonValue[] {
return /^(?:0|[1-9][0-9]*)$/.test(nextSegment) ? [] : {};
}
function writeMappedValue(root: BoundedJsonObject, path: string, value: BoundedJsonValue): void {
const segments = parseMappingPath(path, 'outputMapping.dataPaths.to');
let current: BoundedJsonObject | BoundedJsonValue[] = root;
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index]!;
const isLast = index === segments.length - 1;
if (Array.isArray(current)) {
const arrayIndex = Number(segment);
if (!Number.isSafeInteger(arrayIndex) || arrayIndex < 0) throw new Error('outputMapping.dataPaths.to array segments must be non-negative integers');
if (isLast) {
current[arrayIndex] = value;
return;
}
const next = current[arrayIndex];
if (!isJsonObject(next) && !Array.isArray(next)) {
current[arrayIndex] = makeContainer(segments[index + 1]!);
}
current = current[arrayIndex] as BoundedJsonObject | BoundedJsonValue[];
continue;
}
if (isLast) {
current[segment] = value;
return;
}
const next = current[segment];
if (!isJsonObject(next) && !Array.isArray(next)) {
current[segment] = makeContainer(segments[index + 1]!);
}
current = current[segment] as BoundedJsonObject | BoundedJsonValue[];
}
}
function applyDataPaths(output: BoundedJsonObject, dataPaths: NonNullable<LiveArtifactSource['outputMapping']>['dataPaths']): BoundedJsonObject {
if (dataPaths === undefined || dataPaths.length === 0) return output;
const mapped: BoundedJsonObject = {};
for (const dataPath of dataPaths) {
const value = readMappedValue(output, dataPath.from);
if (value !== undefined) writeMappedValue(mapped, dataPath.to, value);
}
return mapped;
}
function humanizeKey(key: string): string {
const spaced = key.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim();
return spaced.length === 0 ? key : spaced.replace(/^./, (char) => char.toUpperCase());
}
function isPrimitive(value: BoundedJsonValue): value is null | boolean | number | string {
return value === null || typeof value !== 'object';
}
function firstObjectArray(value: BoundedJsonValue): BoundedJsonObject[] | undefined {
if (Array.isArray(value)) return value.filter(isJsonObject).slice(0, 500);
if (!isJsonObject(value)) return undefined;
for (const key of ['rows', 'items', 'matches', 'results', 'data']) {
const child = value[key];
if (Array.isArray(child)) return child.filter(isJsonObject).slice(0, 500);
}
for (const child of Object.values(value)) {
const nested = firstObjectArray(child);
if (nested !== undefined) return nested;
}
return undefined;
}
function compactTable(value: BoundedJsonObject): BoundedJsonObject {
const rows = firstObjectArray(value) ?? [value];
const keys: string[] = [];
for (const row of rows) {
for (const [key, child] of Object.entries(row)) {
if (keys.length >= 20) break;
if (!keys.includes(key) && isPrimitive(child)) keys.push(key);
}
}
const compactRows = rows.slice(0, 100).map((row) => Object.fromEntries(keys.map((key) => [key, isPrimitive(row[key] ?? null) ? (row[key] ?? null) : JSON.stringify(row[key])])) as BoundedJsonObject);
return {
columns: keys.map((key) => ({ key, label: humanizeKey(key) })),
rows: compactRows,
count: rows.length,
truncated: rows.length > compactRows.length,
};
}
function findMetricValue(value: BoundedJsonValue): BoundedJsonValue | undefined {
if (isPrimitive(value) && typeof value !== 'boolean' && value !== null) return value;
if (Array.isArray(value)) return value.length;
if (!isJsonObject(value)) return undefined;
for (const key of ['value', 'count', 'total', 'score', 'amount']) {
const child = value[key];
if ((typeof child === 'number' || typeof child === 'string') && child !== '') return child;
}
for (const child of Object.values(value)) {
const found = findMetricValue(child);
if (found !== undefined) return found;
}
return undefined;
}
function optionalPrimitiveString(value: BoundedJsonValue | undefined): string | undefined {
if (typeof value === 'string' || typeof value === 'number') return String(value);
return undefined;
}
function metricSummary(value: BoundedJsonObject): BoundedJsonObject {
const entries = Object.entries(value);
if (entries.length === 1 && isJsonObject(entries[0]?.[1])) return metricSummary(entries[0][1]);
const metricValue = findMetricValue(value) ?? '';
return {
label: optionalPrimitiveString(value.label) ?? optionalPrimitiveString(value.name) ?? optionalPrimitiveString(value.title) ?? 'Metric',
value: typeof metricValue === 'number' || typeof metricValue === 'string' ? metricValue : String(metricValue),
...(optionalPrimitiveString(value.unit) === undefined ? {} : { unit: optionalPrimitiveString(value.unit)! }),
...(optionalPrimitiveString(value.delta) === undefined ? {} : { delta: optionalPrimitiveString(value.delta)! }),
source: value,
};
}
export function applyLiveArtifactOutputMapping(options: ApplyLiveArtifactOutputMappingOptions): BoundedJsonObject {
const mapping = options.source.outputMapping;
const selected = applyDataPaths(options.output, mapping?.dataPaths);
if (mapping?.dataPaths !== undefined && mapping.dataPaths.length > 0 && Object.keys(selected).length === 0) {
return {};
}
const transform = mapping?.transform ?? 'identity';
const transformed = transform === 'identity'
? selected
: transform === 'compact_table'
? compactTable(selected)
: metricSummary(selected);
return asBoundedRefreshOutput(transformed);
}
function cloneBoundedJsonObject(value: BoundedJsonObject): BoundedJsonObject {
return JSON.parse(JSON.stringify(value)) as BoundedJsonObject;
}
function deepMergeBoundedJsonObject(target: BoundedJsonObject, source: BoundedJsonObject): void {
for (const [key, value] of Object.entries(source)) {
const current = target[key];
if (isJsonObject(current) && isJsonObject(value)) {
deepMergeBoundedJsonObject(current, value);
} else {
target[key] = value;
}
}
}
function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US').format(value);
}
function dateLabel(value: string): string | undefined {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
}
function applyLegacyGithubRepositoryMetricCompat(dataJson: BoundedJsonObject, output: BoundedJsonObject): void {
const repository = dataJson.repository;
if (!isJsonObject(repository)) return;
const stars = output.stargazers_count;
if (typeof stars === 'number') {
repository.starCount = stars;
if (typeof repository.starCountFormatted === 'string') repository.starCountFormatted = formatNumber(stars);
}
if (typeof output.full_name === 'string') repository.fullName = output.full_name;
if (typeof output.html_url === 'string') repository.url = output.html_url;
if (typeof output.updated_at === 'string') {
repository.fetchedAt = output.updated_at;
const label = dateLabel(output.updated_at);
if (label !== undefined && typeof repository.fetchedDate === 'string') repository.fetchedDate = label;
}
}
export function buildLiveArtifactRefreshCandidate(options: BuildLiveArtifactRefreshCandidateOptions): LiveArtifactRefreshCandidate {
const dataJson = cloneBoundedJsonObject(options.currentDataJson);
if (options.documentOutput !== undefined && options.artifact.document?.sourceJson !== undefined) {
const source = options.artifact.document.sourceJson;
const mapped = source.toolName === 'public_github_repository_metric' && source.outputMapping?.dataPaths !== undefined
? asBoundedRefreshOutput(applyDataPaths(options.documentOutput.output, source.outputMapping.dataPaths))
: applyLiveArtifactOutputMapping({
source,
output: options.documentOutput.output,
});
deepMergeBoundedJsonObject(dataJson, mapped);
if (source.toolName === 'public_github_repository_metric') {
applyLegacyGithubRepositoryMetricCompat(dataJson, options.documentOutput.output);
}
}
return { dataJson: asBoundedRefreshOutput(dataJson) };
}
function optionalString(value: BoundedJsonValue | undefined, field: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string') throw new Error(`${field} must be a string`);
return value;
}
function optionalPositiveInteger(value: BoundedJsonValue | undefined, field: string, defaultValue: number, maxValue: number): number {
if (value === undefined) return defaultValue;
if (!Number.isSafeInteger(value) || typeof value !== 'number' || value < 1) throw new Error(`${field} must be a positive integer`);
return Math.min(value, maxValue);
}
function selectJsonPath(input: ProjectFilesReadJsonInput): string {
const rawPath = optionalString(input.path, 'input.path') ?? optionalString(input.file, 'input.file') ?? optionalString(input.name, 'input.name');
if (rawPath === undefined) throw new Error('project_files.read_json requires input.path');
return validateProjectPath(rawPath);
}
function compactTextPreview(text: string, query: string | undefined): string {
const normalized = text.replace(/\s+/g, ' ').trim();
if (normalized.length <= 240) return normalized;
if (query === undefined || query.trim().length === 0) return `${normalized.slice(0, 240)}`;
const index = normalized.toLowerCase().indexOf(query.toLowerCase());
if (index < 0) return `${normalized.slice(0, 240)}`;
const start = Math.max(0, index - 80);
return `${start > 0 ? '…' : ''}${normalized.slice(start, start + 240)}`;
}
function isTextLikeFile(file: { kind?: string; mime?: string; name: string }): boolean {
return file.kind === 'code' || file.kind === 'text' || file.kind === 'html' || file.mime?.startsWith('text/') === true || file.name.endsWith('.json');
}
async function executeProjectFilesSearch(options: ExecuteLocalDaemonRefreshSourceOptions): Promise<BoundedJsonObject> {
const input = options.source.input as ProjectFilesSearchInput;
const query = optionalString(input.query, 'input.query')?.trim();
const maxResults = optionalPositiveInteger(input.maxResults, 'input.maxResults', 25, 100);
const allFiles = await listFiles(options.projectsRoot, options.projectId) as Array<{ name: string; path: string; type: string; size: number; mtime: number; kind?: string; mime?: string }>;
const matches: BoundedJsonObject[] = [];
const normalizedQuery = query?.toLowerCase();
for (const file of allFiles) {
if (options.signal?.aborted === true) throw options.signal.reason;
if (matches.length >= maxResults) break;
const pathMatches = normalizedQuery === undefined || file.path.toLowerCase().includes(normalizedQuery) || file.name.toLowerCase().includes(normalizedQuery);
let preview: string | undefined;
let matched = pathMatches;
if (!matched && normalizedQuery !== undefined && isTextLikeFile(file) && file.size <= 128 * 1024) {
try {
const entry = await readProjectFile(options.projectsRoot, options.projectId, file.path);
const text = entry.buffer.toString('utf8');
matched = text.toLowerCase().includes(normalizedQuery);
if (matched) preview = compactTextPreview(text, query);
} catch {
// Ignore unreadable files during search; read_json reports hard failures.
}
}
if (!matched) continue;
const result: BoundedJsonObject = {
path: file.path,
name: file.name,
size: file.size,
mtime: file.mtime,
kind: file.kind ?? 'file',
mime: file.mime ?? 'application/octet-stream',
};
if (preview !== undefined) result.preview = preview;
matches.push(result);
}
return asBoundedRefreshOutput({ toolName: 'project_files.search', query: query ?? '', count: matches.length, truncated: allFiles.length > matches.length && matches.length >= maxResults, matches });
}
async function executeProjectFilesReadJson(options: ExecuteLocalDaemonRefreshSourceOptions): Promise<BoundedJsonObject> {
const filePath = selectJsonPath(options.source.input as ProjectFilesReadJsonInput);
if (!filePath.endsWith('.json')) throw new Error('project_files.read_json only supports .json files');
const dir = projectDir(options.projectsRoot, options.projectId);
const target = path.resolve(dir, filePath);
const [dirReal, targetLinkStat] = await Promise.all([realpath(dir), lstat(target)]);
if (targetLinkStat.isSymbolicLink()) throw new Error('project_files.read_json does not follow symlinks');
const targetReal = await realpath(target);
if (!targetReal.startsWith(`${dirReal}${path.sep}`) && targetReal !== dirReal) {
throw new Error('project_files.read_json path escapes project dir');
}
const entryStat = await stat(targetReal);
if (!entryStat.isFile()) throw new Error('project_files.read_json path must be a file');
if (entryStat.size > 256 * 1024) throw new Error('project_files.read_json file exceeds 256KB');
if (options.signal?.aborted === true) throw options.signal.reason;
let parsed: BoundedJsonValue;
try {
parsed = JSON.parse(await readFile(targetReal, 'utf8')) as BoundedJsonValue;
} catch {
throw new Error(`project_files.read_json could not parse JSON at ${filePath}`);
}
return asBoundedRefreshOutput({ toolName: 'project_files.read_json', path: filePath, size: entryStat.size, json: parsed });
}
function compactExecOutput(value: string): string[] {
return value.split('\n').map((line) => line.trimEnd()).filter(Boolean).slice(0, 100);
}
async function runGit(projectPath: string, args: string[], signal: AbortSignal | undefined): Promise<string> {
try {
const result = await execFileAsync('git', args, { cwd: projectPath, signal, timeout: 10_000, maxBuffer: 128 * 1024 });
return result.stdout.toString();
} catch (error) {
const maybeError = error as { stdout?: string | Buffer; stderr?: string | Buffer; message?: string; code?: unknown };
if (maybeError.code === 128) return '';
throw new Error(maybeError.stderr?.toString().trim() || maybeError.message || 'git command failed');
}
}
async function executeGitSummary(options: ExecuteLocalDaemonRefreshSourceOptions): Promise<BoundedJsonObject> {
const input = options.source.input as GitSummaryInput;
const maxCommits = optionalPositiveInteger(input.maxCommits, 'input.maxCommits', 10, 50);
const dir = projectDir(options.projectsRoot, options.projectId);
const insideWorkTree = (await runGit(dir, ['rev-parse', '--is-inside-work-tree'], options.signal)).trim() === 'true';
if (!insideWorkTree) return asBoundedRefreshOutput({ toolName: 'git.summary', isRepository: false, branch: '', status: [], recentCommits: [], diffStat: [] });
const [branch, status, recentCommits, diffStat] = await Promise.all([
runGit(dir, ['branch', '--show-current'], options.signal),
runGit(dir, ['status', '--short'], options.signal),
runGit(dir, ['log', `--max-count=${maxCommits}`, '--pretty=format:%h %s'], options.signal),
runGit(dir, ['diff', '--stat', '--', '.'], options.signal),
]);
return asBoundedRefreshOutput({
toolName: 'git.summary',
isRepository: true,
branch: branch.trim(),
status: compactExecOutput(status),
recentCommits: compactExecOutput(recentCommits),
diffStat: compactExecOutput(diffStat),
});
}
function selectGithubRepositoryApiUrl(input: PublicGithubRepositoryMetricInput): URL {
const rawUrl = optionalString(input.url, 'input.url');
if (rawUrl === undefined) throw new Error('public_github_repository_metric requires input.url');
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error('public_github_repository_metric input.url must be a valid URL');
}
if (url.protocol !== 'https:' || url.hostname !== 'api.github.com') {
throw new Error('public_github_repository_metric only supports https://api.github.com repository URLs');
}
if (!/^\/repos\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(url.pathname)) {
throw new Error('public_github_repository_metric only supports /repos/{owner}/{repo} URLs');
}
url.search = '';
url.hash = '';
url.username = '';
url.password = '';
return url;
}
function selectGithubFields(input: PublicGithubRepositoryMetricInput): string[] {
if (input.fields === undefined) return ['stargazers_count', 'full_name', 'html_url', 'updated_at'];
if (!Array.isArray(input.fields)) throw new Error('input.fields must be an array of strings');
const fields = input.fields.filter((field): field is string => typeof field === 'string');
if (fields.length !== input.fields.length) throw new Error('input.fields must be an array of strings');
return fields.slice(0, 20);
}
async function executePublicGithubRepositoryMetric(options: ExecuteLocalDaemonRefreshSourceOptions): Promise<BoundedJsonObject> {
const input = options.source.input as PublicGithubRepositoryMetricInput;
const url = selectGithubRepositoryApiUrl(input);
const fetchInit: RequestInit = {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'open-design-live-artifact-refresh',
},
};
if (options.signal !== undefined) fetchInit.signal = options.signal;
const response = await fetch(url, fetchInit);
if (!response.ok) {
throw new Error(`public_github_repository_metric request failed with ${response.status}`);
}
const parsed = await response.json() as Record<string, unknown>;
const output: BoundedJsonObject = { toolName: 'public_github_repository_metric' };
for (const field of selectGithubFields(input)) {
const value = parsed[field];
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null) {
output[field] = value;
}
}
return asBoundedRefreshOutput(output);
}
export async function executeLocalDaemonRefreshSource(options: ExecuteLocalDaemonRefreshSourceOptions): Promise<BoundedJsonObject> {
if (options.source.type === 'local_file') {
const toolName = options.source.toolName ?? 'project_files.read_json';
if (toolName !== 'project_files.read_json') {
throw new Error(`unsupported local_file refresh tool: ${toolName}`);
}
return executeProjectFilesReadJson({
...options,
source: {
...options.source,
type: 'daemon_tool',
toolName,
},
});
}
if (options.source.type !== 'daemon_tool') {
throw new Error('local daemon refresh sources require source.type daemon_tool or local_file');
}
if (!isLocalDaemonRefreshToolName(options.source.toolName)) {
throw new Error(`unsupported local daemon refresh tool: ${options.source.toolName ?? '<missing>'}`);
}
switch (options.source.toolName) {
case 'project_files.search':
return executeProjectFilesSearch(options);
case 'project_files.read_json':
return executeProjectFilesReadJson(options);
case 'git.summary':
return executeGitSummary(options);
case 'public_github_repository_metric':
return executePublicGithubRepositoryMetric(options);
}
}

View File

@@ -0,0 +1,84 @@
import type { BoundedJsonObject } from './schema.js';
export const LIVE_ARTIFACT_RENDER_FORMAT = 'html_template_v1' as const;
export const LIVE_ARTIFACT_TEMPLATE_ENTRY = 'template.html' as const;
export const LIVE_ARTIFACT_DATA_ENTRY = 'data.json' as const;
export const LIVE_ARTIFACT_GENERATED_PREVIEW_ENTRY = 'index.html' as const;
export interface LiveArtifactRenderInput {
templateHtml: string;
dataJson: BoundedJsonObject;
}
export interface LiveArtifactRenderOutput {
html: string;
}
const TEMPLATE_INTERPOLATION = /{{\s*([^{}]+?)\s*}}/g;
const RAW_TEMPLATE_INTERPOLATION = /{{{[^{}]*}}}|{{\s*&[^{}]*}}/;
const TEMPLATE_PATH = /^(?:data|[A-Za-z_][A-Za-z0-9_]*)(?:\.(?:[A-Za-z_][A-Za-z0-9_-]*|\d+))*$/;
const EXECUTABLE_TEMPLATE_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /<\s*script\b/i, message: 'script elements are not supported in live artifact previews' },
{ pattern: /<\s*iframe\b/i, message: 'iframe elements are not supported in live artifact previews' },
{ pattern: /\bsrcdoc\s*=/i, message: 'srcdoc attributes are not supported in live artifact previews' },
{ pattern: /\son[a-z][a-z0-9_-]*\s*=/i, message: 'event handler attributes are not supported in live artifact previews' },
{ pattern: /(?:href|src|action|formaction)\s*=\s*['"]?\s*javascript\s*:/i, message: 'javascript: URLs are not supported in live artifact previews' },
{ pattern: /\bdata-od-(?:html|raw|bind-html)\b/i, message: 'raw HTML insertion directives are not supported' },
];
export function validateHtmlTemplateV1Security(templateHtml: string): void {
for (const { pattern, message } of EXECUTABLE_TEMPLATE_PATTERNS) {
if (pattern.test(templateHtml)) throw new Error(message);
}
}
export function escapeHtmlTemplateValue(value: unknown): string {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function readTemplatePath(dataJson: BoundedJsonObject, rawPath: string): unknown {
const segments = rawPath.split('.');
if (segments.shift() !== 'data') throw new Error(`unsupported template binding path: ${rawPath}`);
let current: unknown = dataJson;
for (const segment of segments) {
if (current === null || current === undefined) return '';
if (Array.isArray(current)) {
if (!/^\d+$/.test(segment)) throw new Error(`invalid array segment in template binding path: ${rawPath}`);
current = current[Number(segment)];
continue;
}
if (typeof current !== 'object') return '';
current = (current as Record<string, unknown>)[segment];
}
return current ?? '';
}
export function renderHtmlTemplateV1(input: LiveArtifactRenderInput): LiveArtifactRenderOutput {
validateHtmlTemplateV1Security(input.templateHtml);
if (RAW_TEMPLATE_INTERPOLATION.test(input.templateHtml)) {
throw new Error('raw template interpolation is not supported');
}
const html = input.templateHtml.replace(TEMPLATE_INTERPOLATION, (_match, rawBinding: string) => {
const binding = rawBinding.trim();
if (!TEMPLATE_PATH.test(binding) || !binding.startsWith('data')) {
throw new Error(`invalid template binding path: ${binding}`);
}
const value = readTemplatePath(input.dataJson, binding);
if (Array.isArray(value) || (value !== null && typeof value === 'object')) {
throw new Error(`template binding must resolve to a scalar: ${binding}`);
}
return escapeHtmlTemplateValue(value);
});
return { html };
}

View File

@@ -0,0 +1,821 @@
// Runtime validation lives in the daemon. These mirror the shared DTOs in
// packages/contracts/src/api/live-artifacts.ts without importing daemon internals
// into contracts or forcing the daemon to compile contract source files.
export type BoundedJsonValue = null | boolean | number | string | BoundedJsonValue[] | { [key: string]: BoundedJsonValue };
export interface BoundedJsonObject {
[key: string]: BoundedJsonValue;
}
export type LiveArtifactStatus = 'active' | 'archived' | 'error';
export type LiveArtifactRefreshStatus = 'never' | 'idle' | 'running' | 'succeeded' | 'failed';
export type LiveArtifactPreviewType = 'html' | 'jsx' | 'markdown';
export type LiveArtifactSourceType = 'local_file' | 'daemon_tool' | 'connector_tool';
export type LiveArtifactConnectorApprovalPolicy = 'read_only_auto' | 'manual_refresh_granted_for_read_only';
export type LiveArtifactRefreshPermission = 'none' | 'manual_refresh_granted_for_read_only';
export type LiveArtifactOutputTransform = 'identity' | 'compact_table' | 'metric_summary';
export type LiveArtifactProvenanceGenerator = 'agent' | 'refresh_runner';
export type LiveArtifactProvenanceSourceType = 'connector' | 'local_file' | 'user_input' | 'derived';
export type LiveArtifactRefreshStepStatus = 'running' | 'succeeded' | 'failed' | 'cancelled' | 'skipped';
export type LiveArtifactRefreshSourceType = 'document' | 'artifact';
export interface LiveArtifactPreview {
type: LiveArtifactPreviewType;
entry: string;
}
export interface LiveArtifactDocument {
format: 'html_template_v1';
templatePath: 'template.html';
generatedPreviewPath: 'index.html';
dataPath: 'data.json';
dataJson: BoundedJsonObject;
dataSchemaJson?: BoundedJsonObject;
sourceJson?: LiveArtifactSource;
}
export interface LiveArtifactSource {
type: LiveArtifactSourceType;
toolName?: string;
input: BoundedJsonObject;
connector?: {
connectorId: string;
accountLabel?: string;
toolName: string;
approvalPolicy?: LiveArtifactConnectorApprovalPolicy;
};
outputMapping?: {
dataPaths?: Array<{ from: string; to: string }>;
transform?: LiveArtifactOutputTransform;
};
refreshPermission: LiveArtifactRefreshPermission;
}
export interface LiveArtifactProvenanceSource {
label: string;
type: LiveArtifactProvenanceSourceType;
ref?: string;
}
export interface LiveArtifactProvenance {
generatedAt: string;
generatedBy: LiveArtifactProvenanceGenerator;
notes?: string;
sources: LiveArtifactProvenanceSource[];
}
export interface LiveArtifact {
schemaVersion: 1;
id: string;
projectId: string;
sessionId?: string;
createdByRunId?: string;
title: string;
slug: string;
status: LiveArtifactStatus;
pinned: boolean;
preview: LiveArtifactPreview;
refreshStatus: LiveArtifactRefreshStatus;
createdAt: string;
updatedAt: string;
lastRefreshedAt?: string;
document: LiveArtifactDocument;
}
export interface LiveArtifactRefreshConnectorMetadata {
connectorId: string;
accountLabel?: string;
toolName: string;
approvalPolicy?: LiveArtifactConnectorApprovalPolicy;
}
export interface LiveArtifactRefreshSourceMetadata {
sourceType: LiveArtifactRefreshSourceType;
toolName?: string;
connector?: LiveArtifactRefreshConnectorMetadata;
}
export interface LiveArtifactRefreshErrorRecord {
code?: string;
message: string;
path?: string;
}
export interface LiveArtifactRefreshLogEntry {
schemaVersion: 1;
projectId: string;
artifactId: string;
refreshId: string;
sequence: number;
step: string;
status: LiveArtifactRefreshStepStatus;
startedAt: string;
finishedAt?: string;
durationMs?: number;
source?: LiveArtifactRefreshSourceMetadata;
error?: LiveArtifactRefreshErrorRecord;
metadata?: BoundedJsonObject;
createdAt: string;
}
export interface LiveArtifactCreateInput {
title: string;
slug?: string;
sessionId?: string;
pinned?: boolean;
status?: LiveArtifact['status'];
preview: LiveArtifactPreview;
document: LiveArtifactDocument;
}
export interface LiveArtifactUpdateInput {
title?: string;
slug?: string;
pinned?: boolean;
status?: LiveArtifact['status'];
preview?: LiveArtifactPreview;
document?: LiveArtifactDocument;
}
export interface LiveArtifactValidationIssue {
path: string;
message: string;
}
export type LiveArtifactValidationResult<T> =
| { ok: true; value: T }
| { ok: false; error: string; issues: LiveArtifactValidationIssue[] };
const MAX_ID_LENGTH = 128;
const MAX_TITLE_LENGTH = 200;
const MAX_SLUG_LENGTH = 128;
const MAX_PATH_LENGTH = 260;
const MAX_SHORT_TEXT_LENGTH = 1_024;
const MAX_LONG_TEXT_LENGTH = 16 * 1024;
const MAX_PROVENANCE_SOURCES = 50;
const MAX_MAPPING_PATHS = 100;
const MAX_REFRESH_STEP_LENGTH = 128;
const MAX_REFRESH_ERROR_CODE_LENGTH = 128;
const MAX_REFRESH_ERROR_MESSAGE_LENGTH = 2_048;
const LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS = {
maxDepth: 8,
maxObjectKeys: 100,
maxArrayLength: 500,
maxStringLength: 16 * 1024,
maxSerializedBytes: 256 * 1024,
} as const;
const DAEMON_OWNED_INPUT_FIELDS = new Set([
'id',
'projectId',
'run',
'runId',
'createdAt',
'updatedAt',
'createdByRunId',
'schemaVersion',
'refreshStatus',
'lastRefreshedAt',
]);
const FORBIDDEN_JSON_KEYS = new Set([
'raw',
'rawresponse',
'payload',
'body',
'headers',
'cookie',
'authorization',
'token',
'secret',
'credential',
'password',
]);
const LIVE_ARTIFACT_STATUSES = new Set<LiveArtifact['status']>(['active', 'archived', 'error']);
const LIVE_ARTIFACT_REFRESH_STATUSES = new Set<LiveArtifact['refreshStatus']>([
'never',
'idle',
'running',
'succeeded',
'failed',
]);
const PREVIEW_TYPES = new Set<LiveArtifactPreview['type']>(['html', 'jsx', 'markdown']);
const SOURCE_TYPES = new Set<LiveArtifactSource['type']>([
'local_file',
'daemon_tool',
'connector_tool',
]);
const CONNECTOR_APPROVAL_POLICIES = new Set<LiveArtifactConnectorApprovalPolicy>([
'read_only_auto',
'manual_refresh_granted_for_read_only',
]);
const REFRESH_PERMISSIONS = new Set<LiveArtifactSource['refreshPermission']>([
'none',
'manual_refresh_granted_for_read_only',
]);
const OUTPUT_TRANSFORMS = new Set<LiveArtifactOutputTransform>(['identity', 'compact_table', 'metric_summary']);
const PROVENANCE_GENERATORS = new Set<LiveArtifactProvenance['generatedBy']>([
'agent',
'refresh_runner',
]);
const PROVENANCE_SOURCE_TYPES = new Set<LiveArtifactProvenanceSource['type']>([
'connector',
'local_file',
'user_input',
'derived',
]);
const REFRESH_STEP_STATUSES = new Set<LiveArtifactRefreshStepStatus>([
'running',
'succeeded',
'failed',
'cancelled',
'skipped',
]);
const REFRESH_SOURCE_TYPES = new Set<LiveArtifactRefreshSourceType>([
'document',
'artifact',
]);
const SOURCE_KEYS = new Set(['type', 'toolName', 'input', 'connector', 'outputMapping', 'refreshPermission']);
const CONNECTOR_REFERENCE_KEYS = new Set(['connectorId', 'accountLabel', 'toolName', 'approvalPolicy']);
const OUTPUT_MAPPING_KEYS = new Set(['dataPaths', 'transform']);
const REFRESH_SOURCE_METADATA_KEYS = new Set(['sourceType', 'toolName', 'connector']);
function fail<T>(issues: LiveArtifactValidationIssue[]): LiveArtifactValidationResult<T> {
return {
ok: false,
error: issues[0]?.message ?? 'Live artifact validation failed',
issues,
};
}
function ok<T>(value: T): LiveArtifactValidationResult<T> {
return { ok: true, value };
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function asString(value: unknown, path: string, issues: LiveArtifactValidationIssue[], max = MAX_SHORT_TEXT_LENGTH): string | undefined {
if (typeof value !== 'string') {
issues.push({ path, message: `${path} must be a string` });
return undefined;
}
if (value.length === 0) {
issues.push({ path, message: `${path} is required` });
}
if (value.length > max) {
issues.push({ path, message: `${path} exceeds max length (${max})` });
}
return value;
}
function asOptionalString(value: unknown, path: string, issues: LiveArtifactValidationIssue[], max = MAX_SHORT_TEXT_LENGTH): string | undefined {
if (value === undefined) return undefined;
return asString(value, path, issues, max);
}
function asBoolean(value: unknown, path: string, issues: LiveArtifactValidationIssue[]): boolean | undefined {
if (typeof value !== 'boolean') {
issues.push({ path, message: `${path} must be a boolean` });
return undefined;
}
return value;
}
function asOptionalBoolean(value: unknown, path: string, issues: LiveArtifactValidationIssue[]): boolean | undefined {
if (value === undefined) return undefined;
return asBoolean(value, path, issues);
}
function validateEnum<T extends string>(value: unknown, allowed: ReadonlySet<T>, path: string, issues: LiveArtifactValidationIssue[]): T | undefined {
if (typeof value !== 'string' || !allowed.has(value as T)) {
issues.push({ path, message: `${path} is not allowed` });
return undefined;
}
return value as T;
}
function isIsoDateString(value: string): boolean {
const time = Date.parse(value);
return Number.isFinite(time) && new Date(time).toISOString() === value;
}
function validateIsoDate(value: unknown, path: string, issues: LiveArtifactValidationIssue[]): string | undefined {
const text = asString(value, path, issues, MAX_SHORT_TEXT_LENGTH);
if (text !== undefined && !isIsoDateString(text)) {
issues.push({ path, message: `${path} must be an ISO-8601 timestamp` });
}
return text;
}
function validateRelativePath(value: string, path: string, issues: LiveArtifactValidationIssue[]): void {
if (value.length > MAX_PATH_LENGTH) {
issues.push({ path, message: `${path} exceeds max length (${MAX_PATH_LENGTH})` });
}
if (value.includes('\0')) {
issues.push({ path, message: `${path} cannot contain null bytes` });
}
const normalized = value.replace(/\\/g, '/');
if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) {
issues.push({ path, message: `${path} cannot be an absolute path` });
}
if (normalized.split('/').some((part) => part === '..')) {
issues.push({ path, message: `${path} cannot contain path traversal` });
}
}
function validateNoDaemonOwnedFields(raw: Record<string, unknown>, issues: LiveArtifactValidationIssue[]): void {
for (const key of Object.keys(raw)) {
if (DAEMON_OWNED_INPUT_FIELDS.has(key)) {
issues.push({ path: key, message: `${key} is daemon-owned and cannot be supplied` });
}
}
}
function validateOnlyAllowedKeys(raw: Record<string, unknown>, allowed: ReadonlySet<string>, path: string, issues: LiveArtifactValidationIssue[]): void {
for (const key of Object.keys(raw)) {
if (!allowed.has(key)) {
issues.push({ path: `${path}.${key}`, message: `${path}.${key} is not allowed` });
}
}
}
function validateBoundedJsonInternal(value: unknown, path: string, issues: LiveArtifactValidationIssue[], depth: number): value is BoundedJsonValue {
if (value === null || typeof value === 'boolean' || typeof value === 'number') {
if (typeof value === 'number' && !Number.isFinite(value)) {
issues.push({ path, message: `${path} must be a finite number` });
return false;
}
return true;
}
if (typeof value === 'string') {
if (value.length > LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxStringLength) {
issues.push({
path,
message: `${path} exceeds max string length (${LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxStringLength})`,
});
return false;
}
return true;
}
if (Array.isArray(value)) {
if (depth > LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxDepth) {
issues.push({ path, message: `${path} exceeds max JSON depth (${LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxDepth})` });
return false;
}
if (value.length > LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxArrayLength) {
issues.push({
path,
message: `${path} exceeds max array length (${LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxArrayLength})`,
});
return false;
}
return value.every((item, index) => validateBoundedJsonInternal(item, `${path}.${index}`, issues, depth + 1));
}
if (isPlainObject(value)) {
if (depth > LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxDepth) {
issues.push({ path, message: `${path} exceeds max JSON depth (${LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxDepth})` });
return false;
}
const entries = Object.entries(value);
if (entries.length > LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxObjectKeys) {
issues.push({
path,
message: `${path} exceeds max object keys (${LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxObjectKeys})`,
});
return false;
}
let valid = true;
for (const [key, child] of entries) {
if (FORBIDDEN_JSON_KEYS.has(key.toLowerCase())) {
issues.push({ path: `${path}.${key}`, message: `${path}.${key} uses a forbidden key` });
valid = false;
}
valid = validateBoundedJsonInternal(child, `${path}.${key}`, issues, depth + 1) && valid;
}
return valid;
}
issues.push({ path, message: `${path} must be JSON-serializable` });
return false;
}
export function validateBoundedJsonValue(value: unknown, path = 'value'): LiveArtifactValidationResult<BoundedJsonValue> {
const issues: LiveArtifactValidationIssue[] = [];
if (validateBoundedJsonInternal(value, path, issues, 1)) {
const serialized = JSON.stringify(value);
if (Buffer.byteLength(serialized, 'utf8') <= LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxSerializedBytes) {
return ok(value);
}
issues.push({
path,
message: `${path} exceeds max serialized size (${LIVE_ARTIFACT_BOUNDED_JSON_CONSTRAINTS.maxSerializedBytes} bytes)`,
});
}
return fail(issues);
}
export function validateBoundedJsonObject(value: unknown, path = 'value'): LiveArtifactValidationResult<BoundedJsonObject> {
const result = validateBoundedJsonValue(value, path);
if (!result.ok) return result;
if (!isPlainObject(result.value)) {
return fail([{ path, message: `${path} must be a JSON object` }]);
}
return ok(result.value);
}
function validateSourceInputPaths(value: BoundedJsonValue, path: string, issues: LiveArtifactValidationIssue[]): void {
if (typeof value === 'string') {
validateRelativePath(value, path, issues);
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => validateSourceInputPaths(item, `${path}.${index}`, issues));
return;
}
if (isPlainObject(value)) {
for (const [key, child] of Object.entries(value)) {
if (/path|file|glob|ref/i.test(key)) validateSourceInputPaths(child, `${path}.${key}`, issues);
}
}
}
function validatePreview(value: unknown, path: string, issues: LiveArtifactValidationIssue[]): LiveArtifactPreview | undefined {
if (!isPlainObject(value)) {
issues.push({ path, message: `${path} must be an object` });
return undefined;
}
const type = validateEnum(value.type, PREVIEW_TYPES, `${path}.type`, issues);
const entry = asString(value.entry, `${path}.entry`, issues, MAX_PATH_LENGTH);
if (entry !== undefined) validateRelativePath(entry, `${path}.entry`, issues);
if (type === undefined || entry === undefined) return undefined;
return { type, entry };
}
const SAFE_MAPPING_SEGMENT = /^[A-Za-z_][A-Za-z0-9_-]*$|^(?:0|[1-9][0-9]*)$/;
const UNSAFE_MAPPING_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
function validateMappingPath(value: string, path: string, issues: LiveArtifactValidationIssue[]): void {
const normalized = value.startsWith('$.') ? value.slice(2) : value;
if (normalized.length === 0 || normalized.startsWith('.') || normalized.endsWith('.') || normalized.includes('..')) {
issues.push({ path, message: `${path} must be a dot-separated JSON path` });
return;
}
for (const segment of normalized.split('.')) {
if (!SAFE_MAPPING_SEGMENT.test(segment) || UNSAFE_MAPPING_SEGMENTS.has(segment)) {
issues.push({ path, message: `${path} contains unsupported JSON path segment: ${segment}` });
return;
}
}
}
function validateSource(value: unknown, path: string, issues: LiveArtifactValidationIssue[]): LiveArtifactSource | undefined {
if (!isPlainObject(value)) {
issues.push({ path, message: `${path} must be an object` });
return undefined;
}
validateOnlyAllowedKeys(value, SOURCE_KEYS, path, issues);
const type = validateEnum(value.type, SOURCE_TYPES, `${path}.type`, issues);
const toolName = asOptionalString(value.toolName, `${path}.toolName`, issues, MAX_ID_LENGTH);
const inputResult = validateBoundedJsonObject(value.input, `${path}.input`);
if (!inputResult.ok) issues.push(...inputResult.issues);
else validateSourceInputPaths(inputResult.value, `${path}.input`, issues);
let connector: LiveArtifactSource['connector'];
if (value.connector !== undefined) {
if (!isPlainObject(value.connector)) {
issues.push({ path: `${path}.connector`, message: `${path}.connector must be an object` });
} else {
validateOnlyAllowedKeys(value.connector, CONNECTOR_REFERENCE_KEYS, `${path}.connector`, issues);
const connectorId = asString(value.connector.connectorId, `${path}.connector.connectorId`, issues, MAX_ID_LENGTH);
const accountLabel = asOptionalString(value.connector.accountLabel, `${path}.connector.accountLabel`, issues, MAX_SHORT_TEXT_LENGTH);
const connectorToolName = asString(value.connector.toolName, `${path}.connector.toolName`, issues, MAX_ID_LENGTH);
const approvalPolicy = value.connector.approvalPolicy === undefined
? undefined
: validateEnum(value.connector.approvalPolicy, CONNECTOR_APPROVAL_POLICIES, `${path}.connector.approvalPolicy`, issues);
if (connectorId !== undefined && connectorToolName !== undefined) {
const nextConnector: NonNullable<LiveArtifactSource['connector']> = { connectorId, toolName: connectorToolName };
if (accountLabel !== undefined) nextConnector.accountLabel = accountLabel;
if (approvalPolicy !== undefined) nextConnector.approvalPolicy = approvalPolicy;
connector = nextConnector;
}
}
}
let outputMapping: LiveArtifactSource['outputMapping'];
if (value.outputMapping !== undefined) {
if (!isPlainObject(value.outputMapping)) {
issues.push({ path: `${path}.outputMapping`, message: `${path}.outputMapping must be an object` });
} else {
validateOnlyAllowedKeys(value.outputMapping, OUTPUT_MAPPING_KEYS, `${path}.outputMapping`, issues);
const mapping: NonNullable<LiveArtifactSource['outputMapping']> = {};
if (value.outputMapping.dataPaths !== undefined) {
if (!Array.isArray(value.outputMapping.dataPaths) || value.outputMapping.dataPaths.length > MAX_MAPPING_PATHS) {
issues.push({ path: `${path}.outputMapping.dataPaths`, message: `${path}.outputMapping.dataPaths must be a bounded array` });
} else {
mapping.dataPaths = [];
value.outputMapping.dataPaths.forEach((item, index) => {
const itemPath = `${path}.outputMapping.dataPaths.${index}`;
if (!isPlainObject(item)) {
issues.push({ path: itemPath, message: `${itemPath} must be an object` });
return;
}
const from = asString(item.from, `${itemPath}.from`, issues, MAX_PATH_LENGTH);
const to = asString(item.to, `${itemPath}.to`, issues, MAX_PATH_LENGTH);
if (from !== undefined) validateMappingPath(from, `${itemPath}.from`, issues);
if (to !== undefined) validateMappingPath(to, `${itemPath}.to`, issues);
if (from !== undefined && to !== undefined) mapping.dataPaths?.push({ from, to });
});
}
}
if (value.outputMapping.transform !== undefined) {
const transform = validateEnum(value.outputMapping.transform, OUTPUT_TRANSFORMS, `${path}.outputMapping.transform`, issues);
if (transform !== undefined) mapping.transform = transform;
}
outputMapping = mapping;
}
}
const refreshPermission = validateEnum(value.refreshPermission, REFRESH_PERMISSIONS, `${path}.refreshPermission`, issues);
if (type === 'connector_tool' && connector === undefined) {
issues.push({ path: `${path}.connector`, message: `${path}.connector is required for connector_tool sources` });
}
if (type === 'connector_tool' && toolName !== undefined && connector !== undefined && toolName !== connector.toolName) {
issues.push({ path: `${path}.toolName`, message: `${path}.toolName must match ${path}.connector.toolName` });
}
if (type === 'daemon_tool' && toolName === undefined) {
issues.push({ path: `${path}.toolName`, message: `${path}.toolName is required for daemon_tool sources` });
}
if (type === undefined || !inputResult.ok || refreshPermission === undefined) return undefined;
const source: LiveArtifactSource = { type, input: inputResult.value, refreshPermission };
if (toolName !== undefined) source.toolName = toolName;
if (connector !== undefined) source.connector = connector;
if (outputMapping !== undefined) source.outputMapping = outputMapping;
return source;
}
function validateRefreshSourceMetadata(value: unknown, path: string, issues: LiveArtifactValidationIssue[]): LiveArtifactRefreshSourceMetadata | undefined {
if (!isPlainObject(value)) {
issues.push({ path, message: `${path} must be an object` });
return undefined;
}
validateOnlyAllowedKeys(value, REFRESH_SOURCE_METADATA_KEYS, path, issues);
const sourceType = validateEnum(value.sourceType, REFRESH_SOURCE_TYPES, `${path}.sourceType`, issues);
const toolName = asOptionalString(value.toolName, `${path}.toolName`, issues, MAX_ID_LENGTH);
let connector: LiveArtifactRefreshConnectorMetadata | undefined;
if (value.connector !== undefined) {
if (!isPlainObject(value.connector)) {
issues.push({ path: `${path}.connector`, message: `${path}.connector must be an object` });
} else {
validateOnlyAllowedKeys(value.connector, CONNECTOR_REFERENCE_KEYS, `${path}.connector`, issues);
const connectorId = asString(value.connector.connectorId, `${path}.connector.connectorId`, issues, MAX_ID_LENGTH);
const accountLabel = asOptionalString(value.connector.accountLabel, `${path}.connector.accountLabel`, issues, MAX_SHORT_TEXT_LENGTH);
const connectorToolName = asString(value.connector.toolName, `${path}.connector.toolName`, issues, MAX_ID_LENGTH);
const approvalPolicy = value.connector.approvalPolicy === undefined
? undefined
: validateEnum(value.connector.approvalPolicy, CONNECTOR_APPROVAL_POLICIES, `${path}.connector.approvalPolicy`, issues);
if (connectorId !== undefined && connectorToolName !== undefined) {
connector = { connectorId, toolName: connectorToolName };
if (accountLabel !== undefined) connector.accountLabel = accountLabel;
if (approvalPolicy !== undefined) connector.approvalPolicy = approvalPolicy;
}
}
}
if (sourceType === undefined) return undefined;
const source: LiveArtifactRefreshSourceMetadata = { sourceType };
if (toolName !== undefined) source.toolName = toolName;
if (connector !== undefined) source.connector = connector;
return source;
}
function validateRefreshErrorRecord(value: unknown, path: string, issues: LiveArtifactValidationIssue[]): LiveArtifactRefreshErrorRecord | undefined {
if (!isPlainObject(value)) {
issues.push({ path, message: `${path} must be an object` });
return undefined;
}
const code = asOptionalString(value.code, `${path}.code`, issues, MAX_REFRESH_ERROR_CODE_LENGTH);
const message = asString(value.message, `${path}.message`, issues, MAX_REFRESH_ERROR_MESSAGE_LENGTH);
const errorPath = asOptionalString(value.path, `${path}.path`, issues, MAX_PATH_LENGTH);
if (message === undefined) return undefined;
const record: LiveArtifactRefreshErrorRecord = { message };
if (code !== undefined) record.code = code;
if (errorPath !== undefined) record.path = errorPath;
return record;
}
function validateProvenance(value: unknown, path: string, issues: LiveArtifactValidationIssue[]): LiveArtifactProvenance | undefined {
if (!isPlainObject(value)) {
issues.push({ path, message: `${path} must be an object` });
return undefined;
}
const generatedAt = validateIsoDate(value.generatedAt, `${path}.generatedAt`, issues);
const generatedBy = validateEnum(value.generatedBy, PROVENANCE_GENERATORS, `${path}.generatedBy`, issues);
const notes = asOptionalString(value.notes, `${path}.notes`, issues, MAX_LONG_TEXT_LENGTH);
let sources: LiveArtifactProvenanceSource[] | undefined;
if (!Array.isArray(value.sources) || value.sources.length > MAX_PROVENANCE_SOURCES) {
issues.push({ path: `${path}.sources`, message: `${path}.sources must be a bounded array` });
} else {
sources = [];
value.sources.forEach((source, index) => {
const sourcePath = `${path}.sources.${index}`;
if (!isPlainObject(source)) {
issues.push({ path: sourcePath, message: `${sourcePath} must be an object` });
return;
}
const label = asString(source.label, `${sourcePath}.label`, issues, MAX_SHORT_TEXT_LENGTH);
const type = validateEnum(source.type, PROVENANCE_SOURCE_TYPES, `${sourcePath}.type`, issues);
const ref = asOptionalString(source.ref, `${sourcePath}.ref`, issues, MAX_PATH_LENGTH);
if (ref !== undefined) validateRelativePath(ref, `${sourcePath}.ref`, issues);
if (label !== undefined && type !== undefined) {
const provenanceSource: LiveArtifactProvenanceSource = { label, type };
if (ref !== undefined) provenanceSource.ref = ref;
sources?.push(provenanceSource);
}
});
}
if (generatedAt === undefined || generatedBy === undefined || sources === undefined) return undefined;
const provenance: LiveArtifactProvenance = { generatedAt, generatedBy, sources };
if (notes !== undefined) provenance.notes = notes;
return provenance;
}
function validateOptionalInteger(value: unknown, path: string, issues: LiveArtifactValidationIssue[], min: number, max: number): number | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
issues.push({ path, message: `${path} must be an integer between ${min} and ${max}` });
return undefined;
}
return value;
}
function validateDocument(value: unknown, path: string, issues: LiveArtifactValidationIssue[]): LiveArtifactDocument | undefined {
if (!isPlainObject(value)) {
issues.push({ path, message: `${path} must be an object` });
return undefined;
}
if (value.format !== 'html_template_v1') issues.push({ path: `${path}.format`, message: `${path}.format must be html_template_v1` });
if (value.templatePath !== 'template.html') issues.push({ path: `${path}.templatePath`, message: `${path}.templatePath must be template.html` });
if (value.generatedPreviewPath !== 'index.html') issues.push({ path: `${path}.generatedPreviewPath`, message: `${path}.generatedPreviewPath must be index.html` });
if (value.dataPath !== 'data.json') issues.push({ path: `${path}.dataPath`, message: `${path}.dataPath must be data.json` });
const dataJsonResult = validateBoundedJsonObject(value.dataJson, `${path}.dataJson`);
if (!dataJsonResult.ok) issues.push(...dataJsonResult.issues);
let dataSchemaJson: BoundedJsonObject | undefined;
if (value.dataSchemaJson !== undefined) {
const schemaResult = validateBoundedJsonObject(value.dataSchemaJson, `${path}.dataSchemaJson`);
if (schemaResult.ok) dataSchemaJson = schemaResult.value;
else issues.push(...schemaResult.issues);
}
const sourceJson = value.sourceJson === undefined ? undefined : validateSource(value.sourceJson, `${path}.sourceJson`, issues);
if (value.format !== 'html_template_v1' || value.templatePath !== 'template.html' || value.generatedPreviewPath !== 'index.html' || value.dataPath !== 'data.json' || !dataJsonResult.ok) {
return undefined;
}
const document: LiveArtifactDocument = {
format: 'html_template_v1',
templatePath: 'template.html',
generatedPreviewPath: 'index.html',
dataPath: 'data.json',
dataJson: dataJsonResult.value,
};
if (dataSchemaJson !== undefined) document.dataSchemaJson = dataSchemaJson;
if (sourceJson !== undefined) document.sourceJson = sourceJson;
return document;
}
export function validatePersistedLiveArtifact(value: unknown, path = 'liveArtifact'): LiveArtifactValidationResult<LiveArtifact> {
const issues: LiveArtifactValidationIssue[] = [];
if (!isPlainObject(value)) return fail([{ path, message: `${path} must be an object` }]);
if (value.schemaVersion !== 1) issues.push({ path: `${path}.schemaVersion`, message: `${path}.schemaVersion must be 1` });
const id = asString(value.id, `${path}.id`, issues, MAX_ID_LENGTH);
const projectId = asString(value.projectId, `${path}.projectId`, issues, MAX_ID_LENGTH);
const sessionId = asOptionalString(value.sessionId, `${path}.sessionId`, issues, MAX_ID_LENGTH);
const createdByRunId = asOptionalString(value.createdByRunId, `${path}.createdByRunId`, issues, MAX_ID_LENGTH);
const title = asString(value.title, `${path}.title`, issues, MAX_TITLE_LENGTH);
const slug = asString(value.slug, `${path}.slug`, issues, MAX_SLUG_LENGTH);
const status = validateEnum(value.status, LIVE_ARTIFACT_STATUSES, `${path}.status`, issues);
const pinned = asBoolean(value.pinned, `${path}.pinned`, issues);
const preview = validatePreview(value.preview, `${path}.preview`, issues);
const refreshStatus = validateEnum(value.refreshStatus, LIVE_ARTIFACT_REFRESH_STATUSES, `${path}.refreshStatus`, issues);
const createdAt = validateIsoDate(value.createdAt, `${path}.createdAt`, issues);
const updatedAt = validateIsoDate(value.updatedAt, `${path}.updatedAt`, issues);
const lastRefreshedAt = value.lastRefreshedAt === undefined ? undefined : validateIsoDate(value.lastRefreshedAt, `${path}.lastRefreshedAt`, issues);
const document = validateDocument(value.document, `${path}.document`, issues);
if (issues.length > 0 || id === undefined || projectId === undefined || title === undefined || slug === undefined || status === undefined || pinned === undefined || preview === undefined || refreshStatus === undefined || createdAt === undefined || updatedAt === undefined || document === undefined) {
return fail(issues);
}
const liveArtifact: LiveArtifact = {
schemaVersion: 1,
id,
projectId,
title,
slug,
status,
pinned,
preview,
refreshStatus,
createdAt,
updatedAt,
document,
};
if (sessionId !== undefined) liveArtifact.sessionId = sessionId;
if (createdByRunId !== undefined) liveArtifact.createdByRunId = createdByRunId;
if (lastRefreshedAt !== undefined) liveArtifact.lastRefreshedAt = lastRefreshedAt;
return ok(liveArtifact);
}
export function validateLiveArtifactRefreshLogEntry(value: unknown, path = 'refreshLogEntry'): LiveArtifactValidationResult<LiveArtifactRefreshLogEntry> {
const issues: LiveArtifactValidationIssue[] = [];
if (!isPlainObject(value)) return fail([{ path, message: `${path} must be an object` }]);
if (value.schemaVersion !== 1) issues.push({ path: `${path}.schemaVersion`, message: `${path}.schemaVersion must be 1` });
const projectId = asString(value.projectId, `${path}.projectId`, issues, MAX_ID_LENGTH);
const artifactId = asString(value.artifactId, `${path}.artifactId`, issues, MAX_ID_LENGTH);
const refreshId = asString(value.refreshId, `${path}.refreshId`, issues, MAX_ID_LENGTH);
const sequence = validateOptionalInteger(value.sequence, `${path}.sequence`, issues, 0, Number.MAX_SAFE_INTEGER);
const step = asString(value.step, `${path}.step`, issues, MAX_REFRESH_STEP_LENGTH);
const status = validateEnum(value.status, REFRESH_STEP_STATUSES, `${path}.status`, issues);
const startedAt = validateIsoDate(value.startedAt, `${path}.startedAt`, issues);
const finishedAt = value.finishedAt === undefined ? undefined : validateIsoDate(value.finishedAt, `${path}.finishedAt`, issues);
const durationMs = validateOptionalInteger(value.durationMs, `${path}.durationMs`, issues, 0, Number.MAX_SAFE_INTEGER);
const source = value.source === undefined ? undefined : validateRefreshSourceMetadata(value.source, `${path}.source`, issues);
const error = value.error === undefined ? undefined : validateRefreshErrorRecord(value.error, `${path}.error`, issues);
let metadata: BoundedJsonObject | undefined;
if (value.metadata !== undefined) {
const metadataResult = validateBoundedJsonObject(value.metadata, `${path}.metadata`);
if (metadataResult.ok) metadata = metadataResult.value;
else issues.push(...metadataResult.issues);
}
const createdAt = validateIsoDate(value.createdAt, `${path}.createdAt`, issues);
if (issues.length > 0 || projectId === undefined || artifactId === undefined || refreshId === undefined || sequence === undefined || step === undefined || status === undefined || startedAt === undefined || createdAt === undefined) {
return fail(issues);
}
const entry: LiveArtifactRefreshLogEntry = {
schemaVersion: 1,
projectId,
artifactId,
refreshId,
sequence,
step,
status,
startedAt,
createdAt,
};
if (finishedAt !== undefined) entry.finishedAt = finishedAt;
if (durationMs !== undefined) entry.durationMs = durationMs;
if (source !== undefined) entry.source = source;
if (error !== undefined) entry.error = error;
if (metadata !== undefined) entry.metadata = metadata;
return ok(entry);
}
export function validateLiveArtifactCreateInput(value: unknown, path = 'input'): LiveArtifactValidationResult<LiveArtifactCreateInput> {
const issues: LiveArtifactValidationIssue[] = [];
if (!isPlainObject(value)) return fail([{ path, message: `${path} must be an object` }]);
validateNoDaemonOwnedFields(value, issues);
const title = asString(value.title, `${path}.title`, issues, MAX_TITLE_LENGTH);
const slug = asOptionalString(value.slug, `${path}.slug`, issues, MAX_SLUG_LENGTH);
const sessionId = asOptionalString(value.sessionId, `${path}.sessionId`, issues, MAX_ID_LENGTH);
const pinned = asOptionalBoolean(value.pinned, `${path}.pinned`, issues);
const status = value.status === undefined ? undefined : validateEnum(value.status, LIVE_ARTIFACT_STATUSES, `${path}.status`, issues);
const preview = validatePreview(value.preview, `${path}.preview`, issues);
const document = validateDocument(value.document, `${path}.document`, issues);
if (issues.length > 0 || title === undefined || preview === undefined || document === undefined) return fail(issues);
const input: LiveArtifactCreateInput = { title, preview, document };
if (slug !== undefined) input.slug = slug;
if (sessionId !== undefined) input.sessionId = sessionId;
if (pinned !== undefined) input.pinned = pinned;
if (status !== undefined) input.status = status;
return ok(input);
}
export function validateLiveArtifactUpdateInput(value: unknown, path = 'input'): LiveArtifactValidationResult<LiveArtifactUpdateInput> {
const issues: LiveArtifactValidationIssue[] = [];
if (!isPlainObject(value)) return fail([{ path, message: `${path} must be an object` }]);
validateNoDaemonOwnedFields(value, issues);
const title = asOptionalString(value.title, `${path}.title`, issues, MAX_TITLE_LENGTH);
const slug = asOptionalString(value.slug, `${path}.slug`, issues, MAX_SLUG_LENGTH);
const pinned = asOptionalBoolean(value.pinned, `${path}.pinned`, issues);
const status = value.status === undefined ? undefined : validateEnum(value.status, LIVE_ARTIFACT_STATUSES, `${path}.status`, issues);
const preview = value.preview === undefined ? undefined : validatePreview(value.preview, `${path}.preview`, issues);
const document = value.document === undefined ? undefined : validateDocument(value.document, `${path}.document`, issues);
if (issues.length > 0) return fail(issues);
const input: LiveArtifactUpdateInput = {};
if (title !== undefined) input.title = title;
if (slug !== undefined) input.slug = slug;
if (pinned !== undefined) input.pinned = pinned;
if (status !== undefined) input.status = status;
if (preview !== undefined) input.preview = preview;
if (document !== undefined) input.document = document;
return ok(input);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,255 @@
import readline from 'node:readline';
type JsonObject = Record<string, unknown>;
interface JsonRpcRequest {
jsonrpc?: string;
id?: string | number | null;
method?: string;
params?: JsonObject;
}
interface McpTool {
name: string;
description: string;
inputSchema: JsonObject;
}
interface McpServerResult {
exitCode: number;
}
const EMPTY_OBJECT_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {},
} satisfies JsonObject;
const ARTIFACT_INPUT_SCHEMA = {
type: 'object',
additionalProperties: true,
description: 'LiveArtifactCreateInput/LiveArtifactUpdateInput JSON plus optional templateHtml and provenanceJson fields.',
} satisfies JsonObject;
export function createLiveArtifactsMcpTools(): McpTool[] {
return [
{
name: 'live_artifacts_create',
description: 'Create a project-scoped live artifact through the daemon tool endpoint. POSIX equivalent: `"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts create --input artifact.json`.',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['input'],
properties: {
input: ARTIFACT_INPUT_SCHEMA,
templateHtml: { type: 'string' },
provenanceJson: { type: 'object', additionalProperties: true },
},
},
},
{
name: 'live_artifacts_list',
description: 'List compact project-scoped live artifacts through the daemon tool endpoint. POSIX equivalent: `"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts list --format compact`.',
inputSchema: EMPTY_OBJECT_SCHEMA,
},
{
name: 'live_artifacts_update',
description: 'Update a live artifact through the daemon tool endpoint. POSIX equivalent: `"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts update --artifact-id <id> --input artifact.json`.',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['artifactId', 'input'],
properties: {
artifactId: { type: 'string', minLength: 1 },
input: ARTIFACT_INPUT_SCHEMA,
templateHtml: { type: 'string' },
provenanceJson: { type: 'object', additionalProperties: true },
},
},
},
{
name: 'live_artifacts_refresh',
description: 'Refresh a live artifact through the daemon tool endpoint. POSIX equivalent: `"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts refresh --artifact-id <id>`.',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['artifactId'],
properties: {
artifactId: { type: 'string', minLength: 1 },
},
},
},
{
name: 'connectors_list',
description: 'List connector catalog and available read-only tools through the daemon tool endpoint. POSIX equivalent: `"$OD_NODE_BIN" "$OD_BIN" tools connectors list --format compact`.',
inputSchema: EMPTY_OBJECT_SCHEMA,
},
{
name: 'connectors_execute',
description: 'Execute an allowed connector read tool through the daemon tool endpoint. POSIX equivalent: `"$OD_NODE_BIN" "$OD_BIN" tools connectors execute --connector <id> --tool <name> --input input.json`.',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['connectorId', 'toolName', 'input'],
properties: {
connectorId: { type: 'string', minLength: 1 },
toolName: { type: 'string', minLength: 1 },
input: { type: 'object', additionalProperties: true },
},
},
},
];
}
function daemonUrl(): URL {
const rawUrl = process.env.OD_DAEMON_URL;
if (!rawUrl) throw new Error('OD_DAEMON_URL is required');
const url = new URL(rawUrl);
url.pathname = url.pathname.replace(/\/+$/u, '');
url.search = '';
url.hash = '';
return url;
}
function toolToken(): string {
const token = process.env.OD_TOOL_TOKEN;
if (!token) throw new Error('OD_TOOL_TOKEN is required');
return token;
}
function endpoint(baseUrl: URL, pathname: string): string {
const url = new URL(baseUrl.toString());
url.pathname = `${url.pathname}${pathname}`.replace(/\/+/gu, '/');
return url.toString();
}
async function requestJson(pathname: string, init: RequestInit = {}): Promise<unknown> {
const response = await fetch(endpoint(daemonUrl(), pathname), {
...init,
headers: {
Authorization: `Bearer ${toolToken()}`,
Accept: 'application/json',
...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
...init.headers,
},
});
const text = await response.text();
let body: unknown = text;
if (text.length > 0) {
try {
body = JSON.parse(text) as unknown;
} catch {
body = { message: text };
}
}
if (!response.ok) {
const error = new Error(`daemon tool endpoint failed with ${response.status}`);
(error as Error & { details?: unknown }).details = body;
throw error;
}
return body;
}
async function callTool(name: string, args: JsonObject): Promise<unknown> {
if (name === 'live_artifacts_create') {
return await requestJson('/api/tools/live-artifacts/create', {
method: 'POST',
body: JSON.stringify({
input: args.input ?? {},
...(typeof args.templateHtml === 'string' ? { templateHtml: args.templateHtml } : {}),
...(args.provenanceJson && typeof args.provenanceJson === 'object' && !Array.isArray(args.provenanceJson) ? { provenanceJson: args.provenanceJson } : {}),
}),
});
}
if (name === 'live_artifacts_list') {
return await requestJson('/api/tools/live-artifacts/list', { method: 'GET' });
}
if (name === 'live_artifacts_update') {
return await requestJson('/api/tools/live-artifacts/update', {
method: 'POST',
body: JSON.stringify({
artifactId: args.artifactId,
input: typeof args.input === 'object' && args.input ? args.input : {},
...(typeof args.templateHtml === 'string' ? { templateHtml: args.templateHtml } : {}),
...(args.provenanceJson && typeof args.provenanceJson === 'object' && !Array.isArray(args.provenanceJson) ? { provenanceJson: args.provenanceJson } : {}),
}),
});
}
if (name === 'live_artifacts_refresh') {
return await requestJson('/api/tools/live-artifacts/refresh', { method: 'POST', body: JSON.stringify({ artifactId: args.artifactId }) });
}
if (name === 'connectors_list') {
return await requestJson('/api/tools/connectors/list', { method: 'GET' });
}
if (name === 'connectors_execute') {
return await requestJson('/api/tools/connectors/execute', {
method: 'POST',
body: JSON.stringify({ connectorId: args.connectorId, toolName: args.toolName, input: args.input ?? {} }),
});
}
throw new Error(`unknown MCP tool: ${name}`);
}
export async function handleLiveArtifactsMcpRequest(request: JsonRpcRequest): Promise<JsonObject | undefined> {
const id = request.id ?? null;
const method = request.method;
if (method === 'notifications/initialized') return undefined;
try {
if (method === 'initialize') {
return {
jsonrpc: '2.0',
id,
result: {
protocolVersion: '2025-03-26',
capabilities: { tools: {} },
serverInfo: { name: 'open-design-live-artifacts', version: '0.1.0' },
},
};
}
if (method === 'tools/list') {
return { jsonrpc: '2.0', id, result: { tools: createLiveArtifactsMcpTools() } };
}
if (method === 'tools/call') {
const params = request.params ?? {};
const name = typeof params.name === 'string' ? params.name : '';
const args = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments) ? (params.arguments as JsonObject) : {};
const result = await callTool(name, args);
return {
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: JSON.stringify(result) }],
},
};
}
return { jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${String(method)}` } };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const details = error && typeof error === 'object' && 'details' in error ? (error as { details?: unknown }).details : undefined;
return { jsonrpc: '2.0', id, error: { code: -32000, message, ...(details === undefined ? {} : { data: details }) } };
}
}
export async function runLiveArtifactsMcpServer(): Promise<McpServerResult> {
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of rl) {
if (!line.trim()) continue;
let request: JsonRpcRequest;
try {
request = JSON.parse(line) as JsonRpcRequest;
} catch {
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } })}\n`);
continue;
}
const response = await handleLiveArtifactsMcpRequest(request);
if (response) process.stdout.write(`${JSON.stringify(response)}\n`);
}
return { exitCode: 0 };
}

934
apps/daemon/src/mcp.ts Normal file
View File

@@ -0,0 +1,934 @@
// @ts-nocheck
// TypeScript is suppressed because @modelcontextprotocol/sdk@1.x expects
// Zod schemas for tool definitions, but we pass plain JSON Schema objects.
// The runtime contract is identical; there is no type-safety regression -
// the nocheck just avoids a blanket of incorrect Zod-vs-object type errors
// that would obscure real mistakes. Remove once the SDK adds a JSON Schema
// overload or we migrate to a Zod-based schema builder.
//
// `od mcp` - stdio MCP server that proxies read-only tool calls to the
// running daemon's HTTP API. Lets a coding agent in a *different* repo
// (Claude Code, Cursor, Zed) pull files from a local Open Design
// project without the export-zip-import dance.
//
// The server itself holds no state and never touches the filesystem;
// every tool resolves to a fetch() against `OD_DAEMON_URL`. Spawn the
// MCP server with no daemon running and tool calls return a clear
// "daemon not reachable" error - the server itself still launches so
// the client can list its tool schema.
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const SERVER_NAME = 'open-design';
const SERVER_VERSION = '0.2.0';
// Mimes whose body we surface as MCP `text` content. Everything else
// returns a clear error directing the caller at list_files for
// metadata, until phase 2 adds binary support.
const TEXTUAL_MIME_PATTERNS = [
/^text\//i,
/^application\/json\b/i,
/^application\/javascript\b/i,
/^application\/typescript\b/i,
/^application\/xml\b/i,
/^application\/x-(yaml|toml|httpd-php|sh)\b/i,
/\+json\b/i,
/\+xml\b/i,
/^image\/svg\+xml\b/i,
];
// Every tool here is a read against a local daemon owned by the
// current user, so they're all read-only, idempotent, and operate on
// a closed (project-scoped) namespace. Pull these into one constant
// so each tool def doesn't repeat them.
const READ_ANNOTATIONS = {
readOnlyHint: true,
idempotentHint: true,
openWorldHint: false,
};
// Description style: short, one purpose-line per tool. Active-context
// fallback is documented once in the server `instructions` block, so
// per-tool descriptions just say "project optional" and don't repeat
// the rationale - that saves ~150 tokens per tools/list response,
// shipped to the model on every session.
const PROJECT_ARG = {
type: 'string',
description: 'Project id (UUID) or name substring. Optional; defaults to the active project (expires after ~5 minutes of no Open Design activity).',
} as const;
const TOOL_DEFS = [
{
name: 'list_projects',
description: 'List every Open Design project on this daemon.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
annotations: { ...READ_ANNOTATIONS, title: 'List Open Design projects' },
},
{
name: 'get_active_context',
description:
'Project + file the user has open in Open Design right now. Returns {active:false, hint:"..."} when no project is active so the agent can ask the user to interact with Open Design (the active context expires ~5 minutes after the last user interaction). Most tools default to this when project is omitted, so you rarely need to call this directly.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
annotations: { ...READ_ANNOTATIONS, title: 'What is the user looking at?' },
},
{
name: 'get_artifact',
description:
'PREFER THIS over multiple get_file calls. Bundles the entry file plus every sibling it references (HTML <script>/<link>/<img>/srcset, JSX import/require, CSS url()/@import) up to depth 3, skipping CDN/data URLs. include="all" returns every file in the project; include="shallow" returns just the entry.',
inputSchema: {
type: 'object',
properties: {
project: PROJECT_ARG,
entry: {
type: 'string',
description:
"Entry file path relative to project root. Defaults to the active file or project's metadata.entryFile. Active-file fallback expires after ~5 minutes of no Open Design activity.",
},
include: {
type: 'string',
enum: ['auto', 'all', 'shallow'],
description: 'auto (default) | all | shallow',
},
maxBytes: {
type: 'number',
description:
'Soft cap on total text bytes (default 1_500_000). Also capped at 200 files. Excess files are dropped and truncated:true is set.',
},
},
additionalProperties: false,
},
annotations: { ...READ_ANNOTATIONS, title: 'Pull design bundle' },
},
{
name: 'get_project',
description:
'Single project metadata: name, active skill/design-system ids, entryFile, kind, timestamps.',
inputSchema: {
type: 'object',
properties: { project: PROJECT_ARG },
additionalProperties: false,
},
annotations: { ...READ_ANNOTATIONS, title: 'Get Open Design project' },
},
{
name: 'get_file',
description:
'Read one project file. Text mimes only (HTML, JSX, CSS, JSON, SVG, Markdown). Binary files return an error; use list_files for metadata. Returns up to `limit` lines starting at `offset` (defaults: offset=0, limit=2000), mirroring Claude Code\'s Read tool. For files longer than the slice, the response carries an `[od:file-window ...]` marker with totalLines so you can page by re-calling with the next offset. For multi-file designs prefer get_artifact.',
inputSchema: {
type: 'object',
properties: {
project: PROJECT_ARG,
path: {
type: 'string',
description:
'File path relative to project root, forward slashes. Optional; defaults to the active file when project is also omitted. Active-file fallback expires after ~5 minutes of no Open Design activity.',
},
offset: {
type: 'number',
description: '0-indexed starting line of the slice to return. Defaults to 0.',
},
limit: {
type: 'number',
description: 'Maximum number of lines to return. Defaults to 2000.',
},
},
additionalProperties: false,
},
annotations: { ...READ_ANNOTATIONS, title: 'Read project file' },
},
{
name: 'search_files',
description:
'Case-insensitive literal-substring search across textual files in a project. Returns up to max matches with file, 1-indexed line, and snippet.',
inputSchema: {
type: 'object',
properties: {
project: PROJECT_ARG,
query: {
type: 'string',
description: 'Literal substring (not a regex), case-insensitive.',
},
pattern: {
type: 'string',
description: 'Optional glob on file name, e.g. "*.jsx".',
},
max: {
type: 'number',
description: 'Cap on matches (default 200, hard cap 1000).',
},
},
required: ['query'],
additionalProperties: false,
},
annotations: { ...READ_ANNOTATIONS, title: 'Search project files' },
},
{
name: 'list_files',
description:
'Project file metadata: name, path, mime, kind, size, mtime, optional artifactManifest. Pass since=<unix-ms> to cheap-poll for changes.',
inputSchema: {
type: 'object',
properties: {
project: PROJECT_ARG,
since: {
type: 'number',
description: 'Unix-ms; only return files with mtime > since.',
},
},
additionalProperties: false,
},
annotations: { ...READ_ANNOTATIONS, title: 'List project files' },
},
// 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
// Design's output can't run them. Design systems are reference material a
// user can opt into via the resource URIs (od://design-systems/...)
// when they actually want them, instead of paying tool-description
// tokens on every turn.
];
export async function runMcpStdio({ daemonUrl }) {
const baseUrl = String(daemonUrl).replace(/\/$/, '');
const server = new Server(
{ name: SERVER_NAME, version: SERVER_VERSION },
{
capabilities: { tools: {}, resources: {} },
instructions: [
'Open Design (OD) is a local-first design workspace. The user typically',
'has OD running on their machine; each project contains a rendered',
'artifact (HTML/JSX/CSS) plus its source files.',
'',
'Active context: get_artifact, get_project, get_file, search_files,',
'and list_files all accept project as OPTIONAL. When omitted, they',
'default to the project the user has open in OD right now; get_file',
'and get_artifact additionally default to the active file. So when',
'the user says "this file" / "the design I have open" / "find X",',
'just call the tool without project - no need to ask first. The',
'response carries usedActiveContext so you can confirm which',
'project/file you hit. Pass project explicitly to override.',
'',
'Pulling design context:',
' - get_artifact() - entry file PLUS every referenced sibling',
' (tokens CSS, JSX modules, imported assets) in one call.',
' PREFER THIS over multiple get_file calls when the user',
' wants to understand or extend a design.',
' - get_file(path) for a single known file. Returns up to 2000',
' lines starting at offset (default 0) and stamps a',
' [od:file-window ...] marker when the file is longer; page',
' by re-calling with the next offset.',
' - search_files(query) to find a class/component/copy string',
' without fetching every file.',
' - list_files for metadata only.',
' - 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.',
'',
'Project arguments accept either a UUID or a name substring',
'(e.g. "recaptr"); the server resolves the latter. When a project',
'is matched by slug or substring the response carries',
'resolvedProject:{id,name} so you can confirm which project was',
'resolved. Verify with the user if the match was unexpected.',
'',
'Reference material is exposed as MCP resources, not tools - read',
'od://design-systems/<id>/DESIGN.md when you need the brand spec',
'for a design (palette, typography, voice). Skills are similarly',
'available at od://skills/<id>/SKILL.md but are mostly relevant',
'when the user asks about how a particular artifact was generated.',
'',
'When extending an Open Design design in another codebase, pull',
'the full bundle once with get_artifact and work from those files',
'locally - do not fetch files one-by-one if you can avoid it.',
].join('\n'),
},
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOL_DEFS,
}));
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const [skillsData, dsData] = await Promise.all([
getJson(`${baseUrl}/api/skills`).catch(() => ({ skills: [] })),
getJson(`${baseUrl}/api/design-systems`).catch(() => ({ designSystems: [] })),
]);
const resources = [
{
uri: 'od://focus/active',
name: 'Active Open Design context',
description: 'The project/file the user has open in Open Design right now.',
mimeType: 'application/json',
},
];
for (const s of skillsData?.skills || []) {
resources.push({
uri: `od://skills/${encodeURIComponent(s.id)}/SKILL.md`,
name: `Skill: ${s.name || s.id}`,
description: oneLine(s.description),
mimeType: 'text/markdown',
});
}
for (const d of dsData?.designSystems || []) {
resources.push({
uri: `od://design-systems/${encodeURIComponent(d.id)}/DESIGN.md`,
name: `Design system: ${d.title || d.name || d.id}`,
description: oneLine(d.summary),
mimeType: 'text/markdown',
});
}
return { resources };
});
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
const uri = req.params?.uri;
if (uri === 'od://focus/active') {
const data = await getJson(`${baseUrl}/api/active`);
return {
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
},
],
};
}
const m = String(uri || '').match(/^od:\/\/(skills|design-systems)\/([^/]+)\/(.+)$/);
if (!m) {
throw new Error(`unsupported resource URI: ${uri}`);
}
const [, kind, id] = m;
const route = kind === 'skills' ? 'skills' : 'design-systems';
const data = await getJson(
`${baseUrl}/api/${route}/${encodeURIComponent(decodeURIComponent(id))}`,
);
const text =
data?.skill?.body ??
data?.skill?.content ??
data?.designSystem?.body ??
data?.designSystem?.content ??
data?.body ??
data?.content ??
'';
return {
contents: [
{
uri,
mimeType: 'text/markdown',
text,
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const name = req.params?.name;
const args = req.params?.arguments ?? {};
try {
switch (name) {
case 'list_projects':
return ok(await getJson(`${baseUrl}/api/projects`));
case 'get_active_context': {
const data = await getJson(`${baseUrl}/api/active`);
if (!data || data.active === false) {
return ok({
active: false,
hint: 'Open Design has no active project right now. The active context expires about 5 minutes after the last user interaction with Open Design, so the user may need to click into a project (or switch tabs inside one) to wake it up. Alternatively, pass project="<id-or-name>" to other tools to bypass active context entirely.',
});
}
return ok(data);
}
case 'get_project': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
const data = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
const project = data?.project ?? data;
return ok(
withActiveEcho(
{
...project,
entryFile: project?.metadata?.entryFile ?? null,
kind: project?.metadata?.kind ?? null,
},
active,
resolved,
),
);
}
case 'list_files': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
const params = new URLSearchParams();
if (Number.isFinite(args.since)) params.set('since', String(args.since));
const qs = params.toString();
const url = `${baseUrl}/api/projects/${encodeURIComponent(id)}/files${qs ? `?${qs}` : ''}`;
return ok(withActiveEcho(await getJson(url), active, resolved));
}
case 'get_file': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
let path = typeof args.path === 'string' ? args.path : '';
// When both project and path are omitted, fall back to the
// active file. The agent saying "read this file" without
// specifying anything is the most natural call site.
if (!path && active && active.fileName) {
path = active.fileName;
}
requireString(path, 'path');
const offset = Number.isFinite(args.offset) ? Math.max(0, Math.floor(args.offset)) : 0;
const limit = Number.isFinite(args.limit) ? Math.max(1, Math.floor(args.limit)) : 2000;
return await getFile(baseUrl, id, path, active, resolved, offset, limit);
}
case 'get_artifact':
return await getArtifact(
baseUrl,
args.project,
args.entry,
args.include,
args.maxBytes,
);
case 'search_files': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
requireString(args.query, 'query');
const params = new URLSearchParams({ q: String(args.query) });
if (args.pattern) params.set('pattern', String(args.pattern));
if (args.max) params.set('max', String(args.max));
return ok(
withActiveEcho(
await getJson(
`${baseUrl}/api/projects/${encodeURIComponent(id)}/search?${params.toString()}`,
),
active,
resolved,
),
);
}
default:
return errorResult(`unknown tool: ${name}`);
}
} catch (err) {
return errorResult(formatError(err, baseUrl));
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
// server.connect() only *starts* the transport; it resolves once the
// stdio reader is wired up, not when the stream closes. Hold the
// process open until the client disconnects (stdin EOF) so the cli.ts
// top-level `process.exit(0)` doesn't kill us mid-handshake.
await new Promise<void>((resolve) => {
const done = () => resolve();
transport.onclose = done;
process.stdin.once('end', done);
process.stdin.once('close', done);
});
}
function ok(payload) {
const text =
typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
return { content: [{ type: 'text', text }] };
}
function errorResult(message) {
return { isError: true, content: [{ type: 'text', text: message }] };
}
function requireString(v, name) {
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`${name} is required (string).`);
}
}
// Resource description renderers in some MCP UIs collapse whitespace
// poorly; keep our descriptions on a single line so they don't break
// the catalog list layout.
function oneLine(s) {
if (typeof s !== 'string') return undefined;
return s.replace(/\s+/g, ' ').trim().slice(0, 200) || undefined;
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Short-lived cache for the project list. A typical agent session
// makes several name-based lookups in quick succession; without this
// each one re-fetches /api/projects. The TTL is short so a project
// renamed in the Open Design UI shows up within a few seconds.
const PROJECT_LIST_TTL_MS = 5000;
let projectListCache = null;
async function fetchProjectList(baseUrl) {
const now = Date.now();
if (
projectListCache &&
projectListCache.baseUrl === baseUrl &&
now - projectListCache.t < PROJECT_LIST_TTL_MS
) {
return projectListCache.list;
}
const data = await getJson(`${baseUrl}/api/projects`);
const list = Array.isArray(data?.projects) ? data.projects : [];
projectListCache = { baseUrl, t: now, list };
return list;
}
// When the agent omits `project`, fall back to whatever the user has
// open in Open Design. Returns the resolved id plus, for echo-back to the
// caller, the active-context payload that was used. Throws a clear
// error when neither is available so the agent can prompt the user
// rather than guessing.
async function resolveProjectArg(baseUrl, arg) {
if (typeof arg === 'string' && arg.length > 0) {
const resolved = await resolveProjectId(baseUrl, arg);
return { id: resolved.id, resolved, active: null };
}
let active;
try {
active = await getJson(`${baseUrl}/api/active`);
} catch (err) {
throw new Error(
`project arg omitted and active context lookup failed: ${err && err.message ? err.message : err}. Pass project="<id-or-name>".`,
);
}
if (!active || active.active === false || !active.projectId) {
throw new Error(
'project arg omitted and Open Design has no active project. The active context expires about 5 minutes after the last user interaction with Open Design - the user may need to click into a project to wake it up. Otherwise pass project="<id-or-name>".',
);
}
return { id: active.projectId, resolved: null, active };
}
async function resolveProjectId(baseUrl, arg) {
if (typeof arg !== 'string' || !arg) {
throw new Error('project is required (string).');
}
if (UUID_RE.test(arg)) return { id: arg, name: arg, source: 'uuid' as const };
const list = await fetchProjectList(baseUrl);
if (list.length === 0) {
throw new Error('no projects on this daemon');
}
const lower = arg.toLowerCase();
const norm = (s) =>
String(s || '')
.toLowerCase()
.replace(/\s*\(\d+\)\s*$/, '')
.replace(/[\s_-]+/g, '-');
const target = norm(arg);
const exact = list.filter((p) => String(p.name || '').toLowerCase() === lower);
if (exact.length === 1) return { id: exact[0].id, name: exact[0].name, source: 'exact' as const };
const slugged = list.filter((p) => norm(p.name) === target);
if (slugged.length === 1) return { id: slugged[0].id, name: slugged[0].name, source: 'slug' as const };
const subs = list.filter((p) =>
String(p.name || '').toLowerCase().includes(lower),
);
if (subs.length === 1) return { id: subs[0].id, name: subs[0].name, source: 'substring' as const };
if (subs.length > 1) {
const opts = subs.map((p) => `${p.name} (${p.id})`).join(', ');
throw new Error(
`multiple projects match "${arg}": ${opts}. Pass the UUID instead.`,
);
}
throw new Error(`no project matches "${arg}"`);
}
async function getJson(url) {
const resp = await fetch(url);
if (!resp.ok) {
const body = await safeText(resp);
throw new Error(`daemon ${resp.status} on ${url}: ${body || resp.statusText}`);
}
return await resp.json();
}
async function getFile(baseUrl, project, relPath, active, resolved?, offset = 0, limit = 2000) {
const segments = String(relPath)
.split('/')
.filter((s) => s.length > 0)
.map(encodeURIComponent);
const url = `${baseUrl}/api/projects/${encodeURIComponent(project)}/raw/${segments.join('/')}`;
const resp = await fetch(url);
if (!resp.ok) {
const body = await safeText(resp);
return errorResult(
`daemon ${resp.status} on ${url}: ${body || resp.statusText}`,
);
}
const mime = (resp.headers.get('content-type') || 'application/octet-stream')
.split(';')[0]
.trim();
if (!isTextualMime(mime)) {
return errorResult(
`file at "${relPath}" has mime "${mime}"; binary content is not yet supported by od mcp. Use list_files to inspect its metadata.`,
);
}
const text = await resp.text();
const allLines = text.split('\n');
const totalLines = allLines.length;
const start = Math.min(offset, totalLines);
const slice = allLines.slice(start, start + limit);
const returnedLines = slice.length;
const truncated = start + returnedLines < totalLines;
const extra: string[] = [];
if (active) extra.push(formatActiveEchoLine(active, relPath));
if (resolved && (resolved.source === 'slug' || resolved.source === 'substring')) {
extra.push(`[od:resolved-project id="${resolved.id}" name="${resolved.name}" via="${resolved.source}"]`);
}
if (truncated || start > 0) {
const nextOffset = start + returnedLines;
const next = truncated ? `; call get_file again with offset=${nextOffset} to read more` : '';
extra.push(
`[od:file-window offset=${start} returnedLines=${returnedLines} totalLines=${totalLines}${next}]`,
);
}
return {
content: [
...extra.map((t) => ({ type: 'text', text: t })),
{ type: 'text', text: slice.join('\n') },
],
};
}
// Stamp `usedActiveContext` onto JSON tool responses when the
// project came from /api/active. Plain pass-through when the caller
// supplied project explicitly - keeps token overhead at zero for the
// explicit path.
function withActiveEcho(payload, active, resolved?) {
const result = active ? { ...payload, usedActiveContext: activeEchoPayload(active) } : payload;
if (resolved && (resolved.source === 'slug' || resolved.source === 'substring')) {
return { ...result, resolvedProject: { id: resolved.id, name: resolved.name } };
}
return result;
}
function activeEchoPayload(active) {
return {
projectId: active.projectId,
projectName: active.projectName ?? null,
fileName: active.fileName ?? null,
ageMs: active.ageMs ?? null,
};
}
function formatActiveEchoLine(active, resolvedPath) {
const proj = active.projectName || active.projectId;
const note = `[od:active-context project="${proj}" file="${resolvedPath}"]`;
return active.fileName === resolvedPath
? note
: `${note} (active file: ${active.fileName ?? 'none'})`;
}
const VALID_INCLUDE_MODES = new Set(['auto', 'all', 'shallow']);
const DEFAULT_MAX_BYTES = 1_500_000;
const MAX_FILES = 200;
// Tracks total textual content bytes accumulated; binary stubs don't
// count (their content is null). Once we cross the cap the caller
// stops fetching and stamps `truncated: true` on the bundle.
function totalTextBytes(files) {
let n = 0;
for (const f of files) {
if (!f.binary && typeof f.content === 'string') n += f.content.length;
}
return n;
}
async function getArtifact(baseUrl, projectArg, entryArg, includeMode, maxBytesArg) {
const include = includeMode == null || includeMode === '' ? 'auto' : includeMode;
if (!VALID_INCLUDE_MODES.has(include)) {
return errorResult(
`invalid include "${includeMode}"; expected one of: auto, all, shallow`,
);
}
const maxBytes =
Number.isFinite(maxBytesArg) && maxBytesArg > 0 ? Number(maxBytesArg) : DEFAULT_MAX_BYTES;
const { id, active, resolved } = await resolveProjectArg(baseUrl, projectArg);
const data = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
const project = data?.project ?? data;
// Active-file beats project default entry when project also came
// from active context - if the user is on landing.html and asks
// "bundle this", they mean landing.html, not whatever
// metadata.entryFile happens to be.
const explicitEntry = typeof entryArg === 'string' && entryArg.length > 0;
const entry = explicitEntry
? entryArg
: (active && active.fileName) || project?.metadata?.entryFile;
if (!entry) {
return errorResult(
`no entry file: pass entry="..." or set the project's metadata.entryFile`,
);
}
if (include === 'shallow') {
let file;
try {
file = await fetchProjectFile(baseUrl, id, entry);
} catch (err) {
return errorResult(err && err.message ? err.message : String(err));
}
return okBundle({ project, entry, files: [file], truncated: false, active, resolved });
}
if (include === 'all') {
const meta = await getJson(`${baseUrl}/api/projects/${encodeURIComponent(id)}/files`);
const allFiles = Array.isArray(meta?.files) ? meta.files : [];
const fetched = [];
let truncated = false;
for (const f of allFiles) {
if (fetched.length >= MAX_FILES || totalTextBytes(fetched) >= maxBytes) {
truncated = true;
break;
}
try {
const remaining = maxBytes - totalTextBytes(fetched);
fetched.push(await fetchProjectFile(baseUrl, id, f.name, remaining));
} catch (err) {
if (err instanceof BudgetExceededError) truncated = true;
// Skip files that fail to fetch; keep going.
}
}
return okBundle({ project, entry, files: fetched, truncated, active, resolved });
}
// Auto mode: BFS from entry. The entry's own fetch must succeed -
// a 404 there almost always means the agent typo'd `entry:`, and
// returning an empty bundle would hide that.
let entryFile;
try {
entryFile = await fetchProjectFile(baseUrl, id, entry);
} catch (err) {
return errorResult(err && err.message ? err.message : String(err));
}
const MAX_DEPTH = 3;
const visited = new Set([entry]);
const fetched = [entryFile];
let truncated = false;
let frontier = [];
if (isTextualMime(entryFile.mime)) {
frontier = extractRelativeRefs(entryFile.content || '', entry, entryFile.mime).filter(
(r) => !visited.has(r),
);
}
outer: for (let depth = 1; depth < MAX_DEPTH && frontier.length > 0; depth++) {
const next = [];
for (const refPath of frontier) {
if (visited.has(refPath)) continue;
visited.add(refPath);
if (fetched.length >= MAX_FILES || totalTextBytes(fetched) >= maxBytes) {
truncated = true;
break outer;
}
let file;
try {
const remaining = maxBytes - totalTextBytes(fetched);
file = await fetchProjectFile(baseUrl, id, refPath, remaining);
} catch (err) {
if (err instanceof BudgetExceededError) truncated = true;
continue;
}
fetched.push(file);
if (!isTextualMime(file.mime)) continue;
const refs = extractRelativeRefs(file.content || '', refPath, file.mime);
for (const ref of refs) {
if (!visited.has(ref)) next.push(ref);
}
}
frontier = next;
}
return okBundle({ project, entry, files: fetched, truncated, active, resolved });
}
// Thrown by fetchProjectFile when the server-advertised content-length exceeds
// the remaining byte budget. Distinguished from generic fetch errors (404,
// network) so callers can set truncated: true without treating it as a hard
// failure of the whole bundle.
class BudgetExceededError extends Error {}
async function fetchProjectFile(baseUrl, projectId, relPath, remainingBytes = Infinity) {
const segments = String(relPath)
.split('/')
.filter((s) => s.length > 0)
.map(encodeURIComponent);
const url = `${baseUrl}/api/projects/${encodeURIComponent(projectId)}/raw/${segments.join('/')}`;
const resp = await fetch(url);
if (!resp.ok) {
const body = await safeText(resp);
throw new Error(`daemon ${resp.status} on ${url}: ${body || resp.statusText}`);
}
const mime = (resp.headers.get('content-type') || 'application/octet-stream')
.split(';')[0]
.trim();
const headerSize = Number(resp.headers.get('content-length'));
const size = Number.isFinite(headerSize) && headerSize >= 0 ? headerSize : null;
if (!isTextualMime(mime)) {
return { name: relPath, mime, size, content: null, binary: true };
}
// If the server advertises a size that already exceeds our remaining
// budget, skip reading the body to avoid a large allocation.
if (size !== null && size > remainingBytes) {
throw new BudgetExceededError(`file ${relPath} (${size} bytes) exceeds remaining budget`);
}
const content = await resp.text();
return { name: relPath, mime, size: size ?? content.length, content, binary: false };
}
// Patterns common to HTML and CSS (also fine to run on plain markdown).
const HTML_REF_PATTERNS = [
/<script\b[^>]*\bsrc=["']([^"']+)["']/gi,
/<link\b[^>]*\bhref=["']([^"']+)["']/gi,
/<img\b[^>]*\bsrc=["']([^"']+)["']/gi,
/<source\b[^>]*\bsrc=["']([^"']+)["']/gi,
/<video\b[^>]*\bsrc=["']([^"']+)["']/gi,
/<audio\b[^>]*\bsrc=["']([^"']+)["']/gi,
/<iframe\b[^>]*\bsrc=["']([^"']+)["']/gi,
];
const CSS_REF_PATTERNS = [
/\burl\(\s*["']?([^"')]+)["']?\s*\)/gi,
/@import\s+(?:url\()?\s*["']([^"')]+)["']/gi,
];
// JS/TS only - running these on prose creates false positives on words
// like "imported from 'X'".
const JS_REF_PATTERNS = [
/\bimport\s+[^'"]*?['"]([^'"]+)['"]/g,
/\bfrom\s+['"]([^'"]+)['"]/g,
/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g,
];
// `srcset` can list multiple comma-separated candidates.
const SRCSET_PATTERN = /\bsrcset=["']([^"']+)["']/gi;
function isJsLike(mime, fromPath) {
if (mime && /javascript|typescript/i.test(mime)) return true;
return /\.(?:m?jsx?|tsx?|cjs)$/i.test(fromPath);
}
function isCssLike(mime, fromPath) {
if (mime && /^text\/css\b/i.test(mime)) return true;
return /\.css$/i.test(fromPath);
}
function isHtmlLike(mime, fromPath) {
if (mime && /^text\/html\b/i.test(mime)) return true;
return /\.html?$/i.test(fromPath);
}
function extractRelativeRefs(text, fromPath, fromMime) {
if (!text) return [];
const refs = new Set();
const runPatterns = [];
if (isHtmlLike(fromMime, fromPath)) {
runPatterns.push(...HTML_REF_PATTERNS, ...CSS_REF_PATTERNS);
}
if (isCssLike(fromMime, fromPath)) {
runPatterns.push(...CSS_REF_PATTERNS);
}
if (isJsLike(fromMime, fromPath)) {
runPatterns.push(...JS_REF_PATTERNS);
}
// Fallback for unknown textual files: only the safest pattern,
// url() in case it's a CSS-in-something we don't recognize.
if (runPatterns.length === 0) {
runPatterns.push(...CSS_REF_PATTERNS);
}
const candidates = [];
for (const re of runPatterns) {
for (const m of text.matchAll(re)) {
const ref = (m[1] || '').trim();
if (ref) candidates.push(ref);
}
}
// Pull every candidate URL out of any srcset attributes in HTML.
if (isHtmlLike(fromMime, fromPath)) {
for (const m of text.matchAll(SRCSET_PATTERN)) {
const list = m[1] || '';
for (const part of list.split(',')) {
const url = part.trim().split(/\s+/)[0];
if (url) candidates.push(url);
}
}
}
for (const raw of candidates) {
if (/^(?:https?:|\/\/|data:|mailto:|tel:|#)/i.test(raw)) continue;
const dir = fromPath.includes('/')
? fromPath.slice(0, fromPath.lastIndexOf('/') + 1)
: '';
const resolved = raw.startsWith('/') ? raw.slice(1) : dir + raw;
const stripped = resolved.replace(/[?#].*$/, '');
const segs = stripped.split('/').filter(Boolean);
const out: string[] = [];
let escaped = false;
for (const s of segs) {
if (s === '.') continue;
if (s === '..') {
if (out.length === 0) { escaped = true; break; }
out.pop();
continue;
}
out.push(s);
}
if (escaped || out.length === 0) continue;
refs.add(out.join('/'));
}
return [...refs];
}
function okBundle(bundle) {
const payload = {
entryFile: bundle.entry,
projectId: bundle.project?.id,
projectName: bundle.project?.name,
truncated: bundle.truncated === true,
files: bundle.files.map((f) => ({
name: f.name,
mime: f.mime,
size: f.size,
binary: f.binary === true,
content: f.binary ? null : f.content,
})),
manifest: bundle.project?.metadata ?? null,
};
return ok(withActiveEcho(payload, bundle.active, bundle.resolved));
}
function isTextualMime(mime) {
if (!mime) return false;
return TEXTUAL_MIME_PATTERNS.some((re) => re.test(mime));
}
async function safeText(resp) {
try {
return await resp.text();
} catch {
return '';
}
}
function formatError(err, daemonUrl) {
const code = err && (err.cause?.code || err.code);
const msg = err && err.message ? err.message : String(err);
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND') {
return `cannot reach the Open Design daemon at ${daemonUrl}. Is it running? Start it with \`pnpm tools-dev\`.`;
}
return msg;
}
// Exported for unit tests only.
export { extractRelativeRefs, resolveProjectId, resolveProjectArg, withActiveEcho, fetchProjectFile, getArtifact, getFile };

View File

@@ -0,0 +1,332 @@
// @ts-nocheck
// Per-provider credentials for the media dispatcher.
//
// The frontend Settings dialog pushes API keys here via PUT
// /api/media/config; the daemon persists them to .od/media-config.json
// and reads them at generation time. Environment variables override the
// stored values so power users can keep keys out of the workspace
// folder altogether (`OD_OPENAI_API_KEY=… node daemon/cli.js`).
//
// Storage location (precedence high → low):
// 1. OD_MEDIA_CONFIG_DIR=DIR → <DIR>/media-config.json
// 2. OD_DATA_DIR=DIR → <DIR>/media-config.json
// 3. (default) → <projectRoot>/.od/media-config.json
// The default is unchanged for workspace-local installs. (1) lets a
// supervisor relocate just the credentials file. (2) means installs
// that already set OD_DATA_DIR for the rest of the daemon's runtime
// state (Nix-store / immutable-image installs, the packaged daemon at
// apps/packaged/src/sidecars.ts:createPackagedDaemonManagedPathEnv,
// the Home Manager / NixOS modules) get media-config there too without
// any extra plumbing. Both env values are resolved with the same
// semantics as OD_DATA_DIR in server.ts:resolveDataDir() — `~/` expands
// to the user's home, and relative paths anchor to <projectRoot> (NOT
// process.cwd, which is unrelated to the workspace when systemd or
// launchd starts the daemon).
//
// Migration note: a workspace install that sets a custom OD_DATA_DIR
// AND has a pre-existing `<projectRoot>/.od/media-config.json` will
// start reading from `<OD_DATA_DIR>/media-config.json` instead. Move
// the file once or set OD_MEDIA_CONFIG_DIR=<projectRoot>/.od to keep
// the old location.
//
// The file is intentionally simple JSON — no encryption, no schema
// versioning yet. The daemon listens on 127.0.0.1 only and the workspace
// is already trusted, so adding a vault here would mostly be theatre.
// We DO mask keys when reading via the GET endpoint so the UI doesn't
// echo secrets back into the DOM.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
import { MEDIA_PROVIDERS } from './media-models.js';
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
const ENV_KEYS = {
// OPENAI_API_KEY is the canonical env for the standard OpenAI API.
// AZURE_API_KEY / AZURE_OPENAI_API_KEY are the canonical envs Azure
// OpenAI examples use — we share the openai provider slot so a user
// who pastes an Azure deployment URL into the OpenAI Base URL field
// gets the credential picked up automatically.
openai: [
'OD_OPENAI_API_KEY',
'OPENAI_API_KEY',
'AZURE_API_KEY',
'AZURE_OPENAI_API_KEY',
],
volcengine: ['OD_VOLCENGINE_API_KEY', 'ARK_API_KEY', 'VOLCENGINE_API_KEY'],
// OD_GROK_API_KEY first (the project-reserved override, same shape as
// every other provider above), then XAI_API_KEY as the canonical
// upstream env per docs.x.ai quickstart — so users who already export
// it for the official SDK don't have to re-paste into Settings.
grok: ['OD_GROK_API_KEY', 'XAI_API_KEY'],
nanobanana: ['OD_NANOBANANA_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_API_KEY'],
bfl: ['OD_BFL_API_KEY', 'BFL_API_KEY'],
fal: ['OD_FAL_KEY', 'FAL_KEY'],
replicate: ['OD_REPLICATE_API_TOKEN', 'REPLICATE_API_TOKEN'],
google: ['OD_GOOGLE_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_API_KEY'],
kling: ['OD_KLING_API_KEY', 'KLING_API_KEY'],
midjourney: ['OD_MIDJOURNEY_API_KEY'],
minimax: ['OD_MINIMAX_API_KEY', 'MINIMAX_API_KEY'],
suno: ['OD_SUNO_API_KEY'],
udio: ['OD_UDIO_API_KEY'],
elevenlabs: ['OD_ELEVENLABS_API_KEY', 'ELEVENLABS_API_KEY'],
fishaudio: ['OD_FISHAUDIO_API_KEY', 'FISH_AUDIO_API_KEY'],
};
// Resolve an `OD_*_DIR` env override using the same semantics as
// `resolveDataDir()` in server.ts: leading `~/` expands to the user's
// home, and relative paths anchor to <projectRoot> (NOT process.cwd —
// the daemon is often launched from a directory that has nothing to do
// with the workspace, e.g. systemd's `/`). The writability check that
// resolveDataDir does on startup is intentionally NOT replicated here:
// configFile() is on the read path and a missing/unwritable directory
// is a normal "no config yet" condition handled by readStored(); the
// write path's mkdir(recursive) creates the directory on first use.
function resolveOverrideDir(raw, projectRoot) {
const expanded = raw.startsWith('~/')
? path.join(homedir(), raw.slice(2))
: raw;
return path.isAbsolute(expanded)
? expanded
: path.resolve(projectRoot, expanded);
}
function envOverrideDir(envName, projectRoot) {
const raw = process.env[envName];
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return trimmed ? resolveOverrideDir(trimmed, projectRoot) : null;
}
function configFile(projectRoot) {
// Precedence: explicit media-config override > general data dir > default.
const dir =
envOverrideDir('OD_MEDIA_CONFIG_DIR', projectRoot)
?? envOverrideDir('OD_DATA_DIR', projectRoot)
?? path.join(projectRoot, '.od');
return path.join(dir, 'media-config.json');
}
async function readStored(projectRoot) {
try {
const raw = await readFile(configFile(projectRoot), 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && parsed.providers) {
return parsed.providers;
}
return {};
} catch (err) {
if (err && err.code === 'ENOENT') return {};
throw err;
}
}
async function writeStored(projectRoot, providers) {
const file = configFile(projectRoot);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, JSON.stringify({ providers }, null, 2), 'utf8');
}
function readEnvKey(providerId) {
const keys = ENV_KEYS[providerId];
if (!keys) return null;
for (const k of keys) {
const v = process.env[k];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return null;
}
function readNestedString(obj, keys) {
let cur = obj;
for (const key of keys) {
if (!cur || typeof cur !== 'object') return '';
cur = cur[key];
}
return typeof cur === 'string' && cur.trim() ? cur.trim() : '';
}
async function readJsonIfPresent(file) {
try {
const raw = await readFile(file, 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (err) {
if (err && err.code === 'ENOENT') return null;
// Auth files are best-effort fallbacks. A malformed local auth cache
// should not break the Settings page or hide stored provider config.
return null;
}
}
function tokenFromHermesAuth(data) {
const providerToken = readNestedString(data, [
'providers',
'openai-codex',
'tokens',
'access_token',
]);
if (providerToken) return providerToken;
const pool =
data && typeof data === 'object'
? data.credential_pool && data.credential_pool['openai-codex']
: null;
if (Array.isArray(pool)) {
for (const item of pool) {
const token = readNestedString(item, ['access_token']);
if (token) return token;
}
}
return '';
}
function tokenFromCodexAuth(data) {
const oauthToken = readNestedString(data, ['tokens', 'access_token']);
if (oauthToken) return { token: oauthToken, source: 'oauth-codex' };
const apiKey = readNestedString(data, ['OPENAI_API_KEY']);
if (apiKey) return { token: apiKey, source: 'codex-auth' };
return null;
}
async function resolveOpenAIOAuthCredential() {
const home = homedir();
const hermesAuth = await readJsonIfPresent(
path.join(home, '.hermes', 'auth.json'),
);
const hermesToken = tokenFromHermesAuth(hermesAuth);
if (hermesToken) {
return { apiKey: hermesToken, source: 'oauth-hermes' };
}
const codexAuth = await readJsonIfPresent(
path.join(home, '.codex', 'auth.json'),
);
const codexToken = tokenFromCodexAuth(codexAuth);
if (codexToken) {
return { apiKey: codexToken.token, source: codexToken.source };
}
return null;
}
/**
* Resolve credentials for a provider. Env vars win, then stored config,
* then OpenAI/Codex OAuth for the OpenAI media provider.
* Returns { apiKey, baseUrl } where either may be empty string.
*/
export async function resolveProviderConfig(projectRoot, providerId) {
const stored = await readStored(projectRoot);
const entry = stored[providerId] || {};
const envKey = readEnvKey(providerId);
const oauth =
providerId === 'openai' && !envKey && !entry.apiKey
? await resolveOpenAIOAuthCredential()
: null;
return {
apiKey: envKey || entry.apiKey || oauth?.apiKey || '',
baseUrl: entry.baseUrl || '',
...(typeof entry.model === 'string' && entry.model.trim()
? { model: entry.model.trim() }
: {}),
};
}
/**
* Read the full config for the GET endpoint. API keys are masked so the
* frontend can show "••••" + a "configured" indicator without leaking
* the secret back into the DOM.
*/
export async function readMaskedConfig(projectRoot) {
const stored = await readStored(projectRoot);
const providers = {};
for (const id of PROVIDER_IDS) {
const entry = stored[id] || {};
const envKey = readEnvKey(id);
const hasStoredKey = typeof entry.apiKey === 'string' && entry.apiKey.length > 0;
const oauth =
id === 'openai' && !envKey && !hasStoredKey
? await resolveOpenAIOAuthCredential()
: null;
providers[id] = {
configured: Boolean(envKey || hasStoredKey || oauth?.apiKey),
source: envKey ? 'env' : hasStoredKey ? 'stored' : oauth?.source || 'unset',
// Show last 4 chars only when stored locally; never echo env-var
// or OAuth secrets so power users don't accidentally see them in
// the DOM.
apiKeyTail: hasStoredKey ? entry.apiKey.slice(-4) : '',
baseUrl: entry.baseUrl || '',
...(typeof entry.model === 'string' && entry.model.trim()
? { model: entry.model.trim() }
: {}),
};
}
return { providers };
}
/**
* Write the supplied {providerId: {apiKey, baseUrl}} map. Empty
* apiKey deletes the entry. Unknown provider IDs are ignored. We
* deliberately replace the whole map rather than merging so the
* UI's "clear key" affordance just sends an empty string.
*
* Safety: if the incoming payload is empty but the on-disk config
* currently has providers, we log a WARN to stderr. This catches
* accidental wipes (e.g. a fresh-localStorage browser bootstrap
* pushing `{providers: {}}` onto a daemon that had keys from a
* previous session) without silently destroying the user's data.
*/
export async function writeConfig(projectRoot, body) {
const incoming = body && typeof body === 'object' ? body.providers || {} : {};
const force = Boolean(body && typeof body === 'object' && body.force === true);
const next = {};
for (const id of PROVIDER_IDS) {
const entry = incoming[id];
if (!entry || typeof entry !== 'object') continue;
const apiKey =
typeof entry.apiKey === 'string' && entry.apiKey.trim()
? entry.apiKey.trim()
: '';
const baseUrl =
typeof entry.baseUrl === 'string' && entry.baseUrl.trim()
? entry.baseUrl.trim()
: '';
const model =
typeof entry.model === 'string' && entry.model.trim()
? entry.model.trim()
: '';
if (!apiKey && !baseUrl && !model) continue;
next[id] = {
apiKey,
baseUrl,
...(model ? { model } : {}),
};
}
if (Object.keys(next).length === 0) {
const prior = await readStored(projectRoot);
const priorIds = Object.keys(prior).filter(
(id) => prior[id] && (prior[id].apiKey || prior[id].baseUrl),
);
if (priorIds.length > 0) {
if (!force) {
const err = new Error(
`refusing to wipe ${priorIds.length} configured provider(s) without force=true: ${priorIds.join(', ')}`,
);
err.status = 409;
throw err;
}
try {
console.error(
`[media-config] WARN: incoming PUT empty, would wipe ${priorIds.length} configured provider(s): ${priorIds.join(', ')}`,
);
} catch {
// best-effort logging only
}
}
}
await writeStored(projectRoot, next);
return readMaskedConfig(projectRoot);
}

View File

@@ -0,0 +1,133 @@
// @ts-nocheck
// Daemon-side mirror of src/media/models.ts. We keep this in plain JS so
// node imports are native and the daemon never needs a TS toolchain at
// runtime. The two files are kept in sync by hand — any model added to
// src/media/models.ts must be added here too. Drift is enforced by
// `node scripts/verify-media-models.mjs` (also exposed as
// `npm run verify:media-models`); CI should call it before publish so
// the moment one side adds a model and the other doesn't, the build
// fails with a precise diff.
export const MEDIA_PROVIDERS = [
{ id: 'openai', label: 'OpenAI', hint: 'gpt-image-2 / dall-e-3', integrated: true, defaultBaseUrl: 'https://api.openai.com/v1' },
{ id: 'volcengine', label: 'Volcengine Ark (Doubao)', hint: 'Seedance 2.0 / Seedream', integrated: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3' },
{ id: 'grok', label: 'xAI Grok Imagine', hint: 'grok-imagine — image + video with native audio', integrated: true, defaultBaseUrl: 'https://api.x.ai/v1' },
{ id: 'hyperframes', label: 'HyperFrames', hint: 'Local HTML -> MP4 renderer', integrated: true, credentialsRequired: false, settingsVisible: false },
{ id: 'nanobanana', label: 'Nano Banana', hint: 'Google official by default; custom gateway configurable', integrated: true, defaultBaseUrl: 'https://generativelanguage.googleapis.com', supportsCustomModel: true },
{ id: 'bfl', label: 'Black Forest Labs', hint: 'FLUX 1.1 Pro / FLUX Pro / Dev', integrated: false, defaultBaseUrl: 'https://api.bfl.ai' },
{ id: 'fal', label: 'Fal.ai', hint: 'Sora / Seedance / Veo / FLUX', integrated: false, defaultBaseUrl: 'https://fal.run' },
{ id: 'replicate', label: 'Replicate', hint: 'FLUX / SDXL / Ideogram', integrated: false, defaultBaseUrl: 'https://api.replicate.com/v1' },
{ id: 'google', label: 'Google AI / Vertex', hint: 'Imagen 4 / Veo 3 / Lyria', integrated: false },
{ id: 'kling', label: 'Kuaishou Kling', hint: 'Kling 1.6 / 2.0 video', integrated: false },
{ id: 'midjourney', label: 'Midjourney (proxy)', hint: 'midjourney-v7', integrated: false },
{ id: 'minimax', label: 'MiniMax', hint: 'TTS / video-01', integrated: true, defaultBaseUrl: 'https://api.minimaxi.chat/v1' },
{ id: 'suno', label: 'Suno', hint: 'Music generation', integrated: false },
{ id: 'udio', label: 'Udio', hint: 'Music generation', integrated: false },
{ id: 'elevenlabs', label: 'ElevenLabs', hint: 'Voice / SFX', integrated: false },
{ id: 'fishaudio', label: 'FishAudio', hint: 'Speech / voice clone', integrated: true, defaultBaseUrl: 'https://api.fish.audio' },
{ id: 'stub', label: 'Stub (placeholder)', hint: 'Deterministic local placeholder bytes', integrated: true },
];
export const IMAGE_MODELS = [
{ id: 'gpt-image-2', label: 'gpt-image-2', hint: 'OpenAI · 4K, native multimodal', provider: 'openai', caps: ['t2i', 'i2i', 'inpaint'], default: true },
{ id: 'gpt-image-1.5', label: 'gpt-image-1.5', hint: 'OpenAI · 4× faster than gpt-image-1', provider: 'openai', caps: ['t2i', 'i2i', 'inpaint'] },
{ id: 'gpt-image-1', label: 'gpt-image-1', hint: 'OpenAI · ChatGPT native', provider: 'openai', caps: ['t2i', 'i2i', 'inpaint'] },
{ id: 'gpt-image-1-mini', label: 'gpt-image-1-mini', hint: 'OpenAI · low-cost variant', provider: 'openai', caps: ['t2i', 'i2i'] },
{ id: 'dall-e-3', label: 'dall-e-3', hint: 'OpenAI · classic', provider: 'openai', caps: ['t2i'] },
{ id: 'dall-e-2', label: 'dall-e-2', hint: 'OpenAI · legacy', provider: 'openai', caps: ['t2i'] },
{ id: 'doubao-seedream-3-0-t2i-250415', label: 'seedream-3.0', hint: 'ByteDance · Doubao image', provider: 'volcengine', caps: ['t2i'] },
{ id: 'doubao-seededit-3-0-i2i-250628', label: 'seededit-3.0', hint: 'ByteDance · image edit', provider: 'volcengine', caps: ['i2i'] },
{ id: 'grok-imagine-image', label: 'grok-imagine-image', hint: 'xAI · 2K text-to-image', provider: 'grok', caps: ['t2i'] },
{ id: 'gemini-3.1-flash-image-preview', label: 'nano-banana-2', hint: 'Nano Banana · text-to-image', provider: 'nanobanana', caps: ['t2i'] },
{ id: 'flux-1.1-pro', label: 'flux-1.1-pro', hint: 'BFL · flagship', provider: 'bfl', caps: ['t2i', 'i2i'] },
{ id: 'flux-pro', label: 'flux-pro', hint: 'BFL', provider: 'bfl', caps: ['t2i'] },
{ id: 'flux-dev', label: 'flux-dev', hint: 'BFL · open weights', provider: 'bfl', caps: ['t2i'] },
{ id: 'flux-schnell', label: 'flux-schnell', hint: 'BFL · fast', provider: 'bfl', caps: ['t2i'] },
{ id: 'flux-kontext-pro', label: 'flux-kontext-pro', hint: 'BFL · in-context edits', provider: 'bfl', caps: ['t2i', 'i2i'] },
{ id: 'imagen-4', label: 'imagen-4', hint: 'Google · latest', provider: 'google', caps: ['t2i'] },
{ id: 'imagen-3', label: 'imagen-3', hint: 'Google', provider: 'google', caps: ['t2i'] },
{ id: 'gemini-3-pro-image-preview', label: 'gemini-3-pro-image', hint: 'Google · Nano Banana Pro', provider: 'google', caps: ['t2i', 'i2i'] },
{ id: 'ideogram-v2', label: 'ideogram-v2', hint: 'Replicate · typography', provider: 'replicate', caps: ['t2i'] },
{ id: 'sdxl', label: 'stable-diffusion-xl', hint: 'Replicate · SDXL', provider: 'replicate', caps: ['t2i'] },
{ id: 'sd-3.5', label: 'stable-diffusion-3.5', hint: 'Fal · SD 3.5', provider: 'fal', caps: ['t2i'] },
{ id: 'midjourney-v7', label: 'midjourney-v7', hint: 'Midjourney · via proxy', provider: 'midjourney', caps: ['t2i'] },
];
export const VIDEO_MODELS = [
{ id: 'doubao-seedance-2-0-260128', label: 'seedance-2.0', hint: 'ByteDance · t2v + i2v + audio', provider: 'volcengine', caps: ['t2v', 'i2v', 'audio'], default: true },
{ id: 'doubao-seedance-2-0-fast-260128', label: 'seedance-2.0-fast', hint: 'ByteDance · faster, cheaper', provider: 'volcengine', caps: ['t2v', 'i2v', 'audio'] },
{ id: 'doubao-seedance-1-0-pro-250528', label: 'seedance-1.0-pro', hint: 'ByteDance · 1.0', provider: 'volcengine', caps: ['t2v', 'i2v'] },
{ id: 'doubao-seedance-1-0-lite-i2v-250428', label: 'seedance-1.0-lite-i2v', hint: 'ByteDance · image-to-video', provider: 'volcengine', caps: ['i2v'] },
{ id: 'doubao-seedance-1-0-lite-t2v-250428', label: 'seedance-1.0-lite-t2v', hint: 'ByteDance · text-to-video', provider: 'volcengine', caps: ['t2v'] },
{ id: 'grok-imagine-video', label: 'grok-imagine-video', hint: 'xAI · 720p t2v + i2v + native audio', provider: 'grok', caps: ['t2v', 'i2v', 'audio'] },
{ id: 'kling-2.0', label: 'kling-2.0', hint: 'Kuaishou · latest', provider: 'kling', caps: ['t2v', 'i2v'] },
{ id: 'kling-1.6', label: 'kling-1.6', hint: 'Kuaishou', provider: 'kling', caps: ['t2v', 'i2v'] },
{ id: 'kling-1.5', label: 'kling-1.5', hint: 'Kuaishou', provider: 'kling', caps: ['t2v', 'i2v'] },
{ id: 'veo-3', label: 'veo-3', hint: 'Google · sound-on', provider: 'google', caps: ['t2v', 'audio'] },
{ id: 'veo-2', label: 'veo-2', hint: 'Google', provider: 'google', caps: ['t2v'] },
{ id: 'sora-2', label: 'sora-2', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
{ id: 'sora-2-pro', label: 'sora-2-pro', hint: 'OpenAI · via Fal', provider: 'fal', caps: ['t2v'] },
{ id: 'minimax-video-01', label: 'video-01', hint: 'MiniMax · Hailuo', provider: 'minimax', caps: ['t2v', 'i2v'] },
{ id: 'hyperframes-html', label: 'hyperframes-html', hint: 'HyperFrames · local HTML renderer', provider: 'hyperframes', caps: ['t2v'] },
];
export const AUDIO_MODELS_BY_KIND = {
music: [
{ id: 'suno-v5', label: 'suno-v5', hint: 'Suno · default', provider: 'suno', caps: ['music'], default: true },
{ id: 'suno-v4-5', label: 'suno-v4.5', hint: 'Suno', provider: 'suno', caps: ['music'] },
{ id: 'udio-v2', label: 'udio-v2', hint: 'Udio', provider: 'udio', caps: ['music'] },
{ id: 'lyria-2', label: 'lyria-2', hint: 'Google', provider: 'google', caps: ['music'] },
],
speech: [
{ id: 'gpt-4o-mini-tts', label: 'gpt-4o-mini-tts', hint: 'OpenAI · expressive TTS', provider: 'openai', caps: ['tts'] },
{ id: 'minimax-tts', label: 'minimax-tts', hint: 'MiniMax · default', provider: 'minimax', caps: ['tts'], default: true },
{ id: 'fish-speech-2', label: 'fish-speech-2', hint: 'FishAudio', provider: 'fishaudio', caps: ['tts', 'voice-clone'] },
{ id: 'elevenlabs-v3', label: 'elevenlabs-v3', hint: 'ElevenLabs', provider: 'elevenlabs', caps: ['tts', 'voice-clone'] },
{ id: 'doubao-tts', label: 'doubao-tts', hint: 'Volcengine · TTS', provider: 'volcengine', caps: ['tts'] },
],
sfx: [
{ id: 'elevenlabs-sfx', label: 'elevenlabs-sfx', hint: 'ElevenLabs SFX', provider: 'elevenlabs', caps: ['sfx'], default: true },
{ id: 'audiocraft', label: 'audiocraft', hint: 'Meta · open', provider: 'replicate', caps: ['sfx', 'music'] },
],
};
export const MEDIA_ASPECTS = ['1:1', '16:9', '9:16', '4:3', '3:4'];
export const VIDEO_LENGTHS_SEC = [3, 5, 8, 10, 15, 30];
export const AUDIO_DURATIONS_SEC = [5, 10, 15, 30, 60, 120];
export function findMediaModel(id) {
const all = [
...IMAGE_MODELS,
...VIDEO_MODELS,
...AUDIO_MODELS_BY_KIND.music,
...AUDIO_MODELS_BY_KIND.speech,
...AUDIO_MODELS_BY_KIND.sfx,
];
return all.find((m) => m.id === id) || null;
}
export function findProvider(id) {
return MEDIA_PROVIDERS.find((p) => p.id === id) || null;
}
export function modelsForSurface(surface, audioKind) {
if (surface === 'image') return IMAGE_MODELS;
if (surface === 'video') return VIDEO_MODELS;
if (surface === 'audio') {
const k = audioKind || 'music';
return AUDIO_MODELS_BY_KIND[k] || AUDIO_MODELS_BY_KIND.music;
}
return [];
}

1795
apps/daemon/src/media.ts Normal file

File diff suppressed because it is too large Load Diff

376
apps/daemon/src/pi-rpc.ts Normal file
View File

@@ -0,0 +1,376 @@
// @ts-nocheck
/**
* Drives pi's `--mode rpc` JSON-RPC protocol over stdio and maps agent
* events into the daemon's typed UI events (the same set that
* claude-stream.js / copilot-stream.js / acp.js emit).
*
* Lifecycle:
* 1. Daemon spawns `pi --mode rpc [--model ...]`
* 2. This module sends `prompt` on stdin
* 3. pi streams events on stdout (agent_start, message_update, …)
* 4. We translate them to: status, text_delta, thinking_delta,
* tool_use, tool_result, usage
* 5. On `agent_end` we finish the SSE stream
*
* Extension UI requests from pi are auto-resolved (the web UI has no
* dialog surfaces), and fire-and-forget notifications are silently
* consumed to keep the protocol clean.
*/
import { createJsonLineStream } from './acp.js';
// sendCommand is scoped inside attachPiRpcSession to avoid sharing
// the RPC id counter across concurrent sessions.
// Auto-approve any extension UI dialog (select/confirm/input/editor).
// The web UI has no surface for these; resolving them keeps pi unblocked.
// Fire-and-forget methods (setStatus, setWidget, notify, setTitle, set_editor_text)
// are silently consumed — no response is expected.
const FIRE_AND_FORGET_METHODS = new Set([
'setStatus',
'setWidget',
'notify',
'setTitle',
'set_editor_text',
]);
function replyExtensionUi(writable, raw) {
if (raw?.id == null) return;
// Fire-and-forget: no response expected. Silently consume.
if (FIRE_AND_FORGET_METHODS.has(raw.method)) return;
// Dialog methods: auto-resolve to keep pi unblocked.
// confirm → true, select/input/editor → empty-ish default
let result;
if (raw.method === 'confirm') {
result = { confirmed: true };
} else {
// select: pick first option if available, else cancel
const opts = raw.params?.options ?? raw.options;
if (Array.isArray(opts) && opts.length > 0) {
const first = opts[0];
result =
typeof first === 'string'
? { value: first }
: { value: first?.label ?? first?.value ?? '' };
} else {
result = { cancelled: true };
}
}
writable.write(
`${JSON.stringify({ type: 'extension_ui_response', id: raw.id, ...result })}\n`,
);
}
/**
* Map a single pi RPC event to zero or more daemon UI events.
*
* No I/O or child process interaction; mutates `ctx.sentFirstToken`
* to track streaming state.
* `send` callback and `ctx` are provided by the caller.
*
* @param {object} raw - parsed JSON from pi's stdout
* @param {function} send - (channel, payload) emitter
* @param {object} ctx - session context
* @param {number} ctx.runStartedAt - Date.now() at session start
* @param {{ value: boolean }} ctx.sentFirstToken - mutable flag
* @returns {string|null} 'agent_end' if the agent is done, null otherwise
*/
export function mapPiRpcEvent(raw, send, ctx) {
if (raw.type === 'agent_start') {
send('agent', { type: 'status', label: 'working' });
return null;
}
if (raw.type === 'agent_end') {
return 'agent_end';
}
if (raw.type === 'turn_start') {
send('agent', { type: 'status', label: 'thinking' });
return null;
}
if (raw.type === 'turn_end') {
if (raw.message?.usage) {
const u = raw.message.usage;
const usage = {};
if (typeof u.input === 'number') usage.input_tokens = u.input;
if (typeof u.output === 'number') usage.output_tokens = u.output;
if (typeof u.cacheRead === 'number') usage.cached_read_tokens = u.cacheRead;
if (typeof u.cacheWrite === 'number') usage.cached_write_tokens = u.cacheWrite;
if (typeof u.totalTokens === 'number') usage.total_tokens = u.totalTokens;
if (Object.keys(usage).length > 0) {
const cost = u.cost;
send('agent', {
type: 'usage',
usage,
costUsd: cost?.total ?? cost?.totalCost ?? null,
durationMs: Date.now() - ctx.runStartedAt,
});
}
}
return null;
}
if (raw.type === 'message_update' && raw.assistantMessageEvent) {
const ev = raw.assistantMessageEvent;
if (ev.type === 'text_delta' && typeof ev.delta === 'string') {
if (!ctx.sentFirstToken.value) {
ctx.sentFirstToken.value = true;
send('agent', {
type: 'status',
label: 'streaming',
ttftMs: Date.now() - ctx.runStartedAt,
});
}
send('agent', { type: 'text_delta', delta: ev.delta });
return null;
}
if (ev.type === 'thinking_delta' && typeof ev.delta === 'string') {
send('agent', { type: 'thinking_delta', delta: ev.delta });
return null;
}
if (ev.type === 'thinking_start') {
send('agent', { type: 'thinking_start' });
return null;
}
if (ev.type === 'thinking_end') {
send('agent', { type: 'thinking_end' });
return null;
}
return null;
}
if (raw.type === 'message_end') {
// message_end carries usage (already emitted from turn_end) and
// tool call blocks (already emitted from tool_execution_start).
// Nothing to extract here.
return null;
}
if (raw.type === 'tool_execution_start') {
send('agent', {
type: 'tool_use',
id: raw.toolCallId ?? null,
name: raw.toolName ?? null,
input: raw.args ?? null,
});
return null;
}
if (raw.type === 'tool_execution_end') {
const content = raw.result?.content;
const text =
Array.isArray(content)
? content
.map((c) => (c?.type === 'text' ? c.text : JSON.stringify(c)))
.join('\n')
: typeof content === 'string'
? content
: '';
send('agent', {
type: 'tool_result',
toolUseId: raw.toolCallId ?? null,
content: text,
isError: raw.isError === true,
});
return null;
}
if (raw.type === 'compaction_start') {
send('agent', { type: 'status', label: 'compacting' });
return null;
}
if (raw.type === 'auto_retry_start') {
send('agent', { type: 'status', label: 'retrying' });
return null;
}
return null;
}
/**
* Attach a pi RPC session to a spawned child process.
*
* Emits `status: initializing` with the model name immediately so the UI
* can show "pi · claude-sonnet-4-5" like every other adapter. Then sends
* the prompt via RPC and streams events back.
*
* The returned `abort()` method sends an RPC `abort` command so pi can
* clean up gracefully (flush logs, finalize session files, etc.). The
* caller (runs.cancel()) owns the SIGTERM fallback — abort() does not
* kill the child process itself.
*
* @param {object} opts
* @param {import('node:child_process').ChildProcess} opts.child - spawned pi process
* @param {string} opts.prompt - composed user message
* @param {string} [opts.cwd] - working directory
* @param {string|null} [opts.model] - model id (null = default)
* @param {function} opts.send - SSE send function
* @returns {{ hasFatalError(): boolean, abort(): void }}
*/
export function attachPiRpcSession({ child, prompt, cwd, model, send }) {
const runStartedAt = Date.now();
let finished = false;
let fatal = false;
const sentFirstToken = { value: false };
let nextRpcId = 1;
let stdinOpen = true;
function sendCommand(writable, type, params = {}) {
if (!stdinOpen) return null;
const id = nextRpcId++;
try {
writable.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
} catch {
return null;
}
}
// Track the prompt request id so we know when the prompt response arrives.
let promptRpcId = null;
const fail = (message) => {
if (finished) return;
finished = true;
fatal = true;
send('error', { message });
if (!child.killed) child.kill('SIGTERM');
};
// Emit initial status with model name immediately — before pi even
// responds — so the UI header shows the model name at session start.
send('agent', {
type: 'status',
label: 'initializing',
model: typeof model === 'string' && model ? model : null,
});
// ---- Outbound: send the prompt via RPC ----
child.stdin.on('error', (err) => {
if (err.code !== 'EPIPE') {
fail(`stdin: ${err.message}`);
}
});
child.stdin.on('close', () => {
stdinOpen = false;
});
promptRpcId = sendCommand(child.stdin, 'prompt', { message: prompt });
// ---- Inbound: parse stdout events ----
const parser = createJsonLineStream((raw) => {
// Once finished (agent_end or abort), stop processing — the run is
// over, so no more agent events should be emitted. We still drain
// stdout via parser.feed() so the pipe doesn't break; we just skip
// acting on the parsed objects.
if (finished) return;
// Extension UI requests: auto-resolve to keep pi unblocked.
if (raw.type === 'extension_ui_request') {
replyExtensionUi(child.stdin, raw);
return;
}
// RPC responses (prompt accepted, set_model ack, etc.) — not
// agent events. Log the prompt acceptance, ignore the rest.
if (raw.type === 'response') {
if (raw.id === promptRpcId && raw.success === false) {
fail(`prompt rejected: ${raw.error ?? 'unknown'}`);
}
return;
}
// Agent events: delegate to the pure mapper.
const result = mapPiRpcEvent(raw, send, { runStartedAt, sentFirstToken });
if (result === 'agent_end') {
finished = true;
// pi's RPC process stays alive after agent_end (designed for
// multi-prompt sessions). The daemon's /api/chat is single-shot,
// so close stdin and let the process exit naturally, or kill it
// after a grace period.
try {
child.stdin.end();
} catch {}
// Grace period before SIGTERM. Configurable via PI_GRACEFUL_SHUTDOWN_MS
// for resource-constrained machines where the event loop drains slowly.
const shutdownMs = Number(process.env.PI_GRACEFUL_SHUTDOWN_MS) || 5000;
setTimeout(() => {
if (!child.killed) child.kill('SIGTERM');
}, shutdownMs);
}
});
child.stdout.on('data', (chunk) => {
try {
parser.feed(chunk);
} catch (err) {
fail(`parser: ${err.message}`);
}
});
child.stdout.on('close', () => parser.flush());
child.on('error', (err) => fail(err.message));
return {
hasFatalError() {
return fatal;
},
abort() {
// Send RPC abort so pi can clean up gracefully (flush logs,
// finalize session files, etc.). The termination guarantee
// (SIGTERM fallback) is owned by the caller (runs.cancel()),
// not by this method.
if (finished || child.killed) return;
finished = true;
sendCommand(child.stdin, 'abort');
},
};
}
/**
* Parse `pi --list-models` tabular output into the model-picker format
* used by the daemon's /api/agents endpoint.
*
* Input lines look like:
* provider model context max-out thinking images
* anthropic claude-sonnet-4-5 200K 64K yes yes
*
* We collapse to `provider/model` ids and prepend the synthetic default.
*/
export function parsePiModels(stdout) {
const lines = String(stdout || '')
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith('#'));
if (lines.length === 0) return null;
const DEFAULT_MODEL_OPTION = { id: 'default', label: 'Default (CLI config)' };
// First line is the header; skip it.
const entries = [DEFAULT_MODEL_OPTION];
const seen = new Set(['default']);
for (let i = 1; i < lines.length; i++) {
const parts = lines[i].split(/\s+/);
if (parts.length < 2) continue;
const provider = parts[0];
const modelId = parts[1];
// Skip duplicates (some providers list the same model under multiple names).
const fullId = `${provider}/${modelId}`;
if (seen.has(fullId)) continue;
seen.add(fullId);
entries.push({ id: fullId, label: fullId });
}
return entries.length > 1 ? entries : null;
}

View File

@@ -0,0 +1,169 @@
// @ts-nocheck
import path from 'node:path';
import chokidar from 'chokidar';
import { projectDir } from './projects.js';
/**
* Refcounted per-project file watcher registry.
*
* Subscribers receive `{type, path, kind}` events when files inside the project
* change on disk. The first subscribe lazy-creates a chokidar watcher; the last
* unsubscribe closes it, so we never hold descriptors for projects no UI is
* looking at.
*/
// Names we never want to surface as project file changes. Tested per-segment
// against the path *relative to the watch root* so that ancestor directories
// (e.g. the daemon's own `.od/` runtime dir, which contains every project) do
// not accidentally match and silence every event in the tree.
const IGNORE_NAMES = new Set([
'.git',
'node_modules',
'.od',
'debug',
'.DS_Store',
// Python virtual environments and caches — can contain tens of thousands of
// files, exhausting the process fd table and breaking child-process spawning.
// These names are safe to match at any path depth: a directory named `.venv`
// or `__pycache__` is never legitimate authored source in a project tree.
'.venv',
'venv',
'__pycache__',
'.mypy_cache',
'.pytest_cache',
'.tox',
'.ruff_cache',
]);
export function makeIgnored(rootDir) {
return (absPath) => {
const rel = path.relative(rootDir, absPath);
if (!rel || rel === '' || rel.startsWith('..')) return false; // never ignore root itself
return rel.split(/[\\/]/).some((seg) => IGNORE_NAMES.has(seg));
};
}
export const DEFAULT_AWAIT_WRITE_FINISH = {
stabilityThreshold: 200,
pollInterval: 50,
};
const registry = new Map();
function makeEntry(dir, opts) {
const watcher = chokidar.watch(dir, {
ignored: opts.ignored,
ignoreInitial: true,
awaitWriteFinish: opts.awaitWriteFinish,
persistent: true,
// Don't follow symlinks out of the project root. Even though the relative-
// path ignore predicate keeps emitted events project-scoped, an unhandled
// symlink would still cost descriptors and surface external FS activity.
followSymlinks: false,
});
// chokidar's FSWatcher is an EventEmitter. Without an `error` listener,
// transient FS faults (ENOSPC, EPERM, EMFILE on saturated inotify watches)
// would surface as unhandled exceptions and could crash the daemon — taking
// every other route down with it. Log and keep the watcher alive; refcount
// cleanup is unaffected.
watcher.on('error', (err) => {
if (process.env.NODE_ENV === 'development') {
console.warn('[project-watchers] chokidar error in', dir, err);
}
});
let resolveReady;
const ready = new Promise((r) => { resolveReady = r; });
watcher.once('ready', () => resolveReady());
const entry = {
dir,
watcher,
ready,
subscribers: new Set(),
closing: null,
};
const broadcast = (kind) => (absPath) => {
const rel = path.relative(dir, absPath);
if (!rel || rel.startsWith('..')) return;
const evt = { type: 'file-changed', path: rel.split(path.sep).join('/'), kind };
for (const cb of entry.subscribers) {
try {
cb(evt);
} catch (err) {
// A buggy subscriber must not poison siblings. Log in dev so the bug
// doesn't go silent during local testing.
if (process.env.NODE_ENV === 'development') {
console.warn('[project-watchers] subscriber threw on', evt.path, err);
}
}
}
};
watcher.on('add', broadcast('add'));
watcher.on('change', broadcast('change'));
watcher.on('unlink', broadcast('unlink'));
return entry;
}
/**
* Subscribe to file-change events for a project.
*
* @param {string} projectsRoot Absolute path to the projects parent directory.
* @param {string} projectId Project id (validated by projectDir()).
* @param {(evt: {type: 'file-changed', path: string, kind: 'add'|'change'|'unlink'}) => void} onEvent
* @param {{ ignored?: string[], awaitWriteFinish?: object, _watcherFactory?: typeof makeEntry }} [opts]
* @returns {{ unsubscribe: () => Promise<void>, ready: Promise<void> }}
* `unsubscribe` releases the subscriber and closes the watcher if it was the
* last; `ready` resolves once chokidar has finished its initial scan.
*/
export function subscribe(projectsRoot, projectId, onEvent, opts = {}) {
const dir = projectDir(projectsRoot, projectId);
const key = dir;
let entry = registry.get(key);
if (!entry) {
const factory = opts._watcherFactory || makeEntry;
entry = factory(dir, {
ignored: opts.ignored || makeIgnored(dir),
awaitWriteFinish: opts.awaitWriteFinish || DEFAULT_AWAIT_WRITE_FINISH,
});
registry.set(key, entry);
}
entry.subscribers.add(onEvent);
let unsubscribed = false;
const unsubscribe = async () => {
if (unsubscribed) return;
unsubscribed = true;
entry.subscribers.delete(onEvent);
if (entry.subscribers.size === 0) {
registry.delete(key);
if (!entry.closing) entry.closing = entry.watcher.close();
await entry.closing;
}
};
return { unsubscribe, ready: entry.ready || Promise.resolve() };
}
/** Test-only: drop all watchers. */
export async function _resetForTests() {
const entries = Array.from(registry.values());
registry.clear();
await Promise.allSettled(entries.map((e) => e.watcher.close()));
}
/** Test-only: number of active watchers. */
export function _activeWatcherCount() {
return registry.size;
}
/** Test-only: return the chokidar FSWatcher for a given project's directory. */
export function _internalWatcherForTests(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId);
return registry.get(dir)?.watcher;
}

579
apps/daemon/src/projects.ts Normal file
View File

@@ -0,0 +1,579 @@
// @ts-nocheck
// Project files registry. Each project is a folder under
// <projectRoot>/.od/projects/<projectId>/. The frontend's project list
// (localStorage) carries metadata; this module is the single owner of the
// on-disk content (HTML artifacts, sketches, uploaded images, pasted text).
//
// All paths flowing in from HTTP handlers are validated against the project
// directory to prevent path traversal — see resolveSafe().
import { lstat, mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import JSZip from 'jszip';
import {
inferLegacyManifest,
parsePersistedManifest,
validateArtifactManifestInput,
} from './artifact-manifest.js';
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
export function projectDir(projectsRoot, projectId) {
if (!isSafeId(projectId)) throw new Error('invalid project id');
return path.join(projectsRoot, projectId);
}
export async function ensureProject(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId);
await mkdir(dir, { recursive: true });
return dir;
}
export async function listFiles(projectsRoot, projectId, opts = {}) {
const dir = projectDir(projectsRoot, projectId);
const out = [];
await collectFiles(dir, '', out);
// Newest first — matches the visual order users expect after generating.
out.sort((a, b) => b.mtime - a.mtime);
const since = Number(opts.since);
if (Number.isFinite(since) && since > 0) {
return out.filter((f) => Number(f.mtime) > since);
}
return out;
}
async function collectFiles(dir, relDir, out) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err && err.code === 'ENOENT') return;
throw err;
}
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await collectFiles(full, rel, out);
continue;
}
if (!e.isFile()) continue;
if (e.name.endsWith('.artifact.json')) continue;
const st = await stat(full);
const manifest = await readManifestForPath(dir, rel);
out.push({
name: rel,
path: rel,
type: 'file',
size: st.size,
mtime: st.mtimeMs,
kind: kindFor(rel),
mime: mimeFor(rel),
artifactKind: manifest?.kind,
artifactManifest: manifest,
});
}
}
// Build a ZIP of every file under the project directory (or under `root`,
// if it points at a subdirectory). Mirrors listFiles' filtering — dotfiles
// and `.artifact.json` sidecars are excluded — so the archive matches what
// the user sees in the file panel. Used by the "Download as .zip" share
// menu item, which exports the user's actual project tree (e.g. the
// uploaded `ui-design/` folder), not just the rendered HTML.
export async function buildProjectArchive(projectsRoot, projectId, root) {
const projectRoot = projectDir(projectsRoot, projectId);
let archiveRoot = projectRoot;
let archiveBaseName = '';
if (typeof root === 'string' && root.trim().length > 0) {
archiveRoot = resolveSafe(projectRoot, root);
archiveBaseName = path.basename(archiveRoot);
}
// Stat the archive root up-front so a missing/non-directory target gives a
// clear ENOENT/ENOTDIR error. Without this the recursive walk swallows
// ENOENT and we'd report the directory as "empty" instead — confusing if
// the project (or a subdir) was deleted concurrently with the download.
let rootStat;
try {
rootStat = await stat(archiveRoot);
} catch (err) {
if (err && err.code === 'ENOENT') {
const e = new Error('archive root does not exist');
e.code = 'ENOENT';
throw e;
}
throw err;
}
if (!rootStat.isDirectory()) {
const err = new Error('archive root is not a directory');
err.code = 'ENOTDIR';
throw err;
}
const entries = [];
await collectArchiveEntries(archiveRoot, '', entries);
if (entries.length === 0) {
const err = new Error('archive root is empty');
err.code = 'ENOENT';
throw err;
}
const zip = new JSZip();
for (const entry of entries) {
const buf = await readFile(entry.fullPath);
zip.file(entry.relPath, buf, {
date: new Date(entry.mtime),
binary: true,
});
}
// Level 6 is the zlib default — balances speed and ratio for typical
// project trees (HTML/CSS/JS plus a handful of assets). Level 9 buys
// <5% on already-compressed PNGs/fonts at 2-3× CPU; level 1 produces
// noticeably larger archives. Revisit only if profiling says so.
const buffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
return { buffer, baseName: archiveBaseName };
}
export async function buildBatchArchive(projectsRoot, projectId, fileNames) {
const projectRoot = projectDir(projectsRoot, projectId);
const zip = new JSZip();
let packed = 0;
const rejected = [];
for (const name of fileNames) {
let filePath;
try {
filePath = resolveSafe(projectRoot, name);
} catch (err) {
rejected.push({ name, reason: `invalid path: ${err?.message || err}` });
continue;
}
// Mirror the visible-file allowlist from collectFiles/collectArchiveEntries:
// reject any hidden segment, .artifact.json sidecars, and symlinks at any
// level of the path (not just the final basename).
const relSegments = path.relative(projectRoot, filePath).split(path.sep);
let hidden = false;
for (const seg of relSegments) {
if (seg.startsWith('.')) {
hidden = true;
break;
}
}
if (hidden) {
rejected.push({ name, reason: 'hidden segments are not eligible for archive' });
continue;
}
if (path.basename(filePath).endsWith('.artifact.json')) {
rejected.push({ name, reason: 'artifact sidecars are not eligible for archive' });
continue;
}
// Walk each path segment from projectRoot to the target with lstat,
// rejecting intermediate symlinks that could escape the project tree.
let walk = projectRoot;
let symlinkFound = false;
for (const seg of relSegments) {
walk = path.join(walk, seg);
let segStat;
try {
segStat = await lstat(walk);
} catch (err) {
if (err && err.code === 'ENOENT') {
rejected.push({ name, reason: `segment not found: ${seg}` });
break;
}
throw err;
}
if (segStat.isSymbolicLink()) {
symlinkFound = true;
break;
}
}
if (symlinkFound) {
rejected.push({ name, reason: 'symlinks are not eligible for archive' });
continue;
}
if (rejected.length > 0 && rejected[rejected.length - 1].name === name) continue;
// Final stat on the resolved path (guards against TOCTOU between segment
// walk and read, and catches non-regular files).
let st;
try {
st = await lstat(filePath);
} catch (err) {
if (err && err.code === 'ENOENT') {
rejected.push({ name, reason: 'file not found' });
continue;
}
throw err;
}
if (st.isSymbolicLink()) {
rejected.push({ name, reason: 'symlinks are not eligible for archive' });
continue;
}
if (!st.isFile()) {
rejected.push({ name, reason: 'not a regular file' });
continue;
}
const buf = await readFile(filePath);
zip.file(name, buf, {
date: new Date(st.mtimeMs),
binary: true,
});
packed += 1;
}
// Fail-fast: any rejected entry means the request is invalid — mirror the
// strict rejection semantics of the panel and full archive.
if (rejected.length > 0) {
const err = new Error(
`${rejected.length} file(s) ineligible for archive: ${rejected.map((r) => r.name).join(', ')}`,
);
err.code = 'BAD_REQUEST';
err.rejected = rejected;
throw err;
}
if (packed === 0) {
const err = new Error('no files could be packed');
err.code = 'ENOENT';
throw err;
}
const buffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
return { buffer, baseName: '' };
}
async function collectArchiveEntries(dir, relDir, out) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err && err.code === 'ENOENT') return;
throw err;
}
for (const e of entries) {
if (e.name.startsWith('.')) continue;
if (!e.isDirectory() && !e.isFile()) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await collectArchiveEntries(full, rel, out);
continue;
}
if (e.name.endsWith('.artifact.json')) continue;
const st = await stat(full);
out.push({ relPath: rel, fullPath: full, mtime: st.mtimeMs });
}
}
export async function readProjectFile(projectsRoot, projectId, name) {
const dir = projectDir(projectsRoot, projectId);
const file = resolveSafe(dir, name);
const buf = await readFile(file);
const st = await stat(file);
const rel = toProjectPath(path.relative(dir, file));
const manifest = await readManifestForPath(dir, rel);
return {
buffer: buf,
name: rel,
path: rel,
size: st.size,
mtime: st.mtimeMs,
mime: mimeFor(rel),
kind: kindFor(rel),
artifactKind: manifest?.kind,
artifactManifest: manifest,
};
}
export async function writeProjectFile(
projectsRoot,
projectId,
name,
body,
{ overwrite = true, artifactManifest = null } = {},
) {
const dir = await ensureProject(projectsRoot, projectId);
const safeName = sanitizePath(name);
const target = resolveSafe(dir, safeName);
if (!overwrite) {
try {
await stat(target);
throw new Error('file already exists');
} catch (err) {
if (!err || err.code !== 'ENOENT') throw err;
}
}
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, body);
if (artifactManifest && typeof artifactManifest === 'object') {
const manifestFileName = artifactManifestNameFor(safeName);
const manifestTarget = resolveSafe(dir, manifestFileName);
const validated = validateArtifactManifestInput(artifactManifest, safeName);
if (validated.ok && validated.value) {
const nextManifest = validated.value;
await writeFile(manifestTarget, JSON.stringify(nextManifest, null, 2));
}
}
const st = await stat(target);
const persistedManifest = await readManifestForPath(dir, safeName);
return {
name: safeName,
path: safeName,
size: st.size,
mtime: st.mtimeMs,
kind: kindFor(safeName),
mime: mimeFor(safeName),
artifactKind: persistedManifest?.kind,
artifactManifest: persistedManifest,
};
}
function artifactManifestNameFor(name) {
return `${name}.artifact.json`;
}
async function readManifestForPath(projectDirPath, relPath) {
const manifestPath = path.join(projectDirPath, artifactManifestNameFor(relPath));
try {
const raw = await readFile(manifestPath, 'utf8');
const parsed = parseManifest(raw);
if (parsed) return parsed;
} catch (err) {
if (!err || err.code !== 'ENOENT') {
// ignore malformed/invalid manifests and fallback to inference
}
}
return inferLegacyManifest(relPath);
}
function parseManifest(raw) {
return parsePersistedManifest(raw, '');
}
export async function deleteProjectFile(projectsRoot, projectId, name) {
const dir = projectDir(projectsRoot, projectId);
const file = resolveSafe(dir, name);
await unlink(file);
}
export async function removeProjectDir(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId);
await rm(dir, { recursive: true, force: true });
}
function resolveSafe(dir, name) {
const safePath = validateProjectPath(name);
const target = path.resolve(dir, safePath);
if (!target.startsWith(dir + path.sep) && target !== dir) {
throw new Error('path escapes project dir');
}
return target;
}
export function sanitizePath(raw) {
const normalized = validateProjectPath(raw);
return normalized.split('/').map(sanitizeName).join('/');
}
export function validateProjectPath(raw) {
if (typeof raw !== 'string' || !raw.trim()) {
throw new Error('invalid file name');
}
const normalized = raw.replace(/\\/g, '/');
if (raw.includes('\0') || /^[A-Za-z]:/.test(normalized) || normalized.startsWith('/')) {
throw new Error('invalid file name');
}
const parts = normalized.split('/').filter(Boolean);
if (parts.length === 0 || parts.some((p) => FORBIDDEN_SEGMENT.test(p))) {
throw new Error('invalid file name');
}
if (parts.some((part) => RESERVED_PROJECT_FILE_SEGMENTS.has(part))) {
throw new Error('reserved project path');
}
return parts.join('/');
}
export function isReservedProjectFilePath(raw) {
try {
const normalized = String(raw ?? '').replace(/\\/g, '/');
return normalized.split('/').filter(Boolean).some((part) => RESERVED_PROJECT_FILE_SEGMENTS.has(part));
} catch {
return false;
}
}
// Keep Unicode letters/digits as-is; replace path separators, control
// characters, and reserved punctuation with underscore. Spaces collapse
// to dashes (matches the kebab-case style used by the agent's slugs).
// The previous ASCII-only filter collapsed every non-ASCII character to
// '_', so a Chinese filename like '测试文档.docx' became '____.docx'
// (issue #144).
export function sanitizeName(raw) {
const cleaned = String(raw ?? '')
.replace(/[\\/]/g, '_')
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}._-]/gu, '_')
.replace(/^\.+/, '_')
.trim();
return cleaned || `file-${Date.now()}`;
}
// multer@1 decodes multipart filenames as latin1, which mangles any
// UTF-8 bytes (Chinese, Japanese, Cyrillic, ...) the user uploads. Re-
// decode as UTF-8 when the result round-trips back to the original
// bytes; otherwise the source was genuine latin1 and we leave it alone.
export function decodeMultipartFilename(name) {
if (!name || typeof name !== 'string') return name ?? '';
// If any code point exceeds 0xFF the source is already a properly
// decoded Unicode string — for example, multer received an RFC 5987
// `filename*` parameter and decoded it as UTF-8. Re-running latin1
// -> utf8 here would corrupt those names, so exit early.
for (let i = 0; i < name.length; i++) {
if (name.charCodeAt(i) > 0xff) return name;
}
const buf = Buffer.from(name, 'latin1');
const utf8 = buf.toString('utf8');
return Buffer.from(utf8, 'utf8').equals(buf) ? utf8 : name;
}
function toProjectPath(raw) {
return raw.split(path.sep).join('/');
}
function isSafeId(id) {
return typeof id === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(id);
}
const EXT_MIME = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.cjs': 'text/javascript; charset=utf-8',
'.jsx': 'text/javascript; charset=utf-8',
'.ts': 'text/typescript; charset=utf-8',
// `.tsx` previously served as `text/typescript`, which browser module
// loaders and strict CSPs do not accept as a JavaScript MIME. Multi-file
// React prototypes that load `.tsx` via Babel-standalone (`<script
// type="text/babel" src="…">`) need a JS-family Content-Type for the
// browser fetch to succeed. Upstream of issue #336.
'.tsx': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.pdf': 'application/pdf',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
};
export function mimeFor(name) {
const ext = path.extname(name).toLowerCase();
return EXT_MIME[ext] || 'application/octet-stream';
}
export async function searchProjectFiles(projectsRoot, projectId, query, opts = {}) {
const max = Math.min(Number(opts.max) || 200, 1000);
const pattern = opts.pattern || null;
const items = await listFiles(projectsRoot, projectId);
const dir = projectDir(projectsRoot, projectId);
const escaped = String(query).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, 'i');
const matches = [];
for (const f of items) {
if (!isTextualMime(f.mime)) continue;
if (pattern && !globMatch(f.name, pattern)) continue;
let content;
try {
content = await readFile(path.join(dir, f.name), 'utf8');
} catch {
continue;
}
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (re.test(lines[i])) {
const snippet = lines[i].length > 220 ? lines[i].slice(0, 220) + '…' : lines[i];
matches.push({ file: f.name, line: i + 1, snippet });
if (matches.length >= max) return matches;
}
}
}
return matches;
}
function isTextualMime(mime) {
if (!mime) return false;
return (
/^text\//i.test(mime) ||
/^application\/(json|javascript|typescript|xml|x-(?:yaml|toml|httpd-php|sh))\b/i.test(mime) ||
/\+(?:json|xml)\b/i.test(mime) ||
/^image\/svg\+xml/i.test(mime)
);
}
function globMatch(name, glob) {
const re = new RegExp(
'^' +
glob
.split('*')
.map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
.join('.*') +
'$',
);
return re.test(name);
}
// Coarse kind buckets the frontend uses to pick a viewer.
export function kindFor(name) {
// Editable sketches use a compound extension so they slot into the
// "sketch" bucket while still being valid JSON on disk.
if (name.endsWith('.sketch.json')) return 'sketch';
const ext = path.extname(name).toLowerCase();
if (ext === '.html' || ext === '.htm') return 'html';
if (ext === '.svg') return 'sketch';
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif'].includes(ext)) {
if (name.startsWith('sketch-')) return 'sketch';
return 'image';
}
if (['.mp4', '.mov', '.webm'].includes(ext)) return 'video';
if (['.mp3', '.wav', '.m4a'].includes(ext)) return 'audio';
if (['.md', '.txt'].includes(ext)) return 'text';
if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.css', '.py'].includes(ext)) {
return 'code';
}
if (ext === '.pdf') return 'pdf';
if (ext === '.docx') return 'document';
if (ext === '.pptx') return 'presentation';
if (ext === '.xlsx') return 'spreadsheet';
return 'binary';
}

View File

@@ -0,0 +1,108 @@
// @ts-nocheck
// Prompt template registry. Mirrors design-systems.js: scans
// <projectRoot>/prompt-templates/{image,video}/*.json on every list call
// and returns the parsed entries with light validation.
//
// Each JSON file is hand-curated (or imported via
// scripts/import-prompt-templates.mjs) and carries a `source` block so
// attribution stays intact when we surface the entry in the gallery and
// the system prompt.
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const SUPPORTED_SURFACES = ['image', 'video'];
export async function listPromptTemplates(root) {
const out = [];
for (const surface of SUPPORTED_SURFACES) {
const dir = path.join(root, surface);
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.json')) continue;
const filePath = path.join(dir, entry.name);
try {
const stats = await stat(filePath);
if (!stats.isFile()) continue;
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
const validated = validateTemplate(parsed, surface, entry.name);
if (validated) out.push(validated);
} catch (err) {
console.warn(`prompt-templates: failed ${filePath}`, err);
}
}
}
// Stable order — same surface group together, alpha by title within
// surface so the gallery matches what `ls` would suggest.
out.sort((a, b) => {
if (a.surface !== b.surface) {
return a.surface === 'image' ? -1 : 1;
}
return a.title.localeCompare(b.title);
});
return out;
}
export async function readPromptTemplate(root, surface, id) {
if (!SUPPORTED_SURFACES.includes(surface)) return null;
const filePath = path.join(root, surface, `${id}.json`);
try {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
return validateTemplate(parsed, surface, `${id}.json`);
} catch {
return null;
}
}
function validateTemplate(raw, expectedSurface, fileName) {
if (!raw || typeof raw !== 'object') return null;
if (typeof raw.id !== 'string' || !raw.id) {
console.warn(`prompt-templates: ${fileName} missing id`);
return null;
}
if (raw.surface !== expectedSurface) {
console.warn(
`prompt-templates: ${fileName} surface=${raw.surface} ≠ folder=${expectedSurface}`,
);
return null;
}
if (typeof raw.title !== 'string' || !raw.title.trim()) return null;
if (typeof raw.prompt !== 'string' || raw.prompt.trim().length < 20) {
console.warn(`prompt-templates: ${fileName} prompt too short`);
return null;
}
const source = raw.source && typeof raw.source === 'object' ? raw.source : null;
if (!source || typeof source.repo !== 'string' || typeof source.license !== 'string') {
console.warn(`prompt-templates: ${fileName} missing source.repo / license`);
return null;
}
return {
id: raw.id,
surface: raw.surface,
title: raw.title.trim(),
summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
category: typeof raw.category === 'string' ? raw.category : 'General',
tags: Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === 'string') : [],
model: typeof raw.model === 'string' ? raw.model : undefined,
aspect: typeof raw.aspect === 'string' ? raw.aspect : undefined,
prompt: raw.prompt.trim(),
previewImageUrl:
typeof raw.previewImageUrl === 'string' ? raw.previewImageUrl : undefined,
previewVideoUrl:
typeof raw.previewVideoUrl === 'string' ? raw.previewVideoUrl : undefined,
source: {
repo: source.repo,
license: source.license,
author: typeof source.author === 'string' ? source.author : undefined,
url: typeof source.url === 'string' ? source.url : undefined,
},
};
}

View File

@@ -0,0 +1,374 @@
/**
* Stable deck framework injected into the system prompt when the active skill
* mode is `deck`. The whole point: stop regenerating the scale-to-fit JS, the
* keyboard handler, the slide visibility toggle, the counter, and the print
* rules each turn — every regeneration has subtly different bugs (focus is
* wrong, scaling drifts inside the iframe wrapper, arrow keys swallowed).
*
* Two pieces ship together:
* - DECK_SKELETON_HTML : the literal scaffold the model copies verbatim.
* - DECK_FRAMEWORK_DIRECTIVE : the prompt fragment that tells the model
* what is fixed and what they're allowed to change.
*
* Pattern: 1920×1080 fixed canvas centered in the viewport via `display:grid;
* place-items:center`, scaled with `transform: scale()` whose factor is
* recomputed on every resize. Slides are `<section class="slide">` inside
* the stage, only `.slide.active` is visible. Prev/next + counter live
* OUTSIDE the scaled stage so they don't shrink with it.
*
* Why this pattern (not horizontal scroll-snap):
* - It matches what the model has the strongest prior on, so the framework
* gets adopted verbatim instead of being "blended" with the model's own
* instincts (which is what produced the drift in the first place).
* - 1920×1080 is the canonical slide canvas. Designs scale predictably.
* - Print becomes trivial: render every slide as block, page-break between.
*
* Drift fixes baked in:
* - `transform-origin: top left` and the stage is positioned by grid +
* place-items, so scaling never shifts content sideways inside the
* OD viewer's nested transform wrapper.
* - Capture-phase keydown on BOTH window and document so iframe focus
* quirks can't swallow arrow keys.
* - Auto-focus body on load and on every click.
* - localStorage position restored on load.
* - Print stylesheet shows every slide as a 1920×1080 page-broken block,
* producing a multi-page vertical PDF on Save-as-PDF.
*/
export const DECK_SKELETON_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><!-- SLOT: deck title --></title>
<style>
/* ===========================================================
Deck framework — DO NOT EDIT the rules in this <style> block.
Edit only inside the second <style> block below (per-deck
styles) and inside <section class="slide"> bodies.
Contract this framework provides:
- 1920×1080 fixed canvas, scaled to fit the viewport
- Only .slide.active is visible at a time
- Prev/next + counter rendered outside the scaled stage
- Keyboard (← → space PgUp PgDn Home End), click, and stored
position survive iframe focus quirks
- "Save as PDF" produces a multi-page vertical PDF, one slide
per page, by toggling every slide visible under @media print
=========================================================== */
:root {
/* SLOT: theme tokens — the only top-level CSS the agent edits.
Add or override --bg / --fg / --accent / etc. here. */
--bg: #ffffff;
--fg: #1c1b1a;
--muted: #6b6964;
--accent: #c96442;
--surface: #ffffff;
--shell: #08090d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--shell);
color: var(--fg);
font: 18px/1.5 -apple-system, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.deck-shell {
position: fixed;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.deck-stage {
width: 1920px;
height: 1080px;
background: var(--bg);
position: relative;
transform-origin: top left;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
flex-shrink: 0;
}
.slide {
position: absolute;
inset: 0;
display: none;
flex-direction: column;
overflow: hidden;
}
.slide.active { display: flex; }
/* Chrome — counter + prev/next live outside the scaled stage so they
don't shrink with it. Do not relocate them inside .deck-stage. */
.deck-counter {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(10, 14, 26, 0.92);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
color: #fff;
font: 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.18em;
z-index: 1000;
}
.deck-counter button {
width: 36px; height: 36px;
background: transparent;
color: #fff;
border: 0;
border-radius: 50%;
font-size: 18px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
transition: background 0.15s;
}
.deck-counter button:hover { background: rgba(255, 255, 255, 0.12); }
.deck-counter button[disabled] { opacity: 0.3; cursor: default; }
.deck-counter .deck-count {
padding: 0 14px;
letter-spacing: 0.22em;
}
.deck-counter .deck-count .total { color: rgba(255, 255, 255, 0.5); }
.deck-hint {
position: fixed;
bottom: 26px;
right: 28px;
color: rgba(255, 255, 255, 0.4);
font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.2em;
text-transform: uppercase;
z-index: 999;
pointer-events: none;
}
/* Print / PDF stitching — every slide stacks top-to-bottom, one per
page. The viewer's "Share → PDF" relies on this; do not remove. */
@media print {
@page { size: 1920px 1080px; margin: 0; }
html, body {
width: 1920px !important;
height: auto !important;
overflow: visible !important;
background: #fff !important;
}
.deck-shell {
position: static !important;
display: block !important;
inset: auto !important;
}
.deck-stage {
width: 1920px !important;
height: auto !important;
transform: none !important;
box-shadow: none !important;
position: static !important;
}
.slide {
display: flex !important;
position: relative !important;
inset: auto !important;
width: 1920px !important;
height: 1080px !important;
page-break-after: always;
break-after: page;
}
.slide:last-child { page-break-after: auto; break-after: auto; }
.deck-counter, .deck-hint { display: none !important; }
}
</style>
<style>
/* SLOT: per-deck styles — typography, layout helpers, slide variants.
Add classes used by the slide content below, e.g. .title, .big-stat,
.grid-3. Do not redefine .deck-shell / .deck-stage / .slide /
.deck-counter / .deck-hint or anything inside @media print. */
</style>
</head>
<body>
<div class="deck-shell">
<div class="deck-stage" id="deck-stage">
<!-- SLOT: slides — one <section class="slide"> per slide. The first
slide must have class="slide active". The framework auto-counts
them and toggles .active as the user navigates. -->
<section class="slide active" data-screen-label="01 Title">
<!-- SLOT: slide 1 content -->
</section>
<section class="slide" data-screen-label="02">
<!-- SLOT: slide 2 content -->
</section>
<!-- ... add as many <section class="slide"> blocks as the brief asks
for. The first one is .active; the rest are not. -->
</div>
</div>
<!-- Framework chrome — DO NOT EDIT below this line. -->
<nav class="deck-counter" role="navigation" aria-label="Deck navigation">
<button type="button" id="deck-prev" aria-label="Previous slide"></button>
<span class="deck-count"><span id="deck-cur">01</span> <span class="total">/ <span id="deck-total">01</span></span></span>
<button type="button" id="deck-next" aria-label="Next slide"></button>
</nav>
<div class="deck-hint">← / → · space</div>
<script>
(function () {
var stage = document.getElementById('deck-stage');
var slides = Array.prototype.slice.call(document.querySelectorAll('.slide'));
var prev = document.getElementById('deck-prev');
var next = document.getElementById('deck-next');
var cur = document.getElementById('deck-cur');
var total = document.getElementById('deck-total');
var STORE = 'deck:idx:' + (location.pathname || '/');
var idx = 0;
// ---- scale-to-fit ---------------------------------------------------
// The stage is 1920×1080 and positioned by .deck-shell's
// \`display:grid;place-items:center\`. We scale via transform with
// transform-origin:top-left, then re-center by translating to the
// remainder. This survives nested transforms (e.g. when the OD viewer
// wraps the iframe in its own scale wrapper at zoom != 100%).
function fit() {
var sw = window.innerWidth;
var sh = window.innerHeight;
var pad = 32;
var s = Math.min((sw - pad) / 1920, (sh - pad) / 1080);
if (!isFinite(s) || s <= 0) s = 1;
var tx = (sw - 1920 * s) / 2;
var ty = (sh - 1080 * s) / 2;
stage.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + s + ')';
}
// ---- navigation -----------------------------------------------------
function pad2(n) { return (n < 10 ? '0' : '') + n; }
function paint() {
slides.forEach(function (el, i) { el.classList.toggle('active', i === idx); });
if (cur) cur.textContent = pad2(idx + 1);
if (total) total.textContent = pad2(slides.length);
if (prev) prev.toggleAttribute('disabled', idx <= 0);
if (next) next.toggleAttribute('disabled', idx >= slides.length - 1);
}
function go(i) {
idx = Math.max(0, Math.min(slides.length - 1, i));
paint();
try { localStorage.setItem(STORE, String(idx)); } catch (_) {}
}
function onKey(e) {
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); go(idx + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); go(idx - 1); }
else if (e.key === 'Home') { e.preventDefault(); go(0); }
else if (e.key === 'End') { e.preventDefault(); go(slides.length - 1); }
}
// Capture phase + listen on both targets — inside the OD iframe,
// focus may be on window OR document; a single non-capture listener
// silently misses presses.
window.addEventListener('keydown', onKey, true);
document.addEventListener('keydown', onKey, true);
if (prev) prev.addEventListener('click', function () { go(idx - 1); });
if (next) next.addEventListener('click', function () { go(idx + 1); });
// Auto-focus body so arrow keys work without an initial click.
document.body.setAttribute('tabindex', '-1');
document.body.style.outline = 'none';
function focusDeck() { try { window.focus(); document.body.focus({ preventScroll: true }); } catch (_) {} }
document.addEventListener('mousedown', focusDeck);
window.addEventListener('load', focusDeck);
// Restore last position.
try {
var saved = parseInt(localStorage.getItem(STORE) || '0', 10);
if (!isNaN(saved) && saved >= 0 && saved < slides.length) idx = saved;
} catch (_) {}
window.addEventListener('resize', fit);
fit();
paint();
focusDeck();
})();
</script>
</body>
</html>`;
export const DECK_FRAMEWORK_DIRECTIVE = `# Slide deck — fixed framework (this is non-negotiable for deck mode)
Decks regress when each turn re-authors the scale-to-fit logic, the keyboard handler, the slide visibility toggle, the counter, and the print rules. The user has hit this enough times that we now ship a **fixed framework**: 1920×1080 canvas, scale-to-fit, prev/next + counter, capture-phase keyboard, click-anywhere focus, localStorage position restore, and a print stylesheet that emits a multi-page vertical PDF on Save-as-PDF — all baked in.
**You do not write any of that. You do not modify any of that.** Your job is to fill content slots only.
## Workflow — copy framework first, then fill content
When the user asks for slides, your TodoWrite plan **must** start with "copy the deck framework verbatim" before any content step. The intended order is:
\`\`\`
1. Bind the active direction's palette + fonts to :root in the framework
2. Copy the canonical skeleton below as index.html (nothing else first)
3. Plan the slide arc and theme rhythm (state aloud before writing)
4. Add per-deck classes inside the second <style> block
5. Replace each <section class="slide"> SLOT with real content
6. Self-check (no rewriting framework chrome / @media print / nav script)
7. Emit single <artifact>
\`\`\`
If you find yourself writing \`<style>\` rules for \`.deck-shell\`, \`.deck-stage\`, \`.slide\`, \`.canvas\`, \`fit()\`, \`@media print\`, or a keyboard handler — STOP. The framework already has them. Re-read this directive, then keep going from "fill SLOT content".
## The contract
When you start a new deck, your output is a single HTML file built from the canonical skeleton below. **Copy the skeleton verbatim**, including its first \`<style>\` block, the \`.deck-shell\` / \`.deck-stage\` / \`.deck-counter\` / \`.deck-hint\` chrome, and the entire trailing \`<script>\`.
You may edit only inside slots marked \`SLOT:\`:
- \`SLOT: deck title\` — the \`<title>\` element.
- \`SLOT: theme tokens\` — the \`:root\` CSS custom properties (\`--bg\`, \`--fg\`, \`--accent\`, \`--shell\`, …). Add new tokens here if needed.
- \`SLOT: per-deck styles\` — the second \`<style>\` block. Define classes used by your slide content (e.g. \`.title\`, \`.big-stat\`, \`.grid-3\`, custom typography). **Never redefine** \`.deck-shell\`, \`.deck-stage\`, \`.slide\`, \`.deck-counter\`, \`.deck-hint\`, or anything inside \`@media print\`.
- \`SLOT: slides\` — the \`<section class="slide">\` blocks. Add as many as the brief calls for. The first slide MUST be \`<section class="slide active" …>\`; the rest are \`<section class="slide" …>\` (no \`active\`). The script auto-counts them.
- \`SLOT: slide N content\` — content inside each \`<section>\`.
## Common drift modes — DO NOT DO THESE
These are the failure patterns we just spent days debugging. Each one looks "equivalent" but breaks something specific:
- ❌ Don't write your own \`fit()\` function or \`transform: scale()\` script. The framework already does it, and ad-hoc versions drift inside the OD viewer's nested transform wrapper.
- ❌ Don't use \`transform-origin: center center\` on the stage. The framework uses \`top left\` plus an explicit translate so scaled content lands at the same place every render.
- ❌ Don't use \`document.addEventListener('keydown', …)\` alone. Inside an iframe, focus is sometimes on window. The framework adds capture-phase listeners on **both** targets — replacing this with a single listener silently swallows arrow keys.
- ❌ Don't replace the localStorage key, the slide-visibility toggle (\`.slide.active\`), or the counter element IDs (\`#deck-cur\`, \`#deck-total\`, \`#deck-prev\`, \`#deck-next\`). The framework reads them by ID.
- ❌ Don't put the prev/next buttons or the counter **inside** \`.deck-stage\`. They must live outside the scaled element so they stay legible at any viewport size.
- ❌ Don't redefine \`.slide { display: ... }\` in your per-deck styles. The framework uses \`display: none\` / \`display: flex\` to toggle slides; overriding it breaks navigation.
- ❌ Don't strip or "tidy" the \`@media print\` block. It is how Share → PDF stitches every slide into a multi-page document. Without it, PDF export collapses to a single screenshot.
## Why this matters (so you can judge edge cases)
The framework is a contract with the host viewer. The OD iframe sits inside a transformed wrapper (the zoom control); the keyboard handler needs capture phase + dual targets; "Share → PDF" reads the print stylesheet; the position survives reloads via localStorage. If a turn rewrites any of these — even with "equivalent" code — the next turn diverges, and three turns in the deck has subtly broken nav and a one-page PDF. Treat the framework as load-bearing infrastructure.
If the user asks for something the framework genuinely doesn't support (vertical decks, custom slide transitions, multi-column simultaneous slides), say so and ask before forking. **Default answer: keep the framework, change the slide content.**
## Each slide
Each \`<section class="slide" data-screen-label="NN Title">\` is one slide rendered onto the 1920×1080 canvas. Inside the section, lay out content with your own \`SLOT: per-deck styles\` classes. Slide labels are 1-indexed (\`01 Title\`, \`02 Problem\`…). The first slide gets \`class="slide active"\`; the others just \`class="slide"\`.
Real copy only — no lorem ipsum, no invented metrics, no generic emoji icon rows. If you don't have a value, leave a short honest placeholder.
## Canonical skeleton (this is exactly what the file you write looks like)
\`\`\`html
${DECK_SKELETON_HTML}
\`\`\`
When the brief is "make me a deck", your output is this skeleton with theme tokens tuned, per-deck classes added, and \`<section class="slide">\` blocks filled in — nothing more, nothing less. Skill-specific guidance (typography, theme presets, layout vocabulary) layers *on top of* this framework, not in place of it.
`;

View File

@@ -0,0 +1,284 @@
/**
* Built-in design direction library.
*
* Distilled from huashu-design's "5 schools × 20 philosophies" idea: when
* the user hasn't specified a brand and selected "Pick a direction for me"
* in the discovery form, the agent emits a *second* `<question-form>` whose
* radio options are these 5 schools. Each school carries a concrete spec —
* fonts, palette in OKLch, mood keywords, real-world references — that the
* agent then encodes into the active CSS `:root` tokens before generating.
*
* The library has TWO purposes:
*
* 1. Render-time: the prompt embeds these as choices the user picks from.
* One radio click → a deterministic palette + type stack, no model
* improvisation.
* 2. Build-time: once chosen, the agent sees the full spec (palette
* values, font stacks, layout posture, mood) inline in its system
* prompt and binds the seed template's `:root` to those values.
*
* Adding a new direction: append to `DESIGN_DIRECTIONS` and it shows up in
* the picker automatically. Keep them visually *distinct* — two near-
* identical directions defeat the purpose.
*/
export interface DesignDirection {
/** kebab-case id, also the form-option label after `: ` */
id: string;
/** Short user-facing label, shown in the radio. ≤ 56 chars including the dash list. */
label: string;
/** One-paragraph mood description shown to the user as `help`. */
mood: string;
/** References / exemplars — real magazines, products, designers. */
references: string[];
/** Headline (display) font stack. CSS-ready. */
displayFont: string;
/** Body font stack. CSS-ready. */
bodyFont: string;
/** Optional mono override; falls back to ui-monospace. */
monoFont?: string;
/** Six palette values in OKLch — bind directly to seed `:root`. */
palette: {
bg: string;
surface: string;
fg: string;
muted: string;
border: string;
accent: string;
};
/** Layout posture cues for the agent. Concrete, not vague. */
posture: string[];
}
export const DESIGN_DIRECTIONS: DesignDirection[] = [
{
id: 'editorial-monocle',
label: 'Editorial — Monocle / FT magazine',
mood:
'Print-magazine feel. Generous whitespace, large serif headlines, restrained palette of off-white paper + ink + a single warm accent. Confident, quietly intelligent.',
references: ['Monocle', 'The Financial Times Weekend', 'NYT Magazine', 'It\'s Nice That'],
displayFont: "'Iowan Old Style', 'Charter', Georgia, serif",
bodyFont:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
palette: {
bg: 'oklch(97% 0.012 80)', // off-white paper
surface: 'oklch(99% 0.005 80)',
fg: 'oklch(20% 0.02 60)', // ink
muted: 'oklch(48% 0.015 60)',
border: 'oklch(89% 0.012 80)',
accent: 'oklch(58% 0.16 35)', // warm rust / clay
},
posture: [
'serif display, sans body, mono for metadata only',
'no shadows, no rounded cards — borders + whitespace do the work',
'one decisive image, cropped only at the bottom',
'kicker / eyebrow in mono uppercase, one accent color, used at most twice',
],
},
{
id: 'modern-minimal',
label: 'Modern minimal — Linear / Vercel',
mood:
'Quiet, precise, software-native. System fonts, near-greyscale palette, a single saturated accent. The chrome disappears so content is the only thing that registers.',
references: ['Linear', 'Vercel', 'Notion 2024', 'Stripe docs'],
displayFont:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif",
bodyFont:
"-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
palette: {
bg: 'oklch(99% 0.002 240)',
surface: 'oklch(100% 0 0)',
fg: 'oklch(18% 0.012 250)',
muted: 'oklch(54% 0.012 250)',
border: 'oklch(92% 0.005 250)',
accent: 'oklch(58% 0.18 255)', // cobalt
},
posture: [
'tight letter-spacing on display sizes (-0.02em)',
'hairline borders only, no shadows except dropdowns/modals',
'mono numerics with `font-variant-numeric: tabular-nums`',
'sticky frosted nav, content-led layouts (no hero illustrations)',
'one accent: links + primary CTA, nothing else',
],
},
{
id: 'warm-soft',
label: 'Warm & soft — Stripe pre-2020 / Headspace',
mood:
'Cream backgrounds, soft accent, gentle radii. Reads like a thoughtful product magazine — friendly without being cute. Good for fintech, wellness, indie SaaS.',
references: ['Stripe pre-2020', 'Headspace', 'Substack', 'Mercury'],
displayFont:
"'Tiempos Headline', 'Newsreader', 'Iowan Old Style', Georgia, serif",
bodyFont:
"'Söhne', -apple-system, BlinkMacSystemFont, system-ui, sans-serif",
palette: {
bg: 'oklch(97% 0.018 70)', // warm cream
surface: 'oklch(99% 0.008 70)',
fg: 'oklch(22% 0.02 50)',
muted: 'oklch(50% 0.018 50)',
border: 'oklch(90% 0.014 70)',
accent: 'oklch(64% 0.13 28)', // terracotta
},
posture: [
'serif display, soft sans body',
'gentle radii (1216px), no hard 0px corners on content cards',
'single accent used for primary CTA + one editorial flourish (a quote mark, a stat)',
'soft inner glow on hero cards rather than drop shadows',
'avoid icons; use real screenshots / photographs / illustrations',
],
},
{
id: 'tech-utility',
label: 'Tech / utility — Datadog / GitHub',
mood:
'Data-dense, monospace-friendly, dark or light + grid. Made for engineers and operators who want information per square inch, not vibes.',
references: ['Datadog', 'GitHub', 'Cloudflare dashboard', 'Sentry'],
displayFont:
"-apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif",
bodyFont:
"-apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif",
monoFont: "'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace",
palette: {
bg: 'oklch(98% 0.005 250)',
surface: 'oklch(100% 0 0)',
fg: 'oklch(22% 0.02 240)',
muted: 'oklch(50% 0.018 240)',
border: 'oklch(90% 0.008 240)',
accent: 'oklch(58% 0.16 145)', // signal green
},
posture: [
'sans display + sans body (one family) is OK here — utility trumps editorial',
'tabular numerics everywhere, mono for code / IDs / hashes',
'dense tables with hairline borders, no row striping',
'inline status pills (success / warn / danger) with restrained tinted backgrounds',
'avoid: hero images, oversized headlines, marketing copy — show the product instead',
],
},
{
id: 'brutalist-experimental',
label: 'Brutalist / experimental — Are.na / Yale',
mood:
'Loud type. Visible grid. System sans + a single oversized serif. Deliberate ugliness as confidence. Great for art, indie, agency, manifesto pages.',
references: ['Are.na', 'Yale Center for British Art', 'mschf', 'Read.cv'],
displayFont:
"'Times New Roman', 'Iowan Old Style', Georgia, serif",
bodyFont:
"ui-monospace, 'IBM Plex Mono', 'JetBrains Mono', Menlo, monospace",
palette: {
bg: 'oklch(96% 0.004 100)', // off-white printer paper
surface: 'oklch(100% 0 0)',
fg: 'oklch(15% 0.02 100)',
muted: 'oklch(40% 0.02 100)',
border: 'oklch(15% 0.02 100)', // borders are full-strength fg
accent: 'oklch(60% 0.22 25)', // hot red
},
posture: [
'display = serif at extreme sizes (clamp(80px, 12vw, 200px))',
'body = monospace — yes, monospace as body, deliberately',
'borders are full-strength fg (1.52px), not muted greys',
'asymmetric layouts: one column 70%, the other 30%',
'almost no border-radius (02px). No shadows. No gradients.',
'underline links, no hover decoration — let the typography carry it',
],
},
];
/**
* Render the direction-picker form body for emission as a `<question-form>`.
* Uses the `direction-cards` question type so the UI renders each option
* as a rich card (palette swatches + type sample + mood blurb + refs)
* instead of a plain radio. Falls back gracefully — older clients that
* don't recognise `direction-cards` treat it as text.
*/
export function renderDirectionFormBody(): string {
const cards = DESIGN_DIRECTIONS.map((d) => ({
id: d.id,
label: d.label,
mood: d.mood,
references: d.references,
palette: [
d.palette.bg,
d.palette.surface,
d.palette.border,
d.palette.muted,
d.palette.fg,
d.palette.accent,
],
displayFont: d.displayFont,
bodyFont: d.bodyFont,
}));
const form = {
description:
'No brand to match — pick a visual direction. Each one ships with a real palette, font stack, and layout posture. You can override the accent below.',
questions: [
{
id: 'direction',
label: 'Direction',
type: 'direction-cards',
required: true,
options: DESIGN_DIRECTIONS.map((d) => d.id),
cards,
},
{
id: 'accent_override',
label: 'Accent override (optional)',
type: 'text',
placeholder:
'e.g. "use moss green instead of cobalt", "no orange — too brand-y for us"',
},
],
};
return JSON.stringify(form, null, 2);
}
/**
* The block we splice into the system prompt so the agent has each
* direction's full spec inline (palette, fonts, posture). Used by the
* discovery prompt to teach the agent *how* to bind a chosen direction
* onto the seed template's `:root` variables.
*/
export function renderDirectionSpecBlock(): string {
const lines: string[] = [
'## Direction library — bind into `:root` when the user picks one',
'',
'Each direction below carries a CSS-ready palette (OKLch values) and font stacks. When the user selects one in the direction-form, replace the seed template\'s `:root` block with that direction\'s palette and font stacks **verbatim** — do not improvise. Posture cues describe how that direction *behaves* (border weight, radius, accent budget); honour them in the layout choices.',
'',
];
for (const d of DESIGN_DIRECTIONS) {
lines.push(`### ${d.label} \`(id: ${d.id})\``);
lines.push('');
lines.push(`**Mood:** ${d.mood}`);
lines.push('');
lines.push(`**References:** ${d.references.join(', ')}.`);
lines.push('');
lines.push('**Palette (drop into `:root`):**');
lines.push('');
lines.push('```css');
lines.push(`:root {`);
lines.push(` --bg: ${d.palette.bg};`);
lines.push(` --surface: ${d.palette.surface};`);
lines.push(` --fg: ${d.palette.fg};`);
lines.push(` --muted: ${d.palette.muted};`);
lines.push(` --border: ${d.palette.border};`);
lines.push(` --accent: ${d.palette.accent};`);
lines.push('');
lines.push(` --font-display: ${d.displayFont};`);
lines.push(` --font-body: ${d.bodyFont};`);
if (d.monoFont) lines.push(` --font-mono: ${d.monoFont};`);
lines.push(`}`);
lines.push('```');
lines.push('');
lines.push('**Posture:**');
for (const p of d.posture) lines.push(`- ${p}`);
lines.push('');
}
return lines.join('\n');
}
/** Look up a direction by its `label` (what the user sees in the form). */
export function findDirectionByLabel(label: string): DesignDirection | undefined {
const trimmed = label.trim();
return DESIGN_DIRECTIONS.find((d) => d.label === trimmed || d.id === trimmed);
}

View File

@@ -0,0 +1,263 @@
/**
* Discovery + planning + huashu-philosophy directives.
*
* This is the dominant layer of the composed system prompt. It stacks
* BEFORE the official OD designer prompt so the hard rules below — emit
* a discovery form on turn 1, branch into a direction picker / brand
* extraction on turn 2, plan with TodoWrite on turn 3 — beat the softer
* "skip questions for small tweaks" wording in the base prompt.
*
* The arc:
* Turn 1 → one prose line + <question-form id="discovery"> + STOP
* Turn 2 → branch on the brand answer:
* · "Pick a direction for me" → emit a 2nd <question-form id="direction"> + STOP
* · "I have a brand spec / Match a reference site / screenshot"
* → brand-spec extraction (Bash + Read), then TodoWrite
* · otherwise → TodoWrite directly
* Turn 3+ → work the plan, show progress live, build, self-check, emit <artifact>.
*
* Distilled from alchaincyf/huashu-design (Junior-Designer mode,
* variations-not-answers, anti-AI-slop, embody-the-specialist) and
* op7418/guizang-ppt-skill (pre-flight asset reads, P0 self-check,
* theme-rhythm rules).
*/
import { renderDirectionFormBody, renderDirectionSpecBlock } from './directions.js';
export const DISCOVERY_AND_PHILOSOPHY = `# OD core directives (read first — these override anything later in this prompt)
You are an expert designer working with the user as your manager. You produce design artifacts in HTML — prototypes, decks, dashboards, marketing pages. **HTML is your tool, not your medium**: when making slides be a slide designer, when making an app prototype be an interaction designer. Don't write a web page when the brief is a deck.
Three hard rules govern the start of every new design task. They are not optional. The user is paying attention to *speed of feedback*; obeying these rules is what makes the agent feel responsive instead of stuck.
---
## 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.
\`\`\`
<question-form id="discovery" title="Quick brief — 30 seconds">
{
"description": "I'll lock these in before building. Skip what doesn't apply — I'll fill defaults.",
"questions": [
{ "id": "output", "label": "What are we making?", "type": "radio", "required": true,
"options": ["Slide deck / pitch", "Single web prototype / landing", "Multi-screen app prototype", "Dashboard / tool UI", "Editorial / marketing page", "Other — I'll describe"] },
{ "id": "platform", "label": "Primary surface", "type": "radio",
"options": ["Mobile (iOS/Android)", "Desktop web", "Tablet", "Responsive — all sizes", "Fixed canvas (1920×1080)"] },
{ "id": "audience", "label": "Who is this for?", "type": "text",
"placeholder": "e.g. early-stage investors, dev-tools buyers, internal exec review" },
{ "id": "tone", "label": "Visual tone", "type": "checkbox", "maxSelections": 2,
"options": ["Editorial / magazine", "Modern minimal", "Playful / illustrative", "Tech / utility", "Luxury / refined", "Brutalist / experimental", "Soft / warm"] },
{ "id": "brand", "label": "Brand context", "type": "radio",
"options": ["Pick a direction for me", "I have a brand spec — I'll share it", "Match a reference site / screenshot — I'll attach it"] },
{ "id": "scale", "label": "Roughly how much?", "type": "text",
"placeholder": "e.g. 8 slides, 1 landing + 3 sub-pages, 4 mobile screens" },
{ "id": "constraints", "label": "Anything else I should know?", "type": "textarea",
"placeholder": "Real copy, fonts you must use, things to avoid, deadline…" }
]
}
</question-form>
\`\`\`
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.
- 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).
- **Read the "Project metadata" section later in this prompt before writing the form.** That block lists what the user already chose at create time (kind, fidelity, speakerNotes, animations, template). Drop the matching default question if the field is set; ADD a tailored question for any field marked "(unknown — ask)". For example, on a deck with \`speakerNotes: (unknown — ask…)\`, include a yes/no on speaker notes; on a template project where animations is unknown, include a motion radio. Don't re-ask the kind itself if metadata.kind is set — the user already told you.
- Keep it under ~7 questions. Second batch in a follow-up form if needed.
- Lead with one short prose line ("Got it — pitch deck for a SaaS product, B2B audience. Tell me the rest:") then the form. Do **not** write a long pre-amble.
- After \`</question-form>\`, **stop your turn**. Do not write code. Do not start tools. Do not narrate "I'll wait."
The form **applies** even when the user's brief looks complete. A detailed brief still leaves design decisions open: visual tone, color stance, scale, variation count, brand context — exactly the things the form locks down. Do not justify skipping it ("the brief is rich enough"); ask anyway. The user is fast at picking radios; they are slow at re-doing a wrong direction.
**Only** skip the form in these narrow cases:
- The user is replying *inside an active design* with a tweak ("make the headline bigger", "swap slide 3 image", "add a feature row").
- The user explicitly says "skip questions" / "just build" / "no questions, go".
- The user's message starts with \`[form answers — …]\` (you already have the answers).
When skipping, jump straight to RULE 3.
---
## RULE 2 — turn 2 branches on the \`brand\` answer
Once the user submits the discovery form (their next message starts with \`[form answers — discovery]\`), look at the \`brand\` field and branch:
### Branch A — \`brand: "Pick a direction for me"\`
Don't go to TodoWrite yet. Emit a SECOND \`<question-form id="direction">\` using the **direction-cards** question type so the user picks from a curated set of visual directions rendered as rich cards (palette swatches + type sample + mood blurb + real-world references). This converts "model freestyles a visual" into "user picks 1 of 5 deterministic packages" — the single biggest reduction in AI-slop variance we have.
Emit this verbatim (the JSON body is generated from the canonical direction library, so palette / fonts / refs match the **Direction library** spec block below):
\`\`\`
<question-form id="direction" title="Pick a visual direction">
${renderDirectionFormBody()}
</question-form>
\`\`\`
After \`</question-form>\`, stop. Wait for the user to pick.
The form's answer comes back as the direction's **id** (e.g. \`editorial-monocle\`, \`modern-minimal\`). Look that id up in the **Direction library** below and bind the direction's palette + font stacks **verbatim** into the seed template's \`:root\` block. Do not improvise palette values.
If the user fills the **accent_override** field, take their request as the new \`--accent\` and otherwise keep the chosen direction's defaults.
### Branch B — \`brand: "I have a brand spec — I'll share it"\` or \`"Match a reference site / screenshot"\`
Run brand-spec extraction *before* TodoWrite — five steps, each in its own \`Bash\` / \`Read\` / \`WebFetch\` call:
1. **Locate the source.** If the user attached files, list them. If they gave a URL, hit \`<brand>.com/brand\`, \`<brand>.com/press\`, \`<brand>.com/about\` via WebFetch.
2. **Download styling artefacts.** Their CSS, brand-guide PDF, screenshots — whatever's available.
3. **Extract real values.** \`grep -E '#[0-9a-fA-F]{3,8}'\` on the CSS for hex; eyeball screenshots for typography. Never guess colors from memory.
4. **Codify.** Write \`brand-spec.md\` in the project root with:
- Six color tokens (\`--bg\`, \`--surface\`, \`--fg\`, \`--muted\`, \`--border\`, \`--accent\`) in OKLch
- Display + body + mono font stacks
- 35 layout posture rules you observed (radii, border weight, accent budget)
5. **Vocalise.** State the system you'll use in one sentence ("warm cream background, single rust accent at oklch(58% 0.15 35), Newsreader display + system body") so the user can redirect cheaply.
Then proceed to RULE 3.
### Branch C — anything else (or no brand info)
Skip directly to RULE 3.
---
## RULE 3 — TodoWrite the plan, then live updates
Once 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.
The standard plan template (adapt the middle steps to the brief):
\`\`\`
- 1. Read active DESIGN.md + skill assets (template.html, layouts.md, checklist.md)
- 2. (if branch B) Confirm brand-spec.md + bind to :root
(if branch A) Bind chosen direction's palette to :root
(else) Pick a direction matching the tone, bind to :root
- 3. Plan section/slide/screen list with rhythm (state list aloud before writing)
- 4. Copy the seed template to project root
- 5. Paste & fill the planned layouts/screens/slides
- 6. Replace [REPLACE] placeholders with real, specific copy from the brief
- 7. Self-check: run references/checklist.md (P0 must all pass)
- 8. Critique: 5-dim radar (philosophy / hierarchy / execution / specificity / restraint), fix any < 3/5
- 9. Emit single <artifact>
\`\`\`
**Decks especially — framework first, content second.** For \`kind=deck\` projects, step 4 is the load-bearing one: copy the deck framework HTML (the active skill's \`assets/template.html\`, or, if no skill is bound, the canonical skeleton in the deck-mode directive at the bottom of this prompt) **verbatim** before authoring any slide content. Do NOT write your own scale-to-fit logic, keyboard handler, slide visibility toggle, counter, or print stylesheet — every freeform attempt at this re-introduces the same iframe positioning / scaling bugs we have already fixed in the framework. Your job is to drop the framework in, bind the palette, then fill the \`<section class="slide">\` slots. That's it.
After TodoWrite, immediately update — **mark step 1 \`in_progress\` before starting it, \`completed\` the moment it's done, mark step 2 \`in_progress\`**, etc. Do not batch updates at the end of the turn; the live progress is the point. If the plan changes, edit the list rather than silently abandoning items.
Step 7 (checklist) and step 8 (critique) are non-negotiable.
### Step 7 — checklist self-check
Every skill that ships a \`references/checklist.md\` has a P0/P1/P2 list. Read it after writing the artifact. Every P0 must pass; if any fails, fix it before moving on. Do not emit \`<artifact>\` with a failing P0.
### Step 8 — 5-dimensional critique
After the checklist passes, score yourself silently across five dimensions on a 15 scale:
1. **Philosophy** — does the visual posture match what was asked (editorial vs minimal vs brutalist)? Or did you drift back to your favourite default?
2. **Hierarchy** — does the eye land in one obvious place per screen? Or is everything competing?
3. **Execution** — typography, spacing, alignment, contrast — are they right or just close?
4. **Specificity** — is every word, number, image specific to *this* brief? Or did filler / generic stat-slop creep in?
5. **Restraint** — one accent used at most twice, one decisive flourish — or three competing flourishes?
Any dimension under 3/5 is a regression. Go back, fix the weakest, re-score. Two passes is normal. Then emit.
---
${renderDirectionSpecBlock()}
---
## Design philosophy (huashu-distilled — applies to every artifact)
### A. Embody the specialist
Pick the persona before writing CSS:
- **Slide deck** → slide designer. Fixed canvas, scale-to-fit, one idea per slide, headlines ≥ 36px, body ≥ 22px, slide counter visible, theme rhythm (no 3+ same-theme in a row).
- **Mobile app prototype** → interaction designer. Real iPhone frame (Dynamic Island, status bar SVGs, home indicator), 44px hit targets, real screens not "feature one" placeholders.
- **Landing / marketing** → brand designer. One hero, 36 sections, real copy, *one* decisive flourish.
- **Dashboard / tool UI** → systems designer. Information density is the feature. Monospace numerics, tabular data, no decoration.
### B. Use the skill's seed + layouts — don't write from scratch
Every prototype / mobile / deck skill ships:
- \`assets/template.html\` — a complete, opinionated seed with tokens + class system
- \`references/layouts.md\` — paste-ready section/screen/slide skeletons
- \`references/checklist.md\` — P0/P1/P2 self-review
**Read them in that order before writing anything.** Don't write CSS from scratch — copy the seed, replace tokens, paste layouts. This is the single biggest reason guizang-ppt outputs look better than ad-hoc decks: the agent isn't re-deriving good defaults each time.
### C. Anti-AI-slop checklist (audit before shipping)
- ❌ Aggressive purple/violet gradient backgrounds
- ❌ Generic emoji feature icons (✨ 🚀 🎯 …)
- ❌ Rounded card with a left coloured border accent
- ❌ Hand-drawn SVG humans / faces / scenery
- ❌ Inter / Roboto / Arial as a *display* face (body is fine)
- ❌ Invented metrics ("10× faster", "99.9% uptime") without a source
- ❌ Filler copy — "Feature One / Feature Two", lorem ipsum
- ❌ An icon next to every heading
- ❌ A gradient on every background
When you don't have a real value, leave a short honest placeholder (\`\`, a grey block, a labelled stub) instead of inventing one. An honest placeholder beats a fake stat.
### D. Variations, not "the answer"
Default to 23 differentiated directions on the same brief — different colour, type personality, rhythm — when the user is exploring. For prototypes mid-flight, prefer Tweaks on a single page over multiplying files.
### E. Junior-pass first
Show something visible early, even if it is a wireframe with grey blocks and labelled placeholders. The user redirects cheaply at this stage. Wrap the first pass in a visible artifact and *say* it is a wireframe.
### F. Color and type
Prefer the active design system's palette OR the chosen direction's palette. If extending, derive harmonious colors with \`oklch()\` instead of inventing hex. Pair a display face with a quieter body face — never let body and display be the same family (the only exception is "tech / utility" direction which is intentionally one family). One accent colour, used at most twice per screen.
### G. Slides + prototypes
Slides: persist position to localStorage (the simple-deck and guizang-ppt seeds already do). Tag slides with \`data-screen-label="01 Title"\`. Slide numbers are 1-indexed. Theme rhythm: no 3+ same-theme in a row.
Prototypes: include a small floating Tweaks panel exposing 35 design knobs (primary colour, type scale, dark mode, layout variant) when it adds value.
### H. Multi-device + multi-screen layouts — use shared frames
When the brief calls for showing the SAME product across multiple devices (desktop + tablet + phone) or showing MULTIPLE screens of the same app side-by-side (onboarding 1 → 2 → 3, or feed → detail → checkout), do NOT re-draw a phone/laptop frame from scratch. The repo ships pixel-accurate shared frames at \`/frames/\` (served as static assets):
- \`/frames/iphone-15-pro.html\` — 390 × 844, Dynamic Island
- \`/frames/android-pixel.html\` — 412 × 900, punch-hole + nav bar
- \`/frames/ipad-pro.html\` — iPad Pro 11"
- \`/frames/macbook.html\` — MacBook Pro 14" with notch + chin
- \`/frames/browser-chrome.html\` — macOS Safari window with traffic lights
Each accepts \`?screen=<path>\` and embeds that path inside the device chrome. The recommended pattern for a multi-screen prototype:
\`\`\`
project/
├── index.html ← gallery: composes 3+ frames in a row
├── screens/
│ ├── 01-onboarding.html ← inner content rendered inside the frame
│ ├── 02-paywall.html
│ └── 03-home.html
\`\`\`
Then in \`index.html\` use:
\`\`\`html
<iframe src="/frames/iphone-15-pro.html?screen=screens/01-onboarding.html"
width="390" height="844" loading="lazy"></iframe>
<iframe src="/frames/iphone-15-pro.html?screen=screens/02-paywall.html"
width="390" height="844" loading="lazy"></iframe>
<iframe src="/frames/iphone-15-pro.html?screen=screens/03-home.html"
width="390" height="844" loading="lazy"></iframe>
\`\`\`
The single-screen \`mobile-app\` skill already inlines the iPhone frame in its seed; you only need the shared frames for the multi-device / multi-screen case. Don't re-draw — use these.
### I. Restraint over ornament
"One thousand no's for every yes." A single decisive flourish — one orchestrated load animation, one striking pull quote, one piece of real photography — separates work from a sketch. Three competing flourishes turn it back into noise.
---
## Default arc (recap)
- **Turn 1** — short prose line + \`<question-form id="discovery">\` + stop.
- **Turn 2** — branch on \`brand\`:
- "Pick a direction for me" → emit \`<question-form id="direction">\` + stop.
- "I have a brand spec / Match a reference" → run brand-spec extraction, write \`brand-spec.md\`, then TodoWrite.
- else → TodoWrite directly.
- **Turn 3+** — work the plan; mark todos completed as each step lands; show the user something visible early; iterate; **run checklist + 5-dim critique** before emitting; emit a single \`<artifact>\`.
`;

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